Skip to content

Commit

Permalink
Feat: 공통 모달 컴포넌트 구현 및 약관 동의 UI 구현 (#17)
Browse files Browse the repository at this point in the history
* feat: 약관 동의 스키마 및 타입 작성

* feat: 모달 렌더링용 루트 div 생성

* chore: 필요 아이콘 추가

* style: 테일윈드 변수 및 컴포넌트 스타일 최신화

* refactor: 응답 데이터에 맞는 상수 변환

* refactor: 타이머훅 파라미터 사용

* feat: 모달 컨텍스트, 컴포넌트 로직 작성

* feat: 약관 동의 모달 작성 및 체크박스 구현

* feat: 모달 적용

* design: 인증코드 받기 버튼 스타일 변경

* feat: 버셀 레이아웃 경로 재설정

* feat: 랜딩페이지 index 형태로 재설정

* feat: gender 타입 및 스키마 변경

* refactor: 모달 겹치는 로직 통합

* refactor: 반복되는 로직 매핑

* feat: 인증번호 요청 시 이메일 데이터 동봉

* feat: 상수 파일 추가

* fix: gender 선택시 value 오류 문제 해결
  • Loading branch information
WooGi1020 authored Jan 8, 2025
1 parent 0a8ec1b commit 5dc75a7
Show file tree
Hide file tree
Showing 22 changed files with 379 additions and 41 deletions.
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</head>
<body>
<div id="root"></div>
<div id="modal-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
21 changes: 12 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import Layout from './pages/Layout';
import LandingPage from '@/pages/landing/index';
import SignUpPage from './pages/sign-up';
import { ModalProvider } from './contexts/ModalContext';

function App() {
const queryClient = new QueryClient();
Expand All @@ -12,17 +13,19 @@ function App() {
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<BrowserRouter>
<Routes>
{/* 네비바 포함안됨 */}
<Route path="/" element={<LandingPage />} />
<Route path="/signup/*" element={<SignUpPage />} />
<ModalProvider>
<Routes>
{/* 네비바 포함안됨 */}
<Route index element={<LandingPage />} />
<Route path="/signup/*" element={<SignUpPage />} />

{/* 네비바 포함함 */}
<Route path="/*" element={<Layout />}>
{/* <Route path="dashboard" element={<Dashboard />} />
{/* 네비바 포함함 */}
<Route path="/*" element={<Layout />}>
{/* <Route path="dashboard" element={<Dashboard />} />
<Route path="profile" element={<Profile />} /> */}
</Route>
</Routes>
</Route>
</Routes>
</ModalProvider>
</BrowserRouter>
</QueryClientProvider>
);
Expand Down
3 changes: 3 additions & 0 deletions src/assets/icon/agreeLinkIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions src/components/commons/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ const Button = ({
// variantStyle 조건부 설정
if (variant === 'primary') {
variantStyle =
'bg-primary rounded-[10px] font-semibold text-[16px] p-3 text-neutral';
'bg-primary h-[50px] rounded-common font-semibold text-button text-neutral';
} else if (variant === 'secondary') {
variantStyle =
'bg-transparent rounded-[10px] font-semibold text-[16px] p-3 text-white';
'bg-transparent h-[50px] rounded-common font-semibold text-button text-textLightGray';
} else if (variant === 'icon') {
variantStyle = 'bg-transparent';
}
Expand Down
66 changes: 66 additions & 0 deletions src/components/commons/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Controller, Control, FieldValues, Path } from 'react-hook-form';

interface BaseCheckboxProps {
label?: string;
}

interface ControlledCheckboxProps<T extends FieldValues>
extends BaseCheckboxProps {
control: Control<T>;
name: Path<T>;
onChange?: never;
checked?: never;
}

interface UncontrolledCheckboxProps extends BaseCheckboxProps {
control?: never;
name?: never;
onChange: (checked: boolean) => void;
checked: boolean;
}

type CheckboxProps<T extends FieldValues> =
| ControlledCheckboxProps<T>
| UncontrolledCheckboxProps;

function Checkbox<T extends FieldValues>(props: CheckboxProps<T>) {
const CheckboxContent = ({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) => (
<label
className="flex items-center cursor-pointer gap-2 w-full"
onClick={() => onChange(!checked)}
>
<div className="w-5 h-5 border-2 rounded-full flex items-center justify-center">
{checked && <div className="w-4 h-4 bg-primary rounded-full" />}
</div>
{props.label && (
<span
className={`${'control' in props ? 'font-medium text-body' : 'font-semibold text-captionHeader'}`}
>
{props.label}
</span>
)}
</label>
);

if ('control' in props && props.control) {
return (
<Controller
control={props.control}
name={props.name}
render={({ field }) => (
<CheckboxContent checked={field.value} onChange={field.onChange} />
)}
/>
);
}

return <CheckboxContent checked={props.checked} onChange={props.onChange} />;
}

export default Checkbox;
4 changes: 2 additions & 2 deletions src/components/commons/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function Input<T extends FieldValues>({
<>
<label
htmlFor={`${name}-${value}`}
className={`${field.value === value ? 'text-black' : 'text-white'} font-medium text-[14px] ${labelClassName}`}
className={`${field.value === value ? 'text-black' : 'text-white'} font-medium text-body ${labelClassName}`}
>
{label}
</label>
Expand Down Expand Up @@ -64,7 +64,7 @@ function Input<T extends FieldValues>({
id={name.toString()}
type={type}
placeholder={placeholder}
className={`border outline-none border-[#787272] bg-transparent rounded-[10px] p-3 text-white placeholder:text-[14px] ${className ? className : ''}`}
className={`border outline-none border-textDarkGray bg-transparent rounded-common p-3 pl-4 text-textDarkGray placeholder:text-body ${className ? className : ''}`}
{...field}
/>
{fieldState.error && (
Expand Down
99 changes: 99 additions & 0 deletions src/components/modal/AgreementModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Modal from '@/components/modal';
import Button from '../commons/Button';
import Checkbox from '../commons/Checkbox';
import AgreeLinkIcon from '../../../src/assets/icon/agreeLinkIcon.svg?react';
import { AgreementsTypes } from 'gachTaxi-types';
import { useForm, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { agreementsSchema } from '@/libs/schemas/auth';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { useModal } from '@/contexts/ModalContext';
import { AGREE_VALUES } from '@/constants';

const AgreementModal = () => {
const navigate = useNavigate();
const { closeModal } = useModal();

const agreementForm = useForm<z.infer<typeof agreementsSchema>>({
resolver: zodResolver(agreementsSchema),
defaultValues: {
termsAgreement: false,
privacyAgreement: false,
marketingAgreement: false,
},
mode: 'onSubmit',
});

const agreements = agreementForm.watch([
'termsAgreement',
'privacyAgreement',
'marketingAgreement',
]);

const isAllAgreed = agreements.every(Boolean);

const handleAllAgree = (checked: boolean) => {
agreementForm.setValue('termsAgreement', checked);
agreementForm.setValue('privacyAgreement', checked);
agreementForm.setValue('marketingAgreement', checked);
};

const handleSubmitToAgreement: SubmitHandler<AgreementsTypes> = (
data: AgreementsTypes,
) => {
console.log(data);
navigate('/signup/user-info');
closeModal();
};

return (
<>
<Modal.Header className="mt-vertical">
<h1 className="text-header font-bold">
가치 택시를 이용하기 위해 <br /> 약관에 동의해주세요
</h1>
</Modal.Header>
<Modal.Content className="mb-0">
<form
onSubmit={agreementForm.handleSubmit(handleSubmitToAgreement)}
className="flex flex-col gap-4"
>
<div className="p-3 border rounded-common flex items-center gap-2">
<Checkbox
checked={isAllAgreed}
onChange={handleAllAgree}
label="약관 모두 동의"
/>
</div>
{AGREE_VALUES.map((values) => {
return (
<div
key={values.name}
className="flex items-center justify-between"
>
<Checkbox
control={agreementForm.control}
name={values.name}
label={values.label}
/>
<Button variant="icon">
<AgreeLinkIcon />
</Button>
</div>
);
})}
{(agreementForm.formState.errors.privacyAgreement ||
agreementForm.formState.errors.termsAgreement) && (
<p className="text-red-500">필수 약관에 동의해주세요!</p>
)}
<Button className="w-full mt-vertical" type="submit">
시작하기
</Button>
</form>
</Modal.Content>
</>
);
};

export default AgreementModal;
45 changes: 45 additions & 0 deletions src/components/modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';

interface ModalProps {
children: ReactNode;
}

export const Modal = ({ children }: ModalProps) => {
return createPortal(
<div
role="dialog"
className={`flex flex-col gap-[16px] p-[16px] h-fit max-w-[360px] w-full z-[1000] bg-secondary absolute left-1/2 -translate-x-1/2 bottom-0 rounded-t-modal text-white`}
>
{children}
</div>,
(document.getElementById('modal-root') as Element) || document.body,
);
};

Modal.Overlay = ({ onClose }: { onClose: () => void }) => {
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-[999] h-full w-screen"
onClick={onClose}
></div>
);
};

Modal.Section = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => (
<div className={`w-full h-fit mb-vertical ${className || ''}`}>
{children}
</div>
);

Modal.Header = Modal.Section;
Modal.Content = Modal.Section;
Modal.Footer = Modal.Section;

export default Modal;
13 changes: 8 additions & 5 deletions src/components/sign/AuthCodeVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,26 @@ import { authCodeVerificationSchema } from '@/libs/schemas/auth';
import { AuthCodeTypes } from 'gachTaxi-types';
import Input from '../commons/Input';
import Button from '../commons/Button';
import { useNavigate } from 'react-router-dom';
import { useModal } from '../../contexts/ModalContext';
import AgreementModal from '../modal/AgreementModal';

const AuthCodeVerification = ({ emailInfo }: { emailInfo: string }) => {
const { openModal } = useModal();

const AuthCodeVerification = () => {
const navigate = useNavigate();
const authForm = useForm<z.infer<typeof authCodeVerificationSchema>>({
resolver: zodResolver(authCodeVerificationSchema),
defaultValues: {
authCode: '',
email: emailInfo!,
},
mode: 'onSubmit',
});

const handleSubmitToAuth: SubmitHandler<AuthCodeTypes> = (data) => {
// API 구현 시 추가 구현
alert('인증번호 입력 성공!');
navigate('/signup/user-info');
console.table(data);
// 약관 동의 모달 오픈 로직
openModal(<AgreementModal />);
};

return (
Expand Down
8 changes: 5 additions & 3 deletions src/components/sign/EmailVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ const TIMER_DURATION = 300;
const EmailVerification = ({
isEmailVerified,
setIsEmailVerified,
setEmailInfo,
}: {
isEmailVerified: boolean;
setIsEmailVerified: (value: boolean) => void;
setEmailInfo: (value: string) => void;
}) => {
const { timer, startTimer } = useVerificationTimer(TIMER_DURATION);
const signUpForm = useForm<z.infer<typeof emailVerificationSchema>>({
Expand All @@ -31,8 +33,8 @@ const EmailVerification = ({
data,
) => {
// 이메일 전송 로직 구현 (예: API 호출)
alert('이메일 보내기 성공!');
setIsEmailVerified(true);
setEmailInfo(data.email);
startTimer(); // 타이머 시작
console.table(data);
};
Expand All @@ -50,9 +52,9 @@ const EmailVerification = ({
placeholder="gachon.ac 이메일을 입력해주세요"
/>
<Button
variant={isEmailVerified ? 'secondary' : 'primary'}
variant="primary"
type="submit"
className={`mt-3 ${isEmailVerified && 'border border-white'}`}
className="mt-3"
disabled={timer === 0 && isEmailVerified} // 타이머 종료 시 버튼 비활성화
>
{isEmailVerified
Expand Down
8 changes: 4 additions & 4 deletions src/components/sign/userInfoVerification/GenderSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,29 @@ const GenderSelect = ({ control, gender }: GenderSelectProps) => {
<div className="flex items-center w-full h-[50px] border border-[#787272] rounded-[10px] overflow-hidden">
<div
className={`flex-1 h-full cursor-pointer ${
gender === 'male' ? 'bg-primary' : 'text-[#787272]'
gender === 'MALE' ? 'bg-primary text-textDarkGray' : 'text-white'
}`}
>
<Input
control={control}
name="gender"
type="radio"
value="male"
value="MALE"
label="남"
labelClassName={labelStyle}
className="hidden"
/>
</div>
<div
className={`flex-1 h-full cursor-pointer ${
gender === 'female' ? 'bg-primary' : 'text-[#787272]'
gender === 'FEMALE' ? 'bg-primary text-textDarkGray' : 'text-white'
}`}
>
<Input
control={control}
name="gender"
type="radio"
value="female"
value="FEMALE"
label="여"
labelClassName={labelStyle}
className="hidden"
Expand Down
Loading

0 comments on commit 5dc75a7

Please sign in to comment.