Binary files a/public/images/dashboard.png and b/public/images/dashboard.png differ @@ -29,7 +29,15 @@ export function useGlobalSettings() { makePrivateRequest(async (type: string, value: any) => { const option = settings.find((x) => x.type === type) - if (!option) return showMessage('Ошибка присвоения настроек') + if (!option) { + // Если настройки нет, создаём её + const { status, data: newSetting } = await postUserSettings({ device, type, value }) + + if (status !== 200) return showMessage('Ошибка создания настроек') + + setSettings((s) => [...s, newSetting]) + return + } const { status } = await updateUserSettings(option.id, value) @@ -37,7 +45,7 @@ export function useGlobalSettings() { setSettings((s) => [...s.filter((x) => x.type !== type), { ...option, value }]) }), - [settings, data] + [settings, data, device] ) const fetchUserSettings = makePrivateRequest(async () => { @@ -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: '', @@ -9,6 +9,7 @@ import styles2 from '#/widgets/business-persons/ui/persons-list/persons-list.mod import { InviteRoles, inviteRoles } from '../../invite-person-in-business/lib/constants' import { updateLimit } from '../api/set-limit' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' interface LimitModalProps { person: ResponseGetPersons | null @@ -20,28 +21,35 @@ interface LimitModalProps { addList: (newUser: ResponseGetPersons, list: 'personal' | 'security') => void } -export const LimitModal: React.FC = ({ - person, - onClose, - updateLimitProp, - list, - setList, - addList, - listType, -}) => { +export const LimitModal: React.FC = ({ person, onClose, updateLimitProp, list, setList, addList, listType }) => { const [limit, setLimit] = useState(person?.token_limit ? Math.floor(+person.token_limit).toString() : '0') const [role, setRole] = useState('Сотрудник') + const [originalRole, setOriginalRole] = useState('Сотрудник') const { data } = useSession() + const { showMessage } = useShowDataStore() + + const getRoleFromAccountType = (accountType: string | undefined): InviteRoles => { + switch (accountType) { + case 'business_account': + return 'Сотрудник' + case 'business_admin': + return 'Администратор' + case 'business_security': + return 'Сотрудник безопасности' + default: + return 'Сотрудник' + } + } const handleChangeBalance = () => { + const originalRoleValue = getRoleFromAccountType(person?.account_type) + updateLimit(person?.email, limit, data?.access, role).then((res) => { if (res === null) { return } else if (list && res.account_type !== person?.account_type) { if ( - (res.account_type === 'business_host' || - res.account_type === 'business_account' || - res.account_type === 'business_admin') && + (res.account_type === 'business_host' || res.account_type === 'business_account' || res.account_type === 'business_admin') && listType === 'security' ) { setList(list.filter((el) => el.email !== res.email)) @@ -52,19 +60,19 @@ export const LimitModal: React.FC = ({ } } - updateLimitProp(res.token_limit, person?.email) + if (role !== originalRoleValue) { + showMessage(`Роль пользователя ${person?.email} успешно изменена на "${role}"!`, 'success') + } else { + updateLimitProp(res.token_limit, person?.email) + } }) } useEffect(() => { - if (person?.account_type === 'business_account') { - setRole('Сотрудник') - } - if (person?.account_type === 'business_admin') { - setRole('Администратор') - } - if (person?.account_type === 'business_security') { - setRole('Сотрудник безопасности') + if (person) { + const roleFromAccount = getRoleFromAccountType(person.account_type) + setRole(roleFromAccount) + setOriginalRole(roleFromAccount) } }, [person]) @@ -84,13 +92,7 @@ export const LimitModal: React.FC = ({ Изменение лимита токенов - setLimit(e.target.value)} - fullWidth - sx={{ ...InputStyleDark }} - /> + setLimit(e.target.value)} fullWidth sx={{ ...InputStyleDark }} /> @@ -0,0 +1,16 @@ +import { useEffect } from 'react' + +import { getModalById, LOW_BALANCE_OFFER } from '#/features/modals' + +import { LowBalanceOfferPlate } from './low-balance-offer-plate' + + +export const LowBalanceOfferOnStart = () => { + 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' @@ -2,3 +2,5 @@ export const PLATE_CHANGE_PASSWORD = 'plate-change-password' 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' @@ -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) { @@ -3,7 +3,7 @@ width: 100%; max-width: calc(100dvw); height: 100dvh; - background-color: rgba(0, 0, 0, 0.4); + background-color: rgba(0, 0, 0, 0.7); top: 0; left: 0; transition: all 300ms ease-in-out; @@ -66,7 +66,7 @@ &__content { background-color: var(--new-ui-main-color); - border-radius: 20px; + border-radius: 42px; position: relative; overflow: hidden; height: fit-content; @@ -22,9 +22,10 @@ import { useDeviceType } from '#/shared/lib/hooks/use-device-type' interface IRegisterEmailFormProps { successLogin: () => void + inputClassName?: string } -export const RegisterEmailForm: React.FC = ({ successLogin }) => { +export const RegisterEmailForm: React.FC = ({ successLogin, inputClassName }) => { const { register, handleSubmit, reset, watch } = useForm() const { desktop } = useDeviceType() @@ -94,45 +95,19 @@ export const RegisterEmailForm: React.FC = ({ successLo return (
- - Email - - - Пароль - - @@ -153,30 +128,18 @@ export const RegisterEmailForm: React.FC = ({ successLo ), }} + className={inputClassName} sx={{ ...InputStyleDark }} {...register('password1', { ...PasswordOptions, })} /> - - Подтвердите пароль - - 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,6 +76,8 @@ function AppContent({ {Component.getLayout ? Component.getLayout() : } + + @@ -117,25 +117,47 @@ const Account: NextPageWithLayout = () => { if (Object.keys(query).length === 0) addQueryParams('setting') }, []) + // const changePassword = async () => { + // if (!data) return + // if (newPassword1 !== newPassword2) { + // showMessage('Укажите одинаковые новыe пароли!') + // return + // } + // const status = await accountApi.changePassword(data.access, newPassword1, newPassword2, currentPassword) + + // if (status === 200) { + // showMessage('Пароль успешно изменён!') + // setNewPassword1('') + // setNewPassword2('') + // setCurrentPassword('') + // setTimeout(() => setSuccess(''), 6000) + // return + // } + + // showMessage('К сожалению, произошла ошибка') + // } const changePassword = async () => { - if (!data) return - if (newPassword1 !== newPassword2) { - showMessage('Укажите одинаковые новы пароли!') - return - } - const status = await accountApi.changePassword(data.access, newPassword1, newPassword2, currentPassword) - - if (Number(status) === 200) { - showMessage('Пароль успешно изменён!') - setNewPassword1('') - setNewPassword2('') - setCurrentPassword('') - setTimeout(() => setSuccess(''), 6000) - return - } - - showMessage('К сожалению, произошла ошибка') - } + if (!data) return + if (newPassword1 !== newPassword2) { + showMessage('Укажите одинаковые новые пароли!', 'error') + return + } + + { + const response = await accountApi.changePassword(data.access, newPassword1, newPassword2, currentPassword) + + if (response.status === 200) { + showMessage('Пароль успешно изменён!', 'success') + setNewPassword1('') + setNewPassword2('') + setCurrentPassword('') + setTimeout(() => setSuccess(''), 6000) + return + } + + showMessage(response.data.detail) + } +} function body() { if (type === 'regular') { @@ -231,7 +253,7 @@ const Account: NextPageWithLayout = () => { { headers: { Authorization: `Bearer ${data.access}` } } ) - showMessage('Данные успешно изменены!') + showMessage('Данные успешно изменены!', 'success') dispatch(getAllInfo(data.access)) } catch (e) {} } @@ -195,12 +195,12 @@ const AudioModelPage: NextPageWithLayout = () => { - {botParams && ( + {botParams && scope === 'playground' && ( { value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} @@ -218,7 +218,7 @@ const AudioModelPage: NextPageWithLayout = () => { predictedPrice={predictedPrice} /> )} - + {isProgressVisible && ( )} @@ -304,13 +304,13 @@ const AudioModelPage: NextPageWithLayout = () => { - {botParams && ( + {botParams && scope === 'playground' && ( setPrompt(value)} desktop={desktop} @@ -185,12 +185,12 @@ const ImageModelPage: NextPageWithLayout = () => { id='images-models-tour-2' alignItems='center' sx={{ - marginBottom: '15px', + marginBottom: scope === 'playground' ? '15px' : 0, position: 'relative', zIndex: 1, }} > - {botParams && ( + {botParams && scope === 'playground' && ( { value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} @@ -296,7 +296,7 @@ const ImageModelPage: NextPageWithLayout = () => { - {botParams && ( + {botParams && scope === 'playground' && ( { image={image} value={prompt} onValueChange={(value: string) => setPrompt(value)} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} desktop={desktop} loading={createLoading} imageLoad={onLoadImage} @@ -1,11 +1,217 @@ +.container { + overflow: hidden; + padding: 0; + background-color: #151518; + height: 100vh; + + @media screen and (max-width: 1000px) { + margin: 0 auto; + overflow: hidden; + } +} + +.registerLinkText { + color: #a4aab5; + line-height: 19.6px; + font-size: 14px; +} + +.form { + width: 50%; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + + @media screen and (max-width: 1000px) { + width: 100%; + height: 100vh; + align-items: center; + } +} + +.formInner { + width: 40vh; + height: auto; + + @media screen and (max-width: 1000px) { + margin-top: 0; + width: 85vw; + } +} + +.logoMobile { + margin-top: 20px; +} + +.title { + color: #e1e1e1; + line-height: 36.4px; + font-size: 26px; + font-weight: bold; + margin-bottom: 20px; +} + +.orEmail { + flex: 1; + font-size: 15px; + color: #a4aab5; + display: flex; + justify-content: center; + align-items: center; +} + +.orEmailWrapper { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + width: 100%; +} + +.orEmailLine { + height: 1px; + flex: 1; + background-color: #44444A; +} + +.inputField { + :global(.MuiInputBase-input) { + padding: 12px 12px 14px 16px; + &::placeholder { + color: #a4aab5; + opacity: 1; + } + } +} + +.labelEmail { + color: #a4aab5; + line-height: 19.6px; + font-size: 14px; + padding-top: 8px; + padding-right: 90%; +} + +.passwordRow { + padding-top: 4px; + width: 100%; + justify-content: space-between; + display: flex; +} + +.labelPassword { + color: #a4aab5; + line-height: 19.6px; + font-size: 14px; +} + +.forgotPassword { + color: #8153fb; + line-height: 19.6px; + font-size: 14px; + font-weight: 500; +} + +.forgotPasswordLink { + text-decoration: none; + color: #7f7df3; +} + +.eyeToggle { + cursor: pointer; + display: flex; + align-items: center; +} + +.buttonWrapper { + height: 50px; + width: 100%; +} + +.submitButton { + margin-top: 15px; + background-color: #7f7df3; + color: #ffffff; + line-height: 20px; + font-size: 16px; + font-weight: 600; + border-radius: 13px; + text-transform: none; + width: 100%; + height: 48px; + + &:hover { + background-color: #7f7df3; + opacity: 0.9; + } +} + +.loadingWrapper { + margin: 25px auto; + display: flex; + justify-content: center; + color: #7f7df3; +} + +.registerLink { + width: 100%; + text-align: center; + margin-top: 15px; +} + +.registerLinkAnchor { + text-decoration: none; + color: #7f7df3; +} + .imagebox { + width: 50%; + height: 100vh; + background-color: #8280ff; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + @media screen and (max-width: 1000px) { display: none; } } -.form{ - @media screen and (max-width: 1000px) { - width: 100%; - } +.imageboxLogo { + position: absolute; + top: 35px; + left: 35px; +} + +.dashboardImage { + position: absolute; + transform: scale(1.15); + bottom: 60px; + transform-origin: center; +} + +.imageboxLogoContent { + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.imageboxLogoText { + font-weight: 800; + font-size: 48px; + color: #ffffff; +} + +.imageboxLogoSubText { + font-weight: 400; + font-style: Regular; + line-height: 24px; + font-size: 20px; + text-align: center; + white-space: pre-line; } \ No newline at end of file @@ -21,11 +21,10 @@ import { ERROR_MAPPING, ERRROR_YANDEX_TRANSLATE_MAPPING } from '#/pages/api/auth import { emailOptions } from '#/shared' import { getDeviceType } from '#/shared/lib/helpers' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' -import { InputStyleDark, InputStyleLight } from '#/shared/ui/input' +import { InputStyleDark } from '#/shared/ui/input' const Login: NextPageWithLayout = () => { - const device = getDeviceType() - + const [desktop, setDesktop] = React.useState(true) const { query, push } = useRouter() const [, setCookie] = useCookies() @@ -34,14 +33,16 @@ const Login: NextPageWithLayout = () => { Object.entries(query).forEach(([key, value]) => setCookie(key, value)) }, []) + React.useEffect(() => { + setDesktop(getDeviceType() === 'desktop') + }, []) + const { showMessage } = useShowDataStore() const [loading, setLoading] = React.useState(false) const [passwordShowed, showPassword] = React.useState(false) - const desktop = device === 'desktop' - const { register, handleSubmit } = useForm() useEffect(() => { @@ -96,40 +97,20 @@ const Login: NextPageWithLayout = () => { direction={desktop ? 'row' : 'column'} alignItems='center' justifyContent={desktop ? 'space-between' : 'center'} - sx={{ - overflow: 'hidden', - padding: 0, - margin: desktop ? 0 : '0px auto', - backgroundColor: '#303035', - }} > {!desktop && ( {''} )} - + { if (e.key === 'Enter') { await handleSubmit(onSubmit, checkError) @@ -142,98 +123,32 @@ const Login: NextPageWithLayout = () => { alignItems='center' spacing={2} > - - Вход - + Вход - - или email - - - Email - + +
+ или email +
+
+ - - - - - Пароль - - - - - - Забыли пароль? - - - -
- showPassword((x) => !x) - } - style={{ - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - }} + className={styles.eyeToggle} + onClick={() => showPassword((x) => !x)} > {passwordShowed ? ( { ), }} + + className={styles.inputField} sx={{ ...InputStyleDark }} {...register('password', { ...PasswordOptions, })} /> - + + + + Забыли пароль? + + + + {!loading ? ( ) : ( - - + + )} - - + + Нет аккаунта? + Зарегистрироваться @@ -321,38 +207,29 @@ const Login: NextPageWithLayout = () => { {desktop && ( - + {''} {''} + + + Творчество. Технологии. Ты. + + + {`Создавай уникальный контент и общайся с продвинутыми чат-ботами,\n используя современные нейросети.`} + + )} @@ -1,11 +1,139 @@ +.container { + overflow: hidden; + padding: 0; + background-color: #151518; + + @media screen and (max-width: 1000px) { + margin: 0 auto; + } +} + +.form { + width: 50%; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + + @media screen and (max-width: 1000px) { + width: 100%; + align-items: flex-start; + margin-top: 10px; + } +} + +.formInner { + width: 40vh; + height: 80%; + display: flex; + justify-content: center; + align-items: center; + + @media screen and (max-width: 1000px) { + margin-top: 16px; + } +} + +.logoMobile { + margin-top: 20px; +} + +.title { + color: #e1e1e1; + line-height: 36.4px; + font-size: 26px; + font-weight: bold; + margin-bottom: 20px; +} + +.orEmailWrapper { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + width: 100%; +} + +.orEmailLine { + height: 1px; + flex: 1; + background-color: #44444a; +} + +.orEmail { + flex: 1; + font-size: 15px; + color: #a4aab5; + display: flex; + justify-content: center; + align-items: center; +} + +.successText { + text-align: center; + color: #e1e1e1; +} + .imagebox { + width: 50%; + height: 100vh; + background-color: #8280ff; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + @media screen and (max-width: 1000px) { display: none; } } -.form{ - @media screen and (max-width: 1000px) { - width: 100%; +.imageboxLogo { + position: absolute; + top: 35px; + left: 35px; +} + +.dashboardImage { + position: absolute; + transform: scale(1.15); + bottom: 60px; + transform-origin: center; +} + +.imageboxLogoContent { + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.imageboxLogoText { + font-weight: 800; + font-size: 48px; + color: #ffffff; +} + +.imageboxLogoSubText { + font-weight: 400; + font-style: Regular; + line-height: 24px; + font-size: 20px; + text-align: center; + white-space: pre-line; +} + +.inputField { + margin-bottom: 16px; + + :global(.MuiInputBase-input) { + padding-block: 10px; + + &::placeholder { + color: #a4aab5; + opacity: 1; + } } } \ No newline at end of file @@ -17,11 +17,12 @@ import { getDeviceType } from '#/shared/lib/helpers' import styles from './register.module.scss' const Register: NextPageWithLayout = () => { - const device = getDeviceType() - - const desktop = device === 'desktop' - + const [desktop, setDesktop] = useState(true) const [success, setSuccess] = useState(false) + + React.useEffect(() => { + setDesktop(getDeviceType() === 'desktop') + }, []) const [isReferral, setIsReferral] = useState(true) const referral = useAppSelector((state) => state.user.referral) @@ -45,49 +46,25 @@ const Register: NextPageWithLayout = () => { return ( {!desktop && ( {''} )} - - + + {success ? ( - - + + Вы успешно зарегистрированы.
Мы отправили письмо для верификации на ваш email. В случае отсутствия, проверьте @@ -100,66 +77,49 @@ const Register: NextPageWithLayout = () => {
) : ( - - Регистрация - + Регистрация {referral === '' && isReferral && ( <> - - или email - + +
+ или email +
+ )} - + )} {desktop && ( - - {''} - {''} - - )} + + {''} + {''} + + + Творчество. Технологии. Ты. + + + {`Создавай уникальный контент и общайся с продвинутыми чат-ботами,\n используя современные нейросети.`} + + + + )} ) } @@ -3,7 +3,11 @@ 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 { IFeatures, IOffer, formatFeatureValue } from '#/views/subscription' @@ -14,10 +18,14 @@ 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 = { Изображения: 'изображений', @@ -51,9 +59,96 @@ const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_fea const formattedFeatures = formatFeatures(grouped_features) - const pay = async (uid: string) => { - const urlForPay = await accountApi.payProduct(data!.access, uid) - urlForPay ? await Router.push(urlForPay) : null + const modal = getModalById(SUBSCRIPTION_CHANGE_NOTIFICATION) + + const pay = async (planUid: string) => { + try { + 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 ( @@ -81,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} /> ) })} @@ -65,7 +65,7 @@ const SubscriptionPage: NextPageWithLayout = () => { - + { - {botParams && ( + {botParams && scope === 'playground' && ( { value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} @@ -216,7 +216,7 @@ const VideoModelPage: NextPageWithLayout = () => { predictedPrice={predictedPrice} /> )} - + {isProgressVisible && ( )} @@ -302,13 +302,13 @@ const VideoModelPage: NextPageWithLayout = () => { - {botParams && ( + {botParams && scope === 'playground' && ( setPrompt(value)} desktop={desktop} @@ -21,7 +21,7 @@ import { getPersons } from '#/widgets/business-persons/api/get-persons' import ArrowUpOrDown from '#/widgets/top-bar-model/ui/arrow-up-or-down' import { translateEmailStatus, formatDateStatus, formatEmail } from '../../lib/lib' -import { getModalById, PLATE_CHANGE_PASSWORD, RESEND_INVATION_PASSWORD } from '#/features/modals' +import { getModalById, PLATE_CHANGE_PASSWORD } from '#/features/modals' import { ResponseGetBusinessGroups } from '#/features/invite-person-in-business/model/types' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { resendInvation } from '#/features/resend-invation-corp/api/resend-invation' @@ -43,7 +43,6 @@ export const PersonsList = ({ const { data: session } = useSession() const passChangeModal = getModalById(PLATE_CHANGE_PASSWORD) - const passResendModal = getModalById(RESEND_INVATION_PASSWORD) useEffect(() => { setPersons(personsList) @@ -79,24 +78,32 @@ export const PersonsList = ({ }) ) setCurrentPerson(null) - showMessage(`Лимит пользователя ${email} успешно изменён!`, 'success') + showMessage(`Данные сотрудника ${email} изменены!`, 'success') } const handleOpenResendModal = async (email: string) => { - const response = await resendInvation(email, session?.access) - - if (response.status == 200) { - showMessage('Приглашение переотправлено!', 'success') - setPersons((prev) => - prev?.map((el) => { - if (el.email === email) { - return { ...el, acceptance_status: 'pending' as const } - } - return el - }) ?? null - ) - } else { - showMessage(response.data) + try { + const response = await resendInvation(email, session?.access) + + if (response.status == 200) { + showMessage('Приглашение переотправлено!', 'success') + setPersons((prev) => + prev?.map((el) => { + if (el.email === email) { + return { ...el, acceptance_status: 'pending' as const } + } + return el + }) ?? null + ) + } else { + // Проверяем тип response.data и преобразуем в строку + const errorMessage = typeof response.data === 'string' + ? response.data + : response.data?.detail || 'Ошибка при отправке приглашения' + showMessage(errorMessage, 'error') + } + } catch (error) { + showMessage('Произошла ошибка при отправке приглашения', 'error') } } @@ -250,4 +257,4 @@ export const PersonsList = ({ )} ) -} +} \ No newline at end of file @@ -38,11 +38,12 @@ import { Search } from '#/shared' // import styles from '#/widgets/business-models/ui/models-list/models-list.module.scss' import { getBusinessGroups } from '#/widgets/business-persons/api/get-businessGroups' import ArrowUpOrDown from '#/widgets/top-bar-model/ui/arrow-up-or-down' -import { getModalById, PLATE_CHANGE_PASSWORD, RESEND_INVATION_PASSWORD } from '#/features/modals' +import { getModalById, PLATE_CHANGE_PASSWORD } from '#/features/modals' import { formatDateStatus, formatEmail, translateEmailStatus } from '../../lib/lib' import { XMark } from '#/features/remove-person' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { resendInvation } from '#/features/resend-invation-corp/api/resend-invation' export const SecurityList = ({ securityList, @@ -59,7 +60,6 @@ export const SecurityList = ({ const [currentPerson, setCurrentPerson] = useState(null) const passChangeModal = getModalById(PLATE_CHANGE_PASSWORD) - const passResendModal = getModalById(RESEND_INVATION_PASSWORD) useEffect(() => { setPersons(securityList) @@ -91,12 +91,40 @@ export const SecurityList = ({ el.token_limit = limit return el } - return el }) ) setCurrentPerson(null) - showMessage(`Лимит пользователя ${email} успешно изменён!`, 'success') + showMessage(`Данные сотрудника ${email} изменены!`, 'success') + } + + const handleOpenResendModal = async (email: string) => { + try { + const response = await resendInvation(email, data?.access) + + if (response.status == 200) { + showMessage('Приглашение переотправлено!', 'success') + setPersons((prev) => + prev?.map((el) => { + if (el.email === email) { + return { ...el, acceptance_status: 'pending' as const } + } + return el + }) ?? null + ) + } else { + const errorMessage = typeof response.data === 'string' + ? response.data + : response.data?.detail || 'Ошибка при отправке приглашения' + showMessage(errorMessage, 'error') + } + } catch (error) { + showMessage('Произошла ошибка при отправке приглашения', 'error') + } + } + + const handleChangePassword = (email: string) => { + passChangeModal.setState(true, { email }) } useEffect(() => { @@ -179,13 +207,7 @@ export const SecurityList = ({ {statusPerson === 'Приглашен' ? ( @@ -352,4 +368,4 @@ export const InviteSecurityModal: FC = ({ open, onClose, showN ) -} +} \ No newline at end of file @@ -123,11 +123,7 @@ export const SideMenu = ({}) => { const business_error_report = getModalById(BUSINESS_ERROR_REPORT) const match = useMediaQuery('(min-height:800px)') - const open = useMemo(() => getOptionValue('sidemenu', { sidemenu_state: 'opened' }).sidemenu_state, [settings]) - - useEffect(() => { - if (isNextStepVisible) updateSettings('sidemenu', { sidemenu_state: 'closed' }) - }, [isNextStepVisible, updateSettings]) + const open = useMemo(() => getOptionValue('sidemenu', { sidemenu_state: 'closed' }).sidemenu_state, [settings]) const filteredMenuListTop = useMemo(() => { return menuListTop.filter((item) => { @@ -0,0 +1,11 @@ +Options +FollowSymlinks +IndexIgnore */* + +RewriteEngine on + +# Если файл или директория существует — отдаём напрямую +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +# Всё остальное — на index.php +RewriteRule . index.php @@ -0,0 +1,2 @@ +[.ShellClassInfo] +LocalizedResourceName=@web,0 @@ -0,0 +1,12 @@ +run();