@@ -0,0 +1,15 @@ +const Sentry = { + init: jest.fn(), + captureException: jest.fn(), + captureMessage: jest.fn(), + withScope: jest.fn((callback) => { + callback({ + setExtra: jest.fn(), + setTag: jest.fn(), + setUser: jest.fn(), + }); + }), +}; + + +export default Sentry; @@ -0,0 +1,25 @@ +const mockRouter = { + push: jest.fn(() => Promise.resolve()), + replace: jest.fn(() => Promise.resolve()), + reload: jest.fn(), + back: jest.fn(), + prefetch: jest.fn(() => Promise.resolve()), + beforePopState: jest.fn(), + events: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, + query: {}, + asPath: '', + pathname: '', + route: '', +} + +const useRouter = jest.fn(() => mockRouter) + +export { useRouter } + +export const setRouter = (overrides: Partial) => { + Object.assign(mockRouter, overrides) +} @@ -0,0 +1,3 @@ +const ReactComponent = 'div' + +export default ReactComponent \ No newline at end of file @@ -0,0 +1,19 @@ +import fetch from 'cross-fetch' + +export function fetchToCrossfetch() { + global.fetch = (...params: Parameters) => { + let url = params[0] + + const baseUrl = 'http://localhost:3000/' + + if ( + typeof url === 'string' && + !(url as string).startsWith(baseUrl) && + !(url as string).includes('http') + ) { + url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) + params[0] : baseUrl + params[0] + } + + return fetch(url, params[1]) + } +} @@ -0,0 +1,9 @@ +import { store } from '#/app/store/store' +import { render } from '@testing-library/react' +import { Provider } from 'react-redux' + +export function jestRender(component: React.ReactElement) { + return render({component}) +} + + @@ -0,0 +1,28 @@ +//@ts-nocheck +export function windowMock() { + let originalLocation = window.location // Сохраняем оригинальный window.location + + beforeEach(() => { + // Удаляем оригинальный window.location + delete window.location + + // Мокируем window.location + window.location = { + href: '', + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + protocol: 'http:', + host: 'localhost', + pathname: '/', + search: '', + hash: '', + ...originalLocation, // Сохраняем оригинальные методы + } + }) + afterEach(() => { + // Возвращаем оригинальный window.location + // @ts-ignore + window.location = originalLocation + }) +} @@ -0,0 +1,3 @@ + + + @@ -0,0 +1,3 @@ + + + @@ -1,6 +1,10 @@ - - + + \ No newline at end of file @@ -17,6 +17,7 @@ import { API_URL } from '#/shared/lib/constants' import { declineToken } from '#/shared/lib/helpers/get-token' import { IProps } from '#/shared/lib/types/entities' import { NavigationSearch } from '#/widgets/navigation-search' +import { useUserSelector } from '#/entities/user-account' interface InfoBarProps extends IProps {} @@ -60,13 +61,13 @@ const InfoBar: React.FC = ({ device }) => { const balance = useAppSelector((state) => state.balance.balance) - const show_balance = useAppSelector((state) => state.user.show_balance) + const { show_balance } = useUserSelector() const { pathname, replace } = useRouter() const { data } = useSession() - const { email, first_name, last_name, profile_picture_link, account_type } = useAppSelector((state) => state.user) + const { email, first_name, last_name, profile_picture_link, account_type } = useUserSelector() const dispatch = useAppDispatch() @@ -143,9 +144,14 @@ const InfoBar: React.FC = ({ device }) => { > - Мы уже работаем над этой проблемой. Попробуйте перезайти в аккаунт + Мы уже работаем над этой проблемой. Попробуйте перезайти в + аккаунт - @@ -171,7 +177,10 @@ const InfoBar: React.FC = ({ device }) => { {show_balance && ( - + {declineToken(balance.toString())} @@ -199,7 +208,8 @@ const InfoBar: React.FC = ({ device }) => { sx={{ marginTop: '6px' }} PaperProps={{ style: { - backgroundColor: theme === 'dark' ? '#151518' : 'white', + backgroundColor: + theme === 'dark' ? '#151518' : 'white', borderRadius: '13px', boxShadow: 'none', }, @@ -221,7 +231,9 @@ const InfoBar: React.FC = ({ device }) => { router.push('/account?scope=setting')} + onClick={() => + router.push('/account?scope=setting') + } > = ({ device }) => { router.push('/account?scope=business')} + onClick={() => + router.push( + '/account?scope=business' + ) + } > {''} = ({ device }) => { router.push('/account?scope=referral')} + onClick={() => + router.push( + '/account?scope=referral' + ) + } > {''} = ({ device }) => { router.push('/account?scope=subscribe')} + onClick={() => + router.push( + '/account?scope=subscribe' + ) + } > = ({ device }) => { }} sx={{ marginTop: '15px', cursor: 'pointer' }} > - {''} - + {''} + Выйти @@ -23,6 +23,7 @@ --new-ui-btn-danger-bg: #ff23721a; --new-ui-ctrl-f-button-bg: #f9f9fc; --new-ui-ctrl-f-button-border: 1px solid #c4cbd8; + --new-ui-table-cell-text: #97989f; } :root[data-theme='dark'] { @@ -50,6 +51,7 @@ --new-ui-btn-danger-bg: #ff23721a; --new-ui-ctrl-f-button-bg: #242428; --new-ui-ctrl-f-button-border: 1px solid #303035; + --new-ui-table-cell-text: #97989f; } * { @@ -132,16 +134,29 @@ p { /* Измените тень, если необходимо */ } -.MuiInputBase-input:-webkit-autofill, +/* .MuiInputBase-input:-webkit-autofill, .MuiInputBase-input:-webkit-autofill:hover, .MuiInputBase-input:-webkit-autofill:focus, .MuiInputBase-input:-webkit-autofill:active { - /* box-shadow: 0 0 0 100px var(--new-ui-main-color) inset !important; */ -webkit-background-clip: text; -webkit-text-fill-color: var(--text-color-main); transition: background-color 5000s ease-in-out 0s; box-shadow: inset 0 0 0 100px var(--new-ui-main-color); + caret-color: var(--text-color-main); border-radius: 15px; +} */ + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + -webkit-background-clip: text; + -webkit-text-fill-color: var(--text-color-main); + transition: all 5000s ease-in-out 0s; + box-shadow: inset 0 0 0 100px var(--new-ui-main-color); + caret-color: var(--text-color-main); + border-radius: 15px; + color: var(--text-color-main) !important; } /* Изменение цвета полоски сверху выпадающего списка */ @@ -171,10 +186,8 @@ p { /* Изменение цвета активного элемента в выпадающем списке */ .MuiAutocomplete-option.Mui-selected { - background-color: var(--new-ui-main-color); - /* Замените #your-selected-color на цвет активного элемента */ - color: var(--new-ui-main-color); - /* Замените #your-selected-text-color на цвет текста активного элемента */ + background-color: var(--new-ui-main-color); /* Замените #your-selected-color на цвет активного элемента */ + color: var(--new-ui-main-color); /* Замените #your-selected-text-color на цвет текста активного элемента */ } .introjs-tooltiptext { @@ -440,6 +453,26 @@ textarea { transition-duration: 250ms; } +button { + padding: 0; + border: none; + font: inherit; + color: inherit; + background-color: transparent; + text-align: left; + cursor: pointer; +} + +button { + padding: 0; + border: none; + font: inherit; + color: inherit; + background-color: transparent; + text-align: left; + cursor: pointer; +} + .my-node-enter { opacity: 0; } @@ -0,0 +1,3 @@ + + + \ No newline at end of file @@ -0,0 +1,10 @@ + + + \ No newline at end of file @@ -0,0 +1,4 @@ + + + + \ No newline at end of file @@ -0,0 +1,8 @@ + + + + \ No newline at end of file @@ -0,0 +1,8 @@ + + + + \ No newline at end of file @@ -150,6 +150,7 @@ background-color: rgba(0, 0, 0, 0.7); border-radius: 15px; padding-right: 20px; + z-index: 1; span { color: white; @@ -1,8 +1,8 @@ import axios from 'axios' import { API_URL } from '#/shared/lib/constants' +import { IUserSetting } from '../types' -import { IUserSetting } from '../model/types' export const addUserSettings = async ( token: string, @@ -1,8 +1,8 @@ import axios, { AxiosResponse } from 'axios' import { API_URL } from '#/shared/lib/constants' +import { AccountType } from '../types' -import { AccountType } from '../model/types' export const getAccountType = async (token: string | null | undefined): Promise => { if (!token) { return 'regular' @@ -1,8 +1,8 @@ import axios from 'axios' import { API_URL } from '#/shared/lib/constants' +import { IUserSetting } from '../types' -import { IUserSetting } from '../model/types' export const getUserSettings = async (token: string): Promise => { try { @@ -3,7 +3,7 @@ import axios from 'axios' import { API_URL } from '#/shared/lib/constants' import { getUpdatedSettingsLocal } from '../lib/helpers/update-setting-local' -import { IUserSetting, SettingValueType } from '../model/types' +import { IUserSetting, SettingValueType } from '../types' export const updateUserSettings = async ( token: string, @@ -1,6 +1,6 @@ import { Device } from '#/shared/lib/types/entities' -import { IUserSetting, SettingType } from '../../model/types' +import { IUserSetting, SettingType } from '../../types' interface IProps { settings: Omit | null @@ -1,4 +1,4 @@ -import { IUserSetting, SettingValueType } from '../../model/types' +import { IUserSetting, SettingValueType } from '../../types' interface IProps { settings: IUserSetting[] @@ -0,0 +1,3 @@ +export * from './settings' +export * from './user-type-slice' +export * from './use-user-selector' \ No newline at end of file @@ -5,7 +5,7 @@ import { getUserSettings } from '../api/get-user-settings' import { updateUserSettings } from '../api/update-user-settings' import { getUpdatedSettingsLocal } from '../lib/helpers/update-setting-local' -import { IUserSetting, SettingValueType } from './types' +import { IUserSetting, SettingValueType } from '../types' interface IAddSettings { token: string | undefined | null @@ -1,16 +0,0 @@ -import { Device } from '#/shared/lib/types/entities' - -export type AccountType = 'regular' | 'business_host' | 'business_account' - -export type SettingType = 'sidemenu' - -export type SettingValueType = { - sidemenu_state?: 'opened' | 'closed' -} - -export interface IUserSetting { - id: string - device: Device - type: SettingType - value: SettingValueType -} @@ -0,0 +1,4 @@ +import { useAppSelector } from "#/app/store/store"; +import { UserDTO } from "../types"; + +export const useUserSelector = () => useAppSelector((state) => state.user.user) as UserDTO @@ -2,96 +2,22 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' import { createAction } from '@reduxjs/toolkit/src' import axios, { AxiosResponse } from 'axios' -import { loadingThunk } from '#/app/store/store' +import { loadingThunk, useAppSelector } from '#/app/store/store' import { API_URL } from '#/shared/lib/constants' +import { UserDTO } from '../types' type UserState = { status: loadingThunk - referral: string + user: UserDTO | null } -export type ResponseAllInfo = { - uid: string - first_name: string - last_name: string - username: string - created_at: string - email: string - is_active: boolean - is_staff: boolean - show_balance: boolean - is_confirmed: boolean - is_social: boolean - social_auth: string[] - is_subscribed_to_emails: boolean - profile_picture_link: string | null - account_type: string - referral_code: { - code: string - } - token: { - access: string - refresh: string - } - payment_plan: { - uid: string - plan: { - uid: string - price: string - tokens_per_plan: string - title: string - duration: string - accessed_models: string[] | null - } - last_payment_at: string - next_payment_at: string - current_token_balance: number - } -} - -const initialState: UserState & ResponseAllInfo = { +const initialState: UserState = { status: 'idle', - referral: '', - uid: '', - first_name: '', - last_name: '', - username: '', - created_at: '', - email: '', - show_balance: true, - is_active: false, - social_auth: [], - is_staff: false, - is_confirmed: false, - referral_code: { - code: '', - }, - token: { - access: '', - refresh: '', - }, - is_subscribed_to_emails: false, - is_social: false, - profile_picture_link: null, - account_type: 'regular', - payment_plan: { - uid: '', - plan: { - uid: '', - price: '', - tokens_per_plan: '', - duration: '', - title: '', - accessed_models: null, - }, - last_payment_at: '', - next_payment_at: '', - current_token_balance: 0, - }, + user: null, } -export const getAll = async (token: string | null | undefined): Promise => { - const { data } = await axios.get>(API_URL + '/auth/me', { +export const getAll = async (token: string | null | undefined): Promise => { + const { data } = await axios.get>(API_URL + '/auth/me', { headers: { Authorization: `Bearer ${token}`, }, @@ -122,48 +48,11 @@ export const unfollowEmail = createAsyncThunk( export const userSlice = createSlice({ name: 'userSlice', initialState, - reducers: { - addReferral: (state, action) => { - state.referral = action.payload - }, - }, + reducers: {}, extraReducers: (builder) => { builder.addCase(getAllInfo.fulfilled, (state, action) => { state.status = 'succeeded' - state.referral = '' - const { - uid, - is_subscribed_to_emails, - email, - account_type, - first_name, - last_name, - profile_picture_link, - is_active, - is_staff, - created_at, - username, - payment_plan, - is_confirmed, - is_social, - referral_code, - show_balance, - } = action.payload - state.is_subscribed_to_emails = is_subscribed_to_emails - state.account_type = account_type - state.email = email - state.payment_plan = payment_plan - state.is_confirmed = is_confirmed - state.first_name = first_name - state.last_name = last_name - state.profile_picture_link = profile_picture_link - state.is_active = is_active - state.is_staff = is_staff - state.created_at = created_at - state.username = username - state.is_social = is_social - state.referral_code = referral_code - state.show_balance = show_balance + state.user = action.payload }) builder.addCase(getAllInfo.pending, (state) => { state.status = 'pending' @@ -171,10 +60,12 @@ export const userSlice = createSlice({ builder.addCase(getAllInfo.rejected, (state) => { state.status = 'failed' }) - builder.addCase(unfollowEmail.fulfilled, (state, action) => { - state.is_subscribed_to_emails = !state.is_subscribed_to_emails + builder.addCase(unfollowEmail.fulfilled, ({ user }, action) => { + if (!user) return + user.is_subscribed_to_emails = !user.is_subscribed_to_emails }) }, }) -export const { addReferral } = userSlice.actions +export const {} = userSlice.actions + @@ -0,0 +1,74 @@ +import { Device } from '#/shared/lib/types/entities' + +export type UserDTO = { + uid: string + first_name: string + last_name: string + username: string + created_at: string + email: string + is_active: boolean + is_staff: boolean + show_balance: boolean + is_confirmed: boolean + is_social: boolean + social_auth: string[] + is_subscribed_to_emails: boolean + profile_picture_link: string | null + account_type: string + referral_code: { + code: string + } + token: { + access: string + refresh: string + } + payment_plan: { + uid: string + plan: { + uid: string + price: string + tokens_per_plan: string + title: string + duration: string + accessed_models: string[] | null + } + last_payment_at: string + next_payment_at: string + current_token_balance: number + } +} + +export type AccountType = 'regular' | 'business_host' | 'business_account' + +export type SettingType = 'sidemenu' + +export type SettingValueType = { + sidemenu_state?: 'opened' | 'closed' +} + +export interface IUserSetting { + id: string + device: Device + type: SettingType + value: SettingValueType +} + +export interface CreateUserUtmDTO { + utm_source: string + utm_medium: string + utm_campaign: string + utm_term: string + utm_content: string +} + +export interface CreateUserDTO { + email: string + password: string +} + +export interface CreateUserRefererDTO { + referer?: string | null +} + +export type CreateUserWithRelations = CreateUserDTO & CreateUserRefererDTO & CreateUserUtmDTO & {} @@ -0,0 +1 @@ +export * from './dto.user' \ No newline at end of file @@ -1 +1,3 @@ export { getAllInfo, userSlice } from './model/user-type-slice' +export * from './types' +export * from './model' \ No newline at end of file @@ -9,7 +9,6 @@ import { useAppSelector } from '#/app/store/store' import { ButtonGray, ButtonUI, Error, InputStyleDark, InputStyleLight, Loader, Modal } from '#/shared' import { accountApi } from '#/shared/api/account-endpoints' import { API_URL } from '#/shared/lib/constants' -import { useShowData } from '#/shared/lib/hooks' import { DateInput } from '#/shared/ui/date-input/date-input' import styles from '../invite-person-in-business/ui/invite-modal.module.scss' @@ -29,8 +28,6 @@ export const ApiKeyModal = ({ const [title, setTitle] = useState('') const theme = useAppSelector((state) => state.theme.theme) - const { error, showError } = useShowData() - const [isLoading, setIsLoading] = useState(false) const { data, status } = useSession() @@ -101,7 +98,6 @@ export const ApiKeyModal = ({ )} - ) } @@ -3,6 +3,8 @@ export type PersonInBusiness = 'business_host' | 'business_account' | 'business_ export type AcceptanceStatus = 'pending' | 'accepted' | 'rejected' | 'cancelled' export type ResponseGetPersons = { + detail?: string + uid: string email: string account_type: PersonInBusiness acceptance_status: AcceptanceStatus @@ -22,6 +24,7 @@ export type ResponseGetLogsList = { } export type ResponseGetBusinessGroups = { + detail?: string uid: string title: string token_limit: string @@ -9,10 +9,10 @@ import { ResponseGetBusinessGroups } from '#/features/business-group/model/types import { invitePerson } from '#/features/invite-person-in-business/api/invite-person' import { IEmailForms } from '#/features/register-by-email/model/types' import { useAppSelector } from '#/app/store/store' -import { ButtonGray, ButtonUI, Error, InputStyleDark, InputStyleLight, Loader, Modal, ModalProps } from '#/shared' -import { useShowData } from '#/shared/lib/hooks' +import { ButtonGray, ButtonUI, InputStyleDark, InputStyleLight, Loader, Modal, ModalProps } from '#/shared' import styles from './add-business-group.module.scss' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' interface InviteModalProps extends ModalProps { // showNewPersons: (persons: ResponseGetPersons) => void @@ -29,7 +29,7 @@ export const AddBusinessGroup: FC = ({ open, onClose, company_ const [limit, setLimit] = useState('0') const [title, setTitle] = useState('') const theme = useAppSelector((state) => state.theme.theme) - const { error, showError } = useShowData() + const { showMessage } = useShowDataStore() const { data: session } = useSession() const onSubmit = async (data: any) => { @@ -37,7 +37,7 @@ export const AddBusinessGroup: FC = ({ open, onClose, company_ const res = await addBusinessGroup(title, limit, company_uid, session?.access) if (res === null) { - showError('Что-то пошло не так') + showMessage('Что-то пошло не так') setIsLoading(false) return } @@ -52,11 +52,9 @@ export const AddBusinessGroup: FC = ({ open, onClose, company_ onClose() reset() - - // showNewPersons(res) } const checkError: SubmitErrorHandler = (data) => { - showError(Object.values(data)[0].message || 'Неверные данные') + showMessage(Object.values(data)[0].message || 'Неверные данные') } const handleEmailChange = (event: any) => { @@ -111,7 +109,6 @@ export const AddBusinessGroup: FC = ({ open, onClose, company_ )} - ) } @@ -16,9 +16,9 @@ import { IEmailForms } from '#/features/register-by-email/model/types' import { useAppSelector } from '#/app/store/store' import { ButtonGray, ButtonUI, Error, InputStyleDark, InputStyleLight, Loader, Modal, ModalProps } from '#/shared' import { API_URL } from '#/shared/lib/constants' -import { useShowData } from '#/shared/lib/hooks' import styles from './add-business-group.module.scss' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' interface InviteModalProps extends ModalProps { // showNewPersons: (persons: ResponseGetPersons) => void @@ -30,14 +30,7 @@ interface InviteModalProps extends ModalProps { current_group: ResponseGetBusinessGroups } -export const ChangeBusinessGroup: FC = ({ - open, - onClose, - company_uid, - current_group, - setChanges, - changes, -}) => { +export const ChangeBusinessGroup: FC = ({ open, onClose, company_uid, current_group, setChanges, changes }) => { const { handleSubmit, register, reset, setValue, getValues } = useForm() const [isLoading, setIsLoading] = useState(false) const [limit, setLimit] = useState('') @@ -45,7 +38,7 @@ export const ChangeBusinessGroup: FC = ({ const [accounts, setAccounts] = useState() const [addUsers, setAddUsers] = useState() const theme = useAppSelector((state) => state.theme.theme) - const { error, showError } = useShowData() + const { showMessage } = useShowDataStore() const { data: session } = useSession() useEffect(() => { @@ -53,7 +46,7 @@ export const ChangeBusinessGroup: FC = ({ if (res !== null) { setTitle(res.title) setLimit(res.token_limit) - setAccounts(res.accounts) + setAccounts(res.accounts as any) } }) }, [current_group, session?.access]) @@ -93,7 +86,7 @@ export const ChangeBusinessGroup: FC = ({ const res = await changeBusinessGroup(title, limit, current_group.uid, session?.access) if (res === null) { - showError('Что-то пошло не так') + showMessage('Что-то пошло не так') setIsLoading(false) return } else { @@ -113,7 +106,7 @@ export const ChangeBusinessGroup: FC = ({ reset() } const checkError: SubmitErrorHandler = (data) => { - showError(Object.values(data)[0].message || 'Неверные данные') + showMessage(Object.values(data)[0].message || 'Неверные данные') } return ( @@ -211,7 +204,6 @@ export const ChangeBusinessGroup: FC = ({ )} - ) } @@ -7,23 +7,14 @@ import { useSession } from 'next-auth/react' import { ButtonGray, ButtonUI, Error, Loader, Modal } from '#/shared' import { API_URL } from '#/shared/lib/constants' -import { useShowData } from '#/shared/lib/hooks' import { DateInput } from '#/shared/ui/date-input/date-input' import styles from './invite-modal.module.scss' -export const DownloadModal = ({ - open, - setOpen, -}: { - open: boolean - setOpen: React.Dispatch> -}) => { +export const DownloadModal = ({ open, setOpen }: { open: boolean; setOpen: React.Dispatch> }) => { const [endDate, setEndDate] = useState() const [startDate, setStartDate] = useState() - const { error, showError } = useShowData() - const [isLoading, setIsLoading] = useState(false) const { data: session } = useSession() @@ -32,9 +23,7 @@ export const DownloadModal = ({ setIsLoading(true) axios.get( API_URL + - `/auth/business-security/download-report?${startDate ? `start_date=${startDate}` : ''}${ - endDate ? `&end_date=${endDate}` : '' - }`, + `/auth/business-security/download-report?${startDate ? `start_date=${startDate}` : ''}${endDate ? `&end_date=${endDate}` : ''}`, { responseType: 'arraybuffer', headers: { Authorization: `Bearer ${session?.access}` }, @@ -76,7 +65,6 @@ export const DownloadModal = ({ )} - ) } @@ -0,0 +1,19 @@ +import axios from 'axios' + +import { API_URL } from '#/shared/lib/constants' + +export const changePassword = async (email: string, password: string, newPassword: string, token?: string) => { + return await axios.patch( + API_URL + `/auth/business-host/account/change-pass/${email}`, + { + password_1: password, + password_2: newPassword, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + validateStatus: (status) => status < 500 + } + ) +} @@ -0,0 +1 @@ +const ERRORS_TRANSLATE = {'Passwords don\'t match': ''} \ No newline at end of file @@ -0,0 +1,56 @@ +import { useSession } from 'next-auth/react' +import { changePassword } from '../api/change-password' +import { useState } from 'react' +import { getModalById, PLATE_CHANGE_PASSWORD } from '#/features/modals' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { useForm } from 'react-hook-form' + +interface ChangePasswordForm { + password: string + newPassword: string +} + +export const useChangePassword = () => { + const modal = getModalById(PLATE_CHANGE_PASSWORD) + const { + register, + formState: { errors }, + reset, + trigger, + watch, + handleSubmit, + setError, + setValue, + } = useForm() + const { showMessage } = useShowDataStore() + const { data: session } = useSession() + const [passwordShowed, setPasswordShowed] = useState(false) + + const onSubmit = handleSubmit(async (data, event) => { + if (!session) return + + if (data.password !== data.newPassword) { + showMessage('Пароли не совпадают!', 'error') + setError('password', { message: 'Пароли не совпадают' }) + return setError('newPassword', { message: 'Пароли не совпадают' }) + } + + const { data: res, status } = await changePassword( + modal.getStoreProperty('email')!, + data.password, + data.newPassword, + session.access + ) + + if (status == 200) { + showMessage('Пароль изменен успешно!', 'success') + modal.setState(false) + return + } + + showMessage((res as { detail: string }).detail) + reset() + }) + + return { onSubmit, register, watch, reset, errors, trigger, setValue, passwordShowed, setPasswordShowed } +} @@ -0,0 +1,4 @@ +interface ResponseChangePassword { + data: any + status: number +} @@ -1,7 +1,30 @@ -.modalContainer { - min-width: 300px; - min-height: 200px; - background: var(--background-color-additional); - border-radius: 15px; - padding: 15px; -} \ No newline at end of file +.container { + padding-bottom: 0 !important; +} + +.modal { + min-width: 450px; + width: 100%; + + @media screen and (max-width: 600px) { + min-width: 350px; + } + + &__buttons { + display: flex; + margin-top: 45px; + gap: 10px; + } + &__header { + margin: 35px 0px 35px 0px; + font-size: 30px; + font-weight: 600; + letter-spacing: -0.02em; + } +} + +.box { + display: flex; + flex-direction: column; + gap: 25px; +} @@ -0,0 +1,98 @@ +import { getModalById, PLATE_CHANGE_PASSWORD, PlateTemplate } from '#/features/modals' +import styles from './change-password.module.scss' +import React, { useEffect } from 'react' +import { CommonButton } from '#/shared/ui/button' +import { CommonInput } from '#/shared/ui/common-input' +import { useChangePassword } from '../lib/use-change-password' +import OpenedEyeSvg from '#/assets/svg/opened-eye.svg?react' +import ClosedEyeSvg from '#/assets/svg/closed-eye.svg?react' + +export const ChangePasswordPlate = () => { + const modal = getModalById(PLATE_CHANGE_PASSWORD) + const { onSubmit, register, errors, reset, trigger, watch, setValue, passwordShowed, setPasswordShowed } = useChangePassword() + + return ( + { + reset() + }} + > +
+

