From ec25da3148dd644dd9839bbd977ec9ed9dfb22a2 Mon Sep 17 00:00:00 2001 From: chaeseungyun <101871802+chaeseungyun@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:44:30 +0900 Subject: [PATCH] =?UTF-8?q?[=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4]=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EB=B0=8F=20=EB=A9=94=EB=89=B4=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: validate 성공 시 아이콘 추가 * feat: Auth 공용 Form 컴포넌트 제작 * style: box-sizing 값 조정 * refactor: model 재정의 * refactor: 다음 버튼을 재사용 하도록 변경 및 기본값 추가 * feat: Auth 공용 컴포넌트 제작 * feat: input 숨기기 버튼 컴포넌트화 * refactor: 비밀번호 찾기에 공용 컴포넌트 적용 * refactor: useSteps에서 불필요한 값 제거거 * refactor: 공용 컴포넌트 적용 * refactor: 커스텀훅으로 개편 및 컴포넌트화 적용 * refactor: 불필요한 기능 삭제 및 css 조정 * feat: 회원가입 플로우 개선 * style: secondary 색상 변경 * refactor: 라우팅 세팅 * refactor: 메뉴 편집 개편 * style: 버튼 색상 조정 * feat: 메뉴 편집탭 추가 * style: lint 수정 * style: 메뉴추가 버튼 위치 및 스타일 변경 * style: lint 수정 --- src/App.tsx | 2 + src/assets/svg/auth/success.svg | 3 + src/component/common/Header/index.tsx | 11 +- src/model/auth/index.ts | 2 + src/page/AddMenu/AddMenu.module.scss | 6 +- .../MenuCategory/MenuCategory.module.scss | 3 +- .../AddMenu/components/MenuCategory/index.tsx | 3 +- .../MenuImage/MenuImage.module.scss | 13 +- .../AddMenu/components/MenuImage/index.tsx | 37 +- .../components/MenuName/MenuName.module.scss | 3 +- .../PriceInput/PriceInput.module.scss | 11 +- .../MenuPrice/components/PriceInput/index.tsx | 2 +- .../FindPassword/ChangePassword/index.tsx | 44 +- src/page/Auth/FindPassword/Verify/index.tsx | 149 ++--- src/page/Auth/FindPassword/index.module.scss | 3 + src/page/Auth/FindPassword/index.tsx | 2 +- .../agreeStep/agreeStep.module.scss | 19 +- .../Signup/components/agreeStep/index.tsx | 22 +- .../AttachStep/AttachStep.module.scss | 117 ++++ .../components/onwerStep/AttachStep/index.tsx | 188 +++++++ .../onwerStep/common/common.module.scss | 13 + .../components/onwerStep/common/model.ts | 3 + .../Signup/components/onwerStep/index.tsx | 28 + .../components/onwerStep/nameStep/index.tsx | 37 ++ .../onwerStep/onwerNumberStep/index.tsx | 40 ++ .../components/onwerStep/shopStep/index.tsx | 65 +++ .../Signup/components/ownerInfoStep/index.tsx | 106 +--- .../ownerInfoStep/ownerInfoStep.module.scss | 2 - .../Signup/components/phoneStep/index.tsx | 521 +++++++++++------- .../phoneStep/phoneStep.module.scss | 77 +-- .../Signup/components/searchShop/index.tsx | 146 +++-- .../searchShop/searchShop.module.scss | 11 +- src/page/Auth/Signup/index.tsx | 50 +- .../components/Common/BlindButton/index.tsx | 20 + .../components/Common/form/form.module.scss | 80 +++ .../Auth/components/Common/form/index.tsx | 115 ++++ .../Auth/components/Common/index.module.scss | 6 +- src/page/Auth/components/Common/index.tsx | 143 +---- src/page/Auth/hook/useStep.ts | 9 +- src/page/MyShopPage/MyShopPage.module.scss | 12 + .../components/EditMenu/index.module.scss | 12 + .../MyShopPage/components/EditMenu/index.tsx | 29 + .../MenuTable/MenuTable.module.scss | 17 +- .../MyShopPage/components/MenuTable/index.tsx | 56 +- .../components/ShopInfo/ShopInfo.module.scss | 7 +- src/page/MyShopPage/index.tsx | 25 +- src/static/routes.ts | 1 + src/utils/constant/category.ts | 14 + src/utils/hooks/useClickImage.tsx | 15 + 49 files changed, 1520 insertions(+), 780 deletions(-) create mode 100644 src/assets/svg/auth/success.svg create mode 100644 src/page/Auth/Signup/components/onwerStep/AttachStep/AttachStep.module.scss create mode 100644 src/page/Auth/Signup/components/onwerStep/AttachStep/index.tsx create mode 100644 src/page/Auth/Signup/components/onwerStep/common/common.module.scss create mode 100644 src/page/Auth/Signup/components/onwerStep/common/model.ts create mode 100644 src/page/Auth/Signup/components/onwerStep/index.tsx create mode 100644 src/page/Auth/Signup/components/onwerStep/nameStep/index.tsx create mode 100644 src/page/Auth/Signup/components/onwerStep/onwerNumberStep/index.tsx create mode 100644 src/page/Auth/Signup/components/onwerStep/shopStep/index.tsx create mode 100644 src/page/Auth/components/Common/BlindButton/index.tsx create mode 100644 src/page/Auth/components/Common/form/form.module.scss create mode 100644 src/page/Auth/components/Common/form/index.tsx create mode 100644 src/page/MyShopPage/components/EditMenu/index.module.scss create mode 100644 src/page/MyShopPage/components/EditMenu/index.tsx create mode 100644 src/utils/hooks/useClickImage.tsx diff --git a/src/App.tsx b/src/App.tsx index 16d99b4b..792f3085 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import LogPage from 'component/common/PageLog'; import CommonLayout from 'page/Auth/components/Common'; import FindPassword from 'page/Auth/FindPassword'; import ROUTES from 'static/routes'; +import EditMenu from 'page/MyShopPage/components/EditMenu'; interface ProtectedRouteProps { userTypeRequired: UserType; @@ -51,6 +52,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/assets/svg/auth/success.svg b/src/assets/svg/auth/success.svg new file mode 100644 index 00000000..7dda695c --- /dev/null +++ b/src/assets/svg/auth/success.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/component/common/Header/index.tsx b/src/component/common/Header/index.tsx index e43daf9b..f5f77cbf 100644 --- a/src/component/common/Header/index.tsx +++ b/src/component/common/Header/index.tsx @@ -12,8 +12,10 @@ function Header() { const { isMobile } = useMediaQuery(); if ((pathname === ROUTES.Owner.AddMenu() - || pathname.startsWith(ROUTES.Owner.ModifyMenu({ isLink: false })) - || pathname.startsWith(ROUTES.Owner.Event({ isLink: false }))) + || pathname.includes('modify-menu') + || pathname.startsWith(ROUTES.Owner.Event({ isLink: false })) + || pathname.startsWith(ROUTES.Owner.EditMenu()) + ) && isMobile) { return (
@@ -26,9 +28,10 @@ function Header() {
- {pathname === ROUTES.Owner.AddMenu() && '메뉴추가'} - {pathname.startsWith(ROUTES.Owner.ModifyMenu({ isLink: false })) && '메뉴수정'} + {pathname === ROUTES.Owner.AddMenu() && '메뉴 추가'} + {pathname.includes('modify-menu') && '메뉴 수정'} {pathname.startsWith(ROUTES.Owner.Event({ isLink: false })) && '이벤트/공지 작성하기'} + {pathname.startsWith(ROUTES.Owner.EditMenu()) && '메뉴 편집'}
); diff --git a/src/model/auth/index.ts b/src/model/auth/index.ts index aee45b98..9efe17f9 100644 --- a/src/model/auth/index.ts +++ b/src/model/auth/index.ts @@ -93,6 +93,8 @@ export interface Register extends FindPassword { attachment_urls: { file_url: string }[], + verificationCode: string; + shop_call: string; } export interface RegisterUser { diff --git a/src/page/AddMenu/AddMenu.module.scss b/src/page/AddMenu/AddMenu.module.scss index 724a17c2..0c37aa4d 100644 --- a/src/page/AddMenu/AddMenu.module.scss +++ b/src/page/AddMenu/AddMenu.module.scss @@ -33,13 +33,14 @@ width: 94px; height: 43px; flex-shrink: 0; - border: 1px solid #f7941e; + border: 1px solid #cacaca; background-color: white; - color: #f7941e; + color: #cacaca; text-align: center; font-family: "Noto Sans CJK KR", sans-serif; font-size: 18px; font-weight: 500; + border-radius: 8px; &:hover { cursor: pointer; @@ -56,6 +57,7 @@ font-family: "Noto Sans CJK KR", sans-serif; font-size: 18px; font-weight: 500; + border-radius: 8px; &:hover { cursor: pointer; diff --git a/src/page/AddMenu/components/MenuCategory/MenuCategory.module.scss b/src/page/AddMenu/components/MenuCategory/MenuCategory.module.scss index 3c74c2ee..8a3f6abb 100644 --- a/src/page/AddMenu/components/MenuCategory/MenuCategory.module.scss +++ b/src/page/AddMenu/components/MenuCategory/MenuCategory.module.scss @@ -115,12 +115,13 @@ $button-text-color: #252525; &__category-button { height: 39px; - border: 0.5px solid #898a8d; + border: 0.5px solid #cacaca; background-color: white; color: $button-text-color; text-align: center; font-size: 15px; font-weight: 500; + border-radius: 8px; &--selected { background-color: #f7941e; diff --git a/src/page/AddMenu/components/MenuCategory/index.tsx b/src/page/AddMenu/components/MenuCategory/index.tsx index ec0ca008..30990a6c 100644 --- a/src/page/AddMenu/components/MenuCategory/index.tsx +++ b/src/page/AddMenu/components/MenuCategory/index.tsx @@ -66,10 +66,9 @@ export default function MenuCategory({ isComplete }:MenuCategoryProps) { 메뉴 카테고리
- (최대 선택 n개) + (1개 이상 선택)
-
{shopData && shopData.menu_categories.map((category) => ( diff --git a/src/page/AddMenu/components/MenuImage/MenuImage.module.scss b/src/page/AddMenu/components/MenuImage/MenuImage.module.scss index ab17ce6f..e372e853 100644 --- a/src/page/AddMenu/components/MenuImage/MenuImage.module.scss +++ b/src/page/AddMenu/components/MenuImage/MenuImage.module.scss @@ -132,6 +132,7 @@ gap: 16px; white-space: nowrap; align-items: center; + justify-content: space-around; height: 170px; } @@ -140,8 +141,8 @@ justify-content: center; align-items: center; flex-direction: column; - width: 137px; - height: 137px; + width: 100px; + height: 100px; flex-shrink: 0; border: 0.5px solid #a1a1a1; background-color: white; @@ -168,13 +169,13 @@ justify-content: center; align-items: center; position: relative; - width: 137px; - height: 137px; + width: 100px; + height: 100px; } &__selected { - max-width: 137px; - max-height: 137px; + width: 100px; + height: 100px; } } diff --git a/src/page/AddMenu/components/MenuImage/index.tsx b/src/page/AddMenu/components/MenuImage/index.tsx index de1d0867..425a3125 100644 --- a/src/page/AddMenu/components/MenuImage/index.tsx +++ b/src/page/AddMenu/components/MenuImage/index.tsx @@ -58,31 +58,32 @@ export default function MenuImage({ isComplete }: MenuImageProps) {
(최대 이미지 3장)
- {!isComplete && ( - - )} {imageUrl.map((image, index) => (
{`Selected {!isComplete && ( - + )}
))} + {!isComplete && ( + Array.from({ length: 3 - imageUrl.length }).map(() => ( + + )))}
- 사이즈 추가 + 가격 추가 diff --git a/src/page/Auth/FindPassword/ChangePassword/index.tsx b/src/page/Auth/FindPassword/ChangePassword/index.tsx index be650442..06ce440d 100644 --- a/src/page/Auth/FindPassword/ChangePassword/index.tsx +++ b/src/page/Auth/FindPassword/ChangePassword/index.tsx @@ -1,20 +1,50 @@ import { useEffect } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { useFormContext, UseFormSetError } from 'react-hook-form'; import { useOutletContext } from 'react-router-dom'; import { ChangePasswordForm } from 'model/auth'; import { OutletProps } from 'page/Auth/FindPassword/entity'; import Warning from 'assets/svg/auth/warning.svg?react'; import styles from 'page/Auth/FindPassword/index.module.scss'; +import { isKoinError, sendClientError } from '@bcsdlab/koin'; +import { Button } from 'page/Auth/components/Common/form'; +import sha256 from 'utils/ts/SHA-256'; +import { changePassword } from 'api/auth'; +import { useMutation } from '@tanstack/react-query'; + +interface ChangePasswordProps { + setError: UseFormSetError; + nextStep: () => void; +} + +const useChangePassword = ({ setError, nextStep } : ChangePasswordProps) => { + const mutate = useMutation({ + mutationFn: async (data: { phone_number: string, password: string }) => { + const hashPassword = await sha256(data.password); + changePassword({ phone_number: data.phone_number, password: hashPassword }); + }, + onError: (e) => { + if (isKoinError(e)) { + setError('password', { type: 'custom', message: e.message }); + } else { + sendClientError(e); + } + }, + onSuccess: () => nextStep(), + }); + + return mutate; +}; const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{6,18}$/; export default function ChangePassword() { const { - register, formState: { errors, isValid }, getValues, clearErrors, + register, formState: { errors, isValid }, getValues, clearErrors, setError, } = useFormContext(); const steps = useOutletContext(); const { setIsStepComplete } = steps; + const mutation = useChangePassword({ setError, nextStep: steps.nextStep }); useEffect(() => { if (isValid) { @@ -57,7 +87,6 @@ export default function ChangePassword() { ) } -
새 비밀번호 확인
@@ -78,6 +107,15 @@ export default function ChangePassword() { ) }
+ ); } diff --git a/src/page/Auth/FindPassword/Verify/index.tsx b/src/page/Auth/FindPassword/Verify/index.tsx index c88ee542..aec78436 100644 --- a/src/page/Auth/FindPassword/Verify/index.tsx +++ b/src/page/Auth/FindPassword/Verify/index.tsx @@ -11,6 +11,7 @@ import styles from 'page/Auth/FindPassword/index.module.scss'; import { OutletProps } from 'page/Auth/FindPassword/entity'; import { useDebounce } from 'utils/hooks/useDebounce'; import showToast from 'utils/ts/showToast'; +import { Button } from 'page/Auth/components/Common/form'; interface SendCode { getValues: UseFormGetValues; @@ -43,7 +44,7 @@ interface Verify { certification_code: string; } -export default function Verify() { +export default function Verify({ nextStep }: { nextStep: () => void }) { const { register, getValues, setError, formState: { errors }, watch, clearErrors, } = useFormContext(); @@ -90,79 +91,80 @@ export default function Verify() { return (
-
-
휴대폰 번호
-
- - -
- {errors.phone_number +
+
+
휴대폰 번호
+
+ + +
+ {errors.phone_number && (
{errors.phone_number.message}
)} -
-
-
인증번호
-
- - -
- { +
+
+
인증번호
+
+ + +
+ { errors.certification_code && (
@@ -170,14 +172,21 @@ export default function Verify() {
) } - { + { isCertified && watch('certification_code').length === 6 && (
인증되었습니다
) } -
+
+ +
); } diff --git a/src/page/Auth/FindPassword/index.module.scss b/src/page/Auth/FindPassword/index.module.scss index eb995044..3094ba81 100644 --- a/src/page/Auth/FindPassword/index.module.scss +++ b/src/page/Auth/FindPassword/index.module.scss @@ -1,5 +1,8 @@ .container { height: calc(100vh - 30vh); + display: flex; + flex-direction: column; + justify-content: space-between; } .section { diff --git a/src/page/Auth/FindPassword/index.tsx b/src/page/Auth/FindPassword/index.tsx index 30d2ecfd..16567e56 100644 --- a/src/page/Auth/FindPassword/index.tsx +++ b/src/page/Auth/FindPassword/index.tsx @@ -8,7 +8,7 @@ export default function FindPassword() { const { index } = steps; return ( <> - {index === 0 && } + {index === 0 && } {index === 1 && } ); diff --git a/src/page/Auth/Signup/components/agreeStep/agreeStep.module.scss b/src/page/Auth/Signup/components/agreeStep/agreeStep.module.scss index bcca501c..1f40c3ff 100644 --- a/src/page/Auth/Signup/components/agreeStep/agreeStep.module.scss +++ b/src/page/Auth/Signup/components/agreeStep/agreeStep.module.scss @@ -6,7 +6,9 @@ .step-one { height: 100%; - overflow: scroll; + display: flex; + flex-direction: column; + justify-content: space-between; &::-webkit-scrollbar { display: none; @@ -41,7 +43,6 @@ } .personal { - margin-top: 4px; height: 28vh; @include media.media-breakpoint-down(mobile) { @@ -62,16 +63,22 @@ } &__content { - margin-top: 5px; border: 1px dashed #d2dae2; padding: 12px; - height: 20vh; - overflow-y: scroll; + + &--text { + overflow-y: auto; + font-size: 11px; + height: 20vh; + + &::-webkit-scrollbar { + display: none; + } + } } } .koin { - margin-top: 4px; height: 28vh; @include media.media-breakpoint-down(mobile) { diff --git a/src/page/Auth/Signup/components/agreeStep/index.tsx b/src/page/Auth/Signup/components/agreeStep/index.tsx index 23f88ec6..7144e446 100644 --- a/src/page/Auth/Signup/components/agreeStep/index.tsx +++ b/src/page/Auth/Signup/components/agreeStep/index.tsx @@ -1,6 +1,7 @@ import NonCheck from 'assets/svg/auth/non-check.svg?react'; import Check from 'assets/svg/auth/checked.svg?react'; import TERMS from 'page/Auth/Signup/constant/terms'; +import { Button } from 'page/Auth/components/Common/form'; import styles from './agreeStep.module.scss'; interface SelectOptions { @@ -11,9 +12,10 @@ interface SelectOptions { interface AgreeStepProps { selectItems: SelectOptions; handleSelect: (option: keyof SelectOptions | 'all') => void; + nextStep: () => void; } -export default function AgreeStep({ selectItems, handleSelect }: AgreeStepProps) { +export default function AgreeStep({ selectItems, handleSelect, nextStep }: AgreeStepProps) { return (
@@ -27,15 +29,29 @@ export default function AgreeStep({ selectItems, handleSelect }: AgreeStepProps) {selectItems.personal ? : } 개인정보 이용약관(필수) -
{TERMS[0].text}
+
+
+ {TERMS[0].text} +
+
-
{TERMS[1].text}
+
+
+ {TERMS[1].text} +
+
+
); } diff --git a/src/page/Auth/Signup/components/onwerStep/AttachStep/AttachStep.module.scss b/src/page/Auth/Signup/components/onwerStep/AttachStep/AttachStep.module.scss new file mode 100644 index 00000000..49970bc2 --- /dev/null +++ b/src/page/Auth/Signup/components/onwerStep/AttachStep/AttachStep.module.scss @@ -0,0 +1,117 @@ +@use "src/utils/styles/mediaQuery" as media; + +.container { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +} + +.owner-info-container { + margin-top: 25px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 1px; + box-sizing: border-box; +} + +.owner-info-container input { + width: 100%; + box-sizing: border-box; + border-radius: 4px; + background: #f5f5f5; + padding: 12px 16px; + border: none; + margin-top: 8px; + margin-bottom: 2px; + + &:focus { + outline: 1px solid #4590bb; + } + + &:hover { + cursor: pointer; + } +} + +.owner-info-container button { + &:hover { + cursor: pointer; + } +} + +.owner-info-container span { + font-size: 14px; + font-style: normal; + font-weight: 500; + padding-left: 5px; + padding-bottom: 8px; +} + +.document-input { + position: relative; + display: flex; + flex-direction: column; + + &__condition { + color: #8e8e8e; + font-size: 12px; + font-weight: 400; + display: flex; + justify-content: space-between; + } +} + +.shop-name { + &__input { + width: 72% !important; + } +} + +.owner-file-input { + &__button { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + margin-top: 5px; + height: 46px; + width: 100%; + border-radius: 5px; + background: #cacaca; + color: #4b4b4b; + z-index: 2; + } +} + +.file-list { + border-radius: 5px; + background: #f5f5f5; + max-height: 178px; + padding: 10px; + width: 94%; + margin-top: 10px; +} + +.file-card { + display: flex; + gap: 11px; +} + +.file-name { + color: #8e8e8e; + font-size: 14px; + font-weight: 400; +} + +.delete-button { + background-color: #f5f5f5; +} + +.error-message { + display: flex; + align-items: center; + color: #f7941e; + font-size: 12px; +} diff --git a/src/page/Auth/Signup/components/onwerStep/AttachStep/index.tsx b/src/page/Auth/Signup/components/onwerStep/AttachStep/index.tsx new file mode 100644 index 00000000..1e1e633d --- /dev/null +++ b/src/page/Auth/Signup/components/onwerStep/AttachStep/index.tsx @@ -0,0 +1,188 @@ +import { Register } from 'model/auth'; +import { + Button, Input, Title, ValidationMessage, +} from 'page/Auth/components/Common/form'; +import { useFormContext } from 'react-hook-form'; +import { useState } from 'react'; +import FileIcon from 'assets/svg/auth/file-icon.svg?react'; +import DeleteFile from 'assets/svg/auth/delete-file.svg?react'; +import FileUploadModal from 'page/Auth/Signup/components/fileUploadModal'; +import useUploadFile from 'query/upload'; +import { isKoinError, sendClientError } from '@bcsdlab/koin'; +import showToast from 'utils/ts/showToast'; +import { toast } from 'react-toastify'; +import { useMutation } from '@tanstack/react-query'; +import { phoneRegisterUser } from 'api/register'; +import sha256 from 'utils/ts/SHA-256'; +import { DefaultProps } from 'page/Auth/Signup/components/onwerStep/common/model'; +import styles from './AttachStep.module.scss'; + +interface OwnerInfo { + name: string; + shop_name: string; + shop_id: number | null; + company_number: string; + attachment_urls: { file_url: string }[]; +} + +function UploadStep() { + const { + formState: { errors }, + setValue, + } = useFormContext(); + const [isModalOpen, setIsModalOpen] = useState(false); + const { uploadFiles } = useUploadFile(); + const [fileNames, setFileNames] = useState([]); + const [uploadedFiles, setUploadedFiles] = useState<{ file_url: string }[]>([]); + + const openModal = () => setIsModalOpen(true); + const closeModal = () => setIsModalOpen(false); + + const handleUpload = async (files: FileList) => { + const formData = new FormData(); + const names = Array.from(files).map((file) => file.name); + + Array.from(files).forEach((file) => formData.append('files', file)); + try { + const response = await uploadFiles(formData); + const { file_urls: fileUrls } = response.data; + const formattedUrls = fileUrls.map((url: string) => ({ file_url: url })); + if (formattedUrls.length + uploadedFiles.length > 5) { + toast.error('파일은 최대 5개 등록할 수 있습니다'); + return; + } + setUploadedFiles((prev) => [...prev, ...formattedUrls]); + setFileNames((prev) => [...prev, ...names]); + setValue('attachment_urls', [...uploadedFiles, ...formattedUrls]); + } catch (error) { + if (isKoinError(error)) { + showToast('error', error.message); + } + sendClientError(error); + } + }; + + const handleDeleteFile = (index: number) => { + const newUploadedFiles = [...uploadedFiles]; + const newFileNames = [...fileNames]; + newUploadedFiles.splice(index, 1); + newFileNames.splice(index, 1); + setUploadedFiles(newUploadedFiles); + setFileNames(newFileNames); + setValue('attachment_urls', newUploadedFiles); + }; + + return ( +
+
+ 사업자 인증 파일 +
+ 사업자 등록증, 영업신고증, 통장사본을 첨부하세요. +
+ {fileNames.length} + {' '} + / 5 +
+
+ {fileNames.length > 0 && ( +
+ {fileNames.map((name, index) => ( +
+ +
+ {name} +
+
+ ))} +
+ )} + + {errors.attachment_urls && {errors.attachment_urls.message}} +
+ {isModalOpen && } +
+ ); +} +interface PhoneRegister { + company_number: string, + name: string, + password: string, + phone_number: string, + shop_id: number | null, + shop_name: string, + attachment_urls: { file_url: string }[], +} + +const useRegister = (onSuccess: () => void) => useMutation({ + mutationFn: (data: PhoneRegister) => phoneRegisterUser(data), + onError: (error) => { + if (isKoinError(error)) { + showToast('error', error.message); + } + sendClientError(error); + }, + onSuccess, +}); + +export default function AttachStep({ nextStep }: DefaultProps) { + const { + register, formState: { errors }, watch, handleSubmit, + } = useFormContext(); + const [shopCall, attachmentUrls] = watch(['shop_call', 'attachment_urls']); + const mutation = useRegister(nextStep); + const onwerSignup = async (data: Register) => { + const hashedPassword = await sha256(data.password); + const shopId = Number(data.shop_id) ? Number(data.shop_id) : null; + const companyNumber = `${data.company_number.slice(0, 3)}-${data.company_number.slice(3, 5)}-${data.company_number.slice(5)}`; + const processedData = { + company_number: companyNumber, + name: data.name, + password: hashedPassword, + phone_number: data.phone_number, + shop_id: shopId, + shop_name: data.shop_name, + attachment_urls: data.attachment_urls, + }; + mutation.mutate(processedData); + }; + + return ( +
+
+ + <Input + register={register} + name="shop_call" + inputMode="numeric" + placeholder="-없이 가게 연락처를 입력해주세요." + required + requiredMessage="사업자 등록증을 첨부해주세요." + /> + <ValidationMessage + isError={!!errors.attachment_urls} + message={errors.attachment_urls?.message} + /> + <UploadStep /> + </div> + <Button + onClick={handleSubmit(onwerSignup)} + disabled={!!errors.attachment_urls || !attachmentUrls.length || !shopCall} + > + 다음 + </Button> + </div> + ); +} diff --git a/src/page/Auth/Signup/components/onwerStep/common/common.module.scss b/src/page/Auth/Signup/components/onwerStep/common/common.module.scss new file mode 100644 index 00000000..4e5442dc --- /dev/null +++ b/src/page/Auth/Signup/components/onwerStep/common/common.module.scss @@ -0,0 +1,13 @@ +.container { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +} + +.center { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; +} diff --git a/src/page/Auth/Signup/components/onwerStep/common/model.ts b/src/page/Auth/Signup/components/onwerStep/common/model.ts new file mode 100644 index 00000000..03225d49 --- /dev/null +++ b/src/page/Auth/Signup/components/onwerStep/common/model.ts @@ -0,0 +1,3 @@ +export interface DefaultProps { + nextStep: () => void; +} diff --git a/src/page/Auth/Signup/components/onwerStep/index.tsx b/src/page/Auth/Signup/components/onwerStep/index.tsx new file mode 100644 index 00000000..1f9747f8 --- /dev/null +++ b/src/page/Auth/Signup/components/onwerStep/index.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; +import AttachStep from './AttachStep'; +import NameStep from './nameStep'; +import OnwerNumberStep from './onwerNumberStep'; +import ShopStep from './shopStep'; + +interface Props { + complete: () => void; +} + +export default function OwnerStep({ complete }: Props) { + const [steps, setSteps] = useState(0); + const nextStep = () => setSteps((prev) => prev + 1); + + if (steps === 0) return <NameStep nextStep={() => nextStep()} />; + + if (steps === 1) { + return <ShopStep nextStep={() => nextStep()} />; + } + + if (steps === 2) { + return <OnwerNumberStep nextStep={() => nextStep()} />; + } + + return ( + <AttachStep nextStep={complete} /> + ); +} diff --git a/src/page/Auth/Signup/components/onwerStep/nameStep/index.tsx b/src/page/Auth/Signup/components/onwerStep/nameStep/index.tsx new file mode 100644 index 00000000..fe3f59c7 --- /dev/null +++ b/src/page/Auth/Signup/components/onwerStep/nameStep/index.tsx @@ -0,0 +1,37 @@ +import { Register } from 'model/auth'; +import { + Button, Input, Title, ValidationMessage, +} from 'page/Auth/components/Common/form'; +import { useFormContext } from 'react-hook-form'; +import styles from 'page/Auth/Signup/components/onwerStep/common/common.module.scss'; +import { DefaultProps } from 'page/Auth/Signup/components/onwerStep/common/model'; + +export default function NameStep({ nextStep }: DefaultProps) { + const { register, formState: { errors }, watch } = useFormContext<Register>(); + const name = watch('name'); + + return ( + <div className={styles.container}> + <div> + <Title title="사장님의 성명(실명)을 입력해주세요" /> + <Input + register={register} + name="name" + placeholder="성명을 입력해주세요." + required + requiredMessage="성명을 입력해주세요." + /> + <ValidationMessage + isError={!!errors.name} + message={errors.name?.message} + /> + </div> + <Button + onClick={nextStep} + disabled={!!errors.name || !name} + > + 다음 + </Button> + </div> + ); +} diff --git a/src/page/Auth/Signup/components/onwerStep/onwerNumberStep/index.tsx b/src/page/Auth/Signup/components/onwerStep/onwerNumberStep/index.tsx new file mode 100644 index 00000000..91726306 --- /dev/null +++ b/src/page/Auth/Signup/components/onwerStep/onwerNumberStep/index.tsx @@ -0,0 +1,40 @@ +import { + Button, Input, Title, ValidationMessage, +} from 'page/Auth/components/Common/form'; +import { useFormContext } from 'react-hook-form'; +import styles from 'page/Auth/Signup/components/onwerStep/common/common.module.scss'; +import { DefaultProps } from 'page/Auth/Signup/components/onwerStep/common/model'; +import { Register } from 'model/auth'; + +export default function OnwerNumberStep({ nextStep } : DefaultProps) { + const { register, formState: { errors }, watch } = useFormContext<Register>(); + const ownerNumber = watch('company_number'); + + return ( + <div className={styles.container}> + <div> + <Title title="사업자 등록 번호를 입력해주세요." /> + <Input + register={register} + name="company_number" + required + requiredMessage="사업자 등록 번호를 입력해주세요." + placeholder="사업자 등록 번호를 입력해주세요." + pattern={/^[\d]{1,10}$/} + patternMessage="숫자만 입력해주세요." + maxLength={10} + /> + <ValidationMessage + isError={!!errors.company_number} + message={errors.company_number?.message} + /> + </div> + <Button + onClick={nextStep} + disabled={!!errors.company_number || !ownerNumber} + > + 다음 + </Button> + </div> + ); +} diff --git a/src/page/Auth/Signup/components/onwerStep/shopStep/index.tsx b/src/page/Auth/Signup/components/onwerStep/shopStep/index.tsx new file mode 100644 index 00000000..35cd9e70 --- /dev/null +++ b/src/page/Auth/Signup/components/onwerStep/shopStep/index.tsx @@ -0,0 +1,65 @@ +import { Register } from 'model/auth'; +import { + Button, Input, Title, ValidationMessage, +} from 'page/Auth/components/Common/form'; +import { useFormContext } from 'react-hook-form'; +import SearchIcon from 'assets/svg/auth/search-glasses.svg?url'; +import styles from 'page/Auth/Signup/components/onwerStep/common/common.module.scss'; +import { DefaultProps } from 'page/Auth/Signup/components/onwerStep/common/model'; +import { useEffect, useState } from 'react'; +import SearchShop from 'page/Auth/Signup/components/searchShop'; + +export default function ShopStep({ nextStep }: DefaultProps) { + const { + register, formState: { errors }, watch, clearErrors, setValue, + } = useFormContext<Register>(); + const [isShowSearch, setIsShowSearch] = useState(false); + const shopName = watch('shop_name'); + + const onChange = () => { + setValue('shop_id', null); + }; + + useEffect(() => { + if (shopName) { + clearErrors('shop_name'); + } + }, [shopName, clearErrors]); + + if (isShowSearch) return <SearchShop nextStep={() => setIsShowSearch(false)} />; + + return ( + <div className={styles.container}> + <div> + <Title title="가게명을 입력해주세요." /> + <Input + register={register} + name="shop_name" + required + requiredMessage="가게명을 입력해주세요." + placeholder="가게명을 입력해주세요." + onChange={onChange} + /> + <ValidationMessage + isError={!!errors.shop_name} + message={errors.shop_name?.message} + /> + <Button + onClick={() => setIsShowSearch(true)} + secondary + > + <div className={styles.center}> + 등록된 가게 검색 + <img src={SearchIcon} alt="검색" /> + </div> + </Button> + </div> + <Button + onClick={nextStep} + disabled={!!errors.shop_name || !shopName} + > + 다음 + </Button> + </div> + ); +} diff --git a/src/page/Auth/Signup/components/ownerInfoStep/index.tsx b/src/page/Auth/Signup/components/ownerInfoStep/index.tsx index bda6e0c8..e1b7e6d6 100644 --- a/src/page/Auth/Signup/components/ownerInfoStep/index.tsx +++ b/src/page/Auth/Signup/components/ownerInfoStep/index.tsx @@ -1,5 +1,5 @@ import { useFormContext } from 'react-hook-form'; -import { useState, useEffect, ChangeEvent } from 'react'; +import { useState } from 'react'; import FileIcon from 'assets/svg/auth/file-icon.svg?react'; import DeleteFile from 'assets/svg/auth/delete-file.svg?react'; import FileUploadModal from 'page/Auth/Signup/components/fileUploadModal'; @@ -17,15 +17,8 @@ interface OwnerInfo { attachment_urls: { file_url: string }[]; } -interface OwnerInfoStepProps { - onSearch: () => void; - setIsStepComplete: (state: boolean) => void; -} - -export default function OwnerInfoStep({ onSearch, setIsStepComplete }: OwnerInfoStepProps) { +export default function UploadStep() { const { - register, - watch, formState: { errors }, setValue, } = useFormContext<OwnerInfo>(); @@ -71,103 +64,8 @@ export default function OwnerInfoStep({ onSearch, setIsStepComplete }: OwnerInfo setValue('attachment_urls', newUploadedFiles); }; - const formatCompanyNumber = (value: string) => { - const cleaned = value.replace(/\D/g, ''); - const match = cleaned.match(/^(\d{0,3})(\d{0,2})(\d{0,5})$/); - if (match) { - return [match[1], match[2], match[3]].filter(Boolean).join('-'); - } - return value; - }; - - const handleCompanyNumberChange = (e: ChangeEvent<HTMLInputElement>) => { - const inputValue = e.target.value; - const cleanedValue = inputValue.replace(/\D/g, ''); - if (cleanedValue.length <= 10) { - const formattedValue = formatCompanyNumber(cleanedValue); - setValue('company_number', formattedValue); - } - if (cleanedValue.length > 10) { - e.target.value = cleanedValue.slice(0, 10); - const formattedValue = formatCompanyNumber(e.target.value); - setValue('company_number', formattedValue); - } - }; - - const watchedValues = watch(['name', 'shop_name', 'company_number', 'attachment_urls']); - - useEffect(() => { - const values = watch(); - const isComplete = values.name && values.shop_name - && values.company_number - && values.attachment_urls; - setIsStepComplete(!!isComplete); - }, [watchedValues, setIsStepComplete, watch]); - return ( <div className={styles['owner-info-container']}> - <div className={styles['owner-name']}> - <span className="owner-name__label">대표자명(실명)</span> - <input - {...register('name', { - required: { - value: true, - message: '이름을 입력해주세요', - }, - })} - placeholder="이름을 입력해주세요." - /> - {errors.name && <span className={styles['error-message']}>{errors.name.message}</span>} - </div> - <div className={styles['shop-name']}> - <span className="shop-name__label">가게명</span> - <div className={styles['shop-search-box']}> - <input - {...register('shop_name', { - required: { - value: true, - message: '가게명을 입력해주세요', - }, - pattern: { - // eslint-disable-next-line no-useless-escape - value: /^[가-힣a-zA-Z\s\-\.\&]+$/, - message: '유효한 가게명을 입력해주세요', - }, - })} - placeholder="가게명을 입력해주세요." - className={styles['shop-name__input']} - value={watch('shop_name')} - /> - <button - type="button" - className={styles['search-button']} - onClick={() => setTimeout(onSearch, 250)} - > - 가게 검색 - </button> - </div> - {errors.shop_name && <span className={styles['error-message']}>{errors.shop_name.message}</span>} - </div> - <div className={styles['company-registration-number']}> - <span className="company-registration-number__label">사업자 등록번호</span> - <input - {...register('company_number', { - required: { - value: true, - message: '사업자 등록번호를 입력해주세요.', - }, - pattern: { - value: /^[0-9]{3}-[0-9]{2}-[0-9]{5}$/, - message: '형식에 맞게 작성해주세요.', - }, - })} - placeholder="숫자만 입력해주세요." - type="text" - inputMode="numeric" - onChange={handleCompanyNumberChange} - /> - {errors.company_number && <span className={styles['error-message']}>{errors.company_number.message}</span>} - </div> <div className={styles['document-input']}> <span className="document-input__label">사업자 인증 파일</span> <div className={styles['document-input__condition']}> diff --git a/src/page/Auth/Signup/components/ownerInfoStep/ownerInfoStep.module.scss b/src/page/Auth/Signup/components/ownerInfoStep/ownerInfoStep.module.scss index 0c4a86bd..e6374789 100644 --- a/src/page/Auth/Signup/components/ownerInfoStep/ownerInfoStep.module.scss +++ b/src/page/Auth/Signup/components/ownerInfoStep/ownerInfoStep.module.scss @@ -69,8 +69,6 @@ .owner-file-input { &__button { - position: absolute; - bottom: -40px; display: flex; align-items: center; justify-content: center; diff --git a/src/page/Auth/Signup/components/phoneStep/index.tsx b/src/page/Auth/Signup/components/phoneStep/index.tsx index d5bacaf9..6aadf860 100644 --- a/src/page/Auth/Signup/components/phoneStep/index.tsx +++ b/src/page/Auth/Signup/components/phoneStep/index.tsx @@ -1,113 +1,183 @@ -import Error from 'assets/svg/auth/error-icon.svg?react'; import { useFormContext, UseFormGetValues, UseFormSetError, } from 'react-hook-form'; import { - useState, useEffect, ChangeEvent, + useState, ChangeEvent, useEffect, } from 'react'; -import cn from 'utils/ts/className'; import { verificationAuthCode, getPhoneAuthCode } from 'api/register'; import { isKoinError, sendClientError } from '@bcsdlab/koin'; -import { useDebounce } from 'utils/hooks/useDebounce'; import { PhoneNumberRegisterParam } from 'model/register'; import showToast from 'utils/ts/showToast'; +import { + Button, Input, Title, ValidationMessage, +} from 'page/Auth/components/Common/form'; +import { useMutation } from '@tanstack/react-query'; +import BlindButton from 'page/Auth/components/Common/BlindButton'; +import { Register } from 'model/auth'; +import { Link } from 'react-router-dom'; +import ROUTES from 'static/routes'; import styles from './phoneStep.module.scss'; -interface PhoneStepProps { - setIsStepComplete: React.Dispatch<React.SetStateAction<boolean>>; -} - -interface Verify { - phone_number: string; - attachment_urls: { - file_url: string - }[]; - verificationCode: string; - password: string; - passwordConfirm: string; +interface SendCodeParams { + getValues: UseFormGetValues<Register>; + onError: (e: unknown) => void; + onSucess?: () => void; + onClick?: () => void; } -interface SendCodeParams { - getValues: UseFormGetValues<Verify>; - setError: UseFormSetError<Verify>; - setIsSent: React.Dispatch<React.SetStateAction<boolean>>; - setIsClick: React.Dispatch<React.SetStateAction<boolean>>; +interface TimerProps { + seconds: number; + setSeconds: React.Dispatch<React.SetStateAction<number>>; + time?: number; + timeOut?: () => void; + onMid?: () => void; + stop?: boolean; } -const code = ({ - getValues, setError, setIsSent, setIsClick, +const useSendCode = ({ + getValues, onError, onSucess, onClick, }: SendCodeParams) => { + if (onClick) onClick(); const phoneNumber = getValues('phone_number'); const phoneNumberParam: PhoneNumberRegisterParam = { phone_number: phoneNumber }; - getPhoneAuthCode(phoneNumberParam) - .then(() => { - setIsSent(true); - setIsClick(false); + const sendCode = useMutation({ + mutationKey: ['sendCode'], + mutationFn: () => getPhoneAuthCode(phoneNumberParam), + onError: (e) => onError(e), + onSuccess: () => { + if (onSucess) onSucess(); showToast('success', '인증번호를 발송했습니다'); - }) - .catch((e) => { - if (isKoinError(e)) { - setError('phone_number', { type: 'custom', message: e.message }); - } - }); + }, + }); + + return sendCode; }; const useCheckCode = ( - getValues: UseFormGetValues<Verify>, - setError: UseFormSetError<Verify>, + verificationCode: string, + phoneNumber: string, + setError: UseFormSetError<Register>, ) => { const [isCertified, setIsCertified] = useState<boolean>(false); + const mutation = useMutation({ + mutationFn: () => verificationAuthCode({ + certification_code: verificationCode, + phone_number: phoneNumber, + }), + onError: (e) => { + if (isKoinError(e)) { + setError('verificationCode', { type: 'error', message: e.message }); + } else { + sendClientError(e); + } + }, + onSuccess: (res) => { + sessionStorage.setItem('access_token', res.token); + setIsCertified(true); + }, + }); + + return { mutation, isCertified }; +}; + +function Timer({ + seconds, setSeconds, time = 180, timeOut, onMid, stop, +}: TimerProps) { + useEffect(() => { + let interval: NodeJS.Timeout; - const checkCode = () => { - const certificationCode = getValues('verificationCode'); - if (certificationCode.length === 6) { - verificationAuthCode({ - certification_code: certificationCode, - phone_number: getValues('phone_number'), - }) - .then((res) => { - sessionStorage.setItem('access_token', res.token); - setIsCertified(true); - showToast('success', '인증번호 확인 성공'); - }) - .catch((e) => { - if (isKoinError(e)) { - setError('verificationCode', { type: 'error', message: e.message }); - } else { - sendClientError(e); - } - }); + if (seconds <= 0 && timeOut) { + timeOut(); } - }; - return { checkCode, isCertified }; -}; + if (seconds === time - 30 && onMid) { + // 30초 지났을 때 + onMid(); + } + + if (seconds > 0 && !stop) { + interval = setInterval(() => { + setSeconds((prev) => prev - 1); + }, 1000); + } -export default function PhoneStep({ setIsStepComplete }: PhoneStepProps) { + return () => clearInterval(interval); + }, [seconds, setSeconds, timeOut, time, onMid, stop]); + + return ( + <div> + {seconds / 60 >= 10 ? Math.floor(seconds / 60) : `0${Math.floor(seconds / 60)}`} + {' : '} + {seconds % 60 >= 10 ? seconds % 60 : `0${seconds % 60}`} + </div> + ); +} + +interface InquiryButtonProps { + title: string; + link: string; +} + +function InquiryButton({ title, link }:InquiryButtonProps) { + return ( + <div className={styles.inquiry}> + <div className={styles.inquiry__quote}>{title}</div> + <a + className={styles.inquiry__link} + href={link} + target="_blank" + rel="noreferrer" + > + 문의하기 + </a> + </div> + ); +} + +function PhoneStep({ nextStep }: { nextStep: () => void }) { const { register, formState: { errors }, getValues, setError, watch, setValue, clearErrors, - } = useFormContext<Verify>(); + } = useFormContext<Register>(); + + const [steps, setSteps] = useState(0); + const [isShowInquiry, setIsShowInquiry] = useState(false); + const [seconds, setSeconds] = useState(180); + const [hasConfilct, setHasConflict] = useState(false); + const [phoneNumber, verificationCode] = watch(['phone_number', 'verificationCode']); + const onSendCodeSucess = () => { + setSteps((prev) => prev + 1); + setHasConflict(false); + }; + const onSendCodeError = (e: unknown) => { + if (isKoinError(e)) { + if (e.status === 409) { + setError('phone_number', { type: 'custom', message: '이미 가입된 전화번호입니다.' }); + setHasConflict(true); + return; + } + setError('phone_number', { type: 'custom', message: e.message }); + } + }; + + const onRevalidateCodeSuccess = () => { + setSeconds(180); + clearErrors(); + }; + + const sendCode = useSendCode({ + getValues, onError: onSendCodeError, onSucess: onSendCodeSucess, + }); - const [isSent, setIsSent] = useState(false); - const [isClick, setIsClick] = useState(false); - const debounce = useDebounce<SendCodeParams>(code, { - getValues, setError, setIsSent, setIsClick, + const revalidataCode = useSendCode({ + getValues, onError: onSendCodeError, onSucess: onRevalidateCodeSuccess, }); - const { checkCode, isCertified } = useCheckCode( - getValues, + const { mutation, isCertified } = useCheckCode( + verificationCode, + phoneNumber, setError, ); - const sendCode = () => { - setIsClick(true); - if (!getValues('phone_number')) { - setError('phone_number', { type: 'custom', message: '필수 입력 항목입니다.' }); - return; - } - debounce(); - }; - const setCode = (e: ChangeEvent<HTMLInputElement>) => setValue('verificationCode', e.target.value); const handlePhoneNumberChange = (e: ChangeEvent<HTMLInputElement>) => { @@ -116,155 +186,204 @@ export default function PhoneStep({ setIsStepComplete }: PhoneStepProps) { value = value.slice(0, 11); } setValue('phone_number', value); - setIsClick(false); clearErrors(); }; - const watchedValues = watch(['attachment_urls', 'verificationCode', 'password', 'passwordConfirm']); - useEffect(() => { - const values = getValues(); - const isComplete = ( - values.password === values.passwordConfirm - && values.password.length > 0 - ) && isCertified && !!errors; - if (isComplete) { - setIsStepComplete(true); - } else { - setIsStepComplete(false); + if (verificationCode.length === 6 && mutation.isIdle && sendCode.isSuccess) { + mutation.mutate(); } - }, [watchedValues, isCertified, getValues, setIsStepComplete, errors]); + }, [verificationCode, mutation, sendCode]); return ( <div className={styles['default-info']}> - <div className={styles['phone-number']}> - <span className={styles['phone-number__label']}>전화번호</span> - <div className={styles['input-container']}> - <input - {...register('phone_number', { - required: { - value: true, - message: '전화번호를 입력해주세요', - }, - pattern: { - value: /^[0-9]+$/, - message: '숫자만 입력 가능합니다', - }, - onChange: handlePhoneNumberChange, // 전화번호 변경 핸들러 추가 - })} - className={cn({ - [styles['phone-number__input']]: true, - [styles['error-border']]: !!errors.phone_number, - })} + <div> + {steps >= 0 && ( + <div className={styles['phone-number']}> + <Title title="휴대 전화 번호를 입력해주세요" /> + <Input + register={register} + name="phone_number" + required + requiredMessage="올바른 번호가 아닙니다. 다시 입력해주세요." + pattern={/^\d{11}$/} + patternMessage="숫자만 입력 가능합니다" + onChange={handlePhoneNumberChange} placeholder="-없이 번호를 입력해주세요." - type="text" inputMode="numeric" /> - <button - type="button" - className={cn({ - [styles['verification-code__button']]: isClick, - [styles['verification-code__button--active']]: isSent || !isClick, - })} - onClick={sendCode} - disabled={isClick} + <div className={styles.wrapper}> + <ValidationMessage + message={errors.phone_number?.message} + isError={!!errors.phone_number} + /> + {hasConfilct && ( + <Link to={ROUTES.Login()}> + <div className={styles['login-link']}> + 로그인 하기 + </div> + </Link> + )} + </div> + <Button + disabled={!!errors.phone_number?.message + || phoneNumber.length !== 11 + || steps !== 0 + || sendCode.isPending} + onClick={sendCode.mutate} > - {isSent ? '인증번호 재발송' : '인증번호 발송'} - </button> - - </div> - <div className={styles['error-message']}> - {errors.phone_number && <Error />} - {errors.phone_number?.message} + 인증번호 발송 + </Button> + {hasConfilct && ( + <InquiryButton + title="해당 번호로 가입한 적 없으신가요?" + link="https://open.kakao.com/o/sgiYx4Qg" + /> + )} </div> - </div> - <div className={`${styles['verification-code']} ${styles['input-box']}`}> - <span className={styles['verification-code__label']}>인증번호</span> - <div className={styles['input-container']}> - <input - className={cn({ - [styles['verification-code__input']]: true, - [styles['error-border']]: !!errors.verificationCode, - })} - {...register('verificationCode', { - required: { - value: true, - message: '인증번호를 입력해주세요', - }, - pattern: { - value: /^[0-9]{6}$/, - message: '인증번호는 6자리입니다.', - }, - onChange: setCode, // 인증번호 변경 핸들러 추가 - })} + )} + + {steps >= 1 && ( + <div className={`${styles['verification-code']} ${styles['input-box']}`}> + <Title title="발송된 인증 번호를 입력해주세요." /> + <Input + register={register} + name="verificationCode" + required + requiredMessage="인증번호를 입력해주세요" + pattern={/^[0-9]{6}$/} + patternMessage="인증번호는 6자리입니다." + onChange={setCode} placeholder="인증번호를 입력해주세요." - maxLength={6} - type="text" inputMode="numeric" + component={( + <Timer + seconds={seconds} + setSeconds={setSeconds} + timeOut={() => setError('verificationCode', { type: 'error', message: '유효시간이 지났습니다. 인증번호를 재발송 해주세요.' })} + onMid={() => setIsShowInquiry(true)} + stop={isCertified} + /> +)} /> - <button - type="button" - className={cn({ - [styles['verification-code__button']]: true, - [styles['verification-code__button--active']]: isSent, - [styles['verification-code__button--error']]: !!errors.verificationCode, - })} - onClick={checkCode} - > - 인증번호 확인 - </button> - </div> - <div className={styles['error-message']}> - {errors.verificationCode && ( - <> - <Error /> - {errors.verificationCode.message} - </> + <ValidationMessage + message={isCertified ? '인증번호가 일치합니다. 다음으로 넘어가주세요.' : errors.verificationCode?.message} + isError={!!errors.verificationCode} + /> + {!isCertified && ( + <> + <Button onClick={revalidataCode.mutate}> + 인증번호 재발송 + </Button> + {isShowInquiry && ( + <InquiryButton + title="인증번호 재발송이 안 되시나요?" + link="https://open.kakao.com/o/sgiYx4Qg" + /> + )} + </> )} </div> + )} </div> - <div className={`${styles.password} ${styles['input-box']}`}> - <span className={styles.password__label}>비밀번호</span> - <input - {...register('password', { - pattern: { - value: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{6,18}$/, - message: '특수문자 포함 영어와 숫자 6~18 자리로 입력해주세요.', - }, - })} - type="password" - placeholder="비밀번호를 입력해주세요" + + <Button + disabled={!isCertified} + onClick={() => { + setValue('verificationCode', ''); + nextStep(); + }} + > + 다음 + </Button> + </div> + ); +} + +interface Props { + nextStep: () => void; +} + +interface PasswordParams { + password: string; + passwordConfirm: string; +} + +function PasswordStep({ nextStep }: Props) { + const { + register, formState: { errors }, watch, + } = useFormContext<PasswordParams>(); + + const [isBlind, setIsBlind] = useState({ + password: false, + passwordConfirm: false, + }); + + const toggleBlindState = (key: 'password' | 'passwordConfirm') => { + setIsBlind((prev) => ({ + ...prev, [key]: !prev[key], + })); + }; + + const [password, passwordConfirm] = watch(['password', 'passwordConfirm']); + + const isValidPassword = password.length >= 6 && !errors.password; + return ( + <div className={styles['default-info']}> + <div> + <Title title={isValidPassword ? '비밀번호를 한 번 더 입력해주세요.' : '사용하실 비밀번호를 입력해주세요.'} /> + <Input + name="password" + register={register} + required + requiredMessage="비밀번호를 입력해주세요." + pattern={/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{6,18}$/} + patternMessage="특수문자 포함 영어와 숫자 6~18 자리로 입력해주세요." + type={isBlind.password ? 'text' : 'password'} + placeholder="특수문자 포함 영어와 숫자 6~18 자리로 입력해주세요." + component={<BlindButton isBlind={!isBlind.password} onClick={() => toggleBlindState('password')} />} /> - <div className={styles['error-message']}> - {errors.password && ( - <> - <Error /> - {errors.password.message} - </> - )} - </div> - </div> - <div className={`${styles['password-confirm']} ${styles['input-box']}`}> - <span className={styles.password__label}>비밀번호 확인</span> - <input - {...register('passwordConfirm', { - pattern: { - value: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{6,18}$/, - message: '특수문자 포함 영어와 숫자 6~18 자리로 입력해주세요.', - }, - })} - type="password" - placeholder="비밀번호를 확인해주세요." + <ValidationMessage + message={password.length >= 6 && !errors.password ? '사용 가능한 비밀번호입니다.' : errors.password?.message} + isError={!!errors.password} + /> + <Input + name="passwordConfirm" + register={register} + required + requiredMessage="비밀번호를 확인해주세요." + pattern={/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{6,18}$/} + patternMessage="특수문자 포함 영어와 숫자 6~18 자리로 입력해주세요." + type={isBlind.passwordConfirm ? 'text' : 'password'} + placeholder="비밀번호를 다시 입력해주세요." + component={<BlindButton isBlind={!isBlind.passwordConfirm} onClick={() => toggleBlindState('passwordConfirm')} />} + /> + <ValidationMessage + message={password.length >= 6 && password === passwordConfirm && !errors.passwordConfirm ? '비밀번호가 일치합니다. 다음으로 넘어가주세요.' : errors.passwordConfirm?.message} + isError={!!errors.passwordConfirm} /> - <div className={styles['error-message']}> - {errors.passwordConfirm && ( - <> - <Error /> - {errors.passwordConfirm.message} - </> - )} - </div> </div> + <Button + disabled={password !== passwordConfirm + || !!errors.password + || !!errors.passwordConfirm + || password.length < 6} + onClick={nextStep} + > + 다음 + </Button> </div> ); } + +export default function AuthenticationStep({ nextStep }: Props) { + const [steps, setSteps] = useState(0); + + if (steps === 0) { + return ( + <PhoneStep nextStep={() => setSteps((prev) => prev + 1)} /> + ); + } + + return <PasswordStep nextStep={nextStep} />; +} diff --git a/src/page/Auth/Signup/components/phoneStep/phoneStep.module.scss b/src/page/Auth/Signup/components/phoneStep/phoneStep.module.scss index 07ddb98c..f9e22a69 100644 --- a/src/page/Auth/Signup/components/phoneStep/phoneStep.module.scss +++ b/src/page/Auth/Signup/components/phoneStep/phoneStep.module.scss @@ -1,53 +1,10 @@ @use "src/utils/styles/mediaQuery" as media; .default-info { - width: 98%; - margin-left: 1%; - margin-top: 25px; display: flex; flex-direction: column; - gap: 27px; - height: calc(100vh - 45vh); - - @include media.media-breakpoint-down(mobile) { - height: calc(100vh - 40vh); - } -} - -.default-info span { - font-size: 14px; - font-style: normal; - font-weight: 500; -} - -.default-info input { - width: 100%; - box-sizing: border-box; - border-radius: 4px; - background: #f5f5f5; - padding: 12px 16px; - border: none; - - &:focus { - outline: 1px solid #4590bb; - } - - &:hover { - cursor: pointer; - } -} - -.default-info button { - width: 33.33%; - padding: 8px 12px; - border-radius: 4px; - background: #eee; - color: #4b4b4b; - font-size: 13px; - - &:hover { - cursor: pointer; - } + height: 100%; + justify-content: space-between; } .phone-number__input { @@ -87,15 +44,29 @@ gap: 7px; } -.error-border { - &:focus { - outline: 1px solid #f7941e !important; +.inquiry { + display: flex; + justify-content: space-between; + + &__quote { + color: #727272; + } + + &__link { + color: #175c8e; + text-decoration: none; } } -.error-message { - display: flex; - align-items: center; - color: #f7941e; - font-size: 12px; +.wrapper { + position: relative; +} + +.login-link { + color: #175c8e; + text-decoration: none; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); } diff --git a/src/page/Auth/Signup/components/searchShop/index.tsx b/src/page/Auth/Signup/components/searchShop/index.tsx index fe11e5c8..58caa7dc 100644 --- a/src/page/Auth/Signup/components/searchShop/index.tsx +++ b/src/page/Auth/Signup/components/searchShop/index.tsx @@ -2,115 +2,109 @@ import SearchIcon from 'assets/svg/auth/search-icon.svg?react'; import useShopList from 'query/shops'; import { ChangeEvent, useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { useOutletContext } from 'react-router-dom'; import cn from 'utils/ts/className'; -import { OutletProps } from 'page/Auth/FindPassword/entity'; +import { Button } from 'page/Auth/components/Common/form'; import styles from './searchShop.module.scss'; interface ShopInfo { shop_name: string; shop_id: number | null; } -export default function SearchShop() { - const [searchText, setSearchText] = useState(''); - const steps = useOutletContext<OutletProps>(); + +export default function SearchShop({ nextStep }: { nextStep: () => void }) { const { shopList, isError } = useShopList(); const { watch, setValue, } = useFormContext<ShopInfo>(); + function handleClickShop(e: React.MouseEvent<HTMLButtonElement>) { const { name, id } = JSON.parse(e.currentTarget.value); setValue('shop_name', name); setValue('shop_id', id); - steps.setIsShopSelect(true); } const [filteredShopList, setFilteredShopList] = useState(shopList?.shops); - function handleSearch() { - if (searchText !== '') { - setFilteredShopList(shopList?.shops.filter(({ name }) => name.includes(searchText))); - } - } - function handleChangeSearchText(e: ChangeEvent<HTMLInputElement>) { - setSearchText(e.target.value); - } - - function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - if (e.key === 'Enter') { - handleSearch(); + if (e.target.value !== '') { + setFilteredShopList(shopList?.shops.filter(({ name }) => name.includes(e.target.value))); + } else { + setFilteredShopList(shopList?.shops); } } useEffect(() => { - if (searchText === '') { + if (shopList?.shops) { setFilteredShopList(shopList?.shops); } - }, [searchText, shopList?.shops]); + }, [shopList]); return ( <div className={styles['search-shop-container']}> - <div className={styles['search-input-container']}> - <input - type="text" - className={styles['search-shop__input']} - placeholder="가게를 검색해보세요" - value={searchText} - onChange={handleChangeSearchText} - onKeyDown={handleKeyDown} - /> - <SearchIcon - type="button" - className={styles['search-shop__button']} - onClick={() => handleSearch()} - /> - </div> - <div className={styles['shop-list-container']}> - {isError && <div>에러가 발생했습니다.</div>} - {filteredShopList?.map((shop) => ( - <button - key={shop.id} - className={cn({ - [styles['shop-card']]: true, - [styles['shop-card--selected']]: watch('shop_name') === shop.name, - })} - value={JSON.stringify({ - name: shop.name, - id: shop.id, - })} + <div> + <div className={styles['search-input-container']}> + <input + type="text" + className={styles['search-shop__input']} + placeholder="가게를 검색해보세요" + onChange={handleChangeSearchText} + /> + <SearchIcon type="button" - onClick={(e) => { - handleClickShop(e); - }} - > - <span className={styles['shop-card__title']}>{shop.name}</span> - <div className={styles['shop-card-info-container']}> - <span className={cn({ - [styles['shop-card__info']]: true, - [styles['shop-card__info--activate']]: shop.delivery, - })} - > - 배달 - </span> - <span className={cn({ - [styles['shop-card__info']]: true, - [styles['shop-card__info--activate']]: shop.pay_card, + className={styles['search-shop__button']} + /> + </div> + <div className={styles['shop-list-container']}> + {isError && <div>에러가 발생했습니다.</div>} + {filteredShopList?.map((shop) => ( + <button + key={shop.id} + className={cn({ + [styles['shop-card']]: true, + [styles['shop-card--selected']]: watch('shop_name') === shop.name, })} - > - 카드결제 - </span> - <span className={cn({ - [styles['shop-card__info']]: true, - [styles['shop-card__info--activate']]: shop.pay_bank, + value={JSON.stringify({ + name: shop.name, + id: shop.id, })} - > - 계좌이체 - </span> - </div> - </button> - ))} + type="button" + onClick={(e) => { + handleClickShop(e); + }} + > + <span className={styles['shop-card__title']}>{shop.name}</span> + <div className={styles['shop-card-info-container']}> + <span className={cn({ + [styles['shop-card__info']]: true, + [styles['shop-card__info--activate']]: shop.delivery, + })} + > + 배달 + </span> + <span className={cn({ + [styles['shop-card__info']]: true, + [styles['shop-card__info--activate']]: shop.pay_card, + })} + > + 카드결제 + </span> + <span className={cn({ + [styles['shop-card__info']]: true, + [styles['shop-card__info--activate']]: shop.pay_bank, + })} + > + 계좌이체 + </span> + </div> + </button> + ))} + </div> </div> + <Button + onClick={nextStep} + > + 다음 + </Button> </div> ); } diff --git a/src/page/Auth/Signup/components/searchShop/searchShop.module.scss b/src/page/Auth/Signup/components/searchShop/searchShop.module.scss index 94871000..7891fcd5 100644 --- a/src/page/Auth/Signup/components/searchShop/searchShop.module.scss +++ b/src/page/Auth/Signup/components/searchShop/searchShop.module.scss @@ -1,5 +1,12 @@ @use "src/utils/styles/mediaQuery" as media; +.search-shop-container { + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; +} + .search-input-container { border: none; border-radius: 4px; @@ -37,8 +44,8 @@ flex-direction: column; gap: 8px; - @include media.media-breakpoint-down(mobile) { - height: calc(100vh - 40vh); + &::-webkit-scrollbar { + display: none; } } diff --git a/src/page/Auth/Signup/index.tsx b/src/page/Auth/Signup/index.tsx index 44339ac9..e8af513d 100644 --- a/src/page/Auth/Signup/index.tsx +++ b/src/page/Auth/Signup/index.tsx @@ -1,10 +1,9 @@ -import { useEffect, useState } from 'react'; import { useOutletContext } from 'react-router-dom'; import { OutletProps } from 'page/Auth/FindPassword/entity'; +import { useState } from 'react'; +import OwnerStep from './components/onwerStep'; import AgreeStep from './components/agreeStep'; -import OwnerInfoStep from './components/ownerInfoStep'; -import PhoneStep from './components/phoneStep'; -import SearchShop from './components/searchShop'; +import AuthenticationStep from './components/phoneStep'; interface SelectOptions { personal: boolean; @@ -19,7 +18,6 @@ const initialSelectOption: SelectOptions = { export default function SignUp() { const [selectItems, setSelectItems] = useState<SelectOptions>(initialSelectOption); const steps = useOutletContext<OutletProps >(); - const [stepPhoneComplete, setStepPhoneComplete] = useState(false); const handleSelect = (option: keyof SelectOptions | 'all') => { if (option === 'all') { @@ -36,44 +34,22 @@ export default function SignUp() { } }; - useEffect(() => { - setSelectItems(initialSelectOption); - }, [steps.index]); - - useEffect(() => { - if (selectItems.koin && selectItems.personal) { - steps.setIsStepComplete(true); - } else { - steps.setIsStepComplete(false); - } - }, [selectItems, steps]); - - useEffect(() => { - }, [steps.setIsStepComplete]); - - useEffect(() => { - if (stepPhoneComplete) { - steps.setIsStepComplete(true); - } - }, [stepPhoneComplete, steps]); - return ( <> + {steps.index === 0 && ( - <AgreeStep selectItems={selectItems} handleSelect={handleSelect} /> + <AgreeStep + selectItems={selectItems} + handleSelect={handleSelect} + nextStep={steps.nextStep} + /> )} {steps.index === 1 && ( - <PhoneStep setIsStepComplete={setStepPhoneComplete} /> + <AuthenticationStep nextStep={steps.nextStep} /> )} - {steps.index === 2 && (steps.isSearch - ? ( - <SearchShop /> - ) : ( - <OwnerInfoStep - onSearch={() => steps.setIsSearch(true)} - setIsStepComplete={setStepPhoneComplete} - /> - ) + + {steps.index === 2 && ( + <OwnerStep complete={() => steps.setIsComplete(true)} /> )} </> ); diff --git a/src/page/Auth/components/Common/BlindButton/index.tsx b/src/page/Auth/components/Common/BlindButton/index.tsx new file mode 100644 index 00000000..3deff54f --- /dev/null +++ b/src/page/Auth/components/Common/BlindButton/index.tsx @@ -0,0 +1,20 @@ +import ShowIcon from 'assets/svg/auth/show.svg?react'; +import BlindIcon from 'assets/svg/auth/blind.svg?react'; + +interface Props { + isBlind: boolean; + onClick: () => void; + classname?: string; +} + +export default function BlindButton({ isBlind, onClick, classname }: Props) { + return ( + <button + type="button" + className={classname} + onClick={onClick} + > + {isBlind ? <BlindIcon aria-hidden /> : <ShowIcon aria-hidden />} + </button> + ); +} diff --git a/src/page/Auth/components/Common/form/form.module.scss b/src/page/Auth/components/Common/form/form.module.scss new file mode 100644 index 00000000..4d1d996f --- /dev/null +++ b/src/page/Auth/components/Common/form/form.module.scss @@ -0,0 +1,80 @@ +.title { + font-weight: 500; + font-size: 20px; + line-height: 32px; + margin: 25px 0; + white-space: pre-line; +} + +.input-container { + position: relative; +} + +.input { + width: 100%; + border-radius: 4px; + background: #f5f5f5; + padding: 12px 16px; + border: none; + box-sizing: border-box; + + &::placeholder { + color: #8e8e8e; + font-size: 12px; + font-weight: 400; + } +} + +.timer { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); +} + +.button { + background-color: #175c8e; + color: #fff; + width: 100%; + border-radius: 4px; + padding: 12px 16px; + cursor: pointer; + + &__secondary { + background-color: #4590bb; + } + + &__info { + background-color: #f5f5f5; + color: #727272; + } + + &__disabled { + background-color: #e1e1e1; + color: #000; + cursor: default; + } +} + +%message-container { + font-size: 12px; + font-weight: 400; + height: 30px; + display: flex; + align-items: center; + gap: 3px; +} + +.message-container { + &--error { + color: #f7941e; + + @extend %message-container; + } + + &--success { + color: #00a86b; + + @extend %message-container; + } +} diff --git a/src/page/Auth/components/Common/form/index.tsx b/src/page/Auth/components/Common/form/index.tsx new file mode 100644 index 00000000..30a3d790 --- /dev/null +++ b/src/page/Auth/components/Common/form/index.tsx @@ -0,0 +1,115 @@ +import cn from 'utils/ts/className'; +import Error from 'assets/svg/auth/error-icon.svg?react'; +import Success from 'assets/svg/auth/success.svg?react'; +import styles from './form.module.scss'; + +interface InputProps { + type?: string; + placeholder?: string; + register: any; + name: string; + onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void; + inputMode?: string; + required?: boolean; + pattern?: RegExp; + requiredMessage?: string; + patternMessage?: string; + maxLength?: number; + component?: React.ReactNode; +} + +interface ButtonProps { + disabled?: boolean; + children: React.ReactNode; + onClick?: () => void; + onSubmit?: () => void; + type?: 'button' | 'submit' | 'reset'; + primary?: boolean; + secondary?: boolean; + info?: boolean; +} + +interface ValidationMessageProps { + message?: string; + isError: boolean; +} + +export function Title({ title }: { title: string }) { + return ( + <div className={styles.title}>{title}</div> + ); +} + +export function ValidationMessage({ message, isError }: ValidationMessageProps) { + if (isError) { + return ( + <div className={styles['message-container--error']}> + <Error /> + {message} + </div> + ); + } + + return ( + <div className={styles['message-container--success']}> + {message && <Success />} + {message} + </div> + ); +} + +export function Input({ + type = 'text', placeholder, register, onChange, inputMode, name, required, pattern, + requiredMessage, patternMessage, maxLength, component, +}: InputProps) { + return ( + <div className={styles['input-container']}> + <input + {...register(name, { + required: { + value: required, + message: requiredMessage, + }, + pattern: pattern ? { + value: pattern, + message: patternMessage, + } : undefined, + onChange, + })} + className={styles.input} + placeholder={placeholder} + type={type} + inputMode={inputMode} + maxLength={maxLength} + /> + <div className={styles.timer}> + {component} + </div> + </div> + ); +} + +export function Button({ + disabled = false, children, onClick, type = 'button', onSubmit, primary = true, secondary = false, info = false, +}: ButtonProps) { + return ( + <button + // eslint-disable-next-line react/button-has-type + type={type} + onClick={onClick} + onSubmit={onSubmit ? ((e) => { + e.preventDefault(); + onSubmit(); + }) : undefined} + disabled={disabled} + className={cn({ + [styles.button]: primary, + [styles.button__secondary]: secondary, + [styles.button__info]: info, + [styles.button__disabled]: disabled, + })} + > + {children} + </button> + ); +} diff --git a/src/page/Auth/components/Common/index.module.scss b/src/page/Auth/components/Common/index.module.scss index bdc9e0ea..9293bba0 100644 --- a/src/page/Auth/components/Common/index.module.scss +++ b/src/page/Auth/components/Common/index.module.scss @@ -64,18 +64,14 @@ } .content { - height: calc(100vh - 35vh); box-sizing: border-box; padding: 5px 0; overflow: scroll; + height: 75dvh; &::-webkit-scrollbar { display: none; } - - @include media.media-breakpoint-down(mobile) { - height: calc(100vh - 28vh); - } } .button { diff --git a/src/page/Auth/components/Common/index.tsx b/src/page/Auth/components/Common/index.tsx index f1f2c228..692a35c9 100644 --- a/src/page/Auth/components/Common/index.tsx +++ b/src/page/Auth/components/Common/index.tsx @@ -1,130 +1,43 @@ import BackArrow from 'assets/svg/common/back-arrow.svg?react'; -import { FormProvider, useForm, UseFormSetError } from 'react-hook-form'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; -import cn from 'utils/ts/className'; -import { Register, RegisterUser } from 'model/auth'; -import { changePassword } from 'api/auth'; -import { phoneRegisterUser } from 'api/register'; -import { isKoinError, sendClientError } from '@bcsdlab/koin'; +import { FormProvider, useForm } from 'react-hook-form'; +import { Outlet, useLocation } from 'react-router-dom'; +import { Register } from 'model/auth'; import { useStep } from 'page/Auth/hook/useStep'; -import sha256 from 'utils/ts/SHA-256'; -import { useDebounce } from 'utils/hooks/useDebounce'; -import ROUTES from 'static/routes'; import Done from 'page/Auth/components/Done'; import styles from './index.module.scss'; -const setNewPassword = async ( - phone_number: string, - password: string, - setError: UseFormSetError<Register>, -) => { - const hashPassword = await sha256(password); - changePassword({ phone_number, password: hashPassword }) - .catch((e) => { - if (isKoinError(e)) { - setError('password', { type: 'custom', message: e.message }); - } else { - sendClientError(e); - } - }); -}; - -interface RegisterParam { - company_number: string, - name: string, - password: string, - phone_number: string, - shop_id: number | null, - shop_name: string, - attachment_urls: { file_url: string; }[], - setError: UseFormSetError<RegisterUser>, -} - -const registerUser = ( - { - company_number, - name, - password, - phone_number, - shop_id, - shop_name, - attachment_urls, - setError, - }: RegisterParam, -) => { - phoneRegisterUser({ - company_number, name, password, phone_number, shop_id, shop_name, attachment_urls, - }).then(() => sessionStorage.removeItem('accessToken')) - .catch((e) => { - if (isKoinError(e)) { - setError('password', { type: 'custom', message: e.message }); - } else { - sendClientError(e); - } - }); -}; - export default function CommonLayout() { const location = useLocation(); - const navigate = useNavigate(); const isFindPassword = location.pathname.includes('find'); const title = isFindPassword ? '비밀번호 찾기' : '회원가입'; const method = useForm<Register>({ mode: 'onChange', + defaultValues: { + company_number: '', + name: '', + password: '', + phone_number: '', + shop_id: null, + shop_name: '', + attachment_urls: [], + verificationCode: '', + }, }); - const { - formState: { errors }, setError, getValues, - } = method; const steps = useStep(isFindPassword ? 'find' : 'register'); const { - nextStep, previousStep, currentStep, index, totalStep, isComplete, - isStepComplete, - isSearch, - setIsSearch, } = steps; // eslint-disable-next-line const progressPercentage = (index + 1) / totalStep * 100; - const stepCheck = async () => { - if (isComplete) navigate(ROUTES.Login()); - if (!errors.root) { - if (index + 1 === totalStep && isFindPassword) { - setNewPassword(getValues('phone_number'), getValues('password'), setError); - } else if (index + 1 === totalStep && !isFindPassword) { - const hash = await sha256(getValues('password')); - registerUser( - { - company_number: getValues('company_number'), - name: getValues('name'), - password: hash, - phone_number: getValues('phone_number'), - shop_id: getValues('shop_id'), - shop_name: getValues('shop_name'), - attachment_urls: getValues('attachment_urls'), - setError, - }, - ); - } - nextStep(); - } - }; - - const debounce = useDebounce<null>(stepCheck, null); - - const handleSelectComplete = () => { - setIsSearch(false); - steps.setIsStepComplete(true); - }; - return ( <div className={styles.container}> <FormProvider {...method}> @@ -164,36 +77,6 @@ export default function CommonLayout() { ? <Done isFindPassword={isFindPassword} /> : <Outlet context={steps} />} </div> - {!isComplete && !isSearch && ( - <button - type="button" - onClick={debounce} - disabled={!isStepComplete} - className={ - cn({ - [styles['button--active']]: isStepComplete, - [styles.button]: true, - }) - } - > - {index + 1 === totalStep ? '완료' : '다음'} - </button> - )} - {isSearch && ( - <button - type="button" - onClick={handleSelectComplete} - disabled={!steps.isShopSelect} - className={ - cn({ - [styles['button--active']]: steps.isShopSelect, - [styles.button]: true, - }) - } - > - 선택완료 - </button> - )} </div> </FormProvider> </div> diff --git a/src/page/Auth/hook/useStep.ts b/src/page/Auth/hook/useStep.ts index 84f40e82..50352a04 100644 --- a/src/page/Auth/hook/useStep.ts +++ b/src/page/Auth/hook/useStep.ts @@ -14,15 +14,13 @@ export const useStep = (type: Type) => { const [index, setIndex] = useState(0); const [isSearch, setIsSearch] = useState(false); const [isComplete, setIsComplete] = useState<boolean>(false); - const [isStepComplete, setIsStepComplete] = useState<boolean>(false); const [isShopSelect, setIsShopSelect] = useState<boolean>(false); const navigate = useNavigate(); const nextStep = () => { - if (isStepComplete && index + 1 < target.length) { + if (index + 1 < target.length) { setIndex((prev) => prev + 1); - setIsStepComplete(false); - } else if (isStepComplete && index + 1 === target.length) { + } else if (index + 1 === target.length) { setIsComplete(true); } }; @@ -33,7 +31,6 @@ export const useStep = (type: Type) => { setIsSearch(false); } else { setIndex((prev) => prev - 1); - setIsStepComplete(true); // step을 통과한 사람만 뒤로 갈 수 있음 } } else navigate(-1); }; @@ -49,8 +46,6 @@ export const useStep = (type: Type) => { totalStep, isComplete, setIsComplete, - isStepComplete, - setIsStepComplete, isSearch, setIsSearch, isShopSelect, diff --git a/src/page/MyShopPage/MyShopPage.module.scss b/src/page/MyShopPage/MyShopPage.module.scss index 2547707b..083b9227 100644 --- a/src/page/MyShopPage/MyShopPage.module.scss +++ b/src/page/MyShopPage/MyShopPage.module.scss @@ -112,3 +112,15 @@ a { } } } + +.edit-wrapper { + padding: 10px 20px; + border-bottom: 1.5px solid #eee; +} + +.center { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; +} diff --git a/src/page/MyShopPage/components/EditMenu/index.module.scss b/src/page/MyShopPage/components/EditMenu/index.module.scss new file mode 100644 index 00000000..5dd91537 --- /dev/null +++ b/src/page/MyShopPage/components/EditMenu/index.module.scss @@ -0,0 +1,12 @@ +.menu__add { + display: flex; + width: 100%; + justify-content: flex-end; + padding-right: 10px; + box-sizing: border-box; + margin-top: 10px; + + &--button { + color: #175c8e; + } +} diff --git a/src/page/MyShopPage/components/EditMenu/index.tsx b/src/page/MyShopPage/components/EditMenu/index.tsx new file mode 100644 index 00000000..35e67c9a --- /dev/null +++ b/src/page/MyShopPage/components/EditMenu/index.tsx @@ -0,0 +1,29 @@ +import useMyShop from 'query/shop'; +import { Link } from 'react-router-dom'; +import MenuTable from 'page/MyShopPage/components/MenuTable'; +import { useClickImage } from 'utils/hooks/useClickImage'; +import styles from './index.module.scss'; + +export default function EditMenu() { + const { menusData } = useMyShop(); + const { onClickImage } = useClickImage(); + + if (!menusData) return null; + + return ( + <div> + <div className={styles.menu__add}> + <Link to="/owner/add-menu"> + <div className={styles['menu__add--button']}> + + 메뉴추가 + </div> + </Link> + </div> + <MenuTable + shopMenuCategories={menusData?.menu_categories || []} + onClickImage={onClickImage} + isEdit + /> + </div> + ); +} diff --git a/src/page/MyShopPage/components/MenuTable/MenuTable.module.scss b/src/page/MyShopPage/components/MenuTable/MenuTable.module.scss index a6578d30..c43b7ed5 100644 --- a/src/page/MyShopPage/components/MenuTable/MenuTable.module.scss +++ b/src/page/MyShopPage/components/MenuTable/MenuTable.module.scss @@ -2,6 +2,7 @@ .categories { display: flex; + justify-content: space-between; position: sticky; top: 77px; gap: 13px; @@ -53,8 +54,7 @@ } .menu-info { - display: grid; - grid-template-columns: repeat(2, 1fr); + display: flex; padding: 20px; gap: 20px; border-bottom: 1px solid #eee; @@ -85,7 +85,7 @@ @include media.media-breakpoint-down(mobile) { padding-bottom: 12px; - width: 250px; + width: 200px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -101,16 +101,19 @@ } } } + + &__modify-button { + background: none; + color: #175c8e; + height: min-content; + cursor: pointer; + } } .image { display: flex; justify-content: center; - &:not(:first-child) { - display: none; - } - &__button { border: none; background-color: transparent; diff --git a/src/page/MyShopPage/components/MenuTable/index.tsx b/src/page/MyShopPage/components/MenuTable/index.tsx index a3327747..6985c45f 100644 --- a/src/page/MyShopPage/components/MenuTable/index.tsx +++ b/src/page/MyShopPage/components/MenuTable/index.tsx @@ -2,19 +2,21 @@ import { useEffect, useState } from 'react'; import { MenuCategory } from 'model/shopInfo/menuCategory'; import cn from 'utils/ts/className'; import MENU_CATEGORY from 'utils/constant/menu'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import ROUTES from 'static/routes'; import styles from './MenuTable.module.scss'; interface MenuTableProps { shopMenuCategories: MenuCategory[]; onClickImage: (img: string[], index: number) => void; + isEdit?: boolean; } const HEADER_OFFSET = 133; // categories 높이 -function MenuTable({ shopMenuCategories, onClickImage }: MenuTableProps) { +function MenuTable({ shopMenuCategories, onClickImage, isEdit = false }: MenuTableProps) { const [categoryType, setCategoryType] = useState<string>(shopMenuCategories[0].name); + const navigate = useNavigate(); const handleScroll = () => { shopMenuCategories.forEach((menu) => { @@ -29,6 +31,10 @@ function MenuTable({ shopMenuCategories, onClickImage }: MenuTableProps) { }); }; + const goEditPage = (id: number) => { + navigate(ROUTES.Owner.ModifyMenu({ id: String(id), isLink: true })); + }; + useEffect(() => { shopMenuCategories.forEach((menu) => { const element = document.getElementById(menu.name); @@ -99,25 +105,6 @@ function MenuTable({ shopMenuCategories, onClickImage }: MenuTableProps) { {menuCategories.menus.map((menu) => ( menu.option_prices === null ? ( <div className={styles['menu-info']} key={menu.id}> - {menu.image_urls.length > 0 ? ( - menu.image_urls.map((img, idx) => ( - <div key={`${img}`} className={styles.image}> - <button - className={styles.image__button} - type="button" - onClick={() => onClickImage(menu.image_urls, idx)} - > - <img src={`${img}`} alt={`${menu.name}`} /> - </button> - </div> - ))) : ( - <div className={styles['empty-image']}> - <img - src="https://static.koreatech.in/assets/img/mainlogo2.png" - alt="KOIN service logo" - /> - </div> - )} <Link to={ROUTES.Owner.ModifyMenu({ id: String(menu.id), isLink: true })} className={styles['menu-info__modify']}> <div className={styles['menu-info__card']}> <span title={menu.name}>{menu.name}</span> @@ -129,6 +116,33 @@ function MenuTable({ shopMenuCategories, onClickImage }: MenuTableProps) { </span> </div> </Link> + {isEdit && ( + <button + className={styles['menu-info__modify-button']} + type="button" + onClick={() => goEditPage(menu.id)} + > + 변경 + </button> + )} + {menu.image_urls.length > 0 ? ( + <div key={`${menu.image_urls[0]}`} className={styles.image}> + <button + className={styles.image__button} + type="button" + onClick={() => onClickImage(menu.image_urls, 0)} + > + <img src={`${menu.image_urls[0]}`} alt={`${menu.name}`} /> + </button> + </div> + ) : ( + <div className={styles['empty-image']}> + <img + src="https://static.koreatech.in/assets/img/mainlogo2.png" + alt="KOIN service logo" + /> + </div> + )} </div> ) : ( menu.option_prices.map((item) => ( diff --git a/src/page/MyShopPage/components/ShopInfo/ShopInfo.module.scss b/src/page/MyShopPage/components/ShopInfo/ShopInfo.module.scss index 74ea5b52..bd07a1a8 100644 --- a/src/page/MyShopPage/components/ShopInfo/ShopInfo.module.scss +++ b/src/page/MyShopPage/components/ShopInfo/ShopInfo.module.scss @@ -138,14 +138,12 @@ margin-right: 8px; box-sizing: border-box; padding: 10px; - width: auto; - height: 24px; border: 1px solid #f7941e; border-radius: 13.5px; color: #f7941e; font-family: "Noto Sans", sans-serif; font-weight: 500; - font-size: 12px; + font-size: 10px; justify-content: center; display: flex; align-items: center; @@ -157,13 +155,12 @@ box-sizing: border-box; padding: 10px; width: auto; - height: 24px; border: 1px solid #f7941e; border-radius: 13.5px; color: #f7941e; font-family: "Noto Sans", sans-serif; font-weight: 500; - font-size: 12px; + font-size: 10px; justify-content: center; display: flex; align-items: center; diff --git a/src/page/MyShopPage/index.tsx b/src/page/MyShopPage/index.tsx index c23058dc..05c11e50 100644 --- a/src/page/MyShopPage/index.tsx +++ b/src/page/MyShopPage/index.tsx @@ -5,12 +5,12 @@ import { Link, useNavigate } from 'react-router-dom'; import useBooleanState from 'utils/hooks/useBooleanState'; import { useEffect, useState } from 'react'; import cn from 'utils/ts/className'; -import { Portal } from 'component/common/Modal/PortalProvider'; -import useModalPortal from 'utils/hooks/useModalPortal'; import showToast from 'utils/ts/showToast'; -import ImageModal from 'component/common/Modal/ImageModal'; import useLogger from 'utils/hooks/useLogger'; import ROUTES from 'static/routes'; +import { Button } from 'page/Auth/components/Common/form'; +import EditEventIcon from 'assets/svg/myshop/edit-event-icon.svg?react'; +import { useClickImage } from 'utils/hooks/useClickImage'; import CatagoryMenuList from './components/CatagoryMenuList'; import ShopInfo from './components/ShopInfo'; import styles from './MyShopPage.module.scss'; @@ -35,6 +35,7 @@ export default function MyShopPage() { setFalse: closeEditShopInfoModal, value: isEditShopInfoModalOpen, } = useBooleanState(false); + const { onClickImage } = useClickImage(); const logger = useLogger(); @@ -45,7 +46,6 @@ export default function MyShopPage() { } const [tapType, setTapType] = useState('메뉴'); - const portalManager = useModalPortal(); useEffect(() => { refetchShopData(); @@ -57,12 +57,6 @@ export default function MyShopPage() { } }, [shopData, navigate, isLoading]); - const onClickImage = (img: string[], index: number) => { - portalManager.open((portalOption: Portal) => ( - <ImageModal imageList={img} imageIndex={index} onClose={portalOption.close} /> - )); - }; - if (isMobile && shopData && isEditShopInfoModalOpen) { return ( <EditShopInfoModal @@ -133,6 +127,17 @@ export default function MyShopPage() { 이벤트/공지 </button> </div> + <div className={styles['edit-wrapper']}> + <Button + info + onClick={() => navigate(ROUTES.Owner.EditMenu())} + > + <div className={styles.center}> + <EditEventIcon /> + 메뉴 편집하기 + </div> + </Button> + </div> {tapType === '메뉴' ? ( menusData && menusData.menu_categories.length > 0 && ( <MenuTable diff --git a/src/static/routes.ts b/src/static/routes.ts index 40e37146..7f92cd62 100644 --- a/src/static/routes.ts +++ b/src/static/routes.ts @@ -15,6 +15,7 @@ const ROUTES = { Root: () => '/owner', ShopRegistration: () => '/owner/shop-registration', AddMenu: () => '/owner/add-menu', + EditMenu: () => '/owner/edit-menu', ModifyMenu: ({ id, isLink }: ROUTESParams<'id'>) => (isLink ? `/owner/modify-menu/${id}` : '/owner/modify-menu/:id'), ModifyInfo: () => '/owner/modify-info', MenuManagement: () => '/owner/menu-management', diff --git a/src/utils/constant/category.ts b/src/utils/constant/category.ts index 9c644974..16acb9d5 100644 --- a/src/utils/constant/category.ts +++ b/src/utils/constant/category.ts @@ -35,6 +35,20 @@ export const CATEGORY_OWNER: HeaderCategory[] = [ planFlag: true, tag: null, }, + { + title: '메뉴 편집', + link: ROUTES.Owner.EditMenu(), + newFlag: true, + planFlag: true, + tag: null, + }, + { + title: '메뉴 추가', + link: ROUTES.Owner.AddMenu(), + newFlag: true, + planFlag: true, + tag: null, + }, // { // title: '메뉴관리', // link: '/owner/menu-management', diff --git a/src/utils/hooks/useClickImage.tsx b/src/utils/hooks/useClickImage.tsx new file mode 100644 index 00000000..79cd72eb --- /dev/null +++ b/src/utils/hooks/useClickImage.tsx @@ -0,0 +1,15 @@ +import { Portal } from 'component/common/Modal/PortalProvider'; +import ImageModal from 'component/common/Modal/ImageModal'; +import useModalPortal from 'utils/hooks/useModalPortal'; + +export const useClickImage = () => { + const portalManager = useModalPortal(); + + const onClickImage = (img: string[], index: number) => { + portalManager.open((portalOption: Portal) => ( + <ImageModal imageList={img} imageIndex={index} onClose={portalOption.close} /> + )); + }; + + return { onClickImage }; +};