483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
// apps/dm/app/(landing)/reuse/_components/TopNav.tsx
|
||
"use client";
|
||
|
||
import Image from "next/image";
|
||
import Link from "next/link";
|
||
import { useEffect, useId, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
|
||
const NAV_ITEMS = [
|
||
{ href: "/reuse/intro/law", label: "물 재이용 소개" },
|
||
{ href: "/reuse/cases/rain", label: "물 재이용 현황" },
|
||
{ href: "/reuse/business/rain/suwon", label: "물 재이용 사례" },
|
||
{ href: "/reuse/apply/place", label: "수요신청" },
|
||
];
|
||
|
||
// ✅ 필요 시 실제 “수요처등록” 페이지로 경로만 교체하세요.
|
||
const REGISTER_HREF = "/reuse/apply/place";
|
||
|
||
// ✅ 로그인 정보 저장 키(원하는대로 변경 가능)
|
||
const LS_TOKEN_KEY = "reuse_accessToken";
|
||
const LS_USER_KEY = "reuse_userName";
|
||
|
||
type LoginModalProps = {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSuccess?: (payload: { userName: string; accessToken?: string }) => void;
|
||
};
|
||
|
||
type FieldKey = "bizNo" | "workerNo" | "pw";
|
||
|
||
function isBizNoValid(v: string) {
|
||
// 예: 123-45-67890
|
||
return /^\d{3}-\d{2}-\d{5}$/.test(v);
|
||
}
|
||
|
||
function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
|
||
const base =
|
||
"w-full h-[48px] rounded-[4px] border px-5 text-[18px] outline-none";
|
||
if (opts.disabled) return `${base} border-gray-200 bg-gray-50 text-gray-400`;
|
||
if (opts.hasError)
|
||
return `${base} border-red-500 focus:border-red-500 ring-1 ring-red-400`;
|
||
return `${base} border-gray-300 focus:border-sky-400`;
|
||
}
|
||
|
||
function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) {
|
||
const titleId = useId();
|
||
const router = useRouter();
|
||
|
||
const [bizNo, setBizNo] = useState("");
|
||
const [workerNo, setWorkerNo] = useState("");
|
||
const [noWorker, setNoWorker] = useState(false);
|
||
const [pw, setPw] = useState("");
|
||
|
||
// ✅ 유효성/서버 에러 표시용
|
||
const [errors, setErrors] = useState<Partial<Record<FieldKey, string>>>({});
|
||
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>({});
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") onClose();
|
||
};
|
||
window.addEventListener("keydown", onKeyDown);
|
||
|
||
// 스크롤 잠금
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
|
||
// 모달 오픈 시 에러/터치 초기화
|
||
setErrors({});
|
||
setTouched({});
|
||
|
||
return () => {
|
||
window.removeEventListener("keydown", onKeyDown);
|
||
document.body.style.overflow = prev;
|
||
};
|
||
}, [open, onClose]);
|
||
|
||
useEffect(() => {
|
||
if (noWorker) {
|
||
setWorkerNo("");
|
||
setErrors((p) => {
|
||
const { ...rest } = p;
|
||
delete rest.workerNo;
|
||
return rest;
|
||
});
|
||
}
|
||
}, [noWorker]);
|
||
|
||
if (!open) return null;
|
||
|
||
const validate = () => {
|
||
const next: Partial<Record<FieldKey, string>> = {};
|
||
|
||
const b = bizNo.trim();
|
||
const w = workerNo.trim();
|
||
const p = pw.trim();
|
||
|
||
if (!b) next.bizNo = "사업자등록번호를 입력해주세요.";
|
||
else if (!isBizNoValid(b))
|
||
next.bizNo = "사업자등록번호 형식이 올바르지 않습니다. (예: 123-45-67890)";
|
||
|
||
if (!noWorker && !w) next.workerNo = "종사자등록번호를 입력해주세요.";
|
||
|
||
if (!p) next.pw = "비밀번호를 입력해주세요.";
|
||
|
||
setErrors(next);
|
||
return next;
|
||
};
|
||
|
||
const markTouched = (key: FieldKey) =>
|
||
setTouched((p) => ({ ...p, [key]: true }));
|
||
|
||
const markAllTouched = () =>
|
||
setTouched({ bizNo: true, workerNo: true, pw: true });
|
||
|
||
const submit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
markAllTouched();
|
||
|
||
const v = validate();
|
||
if (Object.keys(v).length) return;
|
||
|
||
try {
|
||
setSubmitting(true);
|
||
|
||
// ✅ TODO: 실제 로그인 API 연결 지점
|
||
// const res = await fetch("/api/demand/login", {
|
||
// method: "POST",
|
||
// headers: { "Content-Type": "application/json" },
|
||
// body: JSON.stringify({ bizNo, workerNo: noWorker ? null : workerNo, pw }),
|
||
// });
|
||
// if (!res.ok) { ... }
|
||
// const data = await res.json();
|
||
// onSuccess?.({ userName: data.userName, accessToken: data.token });
|
||
|
||
// --- 데모: 비밀번호가 "1234"면 성공 처리(원하면 제거) ---
|
||
const serverOk = pw.trim() === "1234";
|
||
if (!serverOk) {
|
||
setErrors((p) => ({ ...p, pw: "잘못된 비밀번호입니다. (데모: 1234 입력 시 성공)" }));
|
||
setTouched((p) => ({ ...p, pw: true }));
|
||
return;
|
||
}
|
||
|
||
onSuccess?.({ userName: "마인드원", accessToken: "demo_token" });
|
||
onClose();
|
||
router.refresh();
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[100]">
|
||
{/* Backdrop */}
|
||
<button
|
||
type="button"
|
||
aria-label="닫기"
|
||
onClick={onClose}
|
||
className="absolute inset-0 bg-black/40"
|
||
/>
|
||
|
||
{/* Dialog */}
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
className="w-full max-w-[600px] rounded-[16px] bg-white shadow-2xl overflow-hidden"
|
||
>
|
||
{/* Header */}
|
||
<div className="relative px-10 pt-10">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="닫기"
|
||
className="absolute right-8 top-8 z-40 h-10 w-10 grid place-items-center rounded-full hover:bg-gray-100"
|
||
>
|
||
<span className="text-2xl leading-none">×</span>
|
||
</button>
|
||
|
||
<h2
|
||
id={titleId}
|
||
className="relative z-10 px-2 text-center text-[32px] font-bold tracking-tight leading-none text-gray-900"
|
||
>
|
||
수요처 로그인
|
||
</h2>
|
||
|
||
{/* title underline gradient bar */}
|
||
<div className="relative top-[-7px] -z-1 mx-auto h-[10px] w-[240px] bg-gradient-to-r from-sky-300 to-green-300" />
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<form onSubmit={submit} className="px-14 pb-12 pt-10">
|
||
<div className="space-y-7">
|
||
{/* 사업자 등록번호 */}
|
||
<div>
|
||
<label className="block text-[18px] font-bold text-gray-900 mb-3">
|
||
사업자 등록번호
|
||
</label>
|
||
<input
|
||
value={bizNo}
|
||
onChange={(e) => {
|
||
setBizNo(e.target.value);
|
||
setErrors((p) => ({ ...p, bizNo: undefined }));
|
||
}}
|
||
onBlur={() => {
|
||
markTouched("bizNo");
|
||
validate();
|
||
}}
|
||
placeholder="사업자등록번호를 입력하세요. (예: 123-45-67890)"
|
||
className={inputCls({ hasError: !!(touched.bizNo && errors.bizNo) })}
|
||
/>
|
||
{touched.bizNo && errors.bizNo && (
|
||
<p className="mt-3 text-[14px] font-medium text-red-500">
|
||
{errors.bizNo}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 종사자등록번호 + 해당없음 */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<label className="block text-[18px] font-bold text-gray-900">
|
||
종사자등록번호
|
||
</label>
|
||
|
||
<label className="flex items-center gap-1 text-[16px] text-[#4F4F4F] select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={noWorker}
|
||
onChange={(e) => setNoWorker(e.target.checked)}
|
||
className="h-4 w-4 accent-[#004A94]"
|
||
/>
|
||
해당없음
|
||
</label>
|
||
</div>
|
||
|
||
<input
|
||
value={workerNo}
|
||
onChange={(e) => {
|
||
setWorkerNo(e.target.value);
|
||
setErrors((p) => ({ ...p, workerNo: undefined }));
|
||
}}
|
||
onBlur={() => {
|
||
markTouched("workerNo");
|
||
validate();
|
||
}}
|
||
disabled={noWorker}
|
||
placeholder="종사자등록번호를 입력하세요."
|
||
className={inputCls({
|
||
disabled: noWorker,
|
||
hasError: !noWorker && !!(touched.workerNo && errors.workerNo),
|
||
})}
|
||
/>
|
||
|
||
{!noWorker && touched.workerNo && errors.workerNo && (
|
||
<p className="mt-3 text-[14px] font-medium text-red-500">
|
||
{errors.workerNo}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 비밀번호 */}
|
||
<div>
|
||
<label className="block text-[18px] font-bold text-gray-900 mb-3">
|
||
비밀번호
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={pw}
|
||
onChange={(e) => {
|
||
setPw(e.target.value);
|
||
setErrors((p) => ({ ...p, pw: undefined }));
|
||
}}
|
||
onBlur={() => {
|
||
markTouched("pw");
|
||
validate();
|
||
}}
|
||
placeholder="비밀번호를 입력하세요."
|
||
className={inputCls({ hasError: !!(touched.pw && errors.pw) })}
|
||
/>
|
||
{touched.pw && errors.pw && (
|
||
<p className="mt-3 text-[14px] font-medium text-red-500">
|
||
{errors.pw}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 로그인 버튼 */}
|
||
<button
|
||
type="submit"
|
||
disabled={submitting}
|
||
className={[
|
||
"mt-2 w-full h-[60px] rounded-[4px] text-white text-[18px]",
|
||
submitting
|
||
? "bg-sky-300 cursor-not-allowed"
|
||
: "bg-sky-500 hover:bg-sky-600 active:bg-sky-700",
|
||
].join(" ")}
|
||
>
|
||
{submitting ? "로그인 중..." : "로그인"}
|
||
</button>
|
||
|
||
{/* 비밀번호 초기화 요청 */}
|
||
<button
|
||
type="button"
|
||
onClick={() => alert("TODO: 비밀번호 초기화 요청 화면/기능 연결")}
|
||
className="mx-auto block text-[14px] text-[#767676] hover:text-gray-900"
|
||
>
|
||
비밀번호 초기화 요청
|
||
</button>
|
||
|
||
{/* 하단 안내 박스 + 수요처등록 버튼 */}
|
||
<div className="mt-6 border border-[#004A94] bg-[#EEF3F9] px-4 py-4 flex items-center justify-between gap-6">
|
||
<div className="leading-snug">
|
||
<div className="text-[16px] text-[#8698AA]">
|
||
수요처를 등록하지 않았다면
|
||
</div>
|
||
<div className="text-[18px] text-[#004A94]">
|
||
먼저 수요처등록을 해주세요.
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => router.push(REGISTER_HREF)}
|
||
className="shrink-0 rounded-[4px] h-[48px] px-10 bg-[#004A94] text-white text-[18px] hover:opacity-95"
|
||
>
|
||
수요처등록
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function TopNav() {
|
||
const router = useRouter();
|
||
|
||
const [loginOpen, setLoginOpen] = useState(false);
|
||
const [userName, setUserName] = useState<string | null>(null);
|
||
|
||
// ✅ 초기 로드 시 로그인 상태 반영
|
||
useEffect(() => {
|
||
const name = localStorage.getItem(LS_USER_KEY);
|
||
setUserName(name);
|
||
}, []);
|
||
|
||
const openLogin = () => setLoginOpen(true);
|
||
|
||
const logout = () => {
|
||
localStorage.removeItem(LS_TOKEN_KEY);
|
||
localStorage.removeItem(LS_USER_KEY);
|
||
setUserName(null);
|
||
router.refresh();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<header className="fixed top-0 left-0 right-0 z-50 bg-white backdrop-blur border-b shadow-md min-w-[1800px]">
|
||
{/* ✅ 좌/중/우 정렬이 되도록 컨테이너 구성 */}
|
||
<div className="px-6">
|
||
<div className="relative h-[83px] flex items-center justify-between">
|
||
{/* 좌측: 로고 */}
|
||
<div className="flex items-center gap-3">
|
||
<Link href="/reuse" className="flex items-center">
|
||
<Image
|
||
src="/images/landing/logo.png"
|
||
alt="HASUON"
|
||
width={229}
|
||
height={46}
|
||
className="h-9 w-auto"
|
||
priority
|
||
/>
|
||
</Link>
|
||
</div>
|
||
|
||
{/* 중앙: 상단 메뉴 */}
|
||
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[40px] text-sm font-medium">
|
||
{NAV_ITEMS.map((it) => {
|
||
// ✅ 수요신청: 로그인 전이면 로그인 모달, 로그인 후면 페이지 이동
|
||
if (it.label === "수요신청") {
|
||
return (
|
||
<button
|
||
key={it.href}
|
||
type="button"
|
||
onClick={() => {
|
||
if (!userName) openLogin();
|
||
else router.push(it.href);
|
||
}}
|
||
className={[
|
||
"relative px-3 py-2 text-lg font-bold",
|
||
"text-gray-800 hover:text-sky-700",
|
||
].join(" ")}
|
||
>
|
||
{it.label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Link
|
||
key={it.href}
|
||
href={it.href}
|
||
className={[
|
||
"relative px-3 py-2 text-lg font-bold",
|
||
"text-gray-800 hover:text-sky-700",
|
||
].join(" ")}
|
||
>
|
||
{it.label}
|
||
</Link>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
{/* ✅ 우측: 로그인 버튼 / 로그인 정보 + 로그아웃 */}
|
||
<div className="flex items-center gap-3">
|
||
{!userName ? (
|
||
<button
|
||
type="button"
|
||
onClick={openLogin}
|
||
className="inline-flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-base text-white hover:bg-sky-600"
|
||
>
|
||
로그인
|
||
<span aria-hidden>
|
||
<Image
|
||
src="/images/landing/icon_login.png"
|
||
alt="로그인 아이콘"
|
||
width={24}
|
||
height={24}
|
||
className="h-6 w-6 object-contain ml-1"
|
||
/>
|
||
</span>
|
||
</button>
|
||
) : (
|
||
<>
|
||
<span className="text-base text-gray-700">
|
||
<strong className="text-xl text-emerald-600">{userName}</strong>님 환영합니다.
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={logout}
|
||
className="inline-flex items-center gap-2 rounded-md bg-red-500 px-4 py-2 text-base text-white hover:bg-red-600"
|
||
>
|
||
로그아웃
|
||
<span aria-hidden>
|
||
<Image
|
||
src="/images/landing/icon_logout.png"
|
||
alt="로그아웃 아이콘"
|
||
width={24}
|
||
height={24}
|
||
className="h-6 w-6 object-contain"
|
||
/>
|
||
</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* ✅ 로그인 모달 */}
|
||
<DemandLoginModal
|
||
open={loginOpen}
|
||
onClose={() => setLoginOpen(false)}
|
||
onSuccess={({ userName, accessToken }) => {
|
||
// ✅ 로그인 성공 처리: 저장 + 헤더 갱신
|
||
localStorage.setItem(LS_USER_KEY, userName);
|
||
if (accessToken) localStorage.setItem(LS_TOKEN_KEY, accessToken);
|
||
setUserName(userName);
|
||
setLoginOpen(false);
|
||
router.refresh();
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|