Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: 공통 모달 컴포넌트 구현 및 약관 동의 UI 구현 #17

Merged
merged 18 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 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
Loading