├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.js ├── .stylelintrc.js ├── @types └── api.d.ts ├── Jenkinsfile ├── README.md ├── dockerfile ├── eslint.config.js ├── generated └── api │ └── index.ts ├── index.html ├── nginx.conf ├── orval.config.ts ├── package.json ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico └── site.webmanifest ├── src ├── App.tsx ├── components │ ├── Button │ │ ├── Button.module.css │ │ └── Button.tsx │ ├── Input │ │ ├── Input.module.css │ │ └── Input.tsx │ ├── Typography │ │ └── Typography.tsx │ └── index.ts ├── main.tsx ├── modules │ ├── auth │ │ ├── components │ │ │ └── Countdown │ │ │ │ ├── Countdown.module.css │ │ │ │ └── Countdown.tsx │ │ ├── constants │ │ │ ├── index.ts │ │ │ ├── otpFormScheme.ts │ │ │ ├── phoneFormScheme.ts │ │ │ └── sizes.ts │ │ ├── hooks │ │ │ └── useView.ts │ │ ├── view.module.css │ │ └── view.tsx │ └── profile │ │ ├── constants │ │ ├── index.ts │ │ ├── profileFormScheme.ts │ │ └── sizes.ts │ │ ├── hooks │ │ └── useView.ts │ │ ├── view.module.css │ │ └── view.tsx ├── provider.tsx ├── styles │ ├── global.css │ ├── reset.css │ └── typography.css ├── utils │ ├── api │ │ └── instance.ts │ ├── constants │ │ ├── index.ts │ │ └── keys.ts │ └── store │ │ └── index.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint-staged 2 | yarn build -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | import { prettier } from '@siberiacancode/prettier'; 2 | 3 | /** @type {import('prettier').Config} */ 4 | export default prettier; 5 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | import { stylelint } from '@siberiacancode/stylelint'; 2 | 3 | /** @type {import('stylelint').Config} */ 4 | export default { 5 | ...stylelint, 6 | rules: { 7 | ...stylelint.rules, 8 | 'custom-property-pattern': null 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /@types/api.d.ts: -------------------------------------------------------------------------------- 1 | interface MutationSettings { 2 | config?: ApiRequestConfig; 3 | options?: import('@tanstack/react-query').UseMutationOptions< 4 | Awaited>, 5 | any, 6 | Params, 7 | any 8 | >; 9 | } 10 | 11 | interface QuerySettings { 12 | config?: ApiRequestConfig; 13 | options?: Omit< 14 | import('@tanstack/react-query').UseQueryOptions< 15 | Awaited>, 16 | any, 17 | Awaited>, 18 | any 19 | >, 20 | 'queryKey' 21 | >; 22 | } 23 | 24 | type ApiRequestConfig = import('axios').AxiosRequestConfig; 25 | 26 | type RequestConfig = Params extends undefined 27 | ? { config?: ApiRequestConfig } 28 | : { params: Params; config?: ApiRequestConfig }; 29 | 30 | interface BaseResponse { 31 | reason?: string; 32 | success: boolean; 33 | } 34 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | environment { 4 | GITHUB_TOKEN=credentials('github-container') 5 | IP=credentials('yandex-apps-ip') 6 | 7 | IMAGE_NAME='shift-intensive/web-tester' 8 | IMAGE_VERSION='latest' 9 | PORT='3004' 10 | } 11 | stages { 12 | stage('cleanup') { 13 | steps { 14 | sh 'docker system prune -a --volumes --force' 15 | } 16 | } 17 | stage('build image') { 18 | steps { 19 | sh 'docker build -t $IMAGE_NAME:$IMAGE_VERSION .' 20 | } 21 | } 22 | stage('login to GHCR') { 23 | steps { 24 | sh 'echo $GITHUB_TOKEN_PSW | docker login ghcr.io -u $GITHUB_TOKEN_USR --password-stdin' 25 | } 26 | } 27 | stage('tag image') { 28 | steps { 29 | sh 'docker tag $IMAGE_NAME:$IMAGE_VERSION ghcr.io/$IMAGE_NAME:$IMAGE_VERSION' 30 | } 31 | } 32 | stage('push image') { 33 | steps { 34 | sh 'docker push ghcr.io/$IMAGE_NAME:$IMAGE_VERSION' 35 | } 36 | } 37 | stage('deploy') { 38 | steps { 39 | withCredentials(bindings: [sshUserPrivateKey(credentialsId: 'yandex-apps-container', keyFileVariable: 'SSH_PRIVATE_KEY', usernameVariable: 'SSH_USERNAME')]) { 40 | sh 'echo $SSH_USERNAME' 41 | sh 'install -m 600 -D /dev/null ~/.ssh/id_rsa' 42 | sh 'rm ~/.ssh/id_rsa' 43 | sh 'cp -i $SSH_PRIVATE_KEY ~/.ssh/id_rsa' 44 | sh 'ssh -o "StrictHostKeyChecking=no" $SSH_USERNAME@$IP \ 45 | "sudo docker login ghcr.io -u $GITHUB_TOKEN_USR --password $GITHUB_TOKEN_PSW &&\ 46 | sudo docker rm -f shift-intensive-web-tester &&\ 47 | sudo docker pull ghcr.io/shift-intensive/web-tester:latest &&\ 48 | sudo docker run --restart=always --name shift-intensive-web-tester -d -p $PORT:80 -e PORT=80 --network shift-intensive ghcr.io/shift-intensive/web-tester:latest"' 49 | } 50 | } 51 | } 52 | } 53 | post { 54 | always { 55 | sh 'docker logout' 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **shift intensiv web tester 🧪** 2 | 3 | Данный репозиторий содержит test приложения для выполнения тестировщиков. 4 | 5 | ## Системные требования 6 | 7 | > необходимо скачать [**nodejs/npm**](https://nodejs.org/en/download/) 8 | 9 | - **nodejs** 10 | - **yarn** 11 | 12 | ## Как запустить 13 | 14 | Сначала нужно установить npm пакеты 15 | 16 | ``` 17 | yarn 18 | ``` 19 | 20 | После чего запустить сервер 21 | 22 | ``` 23 | yarn dev 24 | ``` 25 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | LABEL org.opencontainers.image.source https://github.com/shift-intensive/web-tester 3 | 4 | FROM base AS builder 5 | 6 | WORKDIR /app 7 | COPY package*.json ./ 8 | COPY yarn.lock ./ 9 | RUN yarn --production --frozen-lockfile 10 | RUN yarn add vite @vitejs/plugin-react 11 | 12 | COPY . . 13 | 14 | RUN yarn build 15 | 16 | FROM nginx:latest 17 | 18 | COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf 19 | COPY --from=builder /app/dist /usr/share/nginx/html/tester 20 | 21 | EXPOSE 80 443 22 | 23 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { eslint } from '@siberiacancode/eslint'; 2 | 3 | export default eslint( 4 | { 5 | javascript: true, 6 | typescript: true, 7 | react: true, 8 | jsx: true 9 | }, 10 | { 11 | name: 'web-tester/global', 12 | ignores: ['generated'] 13 | } 14 | ); 15 | -------------------------------------------------------------------------------- /generated/api/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by orval v7.4.1 🍺 3 | * Do not edit manually. 4 | * shift tester 🧪 5 | * Апи для тестирования 6 | * OpenAPI spec version: 1.0 7 | */ 8 | import { useMutation, useQuery } from '@tanstack/react-query'; 9 | import type { 10 | DataTag, 11 | DefinedInitialDataOptions, 12 | DefinedUseQueryResult, 13 | MutationFunction, 14 | QueryFunction, 15 | QueryKey, 16 | UndefinedInitialDataOptions, 17 | UseMutationOptions, 18 | UseMutationResult, 19 | UseQueryOptions, 20 | UseQueryResult 21 | } from '@tanstack/react-query'; 22 | import { instance } from '../../src/utils/api/instance'; 23 | export interface SignInResponse { 24 | /** Статус запроса */ 25 | success: boolean; 26 | /** Причина ошибки */ 27 | reason?: string; 28 | /** Пользователь */ 29 | user: User; 30 | /** Пользовательский токен */ 31 | token: string; 32 | } 33 | 34 | export interface SignInDto { 35 | /** Номер телефона */ 36 | phone: string; 37 | /** Отп код */ 38 | code: number; 39 | } 40 | 41 | export interface OtpResponse { 42 | /** Статус запроса */ 43 | success: boolean; 44 | /** Причина ошибки */ 45 | reason?: string; 46 | /** Время запроса повторного отп кода в мс */ 47 | retryDelay: number; 48 | } 49 | 50 | export interface CreateOtpDto { 51 | phone: string; 52 | } 53 | 54 | export interface UpdateProfileProfileDto { 55 | /** Имя */ 56 | firstname?: string; 57 | /** Отчество */ 58 | middlename?: string; 59 | /** Фамилия */ 60 | lastname?: string; 61 | /** Почта */ 62 | email?: string; 63 | /** Город */ 64 | city?: string; 65 | } 66 | 67 | export interface UpdateProfileDto { 68 | /** Данные пользователя */ 69 | profile: UpdateProfileProfileDto; 70 | /** Номер телефона */ 71 | phone: string; 72 | } 73 | 74 | export interface User { 75 | /** Номер телефона */ 76 | phone: string; 77 | /** Имя */ 78 | firstname?: string; 79 | /** Отчество */ 80 | middlename?: string; 81 | /** Фамилия */ 82 | lastname?: string; 83 | /** Почта */ 84 | email?: string; 85 | /** Город */ 86 | city?: string; 87 | } 88 | 89 | export interface UpdateProfileResponse { 90 | /** Статус запроса */ 91 | success: boolean; 92 | /** Причина ошибки */ 93 | reason?: string; 94 | /** Пользователь */ 95 | user: User; 96 | } 97 | 98 | export interface SessionResponse { 99 | /** Статус запроса */ 100 | success: boolean; 101 | /** Причина ошибки */ 102 | reason?: string; 103 | /** Пользователь */ 104 | user: User; 105 | } 106 | 107 | /** 108 | * @summary получить сессию пользователя 109 | */ 110 | export const testerControllerSession = (signal?: AbortSignal) => { 111 | return instance({ url: `/api/tester/session`, method: 'GET', signal }); 112 | }; 113 | 114 | export const getTesterControllerSessionQueryKey = () => { 115 | return [`/api/tester/session`] as const; 116 | }; 117 | 118 | export const getTesterControllerSessionQueryOptions = < 119 | TData = Awaited>, 120 | TError = unknown 121 | >(options?: { 122 | query?: Partial< 123 | UseQueryOptions>, TError, TData> 124 | >; 125 | }) => { 126 | const { query: queryOptions } = options ?? {}; 127 | 128 | const queryKey = queryOptions?.queryKey ?? getTesterControllerSessionQueryKey(); 129 | 130 | const queryFn: QueryFunction>> = ({ 131 | signal 132 | }) => testerControllerSession(signal); 133 | 134 | return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< 135 | Awaited>, 136 | TError, 137 | TData 138 | > & { queryKey: DataTag }; 139 | }; 140 | 141 | export type TesterControllerSessionQueryResult = NonNullable< 142 | Awaited> 143 | >; 144 | export type TesterControllerSessionQueryError = unknown; 145 | 146 | export function useTesterControllerSession< 147 | TData = Awaited>, 148 | TError = unknown 149 | >(options: { 150 | query: Partial< 151 | UseQueryOptions>, TError, TData> 152 | > & 153 | Pick< 154 | DefinedInitialDataOptions>, TError, TData>, 155 | 'initialData' 156 | >; 157 | }): DefinedUseQueryResult & { queryKey: DataTag }; 158 | export function useTesterControllerSession< 159 | TData = Awaited>, 160 | TError = unknown 161 | >(options?: { 162 | query?: Partial< 163 | UseQueryOptions>, TError, TData> 164 | > & 165 | Pick< 166 | UndefinedInitialDataOptions< 167 | Awaited>, 168 | TError, 169 | TData 170 | >, 171 | 'initialData' 172 | >; 173 | }): UseQueryResult & { queryKey: DataTag }; 174 | export function useTesterControllerSession< 175 | TData = Awaited>, 176 | TError = unknown 177 | >(options?: { 178 | query?: Partial< 179 | UseQueryOptions>, TError, TData> 180 | >; 181 | }): UseQueryResult & { queryKey: DataTag }; 182 | /** 183 | * @summary получить сессию пользователя 184 | */ 185 | 186 | export function useTesterControllerSession< 187 | TData = Awaited>, 188 | TError = unknown 189 | >(options?: { 190 | query?: Partial< 191 | UseQueryOptions>, TError, TData> 192 | >; 193 | }): UseQueryResult & { queryKey: DataTag } { 194 | const queryOptions = getTesterControllerSessionQueryOptions(options); 195 | 196 | const query = useQuery(queryOptions) as UseQueryResult & { 197 | queryKey: DataTag; 198 | }; 199 | 200 | query.queryKey = queryOptions.queryKey; 201 | 202 | return query; 203 | } 204 | 205 | /** 206 | * @summary обновить профиль пользователя 207 | */ 208 | export const testerControllerUpdateProfile = (updateProfileDto: UpdateProfileDto) => { 209 | return instance({ 210 | url: `/api/tester/profile`, 211 | method: 'PATCH', 212 | headers: { 'Content-Type': 'application/json' }, 213 | data: updateProfileDto 214 | }); 215 | }; 216 | 217 | export const getTesterControllerUpdateProfileMutationOptions = < 218 | TData = Awaited>, 219 | TError = unknown, 220 | TContext = unknown 221 | >(options?: { 222 | mutation?: UseMutationOptions; 223 | }) => { 224 | const mutationKey = ['testerControllerUpdateProfile']; 225 | const { mutation: mutationOptions } = options 226 | ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey 227 | ? options 228 | : { ...options, mutation: { ...options.mutation, mutationKey } } 229 | : { mutation: { mutationKey } }; 230 | 231 | const mutationFn: MutationFunction< 232 | Awaited>, 233 | { data: UpdateProfileDto } 234 | > = (props) => { 235 | const { data } = props ?? {}; 236 | 237 | return testerControllerUpdateProfile(data); 238 | }; 239 | 240 | return { mutationFn, ...mutationOptions } as UseMutationOptions< 241 | TData, 242 | TError, 243 | { data: UpdateProfileDto }, 244 | TContext 245 | >; 246 | }; 247 | 248 | export type TesterControllerUpdateProfileMutationResult = NonNullable< 249 | Awaited> 250 | >; 251 | export type TesterControllerUpdateProfileMutationBody = UpdateProfileDto; 252 | export type TesterControllerUpdateProfileMutationError = unknown; 253 | 254 | /** 255 | * @summary обновить профиль пользователя 256 | */ 257 | export const useTesterControllerUpdateProfile = < 258 | TData = Awaited>, 259 | TError = unknown, 260 | TContext = unknown 261 | >(options?: { 262 | mutation?: UseMutationOptions; 263 | }): UseMutationResult => { 264 | const mutationOptions = getTesterControllerUpdateProfileMutationOptions(options); 265 | 266 | return useMutation(mutationOptions); 267 | }; 268 | 269 | /** 270 | * @summary создание отп кода 271 | */ 272 | export const testerControllerCreateOtp = (createOtpDto: CreateOtpDto, signal?: AbortSignal) => { 273 | return instance({ 274 | url: `/api/tester/auth/otp`, 275 | method: 'POST', 276 | data: createOtpDto, 277 | signal 278 | }); 279 | }; 280 | 281 | export const getTesterControllerCreateOtpMutationOptions = < 282 | TData = Awaited>, 283 | TError = unknown, 284 | TContext = unknown 285 | >(options?: { 286 | mutation?: UseMutationOptions; 287 | }) => { 288 | const mutationKey = ['testerControllerCreateOtp']; 289 | const { mutation: mutationOptions } = options 290 | ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey 291 | ? options 292 | : { ...options, mutation: { ...options.mutation, mutationKey } } 293 | : { mutation: { mutationKey } }; 294 | 295 | const mutationFn: MutationFunction< 296 | Awaited>, 297 | { data: CreateOtpDto } 298 | > = (props) => { 299 | const { data } = props ?? {}; 300 | 301 | return testerControllerCreateOtp(data); 302 | }; 303 | 304 | return { mutationFn, ...mutationOptions } as UseMutationOptions< 305 | TData, 306 | TError, 307 | { data: CreateOtpDto }, 308 | TContext 309 | >; 310 | }; 311 | 312 | export type TesterControllerCreateOtpMutationResult = NonNullable< 313 | Awaited> 314 | >; 315 | export type TesterControllerCreateOtpMutationBody = CreateOtpDto; 316 | export type TesterControllerCreateOtpMutationError = unknown; 317 | 318 | /** 319 | * @summary создание отп кода 320 | */ 321 | export const useTesterControllerCreateOtp = < 322 | TData = Awaited>, 323 | TError = unknown, 324 | TContext = unknown 325 | >(options?: { 326 | mutation?: UseMutationOptions; 327 | }): UseMutationResult => { 328 | const mutationOptions = getTesterControllerCreateOtpMutationOptions(options); 329 | 330 | return useMutation(mutationOptions); 331 | }; 332 | 333 | /** 334 | * @summary авторизация 335 | */ 336 | export const testerControllerSignin = (signInDto: SignInDto, signal?: AbortSignal) => { 337 | return instance({ 338 | url: `/api/tester/signin`, 339 | method: 'POST', 340 | headers: { 'Content-Type': 'application/json' }, 341 | data: signInDto, 342 | signal 343 | }); 344 | }; 345 | 346 | export const getTesterControllerSigninMutationOptions = < 347 | TData = Awaited>, 348 | TError = unknown, 349 | TContext = unknown 350 | >(options?: { 351 | mutation?: UseMutationOptions; 352 | }) => { 353 | const mutationKey = ['testerControllerSignin']; 354 | const { mutation: mutationOptions } = options 355 | ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey 356 | ? options 357 | : { ...options, mutation: { ...options.mutation, mutationKey } } 358 | : { mutation: { mutationKey } }; 359 | 360 | const mutationFn: MutationFunction< 361 | Awaited>, 362 | { data: SignInDto } 363 | > = (props) => { 364 | const { data } = props ?? {}; 365 | 366 | return testerControllerSignin(data); 367 | }; 368 | 369 | return { mutationFn, ...mutationOptions } as UseMutationOptions< 370 | TData, 371 | TError, 372 | { data: SignInDto }, 373 | TContext 374 | >; 375 | }; 376 | 377 | export type TesterControllerSigninMutationResult = NonNullable< 378 | Awaited> 379 | >; 380 | export type TesterControllerSigninMutationBody = SignInDto; 381 | export type TesterControllerSigninMutationError = unknown; 382 | 383 | /** 384 | * @summary авторизация 385 | */ 386 | export const useTesterControllerSignin = < 387 | TData = Awaited>, 388 | TError = unknown, 389 | TContext = unknown 390 | >(options?: { 391 | mutation?: UseMutationOptions; 392 | }): UseMutationResult => { 393 | const mutationOptions = getTesterControllerSigninMutationOptions(options); 394 | 395 | return useMutation(mutationOptions); 396 | }; 397 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test app 🧪 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | gzip on; 3 | gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon; 4 | gzip_proxied no-cache no-store private expired auth; 5 | gzip_min_length 1000; 6 | 7 | listen 80; 8 | server_name localhost; 9 | resolver 10.96.0.10 valid=30s ipv6=off; 10 | 11 | root /usr/share/nginx/html; 12 | index index.html index.htm; 13 | 14 | location ~* \.(?:json)$ { 15 | add_header Cache-Control "no-cache, max-age=0, must-revalidate"; 16 | } 17 | 18 | location ~* \.(?:css|js)$ { 19 | add_header Cache-Control "public, max-age=31536000, immutable"; 20 | } 21 | 22 | location ~ /static { 23 | add_header Cache-Control "public, max-age=31536000, immutable"; 24 | } 25 | 26 | location ~* sw\.js { 27 | add_header Cache-Control "public, max-age=0, must-revalidate"; 28 | } 29 | 30 | location /tester { 31 | try_files $uri /tester/index.html; 32 | } 33 | 34 | error_page 404 /404.html; 35 | error_page 500 502 503 504 /50x.html; 36 | location = /50x.html { 37 | root /usr/share/nginx/html; 38 | } 39 | } -------------------------------------------------------------------------------- /orval.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'shiftbackend-tester': { 3 | input: 'http://shift-intensive.ru/api/tester-json', 4 | output: { 5 | target: 'generated/api/index.ts', 6 | client: 'react-query', 7 | prettier: true, 8 | override: { 9 | mutator: { 10 | path: './src/utils/api/instance.ts', 11 | name: 'instance' 12 | } 13 | } 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shift-intensiv-tester", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "private": true, 6 | "scripts": { 7 | "prepare": "husky", 8 | "dev": "vite", 9 | "build": "vite build", 10 | "lint": "eslint . --fix", 11 | "format": "prettier --write .", 12 | "pretty": "yarn format && yarn lint", 13 | "stylelint": "stylelint \"**/*.css\"", 14 | "lint-inspector": "npx @eslint/config-inspector", 15 | "preview": "vite preview", 16 | "generate-api": "orval --config ./orval.config.ts" 17 | }, 18 | "dependencies": { 19 | "@hookform/resolvers": "^3.10.0", 20 | "@siberiacancode/fetches": "^1.7.2", 21 | "@siberiacancode/reactuse": "^0.0.78", 22 | "@tanstack/react-query": "^5.64.2", 23 | "@types/node": "^22.10.10", 24 | "axios": "^1.7.9", 25 | "clsx": "^2.1.1", 26 | "generate-api": "^1.0.4", 27 | "lucide-react": "^0.474.0", 28 | "react": "^19.0.0", 29 | "react-dom": "^19.0.0", 30 | "react-hook-form": "^7.54.2", 31 | "react-number-format": "^5.4.3", 32 | "zod": "^3.24.1", 33 | "zustand": "^5.0.3" 34 | }, 35 | "devDependencies": { 36 | "@siberiacancode/eslint": "^2.7.0", 37 | "@siberiacancode/prettier": "^1.2.0", 38 | "@siberiacancode/stylelint": "^1.1.1", 39 | "@tanstack/react-query-devtools": "5.64.2", 40 | "@types/react": "^19.0.8", 41 | "@types/react-dom": "^19.0.3", 42 | "@vitejs/plugin-react": "^4.3.4", 43 | "husky": "^9.1.7", 44 | "lint-staged": "^15.4.2", 45 | "orval": "^7.4.1", 46 | "typescript": "^5.7.3", 47 | "vite": "^6.0.11" 48 | }, 49 | "lint-staged": { 50 | "*.css": [ 51 | "prettier --write", 52 | "stylelint --fix" 53 | ], 54 | "*.js": [ 55 | "prettier --write" 56 | ], 57 | "*.ts": [ 58 | "prettier --write", 59 | "eslint --no-error-on-unmatched-pattern --fix" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-intensive/web-tester/b4ab67cd06b073163340bfeca65ebda74581497b/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-intensive/web-tester/b4ab67cd06b073163340bfeca65ebda74581497b/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-intensive/web-tester/b4ab67cd06b073163340bfeca65ebda74581497b/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-intensive/web-tester/b4ab67cd06b073163340bfeca65ebda74581497b/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-intensive/web-tester/b4ab67cd06b073163340bfeca65ebda74581497b/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shift-intensive/web-tester/b4ab67cd06b073163340bfeca65ebda74581497b/public/favicon.ico -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { "src": "/tester/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, 6 | { "src": "/tester/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } 7 | ], 8 | "theme_color": "#ffffff", 9 | "background_color": "#ffffff", 10 | "display": "standalone" 11 | } 12 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { MoonIcon, SunIcon } from 'lucide-react'; 2 | 3 | import { AuthView } from './modules/auth/view'; 4 | import { ProfileView } from './modules/profile/view'; 5 | import { useStore, useTheme } from './utils/store'; 6 | 7 | export const App = () => { 8 | const theme = useTheme((state) => state.value); 9 | const isLoggedIn = useStore((state) => state.isLoggedIn); 10 | 11 | return ( 12 |
13 |
14 |
useTheme.getState().set(theme && theme === 'light' ? 'dark' : 'light')}> 15 | {theme && theme === 'light' ? : } 16 |
17 |
18 | 19 |
20 | {!isLoggedIn && } 21 | {isLoggedIn && } 22 |
23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | width: 100%; 3 | min-width: max-content; 4 | padding: 16px 32px; 5 | border: 1px solid; 6 | border-radius: 16px; 7 | cursor: pointer; 8 | font-size: 16px; 9 | font-weight: 600; 10 | line-height: 24px; 11 | transition: all 0.2s ease-out; 12 | 13 | &:disabled { 14 | cursor: auto; 15 | } 16 | } 17 | 18 | .contained { 19 | border-color: var(--bg-brand); 20 | background-color: var(--bg-brand); 21 | color: var(--text-invert); 22 | 23 | &:hover { 24 | border-color: var(--bg-hover-primary); 25 | background-color: var(--bg-hover-primary); 26 | } 27 | 28 | &:focus { 29 | border: 1px solid; 30 | border-color: var(--bg-brand-extraLight); 31 | } 32 | 33 | &:disabled:not(.loading) { 34 | border-color: var(--bg-brand-extraLight); 35 | background-color: var(--bg-brand-extraLight); 36 | } 37 | } 38 | 39 | .text { 40 | border: none; 41 | background-color: var(--bg-brand); 42 | color: var(--text-secondary); 43 | 44 | &:hover { 45 | background-color: var(--bg-tertiary); 46 | } 47 | 48 | &:disabled:not(.loading) { 49 | color: var(--text-tertiary); 50 | } 51 | } 52 | 53 | .outlined { 54 | border-color: var(--border-light); 55 | background-color: transparent; 56 | color: var(--text-secondary); 57 | 58 | &:hover { 59 | background-color: var(--bg-tertiary); 60 | } 61 | 62 | &:disabled:not(.loading) { 63 | border-color: var(--border-extraLight); 64 | color: var(--text-tertiary); 65 | } 66 | 67 | &.loading:before { 68 | border-top-color: var(--bg-brand); 69 | } 70 | } 71 | 72 | .link { 73 | padding: 0; 74 | border: none; 75 | background-color: transparent; 76 | color: var(--text-secondary); 77 | font-weight: 500; 78 | text-decoration: underline; 79 | 80 | &:disabled:not(.loading) { 81 | border-color: var(--border-extraLight); 82 | color: var(--text-tertiary); 83 | } 84 | 85 | &.loading:before { 86 | border-top-color: var(--bg-brand); 87 | } 88 | } 89 | 90 | .loading { 91 | position: relative; 92 | 93 | & span { 94 | color: transparent; 95 | } 96 | 97 | &:before { 98 | position: absolute; 99 | left: calc(50% - 12px); 100 | width: 24px; 101 | height: 24px; 102 | border-radius: 50%; 103 | border-top: 2px solid var(--border-light); 104 | border-right: 2px solid transparent; 105 | margin: 0; 106 | animation: 1s spin linear infinite; 107 | background: transparent; 108 | content: ''; 109 | } 110 | } 111 | 112 | @keyframes spin { 113 | 114 | from { 115 | transform: rotate(0deg); 116 | } 117 | 118 | to { 119 | transform: rotate(360deg); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | import styles from './Button.module.css'; 5 | 6 | type ButtonVariant = 'contained' | 'link' | 'outlined' | 'text'; 7 | interface ButtonProps extends React.ComponentProps<'button'> { 8 | children: React.ReactNode; 9 | loading?: boolean; 10 | variant?: ButtonVariant; 11 | } 12 | 13 | export const Button = 14 | ({ children, variant = 'contained', className, loading, ...props }: ButtonProps) => ( 15 | 23 | ) -------------------------------------------------------------------------------- /src/components/Input/Input.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 6px; 5 | } 6 | 7 | .input { 8 | height: 48px; 9 | padding: 12px 8px 12px 12px; 10 | border: 1px solid var(--border-light); 11 | border-radius: 8px; 12 | background-color: transparent; 13 | color: var(--text-primary); 14 | transition: all 0.2s ease-out; 15 | 16 | &:disabled { 17 | background-color: var(--bg-disable); 18 | color: var(--text-tertiary); 19 | cursor: auto; 20 | } 21 | } 22 | 23 | .error { 24 | 25 | & input { 26 | border: 2px solid; 27 | border-color: var(--indicator-error); 28 | } 29 | 30 | & p { 31 | color: var(--indicator-error); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | 2 | import type { JSX } from 'react'; 3 | 4 | import clsx from 'clsx'; 5 | import React from 'react'; 6 | 7 | import styles from './Input.module.css'; 8 | 9 | type InputProps< 10 | Component extends React.JSXElementConstructor | keyof JSX.IntrinsicElements = 'input' 11 | > = { 12 | label?: string; 13 | error?: string; 14 | component?: Component; 15 | } & React.ComponentProps; 16 | 17 | export const Input = 18 | (( 19 | { label, className, component, error, id: externalId, ...props } 20 | ) => { 21 | const internalId = React.useId(); 22 | const id = externalId ?? internalId; 23 | 24 | const Component = component || 'input'; 25 | 26 | return ( 27 |
28 | {label && ( 29 | 32 | )} 33 | 38 | {error &&

{error}

} 39 |
40 | ); 41 | }) as | keyof JSX.IntrinsicElements = 'input'>( 42 | props: InputProps & { ref?: React.ForwardedRef } 43 | ) => React.ReactElement; 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/Typography/Typography.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | type TypographyVariant = 'paragraph14_regular' | 'paragraph16_regular' | 'title'; 4 | type TypographyTag = 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'; 5 | export type TypographyProps = React.ComponentProps & { 6 | variant: TypographyVariant; 7 | tag?: TypographyTag; 8 | children: React.ReactNode; 9 | }; 10 | 11 | export const Typography = ({ 12 | variant, 13 | tag = 'div', 14 | children, 15 | className, 16 | ...props 17 | }: TypographyProps) => { 18 | const Component = tag; 19 | 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button/Button'; 2 | export * from './Input/Input'; 3 | export * from './Typography/Typography'; 4 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | 3 | import { testerControllerSession } from '@/api'; 4 | 5 | import { App } from './App'; 6 | import { Provider } from './provider'; 7 | import { LOCAL_STORAGE_KEYS } from './utils/constants'; 8 | import { useStore } from './utils/store'; 9 | 10 | import './styles/reset.css'; 11 | import './styles/typography.css'; 12 | import './styles/global.css'; 13 | 14 | const init = async () => { 15 | const token = localStorage.getItem(LOCAL_STORAGE_KEYS.TOKEN); 16 | if (token) { 17 | const getTesterControllerSession = await testerControllerSession(); 18 | useStore.setState({ isLoggedIn: true, user: getTesterControllerSession.data.user }); 19 | } 20 | 21 | ReactDOM.createRoot(document.getElementById('root')!).render( 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | init(); 29 | -------------------------------------------------------------------------------- /src/modules/auth/components/Countdown/Countdown.module.css: -------------------------------------------------------------------------------- 1 | .text { 2 | color: var(--text-quaternary); 3 | text-align: left; 4 | } 5 | 6 | .button { 7 | text-align: left; 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/auth/components/Countdown/Countdown.tsx: -------------------------------------------------------------------------------- 1 | import { useTimer } from '@siberiacancode/reactuse'; 2 | 3 | import { Button } from '../../../../components/Button/Button'; 4 | import { Typography } from '../../../../components/Typography/Typography'; 5 | 6 | import styles from './Countdown.module.css'; 7 | 8 | interface CountdownProps { 9 | endTime: number; 10 | loading?: boolean; 11 | onRetry: () => void; 12 | } 13 | 14 | export const Countdown = ({ endTime, onRetry, loading = false }: CountdownProps) => { 15 | const timer = useTimer(Math.floor((endTime - Date.now()))); 16 | const seconds = timer.seconds + timer.minutes * 60; 17 | 18 | if (!seconds) 19 | return ( 20 | 23 | ); 24 | 25 | return ( 26 | 27 | Отправить код повторно через {seconds} секунд 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/modules/auth/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './otpFormScheme'; 2 | export * from './phoneFormScheme'; 3 | export * from './sizes'; 4 | -------------------------------------------------------------------------------- /src/modules/auth/constants/otpFormScheme.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | export const otpFormScheme = z.object({ 4 | otp: z 5 | .string() 6 | .min(1, { 7 | message: 'Поле обязательно для заполнения' 8 | }) 9 | .refine((data) => data.trim().length >= 6, { 10 | message: 'Код должен содержать 6 цифр' 11 | }) 12 | }); 13 | 14 | export type OtpFormScheme = z.infer; 15 | -------------------------------------------------------------------------------- /src/modules/auth/constants/phoneFormScheme.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | export const phoneFormScheme = z.object({ 4 | phone: z.string().min(11, { 5 | message: 'Поле обязательно для заполнения' 6 | }) 7 | }); 8 | 9 | export type PhoneFormScheme = z.infer; 10 | -------------------------------------------------------------------------------- /src/modules/auth/constants/sizes.ts: -------------------------------------------------------------------------------- 1 | export const LENGTH = { 2 | PHONE: 11 3 | }; 4 | -------------------------------------------------------------------------------- /src/modules/auth/hooks/useView.ts: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod'; 2 | import { useDidUpdate } from '@siberiacancode/reactuse'; 3 | import React from 'react'; 4 | import { useForm } from 'react-hook-form'; 5 | 6 | import { useTesterControllerCreateOtp, useTesterControllerSignin } from '@/api'; 7 | import { LOCAL_STORAGE_KEYS } from '@/utils/constants'; 8 | import { useStore } from '@/utils/store'; 9 | 10 | import type { OtpFormScheme, PhoneFormScheme } from '../constants'; 11 | 12 | import { LENGTH, otpFormScheme, phoneFormScheme } from '../constants'; 13 | 14 | export const useView = () => { 15 | const [stage, setStage] = React.useState<'otp' | 'phone'>('phone'); 16 | const [submittedPhones, setSubmittedPhones] = React.useState<{ 17 | [key: string]: number; 18 | }>({}); 19 | 20 | const authForm = useForm({ 21 | mode: 'onBlur', 22 | defaultValues: { 23 | phone: '', 24 | otp: '' 25 | }, 26 | resolver: zodResolver(stage === 'phone' ? phoneFormScheme : otpFormScheme) 27 | }); 28 | 29 | const phone = authForm.watch('phone'); 30 | 31 | useDidUpdate(() => { 32 | if (phone.length < LENGTH.PHONE || !submittedPhones[phone]) setStage('phone'); 33 | if (submittedPhones[phone] > Date.now()) setStage('otp'); 34 | }, [phone]); 35 | 36 | const testerControllerCreateOtpMutation = useTesterControllerCreateOtp(); 37 | const testerControllerSigninMutation = useTesterControllerSignin(); 38 | 39 | const sendOtp = async (phone: string) => { 40 | const postAuthOptMutationResponse = await testerControllerCreateOtpMutation.mutateAsync({ 41 | data: { phone } 42 | }); 43 | 44 | setSubmittedPhones({ 45 | ...submittedPhones, 46 | [phone]: Date.now() + postAuthOptMutationResponse.data.retryDelay 47 | }); 48 | }; 49 | 50 | const onSubmit = authForm.handleSubmit(async (values) => { 51 | if (stage === 'phone' && 'phone' in values) { 52 | await sendOtp(values.phone); 53 | setStage('otp'); 54 | return; 55 | } 56 | 57 | if (stage === 'otp' && 'otp' in values) { 58 | const postUsersSinginMutationResponse = await testerControllerSigninMutation.mutateAsync({ 59 | data: { code: +values.otp, phone } 60 | }); 61 | 62 | if ( 63 | !postUsersSinginMutationResponse.data.success && 64 | postUsersSinginMutationResponse.data.reason 65 | ) { 66 | return authForm.setError('otp', { message: postUsersSinginMutationResponse.data.reason }); 67 | } 68 | 69 | localStorage.setItem(LOCAL_STORAGE_KEYS.TOKEN, postUsersSinginMutationResponse.data.token); 70 | useStore.setState({ isLoggedIn: true, user: postUsersSinginMutationResponse.data.user }); 71 | } 72 | }); 73 | 74 | const onRetry = () => sendOtp(phone); 75 | 76 | return { 77 | form: authForm, 78 | state: { isLoading: authForm.formState.isSubmitting, stage, phone, submittedPhones }, 79 | functions: { onSubmit, onRetry } 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /src/modules/auth/view.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | flex-direction: column; 5 | justify-content: start; 6 | gap: 24px; 7 | text-align: start; 8 | } 9 | 10 | @media screen and (width >= 768px) { 11 | 12 | .container { 13 | width: 328px; 14 | } 15 | } 16 | 17 | .fieldset { 18 | display: flex; 19 | flex-direction: column; 20 | gap: 24px; 21 | } 22 | 23 | .button_container { 24 | display: flex; 25 | overflow: visible; 26 | flex-direction: column; 27 | margin-top: 24px; 28 | gap: 24px; 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/auth/view.tsx: -------------------------------------------------------------------------------- 1 | import { Controller } from 'react-hook-form'; 2 | import { PatternFormat } from 'react-number-format'; 3 | 4 | import { Button, Input, Typography } from '@/components'; 5 | 6 | import { Countdown } from './components/Countdown/Countdown'; 7 | import { useView } from './hooks/useView'; 8 | 9 | import styles from './view.module.css'; 10 | 11 | export const AuthView = () => { 12 | const { form, state, functions } = useView(); 13 | 14 | return ( 15 |
16 |
17 | 18 | Вход 19 | 20 | 21 | 22 | Введите {state.stage === 'phone' ? 'номер телефона' : 'проверочный код'} для входа 23 |
в личный кабинет 24 |
25 | 26 | 27 | 28 | ( 30 | onChange(event.target.value.replace('+', '').replace(/ /g, ''))} 37 | placeholder='Телефон' 38 | {...(fieldState.error && { error: fieldState.error.message })} 39 | {...(value === '72282881488' && { error: 'нет это не пасхалка' })} 40 | /> 41 | )} 42 | name='phone' 43 | control={form.control} 44 | /> 45 | 46 | 47 | {state.stage === 'otp' && ( 48 | ( 50 | 58 | )} 59 | name='otp' 60 | control={form.control} 61 | /> 62 | )} 63 | 64 |
65 | 68 | 69 | {state.stage === 'otp' && state.submittedPhones[state.phone] && ( 70 | 75 | )} 76 |
77 |
78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/modules/profile/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './profileFormScheme'; 2 | -------------------------------------------------------------------------------- /src/modules/profile/constants/profileFormScheme.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | export const profileFormScheme = z.object({ 4 | lastname: z.string(), 5 | firstname: z.string(), 6 | middlename: z.string(), 7 | email: z.string(), 8 | // .email({ message: 'Некорректный email' }) 9 | city: z.string() 10 | }); 11 | 12 | export type ProfileFormScheme = z.infer; 13 | -------------------------------------------------------------------------------- /src/modules/profile/constants/sizes.ts: -------------------------------------------------------------------------------- 1 | export const LENGTH = { 2 | PHONE: 10, 3 | OTP: 6 4 | }; 5 | -------------------------------------------------------------------------------- /src/modules/profile/hooks/useView.ts: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod'; 2 | import { useForm } from 'react-hook-form'; 3 | 4 | import type { User } from '@/api'; 5 | 6 | import { useTesterControllerUpdateProfile } from '@/api'; 7 | import { LOCAL_STORAGE_KEYS } from '@/utils/constants'; 8 | import { useStore } from '@/utils/store'; 9 | 10 | import type { ProfileFormScheme } from '../constants'; 11 | 12 | import { profileFormScheme } from '../constants'; 13 | 14 | export const useView = () => { 15 | const { user, setUser } = useStore(); 16 | 17 | const testerControllerUpdateProfile = useTesterControllerUpdateProfile(); 18 | 19 | const profileForm = useForm({ 20 | mode: 'onBlur', 21 | defaultValues: user, 22 | resolver: zodResolver(profileFormScheme) 23 | }); 24 | 25 | const onSubmit = profileForm.handleSubmit(async ({ middlename, ...values }) => { 26 | await testerControllerUpdateProfile.mutateAsync({ 27 | data: { phone: user.phone, profile: values } 28 | }); 29 | const updatedUser = { ...user, ...values }; 30 | setUser(updatedUser); 31 | }); 32 | 33 | const onLogout = () => { 34 | if (Math.random() < 0.3) throw new Error('Something went wrong, something of undefined'); 35 | 36 | localStorage.removeItem(LOCAL_STORAGE_KEYS.TOKEN); 37 | useStore.setState({ isLoggedIn: false, user: {} as User }); 38 | }; 39 | 40 | return { 41 | form: profileForm, 42 | state: { isLoading: profileForm.formState.isSubmitting, phone: user.phone }, 43 | functions: { onSubmit, onLogout } 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/modules/profile/view.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | width: 100%; 4 | flex-direction: column; 5 | justify-content: start; 6 | gap: 24px; 7 | text-align: start; 8 | } 9 | 10 | @media screen and (width >= 768px) { 11 | 12 | .container { 13 | width: 920px; 14 | } 15 | } 16 | 17 | .fieldset { 18 | display: grid; 19 | gap: 24px; 20 | grid-template-columns: 1fr 1fr; 21 | } 22 | 23 | .button_container { 24 | display: flex; 25 | flex-direction: column; 26 | margin-top: 40px; 27 | gap: 24px; 28 | } 29 | 30 | @media screen and (width >= 768px) { 31 | 32 | .button_container { 33 | width: 50%; 34 | flex-direction: row; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/profile/view.tsx: -------------------------------------------------------------------------------- 1 | import { PatternFormat } from 'react-number-format'; 2 | 3 | import { Button, Input, Typography } from '@/components'; 4 | 5 | import { useView } from './hooks/useView'; 6 | 7 | import styles from './view.module.css'; 8 | 9 | export const ProfileView = () => { 10 | const { form, state, functions } = useView(); 11 | 12 | return ( 13 |
14 | 15 | Профиль 16 | 17 | 18 |
19 | 25 | 26 | 33 | 34 | 42 | 43 | 51 | 52 | 60 | 61 | 69 |
70 | 71 |
72 | 75 | 78 |
79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/provider.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 2 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 3 | 4 | const queryClient = new QueryClient({ 5 | defaultOptions: { 6 | queries: { 7 | refetchOnWindowFocus: false 8 | } 9 | } 10 | }); 11 | 12 | interface ProviderProps { 13 | children: React.ReactNode; 14 | } 15 | 16 | export const Provider = ({ children }: ProviderProps) => ( 17 | 18 | {children} 19 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | :root { 2 | color-scheme: light; 3 | font-family: Inter, sans-serif; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | font-synthesis: none; 7 | font-weight: 400; 8 | line-height: 1.5; 9 | text-rendering: optimizelegibility; 10 | 11 | --bg-brand: #1975ff; 12 | --bg-brand-primary: #f0f6ff; 13 | --bg-hover-primary: #0052cc; 14 | --indicator-white: #fff; 15 | --bg-tertiary: #f9fafb; 16 | --bg-brand-extraLight: #cce0ff; 17 | --bg-primary: #fff; 18 | --bg-disable: #f3f4f6; 19 | --text-brand: #1975ff; 20 | --text-primary: #141c24; 21 | --text-secondary: #344051; 22 | --text-tertiary: #637083; 23 | --text-quaternary: #637083; 24 | --text-disable: #97a1af; 25 | --text-invert: #fff; 26 | --border-light: #ced2da; 27 | --indicator-medium: #97a1af; 28 | --indicator-error: #f64c4c; 29 | --indicator-focused: #4c94ff; 30 | } 31 | 32 | .dark { 33 | color-scheme: dark; 34 | 35 | --bg-tertiary: #404040; 36 | --bg-brand-extraLight: #5f98ed; 37 | --bg-primary: #151e2a; 38 | --bg-disable: #353b40; 39 | --text-primary: #fff; 40 | --text-secondary: #97a1af; 41 | --text-tertiary: #637083; 42 | --text-quaternary: #545d6b; 43 | --text-disable: #344051; 44 | --text-invert: #fff; 45 | --border-light: #637083; 46 | --indicator-medium: #637083; 47 | --indicator-error: #f64c4c; 48 | --indicator-focused: #4c94ff; 49 | } 50 | 51 | #root { 52 | min-height: 100vh; 53 | background-color: var(--bg-primary); 54 | color: var(--text-primary); 55 | } 56 | 57 | .container { 58 | width: 100%; 59 | max-width: 1280px; 60 | padding: 12px 16px; 61 | margin: 0 auto; 62 | } 63 | 64 | header { 65 | display: flex; 66 | width: 100%; 67 | height: 120px; 68 | justify-content: end; 69 | padding: 24px 0; 70 | } 71 | 72 | @media screen and (width <= 768px) { 73 | 74 | header { 75 | position: absolute; 76 | top: 12px; 77 | right: 14px; 78 | height: auto; 79 | padding: 0; 80 | } 81 | } 82 | 83 | .content { 84 | display: flex; 85 | align-items: center; 86 | justify-content: center; 87 | } 88 | 89 | .theme_switcher { 90 | top: 32px; 91 | right: 32px; 92 | color: var(--indicator-medium); 93 | cursor: pointer; 94 | } 95 | -------------------------------------------------------------------------------- /src/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | 1. Use a more-intuitive box-sizing model. 3 | */ 4 | 5 | *, 6 | *:before, 7 | *:after { 8 | box-sizing: border-box; 9 | } 10 | 11 | /* 12 | 2. Remove default margin 13 | */ 14 | 15 | * { 16 | margin: 0; 17 | } 18 | 19 | /* 20 | Typographic tweaks! 21 | 3. Add accessible line-height 22 | 4. Improve text rendering 23 | */ 24 | 25 | body { 26 | -webkit-font-smoothing: antialiased; 27 | line-height: 1.5; 28 | } 29 | 30 | /* 31 | 5. Improve media defaults 32 | */ 33 | 34 | img, 35 | picture, 36 | video, 37 | canvas, 38 | svg { 39 | display: block; 40 | max-width: 100%; 41 | } 42 | 43 | fieldset { 44 | min-width: 0; 45 | padding: 0; 46 | border: 0; 47 | margin: 0; 48 | } 49 | 50 | /* 51 | 6. Remove built-in form typography styles 52 | */ 53 | 54 | input, 55 | button, 56 | textarea, 57 | select { 58 | font: inherit; 59 | outline: none; 60 | } 61 | 62 | /* 63 | 7. Avoid text overflows 64 | */ 65 | 66 | p, 67 | h1, 68 | h2, 69 | h3, 70 | h4, 71 | h5, 72 | h6 { 73 | overflow-wrap: break-word; 74 | } 75 | 76 | /* 77 | 8. Create a root stacking context 78 | */ 79 | 80 | #root { 81 | isolation: isolate; 82 | } 83 | 84 | input::-webkit-outer-spin-button, 85 | input::-webkit-inner-spin-button { 86 | margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ 87 | 88 | /* display: none; <- Crashes Chrome on hover */ 89 | appearance: none; 90 | } 91 | -------------------------------------------------------------------------------- /src/styles/typography.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-size: 24px; 3 | font-weight: 700; 4 | line-height: 32px; 5 | } 6 | 7 | .paragraph16_regular { 8 | font-size: 16px; 9 | font-weight: 400; 10 | line-height: 24px; 11 | } 12 | 13 | .paragraph14_regular { 14 | font-size: 14px; 15 | font-weight: 400; 16 | line-height: 20px; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/api/instance.ts: -------------------------------------------------------------------------------- 1 | import fetches from '@siberiacancode/fetches'; 2 | 3 | import { LOCAL_STORAGE_KEYS } from '../constants'; 4 | 5 | export const api = fetches.create({ 6 | baseURL: 'https://shift-intensive.ru' 7 | }); 8 | 9 | api.interceptors.request.use((config) => { 10 | const token = localStorage.getItem(LOCAL_STORAGE_KEYS.TOKEN); 11 | if (token && config.headers) config.headers.authorization = `Bearer ${token}`; 12 | return config; 13 | }); 14 | 15 | export const instance = ({ 16 | url, 17 | method, 18 | params, 19 | signal, 20 | data 21 | }: { 22 | url: string; 23 | method: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; 24 | headers?: any; 25 | params?: any; 26 | data?: any; 27 | signal?: AbortSignal; 28 | }) => 29 | api.call({ 30 | url, 31 | method, 32 | params, 33 | signal, 34 | body: data 35 | }); 36 | -------------------------------------------------------------------------------- /src/utils/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './keys'; 2 | -------------------------------------------------------------------------------- /src/utils/constants/keys.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_STORAGE_KEYS = { 2 | TOKEN: 'token' 3 | }; 4 | -------------------------------------------------------------------------------- /src/utils/store/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { createJSONStorage, devtools, persist } from 'zustand/middleware'; 3 | 4 | import type { User } from '@/api'; 5 | 6 | interface StoreState { 7 | isLoggedIn: boolean; 8 | user: User; 9 | setIsLoggedIn: (isLoggedIn: boolean) => void; 10 | setUser: (user: User) => void; 11 | } 12 | 13 | export const useStore = create()( 14 | devtools((set) => ({ 15 | user: {} as User, 16 | isLoggedIn: false, 17 | setIsLoggedIn: (isLoggedIn) => set({ isLoggedIn }), 18 | setUser: (user) => set({ user }) 19 | })) 20 | ); 21 | 22 | type Theme = 'dark' | 'light'; 23 | 24 | interface ThemeState { 25 | value: Theme; 26 | set: (value: Theme) => void; 27 | } 28 | 29 | export const useTheme = create()( 30 | persist( 31 | (set) => ({ 32 | value: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', 33 | set: (value) => { 34 | document.documentElement.classList.toggle('dark', value === 'dark'); 35 | set({ value }); 36 | } 37 | }), 38 | { 39 | name: 'theme-storage', 40 | storage: createJSONStorage(() => sessionStorage) 41 | } 42 | ) 43 | ); 44 | document.documentElement.classList.toggle('dark', useTheme.getState().value === 'dark'); 45 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "jsx": "react-jsx", 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "useDefineForClassFields": true, 7 | 8 | "baseUrl": ".", 9 | "module": "ESNext", 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "paths": { 14 | "@/*": ["./src/*"], 15 | "@/api": ["./generated/api"] 16 | }, 17 | "resolveJsonModule": true, 18 | "allowImportingTsExtensions": true, 19 | /* Linting */ 20 | "strict": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noEmit": true, 25 | "isolatedModules": true, 26 | "skipLibCheck": true 27 | }, 28 | "references": [{ "path": "./tsconfig.node.json" }], 29 | "include": ["**/*.ts", "**/*.tsx"] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "strict": true, 7 | "allowSyntheticDefaultImports": true, 8 | "skipLibCheck": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import path from 'node:path'; 3 | import url from 'node:url'; 4 | import { defineConfig } from 'vite'; 5 | 6 | const __filename = url.fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | base: '/tester', 12 | resolve: { 13 | alias: { 14 | '@/api': path.resolve(__dirname, './generated/api'), 15 | '@': path.resolve(__dirname, './src') 16 | } 17 | }, 18 | plugins: [react()] 19 | }); 20 | --------------------------------------------------------------------------------