@@ -0,0 +1,6 @@ + + + + \ No newline at end of file @@ -150,7 +150,7 @@ background-color: rgba(0, 0, 0, 0.7); border-radius: 15px; padding: 20px; - z-index: 10000; + z-index: 1000; &:hover { outline: 4px solid #8280ff66; outline-offset: -3px; @@ -148,7 +148,7 @@ background-color: rgba(0, 0, 0, 0.7); border-radius: 15px; padding: 20px; - z-index: 10000; + z-index: 1000; &:hover { outline: 4px solid #8280ff66; outline-offset: -3px; @@ -0,0 +1,17 @@ +import { api } from '#/shared/api' + +interface SendReport { + errorMessage: string + email: string + file?: File +} + +export async function sendReport({errorMessage, email, file}: SendReport) { + let formData = new FormData() + + formData.append('report_text', `${email}: ${errorMessage}`) + if (file) { + formData.append('images', file) + } + return await api.post('/reports/', formData) +} @@ -0,0 +1 @@ +export * from './error-report.api' \ No newline at end of file @@ -0,0 +1,64 @@ +import { useAppSelector } from '#/app/store/store' +import { API_URL } from '#/shared/lib/constants' +import axios from 'axios' +import { useSession } from 'next-auth/react' +import React, { ChangeEvent } from 'react' +import { sendReport } from '../api' +import { makePrivateRequest } from '#/shared/api' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' + +export const useErrorReport = () => { + + const { showMessage } = useShowDataStore() + const [errorMessage, setErrorMessage] = React.useState('') + + const [error, setError] = React.useState(false) + + const [isSend, setIsSend] = React.useState(false) + + const [file, setFile] = React.useState() + + const { data: session } = useSession() + + const email = useAppSelector((state) => state.user.email) + + const handleFileChange = (e: ChangeEvent) => { + if (e.target.files) { + if (4500000 > e.target.files[0].size) setFile(e.target.files[0]) + } + } + + const sendError = makePrivateRequest(async () => { + if (errorMessage.trim() === '') { + showMessage("Поле не должно быть пустым.") + setError(true) + setTimeout(() => setError(false), 3000) + return + } + + try { + const { status } = await sendReport({ errorMessage, email, file }) + + if (status === 201) { + setErrorMessage('') + setIsSend(true) + setTimeout(() => { + setIsSend(false) + }, 4000) + } + } catch (err) { + setError(true) + setTimeout(() => setError(false), 3000) + } + }) + + return { + isSend, + setErrorMessage, + handleFileChange, + file, + setFile, + sendError, + error, + } +} @@ -0,0 +1,78 @@ +.container { + width: 589px; + height: auto; + background-color: var(--background-color-additional); + padding: 32px; + border-radius: 20px; + + box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 10px -5px, rgba(0, 0, 0, 0.14) 0px 16px 24px 2px, rgba(0, 0, 0, 0.12) 0px 6px 30px 5px; + + &_sended { + display: flex; + gap: 16px; + flex-direction: column; + width: 300px; + text-align: center; + } + + &__buttons { + display: flex; + justify-content: flex-end; + flex-direction: row; + align-items: flex-end; + margin-top: 12px; + gap: 8px; + } + + &__close { + transform: rotate(45deg); + margin-left: 8px; + color: var(--new-ui-gray-color); + } +} + +.text { + color: #868686; + font-size: 17px; + line-height: 1.5; + + &_accent { + color: #22b47f; + font-size: 23px; + } + + &_heading { + font-size: 21px; + font-weight: 500; + } + + &_file { + font-size: 15px; + } +} + +.textarea { + position: relative; + margin: 20px 0px 9.6px 0px; + + &__button { + position: absolute; + padding: 0; + bottom: 16px; + left: 8px; + background-color: transparent; + box-shadow: none; + margin: 0; + rotate: 30deg; + + &:hover { + background-color: transparent; + box-shadow: none; + } + } + + &__file { + display: flex; + align-items: center; + } +} @@ -0,0 +1,92 @@ +import '@testing-library/jest-dom' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { jestRender } from '#/../jest/utils/render' +import { windowMock } from '#/../jest/utils/window-mock' +import { ERROR_REPORT, PLATE_CHANGE_PASSWORD } from '#/features/modals' +import { getModalTest } from '#/../jest/utils/modal-tester' +import { api } from '#/shared/api/instance' +import { ErrorReportPlate } from './error-report' +import axios from 'axios' +import { sendReport } from '../api' + +jest.mock('next/router') +jest.mock('@sentry/nextjs') + +jest.mock('../api') + +const showMessageMock = jest.fn() +const useSessionMock = jest.fn() + +jest.mock('next-auth/react', () => { + return { + useSession: () => useSessionMock(), + } +}) + +jest.mock('#/shared/lib/hooks/use-show-data', () => { + return { + useShowDataStore: jest.fn(() => { + return { + showMessage: showMessageMock, + } + }), + } +}) + +useSessionMock.mockReturnValue({ + data: { + access: 'something access', + }, +}) + +windowMock() + +describe('error-report', () => { + it('successfully show modal', async () => { + jestRender() + + const modal = await getModalTest(ERROR_REPORT) + + act(() => { + modal.setState(true) + }) + ;(sendReport as jest.Mock).mockResolvedValue({ + status: 201, + data: { detail: 'error report sent' }, + }) + + const textarea = screen.getByPlaceholderText('Опишите детально возникшую проблему') + const button = screen.getByText('Отправить') + + fireEvent.change(textarea, { target: { value: 'О нет все пропало' } }) + + act(() => { + fireEvent.click(button) + }) + + await waitFor(() => { + expect(sendReport).toHaveBeenCalledWith({ errorMessage: 'О нет все пропало', email: '' }) + }) + }) + + it('empty textarea', async () => { + jestRender() + + const modal = await getModalTest(ERROR_REPORT) + + act(() => { + modal.setState(true) + }) + + const textarea = screen.getByPlaceholderText('Опишите детально возникшую проблему') + const button = screen.getByText('Отправить') + + act(() => { + fireEvent.click(button) + }) + + await waitFor(() => { + expect(showMessageMock).toHaveBeenCalledWith("Поле не должно быть пустым.") + }) + }) +}) @@ -0,0 +1,73 @@ +import * as React from 'react' +import AttachSvg from '#/assets/svg/attach.svg?react' +import PlusSvg from '#/assets/svg/plus.svg?react' +import { ERROR_REPORT, getModalById, PlateTemplate } from '#/features/modals' + +import styles from './error-report.module.scss' +import { c } from '#/shared' +import { CommonTextArea } from '#/shared/ui/common-textarea' +import { CommonButton } from '#/shared/ui/button' +import { useErrorReport } from '../model/use-error-report' + +export const ErrorReportPlate = () => { + const modal = getModalById(ERROR_REPORT) + + const { error, isSend, setErrorMessage, handleFileChange, file, setFile, sendError } = useErrorReport() + + return ( + + {isSend ? ( +
+

Спасибо!

+

Ваше сообщение направлено нашим специалистам. Ответ придет на почту, указанную при регистрации

+
+ ) : ( +
e.stopPropagation()}> +

