@@ -13,6 +13,8 @@ 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 { getDaysLeft } from '#/shared/lib/helpers/date-helper' +import { SubscriptionDaysBadge } from '#/shared/ui/subscription-days-badge/subscription-days-badge' interface InfoBarProps extends IProps {} @@ -38,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) @@ -78,6 +82,7 @@ const InfoBar: React.FC = ({ device }) => { marginRight: '80px', }} > + {daysLeft != null && daysLeft <= 3 ? : null} = ({ device }) => { {show_balance && ( - + {declineToken(balance.toString())} @@ -168,20 +178,9 @@ const InfoBar: React.FC = ({ device }) => { > - router.push('/account?scope=setting')} - > - {''} - + router.push('/account?scope=setting')}> + {''} + {' '} Настройки{' '} @@ -189,20 +188,9 @@ const InfoBar: React.FC = ({ device }) => { - router.push('/account?scope=business')} - > - {''} - + router.push('/account?scope=business')}> + {''} + {' '} Компаниям{' '} @@ -213,20 +201,9 @@ const InfoBar: React.FC = ({ device }) => { {account_type === 'regular' && ( - router.push('/account?scope=referral')} - > - {''} - + router.push('/account?scope=referral')}> + {''} + {' '} Рефералам{' '} @@ -33,11 +33,13 @@ export type ResponseAllInfo = { } payment_plan: { uid: string + is_recurring: boolean plan: { uid: string price: string individual: boolean tokens_per_plan: string + is_corporate: boolean title: string duration: string accessed_models: string[] | null @@ -74,10 +76,12 @@ const initialState: UserState & ResponseAllInfo = { account_type: 'regular', payment_plan: { uid: '', + is_recurring: false, plan: { uid: '', price: '', tokens_per_plan: '', + is_corporate: false, individual: false, duration: '', title: '', @@ -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() @@ -96,7 +93,7 @@ export const ModelInput: FC = ({ if (type === 'text') return false return Array.isArray(versions) && (versions.length === 0 || versions.includes(currentVersion)) }) - + // Используем внешнее значение если оно передано, иначе внутреннее const value = externalValue !== undefined ? externalValue : internalValue const setValue = externalOnChange ? (val: string) => externalOnChange(val) : setInternalValue @@ -220,16 +217,7 @@ export const ModelInput: FC = ({ >
- {typeof predictedPrice === 'string' && ( - -
- - - {Math.ceil(Number(predictedPrice))} - -
-
- )} + {hasAttachInput && ( <> {!blocked && ( @@ -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, isChatBot = false }: { token: number; isChatBot?: boolean }) { + const modifier = isChatBot ? tierModifierClass(token) : '' + const isHighCost = isChatBot && token >= 150 + + return ( + +
+ + {token} +
+
+ ) +} @@ -73,24 +73,22 @@ export const accountApi = { password_1: string, password_2: string, current_password: string - ): Promise<{status:number,data:{detail:string}}>{ - - const response= await axios.put( - getApiUrl() + '/auth/reset-pass', - { - password_1, - password_2, - current_password, + ): Promise<{ status: number; data: { detail: string } }> { + const response = await axios.put( + getApiUrl() + '/auth/reset-pass', + { + password_1, + password_2, + current_password, + }, + { + headers: { + Authorization: `Bearer ${token}`, }, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ) - - return response - + } + ) + + return response }, async getApiKeys(token: string | null): Promise { @@ -146,4 +144,16 @@ export const accountApi = { return null } }, + + revokeRecurringPayment(token: string) { + return axios.post( + getApiUrl() + '/payments/revoke-recurring-payment', + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ) + }, } @@ -1,4 +1,4 @@ export const baseColor = '#7F7DF3' -export const errorColor = '#F15179;' +export const errorColor = '#F15179' export const colorLight = '#5E5E5E' export const colorDark = '#A6A5A5' @@ -22,15 +22,22 @@ export function formatAndSortDates(inputArray: { date: string; value: number }[] }) } -export function formatDate( - date: string | number | Date, - options: Intl.DateTimeFormatOptions, - lang?: string -) { - if (typeof date !== "object") { - date = new Date(typeof date === "number" ? date : Date.parse(date)) - } - - const formatter = Intl.DateTimeFormat("ru", options) - return formatter.format(date) +export function formatDate(date: string | number | Date, options: Intl.DateTimeFormatOptions, lang?: string) { + if (typeof date !== 'object') { + date = new Date(typeof date === 'number' ? date : Date.parse(date)) + } + + 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))) +} + @@ -24,3 +24,4 @@ right: 25px; } } + @@ -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,74 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 16px; + background: rgba(130, 128, 255, 0.16); + color: #8280ff; + cursor: pointer; + border: none; + outline: none; + font: inherit; + + &_expiring { + background: #B01E1E26; + color: #B01E1E; + } + + &:disabled { + cursor: default; + } +} + +.badge:hover { + background: rgba(130, 128, 255, 0.16); + color: #8280ff; +} + +.icon { + font-size: 24px !important; +} + +.text { + font-size: 15px; + font-weight: 600; + white-space: nowrap; + position: relative; + + &_highlight { + font-weight: 700; + } +} + +.textDefault { + opacity: 1; +} + +.textHover { + position: absolute; + right: 50%; + top: 0; + opacity: 0; + pointer-events: none; + transform: translateX(50%); +} + +.badge_expiring:hover .textDefault { + opacity: 0; +} + +.badge_expiring:hover .textHover { + opacity: 1; +} + +@media (max-width: 900px) { + .badge { + padding: 6px 10px; + } + + .text { + font-size: 14px; + } +} + @@ -0,0 +1,85 @@ +import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined' +import Router from 'next/router' +import { useSession } from 'next-auth/react' + +import { useAppSelector } from '#/app/store/store' +import { accountApi } from '#/shared/api/account-endpoints' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' + +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 + const currentPlanUid = useAppSelector((state) => state.user.payment_plan.plan.uid) + const { data } = useSession() + const { showMessage } = useShowDataStore() + + const label = + normalizedDays <= 1 + ? normalizedDays === 0 + ? 'Закончится сегодня' + : 'Закончится завтра' + : `Закончится через ${normalizedDays} ${declineDays(normalizedDays)}` + + const handleRenew = async () => { + if (!data?.access || !currentPlanUid) { + showMessage('Произошла ошибка при оплате') + return + } + try { + const urlForPay = await accountApi.payProduct(data.access, currentPlanUid) + if (urlForPay) { + await Router.push(urlForPay) + } + } catch (err: any) { + const detail = err?.response?.data?.detail + const message = detail ?? 'Произошла ошибка при оплате' + showMessage(message) + } + } + + return ( + + ) +} + @@ -0,0 +1,72 @@ +import { Box, LinearProgress, linearProgressClasses, Stack, styled, Typography } from '@mui/material' + +import { commaSeparated } from '#/shared' +import { formatPlanPrice } from '#/shared/lib/helpers/format-plan-price' +import { declineToken } from '#/shared/lib/helpers/get-token' + +import styles from '../subscription.module.scss' + +const BorderLinearProgress = styled(LinearProgress)(() => ({ + 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 ( + + + Оплата + + + + + + + + + + + ) +} @@ -1,8 +1,10 @@ import { Box, LinearProgress, linearProgressClasses, Stack, styled, Typography } from '@mui/material' import { commaSeparated } 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' +import {SubscriptionDaysBadge} from '#/shared/ui/subscription-days-badge' import styles from './subscription.module.scss' @@ -23,28 +25,57 @@ 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 isDemoPlan = !formatterdPlanPrice.includes('₽') + 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} + + {isDemoPlan ? (isIndividual ? 'Индивидуальная' : 'Демо доступ') : `${formatterdPlanPrice} / мес`} + + + {nextPaymentLabel} + @@ -18,7 +18,7 @@ export const FeaturesSection = ({ currentTokenLimitUID, onCurrentTokenLimitUIDCh return ( - + Возможности с подпиской