This commit is contained in:
hamid zarghami
2025-08-25 09:19:13 +03:30
parent 418b45fde5
commit 6e1ebdf023
20 changed files with 736 additions and 10 deletions
+101
View File
@@ -8,6 +8,7 @@
"name": "shop", "name": "shop",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
@@ -22,8 +23,12 @@
"next": "15.4.5", "next": "15.4.5",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "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", "swiper": "^11.2.10",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"yup": "^1.7.0",
"zustand": "^5.0.7" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
@@ -240,6 +245,18 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1402,6 +1419,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -5632,6 +5655,12 @@
"react-is": "^16.13.1" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -5690,12 +5719,48 @@
"react": "^19.1.0" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6370,6 +6435,12 @@
"node": ">=18" "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": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -6428,6 +6499,12 @@
"node": ">=8.0" "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": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -6483,6 +6560,18 @@
"node": ">= 0.8.0" "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": { "node_modules/typed-array-buffer": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "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" "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": { "node_modules/zustand": {
"version": "5.0.7", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz",
+5
View File
@@ -9,6 +9,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
@@ -23,8 +24,12 @@
"next": "15.4.5", "next": "15.4.5",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "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", "swiper": "^11.2.10",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"yup": "^1.7.0",
"zustand": "^5.0.7" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
+5
View File
@@ -0,0 +1,5 @@
<svg width="80" height="131" viewBox="0 0 80 131" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9251 0.338654C7.88357 1.20929 3.84207 4.02603 1.89297 7.32931C-0.113446 10.7606 0.00120607 6.63792 0.00120607 65.4824C0.00120607 124.301 -0.113446 120.204 1.89297 123.61C3.98538 127.195 8.45683 130.114 12.8136 130.754C14.1895 130.959 22.3298 131.036 41.1328 130.985L67.5029 130.908L69.366 130.319C74.4967 128.706 78.5955 124.609 79.656 120.076C80.1147 118.156 80.1147 12.8092 79.656 10.8886C78.5955 6.35624 74.6687 2.38719 69.5666 0.722748L67.5029 0.0569763L40.5595 0.00575256C18.0303 -0.0198517 13.3296 0.0313721 11.9251 0.338654ZM66.6143 6.20261C68.6207 6.66353 70.5412 7.81583 71.6877 9.22421C73.4935 11.452 73.3788 7.71341 73.3788 65.4824C73.3788 123.251 73.4935 119.513 71.6877 121.741C71.2004 122.33 70.2545 123.149 69.538 123.584C66.9296 125.197 67.4742 125.172 39.241 125.095L13.6162 125.018L12.0111 124.352C9.97598 123.507 8.42817 122.125 7.48228 120.307L6.73704 118.873V65.4824V12.0922L7.48228 10.6582C8.7148 8.32797 10.9505 6.66353 13.6735 6.10018C14.2754 5.97215 26.056 5.86972 39.843 5.84412C61.369 5.81851 65.1812 5.86972 66.6143 6.20261Z" fill="#CCCCCC"/>
<path d="M30.2613 17.5146C28.8686 18.6669 28.7709 20.7155 30.0903 22.0214L30.8233 22.764L40.1082 22.8408C50.3461 22.9176 50.6881 22.8664 51.4456 21.33C52.0809 20.0753 51.9098 18.8462 50.9814 17.8731L50.1506 17.0025H40.4992C31.2876 17.0025 30.8477 17.0281 30.2613 17.5146Z" fill="#CCCCCC"/>
<path d="M38.2529 102.695C35.5407 104.053 34.2701 106.844 35.052 109.66C35.8095 112.349 37.8131 113.962 40.4031 113.962C43.4329 113.962 45.6808 111.914 46.0962 108.764C46.6826 104.411 42.0157 100.8 38.2529 102.695Z" fill="#CCCCCC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+73
View File
@@ -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<AuthenticationType>({
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 (
<LoginStep2 />
)
}
return (
<div className='flex-1 flex flex-col justify-center'>
<div className='text-[#CCCCCC] text-2xl font-black'>ورود و ثبت نام</div>
<div className='mt-8'>
<Input
placeholder='09120000000'
className='text-left h-11'
{...register("phone")}
error_text={errors.phone?.message}
/>
</div>
<div className='mt-8'>
<Button isLoading={isPending} onClick={handleSubmit(onSubmit)} className='w-full h-11'>دریافت کد</Button>
</div>
</div>
)
}
export default LoginStep1
+132
View File
@@ -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<VerifyOtpType>({
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 (
<div className='flex-1 flex flex-col justify-center'>
<div className='text-[#CCCCCC] text-2xl font-black'>کد دریافتی را وارد نمایید</div>
<div className='mt-8 dltr otp'>
<OTPInput
shouldAutoFocus
value={watch("otpCode")}
numInputs={5}
inputType='tel'
containerStyle={{ direction: 'ltr' }}
onChange={(otp: string) => setValue('otpCode', otp)}
renderInput={(props) =>
<input
{...props}
type='tel'
autoComplete="one-time-code"
inputMode="numeric"
className='w-full h-[50px] flex-1 mx-2 bg-white border rounded-[10px]' />
}
/>
</div>
{
errors.otpCode?.message && <Error errorText={errors.otpCode.message} />
}
<div className='mt-8'>
<Button isLoading={isPending} className='w-full h-11' onClick={handleSubmit(onSubmit)}>تایید</Button>
</div>
{/* Countdown and Resend Section */}
<div className='mt-6'>
<div className='flex items-center justify-between'>
<span className='text-[#CCCCCC] text-sm'>کد فعالسازی را دریافت نکردید؟</span>
{countdownValue !== '00:00' ? (
<span className='text-blue-500 font-medium text-sm'>{countdownValue}</span>
) : (
<Button
variant={'ghost'}
isLoading={isAuthenticationPending}
onClick={handleResendOtp}
className='text-blue-500 hover:text-blue-600 cursor-pointer text-sm font-medium transition-colors'
>
دوباره امتحان کنید
</Button>
)}
</div>
</div>
</div>
)
}
export default LoginStep2
+14
View File
@@ -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,
});
};
+50
View File
@@ -0,0 +1,50 @@
'use client'
import { NextPage } from 'next'
import Image from 'next/image';
import LoginStep1 from './components/LoginStep1';
const LoginPage: NextPage = () => {
return (
<div className='flex justify-center items-center h-screen'>
<div className='h-[450px] w-[1000px] border border-border rounded-xl flex p-10 gap-32'>
<div className='flex-1'>
<div className='text-primary'>
1/2
</div>
<div className='w-full h-0.5 mt-4 flex items-center'>
<div className='size-2 rounded-full bg-primary'></div>
<div className='w-1/2 h-full bg-primary'></div>
<div className='size-2 rounded-full bg-primary'></div>
<div className='w-1/2 h-full bg-primary/40'></div>
<div className='size-2 rounded-full bg-primary/40'></div>
</div>
<div className='mt-20 flex gap-6'>
<Image
src='/images/mobile.svg'
width={80}
height={131}
alt='phone'
/>
<div>
<div className='text-[#8B8B8B] text-xl'>Wellcome</div>
<div className='mt-2 font-black text-4xl text-[#4F4F4F]'>
خوش آمدید
</div>
<p className='mt-4 text-[#8B8B8B] text-sm'>
برای دریافت کد فعالسازی شماره همراه یا ایمیل خود را وارد نمایید
</p>
</div>
</div>
</div>
<LoginStep1 />
</div>
</div>
)
}
export default LoginPage
+12
View File
@@ -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;
};
+9
View File
@@ -0,0 +1,9 @@
import { create } from "zustand";
import { AuthStoreType } from "../types/Types";
export const useAuthStore = create<AuthStoreType>((set) => ({
step: 1,
setStep: (step) => set({ step }),
phone: "",
setPhone: (phone) => set({ phone }),
}));
+15
View File
@@ -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;
};
+8
View File
@@ -187,3 +187,11 @@ textarea.place-black::placeholder {
div { div {
direction: rtl; direction: rtl;
} }
.otp div {
width: 100%;
}
.dltr {
direction: ltr;
}
+2
View File
@@ -3,6 +3,7 @@ import "./globals.css";
import "../../public/css/fontiran.css"; import "../../public/css/fontiran.css";
import 'swiper/css'; import 'swiper/css';
import QueryProvider from "@/config/QueryProvider"; import QueryProvider from "@/config/QueryProvider";
import ToastContainer from "@/components/Toast";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Create Next App",
@@ -19,6 +20,7 @@ export default function RootLayout({
<body> <body>
<QueryProvider> <QueryProvider>
{children} {children}
<ToastContainer />
</QueryProvider> </QueryProvider>
</body> </body>
</html> </html>
+59
View File
@@ -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[]>([]);
// ثبت 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 (
<div className="fixed top-4 text-sm right-0 left-0 mx-auto w-fit z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`px-4 flex items-center gap-2 backdrop-blur-2xl h-16 min-w-[300px] rounded-2xl shadow-md transition-transform ${toast.type === 'success'
? 'bg-white/70 text-black'
: toast.type === 'error'
? 'bg-white/70 text-black'
: 'bg-white/70 text-black'
}`}
>
{
toast.type === 'success' ? <TickCircle className='size-5' color='green' /> :
toast.type === 'error' ? <CloseCircle className='size-5' color='red' /> :
<InfoCircle className='size-5' color='blue' />
}
{toast.message}
</div>
))}
</div>
);
};
export const toast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
addToast({ id: Date.now().toString(), message, type });
};
export default ToastContainer;
+16 -1
View File
@@ -1,6 +1,7 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { ClipLoader } from "react-spinners"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -40,10 +41,14 @@ function Button({
variant, variant,
size, size,
asChild = false, asChild = false,
isLoading = false,
disabled,
children,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
isLoading?: boolean
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
@@ -51,8 +56,18 @@ function Button({
<Comp <Comp
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
disabled={disabled || isLoading}
{...props} {...props}
/> >
{isLoading ? (
<>
<ClipLoader size={16} color="currentColor" />
<span>در حال بارگذاری...</span>
</>
) : (
children
)}
</Comp>
) )
} }
+4 -3
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { IApiErrorRepsonse } from '@/types/error.types' import { IGeneralError } from '@/types/error.types'
import { QueryClient, QueryClientProvider, HydrationBoundary, QueryCache } from '@tanstack/react-query' import { QueryClient, QueryClientProvider, HydrationBoundary, QueryCache } from '@tanstack/react-query'
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { removeToken } from './func' import { removeToken } from './func'
@@ -10,8 +10,9 @@ export default function QueryProvider({ children }: { children: ReactNode }) {
const { setIsLogin } = useSharedStore() const { setIsLogin } = useSharedStore()
const [client] = useState(() => new QueryClient({ const [client] = useState(() => new QueryClient({
queryCache: new QueryCache({ queryCache: new QueryCache({
onError: async (error: IApiErrorRepsonse) => { onError: async (error: Error) => {
if (error.response?.status === 401) { const axiosError = error as { response?: { status: number; data: IGeneralError } };
if (axiosError.response?.status === 401) {
removeToken() removeToken()
setIsLogin(false) setIsLogin(false)
} }
+2 -1
View File
@@ -1,3 +1,4 @@
export const TOKEN_NAME = "sh_token"; export const TOKEN_NAME = "sh_token";
export const REFRESH_TOKEN_NAME = "sh_refresh_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";
+40
View File
@@ -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
);
};
+71
View File
@@ -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<ICountDown>({m: minutes, s: 0});
const [stop, setStop] = useState<boolean>(!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
};
}
+91
View File
@@ -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 حفظ کنید
+27 -5
View File
@@ -1,14 +1,36 @@
import { DefaultError } from "@tanstack/react-query"; import { DefaultError } from "@tanstack/react-query";
import { IBaseResponse } from "./response.types"; import { IBaseResponse } from "./response.types";
interface IError { // ساختار جدید خطا بر اساس نمونه کاربر
message: string[]; interface IErrorDetails {
message: string;
details: string[];
} }
export interface IErrorResponse extends IBaseResponse { export interface IErrorResponse extends IBaseResponse {
error: IError; error: IErrorDetails;
} }
export interface IApiErrorRepsonse extends DefaultError { // Type برای خطاهای API
response?: IErrorResponse; 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;
};
} }