Смена пароля

+
+ { + setValue('password', e.target.value) + trigger('password') + }} + label='Новый пароль' + variant='outline' + placeholder='Пароль' + autoComplete='new-password' + error={errors.password} + type={passwordShowed ? '' : 'password'} + rightSlot={ + + } + // onInput={() => trigger('password')} + /> + { + setValue('newPassword', e.target.value) + trigger('newPassword') + }} + label='Повторить пароль' + variant='outline' + placeholder='Повторить пароль' + type={passwordShowed ? '' : 'password'} + rightSlot={ + + } + error={errors.newPassword} + // onInput={() => trigger('password')} + /> +
+
+ + Сменить пароль + + { + modal.setState(false) + reset() + }} + type='button' + > + Отмена + +
+
+
+ ) +} @@ -0,0 +1 @@ +export * from './change-password' \ No newline at end of file @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -1,4 +1,4 @@ -import { useShowData } from '#/shared' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { MessageSend } from '#/shared/lib/types/model' import { useState, ChangeEvent } from 'react' @@ -9,7 +9,7 @@ export function useImagesUniqInput( ) { const [image, setImage] = useState(null) - const { error, showError } = useShowData() + const { showMessage } = useShowDataStore() function onLoadImage(event: ChangeEvent) { if (event.target.files) { @@ -20,15 +20,15 @@ export function useImagesUniqInput( function onCreateImage(input: string, required: (string | null)[]) { // про switch не слышали люди)) if (required.includes('text') && (input === '' || input === null)) { - showError('Введите сообщение!') + showMessage('Введите сообщение!') return false } if (required.includes('image') && image === null) { - showError('Прикрепите изображение!') + showMessage('Прикрепите изображение!') return false } if (required.includes('zip') && image === null) { - showError('Прикрепите архив!') + showMessage('Прикрепите архив!') return false } @@ -1,6 +1,5 @@ import { useAppSelector } from '#/app/store/store' import { getImagesBySlug, Message } from '#/entities/message' -import { useShowData } from '#/shared' import { Device } from '#/shared/lib/types/entities' import { getImagesGalery } from '#/widgets/messages' import { useMediaQuery } from '@mui/material' @@ -8,6 +7,7 @@ import { useSession } from 'next-auth/react' import { useEffect, useRef, useState } from 'react' import { LimitSize, Limit } from '../types' import { useRouter } from 'next/router' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' export function useImageBotPagination(deviceType: Device) { const refScrollMobile = useRef(null) @@ -24,7 +24,7 @@ export function useImageBotPagination(deviceType: Device) { const [loading, setLoading] = useState(true) - const { showError } = useShowData() + const { showMessage } = useShowDataStore() const { data } = useSession() @@ -59,7 +59,7 @@ export function useImageBotPagination(deviceType: Device) { ) if (response.status >= 400 || !Array.isArray(answer)) - return showError('Ошибка загрузки чата') + return showMessage('Ошибка загрузки чата') if (deviceType === 'desktop') { setMessages((prev) => [...prev, ...answer]) @@ -10,24 +10,18 @@ export const invitePerson = async ( email: string, group?: string, token?: string -): Promise => { - try { - const { data } = await axios.post<{ email: string; token_limit: string }, AxiosResponse>( - API_URL + '/auth/business-host', - { - parent_company: group === '' ? null : group, - email, - account_privileges: Object.entries(inviteRoles).find(([key, value]) => value === role)![0], +) => { + return await axios.post<{ email: string; token_limit: string }, AxiosResponse>( + API_URL + '/auth/business-host', + { + parent_company: group === '' ? null : group, + email, + account_privileges: Object.entries(inviteRoles).find(([key, value]) => value === role)![0], + }, + { + headers: { + Authorization: `Bearer ${token}`, }, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ) - - return data - } catch (err) { - return null - } + } + ) } @@ -3,6 +3,8 @@ export type PersonInBusiness = 'business_host' | 'business_account' | 'business_ export type AcceptanceStatus = 'pending' | 'accepted' | 'rejected' | 'cancelled' export type ResponseGetPersons = { + detail?: string + uid: string email: string account_type: PersonInBusiness acceptance_status: AcceptanceStatus @@ -22,6 +24,7 @@ export type ResponseGetLogsList = { } export type ResponseGetBusinessGroups = { + detail?: string uid: string title: string token_limit: string @@ -20,13 +20,13 @@ import { onlyNumbersOption, Select, } from '#/shared' -import { useShowData } from '#/shared/lib/hooks' import { getBusinessGroups } from '#/widgets/business-persons/api/get-businessGroups' import { InviteRoles, inviteRoles } from '../lib/constants' import { ResponseGetBusinessGroups, ResponseGetPersons } from '../model/types' import styles from './invite-modal.module.scss' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' interface InviteModalProps extends ModalProps { showNewPersons: (persons: ResponseGetPersons) => void @@ -34,10 +34,10 @@ interface InviteModalProps extends ModalProps { export const InviteModal: FC = ({ open, onClose, showNewPersons }) => { const [role, setRole] = useState('Сотрудник') const { handleSubmit, register, reset, setValue, getValues } = useForm() - const { error, showError } = useShowData() const [isLoading, setIsLoading] = useState(false) const [businessGroups, setBusinessGroups] = useState() const [currentGroup, setCurrentGroup] = useState('') + const { showMessage } = useShowDataStore() const { data: session } = useSession() const onSubmit = async (data: any) => { @@ -46,20 +46,26 @@ export const InviteModal: FC = ({ open, onClose, showNewPerson const group: string[] | undefined = businessGroups?.map((el) => (el.title === currentGroup ? el.uid : '')) const res = await invitePerson(role, data.limit, data.email, group && group[0], session?.access) + if (res && res.data.detail && res.status >= 300) { + showMessage(res.data.detail) + setIsLoading(false) + return + } if (res === null) { - showError('Что-то пошло не так') + showMessage('Что-то пошло не так') setIsLoading(false) return } + setIsLoading(false) onClose() reset() - showNewPersons(res) + showNewPersons(res.data) } const checkError: SubmitErrorHandler = (data) => { - showError(Object.values(data)[0].message || 'Неверные данные') + showMessage(Object.values(data)[0].message || 'Неверные данные') } const handleEmailChange = (event: any) => { @@ -85,11 +91,7 @@ export const InviteModal: FC = ({ open, onClose, showNewPerson {/* Поле с выбором роли сотрудника */} Выберите роль - + {/* Поле с выбором группы */} Выберите бизнес-группу @@ -125,7 +127,6 @@ export const InviteModal: FC = ({ open, onClose, showNewPerson )} - ) } @@ -0,0 +1 @@ +export * from './use-login-cookies' \ No newline at end of file @@ -0,0 +1,17 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { useCookies } from 'react-cookie' + +export function useLoginCookies() { + const [, setCookie] = useCookies() + + const { query } = useRouter() + + function setAllQueryToCookies() { + Object.entries(query).forEach(([key, value]) => setCookie(key, value)) + } + + return { + setAllQueryToCookies + } +} @@ -0,0 +1 @@ +export * from './model' \ No newline at end of file @@ -0,0 +1,2 @@ +export * from './use-login-form' +export * from './use-login-validate' \ No newline at end of file @@ -0,0 +1,58 @@ +import { useRouter } from 'next/router' +import { useForm } from 'react-hook-form' +import { LoginForm } from '../types' +import { useEffect, useState } from 'react' +import { signIn } from 'next-auth/react' +import { useShowDataStore } from '#/shared/lib/hooks' +import { captureMessage } from '@sentry/nextjs' +import { ERROR_MAPPING } from '#/pages/api/auth/constants' +import { keysToValues } from '#/shared' + +export function useLoginForm() { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm() + + const [pending, setPending] = useState(false) + + const { showMessage } = useShowDataStore() + + const { push } = useRouter() + + useEffect(() => { + if (Object.values(errors).length === 0) return + showMessage(Object.values(errors)[0].message || 'Неверные данные') + }, [errors]) + + const onSubmit = handleSubmit(async (data: any) => { + setPending(true) + + const response = await signIn('credentials', { + username: data.email, + password: data.password, + redirect: false, + }) + + setPending(false) + + const { ok, error } = response! + + if (ok) return push('/') + + if (!error) return showMessage('Не удалось выполнить вход, попробуйте позже') + + const expectedError = keysToValues(ERROR_MAPPING)[error] + + if (!expectedError) return captureMessage(error) + + showMessage(expectedError) + }) + + return { + onSubmit, + register, + pending, + } +} @@ -0,0 +1,30 @@ +import { RegisterOptions } from 'react-hook-form' +import { EMAIL_REGEXP } from '#/shared/lib/constants' +import { LoginForm } from '../types' + +export function useLoginValidate() { + const emailOptions: RegisterOptions = { + required: 'Поле email обязательно к заполенению!', + minLength: { + value: 5, + message: 'Слишком короткий email', + }, + pattern: { + value: EMAIL_REGEXP, + message: 'Введите валидный email', + }, + } + + const passwordOptions: RegisterOptions = { + required: 'Поле пароль обязательно к заполнению!', + minLength: { + value: 5, + message: 'Слишком короткий пароль', + }, + } + + return { + emailOptions, + passwordOptions + } +} @@ -0,0 +1 @@ +export * from './login-form' \ No newline at end of file @@ -0,0 +1,3 @@ +import { CreateUserDTO } from '#/entities/user-account' + +export interface LoginForm extends CreateUserDTO {} @@ -0,0 +1 @@ +export * from './model' \ No newline at end of file @@ -0,0 +1 @@ +export * from './use-login-query-errors' \ No newline at end of file @@ -0,0 +1,23 @@ +import { ERRROR_YANDEX_TRANSLATE_MAPPING } from '#/pages/api/auth/constants' +import { useShowDataStore } from '#/shared/lib/hooks' +import { captureMessage } from '@sentry/nextjs' + +export function useLoginQueryErrors() { + const { showMessage } = useShowDataStore() + + function onMounted() { + const params = new URL(window.location.href).searchParams + + const error = params.get('error') + + if (!error || error === '') return + + const expectedError = ERRROR_YANDEX_TRANSLATE_MAPPING[error] + showMessage(expectedError ?? 'Не удалось выполнить вход, попробуйте позже') + if (!expectedError) captureMessage(error) + } + + return { + onMounted + } +} @@ -0,0 +1 @@ +export * from './model' \ No newline at end of file @@ -0,0 +1 @@ +export * from './modals.config' \ No newline at end of file @@ -0,0 +1,2 @@ +export const PLATE_CHANGE_PASSWORD = 'plate-change-password' +export const RESEND_INVATION_PASSWORD = 'resend-invation-password' @@ -0,0 +1 @@ +export * from './store.modals' \ No newline at end of file @@ -0,0 +1,78 @@ +import { create } from "zustand"; + +type Set = { + (partial: (state: PlatesStore) => Partial): void; +}; + +type Get = () => PlatesStore; + +function makeModalInstance(set: Set, get: Get, key: string) { + const ModalInstance: Modal = { + id: key, + state: false, + store: {}, + setState(state, store) { + const modals = get().modals; + + store = store ?? {}; + + set(() => ({ + modals: { ...modals, [this.id]: { ...modals[key], state, store } }, + })); + }, + setStoreProperty(key, value) { + const modals = get().modals; + + this.store[key] = value; + + set(() => ({ + modals: { ...modals, [this.id]: { ...modals[key], store: this.store } }, + })); + }, + getStoreProperty(key) { + return this.store[key]; + }, + }; + + return ModalInstance; +} + +export interface Modal { + id: string; + state: boolean; + store: Record; + setState: (state: boolean, store?: Record) => void; + setStoreProperty: (key: string, value: any) => void; + getStoreProperty: (key: string) => T | undefined; +} + +export interface PlatesStore { + modals: Record; + setModal: (key: string) => void; + getModal: (key: string) => Modal; +} + +export const usePlatesStore = create((set, get) => { + const modals: Record = {}; + + function setModal(key: string) { + set((state) => ({ + modals: { + ...state.modals, + [key]: Object.assign({}, makeModalInstance(set, get, key)), + }, + })); + } + + function getModal(key: string) { + return ( + get().modals[key] ?? Object.assign({}, makeModalInstance(set, get, key)) + ); + } + + return { + modals, + setModal, + getModal, + }; +}); @@ -0,0 +1 @@ +export { default as PlateTemplate } from "./template"; \ No newline at end of file @@ -0,0 +1,126 @@ +.template { + position: fixed; + width: 100%; + max-width: calc(100dvw); + height: 100dvh; + background-color: rgba(0, 0, 0, 0.4); + top: 0; + left: 0; + transition: all 300ms ease-in-out; + display: flex; + overflow: hidden; + opacity: 0; + visibility: hidden; + + &_visible { + opacity: 1; + visibility: visible; + z-index: 9999; + } + + &_invisible { + opacity: 0; + visibility: hidden; + z-index: -1; + } + + &_align-x-left { + justify-content: flex-start; + + > * { + margin-left: 10px; + } + } + + &_align-x-center { + justify-content: center; + } + + &_align-x-right { + justify-content: flex-end; + + > * { + margin-right: 10px; + } + } + + &_align-y-top { + align-items: flex-start; + + > * { + margin-top: 10px; + } + } + + &_align-y-center { + align-items: center; + } + + &_align-y-bottom { + align-items: flex-end; + + > * { + margin-bottom: 10px; + } + } + + &__content { + background-color: var(--new-ui-main-color); + border-radius: 20px; + position: relative; + overflow: hidden; + height: fit-content; + transition: all 0.5s ease-in-out; + + &_secondary { + background-color: #373737; + } + } + + // &__header { + // display: flex; + // justify-content: space-between; + // padding: 25px; + // } + + &__close-button { + top: 25px; + right: 25px; + position: absolute; + z-index: 1; + transform: rotate(45deg); + } + + &__content-body { + transition: all 0.3s ease-in-out; + padding: 0px 32px 32px 32px; + overflow: hidden; + + @media screen and (max-width: 1000px) { + padding: 0px 10px 10px 10px; + } + } + + &__animation { + transition: all 0.3s ease-in-out; + } + + &__animation-behavior { + opacity: 0; + } +} + +html[data-theme='light'] { + .template__content { + background-color: var(--new-ui-main-color); + border-radius: 20px; + position: relative; + overflow: hidden; + height: fit-content; + transition: all 0.5s ease-in-out; + + &_secondary { + background-color: white; + } + } +} @@ -0,0 +1,90 @@ +'use client' +import { useEffect } from 'react' +import { c } from '#/shared' +import { usePlatesStore } from '../model' +import styles from './template.module.scss' + +import PlusIcon from '#/assets/svg/plus.svg?react' +import { useThemeAndDevice } from '#/shared/lib/hooks' + +export type AlignX = 'left' | 'center' | 'right' +export type AlignY = 'top' | 'center' | 'bottom' +export type Variant = 'primary' | 'secondary' + +export interface PlatesTemplateProps { + id: string + children: React.ReactNode + title?: string + closeModal?: () => void + hasTemplate?: boolean + hasBg?: boolean + alignX?: AlignX + alignY?: AlignY + animationClass?: string + animationBehaviorClass?: string + headerClassName?: string + variant?: Variant +} + +export default function PlatesTemplate({ + id, + title, + children, + alignX = 'center', + alignY = 'center', + hasTemplate = true, + hasBg = true, + animationClass = styles['template__animation'], + animationBehaviorClass = styles['template__animation-behavior'], + closeModal = () => {}, + headerClassName, + variant = 'primary', +}: PlatesTemplateProps) { + const { setModal, getModal } = usePlatesStore() + + const modal = getModal(id) + + const { theme } = useThemeAndDevice() + + useEffect(() => setModal(id), []) + + return ( +
{ + if (hasBg) modal.setState(false) + }} + > + {hasTemplate ? ( +
e.stopPropagation()} + > + +
{children}
+
+ ) : ( +
{children}
+ )} +
+ ) +} @@ -0,0 +1,9 @@ +import { usePlatesStore } from './model'; + +export function getModalById(id: string) { + const { getModal } = usePlatesStore(); + return getModal(id); +} + +export * from './ui' +export * from './config' \ No newline at end of file @@ -0,0 +1 @@ +export * from './use-referal-info' \ No newline at end of file @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react' + +export function useReferrall() { + const [referral, setReferral] = useState(null) + + useEffect(() => setReferral(localStorage.getItem('referral')), []) + + return { + referral, + setReferral, + } +} @@ -0,0 +1 @@ +export * from './model' \ No newline at end of file @@ -11,15 +11,17 @@ import { useAppDispatch, useAppSelector } from '#/app/store/store' import { ButtonUI, Error, InputStyleDark, InputStyleLight } from '#/shared' import { emailOptions } from '#/shared' import { phoneOptions } from '#/shared/lib/constants/hook-form-options' -import { useShowData } from '#/shared/lib/hooks' import { nameOptions } from '../../lib/constants-step-contact' import { createBusinessCompany, setContact } from '../../model/stepper-slice' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' export const StepContact = () => { - const { phone, email, name, job_title } = useAppSelector((state) => state.stepper.dataForCreate) + const { phone, email, name, job_title } = useAppSelector( + (state) => state.stepper.dataForCreate + ) const theme = useAppSelector((state) => state.theme.theme) - const { error, showError } = useShowData() + const { showMessage } = useShowDataStore() const methods = useForm({ defaultValues: { phone, @@ -31,7 +33,7 @@ export const StepContact = () => { const dispatch = useAppDispatch() const checkError: SubmitErrorHandler = (data) => { - showError(Object.values(data)[0].message || 'Неверные данные') + showMessage(Object.values(data)[0].message || 'Неверные данные') } const { data: session } = useSession() @@ -44,10 +46,25 @@ export const StepContact = () => { return (
- Как в вам обращаться? + Как вам обращаться? @@ -78,7 +95,6 @@ export const StepContact = () => { - ) } @@ -4,28 +4,23 @@ import { FormProvider, useForm } from 'react-hook-form' import { SubmitErrorHandler } from 'react-hook-form/dist/types/form' import { Box, TextField, Typography } from '@mui/material' -import { - FieldActivity, - FieldActivitySelect, - Frequency, - FrequencySelect, -} from '#/features/register-business/lib/constants-step-information' +import { FieldActivity, FieldActivitySelect, Frequency, FrequencySelect } from '#/features/register-business/lib/constants-step-information' import { IEmailForms } from '#/features/register-by-email/model/types' import { useAppDispatch, useAppSelector } from '#/app/store/store' import { ButtonUI, InputStyleDark, InputStyleLight } from '#/shared' import { Error } from '#/shared' -import { useShowData } from '#/shared/lib/hooks' import { SelectUI } from '#/shared/ui/select' import { setFieldActivity, setFrequency, setNumberStuff, switchNextStep } from '../../model/stepper-slice' import styles from './step-information.module.scss' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' export const StepInformation = () => { const dispatch = useAppDispatch() const { frequency, fieldActivity, numberStuff } = useAppSelector((state) => state.stepper.dataForCreate) - const { error, showError } = useShowData() + const { showMessage } = useShowDataStore() const methods = useForm({ mode: 'onSubmit', @@ -41,7 +36,7 @@ export const StepInformation = () => { } const checkError: SubmitErrorHandler = (data) => { - showError(Object.values(data)[0].message || 'Неверные данные') + showMessage(Object.values(data)[0].message || 'Неверные данные') } const theme = useAppSelector((state) => state.theme.theme) @@ -81,7 +76,6 @@ export const StepInformation = () => { - ) @@ -1,6 +1,5 @@ -// @ts-nocheck import React, { useState } from 'react' -import { useForm } from 'react-hook-form' +import { RegisterOptions, useForm } from 'react-hook-form' import { SubmitErrorHandler } from 'react-hook-form/dist/types/form' import { Autocomplete, Box, TextField, Typography } from '@mui/material' @@ -10,12 +9,16 @@ import styles from '#/features/register-business/ui/step-information/step-inform import { IEmailForms } from '#/features/register-by-email/model/types' import { useAppDispatch, useAppSelector } from '#/app/store/store' import { ButtonUI, Error, InputStyleDark, InputStyleLight } from '#/shared' -import { useShowData } from '#/shared/lib/hooks' -import { companyNameOptions, innOptions, ogrnOptions } from '../../lib/constatnts-step-legal-informative' +import { + companyNameOptions, + innOptions, + ogrnOptions, +} from '../../lib/constatnts-step-legal-informative' import { useAutoLoadingInfo } from '../../model/legal-info/use-auto-loading-info' import { useAutoSetInfo } from '../../model/legal-info/use-auto-set-info' import { setCompanyName, setInn, setOgrn, switchNextStep } from '../../model/stepper-slice' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' export const StepLegalInformative = () => { const theme = useAppSelector((state) => state.theme.theme) @@ -37,9 +40,11 @@ export const StepLegalInformative = () => { }, }) - const [rulesData, isIP] = useCheckTypeCompany(typeof selected === 'string' ? selected : selected?.value) + const [rulesData, isIP] = useCheckTypeCompany( + typeof selected === 'string' ? selected : selected?.value + ) - const { error, showError } = useShowData() + const { showMessage } = useShowDataStore() const onSubmit = (data: any, e: any) => { e.preventDefault() dispatch(setCompanyName(data.companyName)) @@ -54,7 +59,7 @@ export const StepLegalInformative = () => { const checkError: SubmitErrorHandler = (data, event) => { event!.preventDefault() - showError(Object.values(data)[0].message || 'Неверные данные') + showMessage(Object.values(data)[0].message || 'Неверные данные') } return ( @@ -70,14 +75,39 @@ export const StepLegalInformative = () => { value={selected} onChange={(event, value) => setSelected(value)} getOptionLabel={(label) => (typeof label !== 'string' ? label.value : '')} + sx={{ + '& .MuiAutocomplete-clearIndicator': { + color: theme === 'light' ? 'black' : 'while', + }, + }} renderInput={(params) => ( )} + componentsProps={{ + paper: { + sx: { + backgroundColor: '#333', // Фон выпадающего списка + color: 'white', // Цвет текста в выпадающем списке + }, + }, + }} /> @@ -85,7 +115,7 @@ export const StepLegalInformative = () => { @@ -93,7 +123,7 @@ export const StepLegalInformative = () => { @@ -101,10 +131,12 @@ export const StepLegalInformative = () => { dispatch(switchNextStep())} text='Пропустить' /> - + - ) } @@ -6,8 +6,6 @@ import Image from 'next/image' import { useRouter } from 'next/router' import { useAppDispatch, useAppSelector } from '#/app/store/store' -import { Error } from '#/shared' -import { useShowData } from '#/shared/lib/hooks' import { steps } from '../../lib/constants' import { switchPreviousStep, switchStep } from '../../model/stepper-slice' @@ -16,6 +14,7 @@ import { StepInformation } from '../step-information/step-information' import { StepLegalInformative } from '../step-legal-informative/step-legal-informative' import styles from './stepper.module.scss' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' export const Stepper = () => { const activeStep = useAppSelector((state) => state.stepper.activeStep) @@ -23,7 +22,7 @@ export const Stepper = () => { const dispatch = useAppDispatch() - const { error, showError } = useShowData() + const { showMessage } = useShowDataStore() const processCreate = useAppSelector((state) => state.stepper.loadingCreate) @@ -31,7 +30,7 @@ export const Stepper = () => { useEffect(() => { if (processCreate === 'failed') { - showError('Ошибка создания аккаунта') + showMessage('Ошибка создания аккаунта') } if (processCreate === 'succeeded') { @@ -112,7 +111,6 @@ export const Stepper = () => { )} - )} @@ -0,0 +1 @@ +export * from './register' \ No newline at end of file @@ -0,0 +1,10 @@ +import { CreateUserWithRelations } from '#/entities/user-account' +import { api } from '#/shared/api' + +export async function getWhitelist(email: string) { + return await api.get('/auth/mail/whitelist', { params: { email } }) +} + +export async function apiRegister(data: CreateUserWithRelations) { + return await api.post('/auth/register', data) +} \ No newline at end of file @@ -0,0 +1,2 @@ +export * from './use-register' +export * from './use-register-validate' \ No newline at end of file @@ -3,3 +3,4 @@ export interface IEmailForms { password1: string password2: string } + \ No newline at end of file @@ -0,0 +1,28 @@ +import { EMAIL_REGEXP } from '#/shared/lib/constants' + +export function useRegisterValidate() { + const emailOptions = { + required: 'Поле email обязательно к заполенению!', + minLength: { + value: 5, + message: 'Слишком короткий email', + }, + pattern: { + value: EMAIL_REGEXP, + message: 'Введите валидный email', + }, + } + + const passwordOptions = { + required: 'Поле пароль обязательно к заполнению!', + minLength: { + value: 5, + message: 'Слишком короткий пароль', + }, + } + + return { + emailOptions, + passwordOptions + } +} @@ -0,0 +1,78 @@ +import { useState } from 'react' +import { apiRegister, getWhitelist } from '../api' +import { SubmitErrorHandler, SubmitHandler, useForm } from 'react-hook-form' +import { RegisterForm } from '../types/register-form' +import { useShowDataStore } from '#/shared/lib/hooks' +import { useCookies } from 'react-cookie' +import { useReferrall } from '#/features/referal' +import { useRouter } from 'next/router' + +export function useRegister(successLogin: Function) { + const { register, handleSubmit, reset, setValue, watch } = useForm() + + const [pending, setPending] = useState(false) + + const { showMessage } = useShowDataStore() + + const { referral } = useReferrall() + + const [{ utm_source, utm_medium, utm_campaign }] = useCookies() + + const { push } = useRouter() + + function onSuccess() { + reset() + successLogin() + setTimeout(() => push('/login'), 7000) + } + + async function onRegister(form: RegisterForm) { + const { status, data } = await apiRegister({ + ...form, + utm_source, + utm_medium, + utm_campaign, + utm_term: utm_campaign, + utm_content: utm_campaign, + referer: referral ?? undefined, + }) + + setPending(false) + + if (status === 201) return onSuccess() + + showMessage(data.detail) + } + + const onValid: SubmitHandler = async ({ rules, confirm, password, ...data }) => { + if (password !== confirm) return showMessage('Пароли не совпадают') + + setPending(true) + + if (rules) return onRegister({ ...data, confirm, password }) + + const response = await getWhitelist(data.email) + + if (response.status !== 200) { + showMessage('Примите пользовательское соглашение') + return setPending(false) + } + + onRegister({ ...data, confirm, password }) + } + + const onInvalid: SubmitErrorHandler = (errors) => { + const error = Object.values(errors).at(0) + if (error) return showMessage(error.message as string) + } + + const onSubmit = handleSubmit(onValid, onInvalid) + + return { + onSubmit, + register, + watch, + setValue, + pending, + } +} @@ -0,0 +1,2 @@ +export * from './use-form-fields' +export * from './use-form-fields-change' \ No newline at end of file @@ -0,0 +1,32 @@ +import { fireEvent, screen } from '@testing-library/dom' +import { useFormFields } from '.' + +export function useFormFieldsChange() { + const { emailInput, passwordInput, confirmInput, spamCheckbox, rulesCheckbox, submitButton } = + useFormFields() + + function allInputsFilled() { + fireEvent.change(emailInput, { + target: { value: 'aleksander.freelancer@gmail.com' }, + }) + fireEvent.change(passwordInput, { target: { value: 'geraldisrivii' } }) + fireEvent.change(confirmInput, { target: { value: 'geraldisrivii' } }) + } + + function allFieldsFilled() { + allInputsFilled() + fireEvent.click(rulesCheckbox) + fireEvent.click(spamCheckbox) + } + + function fieldsFilledWithoutSpam() { + allInputsFilled() + fireEvent.click(rulesCheckbox) + } + + return { + allFieldsFilled, + fieldsFilledWithoutSpam, + allInputsFilled, + } +} @@ -0,0 +1,12 @@ +import { screen } from '@testing-library/dom' + +export function useFormFields() { + return { + emailInput: screen.getByTestId('email-input'), + passwordInput: screen.getByTestId('password-input'), + confirmInput: screen.getByTestId('confirm-input'), + rulesCheckbox: screen.getByTestId('rules-checkbox'), + spamCheckbox: screen.getByTestId('spam-checkbox'), + submitButton: screen.getByTestId('submit-button'), + } +} @@ -0,0 +1 @@ +export * from './register-form' \ No newline at end of file @@ -0,0 +1,7 @@ +import { CreateUserDTO } from '#/entities/user-account' + +export interface RegisterForm extends CreateUserDTO { + confirm: string + rules?: boolean + spam?: boolean +} @@ -0,0 +1 @@ +export * from './register-email-form' \ No newline at end of file @@ -0,0 +1,59 @@ +.form { + &__inputs { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; + } + + &__checkboxes { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__navigate { + padding-top: 10px; + text-align: center; + color: #a7a8bb; + font-size: 16px; + } +} + +.input{ + &__pass{ + display: flex; + align-items: center; + } +} + +.controls { + padding-top: 25px; + width: 100%; + + &__progress { + display: flex; + justify-content: center; + + span{ + color: var(--air-color); + } + } + + &__create { + width: 100%; + } +} + +.checkbox { + padding-left: 12px; + &__text { + color: #868686; + line-height: 1.5; + width: max-content; + } + + &__link { + color: var(--air-color); + } +} @@ -0,0 +1,171 @@ +import { RegisterPage } from '#/views/register' +import '@testing-library/jest-dom' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { jestRender } from '#/../jest/utils/render' +import { windowMock } from '#/../jest/utils/window-mock' +import { RegisterEmailForm } from './register-email-form' +import { api } from '#/shared/api' +import { useFormFields, useFormFieldsChange } from '../test' +import { useRouter } from '#/../__mocks__/next/router' +import { useShowDataStore } from '#/shared/lib/hooks' + +jest.mock('#/shared/api') + +jest.mock('next/router') +jest.mock('@sentry/nextjs') + +const showMessageMock = jest.fn() + +jest.mock('#/shared/lib/hooks', () => { + return { + useShowDataStore: jest.fn(() => { + return { + showMessage: showMessageMock, + } + }), + } +}) + +windowMock() + +describe('register-email-form', () => { + beforeEach(() => { + ;(api.post as jest.Mock).mockClear() + ;(api.get as jest.Mock).mockClear() + }) + + it('renders in register', () => { + jestRender() + + const form = screen.getByTestId('register-email-form') + + expect(form).toBeInTheDocument() + }) + + it('submit by button click', async () => { + await act(async () => { + jestRender( {}} />) + }) + + const { submitButton } = useFormFields() + + const { allFieldsFilled } = useFormFieldsChange() + + ;(api.post as jest.Mock).mockResolvedValue({ + status: 201, + }) + + allFieldsFilled() + + await act(async () => { + fireEvent.click(submitButton) + }) + + await waitFor(() => { + expect(api.post).toHaveBeenCalledWith('/auth/register', { + confirm: 'geraldisrivii', + email: 'aleksander.freelancer@gmail.com', + password: 'geraldisrivii', + spam: true, + }) + }) + + /** Больно долго его ждать (7000ms по timeout) - замедляет тесты (но работает) */ + // await waitFor(() => { + // const { push } = useRouter() + // expect(push).toHaveBeenCalledWith('/login') + // }, {timeout: 8000}) + }) + + it('submit by Enter', async () => { + await act(async () => { + jestRender( {}} />) + }) + ;(api.post as jest.Mock).mockResolvedValue({ + status: 201, + }) + + const { allFieldsFilled } = useFormFieldsChange() + + const { emailInput } = useFormFields() + + allFieldsFilled() + + await userEvent.type(emailInput, '{enter}') + + await waitFor(() => { + expect(api.post).toHaveBeenCalledWith('/auth/register', { + confirm: 'geraldisrivii', + email: 'aleksander.freelancer@gmail.com', + password: 'geraldisrivii', + spam: true, + }) + }) + }) + + it('submit without rules and white list not include sended email', async () => { + await act(async () => { + jestRender( {}} />) + }) + ;(api.get as jest.Mock).mockResolvedValue({ + status: 400, + }) + ;(useShowDataStore as any as jest.Mock).mockImplementation(() => ({ + showMessage: showMessageMock, + })) + + const { allInputsFilled } = useFormFieldsChange() + + const { emailInput } = useFormFields() + + allInputsFilled() + + await userEvent.type(emailInput, '{enter}') + + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith('/auth/mail/whitelist', { + params: { email: 'aleksander.freelancer@gmail.com' }, + }) + }) + + await waitFor(() => { + expect(showMessageMock).toHaveBeenCalledWith('Примите пользовательское соглашение') + }) + }) + + it('submit without rules and white list included sended email', async () => { + await act(async () => { + jestRender( {}} />) + }) + ;(api.get as jest.Mock).mockResolvedValue({ + status: 200, + }) + ;(api.post as jest.Mock).mockResolvedValue({ + status: 201, + }) + + const { allInputsFilled } = useFormFieldsChange() + + const { emailInput } = useFormFields() + + allInputsFilled() + + await userEvent.type(emailInput, '{enter}') + + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith('/auth/mail/whitelist', { + params: { email: 'aleksander.freelancer@gmail.com' }, + }) + }) + + await waitFor(() => { + expect(api.post).toHaveBeenCalledWith('/auth/register', { + confirm: 'geraldisrivii', + email: 'aleksander.freelancer@gmail.com', + password: 'geraldisrivii', + spam: false, + }) + }) + }) +}) @@ -1,304 +1,129 @@ import React, { useState } from 'react' -import { useCookies } from 'react-cookie' -import { useForm } from 'react-hook-form' -import { SubmitErrorHandler, SubmitHandler } from 'react-hook-form/dist/types/form' -import { Button, Checkbox, InputAdornment, TextField } from '@mui/material' -import Box from '@mui/material/Box' import CircularProgress from '@mui/material/CircularProgress' -import Typography from '@mui/material/Typography' -import axios from 'axios' import Link from 'next/link' -import { useRouter } from 'next/router' - -import { PasswordOptions } from '#/features/register-by-email/lib/constants' -import { IEmailForms } from '#/features/register-by-email/model/types' -import { useAppSelector } from '#/app/store/store' -import { emailOptions } from '#/shared' -import { CheckBoxAgreeWithRules } from '#/shared' -import { Error } from '#/shared' -import { InputStyleDark, InputStyleLight } from '#/shared' -import { API_URL } from '#/shared/lib/constants/constants' -import { useShowData } from '#/shared/lib/hooks' -import { useThemeAndDevice } from '#/shared/lib/hooks' +import { c } from '#/shared' import OpenedEyeSvg from '#/assets/svg/opened-eye.svg?react' import ClosedEyeSvg from '#/assets/svg/closed-eye.svg?react' - -interface IRegisterEmailFormProps { - successLogin: () => void +import { useRegister, useRegisterValidate } from '../model' +import { CommonInput } from '#/shared/ui/common-input' +import styles from './register-email-form.module.scss' +import { CommonCheckbox } from '#/shared/ui/checkbox' +import { CommonButton } from '#/shared/ui/button' + +export interface RegisterEmailFormProps { + successLogin: Function } -export const RegisterEmailForm: React.FC = ({ successLogin }) => { - const { register, handleSubmit, reset, watch } = useForm() - - const { theme, desktop } = useThemeAndDevice() - - const referral = useAppSelector((state) => state.user.referral) - - const { error, showError } = useShowData() - - const { push } = useRouter() - - const [loading, setLoading] = React.useState(false) - - const [isEmailWhite, setIsEmailWhite] = React.useState(true) +export function RegisterEmailForm({ successLogin }: RegisterEmailFormProps) { + const { register, onSubmit, watch, pending } = useRegister(successLogin) - const [cookie] = useCookies() - const [passwordShowed, showPassword] = React.useState(false) + const { emailOptions, passwordOptions } = useRegisterValidate() - const makeNewAccount: SubmitHandler = async (data) => { - setLoading(true) - - if (!data.rules) { - try { - const { status } = await axios.get( - API_URL + `/auth/mail/whitelist?email=${data.email}`, { validateStatus: (status) => status < 400 } - ) - if (status === 200) { - setIsEmailWhite(true) - } - } catch (e) { - showError('Примите пользовательское соглашение', true) - setLoading(false) - return - } - } - - const req_data: { - email: any - password: any - utm_source: any - utm_medium: any - utm_campaign: any - utm_term: any - utm_content: any - referer?: string | null - } = { - email: data.email, - password: data.password1, - utm_source: cookie.utm_source, - utm_medium: cookie.utm_medium, - utm_campaign: cookie.utm_campaign, - utm_term: cookie.utm_campaign, - utm_content: cookie.utm_campaign, - } - - if (localStorage.getItem('referral')) { - req_data.referer = localStorage.getItem('referral') - } - - try { - const { status } = await axios.post(API_URL + '/auth/register', req_data, { validateStatus: (status) => status < 400 }) - if (status === 201) { - setLoading(false) - reset() - successLogin() - setTimeout(() => push('/login'), 7000) - } - } catch (err: any) { - setLoading(false) - showError(err.response.data.detail) - } - } - - const checkError: SubmitErrorHandler = (data) => { - showError(Object.values(data)[0].message || 'Неверные данные') - } - - const handleInputChangeTrim = (event: any) => { - event.target.value = event.target.value.trim() - } - - const handleKeyDown = (event: any) => { - if (event.key === 'Enter') { - handleSubmit(makeNewAccount)() - } - } + const [passwordShowed, showPassword] = useState(false) return ( -
- - Email - - - - - Пароль - - - -
showPassword((x) => !x)} - style={{ - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - }} - > - {passwordShowed ? ( - - ) : ( - - )} -
- - ), - }} - sx={theme === 'light' ? { ...InputStyleLight } : { ...InputStyleDark }} - {...register('password1', { - ...PasswordOptions, - })} - /> - - - Подтвердите пароль - - - { - if (watch('password1') != val) { - return 'Пароли не совпадают' - } - }, - })} - /> - - + +
+ - - showPassword((x) => !x)} + > + {passwordShowed ? ( + + ) : ( + + )} + + } /> - +
+ +
+ - {' '} - Я соглашаюсь на получение-информационно-рекламных писем - - + + Я соглашаюсь с условиями  + + + Политики обработки персональных данных  + + и  + + Публичной офертой + + - - {loading ? ( - - - + + + Я соглашаюсь на получение-информационно-рекламных писем + + +
+ +
+ {pending ? ( +
+ +
) : ( - + )} - - - +

+ Есть аккаунт?  + - Есть аккаунт?{' '} - - Войти - - - - + Войти + +

) } @@ -1 +1,4 @@ export { RegisterEmailForm } from './ui/register-email-form' +export * from './model' +export * from './ui' +export * from './test' \ No newline at end of file @@ -1,27 +1,13 @@ -import axios, { AxiosResponse } from 'axios' - -import { ResponseGetPersons } from '#/features/invite-person-in-business' +import axios from 'axios' import { API_URL } from '#/shared/lib/constants' -export const removePerson = async (email: string, token?: string): Promise => { - if (!token) { - return null - } - - try { - const { data } = await axios.put<{ status: 'cancelled' }, AxiosResponse>( - API_URL + `/auth/business-host/accounts/${email}`, - { - status: 'cancelled', - }, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ) - return data - } catch (err) { - return null - } +export const removePerson = async (person_uid?: string, token?: string) => { + return await axios.delete(API_URL + `/auth/business-host`, { + data: { + uid: person_uid, + }, + headers: { + Authorization: `Bearer ${token}`, + }, + }) } @@ -1,27 +1,31 @@ -import { Dispatch, SetStateAction, useState } from 'react' +import { Dispatch, SetStateAction, useState, useEffect } from 'react' import { useSession } from 'next-auth/react' import { ResponseGetPersons } from '#/features/invite-person-in-business' import { removePerson } from '../api/remove' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' export const useRemove = ( - updateList: (persons: ResponseGetPersons) => void, - email: string -): [open: boolean, setOpen: Dispatch>, removeFn: () => void] => { + updateList: (persons: ResponseGetPersons | null) => void, + person: ResponseGetPersons | null +): [open: boolean, setOpen: Dispatch>, remove: () => void] => { const [open, setOpen] = useState(false) - + const { showMessage } = useShowDataStore() const { data } = useSession() const remove = async () => { - const result = await removePerson(email, data?.access) + const response = await removePerson(person?.uid, data?.access) - if (result === null) { + if (response.status === 200) { + showMessage('Cотрудник удален', 'success') + setOpen(false) + updateList(person) return [open, setOpen] + } else { + showMessage(response.data.detail) } - setOpen(false) - updateList(result) } return [open, setOpen, remove] -} +} \ No newline at end of file @@ -6,13 +6,13 @@ import { ButtonGray, ButtonUI, Modal } from '#/shared' import { useRemove } from '../../model/remove' export const XMark = ({ - email, + person, setNewPersons, }: { - email: string - setNewPersons: (persons: ResponseGetPersons) => void + person: ResponseGetPersons | null + setNewPersons: (persons: ResponseGetPersons | null) => void }) => { - const [open, setOpen, removeFn] = useRemove(setNewPersons, email) + const [open, setOpen, remove] = useRemove(setNewPersons, person) return ( e.stopPropagation()} sx={{ marginTop: 0.5, marginLeft: 0.8 }}> @@ -21,9 +21,9 @@ export const XMark = ({ e.stopPropagation() setOpen(true) }} - src={'/x-mark.svg'} - width={15} - height={15} + src={'/svg/trash-outline.svg'} + width={16} + height={16} alt={'Удалить'} /> - Вы дейсвительно хотите удалить пользователя {email} ? + Вы дейсвительно хотите удалить пользователя {person?.email} ? { + onClick={(e: any) => { e.stopPropagation() - await removeFn() + remove() }} text={'Удалить'} /> @@ -0,0 +1,14 @@ +import { API_URL } from '#/shared/lib/constants' +import axios from 'axios' + +export const resendInvation = async (email: string, token?: string) => { + return await axios.post( + API_URL + `/auth/business-host/re-invite/${email}`, + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ) +} @@ -0,0 +1,23 @@ +import { useSession } from 'next-auth/react' +import { resendInvation } from '../api/resend-invation' +import { getModalById, RESEND_INVATION_PASSWORD } from '#/features/modals' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' + +export const useResendInvation = () => { + const modal = getModalById(RESEND_INVATION_PASSWORD) + const { showMessage } = useShowDataStore() + const { data } = useSession() + + const resend = async () => { + const response = await resendInvation(modal.getStoreProperty('email')!, data?.access) + + if (response.status == 200) { + showMessage('Приглашение переотправлено!', 'success') + modal.setState(false) + } else { + showMessage(response.data) + } + } + + return [resend] +} @@ -0,0 +1,4 @@ +interface ResponseResendInvation { + data: any, + status: number +} \ No newline at end of file @@ -0,0 +1 @@ +export * from './resend-invation' \ No newline at end of file @@ -0,0 +1,26 @@ +.container { + padding-bottom: 0; +} + +.modal { + min-width: 450px; + width: 100%; + padding-top: 35px; + + &__buttons { + display: flex; + margin-top: 45px; + gap: 10px; + } + &__header { + margin-bottom: 15px; + font-size: 30px; + font-weight: 600; + letter-spacing: -0.02em; + } + &__message { + font-size: 15px; + color: var(--text-color-main); + width: 70%; + } +} @@ -0,0 +1,32 @@ +import { getModalById, RESEND_INVATION_PASSWORD, PlateTemplate } from '#/features/modals' +import styles from './resend-invation.module.scss' +import React from 'react' +import { CommonButton } from '#/shared/ui/button' +import { CommonInput } from '#/shared/ui/common-input' +import { useResendInvation } from '../lib/use-resend-invation' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' + +interface ResendInvationPlateProps {} + +export const ResendInvationPlate = ({}: ResendInvationPlateProps) => { + const modal = getModalById(RESEND_INVATION_PASSWORD) + + const [resend] = useResendInvation() + + return ( + +
+

Переотправить приглашение

+

При переотправке приглашения у сотрудника будет заменен отправленный пароль.

+
+ resend()}> + Переотправить + + modal.setState(false)}> + Отмена + +
+
+
+ ) +} @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -6,9 +6,9 @@ import { useSession } from 'next-auth/react' import { Template } from '#/domains/copywrite/proxy/types/template' import { loadGeneration } from '#/features/use-copy/copy-slice' import { useAppDispatch } from '#/app/store/store' -import { useShowData } from '#/shared' import { API_URL } from '#/shared/lib/constants' import { Message, MessageSend } from '#/shared/lib/types/model' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' type Languages = 'ru' | 'en' | 'it' | 'fr' type LanguagesText = 'Русский' | 'Английский' | 'Итальянский' | 'Французский' @@ -22,16 +22,7 @@ export const langs: Record = { export const languages = { ...langs, Немецкий: 'de' } export const target_audiences = ['Вся', '18+', '21+', '30+', '14-20', '35-40'] -export const tovs = [ - 'Нейтральный', - 'Спокойный', - 'Агрессивный', - 'Серьезный', - 'Провокационный', - 'Остроумный', - 'Наставнический', - 'Дружелюбный', -] +export const tovs = ['Нейтральный', 'Спокойный', 'Агрессивный', 'Серьезный', 'Провокационный', 'Остроумный', 'Наставнический', 'Дружелюбный'] type Setting = Pick @@ -71,7 +62,7 @@ export const useTemplate = (currentTemplate: Template | null): UseTemplate => { const dispatch = useAppDispatch() - const { showError } = useShowData() + const { showMessage } = useShowDataStore() const { data: session } = useSession() @@ -123,7 +114,7 @@ export const useTemplate = (currentTemplate: Template | null): UseTemplate => { } if (!session?.access) { - showError('У вас неактивный токен, попробуйте перезайти в аккаунт', true) + showMessage('У вас неактивный токен, попробуйте перезайти в аккаунт') return } @@ -0,0 +1 @@ +export * from './yandex-auth-button' \ No newline at end of file @@ -0,0 +1,25 @@ +.button { + background-color: #f1faff; + color: rgb(43, 43, 66); + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 10px 16px; + border-radius: 13px; + transition: opacity 0.3s ease-in-out; + gap: 15px; + font-size: 16px; + font-weight: 500; + + &:hover { + opacity: 0.8; + } +} + +html[data-theme='dark'] { + .button { + background-color: #1C1C1E; + color: white; + } +} @@ -0,0 +1,45 @@ +import { LoginPage } from '#/views/login' +import { RegisterPage } from '#/views/register' +import '@testing-library/jest-dom' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { jestRender } from '#/../jest/utils/render' +import { YandexAuthButton } from './yandex-auth-button' +import { windowMock } from '#/../jest/utils/window-mock' +import { fetchToCrossfetch } from '#/../jest/utils/fetch-to-crossfetch' + +jest.mock('next/router') +jest.mock('@sentry/nextjs') + +windowMock() +fetchToCrossfetch() + +describe('yandex-auth-button', () => { + it('renders in login', () => { + jestRender() + + const button = screen.getByTestId('yandex-auth-button') + + expect(button).toBeInTheDocument() + }) + + it('renders in register', () => { + jestRender() + + const button = screen.getByTestId('yandex-auth-button') + + expect(button).toBeInTheDocument() + }) + + it('opened signin url', async () => { + jestRender() + + const button = screen.getByTestId('yandex-auth-button') + + await userEvent.click(button) + + await waitFor(() => { + expect(window.location.href).toContain('api/auth/signin?csrf=true') + }) + }) +}) @@ -0,0 +1,19 @@ +import React from 'react' +import YandexSvg from '#/assets/svg/yandex.svg?react' +import styles from './yandex-auth-button.module.scss' +import { signIn } from 'next-auth/react' + +interface YandexAuthButtonProps {} + +export const YandexAuthButton = ({}: YandexAuthButtonProps) => { + return ( + + ) +} @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -5,7 +5,6 @@ import YandexProvider from 'next-auth/providers/yandex' import { AuthConstants, ERROR_MAPPING, ERROR_YANDEX_MAPPING } from './constants' import { AuthorizationProxy } from './proxy' -import * as Sentry from '@sentry/nextjs' import { getAll } from '#/entities/user-account/model/user-type-slice' const AuthProxy = new AuthorizationProxy() @@ -25,7 +24,7 @@ export const authOptions: NextAuthOptions = { type: 'credentials', credentials: {}, async authorize(credentials, req) { - const { username, password } = credentials as any + const { username, password } = credentials as { username: string, password: string } const resp = await AuthProxy.loginByEmail(username, password) const data = await resp.json() @@ -33,10 +32,33 @@ export const authOptions: NextAuthOptions = { if (resp.status >= 400 && resp.status < 500) { throw new Error( ERROR_MAPPING[(data as { detail: string }).detail] ?? - JSON.stringify(data as Object) - .replace(/\s+/, '_') - .replace(/[А-Яа-я]/, '') - .toLowerCase() + JSON.stringify(data as Object) + .replace(/\s+/, '_') + .replace(/[А-Яа-я]/, '') + .toLowerCase() + ) + } + return (await data) as User + }, + }), + CredentialsProvider({ + id: 'email_token', + type: 'credentials', + credentials: {}, + async authorize(credentials, req) { + const { token } = credentials as { token: string } + + const resp = await AuthProxy.loginByEmailToken(token) + + const data = await resp.json() + + if (resp.status >= 400 && resp.status < 500) { + throw new Error( + ERROR_MAPPING[(data as { detail: string }).detail] ?? + JSON.stringify(data as Object) + .replace(/\s+/, '_') + .replace(/[А-Яа-я]/, '') + .toLowerCase() ) } return (await data) as User @@ -56,10 +78,6 @@ export const authOptions: NextAuthOptions = { (result.data as { access_token: string }).access_token ) - console.log(info) - - console.log(trigger) - return { ...info.token, tokenExpiry: AuthConstants.getExpiresDate() } } @@ -26,6 +26,15 @@ export class AuthorizationProxy { }) } + public readonly loginByEmailToken = async (emailToken: string) => { + return await fetch(API_URL + `/auth/login-from-token/${emailToken}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + } + public readonly exchangeTokenYandex = async (yandex_token: string) => { return await axios.post<{ access_token: string } | { error_description: string }>( API_URL + '/auth/login-social/convert-token', @@ -17,6 +17,27 @@ import '#/app/styles/globals.css' import '#/app/styles/styles-pages/system.scss' import { NextPage } from 'next' import { pingFangFont } from '#/shared/lib/constants/font/font' +import { useBlockTelegram } from '#/shared/lib/hooks/use-block-telegram' +import { Error } from '#/shared' + +import fetch from 'cross-fetch' + +global.fetch = (...params: Parameters) => { + let url = params[0] + + const baseUrl = process.env.NEXT_PUBLIC_NEXTAUTH_URL! + + if ( + typeof url === 'string' && + !(url as string).startsWith(baseUrl) && + !(url as string).includes('http') + ) { + url = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) + params[0] : baseUrl + params[0] + console.log(url) + } + + return fetch(url, params[1]) +} axios.defaults.httpsAgent = new https.Agent({ rejectUnauthorized: false, @@ -36,16 +57,20 @@ export interface AppPropsWithLayout extends AppProps { } function App({ Component, pageProps: { session, ...pageProps } }: AppPropsWithLayout) { + useBlockTelegram() const getLayout = Component.getLayout ?? ((page) => page) if ('serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations().then(function (registrations) { - for (let registration of registrations) { - registration.unregister(); - } - }).catch(function (error) { - console.error('Ошибка при размонтировании service worker:', error); - }); + navigator.serviceWorker + .getRegistrations() + .then(function (registrations) { + for (let registration of registrations) { + registration.unregister() + } + }) + .catch(function (error) { + console.error('Ошибка при размонтировании service worker:', error) + }) } return ( @@ -62,6 +87,7 @@ function App({ Component, pageProps: { session, ...pageProps } }: AppPropsWithLa {getLayout()} + @@ -51,6 +51,7 @@ class ErrorBoundary extends Component { width: '300px', cursor: 'pointer', fontSize: '16px', + textAlign: 'center' }} onClick={() => (window.location.pathname = '/')} > @@ -0,0 +1,6 @@ +import TelegramBlockedPage from '#/views/telegram-blocked/ui/telegram-blocked' +import { getLayout, LayoutWithoutSideMenu } from '#/widgets/layouts' + +TelegramBlockedPage.getLayout = getLayout(LayoutWithoutSideMenu) + +export default TelegramBlockedPage @@ -10,6 +10,7 @@ import { API_URL } from '#/shared/lib/constants' import { Device } from '#/shared/lib/types/entities' import { IMessageRequest } from '#/shared/lib/types/types-gpt' import { Message, MessageSend } from '#/entities/message' +import { Variant } from '#/shared/lib/hooks/use-show-data' const formDataHelper = (file: File, dataForSend: MessageSend): FormData => { const FD = new FormData() @@ -23,14 +24,11 @@ const formDataHelper = (file: File, dataForSend: MessageSend): FormData => export const ModelsWithChatsEndpoints = { getData: async (chatUid: string, offset: number, token?: string) => { try { - const { data } = await axios.get( - API_URL + `/chats/${chatUid}/messages/?limit=10&offset=${offset}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ) + const { data } = await axios.get(API_URL + `/chats/${chatUid}/messages/?limit=10&offset=${offset}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) return data } catch (err: any) { return { @@ -54,12 +52,12 @@ export const ModelsWithChatsEndpoints = { }, } ) - } + }, } export function useModel( currentChat: string | null, - showError: (message: string, isErrorMessage?: boolean) => void, + showMessage: (message: string, variant?: Variant) => void, modelType: string, clearInput?: () => void ) { @@ -72,19 +70,19 @@ export function useModel( useEffect(() => { if (currentChat) { setMessages([]) - ; (async () => { - setLoading(true) - const answer = await ModelsWithChatsEndpoints.getData(currentChat, 0, data?.access) - setLoading(false) + ;(async () => { + setLoading(true) + const answer = await ModelsWithChatsEndpoints.getData(currentChat, 0, data?.access) + setLoading(false) - if (Array.isArray(answer)) { - setMessages(answer.reverse()) - setOffset(answer.length) - } else { - showError('Ошибка загрузки чата') - return - } - })() + if (Array.isArray(answer)) { + setMessages(answer.reverse()) + setOffset(answer.length) + } else { + showMessage('Ошибка загрузки чата') + return + } + })() } }, [currentChat]) @@ -99,7 +97,7 @@ export function useModel( setMessages([...newMessages, ...messages]) setOffset((prev) => prev + answer.length) } else { - showError('Ошибка загрузки сообщений') + showMessage('Ошибка загрузки сообщений') return } } @@ -117,11 +115,7 @@ export function useModel( info: dataForSend.info as any, is_sent: true, model: '', - file: dataForSend.file - ? ((URL.createObjectURL(dataForSend.file) + - '?type=.' + - dataForSend.file.name.split('.')[1]) as string) - : null, + file: dataForSend.file ? ((URL.createObjectURL(dataForSend.file) + '?type=.' + dataForSend.file.name.split('.')[1]) as string) : null, from_model: false, uid: 'new-send', elapsed_time: '', @@ -140,7 +134,7 @@ export function useModel( setLoading(true) // let timeout = setTimeout(() => { - // showError('Не покидайте страницу, генерация подготавливается!', false) + // showMessage('Не покидайте страницу, генерация подготавливается!', false) // }, 5000) setMessages((prev) => [...prev!, userMessage, modelMessageAboutStartGeneration]) @@ -168,10 +162,9 @@ export function useModel( if (status >= 400) { userMessage.is_sent = false setMessages((prev) => [...prev!, userMessage]) - const error = (result as { detail: string }) - if (error.detail) - return showError(error.detail) - showError("Непредвиденная ошибка, попробуйте еще раз") + const error = result as { detail: string } + if (error.detail) return showMessage(error.detail) + showMessage('Непредвиденная ошибка, попробуйте еще раз') } else { setMessages((prev) => [...prev!, ...(result as Message[])]) } @@ -213,17 +206,13 @@ export const ModelsWithImagesEndpoints = { const HeaderDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' try { - const { data } = await axios.post>( - API_URL + `/media/image/${type}`, - dataForSend, - { - withCredentials: true, - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': HeaderDataType, - }, - } - ) + const { data } = await axios.post>(API_URL + `/media/image/${type}`, dataForSend, { + withCredentials: true, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': HeaderDataType, + }, + }) return data } catch (err: any) { @@ -236,7 +225,7 @@ export const ModelsWithImagesEndpoints = { }, } -export function useModelImages(showError: (message: string) => void, type: string, device: Device) { +export function useModelImages(showMessage: (message: string) => void, type: string, device: Device) { const { data } = useSession() const { getData, sendData } = ModelsWithImagesEndpoints @@ -271,12 +260,12 @@ export function useModelImages(showError: (message: string) => void, type: st useEffect(() => { if (data?.access && type !== '' && type !== undefined) { - ; (async () => { + ;(async () => { setLoading(true) const answer = await getData(type, offset, data?.access) setLoading(false) if ('error' in answer) { - showError('Ошибка загрузки чата') + showMessage('Ошибка загрузки чата') return } @@ -315,7 +304,7 @@ export function useModelImages(showError: (message: string) => void, type: st } else { console.log('err') - showError('Ошибка загрузки сообщений') + showMessage('Ошибка загрузки сообщений') return } } @@ -338,7 +327,7 @@ export function useModelImages(showError: (message: string) => void, type: st if (result.hasOwnProperty('error')) { //@ts-ignore const message = (result.details as AxiosError).response.data.trim() ?? 'Ошибка отправки сообщения' - showError(message) + showMessage(message) return } dispatch(getUserBalance(data?.access)) @@ -380,16 +369,12 @@ export const ModelsMediaApi = { }, sendData: async (type: string | null, dataForSend: MessageSend | FormData, token?: string) => { try { - const { data } = await axios.post>( - API_URL + `/media/audio/${type}`, - dataForSend, - { - withCredentials: true, - headers: { - Authorization: `Bearer ${token}`, - }, - } - ) + const { data } = await axios.post>(API_URL + `/media/audio/${type}`, dataForSend, { + withCredentials: true, + headers: { + Authorization: `Bearer ${token}`, + }, + }) return data } catch (err: any) { @@ -402,7 +387,7 @@ export const ModelsMediaApi = { }, } -export function useMedia(showError: (message: string) => void, modelType: string) { +export function useMedia(showMessage: (message: string) => void, modelType: string) { const { data } = useSession() const [messages, setMessages] = useState(null) @@ -415,12 +400,12 @@ export function useMedia(showError: (message: string) => void, modelType: string useEffect(() => { if (data?.access) { - ; (async () => { + ;(async () => { setLoading(true) const answer = await ModelsMediaApi.getData(modelType, data?.access) setLoading(false) if ('error' in answer) { - showError('Ошибка загрузки') + showMessage('Ошибка загрузки') return } @@ -431,7 +416,7 @@ export function useMedia(showError: (message: string) => void, modelType: string const sendMessage = async (dataForSend: MessageSend) => { if (input.trim() === '') { - showError('Введите запрос!') + showMessage('Введите запрос!') return } @@ -472,7 +457,7 @@ export function useMedia(showError: (message: string) => void, modelType: string try { //@ts-ignore const message = (result.details as AxiosError).response.data.trim() ?? 'Ошибка отправки сообщения' - showError(message) + showMessage(message) return } catch (e) { return @@ -0,0 +1 @@ +export * from './instance' \ No newline at end of file @@ -0,0 +1,6 @@ +import axios from 'axios' + +export const api = axios.create({ + validateStatus: (status) => status < 500, + baseURL: process.env.NEXT_PUBLIC_API_HOST +}) @@ -1,9 +1,10 @@ import { RegisterOptions } from 'react-hook-form/dist/types/validator' -const EMAIL_REGEXP = +export const EMAIL_REGEXP = /^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/iu -const PHONE_REGEXP = /^((8|\+7)[\- ]?)?(\(?\d{3}\)?[\- ]?)?[\d\- ]{7,10}$/ +export const PHONE_REGEXP = /^((8|\+7)[\- ]?)?(\(?\d{3}\)?[\- ]?)?[\d\- ]{7,10}$/ + export const emailOptions: RegisterOptions = { required: 'Поле email обязательно к заполенению!', @@ -1 +1,2 @@ export { API_HOST, API_URL, ModelPagesList, surpriseMePrompts } from './constants' +export * from './hook-form-options' \ No newline at end of file @@ -5,3 +5,4 @@ export * from './get-type-device' export * from './get-random-image' export * from './string' export * from './date-helper' +export * from './object' @@ -0,0 +1,6 @@ +export function keysToValues(obj: Record) { + return Object.keys(obj).reduce( + (prev, curr) => ({ ...prev, [obj[curr]]: curr }), + {} as Record + ) +} @@ -1,3 +1,3 @@ export { useAutoScroll } from './use-auto-scroll' -export { useShowData } from './use-show-data' export { useThemeAndDevice } from './use-theme-and-device' +export * from './use-show-data' \ No newline at end of file @@ -0,0 +1,26 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/router' + +// этот хук проверяет используется ли Telegram Web App + +export const useBlockTelegram = () => { + const router = useRouter() + + useEffect(() => { + if (typeof window === 'undefined') return + + try { + if ( + typeof window !== 'undefined' && + 'Telegram' in window && + (window as any).Telegram?.WebApp && + router.pathname !== '/telegram-blocked' + ) { + router.replace('/telegram-blocked') + } else { + } + } catch (err) { + console.error('Ошибка при проверке WebApp:', err) + } + }, [router]) +} @@ -1,19 +1,45 @@ import React, { useState } from 'react' +import { create } from 'zustand' -export const useShowData = (): { - error: string - showError: (message: string, isErrorMessage?: boolean) => void - isError: boolean -} => { - const [message, setError] = React.useState('') +export type Variant = 'error' | 'success' - const [isError, setIsError] = useState(false) +interface ShowDataStore { + message: string + variant: Variant + isOpened: boolean + setOpened: (isOpened: boolean) => void + setMessage: (message: string) => void + setVariant: (variant: Variant) => void + showMessage: (message: string, variant?: Variant) => void +} - function showError(message: string = 'Произошла ошибка', isErrorMessage: boolean = false) { - setIsError(isErrorMessage) - setError(message) - setTimeout(() => setError(''), 5000) +export const useShowDataStore = create((set, get) => { + function setVariant(variant: Variant = 'error') { + set({ ...get(), variant }) } - return { error: message, showError, isError } -} + function setMessage(message: string) { + set({ ...get(), message }) + } + + function setOpened(isOpened: boolean) { + set({ ...get(), isOpened }) + } + + function showMessage(message: string = 'Произошла ошибка', variant: Variant = 'error') { + setVariant(variant) + setOpened(true) + setMessage(message) + setTimeout(() => setOpened(false), 5000) + } + + return { + message: '', + variant: 'error', + isOpened: false, + setOpened, + setVariant, + setMessage, + showMessage, + } +}) @@ -2,14 +2,16 @@ border: none; outline: none; background-color: transparent; - padding: 15px 25px; + padding: 16px 25px; cursor: pointer; - box-sizing: content-box; + display: flex; + align-items: center; + justify-content: center; &_outline { border-radius: 15px; - border: 0.15rem solid var(--new-ui-text-color); - color: var(--new-ui-text-color); + border: 1px solid white; + color: white; font-weight: 600; transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out; font-size: 15px; @@ -45,14 +47,32 @@ &_primary { background-color: var(--air-color); - color: var(--new-ui-text-color); + color: white; border-radius: 12px; font-weight: 500; - font-size: 15px; + font-size: 16px; transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out; &:hover { background-color: var(--text-color-purple); - color: var(--new-ui-text-color); } } + + &_gray { + background-color: #f2f2f2; + color: var(--air-color); + border-radius: 12px; + font-weight: 500; + font-size: 15px; + transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out; + &:hover { + opacity: 0.7; + } + } +} + +html[data-theme='dark'] { + .button_gray { + color: white !important; + background-color: #5d5a5a !important; + } } @@ -1,7 +1,7 @@ import { c } from '#/shared/lib/helpers' import styles from './common-button.module.scss' -export type ButtonVariant = 'outline' | 'primary' | 'primary-outline' +export type ButtonVariant = 'outline' | 'primary' | 'primary-outline' | 'gray' export interface CommonButtonProps extends React.ButtonHTMLAttributes { children?: React.ReactNode @@ -0,0 +1,50 @@ +.input { + display: none; +} + +.checkbox { + display: flex; + align-items: center; + gap: 10px; + + &__iconbox { + &_primary { + border-radius: 3px; + border: 2px solid var(--air-color); + min-width: 22px; + min-height: 22px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease-in-out; + } + + &_active { + background-color: var(--air-color); + } + } + + &__icon { + transition: all 0.1s ease-in; + opacity: 0; + + &_primary { + color: #151518; + } + + &_active { + opacity: 1; + } + } + + &__text { + + } +} + + +html[data-theme='light'] { + .checkbox__icon_primary{ + color: white; + } +} \ No newline at end of file @@ -0,0 +1,83 @@ +import { c } from '#/shared/lib/helpers' +import DoneIcon from '#/assets/svg/done.svg?react' +import styles from './common-checkbox.module.scss' +import { forwardRef } from 'react' + +export type CheckboxVariant = 'primary' + +export type CheckboxSize = 'md' + +export interface CheckboxProps + extends Omit, 'size' | 'placeholder'> { + name: string + value: boolean + className?: string + variant?: CheckboxVariant + placeholder?: string | React.ReactNode + required?: boolean + size?: CheckboxSize + withoutAnimation?: boolean + children?: React.ReactNode +} + +export const CommonCheckbox = forwardRef( + ( + { + id, + name, + className, + placeholder, + children, + value, + variant = 'primary', + size = 'md', + required = false, + withoutAnimation = false, + ...props + }, + ref + ) => { + return ( + <> + + + + ) + } +) @@ -0,0 +1 @@ +export * from './common-checkbox' \ No newline at end of file @@ -0,0 +1,2 @@ + +export * from "./ui" \ No newline at end of file @@ -0,0 +1,168 @@ +.box { + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + + &__error { + transform: translateY(16px); + color: #ff2372; + font-size: 13px; + opacity: 0; + transition: all 300ms; + margin-top: 8px; + + &_visible { + opacity: 1; + transform: translateY(0); + } + } +} + +.label { + color: #a6a5a5; + font-size: 14px; + margin-bottom: 8px; + width: 100%; + + &_primary { + font-weight: 500; + margin-bottom: 10px; + } + + &_error { + color: #97989f; + font-size: 14px; + margin-bottom: 8px; + } +} + +.input { + background-color: transparent; + border: none; + outline: none; + font-size: 16px; + width: 100%; + font-family: '__Raleway_9d395e' !important; + + &:focus { + border: none; + outline: none; + } + + &_primary { + padding: 16px 0; + color: var(--text-color-main) !important; + font-size: 19px; + font-weight: 400; + + &::placeholder { + color: #97989f !important; + } + } + + &_outline { + padding: 20px 0; + color: var(--text-color-main) !important; + + &::placeholder { + color: #97989f !important; + } + } + + &_error { + color: #ff2372 !important; + &::placeholder { + color: #ff2372 !important; + } + } +} + +.wrapper { + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.3 ease-in; + + &_primary { + color: var(--text-color-main) !important; + padding: 0 14px; + background-color: var(--new-ui-main-color); + border: 2px solid #40404e; + border-radius: 13px; + + &:hover { + border: 2px solid var(--air-color); + } + + &::placeholder { + color: #a6a5a5 !important; + } + + &:focus { + border: 2px solid var(--background-color-table); + } + } + + &_outline { + color: var(--text-color-main) !important; + padding: 0 20px; + border: 2px solid var(--background-color-table); + border-radius: 13px; + + &::placeholder { + color: #97989f !important; + } + + &:focus { + border: 2px solid var(--background-color-table); + } + } + + &_error { + border-color: #ff2372; + + &::placeholder { + color: #ff2372 !important; + } + + &:focus { + border: 2px solid #ff2372; + } + } +} + +html[data-theme='light'] { + .wrapper_outline { + border: 2px solid #f2f2f8 !important; + &:focus { + border: 2px solid var(--background-color-table) !important; + } + } + + .wrapper_primary{ + border-color: #D4D7DB; + + &:hover{ + border-color: var(--air-color); + } + } + + .label_primary{ + color: #181C32; + } + + .wrapper_error { + border-color: #ff76a7 !important; + &::placeholder { + color: #ff76a7 !important; + } + &:focus { + border: 2px solid #ff76a7 !important; + } + } +} @@ -0,0 +1,80 @@ +import { c } from '#/shared/lib/helpers' +import { FieldError } from 'react-hook-form' +import styles from './common-input.module.scss' +import React, { ForwardedRef, forwardRef, useEffect } from 'react' + +export type InputVariant = 'outline' | 'primary' + +export interface CommonInputProps extends React.InputHTMLAttributes { + children?: React.ReactNode + variant?: InputVariant + label?: string | React.ReactNode + error?: FieldError + rightSlot?: React.ReactNode + trim?: boolean + ref?: ForwardedRef +} + +export const CommonInput = forwardRef( + ( + { + rightSlot, + label, + error, + className, + disabled, + trim = false, + variant = 'outline', + ...props + }, + ref + ) => { + const onChange = (event: React.ChangeEvent) => { + event.target.value = trim ? event.target.value.trim() : event.target.value + } + + return ( +
+
+ {label && ( + + )} +
+
+ + {rightSlot} +
+

