From 6e1ebdf023ebebb70d95c2dd292bd04ecf5127f1 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Mon, 25 Aug 2025 09:19:13 +0330 Subject: [PATCH] login --- package-lock.json | 101 +++++++++++++++++++ package.json | 5 + public/images/mobile.svg | 5 + src/app/auth/components/LoginStep1.tsx | 73 ++++++++++++++ src/app/auth/components/LoginStep2.tsx | 132 +++++++++++++++++++++++++ src/app/auth/hooks/useAuthData.ts | 14 +++ src/app/auth/page.tsx | 50 ++++++++++ src/app/auth/service/Service.ts | 12 +++ src/app/auth/store/AuthStore.ts | 9 ++ src/app/auth/types/Types.ts | 15 +++ src/app/globals.css | 8 ++ src/app/layout.tsx | 2 + src/components/Toast.tsx | 59 +++++++++++ src/components/ui/button.tsx | 17 +++- src/config/QueryProvider.tsx | 7 +- src/config/const.ts | 3 +- src/helpers/errorUtils.ts | 40 ++++++++ src/hooks/useCountDown.ts | 71 +++++++++++++ src/types/README.md | 91 +++++++++++++++++ src/types/error.types.ts | 32 +++++- 20 files changed, 736 insertions(+), 10 deletions(-) create mode 100644 public/images/mobile.svg create mode 100644 src/app/auth/components/LoginStep1.tsx create mode 100644 src/app/auth/components/LoginStep2.tsx create mode 100644 src/app/auth/hooks/useAuthData.ts create mode 100644 src/app/auth/page.tsx create mode 100644 src/app/auth/service/Service.ts create mode 100644 src/app/auth/store/AuthStore.ts create mode 100644 src/app/auth/types/Types.ts create mode 100644 src/components/Toast.tsx create mode 100644 src/helpers/errorUtils.ts create mode 100755 src/hooks/useCountDown.ts create mode 100644 src/types/README.md diff --git a/package-lock.json b/package-lock.json index aa3faa0..39ebca5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "shop", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.2.1", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-separator": "^1.1.7", @@ -22,8 +23,12 @@ "next": "15.4.5", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.62.0", + "react-otp-input": "^3.1.1", + "react-spinners": "^0.17.0", "swiper": "^11.2.10", "tailwind-merge": "^3.3.1", + "yup": "^1.7.0", "zustand": "^5.0.7" }, "devDependencies": { @@ -240,6 +245,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.1.tgz", + "integrity": "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1402,6 +1419,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5632,6 +5655,12 @@ "react-is": "^16.13.1" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -5690,12 +5719,48 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-otp-input": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-otp-input/-/react-otp-input-3.1.1.tgz", + "integrity": "sha512-bjPavgJ0/Zmf/AYi4onj8FbH93IjeD+e8pWwxIJreDEWsU1ILR5fs8jEJmMGWSBe/yyvPP6X/W6Mk9UkOCkTPw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.6 || ^17.0.0 || ^18.0.0", + "react-dom": ">=16.8.6 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-spinners": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.17.0.tgz", + "integrity": "sha512-L/8HTylaBmIWwQzIjMq+0vyaRXuoAevzWoD35wKpNTxxtYXWZp+xtgkfD7Y4WItuX0YvdxMPU79+7VhhmbmuTQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6370,6 +6435,12 @@ "node": ">=18" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -6428,6 +6499,12 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6483,6 +6560,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -6784,6 +6873,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.0.tgz", + "integrity": "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zustand": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz", diff --git a/package.json b/package.json index eb9208b..b8ae233 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^5.2.1", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-separator": "^1.1.7", @@ -23,8 +24,12 @@ "next": "15.4.5", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.62.0", + "react-otp-input": "^3.1.1", + "react-spinners": "^0.17.0", "swiper": "^11.2.10", "tailwind-merge": "^3.3.1", + "yup": "^1.7.0", "zustand": "^5.0.7" }, "devDependencies": { diff --git a/public/images/mobile.svg b/public/images/mobile.svg new file mode 100644 index 0000000..494daf6 --- /dev/null +++ b/public/images/mobile.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/auth/components/LoginStep1.tsx b/src/app/auth/components/LoginStep1.tsx new file mode 100644 index 0000000..d487b25 --- /dev/null +++ b/src/app/auth/components/LoginStep1.tsx @@ -0,0 +1,73 @@ +import Input from '@/components/Input' +import { Button } from '@/components/ui/button' +import React from 'react' +import { useAuthStore } from '../store/AuthStore'; +import { useAuthentication } from '../hooks/useAuthData'; +import { AuthenticationType } from '../types/Types'; +import { useForm } from 'react-hook-form'; +import * as yup from "yup" +import { yupResolver } from "@hookform/resolvers/yup" +import { toast } from '@/components/Toast'; +import { extractErrorMessage } from '@/helpers/errorUtils'; +import LoginStep2 from './LoginStep2'; + + +const LoginStep1 = () => { + + const { step, setStep, setPhone } = useAuthStore(); + const { mutate: authentication, isPending } = useAuthentication(); + + const validationSchema = yup + .object({ + phone: yup.string().required('شماره تلفن خود را وارد کنید'), + }) + .required() + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: yupResolver(validationSchema), + }) + + const onSubmit = (data: AuthenticationType) => { + authentication(data, { + onSuccess: () => { + toast("کد با موفقیت ارسال شد", "success") + setPhone(watch("phone")) + setStep(2) + }, + onError: (error: Error) => { + toast(extractErrorMessage(error), 'error') + } + }) + } + + if (step === 2) { + return ( + + ) + } + + + return ( +
+
ورود و ثبت نام
+
+ +
+
+ +
+
+ ) +} + +export default LoginStep1 \ No newline at end of file diff --git a/src/app/auth/components/LoginStep2.tsx b/src/app/auth/components/LoginStep2.tsx new file mode 100644 index 0000000..f0b48bb --- /dev/null +++ b/src/app/auth/components/LoginStep2.tsx @@ -0,0 +1,132 @@ +import { FC, useEffect } from 'react' +import { useAuthentication, useVerifyOtp } from '../hooks/useAuthData' +import { Button } from '@/components/ui/button' +import OTPInput from 'react-otp-input' +import { useForm } from 'react-hook-form' +import { VerifyOtpType } from '../types/Types' +import * as yup from "yup" +import { yupResolver } from "@hookform/resolvers/yup" +import { useAuthStore } from '../store/AuthStore' +import { toast } from '@/components/Toast' +import { extractErrorMessage } from '@/helpers/errorUtils' +import Error from '@/components/Error' +import { setRefreshToken, setToken } from '@/config/func' +import { useCountDown } from '@/hooks/useCountDown' + +const LoginStep2: FC = () => { + + const { phone } = useAuthStore() + const { isPending, mutate: verifyOtp } = useVerifyOtp() + + const { mutate: authentication, isPending: isAuthenticationPending } = useAuthentication(); + + const { value: countdownValue, reset: resetCountdown } = useCountDown(2, true, () => { + // When countdown ends, enable resend button + }) + + const validationSchema = yup + .object({ + otpCode: yup.string().required('کد را وارد کنید'), + phone: yup.string().required('شماره تلفن خود را وارد کنید'), + }) + .required() + + const { + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: yupResolver(validationSchema), + defaultValues: { + phone: phone, + }, + }) + + const onSubmit = (data: VerifyOtpType) => { + verifyOtp(data, { + onSuccess: (data) => { + toast('ورود با موفقیت انجام شد', 'success') + setToken(data?.results?.data?.accessToken?.token) + setRefreshToken(data?.results?.data?.refreshToken?.token) + window.location.href = '/' + + }, + onError: (error: Error) => { + toast(extractErrorMessage(error), 'error') + } + }) + } + + const handleResendOtp = () => { + authentication({ + phone: phone, + }, { + onSuccess: () => { + resetCountdown() + toast('کد جدید ارسال شد', 'success') + } + }) + } + + useEffect(() => { + if (watch('otpCode')?.length === 5) { + onSubmit({ + otpCode: watch('otpCode'), + phone: phone, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [watch('otpCode')]) + + return ( +
+
کد دریافتی را وارد نمایید
+
+ setValue('otpCode', otp)} + renderInput={(props) => + + } + /> +
+ { + errors.otpCode?.message && + } +
+ +
+ + {/* Countdown and Resend Section */} +
+
+ کد فعالسازی را دریافت نکردید؟ + {countdownValue !== '00:00' ? ( + {countdownValue} + ) : ( + + )} +
+
+
+ ) +} + +export default LoginStep2 \ No newline at end of file diff --git a/src/app/auth/hooks/useAuthData.ts b/src/app/auth/hooks/useAuthData.ts new file mode 100644 index 0000000..c82d7d2 --- /dev/null +++ b/src/app/auth/hooks/useAuthData.ts @@ -0,0 +1,14 @@ +import { useMutation } from "@tanstack/react-query"; +import { authentication, verifyOtp } from "../service/Service"; + +export const useAuthentication = () => { + return useMutation({ + mutationFn: authentication, + }); +}; + +export const useVerifyOtp = () => { + return useMutation({ + mutationFn: verifyOtp, + }); +}; diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx new file mode 100644 index 0000000..b8bf877 --- /dev/null +++ b/src/app/auth/page.tsx @@ -0,0 +1,50 @@ +'use client' +import { NextPage } from 'next' +import Image from 'next/image'; +import LoginStep1 from './components/LoginStep1'; + +const LoginPage: NextPage = () => { + + return ( +
+
+
+
+ 1/2 +
+
+
+
+
+ +
+
+
+ +
+ phone + +
+
Wellcome
+
+ خوش آمدید +
+

+ برای دریافت کد فعالسازی شماره همراه یا ایمیل خود را وارد نمایید +

+
+
+
+ + +
+
+ ) +} + +export default LoginPage \ No newline at end of file diff --git a/src/app/auth/service/Service.ts b/src/app/auth/service/Service.ts new file mode 100644 index 0000000..37c938a --- /dev/null +++ b/src/app/auth/service/Service.ts @@ -0,0 +1,12 @@ +import axios from "@/config/axios"; +import { AuthenticationType, VerifyOtpType } from "../types/Types"; + +export const authentication = async (params: AuthenticationType) => { + const { data } = await axios.post("/user/authenticate", params); + return data; +}; + +export const verifyOtp = async (params: VerifyOtpType) => { + const { data } = await axios.post("/user/login/otp", params); + return data; +}; diff --git a/src/app/auth/store/AuthStore.ts b/src/app/auth/store/AuthStore.ts new file mode 100644 index 0000000..79ee991 --- /dev/null +++ b/src/app/auth/store/AuthStore.ts @@ -0,0 +1,9 @@ +import { create } from "zustand"; +import { AuthStoreType } from "../types/Types"; + +export const useAuthStore = create((set) => ({ + step: 1, + setStep: (step) => set({ step }), + phone: "", + setPhone: (phone) => set({ phone }), +})); diff --git a/src/app/auth/types/Types.ts b/src/app/auth/types/Types.ts new file mode 100644 index 0000000..f6a8fff --- /dev/null +++ b/src/app/auth/types/Types.ts @@ -0,0 +1,15 @@ +export type AuthStoreType = { + step: number; + setStep: (step: number) => void; + phone: string; + setPhone: (phone: string) => void; +}; + +export type AuthenticationType = { + phone: string; +}; + +export type VerifyOtpType = { + phone: string; + otpCode: string; +}; diff --git a/src/app/globals.css b/src/app/globals.css index f8f766b..eb491af 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -187,3 +187,11 @@ textarea.place-black::placeholder { div { direction: rtl; } + +.otp div { + width: 100%; +} + +.dltr { + direction: ltr; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 44d54b3..1361b44 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import "../../public/css/fontiran.css"; import 'swiper/css'; import QueryProvider from "@/config/QueryProvider"; +import ToastContainer from "@/components/Toast"; export const metadata: Metadata = { title: "Create Next App", @@ -19,6 +20,7 @@ export default function RootLayout({ {children} + diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..a692a46 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,59 @@ +'use client' +import { CloseCircle, InfoCircle, TickCircle } from 'iconsax-react'; +import React, { useState, useEffect } from 'react'; + +interface Toast { + id: string; + message: string; + type?: 'success' | 'error' | 'info'; +} + +let addToast: (toast: Toast) => void; + +const ToastContainer: React.FC = () => { + const [toasts, setToasts] = useState([]); + + // ثبت toast جدید + const showToast = (toast: Toast) => { + setToasts((prev) => [...prev, toast]); + + // حذف خودکار بعد از ۳ ثانیه + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== toast.id)); + }, 3000); + }; + + // تخصیص تابع نمایش toast به متغیر سراسری + useEffect(() => { + addToast = showToast; + }, []); + + return ( +
+ {toasts.map((toast) => ( +
+ { + toast.type === 'success' ? : + toast.type === 'error' ? : + + } + {toast.message} +
+ ))} +
+ ); +}; + +export const toast = (message: string, type: 'success' | 'error' | 'info' = 'info') => { + addToast({ id: Date.now().toString(), message, type }); +}; + +export default ToastContainer; \ No newline at end of file diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index a2df8dc..171f02a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,6 +1,7 @@ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" +import { ClipLoader } from "react-spinners" import { cn } from "@/lib/utils" @@ -40,10 +41,14 @@ function Button({ variant, size, asChild = false, + isLoading = false, + disabled, + children, ...props }: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean + isLoading?: boolean }) { const Comp = asChild ? Slot : "button" @@ -51,8 +56,18 @@ function Button({ + > + {isLoading ? ( + <> + + در حال بارگذاری... + + ) : ( + children + )} + ) } diff --git a/src/config/QueryProvider.tsx b/src/config/QueryProvider.tsx index cd72152..f7d3220 100644 --- a/src/config/QueryProvider.tsx +++ b/src/config/QueryProvider.tsx @@ -1,6 +1,6 @@ 'use client' -import { IApiErrorRepsonse } from '@/types/error.types' +import { IGeneralError } from '@/types/error.types' import { QueryClient, QueryClientProvider, HydrationBoundary, QueryCache } from '@tanstack/react-query' import { ReactNode, useState } from 'react' import { removeToken } from './func' @@ -10,8 +10,9 @@ export default function QueryProvider({ children }: { children: ReactNode }) { const { setIsLogin } = useSharedStore() const [client] = useState(() => new QueryClient({ queryCache: new QueryCache({ - onError: async (error: IApiErrorRepsonse) => { - if (error.response?.status === 401) { + onError: async (error: Error) => { + const axiosError = error as { response?: { status: number; data: IGeneralError } }; + if (axiosError.response?.status === 401) { removeToken() setIsLogin(false) } diff --git a/src/config/const.ts b/src/config/const.ts index e8a0c95..5583b1c 100644 --- a/src/config/const.ts +++ b/src/config/const.ts @@ -1,3 +1,4 @@ export const TOKEN_NAME = "sh_token"; export const REFRESH_TOKEN_NAME = "sh_refresh_token"; -export const BASE_URL = "https://api.shinan.ir"; +// export const BASE_URL = "https://api.shinan.ir"; +export const BASE_URL = "https://api-sondos.run.danakcorp.com"; diff --git a/src/helpers/errorUtils.ts b/src/helpers/errorUtils.ts new file mode 100644 index 0000000..35e99e5 --- /dev/null +++ b/src/helpers/errorUtils.ts @@ -0,0 +1,40 @@ +import { IGeneralError } from "@/types/error.types"; + +/** + * استخراج پیام خطا از error object + * @param error - error object از React Query یا Axios + * @param fallbackMessage - پیام پیش‌فرض در صورت عدم وجود خطا + * @returns پیام خطا + */ +export const extractErrorMessage = ( + error: Error | unknown, + fallbackMessage: string = "خطایی رخ داده است" +): string => { + try { + const axiosError = error as { response?: { data: IGeneralError } }; + return axiosError?.response?.data?.error?.details?.[0] || fallbackMessage; + } catch { + return fallbackMessage; + } +}; + +/** + * بررسی اینکه آیا error object معتبر است یا نه + * @param error - error object + * @returns boolean + */ +export const isValidError = ( + error: unknown +): error is { response?: { data: IGeneralError } } => { + return !!( + typeof error === "object" && + error !== null && + "response" in error && + error.response && + typeof error.response === "object" && + "data" in error.response && + error.response.data && + typeof error.response.data === "object" && + "error" in error.response.data + ); +}; diff --git a/src/hooks/useCountDown.ts b/src/hooks/useCountDown.ts new file mode 100755 index 0000000..944028b --- /dev/null +++ b/src/hooks/useCountDown.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; + +export interface ICountDown { + m: number; + s: number; +} +/** + * + * @param minutes how many minutes. + * @param autoplay by default true. if you want play coundDown after an action, you can set it to false and manually play it. + * @param onEnd you can pass a callback function that will trigger at the END. + * @returns + */ + +export function useCountDown(minutes: number, autoplay = true, onEnd = () => {}) { + + const [countDown, setCountDown] = useState({m: minutes, s: 0}); + const [stop, setStop] = useState(!autoplay); + + useEffect(() => { + const handler = setTimeout(() => { + if (countDown.m !== 0 || countDown.s !== 0) { + setCountDown(calculateCountDown(countDown)); + } else { + onEnd(); + } + }, 1000); + + return () => { + clearTimeout(handler); + }; + }, [countDown]); + + const reset = () => { + setCountDown({m: minutes, s: 0}); + }; + const pause = () => { + setStop(true); + }; + const play = () => { + setStop(false); + setCountDown({...countDown}); + }; + + const calculateCountDown = (st: ICountDown): ICountDown => { + if (stop) { + return countDown; + } + const timeLeft = { m: 0, s: 0 }; + const remain = st.m * 60 + st.s - 1; + if (remain > 0) { + timeLeft.m = Math.floor((remain) / 60); + timeLeft.s = remain - timeLeft.m * 60; + return timeLeft; + } else { + if (remain === 0 && countDown.s === 1) { + return timeLeft; + } else { + return countDown; + } + } + }; + + + return { + value: `${countDown.m > 9 ? countDown.m : '0' + countDown.m}:${countDown.s > 9 ? countDown.s : '0' + countDown.s}`, + reset, + pause, + play + }; +} \ No newline at end of file diff --git a/src/types/README.md b/src/types/README.md new file mode 100644 index 0000000..2f77da0 --- /dev/null +++ b/src/types/README.md @@ -0,0 +1,91 @@ +# سیستم مدیریت خطا + +## ساختار خطاها + +خطاهای API در این پروژه دارای ساختار زیر هستند: + +```typescript +{ + "status": 400, + "success": false, + "error": { + "message": "Bad request", + "details": ["فرمت موبایل اشتباه است"] + } +} +``` + +## Type های تعریف شده + +### IGeneralError +Type اصلی برای خطاهای API: +```typescript +interface IGeneralError { + status: number; + success: boolean; + error: { + message: string; + details: string[]; + }; +} +``` + +### IQueryError +Type برای خطاهای React Query: +```typescript +interface IQueryError { + response?: { + data: IGeneralError; + }; +} +``` + +## نحوه استفاده + +### 1. استفاده مستقیم در onError +```typescript +onError: (error: Error) => { + const axiosError = error as { response?: { data: IGeneralError } }; + const errorMessage = axiosError?.response?.data?.error?.details?.[0] || 'خطایی رخ داده است'; + toast(errorMessage, 'error'); +} +``` + +### 2. استفاده از utility function +```typescript +import { extractErrorMessage } from '@/helpers/errorUtils'; + +onError: (error: Error) => { + const errorMessage = extractErrorMessage(error); + toast(errorMessage, 'error'); +} +``` + +### 3. استفاده با fallback message +```typescript +const errorMessage = extractErrorMessage(error, 'خطای پیش‌فرض'); +``` + +## Utility Functions + +### extractErrorMessage +این تابع پیام خطا را از error object استخراج می‌کند: +```typescript +export const extractErrorMessage = ( + error: Error | unknown, + fallbackMessage: string = 'خطایی رخ داده است' +): string +``` + +### isValidError +این تابع بررسی می‌کند که آیا error object معتبر است یا نه: +```typescript +export const isValidError = (error: unknown): error is { response?: { data: IGeneralError } } +``` + +## نکات مهم + +1. همیشه از `error?.response?.data?.error?.details?.[0]` برای دسترسی به پیام خطا استفاده کنید +2. از `fallback message` برای نمایش پیام پیش‌فرض در صورت عدم وجود خطا استفاده کنید +3. از utility functions برای مدیریت بهتر خطاها استفاده کنید +4. Type safety را با استفاده از type assertion حفظ کنید diff --git a/src/types/error.types.ts b/src/types/error.types.ts index facf569..ada0cef 100644 --- a/src/types/error.types.ts +++ b/src/types/error.types.ts @@ -1,14 +1,36 @@ import { DefaultError } from "@tanstack/react-query"; import { IBaseResponse } from "./response.types"; -interface IError { - message: string[]; +// ساختار جدید خطا بر اساس نمونه کاربر +interface IErrorDetails { + message: string; + details: string[]; } export interface IErrorResponse extends IBaseResponse { - error: IError; + error: IErrorDetails; } -export interface IApiErrorRepsonse extends DefaultError { - response?: IErrorResponse; +// Type برای خطاهای API +export interface IApiErrorResponse extends DefaultError { + response?: { + data: IErrorResponse; + }; +} + +// Type عمومی برای خطاها +export interface IGeneralError { + status: number; + success: boolean; + error: { + message: string; + details: string[]; + }; +} + +// Type برای خطاهای React Query +export interface IQueryError { + response?: { + data: IGeneralError; + }; }