diff --git a/public/images/Particle.png b/public/images/Particle.png deleted file mode 100644 index c9da1b17..00000000 Binary files a/public/images/Particle.png and /dev/null differ diff --git a/public/images/ParticleWithLogo.png b/public/images/ParticleWithLogo.png index b873cea2..c6434a3d 100644 Binary files a/public/images/ParticleWithLogo.png and b/public/images/ParticleWithLogo.png differ diff --git a/public/svg/SnsShareImg.svg b/public/svg/SnsShareImg.svg new file mode 100644 index 00000000..551a6c5e --- /dev/null +++ b/public/svg/SnsShareImg.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/api/auth/volunteer/refresh.ts b/src/api/auth/volunteer/refresh.ts index 130a73f5..da69d79a 100644 --- a/src/api/auth/volunteer/refresh.ts +++ b/src/api/auth/volunteer/refresh.ts @@ -16,7 +16,7 @@ export const fetchRefresh = async (data: LoginResponse) => { }; export const getRefresh = async () => { - const response = await fe.get(`auth/token/refresh`).then(res => + const response = await fe.get(`auth/token`).then(res => res.json<{ success: boolean; }>() diff --git a/src/api/instance.ts b/src/api/instance.ts index 72b02a38..15841615 100644 --- a/src/api/instance.ts +++ b/src/api/instance.ts @@ -3,7 +3,8 @@ import { setAuthorizationHeader } from '@/utils/ky/hooks/beforeRequest'; import { deleteClientCokiesPath, retryRequestOnUnauthorized, - throwServerErrorMessage + throwServerErrorMessage, + redirectTokenError } from '@/utils/ky/hooks/afterResponse'; /** @@ -26,6 +27,7 @@ const api = ky.extend({ beforeRequest: [setAuthorizationHeader(process)], afterResponse: [ retryRequestOnUnauthorized(process), + redirectTokenError(process), throwServerErrorMessage, deleteClientCokiesPath ] diff --git a/src/api/mypage/useLogout.tsx b/src/api/mypage/useLogout.tsx index 418294d8..36308b79 100644 --- a/src/api/mypage/useLogout.tsx +++ b/src/api/mypage/useLogout.tsx @@ -2,6 +2,7 @@ import { useAuthContext } from '@/providers/AuthContext'; import { useMutation } from '@tanstack/react-query'; import useToast from '@/hooks/useToast'; import { fe } from '../instance'; +import { STORAGE_KEY_HOME_CALENDAR_FILTER_INPUT } from '@/constants/localStorageKeys'; export default function useLogout() { const { logout: clientLogout } = useAuthContext(); @@ -18,6 +19,7 @@ export default function useLogout() { onSuccess: response => { clientLogout(); location.href = '/login'; + localStorage.removeItem(STORAGE_KEY_HOME_CALENDAR_FILTER_INPUT); toastOn('로그아웃이 완료되었습니다.'); }, onError: error => { diff --git a/src/api/queryKey.ts b/src/api/queryKey.ts index 96cafb34..dc87cd81 100644 --- a/src/api/queryKey.ts +++ b/src/api/queryKey.ts @@ -1,9 +1,10 @@ export const shelterKey = { all: ['shelter'] as const, - animalList: () => [...shelterKey.all, 'observation-animal-list'] as const, - animal: (id: number) => [...shelterKey.animalList(), id] as const, - image: () => [...shelterKey.all, 'image'] as const, + animalLists: () => [...shelterKey.all, 'observation-animal-list'] as const, + animalList: (shelterId: number) => + [...shelterKey.animalLists(), shelterId] as const, + animal: (id: number) => [...shelterKey.animalLists(), 'animal', id] as const, info: () => [...shelterKey.all, 'info'] as const, - homeInfo: () => [...shelterKey.all, 'info', 'home'] as const, - observationAnimal: () => [...shelterKey.all, 'observation-animal'] as const + image: () => [...shelterKey.info(), 'image'] as const, + homeInfo: () => [...shelterKey.info(), 'home'] as const }; diff --git a/src/api/shelter/admin/observation-animal.ts b/src/api/shelter/admin/observation-animal.ts index 28cecd89..26bfd32b 100644 --- a/src/api/shelter/admin/observation-animal.ts +++ b/src/api/shelter/admin/observation-animal.ts @@ -19,13 +19,6 @@ export const get = async (observationAnimalId: number) => { return response; }; -export const getAll = async () => { - const response = await api - .get('shelter/admin/observation-animal') - .then(res => res.json()); - return response; -}; - export const post = async (data: ObservationAnimalPayload) => { const response = await api .post(`shelter/admin/observation-animal`, { diff --git a/src/api/shelter/admin/useCreateObservationAnimal.ts b/src/api/shelter/admin/useCreateObservationAnimal.ts index 81f738cc..eea0b48b 100644 --- a/src/api/shelter/admin/useCreateObservationAnimal.ts +++ b/src/api/shelter/admin/useCreateObservationAnimal.ts @@ -26,7 +26,7 @@ export default function useCreateObservationAnimal( { onSuccess: (data, variables, context) => { options?.onSuccess && options.onSuccess(data, variables, context); - return queryClient.invalidateQueries(shelterKey.animalList()); + return queryClient.invalidateQueries(shelterKey.animalLists()); }, ...options } diff --git a/src/api/shelter/admin/useDeleteObservationAnimal.ts b/src/api/shelter/admin/useDeleteObservationAnimal.ts index 9ef2f20c..d54c4407 100644 --- a/src/api/shelter/admin/useDeleteObservationAnimal.ts +++ b/src/api/shelter/admin/useDeleteObservationAnimal.ts @@ -22,7 +22,7 @@ export default function useDeleteObservationAnimal( { onSuccess: (data, variables, context) => { options?.onSuccess && options.onSuccess(data, variables, context); - return queryClient.invalidateQueries(shelterKey.animalList()); + return queryClient.invalidateQueries(shelterKey.animalLists()); }, ...options } diff --git a/src/api/shelter/admin/useDeleteVolunteerEvent.ts b/src/api/shelter/admin/useDeleteVolunteerEvent.ts index e672f9c7..3baa1639 100644 --- a/src/api/shelter/admin/useDeleteVolunteerEvent.ts +++ b/src/api/shelter/admin/useDeleteVolunteerEvent.ts @@ -4,7 +4,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { DeleteResponse, remove } from './volunteer-event'; -import { queryKey } from '../volunteer-event'; export interface DeleteEventPayload extends DeleteResponse { shelterId: number; diff --git a/src/api/shelter/admin/useObservationAnimalList.ts b/src/api/shelter/admin/useObservationAnimalList.ts deleted file mode 100644 index 176d32a0..00000000 --- a/src/api/shelter/admin/useObservationAnimalList.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; -import { getAll } from './observation-animal'; -import { shelterKey } from '../../queryKey'; -import { ObservationAnimal } from '@/types/shelter'; - -export default function useObservationAnimalList( - options?: UseQueryOptions -) { - return useQuery( - shelterKey.animalList(), - () => getAll(), - { - ...options - } - ); -} diff --git a/src/api/shelter/admin/useUpdateEssentialInfo.ts b/src/api/shelter/admin/useUpdateEssentialInfo.ts index 127fc229..e8b140da 100644 --- a/src/api/shelter/admin/useUpdateEssentialInfo.ts +++ b/src/api/shelter/admin/useUpdateEssentialInfo.ts @@ -22,7 +22,7 @@ export default function useUpdateEssentialInfo( { onSuccess: (data, variables, context) => { options?.onSuccess && options.onSuccess(data, variables, context); - return queryClient.invalidateQueries(shelterKey.info()); + queryClient.invalidateQueries(shelterKey.info()); }, ...options } diff --git a/src/api/shelter/admin/useUpdateObservationAnimal.ts b/src/api/shelter/admin/useUpdateObservationAnimal.ts index a1968929..c10f4631 100644 --- a/src/api/shelter/admin/useUpdateObservationAnimal.ts +++ b/src/api/shelter/admin/useUpdateObservationAnimal.ts @@ -27,7 +27,7 @@ export default function useUpdateObservationAnimal( { onSuccess: (data, variables, context) => { options?.onSuccess && options.onSuccess(data, variables, context); - return queryClient.invalidateQueries(shelterKey.animalList()); + return queryClient.invalidateQueries(shelterKey.animalLists()); }, ...options } diff --git a/src/api/shelter/admin/useUpdateVolunteerEvent.tsx b/src/api/shelter/admin/useUpdateVolunteerEvent.tsx new file mode 100644 index 00000000..5fcc5600 --- /dev/null +++ b/src/api/shelter/admin/useUpdateVolunteerEvent.tsx @@ -0,0 +1,26 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient +} from '@tanstack/react-query'; +import { PutResponse, PutVolunteerEventPayload, put } from './volunteer-event'; + +export interface PutEventPayload extends PutVolunteerEventPayload { + eventId: number; +} + +export default function useUpdateVolunteerEvent( + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + return useMutation( + ({ eventId, ...payload }) => put(eventId, payload), + { + onSuccess: (data, variables, context) => { + options?.onSuccess && options.onSuccess(data, variables, context); + return queryClient.invalidateQueries(); + }, + ...options + } + ); +} diff --git a/src/api/shelter/admin/useWriteVolunteerEvent.tsx b/src/api/shelter/admin/useWriteVolunteerEvent.tsx new file mode 100644 index 00000000..a1b4f2d1 --- /dev/null +++ b/src/api/shelter/admin/useWriteVolunteerEvent.tsx @@ -0,0 +1,22 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient +} from '@tanstack/react-query'; +import { PostResponse, VolunteerEventPayload, post } from './volunteer-event'; + +export default function useWriteVolunteerEvent( + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + return useMutation( + payload => post(payload), + { + onSuccess: (data, variables, context) => { + options?.onSuccess && options.onSuccess(data, variables, context); + return queryClient.invalidateQueries(); + }, + ...options + } + ); +} diff --git a/src/api/shelter/auth/login.ts b/src/api/shelter/auth/login.ts index 916045e9..a4e596ee 100644 --- a/src/api/shelter/auth/login.ts +++ b/src/api/shelter/auth/login.ts @@ -9,8 +9,26 @@ export interface LoginPayload { export type LoginResponse = { accessToken: string; refreshToken: string; + needToChangePassword: boolean; +}; +export interface FowardPwdPayload { + email: string; + phoneNumber: string; +} + +export type FowardPwdResponse = { + shelterUserId: number; + needToChangePassword: boolean; }; +export interface PwdChangePayload { + password: string; +} +export interface PwdChangeResponse { + shelterUserId: number; + needToChangePassword: boolean; +} + export const loginShelter = async (data: LoginPayload) => { const response = await api .post(`auth/shelter/login`, { @@ -28,3 +46,23 @@ export const isExist = async (value: string, type: string) => { return response; }; + +export const fowardPwdLink = async (data: FowardPwdPayload) => { + const response = await api + .post(`auth/shelter/reset-password`, { + json: data + }) + .then(res => res.json()); + + return response; +}; + +export const pwdChange = async (data: PwdChangePayload) => { + const response = await api + .post(`auth/shelter/change-password`, { + json: data + }) + .then(res => res.json()); + + return response; +}; diff --git a/src/api/shelter/event/useParticipateVolEvent.ts b/src/api/shelter/event/useParticipateVolEvent.ts index 861c0169..f457c404 100644 --- a/src/api/shelter/event/useParticipateVolEvent.ts +++ b/src/api/shelter/event/useParticipateVolEvent.ts @@ -23,10 +23,7 @@ export default function useParticipateVolEvent( participate(shelterId, volunteerEventId), { onSuccess: (data, variables, context) => { - return queryClient.invalidateQueries({ - queryKey: queryKey.all, - refetchType: 'all' - }); + return queryClient.invalidateQueries(); }, ...options } diff --git a/src/api/shelter/event/useWithdrawVolEvent.ts b/src/api/shelter/event/useWithdrawVolEvent.ts index 2c9702e4..4efb043b 100644 --- a/src/api/shelter/event/useWithdrawVolEvent.ts +++ b/src/api/shelter/event/useWithdrawVolEvent.ts @@ -22,10 +22,7 @@ export default function useWithdrawVolEvent( ({ shelterId, volunteerEventId }) => withdraw(shelterId, volunteerEventId), { onSuccess: (data, variables, context) => { - return queryClient.invalidateQueries({ - queryKey: queryKey.all, - refetchType: 'all' - }); + return queryClient.invalidateQueries(); }, ...options } diff --git a/src/api/shelter/{shelterId}/useBookMarkMutation.ts b/src/api/shelter/{shelterId}/useBookMarkMutation.ts index 8a42d5f5..a8476ee7 100644 --- a/src/api/shelter/{shelterId}/useBookMarkMutation.ts +++ b/src/api/shelter/{shelterId}/useBookMarkMutation.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import useShelterHomeInfo from './useShelterHomeInfo'; import { post } from './bookmark'; import { useRouter } from 'next/navigation'; @@ -7,12 +7,15 @@ export default function useBookMarkMutation( shelterId: number, onSuccessCallback: (bookMarkState: boolean) => void ) { + const queryClient = useQueryClient(); + const { refetch } = useShelterHomeInfo(shelterId); const router = useRouter(); const mutate = useMutation(post, { onSuccess: data => { refetch(); onSuccessCallback(data.bookMarked!); + return queryClient.invalidateQueries(); }, onError: () => { router.replace('/login/volunteer'); diff --git a/src/api/shelter/{shelterId}/useObservationAnimalList.ts b/src/api/shelter/{shelterId}/useObservationAnimalList.ts index 69ea89c8..5c970106 100644 --- a/src/api/shelter/{shelterId}/useObservationAnimalList.ts +++ b/src/api/shelter/{shelterId}/useObservationAnimalList.ts @@ -1,15 +1,29 @@ -import { useQuery } from '@tanstack/react-query'; -import { get } from './observation-animal'; +import { + UseInfiniteQueryOptions, + useInfiniteQuery +} from '@tanstack/react-query'; +import { ObservationAnimalInfo, get } from './observation-animal'; import { shelterKey } from '../../queryKey'; -export default function useObservationAnimalListAtHome({ - shelterId, - page -}: { - shelterId: number; - page: number; -}) { - return useQuery(shelterKey.observationAnimal(), () => - get({ shelterId, page }) +export default function useObservationAnimalListAtHome( + { + shelterId + }: { + shelterId: number; + }, + options?: UseInfiniteQueryOptions +) { + return useInfiniteQuery( + shelterKey.animalList(shelterId), + ({ pageParam = 0 }) => get({ shelterId, page: pageParam }), + { + getNextPageParam: lastPage => { + if (lastPage.content.length === 0) { + return false; + } + return lastPage.pageNumber + 1; + }, + ...options + } ); } diff --git a/src/app/admin/shelter/edit/extra/page.tsx b/src/app/admin/shelter/edit/extra/page.tsx index f8f29080..8a632385 100644 --- a/src/app/admin/shelter/edit/extra/page.tsx +++ b/src/app/admin/shelter/edit/extra/page.tsx @@ -21,6 +21,7 @@ import yup from '@/utils/yup'; import useHeader from '@/hooks/useHeader'; import { OutLink } from '@/types/shelter'; import useBooleanState from '@/hooks/useBooleanState'; +import useRouteGuard from '@/hooks/useRouteGuard'; type FormValues = { instagram?: string; @@ -71,13 +72,14 @@ export default function ShelterEditExtraPage() { handleSubmit, watch, reset, - formState: { errors } + formState: { errors, isDirty } } = useForm({ mode: 'all', reValidateMode: 'onChange', resolver: yupResolver(schema) }); const router = useRouter(); + const { setRoutable } = useRouteGuard(); const shelterQuery = useShelterInfo(); const { mutateAsync: update } = useUpdateAdditionalInfo(); const [loading, loadingOn] = useBooleanState(false); @@ -86,20 +88,27 @@ export default function ShelterEditExtraPage() { const bankName = watch('bankName'); const accountNumber = watch('accountNumber'); - const isAccountCompleted = Boolean(accountNumber) !== Boolean(bankName); + const isAccountCompleted = Boolean(accountNumber) === Boolean(bankName); const isNotError = isEmpty(errors); - const isSubmittable = isAccountCompleted && isNotError; - const accountNumberError = !!(isAccountCompleted && !accountNumber) - ? { - message: '계좌번호를 입력해주세요' - } - : undefined; - const bankNameError = !!(isAccountCompleted && !bankName) - ? { - message: '은행명를 입력해주세요' - } - : undefined; + const isSubmittable = isAccountCompleted && isNotError && isDirty; + const accountNumberError = + !isAccountCompleted && !accountNumber + ? { + message: '계좌번호를 입력해주세요' + } + : undefined; + const bankNameError = + !isAccountCompleted && !bankName + ? { + message: '은행명를 입력해주세요' + } + : undefined; + + useEffect(() => { + if (isSubmittable) setRoutable(false); + else setRoutable(true); + }, [isSubmittable, setRoutable]); useEffect(() => { if (shelterQuery.isSuccess) { @@ -156,14 +165,13 @@ export default function ShelterEditExtraPage() { const onSubmit = useCallback( async (data: FormValues) => { - console.log('🔸 → onSubmit → data:', data); loadingOn(); const payload = getPayload(data); - console.log('🔸 → ShelterEditExtraPage → payload:', payload); + setRoutable(true); await update({ payload }); router.replace('/admin/shelter/edit' + window.location.hash); }, - [getPayload, loadingOn, router, update] + [getPayload, loadingOn, router, setRoutable, update] ); return ( @@ -199,7 +207,7 @@ export default function ShelterEditExtraPage() { />
- 카카오페이 코드송금 링크를 입력하면, 원터치로 후원금 모금이 + 카카오페이 코드 송금 링크를 입력하면, 원터치로 후원금 모금이 가능해요.
@@ -207,9 +215,9 @@ export default function ShelterEditExtraPage() { className={textButton} element={'a'} color="primary300" - href={process.env.NEXT_PUBLIC_QNA_URL} + onClick={() => window.open(process.env.NEXT_PUBLIC_QNA_URL)} > - 코드송금 링크는 어떻게 생성하나요? + 코드 송금 링크는 어떻게 생성하나요?
@@ -241,7 +249,7 @@ export default function ShelterEditExtraPage() { /> - diff --git a/src/app/admin/shelter/edit/page.tsx b/src/app/admin/shelter/edit/page.tsx index 92e6a9cd..8e03f2b9 100644 --- a/src/app/admin/shelter/edit/page.tsx +++ b/src/app/admin/shelter/edit/page.tsx @@ -1,6 +1,6 @@ 'use client'; import ImageUploader from '@/components/common/ImageUploader/ImageUploader'; -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import EditMenu from '@/components/shelter-edit/EditMenu/EditMenu'; import Badge from '@/components/common/Badge/Badge'; import Divider from '@/components/common/Divider/Divider'; @@ -14,20 +14,20 @@ import useBooleanState from '@/hooks/useBooleanState'; import useDialog from '@/hooks/useDialog'; import useToast from '@/hooks/useToast'; import useDeleteObservationAnimal from '@/api/shelter/admin/useDeleteObservationAnimal'; -import useObservationAnimalList from '@/api/shelter/admin/useObservationAnimalList'; import useShelterInfo from '@/api/shelter/admin/useShelterInfo'; import { OUT_LINK_TYPE } from '@/constants/shelter'; import useImageUploader from '@/hooks/useImageUploader'; import useUpdateImage from '@/api/shelter/admin/useUpdateImage'; import useHeader from '@/hooks/useHeader'; -import { ObservationAnimal, ShelterAdditionalInfo } from '@/types/shelter'; +import { ObservationAnimal, ShelterInfo } from '@/types/shelter'; import FixedFooter from '@/components/common/FixedFooter/FixedFooter'; -import RegisterComplete from '@/app/register/shelter/components/RegisterComplete'; +import RegisterComplete from '@/app/register/shelter/[...slug]/RegisterComplete'; +import useObservationAnimalListAtHome from '@/api/shelter/{shelterId}/useObservationAnimalList'; export default function ShelterEditPage() { useHeader({ title: '보호소 정보' }); - const { onChangeImage, isUploading } = useImageUploader(); - const [uploadError, setUploadError] = useState(false); + const { onChangeImage, isUploading, uploadError } = useImageUploader(); + const [postError, setPostError] = useState(false); const router = useRouter(); const [isOpened, openDialog, closeDialog] = useBooleanState(false); @@ -36,8 +36,24 @@ export default function ShelterEditPage() { const [targetAnimal, setTargetAnimal] = useState(); const [registerCompleted, setRegisterCompleted] = useState(false); - const animalsQuery = useObservationAnimalList(); const shelterQuery = useShelterInfo(); + const { data, fetchNextPage, hasNextPage, isSuccess } = + useObservationAnimalListAtHome( + { shelterId: shelterQuery.data?.id || -1 }, + { enabled: Boolean(shelterQuery.data?.id) } + ); + const animalLists = useMemo(() => { + return data?.pages.reduce((acc: ObservationAnimal[], page) => { + return [...acc, ...page.content]; + }, []); + }, [data?.pages]); + + useEffect(() => { + if (hasNextPage) { + fetchNextPage(); + } + }, [hasNextPage, fetchNextPage]); + const { mutateAsync: deleteAnimal } = useDeleteObservationAnimal(); const { mutateAsync: updateImage } = useUpdateImage(); @@ -47,7 +63,7 @@ export default function ShelterEditPage() { if (!url) throw Error(); await updateImage(url); } catch { - setUploadError(true); + setPostError(true); shelterQuery.refetch(); } }); @@ -71,8 +87,8 @@ export default function ShelterEditPage() { }; const handleClickEdit = (idx: number) => { - if (animalsQuery.data) { - setTargetAnimal(animalsQuery.data[idx]); + if (animalLists) { + setTargetAnimal(animalLists[idx]); openDialog(); } }; @@ -82,20 +98,39 @@ export default function ShelterEditPage() { openDialog(); }; - const isAddtionalInfoCompleted = (info: ShelterAdditionalInfo) => { - if (info.outLinks.length !== Object.keys(OUT_LINK_TYPE).length) - return false; - return !Object.values(info).includes(null); + const getAdditionalInfoStatus = (info: ShelterInfo) => { + const isOutlinkCompleted = + info.outLinks.length === Object.keys(OUT_LINK_TYPE).length; + const isCompleted = + info.parkingInfo && info.bankAccount && info.notice && isOutlinkCompleted; + const isInProgress = + info.parkingInfo || + info.bankAccount || + info.notice || + info.outLinks.length > 0; + + return isCompleted + ? 'completed' + : isInProgress + ? 'in_progress' + : 'not_entered'; }; const handleClickCompleteRegister = () => { setRegisterCompleted(true); }; - const MenuBadge = (isCompleted: boolean) => ( - - {isCompleted ? '입력 완료' : '미입력'} - + const MenuBadge = useCallback( + (status: ReturnType) => ( + + {status === 'completed' + ? '입력 완료' + : status === 'in_progress' + ? '입력중' + : '미입력'} + + ), + [] ); if (registerCompleted) { @@ -112,15 +147,22 @@ export default function ShelterEditPage() { defaultImage="shelter" size="96" loading={isUploading} - error={uploadError} + error={uploadError || postError} onChangeCallback={handleChangeImage} />
+ router.push(location.pathname + '/password')} + /> + + router.push(location.pathname + '/required')} /> @@ -128,8 +170,9 @@ export default function ShelterEditPage() { title="추가 정보" caption="SNS계정 / 후원 계좌 정보 / 주차 정보 / 사전 안내사항" titleSuffix={MenuBadge( - shelterQuery.isSuccess && - isAddtionalInfoCompleted(shelterQuery.data) + shelterQuery.isSuccess + ? getAdditionalInfoStatus(shelterQuery.data) + : 'not_entered' )} onClick={() => router.push(location.pathname + '/extra')} /> @@ -142,12 +185,10 @@ export default function ShelterEditPage() { titleSuffix={

0 - ? 'primary300' - : 'gray400' + animalLists && animalLists.length > 0 ? 'primary300' : 'gray400' } > - {animalsQuery.data?.length || 0} + {animalLists?.length || 0}

} /> @@ -159,9 +200,9 @@ export default function ShelterEditPage() { > 동물 추가하기 - {animalsQuery.isSuccess && ( + {isSuccess && animalLists && (
- {animalsQuery.data.map((animal, idx) => ( + {animalLists.map((animal, idx) => ( ({ + mode: 'all', + reValidateMode: 'onChange', + resolver: yupResolver(passwordChangeValidatgion) + }); + const { + register, + formState: { errors }, + getValues, + handleSubmit + } = methods; + + const areInputsFilled = + Boolean(getValues('password')?.trim()) && + Boolean(getValues('passwordConfirm')?.trim()); + + const { mutate: logout } = useLogout(); + + const handlePasswordChange = useCallback( + async (data: PassChangeFormValue) => { + const newData = { + password: data.password + }; + + try { + await pwdChange(newData); + logout(); + toastOn('비밀번호가 변경되었습니다. 로그인 화면으로 이동합니다.'); + router.push('/login'); + } catch (error) { + toastOn('알 수 없는 오류가 발생했습니다. 다시 시도해주세요.'); + } + }, + [] + ); + + return ( +
+ + + + + +
+ ); +} diff --git a/src/app/admin/shelter/edit/password/style.css.ts b/src/app/admin/shelter/edit/password/style.css.ts new file mode 100644 index 00000000..b61e765f --- /dev/null +++ b/src/app/admin/shelter/edit/password/style.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + paddingTop: '24px', + display: 'flex', + flexDirection: 'column', + rowGap: '24px' +}); diff --git a/src/app/admin/shelter/edit/required/page.tsx b/src/app/admin/shelter/edit/required/page.tsx index ac7a37e0..05e4b923 100644 --- a/src/app/admin/shelter/edit/required/page.tsx +++ b/src/app/admin/shelter/edit/required/page.tsx @@ -2,7 +2,7 @@ import Button from '@/components/common/Button/Button'; import TextField from '@/components/common/TextField/TextField'; import { yupResolver } from '@hookform/resolvers/yup'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as styles from './styles.css'; import { Caption1 } from '@/components/common/Typography'; @@ -18,6 +18,7 @@ import yup from '@/utils/yup'; import useHeader from '@/hooks/useHeader'; import { SearchedAddress } from '@/types/shelter'; import useBooleanState from '@/hooks/useBooleanState'; +import useRouteGuard from '@/hooks/useRouteGuard'; type FormValues = { name: string; @@ -70,7 +71,7 @@ export default function ShelterEditRequiredPage() { handleSubmit, reset, watch, - formState: { errors } + formState: { errors, isDirty } } = useForm({ mode: 'all', reValidateMode: 'onChange', @@ -78,6 +79,7 @@ export default function ShelterEditRequiredPage() { }); const router = useRouter(); + const { setRoutable } = useRouteGuard(); const shelterQuery = useShelterInfo(); const { mutateAsync: update } = useUpdateEssentialInfo(); const [searchedAddress, setSearchedAddress] = useState(); @@ -110,7 +112,6 @@ export default function ShelterEditRequiredPage() { const onSubmit = useCallback( async (data: FormValues) => { - console.log('🔸 → onSubmit → data:', data); if (!shelterQuery.isSuccess || !searchedAddress) return; loadingOn(); const payload: ShelterEssentialInfoPayload = { @@ -121,12 +122,27 @@ export default function ShelterEditRequiredPage() { addressDetail: data.addressDetail } }; + setRoutable(true); await update({ payload }); router.replace('/admin/shelter/edit'); }, - [loadingOn, router, searchedAddress, shelterQuery.isSuccess, update] + [ + loadingOn, + router, + searchedAddress, + setRoutable, + shelterQuery.isSuccess, + update + ] ); + const isSubmittable = isDirty && isEmpty(errors) && Boolean(searchedAddress); + + useEffect(() => { + if (isSubmittable) setRoutable(false); + else setRoutable(true); + }, [isSubmittable, setRoutable]); + return (
@@ -164,7 +180,7 @@ export default function ShelterEditRequiredPage() {
- 아직 dangledangle 회원이 아니신가요? + 아직 댕글댕글 회원이 아니신가요? router.push('/register/shelter')} diff --git a/src/app/login/shelter/password/page.tsx b/src/app/login/shelter/password/page.tsx index 4ecd5ae2..15329e44 100644 --- a/src/app/login/shelter/password/page.tsx +++ b/src/app/login/shelter/password/page.tsx @@ -10,31 +10,38 @@ import useHeader from '@/hooks/useHeader'; import useToast from '@/hooks/useToast'; import { yupResolver } from '@hookform/resolvers/yup'; import { isEmpty } from 'lodash'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { passWordFindValidation } from '../../../shelter/utils/shelterValidaion'; import * as styles from './styles.css'; +import FormProvider from '@/components/common/FormProvider/FormProvider'; +import { handlePhoneNumberChange, removeDash } from '@/utils/formatInputs'; +import { fowardPwdLink } from '@/api/shelter/auth/login'; +import { ApiErrorResponse } from '@/types/apiTypes'; const helperMessage = `등록한 파트너 계정의 이메일을 입력해주세요. 비밀번호를 재설정할 수 있는 링크를 보내드립니다.`; interface FindPassFormValue { email: string; + phoneNumber: string; } export default function ShelterPassword() { - const { - register, - formState: { errors }, - setError, - watch - } = useForm({ + const methods = useForm({ mode: 'all', reValidateMode: 'onChange', resolver: yupResolver(passWordFindValidation) }); + const { + register, + formState: { errors }, + setError, + watch, + handleSubmit + } = methods; - const setHeader = useHeader({ title: '비밀번호 찾기' }); + useHeader({ title: '비밀번호 찾기' }); const toastOn = useToast(); useEffect(() => { @@ -58,13 +65,26 @@ export default function ShelterPassword() { } }, [emailValue, debouncedValidator]); - const handleSendPassLink = async () => { - try { - toastOn('비밀번호 재설정 링크가 전송되었습니다.'); - } catch (error) { - toastOn('비밀번호 재설정 링크를 전송하는 데 실패했습니다.'); - } - }; + const handleSendPassLink = useCallback( + async (data: FindPassFormValue) => { + const newData = { + ...data, + phoneNumber: removeDash(data.phoneNumber) + }; + + try { + await fowardPwdLink(newData); + toastOn('비밀번호 재설정 링크가 발송되었습니다.'); + } catch (e) { + if ((e as ApiErrorResponse).exceptionCode === 'STORAGE-001') { + toastOn('등록하신 핸드폰 번호를 다시 확인해주세요.'); + } else { + toastOn('알 수 없는 오류가 발생했습니다. 다시 시도해주세요.'); + } + } + }, + [toastOn] + ); return ( <> @@ -74,25 +94,37 @@ export default function ShelterPassword() { 등록하신 이메일을 입력해주세요
- { - if (emailValue?.length > 0) { - debouncedValidator(emailValue, 'EMAIL'); - } - }} - error={errors.email} - /> - + { + if (emailValue?.length > 0) { + debouncedValidator(emailValue, 'EMAIL'); + } + }} + error={errors.email} + autoFocus + /> + + + ); } diff --git a/src/app/login/shelter/styles.css.ts b/src/app/login/shelter/styles.css.ts index 9acd0d6c..2b1f29d7 100644 --- a/src/app/login/shelter/styles.css.ts +++ b/src/app/login/shelter/styles.css.ts @@ -22,5 +22,6 @@ export const registerTextWrapper = style({ display: 'flex', columnGap: '10px', justifyContent: 'center', + alignItems: 'center', marginTop: '34px' }); diff --git a/src/app/register/shelter/components/Account.tsx b/src/app/register/shelter/[...slug]/Account.tsx similarity index 97% rename from src/app/register/shelter/components/Account.tsx rename to src/app/register/shelter/[...slug]/Account.tsx index aef369f7..eb93e926 100644 --- a/src/app/register/shelter/components/Account.tsx +++ b/src/app/register/shelter/[...slug]/Account.tsx @@ -10,23 +10,24 @@ import useBooleanState from '@/hooks/useBooleanState'; import useDebounceValidator from '@/hooks/useDebounceValidator'; import React, { useCallback, useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { OnNextProps } from '../page'; -import * as styles from './../styles.css'; +import { OnNextProps } from './page'; +import * as styles from '../styles.css'; import { useRouter } from 'next/navigation'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import { URL_PRIVACY_POLICY, URL_TERMS_OF_USE } from '@/constants/landingURL'; +import Cookies from 'js-cookie'; type SingleCheckedKeys = 'over14' | 'terms' | 'privacy' | 'marketing'; type SingleCheckedState = Record; export default function Account({ onNext }: OnNextProps) { - const router = useRouter(); const [isSheet, isOpenSheet, isCloseSheet] = useBooleanState(); const { register, formState: { errors }, watch, - setError + setError, + setFocus } = useFormContext(); const emailValue = watch('email'); @@ -45,6 +46,10 @@ export default function Account({ onNext }: OnNextProps) { } }, [emailValue, debouncedValidator]); + useEffect(() => { + setFocus('email'); + }, []); + const areInputsFilled = Boolean(emailValue?.trim()) && Boolean(passwordValue?.trim()) && diff --git a/src/app/register/shelter/components/Additional.tsx b/src/app/register/shelter/[...slug]/Additional.tsx similarity index 98% rename from src/app/register/shelter/components/Additional.tsx rename to src/app/register/shelter/[...slug]/Additional.tsx index 25bd4a22..a7bfd3f5 100644 --- a/src/app/register/shelter/components/Additional.tsx +++ b/src/app/register/shelter/[...slug]/Additional.tsx @@ -8,7 +8,7 @@ import { ButtonText1 } from '@/components/common/Typography'; import CarouselItem from '@/components/shelter/CarouselItem/CarouselItem'; import useHeader from '@/hooks/useHeader'; import { useRouter } from 'next/navigation'; -import { OnNextProps } from '../page'; +import { OnNextProps } from './page'; import * as styles from '../styles.css'; import { Register_1, Register_2, Register_3, Register_4 } from '@/asset/icons'; import { assignInlineVars } from '@vanilla-extract/dynamic'; diff --git a/src/app/register/shelter/components/Address.tsx b/src/app/register/shelter/[...slug]/Address.tsx similarity index 84% rename from src/app/register/shelter/components/Address.tsx rename to src/app/register/shelter/[...slug]/Address.tsx index e5821ac9..b7877d3a 100644 --- a/src/app/register/shelter/components/Address.tsx +++ b/src/app/register/shelter/[...slug]/Address.tsx @@ -4,19 +4,23 @@ import EmphasizedTitle, { } from '@/components/common/EmphasizedTitle/EmphasizedTitle'; import AddressSearchBar from '@/components/shelter-edit/AddressSearchBar/AddressSearchBar'; import useHeader from '@/hooks/useHeader'; -import { useCallback, useState } from 'react'; -import { useFormContext } from 'react-hook-form'; -import { OnNextProps } from '../page'; -import * as styles from './../styles.css'; import { SearchedAddress } from '@/types/shelter'; +import { useCallback, useEffect, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import * as styles from '../styles.css'; +import { OnNextProps } from './page'; export default function Address({ onNext }: OnNextProps) { - const { setValue } = useFormContext(); - const setHeader = useHeader({ + const { setValue, setFocus } = useFormContext(); + useHeader({ thisPage: 3, entirePage: 4 }); + useEffect(() => { + setFocus('address[address]'); + }, []); + const [searchedAddress, setSearchedAddress] = useState(); const handleChangeAddress = useCallback( (address?: SearchedAddress) => { diff --git a/src/app/register/shelter/components/Description.tsx b/src/app/register/shelter/[...slug]/Description.tsx similarity index 87% rename from src/app/register/shelter/components/Description.tsx rename to src/app/register/shelter/[...slug]/Description.tsx index b1a997a9..1e2aa6ea 100644 --- a/src/app/register/shelter/components/Description.tsx +++ b/src/app/register/shelter/[...slug]/Description.tsx @@ -5,18 +5,24 @@ import EmphasizedTitle, { import TextArea from '@/components/common/TextField/TextArea'; import useHeader from '@/hooks/useHeader'; import { FieldValues, SubmitHandler, useFormContext } from 'react-hook-form'; -import { OnNextProps } from '../page'; -import * as styles from './../styles.css'; +import { OnNextProps } from './page'; +import * as styles from '../styles.css'; +import { useEffect } from 'react'; export default function Description({ onSubmit }: OnNextProps) { const { handleSubmit, register, formState: { errors }, - watch + watch, + setFocus } = useFormContext(); const descriptionValue = watch('description'); + useEffect(() => { + setFocus('description'); + }, []); + const setHeader = useHeader({ thisPage: 4, entirePage: 4 diff --git a/src/app/register/shelter/components/Hp.tsx b/src/app/register/shelter/[...slug]/Hp.tsx similarity index 86% rename from src/app/register/shelter/components/Hp.tsx rename to src/app/register/shelter/[...slug]/Hp.tsx index 17e70c83..069da5d7 100644 --- a/src/app/register/shelter/components/Hp.tsx +++ b/src/app/register/shelter/[...slug]/Hp.tsx @@ -5,16 +5,17 @@ import EmphasizedTitle, { import TextField from '@/components/common/TextField/TextField'; import useHeader from '@/hooks/useHeader'; import { formatPhone } from '@/utils/formatInputs'; -import { useCallback } from 'react'; +import { use, useCallback, useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; -import { OnNextProps } from '../page'; -import * as styles from './../styles.css'; +import { OnNextProps } from './page'; +import * as styles from '../styles.css'; export default function Hp({ onNext }: OnNextProps) { const { register, formState: { errors }, - watch + watch, + setFocus } = useFormContext(); const hpValue = watch('phoneNumber'); @@ -23,6 +24,10 @@ export default function Hp({ onNext }: OnNextProps) { entirePage: 4 }); + useEffect(() => { + setFocus('phoneNumber'); + }, []); + const handlePhoneNumberChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.value; diff --git a/src/app/register/shelter/components/Name.tsx b/src/app/register/shelter/[...slug]/Name.tsx similarity index 87% rename from src/app/register/shelter/components/Name.tsx rename to src/app/register/shelter/[...slug]/Name.tsx index 76d78647..d177c0dd 100644 --- a/src/app/register/shelter/components/Name.tsx +++ b/src/app/register/shelter/[...slug]/Name.tsx @@ -7,15 +7,17 @@ import useDebounceValidator from '@/hooks/useDebounceValidator'; import useHeader from '@/hooks/useHeader'; import { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; -import { OnNextProps } from '../page'; -import * as styles from './../styles.css'; +import { OnNextProps } from './page'; +import * as styles from '../styles.css'; +import Cookies from 'js-cookie'; export default function Name({ onNext }: OnNextProps) { const { register, formState: { errors }, watch, - setError + setError, + setFocus } = useFormContext(); const nameValue = watch('name'); @@ -36,6 +38,13 @@ export default function Name({ onNext }: OnNextProps) { debouncedValidator(nameValue, 'NAME'); } }, [nameValue, debouncedValidator]); + useEffect(() => { + Cookies.set('step', 'processing'); + }, []); + + useEffect(() => { + setFocus('name'); + }, []); return ( <> diff --git a/src/app/register/shelter/components/RegisterComplete.tsx b/src/app/register/shelter/[...slug]/RegisterComplete.tsx similarity index 88% rename from src/app/register/shelter/components/RegisterComplete.tsx rename to src/app/register/shelter/[...slug]/RegisterComplete.tsx index 7380c83b..6535c24d 100644 --- a/src/app/register/shelter/components/RegisterComplete.tsx +++ b/src/app/register/shelter/[...slug]/RegisterComplete.tsx @@ -1,13 +1,11 @@ import Button from '@/components/common/Button/Button'; import { H2, H3 } from '@/components/common/Typography'; import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import * as styles from './../styles.css'; +import * as styles from '../styles.css'; import { DaenggleLogo } from '@/asset/icons'; import useHeader from '@/hooks/useHeader'; export default function RegisterComplete() { - const router = useRouter(); useHeader({ isHeader: 'hidden' }); return (
@@ -22,7 +20,7 @@ export default function RegisterComplete() { particle diff --git a/src/app/register/shelter/components/RequireComplete.tsx b/src/app/register/shelter/[...slug]/RequireComplete.tsx similarity index 93% rename from src/app/register/shelter/components/RequireComplete.tsx rename to src/app/register/shelter/[...slug]/RequireComplete.tsx index c5d11086..38366b28 100644 --- a/src/app/register/shelter/components/RequireComplete.tsx +++ b/src/app/register/shelter/[...slug]/RequireComplete.tsx @@ -1,8 +1,8 @@ import Button from '@/components/common/Button/Button'; import { H2, H3 } from '@/components/common/Typography'; import Image from 'next/image'; -import { OnNextProps } from '../page'; -import * as styles from './../styles.css'; +import { OnNextProps } from './page'; +import * as styles from '../styles.css'; import useHeader from '@/hooks/useHeader'; import { useFormContext } from 'react-hook-form'; import { MouseEventHandler } from 'react'; diff --git a/src/app/register/shelter/components/SpecificAddress.tsx b/src/app/register/shelter/[...slug]/SpecificAddress.tsx similarity index 88% rename from src/app/register/shelter/components/SpecificAddress.tsx rename to src/app/register/shelter/[...slug]/SpecificAddress.tsx index b4426939..54bbc936 100644 --- a/src/app/register/shelter/components/SpecificAddress.tsx +++ b/src/app/register/shelter/[...slug]/SpecificAddress.tsx @@ -5,17 +5,22 @@ import EmphasizedTitle, { import Message from '@/components/common/TextField/Message/Message'; import TextField from '@/components/common/TextField/TextField'; import { useFormContext } from 'react-hook-form'; -import { OnNextProps } from '../page'; -import * as styles from './../styles.css'; +import { OnNextProps } from './page'; +import * as styles from '../styles.css'; +import { useEffect } from 'react'; export default function SpecificAddress({ onNext }: OnNextProps) { const { register, formState: { errors }, - watch + watch, + setFocus } = useFormContext(); const addressValue = watch('address[addressDetail]'); + useEffect(() => { + setFocus('address[addressDetail]'); + }, []); return ( <>
@@ -28,10 +33,12 @@ export default function SpecificAddress({ onNext }: OnNextProps) {
; + onLogin: ( + loginData: Pick + ) => Promise; +} + +export interface SignUpFormValue extends ShelterRegisterPayload { + passwordConfirm: string; +} + +const Steps: StepsProps[] = [ + { + component: Account, + path: '1' + }, + { + component: Name, + path: '2' + }, + { + component: Hp, + path: '3' + }, + { + component: Address, + path: '4' + }, + { + component: SpecificAddress, + path: '5' + }, + { + component: Description, + path: '6' + }, + { + component: RequireComplete, + path: '7' + }, + { + component: Additional, + path: '8' + }, + { + component: RegisterComplete, + path: '9' + } +]; + +export default function ShelterRegister() { + const toastOn = useToast(); + useHeader({ title: '보호소 파트너 계정 가입' }); + const pathname = usePathname(); + + // 새로고침시 url step으로 초기화 + useEffect(() => { + if (Cookies.get('step') === 'processing') { + Cookies.remove('step'); + const initialUrl = `${window.location.origin}/register/shelter`; + location.replace(initialUrl); + } + }, []); + + const { goToNextStep, currentStepIndex } = useFunnel( + Steps, + pathname + ); + const CurrentComponent = Steps[currentStepIndex].component; + + const methods = useForm({ + mode: 'all', + reValidateMode: 'onChange', + resolver: yupResolver(registerValidation) + }); + + const { mutateAsync: registerMutateAsync } = useShelterRegister(); + const { mutateAsync: loginMutateAsync } = useShelterLogin(); + + const { handleSubmit } = methods; + + const onSubmit = useCallback( + async (data: SignUpFormValue) => { + const newData: ShelterRegisterPayload = { + ...data, + name: data.name.trim(), + phoneNumber: removeDash(data.phoneNumber) + }; + + try { + await registerMutateAsync(newData); + goToNextStep(); + toastOn('회원가입에 성공했습니다.'); + } catch (error) { + toastOn('회원가입에 실패했습니다.'); + } + }, + [goToNextStep, toastOn, registerMutateAsync] + ); + + const onLogin = useCallback( + async (loginData: Pick) => { + try { + await loginMutateAsync(loginData); + goToNextStep(); + toastOn('로그인에 성공했습니다.'); + } catch (error) { + toastOn('로그인에 실패했습니다.'); + } + }, + [goToNextStep, toastOn, loginMutateAsync] + ); + return ( + + + + ); +} diff --git a/src/app/register/shelter/components/Sure.tsx b/src/app/register/shelter/components/Sure.tsx deleted file mode 100644 index bbd26113..00000000 --- a/src/app/register/shelter/components/Sure.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Question, Warning } from '@/asset/icons'; -import Button from '@/components/common/Button/Button'; -import CheckBox from '@/components/common/CheckBox/CheckBox'; -import EmphasizedTitle, { - E, - Line -} from '@/components/common/EmphasizedTitle/EmphasizedTitle'; -import { Body3, Body4, H4 } from '@/components/common/Typography'; -import { useState } from 'react'; -import { OnNextProps } from '../page'; -import * as styles from './../styles.css'; -import { assignInlineVars } from '@vanilla-extract/dynamic'; - -export default function Sure({ onNext }: OnNextProps) { - const [checked, setChecked] = useState(false); - return ( - <> -
- - - 보호소 파트너로 - - 가입하시는 것이 맞는지 - 꼭 확인해주세요. - -
- -
- -

보호소 파트너란?

-
- -
-
- 시보호소 또는 민간보호소를 운영하는 -
- 운영자, 관계자분들을 대상 - 으로해요. -
-
-
- -
- -

주의해주세요.

-
- -
-
- 운영자가 확인했을 때 시보호소/민간 보호소 - 관계자가 아닌, 개인 구조자, 분양 홍보자 등일 경우 -
- 임의로 해당  - 계정을 사용 중지 처리할 수 있어요. -
-
-
- -
- -
- - - ); -} diff --git a/src/app/register/shelter/page.tsx b/src/app/register/shelter/page.tsx index 15e9e566..981e4133 100644 --- a/src/app/register/shelter/page.tsx +++ b/src/app/register/shelter/page.tsx @@ -1,144 +1,90 @@ 'use client'; +import { Question, Warning } from '@/asset/icons'; +import Button from '@/components/common/Button/Button'; +import CheckBox from '@/components/common/CheckBox/CheckBox'; +import EmphasizedTitle, { + E, + Line +} from '@/components/common/EmphasizedTitle/EmphasizedTitle'; +import { Body3, Body4, H4 } from '@/components/common/Typography'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import { useRouter } from 'next/navigation'; +import { useCallback, useState } from 'react'; +import * as styles from './styles.css'; +import useKeyboardActive from '@/hooks/useKeyboardActive'; -import { ShelterRegisterPayload } from '@/api/shelter/auth/sign-up'; -import useShelterRegister from '@/api/shelter/auth/useShelterRegister'; -import FormProvider from '@/components/common/FormProvider/FormProvider'; -import useFunnel, { StepsProps } from '@/hooks/useFunnel'; -import useToast from '@/hooks/useToast'; -import { removeDash } from '@/utils/formatInputs'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { usePathname } from 'next/navigation'; -import { useCallback } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; -import Account from './components/Account'; -import Additional from './components/Additional'; -import Address from './components/Address'; -import Description from './components/Description'; -import Hp from './components/Hp'; -import Name from './components/Name'; -import RegisterComplete from './components/RegisterComplete'; -import RequireComplete from './components/RequireComplete'; -import SpecificAddress from './components/SpecificAddress'; -import Sure from './components/Sure'; -import { registerValidation } from '@/app/shelter/utils/shelterValidaion'; -import useHeader from '@/hooks/useHeader'; -import useShelterLogin from '@/api/shelter/auth/useShelterLogin'; +export default function Sure() { + const router = useRouter(); + const [checked, setChecked] = useState(false); + const keyboardActive = useKeyboardActive(); -export interface OnNextProps { - onNext: VoidFunction; - onSubmit: SubmitHandler; - onLogin: ( - loginData: Pick - ) => Promise; -} - -export interface SignUpFormValue extends ShelterRegisterPayload { - passwordConfirm: string; -} + const handleClick = useCallback(() => { + keyboardActive(); + router.push('/register/shelter/step'); + }, []); -const Steps: StepsProps[] = [ - { - component: Sure, - path: 'step0' - }, - { - component: Account, - path: 'step1' - }, - { - component: Name, - path: 'step2' - }, - { - component: Hp, - path: 'step3' - }, - { - component: Address, - path: 'step4' - }, - { - component: SpecificAddress, - path: 'step5' - }, - { - component: Description, - path: 'step6' - }, - { - component: RequireComplete, - path: 'step7' - }, - { - component: Additional, - path: 'step8' - }, - { - component: RegisterComplete, - path: 'step9' - } -]; - -export default function ShelterRegister() { - const toastOn = useToast(); - useHeader({ title: '보호소 파트너 계정 가입' }); - - const pathname = usePathname(); - const { goToNextStep, currentStepIndex } = useFunnel( - Steps, - pathname - ); - const CurrentComponent = Steps[currentStepIndex].component; - - const methods = useForm({ - mode: 'all', - reValidateMode: 'onChange', - resolver: yupResolver(registerValidation) - }); + return ( + <> +
+ + + 보호소 파트너로 + + 가입하시는 것이 맞는지 + 꼭 확인해주세요. + +
- const { mutateAsync: registerMutateAsync } = useShelterRegister(); - const { mutateAsync: loginMutateAsync } = useShelterLogin(); +
+ +

보호소 파트너란?

+
- const { handleSubmit } = methods; +
+
+ 시보호소 또는 민간보호소를 운영하는 +
+ 운영자, 관계자분들을 대상 + 으로해요. +
+
+
- const onSubmit = useCallback( - async (data: SignUpFormValue) => { - const newData: ShelterRegisterPayload = { - ...data, - name: data.name.trim(), - phoneNumber: removeDash(data.phoneNumber) - }; +
+ +

주의해주세요.

+
- try { - await registerMutateAsync(newData); - goToNextStep(); - toastOn('회원가입에 성공했습니다.'); - } catch (error) { - toastOn('회원가입에 실패했습니다.'); - } - }, - [goToNextStep, toastOn, registerMutateAsync] - ); +
+
+ 운영자가 확인했을 때 시보호소/민간 보호소 + 관계자가 아닌, 개인 구조자, 분양 홍보자 등일 경우 +
+ 임의로 해당  + 계정을 사용 중지 처리할 수 있어요. +
+
+
- const onLogin = useCallback( - async (loginData: Pick) => { - try { - await loginMutateAsync(loginData); - goToNextStep(); - toastOn('로그인에 성공했습니다.'); - } catch (error) { - toastOn('로그인에 실패했습니다.'); - } - }, - [goToNextStep, toastOn, loginMutateAsync] - ); - return ( - - - +
+ +
+ + ); } diff --git a/src/app/register/volunteer/[...slug]/ContactNumber.tsx b/src/app/register/volunteer/[...slug]/ContactNumber.tsx index cebf15e7..6aabb57d 100644 --- a/src/app/register/volunteer/[...slug]/ContactNumber.tsx +++ b/src/app/register/volunteer/[...slug]/ContactNumber.tsx @@ -1,15 +1,19 @@ import EmphasizedTitle, { Line } from '@/components/common/EmphasizedTitle/EmphasizedTitle'; -import TextField from '@/components/common/TextField/TextField'; import { formatPhone } from '@/utils/formatInputs'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; import { CurrentComponentProps } from './CurrentComponentTypes'; import * as style from './style.css'; +import TextFieldWithForm from '@/components/common/TextField/TextFieldWithForm'; export default function ContactNumber({ formName }: CurrentComponentProps) { - const { register } = useFormContext(); + const { setFocus } = useFormContext(); + useEffect(() => { + formName && setFocus(formName); + }, [formName, setFocus]); + const handlePhoneNumberChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.value; @@ -27,9 +31,10 @@ export default function ContactNumber({ formName }: CurrentComponentProps) {
{formName && ( - )}
diff --git a/src/app/register/volunteer/[...slug]/NickName.tsx b/src/app/register/volunteer/[...slug]/NickName.tsx index fcd84d3c..18b770cd 100644 --- a/src/app/register/volunteer/[...slug]/NickName.tsx +++ b/src/app/register/volunteer/[...slug]/NickName.tsx @@ -4,8 +4,14 @@ import EmphasizedTitle, { import * as style from './style.css'; import TextFieldWithForm from '@/components/common/TextField/TextFieldWithForm'; import { CurrentComponentProps } from './CurrentComponentTypes'; +import { useEffect } from 'react'; +import { useFormContext } from 'react-hook-form'; export default function NickName({ formName }: CurrentComponentProps) { + const { setFocus } = useFormContext(); + useEffect(() => { + formName && setFocus(formName); + }, []); return ( <>
diff --git a/src/app/register/volunteer/[...slug]/validationSchema.ts b/src/app/register/volunteer/[...slug]/validationSchema.ts index 326230b3..2c286ac6 100644 --- a/src/app/register/volunteer/[...slug]/validationSchema.ts +++ b/src/app/register/volunteer/[...slug]/validationSchema.ts @@ -1,11 +1,17 @@ import * as Yup from 'yup'; -import { RegisterFormValues } from './CurrentComponentTypes'; +import { + FORM_CONTACT_NUMBER, + FORM_NICKNAME, + RegisterFormValues +} from './CurrentComponentTypes'; import { phoneRegex, removeDash } from '@/utils/formatInputs'; +import yup from '@/utils/yup'; export const validation: Yup.ObjectSchema<{ [K in RegisterFormValues]: any; -}> = Yup.object().shape({ - nickname: Yup.string() +}> = yup.object().shape({ + [FORM_NICKNAME]: yup + .string() .max(10) .required('닉네임을 한글자 이상 입력해주세요.') .test( @@ -14,7 +20,9 @@ export const validation: Yup.ObjectSchema<{ (value = '') => !/(\p{Emoji_Presentation}|\p{Extended_Pictographic})/gu.test(value) ), - contactNumber: Yup.string() + [FORM_CONTACT_NUMBER]: yup + .string() + .required() .matches(phoneRegex, '숫자만 입력해주세요') .test( 'phone-format-validation', diff --git a/src/app/shelter/[id]/event/[eventid]/page.tsx b/src/app/shelter/[id]/event/[eventid]/page.tsx index becc9faa..87d027ed 100644 --- a/src/app/shelter/[id]/event/[eventid]/page.tsx +++ b/src/app/shelter/[id]/event/[eventid]/page.tsx @@ -1,4 +1,6 @@ +import { get } from '@/api/shelter/event/volunteer-event'; import VolunteerEventPage from '@/components/shelter-event/VolunteerEventPage/VolunteerEventPage'; +import { Metadata } from 'next'; export interface EventPageProps { params: { @@ -6,6 +8,19 @@ export interface EventPageProps { eventid: string; }; } + +interface Props extends EventPageProps { + searchParams: { [key: string]: string | string[] | undefined }; +} + +export async function generateMetadata({ params }: Props): Promise { + const data = await get(Number(params.id), Number(params.eventid)); + return { + title: data.shelterName, + description: data.title + }; +} + export default async function EventPage({ params }: EventPageProps) { const { id: shelterId, eventid: volunteerEventId } = params; diff --git a/src/app/shelter/[id]/page.tsx b/src/app/shelter/[id]/page.tsx index 97508352..f33905b1 100644 --- a/src/app/shelter/[id]/page.tsx +++ b/src/app/shelter/[id]/page.tsx @@ -3,6 +3,8 @@ import { get } from '@/api/shelter/{shelterId}'; import ShelterProfile from '@/components/shelter/ShelterProfile/ShelterProfile'; import Description from '@/components/shelter/ShelterProfile/Description/Description'; import ShelterHomeTabs from '@/components/shelter/tab/ShelterHomeTabs/ShelterHomeTabs'; +import { QueryClient } from '@tanstack/query-core'; +import { shelterKey } from '@/api/queryKey'; export default async function ShelterMainPage({ params @@ -14,8 +16,9 @@ export default async function ShelterMainPage({ throw Error('잘못된 접근, 에러페이지로 이동'); } - //보호소 정보 서버컴포넌트에서 fetch const shelterHomeInfo = await get(shelterId); + const queryClient = new QueryClient(); + queryClient.setQueryData(shelterKey.homeInfo(), shelterHomeInfo); return ( <> @@ -26,7 +29,10 @@ export default async function ShelterMainPage({ profileImageUrl={shelterHomeInfo.profileImageUrl} bookMarked={shelterHomeInfo.bookMarked} /> - + diff --git a/src/app/shelter/utils/shelterValidaion.ts b/src/app/shelter/utils/shelterValidaion.ts index 8656dfc0..f1a0c820 100644 --- a/src/app/shelter/utils/shelterValidaion.ts +++ b/src/app/shelter/utils/shelterValidaion.ts @@ -8,20 +8,54 @@ export const loginValidation = yup.object().shape({ .email('올바른 이메일 형식이 아닙니다.'), password: yup .string() - .required() .min(8, '비밀번호가 너무 짧습니다. 8~15자로 입력해주세요.') - .matches( - /(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*])/, - '영문, 숫자, 특수문자 3가지 조합 8~15자로 입력해주세요.' - ) .max(15, '비밀번호가 너무 깁니다. 8~15자로 입력해주세요.') + .test( + 'password', + '영문, 숫자, 특수문자 중 2가지 조합으로 8~15자로 입력해주세요.', + value => { + const matches = checkPwd(value); + return matches; + } + ) }); export const passWordFindValidation = yup.object().shape({ email: yup .string() .required('필수항목 입니다.') - .email('올바른 이메일 형식이 아닙니다.') + .email('올바른 이메일 형식이 아닙니다.'), + phoneNumber: yup + .string() + .required('필수항목 입니다.') + .matches(phoneRegex, '숫자만 입력해주세요') + .test( + 'phone-format-validation', + '전화번호 형식이 올바르지 않습니다', + value => { + const matches = checkPhoneNumber(value); + return matches; + } + ) +}); + +export const passwordChangeValidatgion = yup.object().shape({ + password: yup + .string() + .min(8, '비밀번호가 너무 짧습니다. 8~15자로 입력해주세요.') + .max(15, '비밀번호가 너무 깁니다. 8~15자로 입력해주세요.') + .test( + 'password', + '영문, 숫자, 특수문자 중 2가지 조합으로 8~15자로 입력해주세요.', + value => { + const matches = checkPwd(value); + return matches; + } + ), + passwordConfirm: yup + .string() + .required() + .oneOf([yup.ref('password')], '비밀번호가 일치하지 않습니다.') }); export const registerValidation = yup.object({ @@ -29,11 +63,15 @@ export const registerValidation = yup.object({ password: yup .string() .min(8, '비밀번호가 너무 짧습니다. 8~15자로 입력해주세요.') - .matches( - /(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*])/, - '영문, 숫자, 특수문자 3가지 조합 8~15자로 입력해주세요.' - ) - .max(15, '비밀번호가 너무 깁니다. 8~15자로 입력해주세요.'), + .max(15, '비밀번호가 너무 깁니다. 8~15자로 입력해주세요.') + .test( + 'password', + '영문, 숫자, 특수문자 중 2가지 조합으로 8~15자로 입력해주세요.', + value => { + const matches = checkPwd(value); + return matches; + } + ), passwordConfirm: yup .string() .optional() @@ -59,25 +97,8 @@ export const registerValidation = yup.object({ 'phone-format-validation', '전화번호 형식이 올바르지 않습니다', value => { - let val = removeDash(value || ''); - if (!val || (val && val.length <= 3)) { - return true; - } - - const result = val.slice(0, 2); - const phone = val.slice(2); - - if (result === '02' && (phone.length === 7 || phone.length <= 8)) { - return true; - } else if ( - phone.length === 7 || - phone.length === 8 || - phone.length === 9 - ) { - return true; - } else { - return false; - } + const matches = checkPhoneNumber(value); + return matches; } ), address: yup.object().shape({ @@ -89,3 +110,32 @@ export const registerValidation = yup.object({ }), description: yup.string().max(300, '입력 가능 글자수를 초과했어요.') }); + +const checkPwd = (value?: string) => { + if (!value) return false; + let matches = 0; + + if (/[a-zA-Z]/.test(value)) matches++; + if (/[0-9]/.test(value)) matches++; + if (/[!@#$%^&*]/.test(value)) matches++; + + return matches >= 2; +}; + +const checkPhoneNumber = (value?: string) => { + let val = removeDash(value || ''); + if (!val || (val && val.length <= 3)) { + return true; + } + + const result = val.slice(0, 2); + const phone = val.slice(2); + + if (result === '02' && (phone.length === 7 || phone.length <= 8)) { + return true; + } else if (phone.length === 7 || phone.length === 8 || phone.length === 9) { + return true; + } else { + return false; + } +}; diff --git a/src/app/volunteer/redirect/route.ts b/src/app/volunteer/redirect/route.ts index fb7c5313..f5e4fd58 100644 --- a/src/app/volunteer/redirect/route.ts +++ b/src/app/volunteer/redirect/route.ts @@ -48,15 +48,13 @@ export async function GET(req: NextRequest) { const redirectPath = cookieStore.get(COOKIE_REDIRECT_URL)?.value || '/'; const redirectTo = `${originUrl}${decodeURIComponent(redirectPath)}`; const res = NextResponse.redirect(redirectTo, { - status: 308, - headers: { - locagion: redirectTo - } + status: 308 }); const cookieConfig = getCookieConfig(req); res.cookies.set(COOKIE_ACCESS_TOKEN_KEY, accessToken, cookieConfig); res.cookies.set(COOKIE_REFRESH_TOKEN_KEY, refreshToken, cookieConfig); + res.cookies.delete(COOKIE_REDIRECT_URL); return res; } catch (e) { diff --git a/src/components/common/Avartar/Avartar.css.ts b/src/components/common/Avartar/Avartar.css.ts index 7b648a89..e18481e2 100644 --- a/src/components/common/Avartar/Avartar.css.ts +++ b/src/components/common/Avartar/Avartar.css.ts @@ -1,5 +1,5 @@ import { palette } from '@/styles/color'; -import { createVar } from '@vanilla-extract/css'; +import { createVar, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; export const size = createVar('size'); @@ -8,7 +8,8 @@ export const avartar = recipe({ border: `1px solid ${palette.gray300}`, backgroundSize: 'cover', backgroundPosition: 'center', - backgroundRepeat: 'no-repeat' + backgroundRepeat: 'no-repeat', + objectFit: 'cover' }, variants: { shape: { @@ -35,6 +36,13 @@ export const avartar = recipe({ } } }); +export const imageWrapper = style({ + position: 'relative' +}); + +export const imageChildren = style({ + position: 'absolute' +}); type AvartarVariants = RecipeVariants; export type ShapeVariant = NonNullable['shape']; diff --git a/src/components/common/Avartar/Avartar.tsx b/src/components/common/Avartar/Avartar.tsx index 95cc1693..7bb4d05a 100644 --- a/src/components/common/Avartar/Avartar.tsx +++ b/src/components/common/Avartar/Avartar.tsx @@ -53,29 +53,35 @@ const ImageAvartar: React.FC = ({ style }) => { return ( - {alt} + {alt} {children} - +
); }; const Avartar: React.FC = ({ children, imagePath, ...props }) => { return typeof imagePath === 'string' && imagePath !== '' ? ( - + + {children} + ) : ( {children} ); diff --git a/src/components/common/Calendar/DangleCalendar.css.ts b/src/components/common/Calendar/DangleCalendar.css.ts index 6aadfbb4..ffe67818 100644 --- a/src/components/common/Calendar/DangleCalendar.css.ts +++ b/src/components/common/Calendar/DangleCalendar.css.ts @@ -1,15 +1,23 @@ import { palette } from '@/styles/color'; -import { GLOBAL_PADDING_X } from '@/styles/global.css'; +import { GLOBAL_PADDING_X, expandGlobalPadding } from '@/styles/global.css'; import { globalStyle, style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; +export const container = style([ + expandGlobalPadding, + { + backgroundColor: palette.white, + overflowX: 'hidden' + } +]); export const calendar = style({ - width: 'calc(100% + 40px)', - margin: 'auto', - backgroundColor: palette.white, - color: palette.gray900, + width: '100%', transform: `translateX(${-GLOBAL_PADDING_X}px)`, - padding: '20px 0' + padding: `${GLOBAL_PADDING_X}px` +}); + +export const footer = style({ + transform: 'translateY(-20px)' }); export const dotWrapper = style({ @@ -58,10 +66,6 @@ globalStyle(`${calendar} &::before`, { }); globalStyle(`${calendar} button`, { - width: '32px', - height: '32px', - borderRadius: '6px', - padding: '4px 6px 4px 6px', textAlign: 'center' }); @@ -69,37 +73,46 @@ globalStyle(`${calendar} button:enabled:hover`, { cursor: 'pointer' }); +globalStyle(`${calendar} .react-calendar__viewContainer`, {}); // 캘린더 인디케이터 globalStyle(`${calendar} .react-calendar__navigation`, { display: 'flex', - alignItems: 'center', - height: '44px', - padding: '0px 80px 0px 80px' + margin: '0 auto', + width: 'fit-content', + columnGap: 16, + alignItems: 'center' }); -globalStyle(`${calendar} .react-calendar__navigation button`, { - fontSize: '14px', - fontFamily: 'Pretendard', - fontStyle: 'normal', - fontWeight: 700 +globalStyle(`${calendar} .react-calendar__navigation > *`, { + height: 24 }); +globalStyle(`${calendar} .react-calendar__navigation__label`, { + fontSize: '16px', + fontWeight: 600 +}); + +globalStyle(`${calendar} .react-calendar__month-view`, { + padding: `12px` +}); // 요일 column globalStyle(`${calendar} .react-calendar__month-view__weekdays`, { - padding: '12px 12px 4px 12px', fontSize: '12px', - fontFamily: 'Pretendard', - fontStyle: 'normal', fontWeight: 500, lineHeight: '14px', - color: '#6c6c6c', - textAlign: 'center' + color: palette.gray600, + textAlign: 'center', + padding: '3px 0', + marginBottom: '4px' }); // 날짜 container -globalStyle(`${calendar} .react-calendar__month-view__days`, { - padding: '0px 12px 0px 12px' -}); +globalStyle( + `${calendar} .react-calendar__month-view__weekdays__weekday--weekend`, + { + color: palette.primary300 + } +); globalStyle(`${calendar} .react-calendar__tile`, { display: 'flex', @@ -107,14 +120,13 @@ globalStyle(`${calendar} .react-calendar__tile`, { alignItems: 'center', justifyContent: 'center', - fontSize: '12px', - fontFamily: 'Pretendard', - fontStyle: 'normal', - fontWeight: 500, - lineHeight: '14px', - color: '#6c6c6c', + fontSize: '14px', + fontWeight: 400, + lineHeight: '20px', + color: palette.gray900, - position: 'relative' + position: 'relative', + height: '32px' }); globalStyle(`${calendar} .react-calendar__tile::before`, { @@ -126,37 +138,44 @@ globalStyle(`${calendar} .react-calendar__tile::before`, { zIndex: -100 }); -// 날짜 호버됐을 시 -globalStyle(`${calendar} .react-calendar__tile:enabled:hover`, { - color: palette.primary200 -}); -globalStyle(`${calendar} .react-calendar__tile:enabled:hover::before`, { - background: palette.primary50 +globalStyle(`${calendar} .react-calendar__month-view__days`, { + rowGap: 4 }); globalStyle(`${calendar} .react-calendar__month-view__days__day--weekend`, { - color: palette.error + color: palette.primary300 }); globalStyle( `${calendar} .react-calendar__month-view__days__day--neighboringMonth`, { - color: palette.gray200 + opacity: 0.3 } ); // 오늘 날짜 globalStyle(`${calendar} .react-calendar__tile--now `, { - color: palette.white + color: `${palette.white} !important` }); globalStyle(`${calendar} .react-calendar__tile--now::before`, { - background: palette.gray900 + background: `${palette.gray900} !important` }); -// 선택한 날짜 -globalStyle(`${calendar} .react-calendar__tile--active:enabled:focus`, { - color: palette.gray800 +// 날짜 호버됐을 시 +globalStyle(`${calendar} .react-calendar__tile:enabled:hover`, { + color: palette.gray900 }); -globalStyle(`${calendar} .react-calendar__tile:enabled:focus::before`, { +globalStyle(`${calendar} .react-calendar__tile:enabled:hover::before`, { background: palette.gray200 }); + +// 선택한 날짜 +globalStyle(`${calendar} .react-calendar__tile--rangeBothEnds:enabled`, { + color: palette.gray900 +}); +globalStyle( + `${calendar} .react-calendar__tile--rangeBothEnds:enabled::before`, + { + background: palette.gray200 + } +); diff --git a/src/components/common/Calendar/DangleCalendar.tsx b/src/components/common/Calendar/DangleCalendar.tsx index cd246095..e5bf34e2 100644 --- a/src/components/common/Calendar/DangleCalendar.tsx +++ b/src/components/common/Calendar/DangleCalendar.tsx @@ -13,6 +13,7 @@ interface DangleCalendarProps onChange?: (value: Date, event: React.MouseEvent) => void; mark?: (string | Date)[]; onChangeMonth?: (value: Date) => void; + footer?: React.ReactNode; } export default function DangleCalendar({ @@ -21,6 +22,7 @@ export default function DangleCalendar({ onChange, mark, onChangeMonth, + footer, ...rest }: DangleCalendarProps) { const handleDotIcon = useCallback( @@ -37,20 +39,16 @@ export default function DangleCalendar({ className={clsx([ styles.dot({ date: isToday ? 'today' : 'other' }) ])} - >
+ /> ); } - return ( - <> -
{html}
- - ); + return
{html}
; }, [mark] ); return ( -
+
} prevLabel={} - onActiveStartDateChange={({ activeStartDate, value, view }) => { + onActiveStartDateChange={({ activeStartDate }) => { activeStartDate && onChangeMonth?.(activeStartDate); }} tileContent={handleDotIcon} minDetail="month" // 상단 네비게이션에서 '월' 단위만 보이게 설정 maxDetail="month" // 상단 네비게이션에서 '월' 단위만 보이게 설정 + navigationLabel={({ date }) => moment(date).format('YYYY.MM')} + calendarType="US" /> +
{footer}
); } diff --git a/src/components/common/Carousel/Carousel.tsx b/src/components/common/Carousel/Carousel.tsx index 929d66e0..bf6cc4cf 100644 --- a/src/components/common/Carousel/Carousel.tsx +++ b/src/components/common/Carousel/Carousel.tsx @@ -15,9 +15,9 @@ import { GLOBAL_PADDING_X } from '@/styles/global.css'; interface CarouselProps extends PropsWithChildren {} // 0 < SENSITIVITY <= 1. 값이 작을수록 인덱스가 쉽게 변경됨 -const SENSITIVITY = 0.4; +const SENSITIVITY = 0.2; // 0 < WHEEL_SPEED <= 1 값이 클수록 휠 한 번에 스크롤되는 양이 많아짐 -const WHEEL_SPEED = 0.5; +const WHEEL_SPEED = 0.8; const Carousel: React.FC = props => { const [isMouseDown, setIsMouseDown] = useState(false); @@ -78,6 +78,7 @@ const Carousel: React.FC = props => { const scrollStart = useCallback((pageX: number) => { if (!containerRef.current) return; + document.body.style.overflowY = 'hidden'; setIsMouseDown(true); setStartX(pageX - containerRef.current.offsetLeft); setStartScrollLeft(containerRef.current.scrollLeft); @@ -94,6 +95,7 @@ const Carousel: React.FC = props => { ); const scrollEnd = useCallback(() => { + document.body.style.overflowY = ''; setIsMouseDown(false); if (!isMouseDown) return; paginate(); diff --git a/src/components/common/Filter/Filter.tsx b/src/components/common/Filter/Filter.tsx index 5b614545..00268e0c 100644 --- a/src/components/common/Filter/Filter.tsx +++ b/src/components/common/Filter/Filter.tsx @@ -43,6 +43,7 @@ interface FilterProps { export interface FilterRef { setPickOption: (option: string) => void; + name: string; } const Filter = forwardRef( ({ name, label, options, onChange }: FilterProps, ref) => { @@ -53,10 +54,12 @@ const Filter = forwardRef( setPickOption( typeof options[0] === 'string' ? options[0] : options[0]?.label ); - }, [options]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [name]); useImperativeHandle(ref, () => ({ - setPickOption + setPickOption, + name })); const handleChangeData = useCallback( @@ -68,6 +71,14 @@ const Filter = forwardRef( [name, onChange, closeFilter] ); + useEffect(() => { + if (isFilter) { + document.body.style.overflowY = 'hidden'; + } else { + document.body.style.overflowY = ''; + } + }, [isFilter]); + return ( <> diff --git a/src/constants/dom.ts b/src/constants/dom.ts index e9067236..b614dab7 100644 --- a/src/constants/dom.ts +++ b/src/constants/dom.ts @@ -1 +1,2 @@ export const DOM_ID_BANNER = 'dangle_banner'; +export const DOM_ID_BACKGROUND_THEME = 'dangle_background_theme'; diff --git a/src/constants/exceptionCode.ts b/src/constants/exceptionCode.ts index a560cff5..1c9cdf8a 100644 --- a/src/constants/exceptionCode.ts +++ b/src/constants/exceptionCode.ts @@ -1,13 +1,36 @@ -/** 유저가 DB에 등록되지 않은 경우 */ export enum ExceptionCode { - /** API-000 / 서버에서 알 수 없는 치명적인 에러 발생 */ - FATAL_ERROR = 'API-000', - /** API-001 / 서버에서 핸들링 하지 않은 에러 발생 */ - UNHANDLED_ERROR = 'API-001', + /** SYSTEM-000 / 서버에서 알 수 없는 치명적인 에러 발생 */ + FATAL_ERROR = 'SYSTEM-000', + /** SYSTEM-001 / 서버에서 핸들링 하지 않은 에러 발생 */ + UNHANDLED_ERROR = 'SYSTEM-001', + /** API-002 / 인증되지 않은 사용자 / 만료된 토큰입니다. 다시 로그인을 진행해주세요. */ UNAUTHENTICATED = 'API-002', /** API-003 / 인가되지 않은 사용자 */ UNAUTHORIZED = 'API-003', + /** API-004 / 날짜 형식에러 */ + DATE_FORMAT_ERROR = 'API-004', + /** API-005 / 파라미터 형식 에러 */ + PARAMETER_FORMAT_ERROR = 'API-005', + /** API-006 / 토큰 유효 에러 */ + TOKEN_VALID_ERROR = 'API-006', + + /** LOCK-001 / Lock Key 생성 에러 (서버 내부 에러) */ + LOCK_KEY_CREATION_ERROR = 'LOCK-001', + /** LOCK-002 / 동시성 에러 발생 (이벤트 참여 등등 | 재시도 정책등 정의 필요) */ + CONCURRENCY_ERROR = 'LOCK-002', + + /** VOLUNTEER_EVENT-001 / 이미 봉사에 참여중 */ + ALREADY_VOLUNTEERING = 'VOLUNTEER_EVENT-001', + /** VOLUNTEER_EVENT-002 / 봉사에 참여중이지 않음 (봉사취소 API 호출시) */ + NOT_VOLUNTEERING = 'VOLUNTEER_EVENT-002', + /** VOLUNTEER_EVENT-003 / 봉사 인원수 변경 불가 (봉사수정 API 호출시) */ + CANNOT_CHANGE_VOLUNTEER_COUNT = 'VOLUNTEER_EVENT-003', + /** VOLUNTEER_EVENT-004 / 날짜 형식에러 */ + VOLUNTEER_DATE_FORMAT_ERROR = 'VOLUNTEER_EVENT-004', + /** VOLUNTEER_EVENT-005 / 참여 할 수 없는 봉사 (봉사 참여 API 호출시) */ + CANNOT_VOLUNTEER = 'VOLUNTEER_EVENT-005', + /** STOREAGE-001 / DB에서 해당하는 데이터를 찾을 수 없음 */ DATA_NOT_FOUND_IN_DB = 'STORAGE-001' } diff --git a/src/constants/landingURL.ts b/src/constants/landingURL.ts index 2cde1ec5..8e4a4bd3 100644 --- a/src/constants/landingURL.ts +++ b/src/constants/landingURL.ts @@ -1,6 +1,11 @@ -export const URL_PRIVACY_POLICY = '#'; +export const URL_PRIVACY_POLICY = + 'https://dankim9494.notion.site/dankim9494/16cc9c83c5bd4b4798159bce9ecb45f4'; export const URL_TERMS_OF_USE = 'https://dankim9494.notion.site/dankim9494/8334ce2511894b71a0f57dca52367e4c'; -export const URL_FAQ = '#'; export const URL_SERVICE_INTRODUCTION = 'https://dankim9494.notion.site/91e59199f72c41c3842320bf2232c3f4?pvs=4'; +export const URL_PASSWORD_CHANGE = '/admin/shelter/edit/password'; +export const URL_FAQ = + 'https://dankim9494.notion.site/FAQ-2c54d818708f454cb4f094d62c3bd4d0?pvs=4'; +export const URL_ONETOONE = + 'mailto:dangledangle.official@gmail.com?subject=[1:1 문의]'; diff --git a/src/constants/localStorageKeys.ts b/src/constants/localStorageKeys.ts new file mode 100644 index 00000000..177c0aa3 --- /dev/null +++ b/src/constants/localStorageKeys.ts @@ -0,0 +1,2 @@ +export const STORAGE_KEY_HOME_CALENDAR_FILTER_INPUT = + 'home_calendar_filter_input'; diff --git a/src/constants/volunteerEvent.ts b/src/constants/volunteerEvent.ts index 243fb777..8df9d844 100644 --- a/src/constants/volunteerEvent.ts +++ b/src/constants/volunteerEvent.ts @@ -55,15 +55,14 @@ export const REGION_OPTIONS = [ export type RegionOptions = (typeof REGION_OPTIONS)[number]; -const EVENT_STATUS_FILTER = { +export const EVENT_STATUS_FILTER = { + all: '전체', IN_PROGRESS: '모집 중', DONE: '모집 종료' }; -export const EVENT_STATUS_FILTER_OPTIONS: FilterOption[] = createInputOptions({ - all: '전체', - ...EVENT_STATUS_FILTER -}); +export const EVENT_STATUS_FILTER_OPTIONS: FilterOption[] = + createInputOptions(EVENT_STATUS_FILTER); export const MY_STATUS = { NONE: '미참여', diff --git a/src/hooks/useHeader.tsx b/src/hooks/useHeader.tsx index 26d145dc..c46d5b37 100644 --- a/src/hooks/useHeader.tsx +++ b/src/hooks/useHeader.tsx @@ -1,7 +1,8 @@ +import { DOM_ID_BACKGROUND_THEME } from '@/constants/dom'; import { HeaderState, headerState } from '@/store/header'; import { palette } from '@/styles/color'; import { useLayoutEffect } from 'react'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; export interface UseHeaderProps extends Omit { title?: string; @@ -39,7 +40,6 @@ const useHeader = ({ entirePage, RightSideComponent ]); - return setHeader; }; diff --git a/src/hooks/useImageUploader.tsx b/src/hooks/useImageUploader.tsx index 215e56ad..50441dde 100644 --- a/src/hooks/useImageUploader.tsx +++ b/src/hooks/useImageUploader.tsx @@ -5,23 +5,33 @@ import uploadImage from '@/utils/uploadImage'; export default function useImageUploader() { const [src, setSrc] = useState(); const [isUploading, startUploading, finishUploading] = useBooleanState(); + const [uploadError, setUploadError] = useState(false); const onChangeImage = useCallback( - (file?: File, onUploaded?: (url?: string) => Promise) => { + async (file?: File, onUploaded?: (url?: string) => Promise) => { if (!file) return; startUploading(); - return uploadImage(file) - .then(async url => { - setSrc(url); + try { + const url = await uploadImage(file); + setSrc(url); + if (url) { onUploaded && (await onUploaded(url)); + finishUploading(); + setUploadError(false); return url; - }) - .catch(() => setSrc(undefined)) - .finally(finishUploading); + } else { + throw Error(); + } + } catch { + setUploadError(true); + setSrc(undefined); + finishUploading(); + return undefined; + } }, [finishUploading, startUploading] ); - return { src, setSrc, isUploading, onChangeImage }; + return { src, setSrc, isUploading, uploadError, onChangeImage }; } diff --git a/src/hooks/useKeyboardActive.ts b/src/hooks/useKeyboardActive.ts new file mode 100644 index 00000000..cb617d1d --- /dev/null +++ b/src/hooks/useKeyboardActive.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; + +function useKeyboardActive() { + return useCallback(() => { + const fakeInput = document.createElement('input'); + document.body.appendChild(fakeInput); + fakeInput.style.position = 'fixed'; + fakeInput.style.bottom = '0'; + fakeInput.style.left = '-100vh'; + fakeInput.setAttribute('id', 'dangle_input_fake'); + fakeInput.focus(); + window.scrollTo(0, 0); + fakeInput.addEventListener('blur', () => { + document.getElementById('dangle_input_fake')?.remove(); + }); + }, []); +} +export default useKeyboardActive; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..26cae0cd --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,43 @@ +import { useCallback } from 'react'; + +export default function useLocalStorage(key: string) { + const save = useCallback( + (json: T) => { + localStorage.setItem(key, JSON.stringify(json)); + }, + [key] + ); + + const update = useCallback( + (json: Partial) => { + const item = localStorage.getItem(key); + try { + if (!item) throw Error(); + + const parsed = JSON.parse(item); + save({ ...parsed, ...json }); + return true; + } catch { + console.error(`'${key}' 키값에 해당하는 localStorage 값이 없습니다`); + return false; + } + }, + [key, save] + ); + + const destroy = useCallback(() => { + localStorage.removeItem(key); + }, [key]); + + const get = useCallback(() => { + const item = localStorage.getItem(key); + try { + if (!item) throw Error(); + return JSON.parse(item) as T; + } catch { + return null; + } + }, [key]); + + return { save, update, destroy, get }; +} diff --git a/src/hooks/useRouteGuard.tsx b/src/hooks/useRouteGuard.tsx new file mode 100644 index 00000000..93c23f76 --- /dev/null +++ b/src/hooks/useRouteGuard.tsx @@ -0,0 +1,80 @@ +import { useCallback, useEffect } from 'react'; +import useDialog from './useDialog'; + +const GUARD_STATE = 'Route guard init'; + +const onBeforeunload = (event: BeforeUnloadEvent) => { + // 브라우저 새로고침, 닫기 방지 + event.preventDefault(); + event.returnValue = ''; +}; + +/** + * 유저의 의도치않은 페이지 이탈을 방지 + * @param callback 브라우저 뒤로가기 동작 대신 수행할 함수 + * @returns setRoutable로 온오프 가능 + * @example ```js + * const onSubmit = () => { + * setRoutable(true); + * router.back(); + * } + * ``` + */ +export default function useRouteGuard(callback?: () => unknown) { + const { dialogOn, dialogOff } = useDialog(); + + const defaultCallback = useCallback(() => { + dialogOn({ + message: '앗! 이대로 나가면 입력하신 정보가 모두 사라져요!', + confirm: { + text: '나가기', + variant: 'filled', + onClick: () => { + setRoutable(true); + history.back(); + dialogOff(); + } + }, + close: { + onClick: () => { + setRoutable(false); + dialogOff(); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onPopstate = (e: PopStateEvent) => { + // 브라우저 뒤로가기 방지 + e.preventDefault(); + callback ? callback() : defaultCallback(); + return; + }; + + const setRoutable = useCallback( + (routable: boolean) => { + if (!routable) { + history.pushState(GUARD_STATE, ''); + window.onbeforeunload = onBeforeunload; + window.onpopstate = onPopstate; + } else { + window.onbeforeunload = null; + window.onpopstate = null; + if (history.state === GUARD_STATE) history.back(); // GUARD_STATE state 제거 + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [callback] + ); + + useEffect(() => { + setRoutable(false); + return () => { + window.onpopstate = null; + window.onbeforeunload = null; + }; + }, [setRoutable]); + + return { setRoutable }; +} diff --git a/src/hooks/useSafariBackgroundControll.ts b/src/hooks/useSafariBackgroundControll.ts new file mode 100644 index 00000000..34a2471f --- /dev/null +++ b/src/hooks/useSafariBackgroundControll.ts @@ -0,0 +1,38 @@ +'use client'; + +import { DOM_ID_BACKGROUND_THEME } from '@/constants/dom'; +import { palette } from '@/styles/color'; +import { useCallback, useEffect, useState } from 'react'; + +function useSafariBackgroundControll() { + const [themeElem, setThemeElem] = useState(null); + + const setSafariBackground = useCallback( + (theme: string) => { + if (!themeElem) return; + themeElem.setAttribute('content', theme); + }, + [themeElem] + ); + + useEffect(() => { + let themeElem = document.getElementById( + DOM_ID_BACKGROUND_THEME + ) as HTMLMetaElement; + if (!themeElem) { + themeElem = document.createElement('meta'); + document.head.append( + Object.assign(themeElem, { + id: DOM_ID_BACKGROUND_THEME, + name: 'theme-color', + content: palette.background + }) + ); + } + setThemeElem(themeElem); + }, []); + + return { setSafariBackground }; +} + +export default useSafariBackgroundControll; diff --git a/src/hooks/useSnsShare.tsx b/src/hooks/useSnsShare.tsx index 0a28366b..7edefe3b 100644 --- a/src/hooks/useSnsShare.tsx +++ b/src/hooks/useSnsShare.tsx @@ -32,11 +32,15 @@ export default function useSnsShare() { } kakao.Share.sendDefault({ - objectType: 'text', - text: title, - link: { - mobileWebUrl: url, - webUrl: url + objectType: 'feed', + content: { + title, + description: '댕글댕글과 함께 더 나은 세상을 만들어봐요!', + imageUrl: 'https://dangle.co.kr/svg/SnsShareImg.svg', + link: { + mobileWebUrl: url, + webUrl: url + } } }); }; diff --git a/src/middleware.ts b/src/middleware.ts index 186e4887..9df9eb75 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -6,9 +6,10 @@ import protectedURLs from './utils/middleware/hooks/protectedURLs'; export async function middleware(req: NextRequest) { const requestHeaders = new Headers(req.headers); - const adminURLsResult = protectedURLs({ req, requestHeaders }); - if (adminURLsResult.redirect) { - return adminURLsResult.response; + const { redirect, response } = protectedURLs({ req, requestHeaders }); + + if (redirect) { + return response; } appendHeaderTitle({ req, requestHeaders }); @@ -21,5 +22,5 @@ export async function middleware(req: NextRequest) { } export const config = { - matcher: '/((?!.*\\.).*)' + matcher: ['/((?!.*\\.).*)', '/api/(.*)'] }; diff --git a/src/styles/global.css.ts b/src/styles/global.css.ts index 89fb777d..2793e43f 100644 --- a/src/styles/global.css.ts +++ b/src/styles/global.css.ts @@ -4,7 +4,8 @@ import { palette } from './color'; globalStyle('body', { backgroundColor: palette.background, - overflowX: 'hidden' + overflowX: 'hidden', + color: palette.gray900 }); globalStyle('main, footer', { backgroundColor: palette.background }); globalStyle('ul, ol, li', { listStyle: 'none' }); diff --git a/src/utils/breakLine.tsx b/src/utils/breakLine.tsx new file mode 100644 index 00000000..8e5036d3 --- /dev/null +++ b/src/utils/breakLine.tsx @@ -0,0 +1,23 @@ +import { Fragment } from 'react'; + +/** + * + * @param str + * @returns str에 \n을
태그로 대치하여 렌더링한다. + */ +export default function breakLine(str: string) { + const sentences = str.trim().split('\n'); + + return ( + <> + {sentences.map((value, idx) => { + return ( + + {value} + {idx !== sentences.length - 1 &&
} +
+ ); + })} + + ); +} diff --git a/src/utils/formatInputs.ts b/src/utils/formatInputs.ts index ccfc561d..6f62a335 100644 --- a/src/utils/formatInputs.ts +++ b/src/utils/formatInputs.ts @@ -29,3 +29,10 @@ export function formatPhone(input: string) { export function removeDash(input: string) { return input.replace(/-/g, ''); } + +export const handlePhoneNumberChange = ( + event: React.ChangeEvent +) => { + const value = event.target.value; + event.target.value = formatPhone(value); +}; diff --git a/src/utils/ky/hooks/afterResponse.ts b/src/utils/ky/hooks/afterResponse.ts index d734e6aa..27fb6ef1 100644 --- a/src/utils/ky/hooks/afterResponse.ts +++ b/src/utils/ky/hooks/afterResponse.ts @@ -19,13 +19,26 @@ export const retryRequestOnUnauthorized: AfterResponseHookWithProcess = if (data.success === true) { return ky(request, options); } else { - window.location.href = '/login'; + window.location.href = '/logout?force=true'; return; } } } }; +export const redirectTokenError: AfterResponseHookWithProcess = + process => async (request, options, response) => { + const data = await response.json(); + if (data.exceptionCode === ExceptionCode.TOKEN_VALID_ERROR) { + console.log('exceptionCode === api-006', 123123); + if (runtimeCheck() === 'browser') { + console.log('browser redirect?'); + window.location.href = '/logout?force=true'; + return; + } + } + }; + /** api 요청 과정에서 에러 발생시 * ky 에러가 아닌 서버에서 전달받은 에러 throw */ @@ -36,6 +49,7 @@ export const throwServerErrorMessage: AfterResponseHook = async ( ) => { if (response.status >= 400) { const responseData = (await response.json()) as ApiErrorResponse; + console.log('throwError, ', responseData); throw responseData; } diff --git a/src/utils/middleware/headerServerSideRenderProp.ts b/src/utils/middleware/headerServerSideRenderProp.ts index ab3f955d..3111b9f3 100644 --- a/src/utils/middleware/headerServerSideRenderProp.ts +++ b/src/utils/middleware/headerServerSideRenderProp.ts @@ -21,6 +21,10 @@ export const headerServerSideRenderProp: HeaderServerSideRenderProp[] = [ url: '/login/shelter/password', title: '비밀번호 찾기' }, + { + url: '/admin/shelter/edit/password', + title: '비밀번호 변경' + }, { url: '/register/shelter', title: '보호소 파트너 계정 가입' diff --git a/src/utils/middleware/hooks/protectedURLs.ts b/src/utils/middleware/hooks/protectedURLs.ts index 8d570a27..a04266e3 100644 --- a/src/utils/middleware/hooks/protectedURLs.ts +++ b/src/utils/middleware/hooks/protectedURLs.ts @@ -1,6 +1,7 @@ import { COOKIE_ACCESS_TOKEN_KEY, - COOKIE_REDIRECT_URL + COOKIE_REDIRECT_URL, + COOKIE_REFRESH_TOKEN_KEY } from '@/constants/cookieKeys'; import { NextResponse, type NextRequest } from 'next/server'; @@ -15,14 +16,13 @@ export default function protectedURLs({ }: HandleAdminURLsProps) { const accessToken = req.cookies.get(COOKIE_ACCESS_TOKEN_KEY)?.value || ''; + const { pathname, search, origin, basePath } = req.nextUrl; if (!accessToken) { - const noRedirectForEditExtra = req.nextUrl.pathname.startsWith( + const noRedirectForEditExtra = pathname.startsWith( '/admin/shelter/edit/extra' ); - if (!noRedirectForEditExtra && req.nextUrl.pathname.startsWith('/admin')) { - const { pathname, search, origin, basePath } = req.nextUrl; - + if (!noRedirectForEditExtra && pathname.startsWith('/admin')) { if (!req.cookies.get(COOKIE_REDIRECT_URL)?.value) { requestHeaders.append( 'Set-Cookie', @@ -35,8 +35,20 @@ export default function protectedURLs({ response: NextResponse.redirect(signUrl, { headers: requestHeaders }) }; } - } else if (accessToken) { - if (req.nextUrl.pathname.startsWith('/login')) { + } + + if (accessToken) { + if (pathname.startsWith('/logout') && search === '?force=true') { + const response = NextResponse.redirect(`${origin}/login`); + response.cookies.delete(COOKIE_ACCESS_TOKEN_KEY); + response.cookies.delete(COOKIE_REFRESH_TOKEN_KEY); + return { + redirect: true, + response + }; + } + + if (pathname.startsWith('/login')) { const { origin, basePath } = req.nextUrl; const mainReturnUrl = new URL(`${basePath}`, origin); diff --git a/src/utils/timeConvert.ts b/src/utils/timeConvert.ts index 83cc168f..e58e8f45 100644 --- a/src/utils/timeConvert.ts +++ b/src/utils/timeConvert.ts @@ -43,7 +43,7 @@ export function isDatePast(dateStr: string | Date) { } export function pmamConvert(time: string | Date) { - const convertedTime = moment(time, 'HH:mm'); + const convertedTime = moment(time, 'YYYY-MM-DD HH:mm:ss'); const formattedTime = convertedTime.minute() === 0 ? convertedTime.format('A h시') @@ -55,8 +55,8 @@ export function getDuration( startTimeStr: string | Date, endTimeStr: string | Date ) { - const start = moment(startTimeStr, 'HH:mm'); - const end = moment(endTimeStr, 'HH:mm'); + const start = moment(startTimeStr, 'YYYY-MM-DD HH:mm:ss'); + const end = moment(endTimeStr, 'YYYY-MM-DD HH:mm:ss'); const hours = end.diff(start, 'hours'); const minutes = end.subtract(hours, 'hours').diff(start, 'minutes');