로그인관련
This commit is contained in:
82
src/app/find-id/FindIdDevOption.tsx
Normal file
82
src/app/find-id/FindIdDevOption.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
type FindIdDevOptionProps = {
|
||||
doneEnabled?: boolean;
|
||||
setDoneEnabled?: (enabled: boolean) => void;
|
||||
failedEnabled?: boolean;
|
||||
setFailedEnabled?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function FindIdDevOption({
|
||||
doneEnabled,
|
||||
setDoneEnabled,
|
||||
failedEnabled,
|
||||
setFailedEnabled,
|
||||
}: FindIdDevOptionProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`fixed bottom-2 right-2 bg-red-400 cursor-pointer rounded-full w-[40px] h-[40px] shadow-xl z-100`}
|
||||
>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
className="absolute inset-0 bg-black/20 cursor-default"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-700 flex items-center justify-center"
|
||||
>
|
||||
<span className="text-sm">✕</span>
|
||||
</button>
|
||||
<ul className="flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">find-id done overlay</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="find-id done overlay 토글"
|
||||
aria-pressed={!!doneEnabled}
|
||||
onClick={() => setDoneEnabled?.(!doneEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${doneEnabled ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${doneEnabled ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">find-id failed overlay</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="find-id failed overlay 토글"
|
||||
aria-pressed={!!failedEnabled}
|
||||
onClick={() => setFailedEnabled?.(!failedEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${failedEnabled ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${failedEnabled ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
131
src/app/find-id/FindIdOption.tsx
Normal file
131
src/app/find-id/FindIdOption.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||
import Link from "next/link";
|
||||
|
||||
type FindIdOptionProps = {
|
||||
onOpenDone: (userId?: string) => void;
|
||||
onOpenFailed: () => void;
|
||||
};
|
||||
|
||||
export default function FindIdOption({ onOpenDone, onOpenFailed }: FindIdOptionProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const isNameValid = useMemo(() => name.trim().length > 0, [name]);
|
||||
const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]);
|
||||
const canSubmit = useMemo(() => isNameValid && isPhoneValid, [isNameValid, isPhoneValid]);
|
||||
|
||||
function validateAll() {
|
||||
const next: Record<string, string> = {};
|
||||
if (!isNameValid) next.name = "이름을 입력해 주세요.";
|
||||
if (!isPhoneValid) next.phone = "휴대폰 번호를 숫자만 9~11자리로 입력해 주세요.";
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!validateAll()) return;
|
||||
const mockUserId = `${name.trim()}@example.com`;
|
||||
onOpenDone(mockUserId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full relative">
|
||||
<Link
|
||||
href="/login"
|
||||
aria-label="닫기"
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-700 flex items-center justify-center"
|
||||
>
|
||||
<span className="text-sm">✕</span>
|
||||
</Link>
|
||||
<div className="my-15 flex flex-col items-center">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
||||
아이디 찾기
|
||||
</div>
|
||||
<p className="text-[18px] leading-[150%] text-[#6c7682] mt-[8px] text-center">
|
||||
가입 시 등록한 회원정보를 입력해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-[15px] font-semibold text-[#6c7682]">
|
||||
이름
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, name: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, name: false }))}
|
||||
placeholder="이름을 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{name.trim().length > 0 && focused.name && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setName("");
|
||||
}}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer">
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.name && <p className="text-error text-[13px] leading-tight">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="phone" className="text-[15px] font-semibold text-[#6c7682]">
|
||||
휴대폰 번호
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
onFocus={() => setFocused((p) => ({ ...p, phone: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, phone: false }))}
|
||||
placeholder="-없이 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{phone.trim().length > 0 && focused.phone && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setPhone("");
|
||||
}}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer">
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.phone && <p className="text-error text-[13px] leading-tight">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`h-[40px] w-full rounded-[12px] text-[18px] font-semibold text-white ${canSubmit ? "bg-active-button" : "bg-inactive-button"} cursor-pointer`}>
|
||||
다음
|
||||
</button>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
93
src/app/find-id/IdFindDone.tsx
Normal file
93
src/app/find-id/IdFindDone.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type IdFindDoneProps = {
|
||||
on: boolean;
|
||||
onClose?: () => void;
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
primaryHref?: string;
|
||||
primaryText?: string;
|
||||
userId?: string;
|
||||
secondaryHref?: string;
|
||||
secondaryText?: string;
|
||||
joinedAtText?: string;
|
||||
};
|
||||
|
||||
export default function IdFindDone({
|
||||
on,
|
||||
onClose,
|
||||
children,
|
||||
title = "아이디 확인",
|
||||
description = "회원님의 정보와 일치하는 아이디 목록입니다.",
|
||||
primaryHref = "/login",
|
||||
primaryText = "로그인",
|
||||
userId = "test@example.com",
|
||||
secondaryHref = "/reset-password",
|
||||
secondaryText = "비밀번호 재설정",
|
||||
joinedAtText = "가입일: 2025년 10월 17일",
|
||||
}: IdFindDoneProps) {
|
||||
if (!on) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<div className="absolute inset-0 overflow-auto">
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-center p-6">
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="w-full max-w-[480px] text-center">
|
||||
<div className="space-y-4">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-[18px] leading-[150%] text-[#6c7682] whitespace-pre-line">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-[60px]">
|
||||
<div className="bg-[#f9fafb] border border-neutral-40 rounded-[16px] py-[24px]">
|
||||
{userId && (
|
||||
<p className="text-[18px] font-semibold text-neutral-700">
|
||||
{userId}
|
||||
</p>
|
||||
)}
|
||||
{joinedAtText && (
|
||||
<p className="text-[16px] text-[#6c7682] mt-2">
|
||||
({joinedAtText})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[24px] flex gap-3">
|
||||
<Link
|
||||
href={secondaryHref}
|
||||
className="h-[56px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-basic-text flex items-center justify-center"
|
||||
>
|
||||
{secondaryText}
|
||||
</Link>
|
||||
<Link
|
||||
href={primaryHref}
|
||||
className="h-[56px] flex-1 rounded-[12px] bg-active-button text-white text-[18px] font-semibold flex items-center justify-center"
|
||||
>
|
||||
{primaryText}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
className="absolute inset-0 -z-10 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/app/find-id/IdFindFailed.tsx
Normal file
71
src/app/find-id/IdFindFailed.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type IdFindFailedProps = {
|
||||
on: boolean;
|
||||
onClose?: () => void;
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
primaryHref?: string;
|
||||
primaryText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 아이디 찾기 실패 단일 페이지(전체 덮기) 컴포넌트.
|
||||
* - 기본 텍스트/버튼 제공. 필요 시 children으로 완전 대체 가능.
|
||||
* - 디자인 토큰은 globals.css (@theme inline)을 사용.
|
||||
*/
|
||||
export default function IdFindFailed({
|
||||
on,
|
||||
onClose,
|
||||
children,
|
||||
title = "아이디 확인",
|
||||
description = "회원님의 정보로 가입된 아이디가 없습니다.\n회원가입을 진행해 주세요.",
|
||||
primaryHref = "/register",
|
||||
primaryText = "회원가입",
|
||||
}: IdFindFailedProps) {
|
||||
if (!on) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<div className="absolute inset-0 overflow-auto">
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-center p-6">
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="w-full max-w-[480px] text-center">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700 mb-4">
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-[18px] text-[#6c7682] mb-[60px] whitespace-pre-line">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href={primaryHref}
|
||||
className="h-[56px] flex-1 rounded-[12px] bg-active-button text-white text-[18px] font-semibold flex items-center justify-center"
|
||||
>
|
||||
{primaryText}
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
className="absolute inset-0 -z-10 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
50
src/app/find-id/page.tsx
Normal file
50
src/app/find-id/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import IdFindDone from "./IdFindDone";
|
||||
import IdFindFailed from "./IdFindFailed";
|
||||
import FindIdOption from "./FindIdOption";
|
||||
import FindIdDevOption from "./FindIdDevOption";
|
||||
|
||||
export default function FindIdPage() {
|
||||
const [isDoneOpen, setIsDoneOpen] = useState(false);
|
||||
const [isFailedOpen, setIsFailedOpen] = useState(false);
|
||||
const [foundUserId, setFoundUserId] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
||||
<IdFindDone
|
||||
on={isDoneOpen}
|
||||
userId={foundUserId}
|
||||
onClose={() => setIsDoneOpen(false)}
|
||||
/>
|
||||
<IdFindFailed
|
||||
on={isFailedOpen}
|
||||
onClose={() => setIsFailedOpen(false)}
|
||||
/>
|
||||
|
||||
<FindIdOption
|
||||
onOpenDone={(id) => {
|
||||
setFoundUserId(id);
|
||||
setIsDoneOpen(true);
|
||||
}}
|
||||
onOpenFailed={() => {
|
||||
setIsFailedOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FindIdDevOption
|
||||
doneEnabled={isDoneOpen}
|
||||
setDoneEnabled={setIsDoneOpen}
|
||||
failedEnabled={isFailedOpen}
|
||||
setFailedEnabled={setIsFailedOpen}
|
||||
/>
|
||||
|
||||
<p className="text-center py-[40px] text-[15px] text-basic-text">
|
||||
Copyright ⓒ 2025 XL LMS. All rights reserved
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode; }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="ko">
|
||||
<body className={pretendard.className}>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -28,7 +28,15 @@ export default function LoginOption({
|
||||
</button>
|
||||
{ isOpen && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg">
|
||||
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-3 right-3 inline-flex items-center justify-center rounded-full w-8 h-8 bg-gray-200 hover:bg-gray-300 text-gray-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<ul className="flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">login error modal</p>
|
||||
|
||||
@@ -7,7 +7,7 @@ import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
||||
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||
import LoginErrorModal from "./LoginErrorModal";
|
||||
import LoginOption from "@/app/login/loginoption";
|
||||
import LoginOption from "@/app/login/LoginOption";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [userId, setUserId] = useState("");
|
||||
@@ -70,7 +70,7 @@ export default function LoginPage() {
|
||||
onBlur={() => setIsUserIdFocused(false)}
|
||||
placeholder="아이디 (이메일)"
|
||||
className="
|
||||
h-[56px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40
|
||||
h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40
|
||||
focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none
|
||||
focus:appearance-none focus:border-neutral-700
|
||||
text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text
|
||||
@@ -106,7 +106,7 @@ export default function LoginPage() {
|
||||
onBlur={() => setIsPasswordFocused(false)}
|
||||
placeholder="비밀번호"
|
||||
className="
|
||||
h-[56px] px-[12px] py-[7px] rounded-[8px] w-full border border-neutral-40
|
||||
h-[40px] px-[12px] py-[7px] rounded-[8px] w-full border border-neutral-40
|
||||
focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none
|
||||
focus:appearance-none focus:border-neutral-700
|
||||
text-[18px] text-neutral-700 font-normal leading-[150%] placeholder:text-input-placeholder-text
|
||||
@@ -164,7 +164,7 @@ export default function LoginPage() {
|
||||
{/* 로그인 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
className={`h-14 w-full rounded-lg text-[16px] font-semibold text-white transition-opacity cursor-pointer mb-3 ${userId.trim().length > 0 && password.trim().length > 0 ? "bg-active-button" : "bg-inactive-button"}`}
|
||||
className={`h-[40px] w-full rounded-lg text-[16px] font-semibold text-white transition-opacity cursor-pointer mb-3 ${userId.trim().length > 0 && password.trim().length > 0 ? "bg-active-button" : "bg-inactive-button"}`}
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
@@ -180,11 +180,11 @@ export default function LoginPage() {
|
||||
<div
|
||||
className="flex items-center gap-3 text-basic-text"
|
||||
>
|
||||
<Link href="#" className="underline-offset-2">
|
||||
<Link href="/find-id" className="underline-offset-2">
|
||||
아이디 찾기
|
||||
</Link>
|
||||
<span className="h-3 w-px bg-input-border" />
|
||||
<Link href="#" className="underline-offset-2">
|
||||
<Link href="/reset-password" className="underline-offset-2">
|
||||
비밀번호 재설정
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
58
src/app/register/RegisterCodeErrorModal.tsx
Normal file
58
src/app/register/RegisterCodeErrorModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
type RegisterCodeErrorModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
message?: string;
|
||||
confirmText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 회원가입 이메일 인증번호 오류 안내 모달.
|
||||
* - open: 모달 표시 여부
|
||||
* - onClose: 닫기 핸들러
|
||||
* - message: 본문 메시지 (기본값: 인증번호 불일치 안내)
|
||||
* - confirmText: 확인 버튼 텍스트 (기본값: "확인")
|
||||
*/
|
||||
export default function RegisterCodeErrorModal({
|
||||
open,
|
||||
onClose,
|
||||
message = "인증번호가 일치하지 않습니다.\n다시 한번 확인 후 입력해 주세요.",
|
||||
confirmText = "확인",
|
||||
}: RegisterCodeErrorModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
className="absolute inset-0 bg-black/40 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="register-code-error-title"
|
||||
className="relative bg-white box-border flex flex-col items-stretch justify-start p-6 rounded-[8px] min-w-[500px] max-w-[calc(100%-48px)] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<div className="text-[18px] leading-normal font-semibold text-neutral-700 mb-8 whitespace-pre-line" id="register-code-error-title">
|
||||
{message}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-[8px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-[40px] min-w-20 px-[12px] rounded-[8px] bg-active-button text-white text-[16px] font-semibold cursor-pointer"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
80
src/app/register/RegisterDone.tsx
Normal file
80
src/app/register/RegisterDone.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type RegisterDoneProps = {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
primaryHref?: string;
|
||||
primaryText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 전체 화면을 덮는 회원가입 완료 싱글페이지 컴포넌트.
|
||||
* - open: true일 때 화면 전체를 덮어 표시
|
||||
* - children: 피그마에서 생성/복사한 컴포넌트를 그대로 주입 가능
|
||||
* - 기본 UI(title/description/primary 버튼) 제공, 필요 시 children으로 대체
|
||||
*/
|
||||
export default function RegisterDone({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title = "회원가입이 완료되었습니다.",
|
||||
description = "이제 로그인하여 서비스를 이용하실 수 있어요.",
|
||||
primaryHref = "/login",
|
||||
primaryText = "로그인 하러 가기",
|
||||
}: RegisterDoneProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<div className="absolute inset-0 overflow-auto">
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-center p-6">
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="w-full max-w-[560px] text-center">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700 mb-3">
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-[15px] text-basic-text mb-8 whitespace-pre-line">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href={primaryHref}
|
||||
className="h-[40px] flex-1 rounded-[12px] bg-active-button text-white text-[18px] font-semibold flex items-center justify-center"
|
||||
>
|
||||
{primaryText}
|
||||
</Link>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-basic-text"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
className="absolute inset-0 -z-10 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
424
src/app/register/RegisterForm.tsx
Normal file
424
src/app/register/RegisterForm.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import LoginCheckboxActiveSvg from "@/app/svgs/logincheckboxactivesvg";
|
||||
import LoginCheckboxInactiveSvg from "@/app/svgs/logincheckboxinactivesvg";
|
||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||
|
||||
type Gender = "male" | "female" | "";
|
||||
|
||||
type RegisterFormProps = {
|
||||
onOpenDone: () => void;
|
||||
onOpenCodeError: (message?: string) => void;
|
||||
};
|
||||
|
||||
export default function RegisterForm({ onOpenDone, onOpenCodeError }: RegisterFormProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [gender, setGender] = useState<Gender>("");
|
||||
const [birthdate, setBirthdate] = useState("");
|
||||
|
||||
// 이메일 인증 관련
|
||||
const [emailCodeSent, setEmailCodeSent] = useState(false);
|
||||
const [emailCode, setEmailCode] = useState("");
|
||||
const [emailCodeVerified, setEmailCodeVerified] = useState(false);
|
||||
|
||||
const [agreeAge, setAgreeAge] = useState(false);
|
||||
const [agreeTos, setAgreeTos] = useState(false);
|
||||
const [agreePrivacy, setAgreePrivacy] = useState(false);
|
||||
|
||||
const allAgree = useMemo(() => agreeAge && agreeTos && agreePrivacy, [agreeAge, agreeTos, agreePrivacy]);
|
||||
function toggleAllAgree(checked: boolean) {
|
||||
setAgreeAge(checked);
|
||||
setAgreeTos(checked);
|
||||
setAgreePrivacy(checked);
|
||||
}
|
||||
|
||||
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const isEmailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]);
|
||||
const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]);
|
||||
const isPasswordValid = useMemo(() => password.length >= 8 && password.length <= 16, [password]);
|
||||
const isPasswordConfirmValid = useMemo(() => passwordConfirm === password && passwordConfirm.length > 0, [password, passwordConfirm]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return (
|
||||
name.trim().length > 0 &&
|
||||
isPhoneValid &&
|
||||
isEmailValid &&
|
||||
isPasswordValid &&
|
||||
isPasswordConfirmValid &&
|
||||
gender !== "" &&
|
||||
birthdate.trim().length > 0 &&
|
||||
allAgree
|
||||
);
|
||||
}, [name, isPhoneValid, isEmailValid, isPasswordValid, isPasswordConfirmValid, gender, birthdate, allAgree]);
|
||||
|
||||
function validateAll() {
|
||||
const nextErrors: Record<string, string> = {};
|
||||
if (name.trim().length === 0) nextErrors.name = "이름을 입력해 주세요.";
|
||||
if (!isPhoneValid) nextErrors.phone = "휴대폰 번호를 숫자만 9~11자리로 입력해 주세요.";
|
||||
if (!isEmailValid) nextErrors.email = "올바른 이메일 형식을 입력해 주세요.";
|
||||
if (!isPasswordValid) nextErrors.password = "비밀번호는 8~16자여야 합니다.";
|
||||
if (!isPasswordConfirmValid) nextErrors.passwordConfirm = "비밀번호가 일치하지 않습니다.";
|
||||
if (gender === "") nextErrors.gender = "성별을 선택해 주세요.";
|
||||
if (birthdate.trim().length === 0) nextErrors.birthdate = "생년월일을 선택해 주세요.";
|
||||
if (!allAgree) nextErrors.agreements = "필수 약관에 모두 동의해 주세요.";
|
||||
setErrors(nextErrors);
|
||||
return Object.keys(nextErrors).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!validateAll()) return;
|
||||
onOpenDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full">
|
||||
{/* 로고 / 제목 영역 */}
|
||||
<div className="my-15 flex flex-col items-center">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
||||
회원가입
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 이름 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-[15px] font-semibold text-[#6c7682]">이름</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, name: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, name: false }))}
|
||||
placeholder="이름을 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{name.trim().length > 0 && focused.name && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setName(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.name && <p className="text-error text-[13px] leading-tight">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* 휴대폰 번호 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="phone" className="text-[15px] font-semibold text-[#6c7682]">휴대폰 번호</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
onFocus={() => setFocused((p) => ({ ...p, phone: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, phone: false }))}
|
||||
placeholder="-없이 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{phone.trim().length > 0 && focused.phone && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setPhone(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.phone && <p className="text-error text-[13px] leading-tight">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
{/* 아이디(이메일) + 인증번호 전송 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-[15px] font-semibold text-[#6c7682]">아이디 (이메일)</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, email: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
|
||||
placeholder="이메일을 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{email.trim().length > 0 && focused.email && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setEmail(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isEmailValid}
|
||||
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid ? "bg-inactive-button text-white" : "bg-gray-50 text-input-placeholder-text"}`}
|
||||
onClick={() => {
|
||||
if (!isEmailValid) return;
|
||||
alert("인증번호 전송 (가상 동작)");
|
||||
setEmailCodeSent(true);
|
||||
setEmailCode("");
|
||||
setEmailCodeVerified(false);
|
||||
}}
|
||||
>
|
||||
인증번호 전송
|
||||
</button>
|
||||
</div>
|
||||
{errors.email && <p className="text-error text-[13px] leading-tight">{errors.email}</p>}
|
||||
{emailCodeSent && (
|
||||
<div className="space-y-2" aria-expanded={emailCodeSent}>
|
||||
<label htmlFor="emailCode" className="sr-only">인증번호</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
id="emailCode"
|
||||
name="emailCode"
|
||||
inputMode="numeric"
|
||||
value={emailCode}
|
||||
onChange={(e) => {
|
||||
const onlyDigits = e.target.value.replace(/[^0-9]/g, "");
|
||||
setEmailCode(onlyDigits.slice(0, 6));
|
||||
}}
|
||||
onFocus={() => setFocused((p) => ({ ...p, emailCode: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, emailCode: false }))}
|
||||
placeholder="인증번호 6자리"
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{emailCode.trim().length > 0 && focused.emailCode && !emailCodeVerified && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setEmailCode(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={emailCodeVerified}
|
||||
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${!emailCodeVerified ? "bg-active-button text-white" : "bg-gray-50 text-input-placeholder-text"}`}
|
||||
onClick={() => {
|
||||
// 가상 검증: 6자리면 성공, 아니면 에러 모달
|
||||
if (emailCode.length !== 6) {
|
||||
onOpenCodeError();
|
||||
return;
|
||||
}
|
||||
setEmailCodeVerified(true);
|
||||
}}
|
||||
>
|
||||
{emailCodeVerified ? "인증완료" : "인증하기"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-[15px] font-semibold text-[#6c7682]">비밀번호</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, password: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, password: false }))}
|
||||
placeholder="8~16자의 영문/숫자/특수문자를 조합해서 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{password.trim().length > 0 && focused.password && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setPassword(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.password && <p className="text-error text-[13px] leading-tight">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="passwordConfirm" className="text-[15px] font-semibold text-[#6c7682]">비밀번호 확인</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, passwordConfirm: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, passwordConfirm: false }))}
|
||||
placeholder="비밀번호를 다시 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{passwordConfirm.trim().length > 0 && focused.passwordConfirm && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setPasswordConfirm(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.passwordConfirm && <p className="text-error text-[13px] leading-tight">{errors.passwordConfirm}</p>}
|
||||
</div>
|
||||
|
||||
{/* 성별 */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-[15px] font-semibold text-[#6c7682]">성별</div>
|
||||
<div className="flex items-center gap-5 h-[40px]">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none text-[15px] text-neutral-700">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value="male"
|
||||
checked={gender === "male"}
|
||||
onChange={() => setGender("male")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`inline-block rounded-full size-[18px] border ${gender === "male" ? "border-active-button" : "border-[#8c95a1]"}`}>
|
||||
{gender === "male" && <span className="block size-[9px] rounded-full bg-active-button m-[4.5px]" />}
|
||||
</span>
|
||||
남성
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none text-[15px] text-neutral-700">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value="female"
|
||||
checked={gender === "female"}
|
||||
onChange={() => setGender("female")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`inline-block rounded-full size-[18px] border ${gender === "female" ? "border-active-button" : "border-[#8c95a1]"}`}>
|
||||
{gender === "female" && <span className="block size-[9px] rounded-full bg-active-button m-[4.5px]" />}
|
||||
</span>
|
||||
여성
|
||||
</label>
|
||||
</div>
|
||||
{errors.gender && <p className="text-error text-[13px] leading-tight">{errors.gender}</p>}
|
||||
</div>
|
||||
|
||||
{/* 생년월일 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="birthdate" className="text-[15px] font-semibold text-[#6c7682]">생년월일</label>
|
||||
<input
|
||||
id="birthdate"
|
||||
name="birthdate"
|
||||
type="date"
|
||||
value={birthdate}
|
||||
onChange={(e) => setBirthdate(e.target.value)}
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[16px] text-neutral-700"
|
||||
/>
|
||||
{errors.birthdate && <p className="text-error text-[13px] leading-tight">{errors.birthdate}</p>}
|
||||
</div>
|
||||
|
||||
{/* 약관 동의 */}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none text-[18px] font-semibold text-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allAgree}
|
||||
onChange={(e) => toggleAllAgree(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{allAgree ? <LoginCheckboxActiveSvg /> : <LoginCheckboxInactiveSvg />}
|
||||
모든 항목에 동의합니다.
|
||||
</label>
|
||||
<hr className="h-px bg-input-border border-0" />
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-[15px] text-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreeAge}
|
||||
onChange={(e) => setAgreeAge(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{agreeAge ? <LoginCheckboxActiveSvg /> : <LoginCheckboxInactiveSvg />}
|
||||
<span>만 14세 이상입니다. <span className="text-[#384fbf]">(필수)</span></span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-[15px] text-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreeTos}
|
||||
onChange={(e) => setAgreeTos(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{agreeTos ? <LoginCheckboxActiveSvg /> : <LoginCheckboxInactiveSvg />}
|
||||
<span>이용 약관 동의 <span className="text-[#384fbf]">(필수)</span></span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-[15px] text-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreePrivacy}
|
||||
onChange={(e) => setAgreePrivacy(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{agreePrivacy ? <LoginCheckboxActiveSvg /> : <LoginCheckboxInactiveSvg />}
|
||||
<span>개인정보 수집 및 이용 동의 <span className="text-[#384fbf]">(필수)</span></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{errors.agreements && <p className="text-error text-[13px] leading-tight">{errors.agreements}</p>}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-center flex items-center justify-center text-basic-text"
|
||||
>
|
||||
돌아가기
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`h-[40px] flex-1 rounded-[12px] text-[18px] font-semibold text-center flex items-center justify-center text-white ${canSubmit ? "bg-active-button" : "bg-inactive-button"} cursor-pointer`}
|
||||
>
|
||||
회원 가입 완료
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
80
src/app/register/RegisterOption.tsx
Normal file
80
src/app/register/RegisterOption.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type RegisterOptionProps = {
|
||||
doneOpen?: boolean;
|
||||
setDoneOpen?: (enabled: boolean) => void;
|
||||
codeErrorModalEnabled?: boolean;
|
||||
setCodeErrorModalEnabled?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function RegisterOption({
|
||||
doneOpen,
|
||||
setDoneOpen,
|
||||
codeErrorModalEnabled,
|
||||
setCodeErrorModalEnabled,
|
||||
}: RegisterOptionProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`fixed bottom-2 right-2 bg-red-400 cursor-pointer rounded-full w-[40px] h-[40px] shadow-xl z-1000`}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-1000">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
className="absolute inset-0 bg-black/20 cursor-default"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="relative w-[500px] h-[600px] flex bg-white p-10 border rounded-lg shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-700 flex items-center justify-center"
|
||||
>
|
||||
<span className="text-sm">✕</span>
|
||||
</button>
|
||||
<ul className="flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">register done</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="register done 토글"
|
||||
aria-pressed={!!doneOpen}
|
||||
onClick={() => setDoneOpen?.(!doneOpen)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${doneOpen ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${doneOpen ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">register code error modal</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="register code error modal 토글"
|
||||
aria-pressed={!!codeErrorModalEnabled}
|
||||
onClick={() => setCodeErrorModalEnabled?.(!codeErrorModalEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${codeErrorModalEnabled ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${codeErrorModalEnabled ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import RegisterForm from "@/app/register/RegisterForm";
|
||||
import RegisterOption from "@/app/register/RegisterOption";
|
||||
import RegisterDone from "@/app/register/RegisterDone";
|
||||
import RegisterCodeErrorModal from "@/app/register/RegisterCodeErrorModal";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [doneOpen, setDoneOpen] = useState(false);
|
||||
const [codeErrorOpen, setCodeErrorOpen] = useState(false);
|
||||
const [codeErrorMessage, setCodeErrorMessage] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
||||
<RegisterForm
|
||||
onOpenDone={() => setDoneOpen(true)}
|
||||
onOpenCodeError={(msg) => {
|
||||
setCodeErrorMessage(msg);
|
||||
setCodeErrorOpen(true);
|
||||
}}
|
||||
/>
|
||||
<RegisterOption
|
||||
doneOpen={doneOpen}
|
||||
setDoneOpen={setDoneOpen}
|
||||
codeErrorModalEnabled={codeErrorOpen}
|
||||
setCodeErrorModalEnabled={setCodeErrorOpen}
|
||||
/>
|
||||
<p className="text-center py-[40px] text-[15px] text-basic-text">
|
||||
Copyright ⓒ 2025 XL LMS. All rights reserved
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RegisterDone
|
||||
open={doneOpen}
|
||||
onClose={() => setDoneOpen(false)}
|
||||
/>
|
||||
<RegisterCodeErrorModal
|
||||
open={codeErrorOpen}
|
||||
onClose={() => setCodeErrorOpen(false)}
|
||||
message={codeErrorMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
80
src/app/reset-password/ResetPasswordDone.tsx
Normal file
80
src/app/reset-password/ResetPasswordDone.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type ResetPasswordDoneProps = {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
primaryHref?: string;
|
||||
primaryText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 완료 전체화면 컴포넌트.
|
||||
* - open이 true일 때 화면 전체를 덮어 표시
|
||||
* - children 전달 시 피그마 컴포넌트를 그대로 주입하여 커스텀 UI 표시
|
||||
* - 기본 제공 UI는 디자인 토큰(색상/타이포)과 기존 완료 컴포넌트 패턴과 동일하게 맞춤
|
||||
*/
|
||||
export default function ResetPasswordDone({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title = "비밀번호 재설정이 완료됐습니다.",
|
||||
description = "새로운 비밀번호로 다시 로그인 해주세요.",
|
||||
primaryHref = "/login",
|
||||
primaryText = "로그인",
|
||||
}: ResetPasswordDoneProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
<div className="absolute inset-0 overflow-auto">
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-center p-6">
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="w-full max-w-[560px] text-center">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700 mb-3">
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-[15px] text-basic-text mb-8 whitespace-pre-line">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href={primaryHref}
|
||||
className="h-[40px] flex-1 rounded-[12px] bg-active-button text-white text-[18px] font-semibold flex items-center justify-center"
|
||||
>
|
||||
{primaryText}
|
||||
</Link>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-basic-text cursor-pointer"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
className="absolute inset-0 -z-10 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
61
src/app/reset-password/ResetPaswordOption.tsx
Normal file
61
src/app/reset-password/ResetPaswordOption.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
type ResetPaswordOptionProps = {
|
||||
doneModalEnabled?: boolean;
|
||||
setDoneModalEnabled?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ResetPaswordOption({ doneModalEnabled, setDoneModalEnabled }: ResetPaswordOptionProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`fixed bottom-2 right-2 bg-red-400 cursor-pointer rounded-full w-[40px] h-[40px] shadow-xl z-100`}
|
||||
>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
className="absolute inset-0 bg-black/20 cursor-default"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-700 flex items-center justify-center"
|
||||
>
|
||||
<span className="text-sm">✕</span>
|
||||
</button>
|
||||
<ul className="flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">reset password done modal</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="reset password done modal 토글"
|
||||
aria-pressed={!!doneModalEnabled}
|
||||
onClick={() => setDoneModalEnabled?.(!doneModalEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${doneModalEnabled ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${doneModalEnabled ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
184
src/app/reset-password/page.tsx
Normal file
184
src/app/reset-password/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||
import ResetPasswordDone from "./ResetPasswordDone";
|
||||
import ResetPaswordOption from "./ResetPaswordOption";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [isDoneOpen, setIsDoneOpen] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const isEmailValid = useMemo(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), [email]);
|
||||
const isPasswordValid = useMemo(() => password.length >= 8 && password.length <= 16, [password]);
|
||||
const isPasswordConfirmValid = useMemo(() => passwordConfirm === password && passwordConfirm.length > 0, [password, passwordConfirm]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return isEmailValid && isPasswordValid && isPasswordConfirmValid;
|
||||
}, [isEmailValid, isPasswordValid, isPasswordConfirmValid]);
|
||||
|
||||
function validateAll() {
|
||||
const nextErrors: Record<string, string> = {};
|
||||
if (!isEmailValid) nextErrors.email = "올바른 이메일 형식을 입력해 주세요.";
|
||||
if (!isPasswordValid) nextErrors.password = "비밀번호는 8~16자여야 합니다.";
|
||||
if (!isPasswordConfirmValid) nextErrors.passwordConfirm = "비밀번호가 일치하지 않습니다.";
|
||||
setErrors(nextErrors);
|
||||
return Object.keys(nextErrors).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!validateAll()) return;
|
||||
// 성공 시 완료 오버레이 표시
|
||||
setIsDoneOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
||||
<ResetPasswordDone
|
||||
open={isDoneOpen}
|
||||
onClose={() => setIsDoneOpen(false)}
|
||||
/>
|
||||
|
||||
<ResetPaswordOption doneModalEnabled={isDoneOpen} setDoneModalEnabled={setIsDoneOpen} />
|
||||
|
||||
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full">
|
||||
{/* 제목 영역 */}
|
||||
<div className="my-15 flex flex-col items-center text-center">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
||||
비밀번호 재설정
|
||||
</div>
|
||||
<p className="mt-2 text-[18px] font-normal leading-[150%] text-[#6c7682]">
|
||||
비밀번호 재설정을 위해 아래 정보를 입력해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 아이디(이메일) + 인증번호 전송 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-[15px] font-semibold text-[#6c7682]">아이디 (이메일)</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, email: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, email: false }))}
|
||||
placeholder="이메일을 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{email.trim().length > 0 && focused.email && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setEmail(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isEmailValid}
|
||||
className={`h-[40px] px-[12px] rounded-[8px] text-[16px] font-semibold ${isEmailValid ? "bg-inactive-button text-white" : "bg-gray-50 text-input-placeholder-text"}`}
|
||||
onClick={() => {
|
||||
if (!isEmailValid) return;
|
||||
alert("인증번호 전송 (가상 동작)");
|
||||
}}
|
||||
>
|
||||
인증번호 전송
|
||||
</button>
|
||||
</div>
|
||||
{errors.email && <p className="text-error text-[13px] leading-tight">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
{/* 새 비밀번호 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-[15px] font-semibold text-[#6c7682]">새 비밀번호</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, password: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, password: false }))}
|
||||
placeholder="8~16자의 영문/숫자/특수문자를 조합해서 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{password.trim().length > 0 && focused.password && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setPassword(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.password && <p className="text-error text-[13px] leading-tight">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
{/* 새 비밀번호 확인 */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="passwordConfirm" className="text-[15px] font-semibold text-[#6c7682]">새 비밀번호 확인</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, passwordConfirm: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, passwordConfirm: false }))}
|
||||
placeholder="비밀번호를 다시 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{passwordConfirm.trim().length > 0 && focused.passwordConfirm && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setPasswordConfirm(""); }}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.passwordConfirm && <p className="text-error text-[13px] leading-tight">{errors.passwordConfirm}</p>}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-center flex items-center justify-center text-basic-text"
|
||||
>
|
||||
이전
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`h-[40px] flex-1 rounded-[12px] text-[18px] font-semibold text-center flex items-center justify-center text-white ${canSubmit ? "bg-active-button" : "bg-inactive-button"} cursor-pointer`}
|
||||
>
|
||||
비밀번호 재설정
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p className="text-center py-[40px] text-[15px] text-basic-text">
|
||||
Copyright ⓒ 2025 XL LMS. All rights reserved
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user