Сообщить об ошибке

+ setErrorMessage(evt.target.value)} + buttomSlot={ + <> + + + } + /> + {file && ( +
+

Добавлено 1 изображение: {file.name}

+ + +
+ )} + +
+ modal.setState(false)}> + Отмена + + + Отправить + +
+
+ )} +
+ ) +} @@ -0,0 +1 @@ +export * from './error-report' \ No newline at end of file @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -1,2 +1,3 @@ export const PLATE_CHANGE_PASSWORD = 'plate-change-password' export const RESEND_INVATION_PASSWORD = 'resend-invation-password' +export const ERROR_REPORT = 'error-report' @@ -12,7 +12,6 @@ export interface CommonInputProps extends React.InputHTMLAttributes } export const CommonInput = forwardRef( @@ -0,0 +1,120 @@ +.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%; + + &_error { + color: #97989f; + font-size: 14px; + margin-bottom: 8px; + } +} + +.textarea { + line-height: 28px; + padding: 0; + resize: none; + background-color: transparent; + border: none; + outline: none; + font-size: 16px; + width: 100%; + font-family: '__Raleway_9d395e' !important; + + &:focus { + border: none; + outline: none; + } + + &_default { + font-size: 19px; + color: var(--new-ui-text-color) !important; + } + + &_error { + color: #ff2372 !important; + &::placeholder { + color: #ff2372 !important; + } + } +} + +.wrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + transition: all 0.3 ease-in; + + &_default { + color: var(--new-ui-text-color) !important; + padding: 16.5px 14px; + background-color: rgb(61, 61, 61); + border: 1px solid var(--border-color2); + border-radius: 8px; + + &:hover { + border-color: var(--border-color); + } + + &::placeholder { + color: var(--border-color2) !important; + } + } + + &_error { + border-color: #ff2372; + + &::placeholder { + color: #ff2372 !important; + } + + &:focus { + border: 2px solid #ff2372; + } + } +} + +html[data-theme='light'] { + .wrapper { + &_error { + border-color: #ff76a7 !important; + &::placeholder { + color: #ff76a7 !important; + } + &:focus { + border: 2px solid #ff76a7 !important; + } + } + + &_default { + background-color: white; + } + } +} @@ -0,0 +1,55 @@ +import { c } from '#/shared/lib/helpers' +import { FieldError } from 'react-hook-form' +import styles from './common-textarea.module.scss' +import React, { ForwardedRef, forwardRef, useEffect } from 'react' + +export type TextAreaVariant = 'default' + +export interface CommonTextAreaProps extends React.TextareaHTMLAttributes { + children?: React.ReactNode + variant?: TextAreaVariant + label?: string | React.ReactNode + error?: FieldError + buttomSlot?: React.ReactNode + trim?: boolean + showError?: boolean +} + +export const CommonTextArea = forwardRef( + ({ showError = true, buttomSlot, label, error, className, disabled, trim = false, variant = 'default', ...props }, ref) => { + const onChange = (event: React.ChangeEvent) => { + event.target.value = trim ? event.target.value.trim() : event.target.value + } + + return ( +
+
+ {label && } +
+
+