+ {error ? error?.message : ''} +

+
+ ) + } +) @@ -0,0 +1 @@ +export * from './common-input' \ No newline at end of file @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -4,6 +4,7 @@ import Router from 'next/router' import { signOut, useSession } from 'next-auth/react' import { useAppSelector } from '#/app/store/store' +import { useUserSelector } from '#/entities/user-account' interface IAccountMenu { anchorEl: HTMLElement | null @@ -11,7 +12,11 @@ interface IAccountMenu { device?: 'mobile' | 'desktop' } -export const AccountMenu: React.FC = ({ anchorEl, changeVisible, device = 'desktop' }) => { +export const AccountMenu: React.FC = ({ + anchorEl, + changeVisible, + device = 'desktop', +}) => { const { data } = useSession() const goAccount = async () => { await Router.push('/account') @@ -23,7 +28,7 @@ export const AccountMenu: React.FC = ({ anchorEl, changeVisible, d const desktop = device === 'desktop' - const email = useAppSelector((state) => state.user.email) + const { email } = useUserSelector() return ( = ({ anchorEl, changeVisible, d onClose={() => changeVisible()} PaperProps={{ style: { - transform: desktop ? 'translateX(-9%) translateY(28%)' : 'translateX(0%) translateY(16%)', + transform: desktop + ? 'translateX(-9%) translateY(28%)' + : 'translateX(0%) translateY(16%)', borderRadius: desktop ? 10 : '0px 0px 15px 15px', marginTop: desktop ? 0 : 12, }, @@ -8,8 +8,7 @@ interface ICheckBoxAgreeWithRulesProps { changeAgree?: () => void isAgreeError?: boolean register?: UseFormRegister - name?: string - isEmailWhite: boolean + name: string } export const CheckBoxAgreeWithRules: React.FC = ({ @@ -18,16 +17,13 @@ export const CheckBoxAgreeWithRules: React.FC = ({ isAgreeError, name, register, - isEmailWhite, }) => { return ( <> { - const { payment_plan, status } = useAppSelector((state) => state.user) + const { payment_plan } = useUserSelector() if (Math.floor(+payment_plan?.plan.price) !== 0) { return null @@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react' import { useAppSelector } from '#/app/store/store' import { API_URL } from '#/shared/lib/constants' +import { useUserSelector } from '#/entities/user-account' const style = { position: 'absolute' as 'absolute', @@ -76,7 +77,7 @@ const ErrorModal: React.FC = ({ open, handleClose, device }) => { const { data: session } = useSession() - const email = useAppSelector((state) => state.user.email) + const { email } = useUserSelector() const theme = useAppSelector((state) => state.theme.theme) @@ -138,7 +139,9 @@ const ErrorModal: React.FC = ({ open, handleClose, device }) => { > {isSend ? ( - Спасибо! + + Спасибо! + = ({ open, handleClose, device }) => { color: '#868686', }} > - Ваше сообщение направлено нашим специалистам. Ответ придет на почту, указанную при - регистрации + Ваше сообщение направлено нашим специалистам. Ответ придет на почту, + указанную при регистрации ) : ( @@ -1,55 +1,59 @@ -import React, { useRef } from 'react' +import React, { useEffect, useRef } from 'react' import { Alert, Slide, Snackbar, Typography } from '@mui/material' import Image from 'next/image' import { useThemeAndDevice } from '#/shared/lib/hooks' import { CSSTransition } from 'react-transition-group' +import { useShowDataStore } from '../lib/hooks/use-show-data' +import SuccessSvg from '#/assets/svg/success.svg?react' export interface IError { - error: string - open: boolean handleClose?: () => void } const ErrorIcon = () => { const { theme } = useThemeAndDevice() - return ( - {''} - ) + return {''} } -export const Error: React.FC = ({ error, handleClose, open }) => { +export const Error: React.FC = ({ handleClose }) => { + const { message, variant, isOpened } = useShowDataStore() const { theme } = useThemeAndDevice() const nodeRef = useRef(null) return ( - - - } - severity='error' - sx={{ - backgroundColor: theme === 'light' ? 'white' : '#404040', - border: '1px solid #F15179', - color: theme === 'light' ? '#666666' : '#C7C7C7', - borderRadius: '12px', - }} - > - {error} - + + + {variant == 'error' ? ( + } + severity='error' + sx={{ + backgroundColor: theme === 'light' ? 'white' : '#404040', + border: '1px solid #F15179', + color: theme === 'light' ? '#666666' : '#C7C7C7', + borderRadius: '12px', + }} + > + {message} + + ) : ( + } + severity='error' + sx={{ + backgroundColor: theme === 'light' ? 'white' : '#404040', + border: '1px solid #5ef151', + color: theme === 'light' ? '#666666' : '#C7C7C7', + borderRadius: '12px', + }} + > + {message} + + )} ) @@ -1,60 +0,0 @@ -import React, { useMemo } from 'react' -import { Alert, Slide, Snackbar, Typography } from '@mui/material' -import Image from 'next/image' - -import { useThemeAndDevice } from '#/shared/lib/hooks' - -interface ISuccess { - message: string - open: boolean - handleClose?: () => void - isError?: boolean -} -export const Success: React.FC = ({ message, handleClose, open, isError = false }) => { - const { theme } = useThemeAndDevice() - - const WidgetIcon = useMemo(() => { - if (isError) { - return theme === 'dark' ? '/error2.svg' : '/error2_white.svg' - } - - return '/svg/alert/success.svg' - }, [theme, isError]) - - return ( - - - - ), - }} - severity='success' - sx={ - !isError - ? { - backgroundColor: theme === 'light' ? 'white' : '#294239', - border: '2px solid #22B47F', - color: theme === 'light' ? '#666666' : '#C7C7C7', - borderRadius: '12px', - } - : { - backgroundColor: theme === 'light' ? 'white' : '#404040', - border: '1px solid #F15179', - color: theme === 'light' ? '#666666' : '#C7C7C7', - borderRadius: '12px', - } - } - > - {message} - - - - ) -} @@ -1,6 +1,5 @@ export { emailOptions, onlyNumbersOption } from './lib/constants/hook-form-options' export { translateTypeModel } from './lib/helpers/model-helpers' -export { useShowData } from './lib/hooks' export { useAutoLoad } from './lib/hooks/use-auto-load' export { AccountMenu } from './ui/account-menu' export { Balance } from './ui/balance' @@ -25,7 +24,6 @@ export { ReferralBlock } from './ui/referral-block' export { Search } from './ui/search/search' export { SelectUI as Select } from './ui/select' export { Slider } from './ui/slider/slider' -export { Success } from './ui/success' export { SwitchCustom } from './ui/switch/switch' export { TooltipCustom } from './ui/tooltip/tooltip' export { TooltipFreeTokens } from './ui/tooltip-free-tokens' @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom' + +describe('Test', () => { + it('renders a heading', () => { + // throw new Error('test') + }) +}) @@ -12,7 +12,7 @@ import { getAllInfo, unfollowEmail } from '#/entities/user-account/model/user-ty import { useAppDispatch, useAppSelector } from '#/app/store/store' import styles2 from '#/app/styles/accountTabs.module.css' import styles from '#/app/styles/business.module.scss' -import { ButtonUI, Input, Loader, Modal, Success, SwitchCustom, useShowData } from '#/shared' +import { ButtonUI, Error, Input, Loader, Modal, SwitchCustom } from '#/shared' import { accountApi } from '#/shared/api/account-endpoints' import { API_URL } from '#/shared/lib/constants/constants' import { getDeviceType } from '#/shared/lib/helpers' @@ -24,6 +24,8 @@ import { Referral } from '#/widgets/referral' import { NextPageWithLayout } from '#/pages/_app' import { DownloadModal } from '#/features/business-security-download' import { scopes } from '../config' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { useUserSelector } from '#/entities/user-account' const Account: NextPageWithLayout = () => { const { @@ -35,13 +37,13 @@ const Account: NextPageWithLayout = () => { username, profile_picture_link, is_social, - status: userInfoLoaded, referral_code, - } = useAppSelector((state) => state.user) + account_type: type, + } = useUserSelector() const device = getDeviceType() - const { status, account_type: type } = useAppSelector((state) => state.user) + const { status } = useAppSelector((state) => state.user) const [name, setName] = useState('') @@ -61,7 +63,7 @@ const Account: NextPageWithLayout = () => { const dispatch = useAppDispatch() - const { error, showError, isError } = useShowData() + const { showMessage } = useShowDataStore() const handleFileChange = async (event: any) => { const formData = new FormData() @@ -76,11 +78,11 @@ const Account: NextPageWithLayout = () => { }, }) dispatch(getAllInfo(data?.access)) - showError('Изображение успешно загружено!') + showMessage('Изображение успешно загружено!') setLoading(false) } catch (e) { setLoading(false) - showError('Ошибка загрузки изображения на сервере!', true) + showMessage('Ошибка загрузки изображения на сервере!', 'error') } } @@ -129,13 +131,18 @@ const Account: NextPageWithLayout = () => { const changePassword = async () => { if (!data) return if (newPassword1 !== newPassword2) { - showError('Укажите одинаковые новы пароли!', true) + showMessage('Укажите одинаковые новы пароли!') return } - const status = await accountApi.changePassword(data.access, newPassword1, newPassword2, currentPassword) + const status = await accountApi.changePassword( + data.access, + newPassword1, + newPassword2, + currentPassword + ) if (status === 200) { - showError('Пароль успешно изменён!') + showMessage('Пароль успешно изменён!') setNewPassword1('') setNewPassword2('') setCurrentPassword('') @@ -143,13 +150,19 @@ const Account: NextPageWithLayout = () => { return } - showError('К сожалению, произошла ошибка', true) + showMessage('К сожалению, произошла ошибка') } function body() { if (type === 'regular') { return ( - + ) @@ -197,9 +210,9 @@ const Account: NextPageWithLayout = () => { ) const promocodeActivate = async () => { - if (!data) return + if (!data) return if (!promocode.trim()) { - showError('Введите корректный промокод', true) + showMessage('Введите корректный промокод') return } let resStatus = 404 @@ -213,26 +226,26 @@ const Account: NextPageWithLayout = () => { } catch (e) {} if (resStatus === 200) { - showError('Промокод успешно активирован! Токены уже зачислены!') + showMessage('Промокод успешно активирован! Токены уже зачислены!') dispatch(getUserBalance(data?.access)) return } if (resStatus === 403) { - showError('Промокод уже был активирован!', true) + showMessage('Промокод уже был активирован!') return } if (resStatus === 404) { - showError('Промокод не найден!', true) + showMessage('Промокод не найден!') return } } async function changeUserData() { - if (!data) return + if (!data) return if (!isUserDataChange) { - showError('Вы не изменили данные', true) + showMessage('Вы не изменили данные', 'success') return } @@ -248,13 +261,13 @@ const Account: NextPageWithLayout = () => { { headers: { Authorization: `Bearer ${data.access}` } } ) - showError('Данные успешно изменены!') + showMessage('Данные успешно изменены!') dispatch(getAllInfo(data.access)) } catch (e) {} } const deleteAccount = async () => { - if (!data) return + if (!data) return try { const { status } = await axios.delete(API_URL + '/auth/remove', { headers: { @@ -297,7 +310,9 @@ const Account: NextPageWithLayout = () => { height: '100%', }} > - Ваш аккаунт + + Ваш аккаунт + { right: '37px', }} > - {!loading || !(userInfoLoaded === 'succeeded') ? ( + {!loading || + !(status === 'succeeded') ? ( { - Имя + + Имя + setName(e.target.value)} + onChange={(e) => + setName(e.target.value) + } fullWidth /> - Фамилия + + Фамилия + setLastName(e.target.value)} + onChange={(e) => + setLastName(e.target.value) + } fullWidth /> @@ -405,7 +429,9 @@ const Account: NextPageWithLayout = () => { alignItems='center' justifyContent='space-between' > - + Email { sx={{ marginBottom: '5px' }} > { - if (!data) return - await dispatch(unfollowEmail(data.access)) - showError('Данные изменены!') + if (!data) return + await dispatch( + unfollowEmail( + data.access + ) + ) + showMessage( + 'Данные изменены!' + ) }} /> { - Никнейм + + Никнейм + setUserName(e.target.value)} + onChange={(e) => + setUserName(e.target.value) + } fullWidth /> @@ -467,7 +505,9 @@ const Account: NextPageWithLayout = () => { - Активация промокода + + Активация промокода + { {!is_social && ( - Изменить пароль - + + Изменить пароль + + Текущий пароль @@ -489,7 +534,9 @@ const Account: NextPageWithLayout = () => { setCurrentPassword(e.target.value)} + onChange={(e) => + setCurrentPassword(e.target.value) + } fullWidth /> @@ -502,24 +549,36 @@ const Account: NextPageWithLayout = () => { }} > - + Новый пароль setNewPassword1(e.target.value)} + onChange={(e) => + setNewPassword1( + e.target.value + ) + } fullWidth /> - + Подтвердить пароль setNewPassword2(e.target.value)} + onChange={(e) => + setNewPassword2( + e.target.value + ) + } fullWidth /> @@ -533,8 +592,14 @@ const Account: NextPageWithLayout = () => { )} - - Удаление аккаунта + + + Удаление аккаунта + Удаление аккаунта приведет к потере всех настроек @@ -551,9 +616,14 @@ const Account: NextPageWithLayout = () => { /> - setConfirmDeleteModal(false)}> + setConfirmDeleteModal(false)} + > - Удаление аккаунта + + Удаление аккаунта + Вы действительно хотите удалить ваш аккаунт? @@ -591,7 +661,6 @@ const Account: NextPageWithLayout = () => { ) : ( <> )} - @@ -1,5 +1,14 @@ import React, { useEffect, useState } from 'react' -import { Box, Stack, Table, TableBody, TableHead, TableRow, TextField, Typography } from '@mui/material' +import { + Box, + Stack, + Table, + TableBody, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material' import Button from '@mui/material/Button' import TableCell from '@mui/material/TableCell' import axios from 'axios' @@ -7,7 +16,7 @@ import Image from 'next/image' import Link from 'next/link' import { useSession } from 'next-auth/react' -import { getAll, ResponseAllInfo } from '#/entities/user-account/model/user-type-slice' +import { getAll } from '#/entities/user-account/model/user-type-slice' import { ApiKeyModal } from '#/features/api-key-modal/api-key-modal' import { Layout } from '#/app/layout' import { useAppSelector } from '#/app/store/store' @@ -17,13 +26,14 @@ import { API_URL } from '#/shared/lib/constants' import styles from '#/shared/styles/api-keys.module.scss' import { InputStyleSmallDark, InputStyleSmallLight } from '#/shared/ui/input' import { NextPageWithLayout } from '#/pages/_app' +import { UserDTO } from '#/entities/user-account' const ApiKeys: NextPageWithLayout = () => { const [keys, setKeys] = useState | null>(null) const [loading, setLoading] = useState(false) const { data, status } = useSession() const [modal, setModal] = useState(false) - const [userInfo, setUserInfo] = useState() + const [userInfo, setUserInfo] = useState() const [disabled, setDisabled] = React.useState(true) useEffect(() => { @@ -78,9 +88,9 @@ const ApiKeys: NextPageWithLayout = () => { - API-ключ — это инструмент, который идентифицирует пользователя или программу, - запрашивающих доступ к API платформы. С помощью ключа можно отслеживать, кто и когда - пользуется API, рассчитывать оплату. + API-ключ — это инструмент, который идентифицирует пользователя или + программу, запрашивающих доступ к API платформы. С помощью ключа можно + отслеживать, кто и когда пользуется API, рассчитывать оплату.
@@ -285,31 +245,27 @@ const Page: NextPageWithLayout = () => { handleClickChatSetting={handleClickChatSetting} /> {desktop && ( - - {botParams?.versions && - botParams.versions?.length !== 0 && ( - <> - - ВЕРСИИ - - - - )} + + {botParams?.versions && botParams.versions?.length !== 0 && ( + <> + + ВЕРСИИ + + + + )} {botParams && botParams.parameters?.length > 0 && ( = () => { viewBox='0 0 21 13' fill='none' xmlns='http://www.w3.org/2000/svg' - className={`${ - params ? 'rotate-180' : 'rotate-0' - }`} + className={`${params ? 'rotate-180' : 'rotate-0'}`} > = () => { )} {botParams && botParams.parameters?.length > 0 ? ( - - - + + + ) : ( = () => { )} {!desktop && ( - + - {botParams?.versions && - botParams.versions?.length !== 0 && ( - <> - - ВЕРСИИ - - - - )} + {botParams?.versions && botParams.versions?.length !== 0 && ( + <> + + ВЕРСИИ + + + + )} {botParams && botParams.parameters?.length > 0 ? ( <> = () => { > ПАРАМЕТРЫ - - + + ) : ( = () => { )} - @@ -9,11 +9,12 @@ import { NextPageWithLayout } from '#/pages/_app' import { ChatModelCard, IShortModel } from '#/entities/model-entity' import styles from './chat-bots.module.scss' +import { useUserSelector } from '#/entities/user-account' const Page: NextPageWithLayout = () => { const [bots, setBots] = useState(null) const { data } = useSession() - const user = useAppSelector((state) => state.user) + const user = useUserSelector() useEffect(() => { if (!data) return @@ -6,35 +6,32 @@ import { useRouter } from 'next/router' import { API_URL } from '#/shared/lib/constants/constants' import { NextPageWithLayout } from '#/pages/_app' +import { signIn } from 'next-auth/react' const Confirm: NextPageWithLayout = () => { - const { push, query } = useRouter() - const [success, setSuccess] = useState(false) + const { push } = useRouter() + const [success, setSuccess] = useState(null) useEffect(() => { let formData: any = new FormData() - - formData.append('token', query.token) + const tokenData = window.location.search.slice(1).split('=')[1] + formData.append("token", tokenData) axios.post(API_URL + '/auth/confirm', formData, { headers: { 'Content-Type': 'multipart/form-data', - }, + }, validateStatus: (status) => status < 400 + }).then(() => { + setSuccess(true) + setTimeout(() => signIn('email_token', { token: tokenData, redirect: false }) + .then(() => push('/')) + .catch((err) => push({ pathname: '/login', query: { error: err ?? '' } })), 5000) + }).catch((err) => { + setSuccess(false) + setTimeout(() => push({ pathname: '/login', query: { error: err?.response.data.detail ?? '' } }), 5000) }) - .then(() => { - setSuccess(true) - }) - .catch(() => { - push('/') - }) }, []) - useEffect(() => { - if (success) { - setTimeout(() => push('/login'), 10000) - } - }, [success]) - return ( { textAlign: 'center', }} > - {' '} - {success ? ( + {success !== null && success ? ( + <> + + Ваша почта подтверждена, вы будете перенаправлены в личный кабинет + + + ) : success !== null && !success ? ( <> - Ваша почта подтверждена, вы будете перенаправлены на страницу авторизации + Произошла проблема при потверждении почты +
+ Уже получили сообщение об ошибке, пожалуйста - повторите попытку позже
- Войти в аккаунт + Вернуться к авторизации ) : ( @@ -1,5 +1,5 @@ import { useEffect, useMemo } from 'react' -import { Box, Collapse, Stack,Typography } from '@mui/material' +import { Box, Collapse, Stack, Typography } from '@mui/material' import Head from 'next/head' import { useRouter } from 'next/router' import { useSession } from 'next-auth/react' @@ -19,67 +19,40 @@ import { useImagesUniqInput } from '#/features/image-bot-input' import { useImageBotPagination } from '#/features/image-bot-pagination' import Title from '#/features/title/title' import { NextPageWithLayout } from '#/pages/_app' -import { DrawerCustom, Error, Loader,useShowData } from '#/shared' +import { DrawerCustom, Error, Loader } from '#/shared' import { c, getDeviceType, getOs } from '#/shared/lib/helpers' import { useThemeAndDevice } from '#/shared/lib/hooks' import { Device, DeviceOs } from '#/shared/lib/types/entities' import { ArrowDownScroll } from '#/shared/ui/icon-components/scroll-down-arrow' import { SvgIcon } from '#/shared/ui/svg' -import { ImageMessagesList,useImagesPagination } from '#/widgets/messages' +import { ImageMessagesList, useImagesPagination } from '#/widgets/messages' import { ModelInput } from '#/features/model-input' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' const ImageModelPage: NextPageWithLayout = () => { const { query } = useRouter() - const { - botParams, - version, - modelType, - fetchBotParams, - resetParams, - setDefaultParams, - setVersion, - } = useImageBot(query.slug as string) + const { botParams, version, modelType, fetchBotParams, resetParams, setDefaultParams, setVersion } = useImageBot(query.slug as string) const deviceType = getDeviceType() const deviceOs = getOs() const { ios, desktop } = useThemeAndDevice(deviceType, deviceOs) - const { error, showError } = useShowData() + const { showMessage } = useShowDataStore() const router = useRouter() const { data: session } = useSession() - const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = - useImagesBotFilters() + const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = useImagesBotFilters() - const { - refScrollMobile, - refScrollDesktop, - mobileScrollContainer, - onObserverMounted, - setMessages, - fetchMessages, - loading, - offset, - messages, - } = useImageBotPagination(deviceType) + const { refScrollMobile, refScrollDesktop, mobileScrollContainer, onObserverMounted, setMessages, fetchMessages, loading, offset, messages } = + useImageBotPagination(deviceType) - const { createImage, isComplete, createLoading } = useImageBotCreateImage( - showError, - modelType, - deviceType, - setMessages, - mobileScrollContainer - ) + const { createImage, isComplete, createLoading } = useImageBotCreateImage(showMessage, modelType, deviceType, setMessages, mobileScrollContainer) - const { onCreateImage, onLoadImage, image, setImage } = useImagesUniqInput( - version, - includeParams, - createImage - ) + const { onCreateImage, onLoadImage, image, setImage } = useImagesUniqInput(version, includeParams, createImage) async function onFetch() { await Promise.all([fetchBotParams()]) @@ -108,16 +81,9 @@ const ImageModelPage: NextPageWithLayout = () => { {botParams && botParams.tags.map((tag, index) => (
- + - - {tag.title} - + {tag.title}
))} @@ -166,21 +132,13 @@ const ImageModelPage: NextPageWithLayout = () => { imageLoad={onLoadImage} sendMessage={onCreateImage} unpinImage={() => setImage(null)} - viewMobileSettings={() => - setOpenFiltersMobile(true) - } + viewMobileSettings={() => setOpenFiltersMobile(true)} /> )} {botParams?.blocked && (
- - - Модель недоступна - + + Модель недоступна
)}
@@ -210,18 +168,8 @@ const ImageModelPage: NextPageWithLayout = () => { ) : ( - -
+ +
{ { ref={mobileScrollContainer} className={'smallScroll'} > -
+
{ getMessagesPagination={fetchMessages} />
- + {botParams && ( { imageLoad={onLoadImage} sendMessage={onCreateImage} unpinImage={() => setImage(null)} - viewMobileSettings={() => - setOpenFiltersMobile(true) - } + viewMobileSettings={() => setOpenFiltersMobile(true)} /> )} {botParams?.blocked && (
- - - Модель недоступна - + + Модель недоступна
)}
@@ -297,11 +229,7 @@ const ImageModelPage: NextPageWithLayout = () => { )} {desktop && ( - + {botParams?.versions && botParams.versions.length !== 0 ? ( <> { )} {botParams && botParams.parameters?.length > 0 ? ( - - - setOpenFiltersMobile(false)} - reset={resetParams} - /> + + + setOpenFiltersMobile(false)} reset={resetParams} /> ) : ( { )} )} - setOpenFiltersMobile(false)} - > + setOpenFiltersMobile(false)}> {botParams?.versions && botParams.versions.length !== 0 ? ( <> @@ -433,15 +346,8 @@ const ImageModelPage: NextPageWithLayout = () => { > ПАРАМЕТРЫ - - setOpenFiltersMobile(false)} - desktop={desktop} - reset={resetParams} - /> + + setOpenFiltersMobile(false)} desktop={desktop} reset={resetParams} /> ) : ( { )} - ) @@ -10,10 +10,11 @@ import { NextPageWithLayout } from '#/pages/_app' import { ImageModelCard, IShortModel } from '#/entities/model-entity' import styles from './image-models.module.scss' +import { useUserSelector } from '#/entities/user-account' export const ImageModelsPage: NextPageWithLayout = () => { const [bots, setBots] = useState(null) - const user = useAppSelector((state) => state.user) + const user = useUserSelector() const { data, status } = useSession() useEffect(() => { @@ -12,11 +12,12 @@ import { SocialMedia } from '#/shared/ui/social-media-block' import { Statistics } from '#/widgets/stats/stats' import { NextPageWithLayout } from '#/pages/_app' import { getDeviceType } from '#/shared/lib/helpers/get-type-device' +import { useUserSelector } from '#/entities/user-account' export interface MainPageProps {} const Main: NextPageWithLayout = () => { - const { first_name, account_type } = useAppSelector((state) => state.user) + const { first_name, account_type } = useUserSelector() const deviceType = getDeviceType() @@ -1,11 +1,136 @@ .imagebox { + position: relative; + background-color: var(--air-color); + width: 100%; + @media screen and (max-width: 1000px) { display: none; } + + &__image { + position: absolute; + right: -530px; + bottom: -490px; + transform: rotate(-13deg); + border: 10px solid rgba(255, 255, 255, 0.3); + border-radius: 20px; + } + + &__logo { + position: absolute; + left: 35px; + top: 35px; + } } -.form{ - @media screen and (max-width: 1000px) { +.login { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 100dvh; + background-color: white; + overflow: hidden; + + @media screen and (max-width: 1000px) { + grid-template-columns: 1fr; + grid-auto-rows: max-content; + overflow: scroll; + } + + &__left { + margin: 20px 0px; + @media screen and (max-width: 1000px) { + height: max-content; + } + } + + &__or { + padding-top: 16px; + text-align: center; + color: #a4aab5; + } + + &__title { + font-weight: 700; + font-size: 26px; + text-align: center; + margin-bottom: 16px; + } + + &__form { + width: 320px; + height: 100%; + display: flex; + align-items: center; + margin: 0 auto; + @media screen and (max-width: 1000px) { + width: 90%; + } + } + + &__active { + width: 100%; + } + + &__logo { + display: none; + margin: 0 auto; + margin-bottom: 40px; + @media screen and (max-width: 1000px) { + display: block; + } + } +} + +html[data-theme='dark'] { + .login { + background-color: var(--new-ui-bg-app-color); + } +} + + +.form { + width: 100%; + &__inputs { + display: flex; + flex-direction: column; + gap: 14px; + margin-bottom: 10px; + } + + &__navigate { + padding-top: 10px; + text-align: center; + font-size: 16px; + } +} + +.input{ + &__pass{ + display: flex; + align-items: center; + } + &__label{ + display: flex; + align-items: center; + justify-content: space-between; width: 100%; + } +} + +.controls { + padding-top: 25px; + width: 100%; + + &__progress { + display: flex; + justify-content: center; + span{ + color: var(--air-color); + } } -} \ No newline at end of file + + &__create { + width: 100%; + } +} + @@ -0,0 +1,128 @@ +import { RegisterPage } from '#/views/register' +import '@testing-library/jest-dom' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { jestRender } from '#/../jest/utils/render' +import { windowMock } from '#/../jest/utils/window-mock' +import fetchMock from 'jest-fetch-mock' +import Login from './login' +import { signIn } from 'next-auth/react' +import { useShowDataStore } from '#/shared/lib/hooks' +import { create } from 'zustand' +import { useRouter } from '#/../__mocks__/next/router' + +jest.mock('next-auth/react') + +jest.mock('zustand', () => { + return { + create: jest.fn(), + } +}) + +const showMessageMock = jest.fn() + +jest.mock('#/shared/lib/hooks', () => { + return { + useShowDataStore: jest.fn(() => { + return { + showMessage: showMessageMock, + } + }), + } +}) + +jest.mock('next/router') +jest.mock('@sentry/nextjs') + +fetchMock.enableMocks() +windowMock() + +describe('login-page', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('renders in register', () => { + jestRender() + + const form = screen.getByTestId('register-email-form') + + expect(form).toBeInTheDocument() + }) + + it('submit by button click', async () => { + await act(async () => { + jestRender() + }) + + const submitButton = screen.getByTestId('submit-button') + const emailInput = screen.getByTestId('email-input') + const passwordInput = screen.getByTestId('password-input') + + ;(signIn as jest.Mock).mockResolvedValue({ + error: null, + status: 200, + ok: true, + url: '', + }) + + fireEvent.change(emailInput, { target: { value: 'aleksander.freelancer@gmail.com' } }) + fireEvent.change(passwordInput, { target: { value: 'geraldisrivii' } }) + + await act(async () => { + fireEvent.click(submitButton) + }) + + await waitFor(() => { + expect(signIn).toHaveBeenCalledWith('credentials', { + username: 'aleksander.freelancer@gmail.com', + password: 'geraldisrivii', + redirect: false, + }) + }) + + await waitFor(() => { + const { push } = useRouter() + expect(push).toHaveBeenCalledWith('/') + }) + }) + + it('submit by button click with some error (email in example)', async () => { + await act(async () => { + jestRender() + }) + ;(useShowDataStore as any as jest.Mock).mockImplementation(() => ({ + showMessage: showMessageMock, + })) + + const submitButton = screen.getByTestId('submit-button') + const emailInput = screen.getByTestId('email-input') + const passwordInput = screen.getByTestId('password-input') + + ;(signIn as jest.Mock).mockResolvedValue({ + error: 'email_not_correct', + status: 400, + ok: false, + url: '', + }) + + fireEvent.change(emailInput, { target: { value: 'aleksander.freelancer@gmail.com' } }) + fireEvent.change(passwordInput, { target: { value: 'geraldisrivii' } }) + + await act(async () => { + fireEvent.click(submitButton) + }) + + await waitFor(() => { + expect(signIn).toHaveBeenCalledWith('credentials', { + username: 'aleksander.freelancer@gmail.com', + password: 'geraldisrivii', + redirect: false, + }) + }) + + await waitFor(() => { + expect(showMessageMock).toHaveBeenCalledWith('Неверный email') + }) + }) +}) @@ -1,267 +1,70 @@ import * as React from 'react' -import { useCookies } from 'react-cookie' -import { useForm } from 'react-hook-form' -import { SubmitErrorHandler } from 'react-hook-form/dist/types/form' import { Box, Button, InputAdornment, Link, Stack, TextField, Typography } from '@mui/material' import CircularProgress from '@mui/material/CircularProgress' import Image from 'next/image' -import { Router, useRouter } from 'next/router' -import Script from 'next/script' -import { signIn } from 'next-auth/react' -import { authTelegram } from '#/features/auth-telegram' -import { PasswordOptions } from '#/features/register-by-email/lib/constants' -import { IEmailForms } from '#/features/register-by-email/model/types' import { useAppSelector } from '#/app/store/store' -import { emailOptions, Error } from '#/shared' -import { getDeviceType } from '#/shared/lib/helpers' -import { useShowData } from '#/shared/lib/hooks' +import { c, getDeviceType } from '#/shared/lib/helpers' import { InputStyleDark, InputStyleLight } from '#/shared/ui/input' import { NextPageWithLayout } from '#/pages/_app' import styles from './login.module.scss' -import { ERROR_MAPPING, ERRROR_YANDEX_TRANSLATE_MAPPING } from '#/pages/api/auth/constants' import OpenedEyeSvg from '#/assets/svg/opened-eye.svg?react' import ClosedEyeSvg from '#/assets/svg/closed-eye.svg?react' -import * as Sentry from '@sentry/nextjs' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' +import { YandexAuthButton } from '#/features/yandex-auth-button' +import { useLoginCookies } from '#/features/login-cookies' +import { useLoginForm, useLoginValidate } from '#/features/login-form' +import { useLoginQueryErrors } from '#/features/login-query-errors' +import LogoSvg from '#/assets/svg/logo.svg?react' +import { CommonInput } from '#/shared/ui/common-input' +import { CommonButton } from '#/shared/ui/button' const Login: NextPageWithLayout = () => { - const device = getDeviceType() + const { setAllQueryToCookies } = useLoginCookies() - const { query, push } = useRouter() + const { passwordOptions, emailOptions } = useLoginValidate() - const [, setCookie] = useCookies() + const { onMounted } = useLoginQueryErrors() - React.useEffect(() => { - Object.entries(query).forEach(([key, value]) => setCookie(key, value)) - }, []) - - const { error, showError } = useShowData() - - const [loading, setLoading] = React.useState(false) + const [passwordShowed, showPassword] = useState(false) - const [passwordShowed, showPassword] = React.useState(false) - - const desktop = device === 'desktop' - - const { register, handleSubmit, getValues, reset } = useForm() + const { register, onSubmit, pending } = useLoginForm() useEffect(() => { - const params = new URL(window.location.href).searchParams - - const error = params.get('error') - - if (!error || error === '') return - - const expectedError = ERRROR_YANDEX_TRANSLATE_MAPPING[error] - showError(expectedError ?? 'Непредвиденная ошибка') - if (!expectedError) Sentry.captureMessage(error) + setAllQueryToCookies() + onMounted() }, []) - async function onSubmit(data: any) { - setLoading(true) - - if (window.sessionStorage.__telegram__initParams !== '{}') { - await authTelegram(data.email, data.password) - setLoading(false) - reset() - return - } - - const resp = await signIn('credentials', { - username: data.email, - password: data.password, - redirect: false, - }) - if (resp && !resp.ok && resp.error && resp.error !== '') { - const expectedError = Object.keys(ERROR_MAPPING).reduce( - (prev, curr) => ({ ...prev, [ERROR_MAPPING[curr]]: curr }), - {} as Record - )[resp.error] - showError(expectedError ?? 'Непредвиденная ошибка') - if (!expectedError) Sentry.captureMessage(resp.error) - setLoading(false) - } else { - push('/') - } - } - - const checkError: SubmitErrorHandler = (data) => { - showError(Object.values(data)[0].message || 'Неверные данные') - } - - const handleKeyDown = (event: any) => { - if (event.key === 'Enter') { - handleSubmit(onSubmit)() - } - } - - const handleInputChangeTrim = (event: any) => { - event.target.value = event.target.value.trim() - } - - const theme = useAppSelector((state) => state.theme.theme) - return ( <> -