Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
11 commits
Select commit Hold shift + click to select a range
66a4fa6
feat: ์ธ์ฆ ํŒŒ์ผ ๊ด€๋ จ ์ƒ์ˆ˜ ์ถ”๊ฐ€ (ํŒŒ์ผ ํฌ๊ธฐ ์ œํ•œ ๋ฐ ํ—ˆ์šฉ ํŒŒ์ผ ํƒ€์ž…)
Dobbymin Aug 19, 2025
0dbb9c6
feat: ๋ถ€๋™์‚ฐ ์ธ์ฆ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ ๋ฐ ํŒŒ์ผ ์—…๋กœ๋“œ ์ปดํฌ๋„ŒํŠธ ๊ตฌํ˜„
Dobbymin Aug 19, 2025
fbc61e5
feat: ๋ถ€๋™์‚ฐ ์ธ์ฆ ์Šคํ‚ค๋งˆ ๋ฐ ๊ด€๋ จ ๋ชจ๋“ˆ ์ถ”๊ฐ€
Dobbymin Aug 19, 2025
a472a81
feat: ์ธ์ฆ์„œ ํŒŒ์ผ ์—…๋กœ๋“œ ํ•„๋“œ ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€
Dobbymin Aug 19, 2025
b28454c
feat: ๋ถ€๋™์‚ฐ ์ธ์ฆ์„œ ์ œ์ถœ ํผ ๋ฐ ์„ฑ๊ณต ์„น์…˜ ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€
Dobbymin Aug 19, 2025
d263898
feat: ๊ณต์ธ์ค‘๊ฐœ์‚ฌ ์ธ์ฆ์„œ ์ œ์ถœ ์„ฑ๊ณต ์„น์…˜ ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€
Dobbymin Aug 19, 2025
94d70c5
feat: ๊ณต์ธ์ค‘๊ฐœ์‚ฌ ์ธ์ฆ ์„น์…˜ ์ œ๋ชฉ ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€
Dobbymin Aug 19, 2025
12a2104
feat: ๊ณต์ธ์ค‘๊ฐœ์‚ฌ ์ธ์ฆ ํŽ˜์ด์ง€์— ์ œ์ถœ ์ƒํƒœ ๊ด€๋ฆฌ ๋ฐ ์ปดํฌ๋„ŒํŠธ ๊ตฌ์„ฑ ์ถ”๊ฐ€
Dobbymin Aug 19, 2025
7a26cec
fix: ๊ณต์ธ์ค‘๊ฐœ์‚ฌ ์ธ์ฆ ํผ์—์„œ ์ œ์ถœ ์ƒํƒœ ๊ด€๋ฆฌ ๋ฐ ์„ฑ๊ณต ์„น์…˜ ์ œ๊ฑฐ
Dobbymin Aug 19, 2025
c358b93
feat: ์ธ์ฆ์„œ ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ์ถ”๊ฐ€
Dobbymin Aug 19, 2025
94a1534
remove: ํ‘ธํ„ฐ ๊ด€๋ จ ์ปดํฌ๋„ŒํŠธ ๋ฐ ํƒ€์ž… ์‚ญ์ œ
Dobbymin Aug 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/features/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './main';
export * from './login';
export * from './realtor-certification';
Original file line number Diff line number Diff line change
@@ -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<RealtorCertificationFormData>();
const fileInputRef = useRef<HTMLInputElement>(null);

const updateFile = (file: File | null) => {
form.setValue(name, file as RealtorCertificationFormData[keyof RealtorCertificationFormData]);
form.trigger(name);
};

const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={cn('w-full', className)}>
{label && <label className='mb-2 block text-lg font-medium text-gray-700'>{label}</label>}

