@@ -9,11 +9,12 @@ import { signOut } from 'next-auth/react' import styles from '#/app/layout/styles/styles.module.css' import { useAppSelector } from '#/app/store/store' -import { TooltipCustom } from '#/shared' import { SvgIcon } from '#/shared/ui/svg' 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 { SubscriptionDaysBadge } from '#/shared/ui/subscription-days-badge/subscription-days-badge' interface InfoBarProps extends IProps {} @@ -39,6 +40,8 @@ const convertLang = (toShort: T, lang: LangParam): LangRet } const InfoBar: React.FC = ({ device }) => { + const nextPaymentAt = useAppSelector((state) => state.user.payment_plan.next_payment_at) + const daysLeft = getDaysLeft(nextPaymentAt) const balance = useAppSelector((state) => state.balance.balance) const { email, first_name, last_name, profile_picture_link, account_type, show_balance } = useAppSelector((state) => state.user) @@ -80,6 +83,7 @@ const InfoBar: React.FC = ({ device }) => { marginRight: '80px', }} > + {daysLeft != null && daysLeft <= 3 ? : null} { + const modal = getModalById(LOW_BALANCE_OFFER) + + useEffect(() => { + modal.setState(true) + }, []) + + return +} @@ -0,0 +1,47 @@ +.container { + width: 100%; + max-width: 570px; + padding: 32px 0 0 0; + display: flex; + flex-direction: column; + background-color: #151518; + align-items: flex-start; +} + +.text { + color: #8B8B8B; + font-size: 17px; + line-height: 1.5; + margin: 16px 0 24px; +} + +.text_heading { + font-weight: 800; + font-size: 48px; + line-height: 42px; +} + +.offersWrapper { + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + gap: 20px; + width: 100%; + margin-bottom: 24px; + border-radius: 30px; + box-shadow: + 0 0 20px rgba(100, 180, 255, 0.12), + 0 0 40px rgba(80, 150, 255, 0.08), +} + +.link { + font-weight: 600; + font-style: SemiBold; + font-size: 16px; + align-self: center; + + p { + color:#80808E; + } +} @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react' +import Link from 'next/link' +import { useSession } from 'next-auth/react' + +import { LOW_BALANCE_OFFER, getModalById, PlateTemplate } from '#/features/modals' +import { c } from '#/shared' +import { accountApi } from '#/shared/api/account-endpoints' +import { getDeviceType } from '#/shared/lib/helpers' +import { IOffer } from '#/views/subscription' +import Offer from '#/views/subscription/ui/offer' + +import styles from './low-balance-offer-plate.module.scss' + +export const LowBalanceOfferPlate = () => { + const modal = getModalById(LOW_BALANCE_OFFER) + const { data } = useSession() + const [offers, setOffers] = useState(null) + + useEffect(() => { + if (data?.access) { + accountApi.getPaymentsPlans(data.access).then((res) => { + if (res) setOffers(res) + }) + } + }, [data?.access]) + + const handleClose = () => { + modal.setState(false) + } + + const offerIndex = modal.getStoreProperty('offerIndex') ?? 1 + const offerUid = modal.getStoreProperty('offerUid'); + const notEnoughtTokens = modal.getStoreProperty('notEnoughtTokens') + + const offerToShow = offerUid + ? offers?.find((o) => o.uid === offerUid) + : offerIndex + ? offers?.[offerIndex] + : offers?.[1] + + return ( + +
e.stopPropagation()}> + {notEnoughtTokens ? ( +

У вас не осталось
токенов...

+ ) : ( +

Осталось
мало токенов...

+ )} +

+ Подобрали для Вас наиболее подходящий тариф,
+ взгляните на преимущества! +

+
+ {offers === null ? ( +

Загрузка тарифов...

+ ) : offerToShow ? ( + + ) : null} +
+ +

Нет, я хочу посмотреть все тарифы

