diff --git a/src/features/index.ts b/src/features/index.ts index ab7f184..0de3346 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -1,2 +1,3 @@ export * from './main'; export * from './login'; +export * from './realtor-certification'; diff --git a/src/features/realtor-certification/components/common/CustomFileUpload.tsx b/src/features/realtor-certification/components/common/CustomFileUpload.tsx new file mode 100644 index 0000000..1ec9713 --- /dev/null +++ b/src/features/realtor-certification/components/common/CustomFileUpload.tsx @@ -0,0 +1,143 @@ +import { useRef } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { cn } from '@/shared'; + +import { type RealtorCertificationFormData } from '../../model'; + +interface CustomFileUploadProps { + name: keyof RealtorCertificationFormData; + label?: string; + placeholder?: string; + className?: string; + disabled?: boolean; + error?: string; +} + +export const CustomFileUpload = ({ + name, + label, + placeholder = '파일을 선택하거나 여기에 드래그하세요', + className, + disabled, + error, +}: CustomFileUploadProps) => { + const form = useFormContext(); + const fileInputRef = useRef(null); + + const updateFile = (file: File | null) => { + form.setValue(name, file as RealtorCertificationFormData[keyof RealtorCertificationFormData]); + form.trigger(name); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] || null; + updateFile(file); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.currentTarget.classList.add('border-blue-500', 'bg-blue-50'); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + event.currentTarget.classList.remove('border-blue-500', 'bg-blue-50'); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + event.currentTarget.classList.remove('border-blue-500', 'bg-blue-50'); + + const file = event.dataTransfer.files[0] || null; + updateFile(file); + }; + + const handleClick = () => { + fileInputRef.current?.click(); + }; + + const selectedFile = form.watch(name) as File | null; + + return ( +
+ {label && } + +
+ + + {selectedFile ? ( +
+
+ + + +
+
+

{selectedFile.name}

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+
+

클릭하여 다른 파일 선택

+
+ ) : ( +
+
+ + + +
+
+

+ 클릭하여 파일 선택 + 또는 드래그 앤 드롭 +

+

{placeholder}

+
+
지원 형식: PDF, JPG, PNG, WEBP (최대 10MB)
+
+ )} +
+ + {error &&

{error}

} +
+ ); +}; diff --git a/src/features/realtor-certification/components/common/index.ts b/src/features/realtor-certification/components/common/index.ts new file mode 100644 index 0000000..39b1e32 --- /dev/null +++ b/src/features/realtor-certification/components/common/index.ts @@ -0,0 +1 @@ +export * from './CustomFileUpload'; diff --git a/src/features/realtor-certification/components/features/FileUploadField.tsx b/src/features/realtor-certification/components/features/FileUploadField.tsx new file mode 100644 index 0000000..3a511dc --- /dev/null +++ b/src/features/realtor-certification/components/features/FileUploadField.tsx @@ -0,0 +1,28 @@ +import { useFormContext } from 'react-hook-form'; + +import { FormField, FormItem, FormMessage } from '@/shared'; + +import { type RealtorCertificationFormData } from '../../model'; +import { CustomFileUpload } from '../common'; + +export const FileUploadField = () => { + const form = useFormContext(); + + return ( + ( + + + + + )} + /> + ); +}; diff --git a/src/features/realtor-certification/components/features/index.ts b/src/features/realtor-certification/components/features/index.ts new file mode 100644 index 0000000..b52cf34 --- /dev/null +++ b/src/features/realtor-certification/components/features/index.ts @@ -0,0 +1 @@ +export * from './FileUploadField'; diff --git a/src/features/realtor-certification/components/index.ts b/src/features/realtor-certification/components/index.ts new file mode 100644 index 0000000..1aacf69 --- /dev/null +++ b/src/features/realtor-certification/components/index.ts @@ -0,0 +1,2 @@ +export * from './common'; +export * from './features'; diff --git a/src/features/realtor-certification/constants/certification-file.ts b/src/features/realtor-certification/constants/certification-file.ts new file mode 100644 index 0000000..4d6560a --- /dev/null +++ b/src/features/realtor-certification/constants/certification-file.ts @@ -0,0 +1,11 @@ +// 파일 크기 제한 (10MB) +export const MAX_FILE_SIZE_10MB = 10 * 1024 * 1024; + +// 허용되는 파일 타입 +export const ACCEPTED_FILE_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', +]; diff --git a/src/features/realtor-certification/constants/index.ts b/src/features/realtor-certification/constants/index.ts new file mode 100644 index 0000000..c217c59 --- /dev/null +++ b/src/features/realtor-certification/constants/index.ts @@ -0,0 +1 @@ +export * from './certification-file'; diff --git a/src/features/realtor-certification/index.ts b/src/features/realtor-certification/index.ts new file mode 100644 index 0000000..5ecdd1f --- /dev/null +++ b/src/features/realtor-certification/index.ts @@ -0,0 +1 @@ +export * from './ui'; diff --git a/src/features/realtor-certification/model/index.ts b/src/features/realtor-certification/model/index.ts new file mode 100644 index 0000000..e27a6e2 --- /dev/null +++ b/src/features/realtor-certification/model/index.ts @@ -0,0 +1 @@ +export * from './schema'; diff --git a/src/features/realtor-certification/model/schema/index.ts b/src/features/realtor-certification/model/schema/index.ts new file mode 100644 index 0000000..7c2c56a --- /dev/null +++ b/src/features/realtor-certification/model/schema/index.ts @@ -0,0 +1 @@ +export * from './realtor-certification.schema'; diff --git a/src/features/realtor-certification/model/schema/realtor-certification.schema.ts b/src/features/realtor-certification/model/schema/realtor-certification.schema.ts new file mode 100644 index 0000000..7c82d3a --- /dev/null +++ b/src/features/realtor-certification/model/schema/realtor-certification.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import { ACCEPTED_FILE_TYPES, MAX_FILE_SIZE_10MB } from '../../constants'; + +export const realtorCertificationSchema = z.object({ + certificateFile: z + .instanceof(File, { message: '인증서 파일을 업로드해주세요.' }) + .refine((file) => file.size <= MAX_FILE_SIZE_10MB, '파일 크기는 10MB 이하여야 합니다.') + .refine( + (file) => ACCEPTED_FILE_TYPES.includes(file.type), + 'PDF, JPG, PNG, WEBP 파일만 업로드 가능합니다.', + ) + .refine((file) => file.name.trim().length > 0, '파일명이 비어있습니다.'), +}); + +export type RealtorCertificationFormData = z.infer; diff --git a/src/features/realtor-certification/ui/CertificationForm.tsx b/src/features/realtor-certification/ui/CertificationForm.tsx new file mode 100644 index 0000000..44984c2 --- /dev/null +++ b/src/features/realtor-certification/ui/CertificationForm.tsx @@ -0,0 +1,55 @@ +import { useForm } from 'react-hook-form'; + +import { zodResolver } from '@hookform/resolvers/zod'; + +import { Button, Form } from '@/shared'; + +import { FileUploadField } from '../components'; +import { type RealtorCertificationFormData, realtorCertificationSchema } from '../model'; + +type props = { + onSuccess?: () => void; +}; + +export const CertificationForm = ({ onSuccess }: props) => { + const form = useForm({ + resolver: zodResolver(realtorCertificationSchema), + defaultValues: { + certificateFile: undefined, + }, + }); + + const onSubmit = async (data: RealtorCertificationFormData) => { + try { + // TODO: API 호출 로직 구현 + console.log('제출할 데이터:', data); + + // API 호출 시뮬레이션 + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // 제출 성공 시 상태 변경 + onSuccess?.(); + } catch (error) { + console.error('제출 실패:', error); + // TODO: 에러 처리 로직 + } + }; + + return ( +
+ e.preventDefault()}> +
+ + +
+
+ + ); +}; diff --git a/src/features/realtor-certification/ui/SubmitSuccessSection.tsx b/src/features/realtor-certification/ui/SubmitSuccessSection.tsx new file mode 100644 index 0000000..0abc378 --- /dev/null +++ b/src/features/realtor-certification/ui/SubmitSuccessSection.tsx @@ -0,0 +1,20 @@ +import { useNavigate } from 'react-router-dom'; + +import { Button, ROUTER_PATH } from '@/shared'; + +export const SubmitSuccessSection = () => { + const navigate = useNavigate(); + + return ( +
+
+

공인중개사 인증서가 제출되었습니다!

+

빠른 내부 심사 후 중개사 자격을 드릴게요!

+
+ + +
+ ); +}; diff --git a/src/features/realtor-certification/ui/TitleSection.tsx b/src/features/realtor-certification/ui/TitleSection.tsx new file mode 100644 index 0000000..fe9b8a8 --- /dev/null +++ b/src/features/realtor-certification/ui/TitleSection.tsx @@ -0,0 +1,8 @@ +export const TitleSection = () => { + return ( +

+ 공인중개사 인증하고 + 간편하고 투명하게 계약해요! +

+ ); +}; diff --git a/src/features/realtor-certification/ui/index.ts b/src/features/realtor-certification/ui/index.ts new file mode 100644 index 0000000..ee3264e --- /dev/null +++ b/src/features/realtor-certification/ui/index.ts @@ -0,0 +1,3 @@ +export * from './CertificationForm'; +export * from './TitleSection'; +export * from './SubmitSuccessSection'; diff --git a/src/pages/realtor/certification/RealtorCertificationPage.tsx b/src/pages/realtor/certification/RealtorCertificationPage.tsx index 242996d..36379cb 100644 --- a/src/pages/realtor/certification/RealtorCertificationPage.tsx +++ b/src/pages/realtor/certification/RealtorCertificationPage.tsx @@ -1,3 +1,22 @@ +import { useState } from 'react'; + +import { CertificationForm, SubmitSuccessSection, TitleSection } from '@/features'; +import { AuthLogoSection } from '@/shared'; + export default function RealtorCertificationPage() { - return
RealtorCertificationPage
; + const [isSubmitted, setIsSubmitted] = useState(false); + + return ( +
+
+ + {!isSubmitted && } + {isSubmitted ? ( + + ) : ( + setIsSubmitted(true)} /> + )} +
+
+ ); } diff --git a/src/shared/components/features/footer/AuthFooter.tsx b/src/shared/components/features/footer/AuthFooter.tsx deleted file mode 100644 index cdeb1d8..0000000 --- a/src/shared/components/features/footer/AuthFooter.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const AuthFooter = () => { - return ( -
- Footer -
- ); -}; diff --git a/src/shared/components/features/footer/index.ts b/src/shared/components/features/footer/index.ts deleted file mode 100644 index fd67ded..0000000 --- a/src/shared/components/features/footer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './AuthFooter'; diff --git a/src/shared/components/features/layout/Footer.tsx b/src/shared/components/features/layout/Footer.tsx deleted file mode 100644 index 199ef7b..0000000 --- a/src/shared/components/features/layout/Footer.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { FooterType } from '../../../types'; -import { AuthFooter } from '../footer'; - -type Props = { - type: FooterType; -}; - -type FooterComponentMap = { - Main: null; - Auth: typeof AuthFooter; -}; - -const FOOTER_COMPONENTS: FooterComponentMap = { - Main: null, - Auth: AuthFooter, -} as const; - -export const Footer = ({ type }: Props) => { - const Component = FOOTER_COMPONENTS[type]; - - return Component === null ? null : ; -}; diff --git a/src/shared/components/features/layout/index.ts b/src/shared/components/features/layout/index.ts index 13ce3a8..266dec8 100644 --- a/src/shared/components/features/layout/index.ts +++ b/src/shared/components/features/layout/index.ts @@ -1,2 +1 @@ export * from './Header'; -export * from './Footer'; diff --git a/src/shared/types/layout.type.ts b/src/shared/types/layout.type.ts index 2fccf0a..d3029d2 100644 --- a/src/shared/types/layout.type.ts +++ b/src/shared/types/layout.type.ts @@ -5,8 +5,3 @@ export type LayoutType = { }; export type HeaderType = LayoutType[keyof LayoutType]; -export type FooterType = Exclude; - -export type LayoutToFooterMap = { - [K in keyof LayoutType]: K extends 'Realtor' ? 'Main' : LayoutType[K]; -}; diff --git a/src/widgets/layout/Layout.tsx b/src/widgets/layout/Layout.tsx index 9f6b12b..ae0e0f4 100644 --- a/src/widgets/layout/Layout.tsx +++ b/src/widgets/layout/Layout.tsx @@ -1,17 +1,11 @@ import { Outlet, useMatches } from 'react-router-dom'; -import { Footer, Header, type LayoutToFooterMap, type LayoutType, ScrollToTop } from '@/shared'; +import { Header, type LayoutType, ScrollToTop } from '@/shared'; type PageHandle = { layout?: keyof LayoutType; }; -const layoutToFooterMap: LayoutToFooterMap = { - Main: 'Main', - Auth: 'Auth', - Realtor: 'Main', -} as const; - export const Layout = () => { const matches = useMatches(); @@ -27,7 +21,6 @@ export const Layout = () => { -