<div
className={cn(
'relative flex min-h-[120px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 transition-colors hover:border-gray-400 hover:bg-gray-100',
error && 'border-red-300 bg-red-50',
disabled && 'cursor-not-allowed opacity-50',
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={!disabled ? handleClick : undefined}
>
<input
ref={fileInputRef}
type='file'
accept='.pdf,.jpg,.jpeg,.png,.webp'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

accept ์†์„ฑ์— ํŒŒ์ผ ํ˜•์‹์ด ํ•˜๋“œ์ฝ”๋”ฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. src/features/realtor-certification/constants/certification-file.ts์— ์ •์˜๋œ ACCEPTED_FILE_TYPES ์ƒ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์ƒ์ˆ˜์™€ ์‹ค์ œ ํ—ˆ์šฉ ํŒŒ์ผ ํ˜•์‹์ด ํ•ญ์ƒ ๋™๊ธฐํ™”๋˜์–ด ์œ ์ง€๋ณด์ˆ˜์„ฑ์ด ํ–ฅ์ƒ๋ฉ๋‹ˆ๋‹ค.

๋จผ์ € ์ƒ์ˆ˜๋ฅผ import ํ•˜์„ธ์š”:

import { ACCEPTED_FILE_TYPES } from '../../constants';

๊ทธ๋Ÿฐ ๋‹ค์Œ accept ์†์„ฑ์„ ์ˆ˜์ •ํ•˜์„ธ์š”.

Suggested change
accept='.pdf,.jpg,.jpeg,.png,.webp'
accept={ACCEPTED_FILE_TYPES.join(',')}

onChange={handleInputChange}
className='hidden'
disabled={disabled}
/>

{selectedFile ? (
<div className='flex flex-col items-center gap-2 text-center'>
<div className='rounded-full bg-green-100 p-3'>
<svg
className='size-10 text-green-600'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
</div>
<div>
<p className='text-sm font-medium text-gray-700'>{selectedFile.name}</p>
<p className='text-sm text-gray-500'>
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<p className='text-sm text-gray-400'>ํด๋ฆญํ•˜์—ฌ ๋‹ค๋ฅธ ํŒŒ์ผ ์„ ํƒ</p>
</div>
) : (
<div className='flex flex-col items-center gap-2 text-center'>
<div className='rounded-full bg-gray-200 p-5'>
<svg
className='size-10 text-gray-600'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12'
/>
</svg>
</div>
<div className='flex flex-col gap-2 py-3'>
<p className='text-md flex gap-1 font-medium text-gray-700'>
<span className='text-blue-600 hover:text-blue-500'>ํด๋ฆญํ•˜์—ฌ ํŒŒ์ผ ์„ ํƒ</span>
<span className='text-gray-500'>๋˜๋Š” ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ</span>
</p>
<p className='text-sm text-gray-500'>{placeholder}</p>
</div>
<div className='text-sm text-gray-400'>์ง€์› ํ˜•์‹: PDF, JPG, PNG, WEBP (์ตœ๋Œ€ 10MB)</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

์ง€์› ํ˜•์‹๊ณผ ์ตœ๋Œ€ ํŒŒ์ผ ํฌ๊ธฐ์— ๋Œ€ํ•œ ์•ˆ๋‚ด ๋ฌธ๊ตฌ๊ฐ€ ํ•˜๋“œ์ฝ”๋”ฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ํŒŒ์ผ ํ˜•์‹์€ ACCEPTED_FILE_TYPES์—์„œ, ์ตœ๋Œ€ ํŒŒ์ผ ํฌ๊ธฐ๋Š” MAX_FILE_SIZE_10MB์—์„œ ๊ฐ€์ ธ์™€ ๋™์ ์œผ๋กœ ํ‘œ์‹œํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด Zod ์Šคํ‚ค๋งˆ์˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๊ทœ์น™๊ณผ UI์— ํ‘œ์‹œ๋˜๋Š” ์ •๋ณด๊ฐ€ ํ•ญ์ƒ ์ผ์น˜ํ•˜๊ฒŒ ๋˜์–ด ์œ ์ง€๋ณด์ˆ˜์„ฑ์ด ํ–ฅ์ƒ๋ฉ๋‹ˆ๋‹ค.

</div>
)}
</div>

{error && <p className='mt-2 text-sm text-red-600'>{error}</p>}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './CustomFileUpload';
Original file line number Diff line number Diff line change
@@ -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<RealtorCertificationFormData>();

return (
<FormField
control={form.control}
name='certificateFile'
render={({ fieldState: { error } }) => (
<FormItem>
<CustomFileUpload
name='certificateFile'
label='์ธ์ฆ์„œ ๋“ฑ๋ก'
placeholder='๊ณต์ธ์ค‘๊ฐœ์‚ฌ ์ธ์ฆ์„œ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜์„ธ์š”'
error={error?.message}
/>
<FormMessage />
</FormItem>
)}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FileUploadField';
2 changes: 2 additions & 0 deletions src/features/realtor-certification/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './common';
export * from './features';
11 changes: 11 additions & 0 deletions src/features/realtor-certification/constants/certification-file.ts
Original file line number Diff line number Diff line change
@@ -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',
];
1 change: 1 addition & 0 deletions src/features/realtor-certification/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './certification-file';
1 change: 1 addition & 0 deletions src/features/realtor-certification/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ui';
1 change: 1 addition & 0 deletions src/features/realtor-certification/model/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './schema';
1 change: 1 addition & 0 deletions src/features/realtor-certification/model/schema/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './realtor-certification.schema';
Original file line number Diff line number Diff line change
@@ -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<typeof realtorCertificationSchema>;
55 changes: 55 additions & 0 deletions src/features/realtor-certification/ui/CertificationForm.tsx
Original file line number Diff line number Diff line change
@@ -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<RealtorCertificationFormData>({
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 (
<Form {...form}>
<form onSubmit={(e) => e.preventDefault()}>
<div className='space-y-6'>
<FileUploadField />
<Button
onClick={form.handleSubmit(onSubmit)}
type='submit'
className='h-12 w-full rounded-md text-lg'
disabled={!form.formState.isValid}
>
์ œ์ถœํ•˜๊ธฐ
</Button>
</div>
</form>
Comment on lines +40 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

ํ˜„์žฌ ํผ ์ œ์ถœ ๋กœ์ง์ด <Button>์˜ onClick ์ด๋ฒคํŠธ์— ์—ฐ๊ฒฐ๋˜์–ด ์žˆ๊ณ , <form>์˜ onSubmit์€ preventDefault๋กœ ๋ง‰ํ˜€์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ํ‚ค๋ณด๋“œ(์˜ˆ: Enter ํ‚ค)๋ฅผ ์‚ฌ์šฉํ•œ ํผ ์ œ์ถœ๊ณผ ๊ฐ™์€ ์›น ์ ‘๊ทผ์„ฑ ํ‘œ์ค€์„ ์ €ํ•ดํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. react-hook-form์˜ handleSubmit ํ•จ์ˆ˜๋ฅผ <form>์˜ onSubmit ์ด๋ฒคํŠธ์— ์ง์ ‘ ์ „๋‹ฌํ•˜๊ณ , ๋ฒ„ํŠผ์—์„œ๋Š” type="submit"๋งŒ ์‚ฌ์šฉํ•˜๋„๋ก ์ˆ˜์ •ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

      <form onSubmit={form.handleSubmit(onSubmit)}>
        <div className='space-y-6'>
          <FileUploadField />
          <Button
            type='submit'
            className='h-12 w-full rounded-md text-lg'
            disabled={!form.formState.isValid}
          >
            ์ œ์ถœํ•˜๊ธฐ
          </Button>
        </div>
      </form>

</Form>
);
};
20 changes: 20 additions & 0 deletions src/features/realtor-certification/ui/SubmitSuccessSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useNavigate } from 'react-router-dom';

import { Button, ROUTER_PATH } from '@/shared';

export const SubmitSuccessSection = () => {
const navigate = useNavigate();

return (
<div className='flex flex-col items-center justify-center gap-8 text-center'>
<div className='flex flex-col items-center justify-center gap-2 text-center'>
<h1 className='text-2xl font-semibold'>๊ณต์ธ์ค‘๊ฐœ์‚ฌ ์ธ์ฆ์„œ๊ฐ€ ์ œ์ถœ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!</h1>
<p className='text-lg text-gray-500'>๋น ๋ฅธ ๋‚ด๋ถ€ ์‹ฌ์‚ฌ ํ›„ ์ค‘๊ฐœ์‚ฌ ์ž๊ฒฉ์„ ๋“œ๋ฆด๊ฒŒ์š”!</p>
</div>

<Button className='h-12 w-2/3 rounded-md text-lg' onClick={() => navigate(ROUTER_PATH.MAIN)}>
๋ฉ”์ธ ํ™”๋ฉด์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ
</Button>
</div>
);
};
8 changes: 8 additions & 0 deletions src/features/realtor-certification/ui/TitleSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const TitleSection = () => {
return (
<h1 className='flex flex-col items-center text-[28px] font-semibold'>
<span>๊ณต์ธ์ค‘๊ฐœ์‚ฌ ์ธ์ฆํ•˜๊ณ </span>
<span>๊ฐ„ํŽธํ•˜๊ณ  ํˆฌ๋ช…ํ•˜๊ฒŒ ๊ณ„์•ฝํ•ด์š”!</span>
</h1>
);
};
3 changes: 3 additions & 0 deletions src/features/realtor-certification/ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './CertificationForm';
export * from './TitleSection';
export * from './SubmitSuccessSection';
21 changes: 20 additions & 1 deletion src/pages/realtor/certification/RealtorCertificationPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import { useState } from 'react';

import { CertificationForm, SubmitSuccessSection, TitleSection } from '@/features';
import { AuthLogoSection } from '@/shared';

export default function RealtorCertificationPage() {
return <div>RealtorCertificationPage</div>;
const [isSubmitted, setIsSubmitted] = useState(false);

return (
<div className='flex h-screen flex-col items-center justify-center'>
<div className='flex flex-col items-center gap-12'>
<AuthLogoSection />
{!isSubmitted && <TitleSection />}
{isSubmitted ? (
<SubmitSuccessSection />
) : (
<CertificationForm onSuccess={() => setIsSubmitted(true)} />
)}
</div>
</div>
);
}
7 changes: 0 additions & 7 deletions src/shared/components/features/footer/AuthFooter.tsx

This file was deleted.

1 change: 0 additions & 1 deletion src/shared/components/features/footer/index.ts

This file was deleted.

22 changes: 0 additions & 22 deletions src/shared/components/features/layout/Footer.tsx

This file was deleted.

1 change: 0 additions & 1 deletion src/shared/components/features/layout/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from './Header';
export * from './Footer';
5 changes: 0 additions & 5 deletions src/shared/types/layout.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,3 @@ export type LayoutType = {
};

export type HeaderType = LayoutType[keyof LayoutType];
export type FooterType = Exclude<HeaderType, 'Realtor'>;

export type LayoutToFooterMap = {
[K in keyof LayoutType]: K extends 'Realtor' ? 'Main' : LayoutType[K];
};
Loading
Loading