+ +
+
+ ) +} @@ -0,0 +1,80 @@ +import { useEffect, useRef } from 'react' +import axios from 'axios' + +import { useAppSelector } from '#/app/store/store' +import { getModalById, LOW_BALANCE_OFFER } from '#/features/modals' +import { LowBalanceOfferPlate } from './low-balance-offer-plate' +import type { ResponseAllInfo } from '#/entities/user-account/model/user-type-slice' +import { useFeatureFlag } from '#/shared/lib/hooks/use-feature-flag' +import { balanceSelector } from '#/shared/lib/selectors' + + +export const LowBalanceOfferTrigger = () => { + const balance = useAppSelector(balanceSelector) + const me = useAppSelector((state) => state.user) as ResponseAllInfo & { status: string; referral: string } + const isFewTokensModalEnabled = useFeatureFlag('few-tokens-modal-enabled') + const prevBalanceRef = useRef(null) + const modal = getModalById(LOW_BALANCE_OFFER) + + useEffect(() => { + const interceptorId = axios.interceptors.response.use( + (response) => { + if (response.status === 402 && response?.data?.detail?.includes('Баланс')) { + invokeLowBalanceOffer(true) + } + return response + }, + ) + + return () => { + axios.interceptors.response.eject(interceptorId) + } + }, []) + + useEffect(() => { + + if (!isFewTokensModalEnabled) return + + // Пропускаем первый рендер (начальная загрузка баланса) + if (prevBalanceRef.current === null) { + prevBalanceRef.current = balance + return + } + + if (prevBalanceRef.current !== balance) { + prevBalanceRef.current = balance + + if (typeof window !== 'undefined' && sessionStorage.getItem('low-balance-offer-shown') === 'true') { + return + } + + invokeLowBalanceOffer(); + } + }, [balance, me, modal, isFewTokensModalEnabled]) + + const invokeLowBalanceOffer = (notEnoughtTokens: boolean = false) => { + const tokensPerPlan = Number(me.payment_plan?.plan?.tokens_per_plan) + const isCorporate = me.payment_plan?.plan?.is_corporate + const isFreePlan = tokensPerPlan <= 10 + const threshold = tokensPerPlan * 0.15 + const freePlanThreshold = tokensPerPlan * 0.3 + + if (isCorporate) { + return + } + + if (!isFreePlan && balance < threshold) { + modal.setState(true, { offerUid: me.payment_plan?.plan?.uid, me, notEnoughtTokens }) + sessionStorage.setItem('low-balance-offer-shown', 'true') + return + } + + if (isFreePlan && balance < freePlanThreshold) { + modal.setState(true, { offerIndex: 1, notEnoughtTokens }) + sessionStorage.setItem('low-balance-offer-shown', 'true') + return + } + } + + return +} @@ -0,0 +1,3 @@ +export { LowBalanceOfferPlate } from './ui/low-balance-offer-plate' +export { LowBalanceOfferOnStart } from './ui/low-balance-offer-on-start' +export { LowBalanceOfferTrigger } from './ui/low-balance-offer-trigger' @@ -3,5 +3,5 @@ export const RESEND_INVATION_PASSWORD = 'resend-invation-password' export const ERROR_REPORT = 'error-report' export const BUSINESS_ERROR_REPORT = 'business-error-report' export const LOW_BALANCE_OFFER = 'low-balance-offer' -export const SUBSCRIPTION_CHANGE_NOTIFICATION = 'subscription-change-notification' export const PLATE_ADD_VOICE = 'plate-add-voice' +export const SUBSCRIPTION_CHANGE_NOTIFICATION = 'subscription-change-notification' @@ -13,11 +13,12 @@ function makeModalInstance(set: Set, get: Get, key: string) { store: {}, setState(state, store) { const modals = get().modals; + const currentModal = modals[key]; - store = store ?? {}; + const newStore = store !== undefined ? store : (currentModal?.store ?? {}); set(() => ({ - modals: { ...modals, [this.id]: { ...modals[key], state, store } }, + modals: { ...modals, [this.id]: { ...modals[key], state, store: newStore } }, })); }, setStoreProperty(key, value) { @@ -73,3 +73,8 @@ .generationIcon { margin-top: 3px; } + +.settingsIcon { + flex-shrink: 0; + cursor: pointer; +} @@ -2,14 +2,11 @@ import React, { FC, useCallback, useEffect, useRef } from 'react' import { TextFieldProps } from '@mui/material/TextField/TextField' import classes from './model-input.module.scss' +import { PredictPrice } from './predict-price' import { LoadImage } from '#/app/components/input_components/load_image' import { SendBtn } from '#/app/components/input_components/send_button' import { IModelInputs } from '#/shared/api/models/models' -import { TooltipCustom } from '#/shared' -import { SvgIcon } from '#/shared/ui/svg' -import { Typography } from '@mui/material' -import { ThreePOutlined } from '@mui/icons-material' function buildTypeVersionsMap(inputs: IModelInputs[]): Record { const byType = new Map() @@ -214,16 +211,7 @@ export const ModelInput: FC = ({ >
- {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,68 @@ +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 actionType = modal.getStoreProperty<'topup' | 'upgrade'>('actionType') + const tokensToAdd = modal.getStoreProperty('tokensToAdd') + const purchasePrice = modal.getStoreProperty('purchasePrice') + + const handleClose = () => { + modal.setState(false) + } + + const handleConfirm = () => { + modal.getStoreProperty<() => void>('onConfirm')?.() + modal.setState(false) + } + + return ( + +
e.stopPropagation()}> +

Смена подписки

+ + {(actionType === 'topup' || actionType === 'upgrade') && tokensToAdd != null && purchasePrice != null ? ( +

+ Вы докупите {formatTokens(tokensToAdd)} токенов за{' '} + {formatTokens(purchasePrice)} руб. Продолжить? +

+ ) : !isUpgrade ? ( +

+ Вы собираетесь перейти на подписку{' '} + {formatTokens(toTokens)} токенов. +
+ Обратите внимание! Все неиспользованные токены сгорят после перехода на подписку дешевле. +
+ Вы можете использовать токены, а затем перейти на подписку дешевле. +
+ Продолжить? +

+ ) : ( +

+ Уведомляем вас, что при смене подписки часть токенов сверх лимита текущей подписки сгорит и не перенесётся на новый план. Продолжить? +

+ )} + +
+ + {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' @@ -34,3 +34,13 @@ export function formatDate( const formatter = Intl.DateTimeFormat("ru", options) return formatter.format(date) } + +export function getDaysLeft(iso: string | null | undefined): number | null { + if (!iso?.trim()) return null + const target = new Date(iso) + if (Number.isNaN(target.getTime())) return null + + const now = new Date() + const msDiff = target.getTime() - now.getTime() + return Math.max(0, Math.ceil(msDiff / (1000 * 60 * 60 * 24))) +} @@ -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 + /** Растянуть диалог по ширине (по умолчанию фиксированная ширина 408px у корневого sx) */ + wide?: boolean } -export const Modal: FC = ({ open, onClose, children }) => { +export const Modal: FC = ({ open, onClose, children, paperSx, wide = false }) => { return ( , }} open={open} onClose={onClose} @@ -0,0 +1,2 @@ +export { SubscriptionDaysBadge } from './subscription-days-badge' + @@ -0,0 +1,39 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 16px; + background: rgba(130, 128, 255, 0.16); + color: #8280ff; + + &_expiring { + background: #B01E1E26; + color: #B01E1E; + } +} + +.icon { + font-size: 24px !important; +} + +.text { + font-size: 15px; + font-weight: 600; + white-space: nowrap; + + &_highlight { + font-weight: 700; + } +} + +@media (max-width: 900px) { + .badge { + padding: 6px 10px; + } + + .text { + font-size: 14px; + } +} + @@ -0,0 +1,32 @@ +import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined' + +import styles from './subscription-days-badge.module.scss' + +interface SubscriptionDaysBadgeProps { + days: number +} + +function declineDays(days: number): string { + const abs = Math.abs(days) % 100 + const last = abs % 10 + + if (abs > 10 && abs < 20) return 'дней' + if (last > 1 && last < 5) return 'дня' + if (last === 1) return 'день' + return 'дней' +} + +export const SubscriptionDaysBadge = ({ days }: SubscriptionDaysBadgeProps) => { + const normalizedDays = Number.isFinite(days) ? Math.max(0, Math.trunc(days)) : 0 + const isExpiring = normalizedDays <= 3; + + return ( +
+ + + Истечет через {normalizedDays} {declineDays(normalizedDays)} + +
+ ) +} + @@ -25,6 +25,7 @@ export { Search } from './ui/search/search' export { SelectUI as Select } from './ui/select' export { Slider } from './ui/slider/slider' export { SwitchCustom } from './ui/switch/switch' +export { SubscriptionDaysBadge } from './ui/subscription-days-badge' export { TooltipCustom } from './ui/tooltip/tooltip' export { TooltipFreeTokens } from './ui/tooltip-free-tokens' export { useModel } from '#/shared/api/models/endpoints' @@ -1,62 +0,0 @@ -import { useState } from 'react' -import { Box, Button, Typography } from '@mui/material' - -import { Modal } from '#/shared' - -import styles from './subscription.module.scss' - -export const CancelSubscriptionButton = () => { - 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 { commaSeparated, SubscriptionDaysBadge } from '#/shared' +import { formatDate, getDaysLeft } from '#/shared/lib/helpers/date-helper' import { formatPlanPrice } from '#/shared/lib/helpers/format-plan-price' import { declineToken } from '#/shared/lib/helpers/get-token' @@ -23,28 +24,55 @@ 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 daysLeft = getDaysLeft(nextPaymentAt) + const nextPaymentLabel = nextPaymentFormatted + ? `${isRecurring ? 'Следующее списание' : 'Дата окончания'}: ${nextPaymentFormatted}` + : 'Без даты окончания' const planTokenLimitNumber = Number(planTokenLimit) const percentage = planTokenLimitNumber > 0 ? Math.min(100, Math.max(0, (balance / planTokenLimitNumber) * 100)) : 0 return ( - - Последняя оплата - + + + Мой тариф + + {daysLeft !== null ? : null} + {commaSeparated(Math.round(Number(planTokenLimit)))} токенов - {formatterdPlanPrice} + {formatterdPlanPrice} / мес + + {nextPaymentLabel} + @@ -18,7 +18,7 @@ export const FeaturesSection = ({ currentTokenLimitUID, onCurrentTokenLimitUIDCh return ( - + Возможности с подпиской