Merge branch 'iipbs/dm/dev'
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
export type AnchorSection = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type BusinessSubNavMenu = {
|
||||
key: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
current?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
sections?: AnchorSection[];
|
||||
};
|
||||
|
||||
type BusinessSubNavProps = {
|
||||
menus?: BusinessSubNavMenu[];
|
||||
offsetTop?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function BusinessSubNav({
|
||||
menus = [],
|
||||
offsetTop = 120,
|
||||
className = "",
|
||||
}: BusinessSubNavProps) {
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
||||
|
||||
const allSections = useMemo(
|
||||
() => menus.flatMap((menu) => menu.sections ?? []),
|
||||
[menus]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const initialOpenState: Record<string, boolean> = {};
|
||||
|
||||
menus.forEach((menu) => {
|
||||
initialOpenState[menu.key] = menu.defaultOpen ?? menu.current ?? false;
|
||||
});
|
||||
|
||||
setOpenMenus(initialOpenState);
|
||||
}, [menus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allSections.length) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||
|
||||
if (visible[0]) {
|
||||
setActiveId(visible[0].target.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: `-${offsetTop}px 0px -55% 0px`,
|
||||
threshold: [0.2, 0.35, 0.5, 0.7],
|
||||
}
|
||||
);
|
||||
|
||||
const targets = allSections
|
||||
.map((section) => document.getElementById(section.id))
|
||||
.filter(Boolean) as HTMLElement[];
|
||||
|
||||
targets.forEach((el) => observer.observe(el));
|
||||
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
const initialActive =
|
||||
allSections.find((section) => section.id === hash)?.id ??
|
||||
allSections[0]?.id ??
|
||||
"";
|
||||
|
||||
setActiveId(initialActive);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [allSections, offsetTop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
|
||||
const parentMenu = menus.find((menu) =>
|
||||
(menu.sections ?? []).some((section) => section.id === activeId)
|
||||
);
|
||||
|
||||
if (!parentMenu) return;
|
||||
|
||||
setOpenMenus(() => {
|
||||
const nextOpenState: Record<string, boolean> = {};
|
||||
|
||||
menus.forEach((menu) => {
|
||||
nextOpenState[menu.key] = menu.key === parentMenu.key;
|
||||
});
|
||||
|
||||
return nextOpenState;
|
||||
});
|
||||
}, [activeId, menus]);
|
||||
|
||||
const handleMove = (id: string) => {
|
||||
setActiveId(id);
|
||||
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
|
||||
window.history.replaceState(null, "", `#${id}`);
|
||||
|
||||
const top = target.getBoundingClientRect().top + window.scrollY - offsetTop;
|
||||
|
||||
window.scrollTo({
|
||||
top,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMenu = (key: string) => {
|
||||
setOpenMenus((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
if (!menus.length) return null;
|
||||
|
||||
return (
|
||||
<aside className={`fixed right-2 top-[110px] z-50 w-[260px] ${className}`}>
|
||||
<div className="overflow-hidden bg-white">
|
||||
{menus.map((menu) => {
|
||||
const isOpen = openMenus[menu.key] ?? false;
|
||||
const hasChildren = (menu.sections?.length ?? 0) > 0;
|
||||
const isCurrentGroup = (menu.sections ?? []).some(
|
||||
(section) => section.id === activeId
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={menu.key}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-2">
|
||||
{menu.href ? (
|
||||
<Link
|
||||
href={menu.href}
|
||||
className={[
|
||||
"text-[16px] font-medium tracking-[-0.02em]",
|
||||
isCurrentGroup || menu.current
|
||||
? "text-sky-500"
|
||||
: "text-[#767676]",
|
||||
].join(" ")}
|
||||
>
|
||||
{menu.label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasChildren) toggleMenu(menu.key);
|
||||
}}
|
||||
className={[
|
||||
"text-left text-[16px] font-medium tracking-[-0.02em]",
|
||||
isCurrentGroup || menu.current
|
||||
? "text-sky-500"
|
||||
: "text-[#767676]",
|
||||
].join(" ")}
|
||||
>
|
||||
{menu.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMenu(menu.key)}
|
||||
className={[
|
||||
"ml-2 shrink-0",
|
||||
isCurrentGroup || menu.current
|
||||
? "text-sky-500"
|
||||
: "text-[#767676]",
|
||||
].join(" ")}
|
||||
aria-label={`${menu.label} 드롭다운 토글`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && isOpen && (
|
||||
<div className="relative px-5 pb-5">
|
||||
<div className="absolute left-[31px] top-[6px] bottom-[14px] w-[2px] bg-[#CDD5DB]" />
|
||||
|
||||
<ul className="space-y-4 pl-2">
|
||||
{menu.sections!.map((section) => {
|
||||
const active = activeId === section.id;
|
||||
|
||||
return (
|
||||
<li key={section.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMove(section.id)}
|
||||
className="flex items-start gap-3 text-left"
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"relative z-[1] mt-[6px] -ml-[2px] h-[11px] w-[11px] shrink-0 rounded-full border",
|
||||
active
|
||||
? "border-sky-500 bg-sky-500"
|
||||
: "border-[#BFC7CF] bg-white",
|
||||
].join(" ")}
|
||||
/>
|
||||
<span
|
||||
className={[
|
||||
"break-keep text-[14px] leading-[1.45] transition-colors",
|
||||
active
|
||||
? "text-[#4A4A4A]"
|
||||
: "text-[#D1D7DE] hover:text-[#7B8794]",
|
||||
].join(" ")}
|
||||
>
|
||||
{section.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
@@ -9,167 +8,151 @@ type Slide = { src: string; alt?: string };
|
||||
type Props = {
|
||||
slides: Slide[];
|
||||
className?: string;
|
||||
maxWidthClassName?: string; // 예: "max-w-[760px]"
|
||||
maxWidthClassName?: string;
|
||||
autoPlayMs?: number;
|
||||
imageHeightClassName?: string;
|
||||
};
|
||||
|
||||
const PER_VIEW = 2;
|
||||
|
||||
export default function CaseImageSlider({
|
||||
slides,
|
||||
className = "",
|
||||
maxWidthClassName = "max-w-[760px]",
|
||||
maxWidthClassName = "max-w-[700px]",
|
||||
autoPlayMs = 4500,
|
||||
imageHeightClassName = "h-[340px]",
|
||||
}: Props) {
|
||||
const safeSlides = useMemo(() => slides.filter(Boolean), [slides]);
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
|
||||
const startX = useRef(0);
|
||||
const deltaX = useRef(0);
|
||||
|
||||
const total = safeSlides.length;
|
||||
const canSlide = total > 1;
|
||||
const pageCount = Math.ceil(safeSlides.length / PER_VIEW);
|
||||
const canSlide = pageCount > 1;
|
||||
|
||||
const go = (dir: number) => {
|
||||
const goTo = (nextPage: number) => {
|
||||
if (!canSlide) return;
|
||||
setIdx((prev) => {
|
||||
const next = prev + dir;
|
||||
if (next < 0) return total - 1;
|
||||
if (next >= total) return 0;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nextPage < 0) setPage(pageCount - 1);
|
||||
else if (nextPage >= pageCount) setPage(0);
|
||||
else setPage(nextPage);
|
||||
};
|
||||
|
||||
// ← → 키보드 지원(선택)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!canSlide) return;
|
||||
if (e.key === "ArrowLeft") go(-1);
|
||||
if (e.key === "ArrowRight") go(1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [canSlide, total]);
|
||||
const goNext = () => goTo(page + 1);
|
||||
const goPrev = () => goTo(page - 1);
|
||||
|
||||
// 드래그(마우스/터치)로 넘기기
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
// ✅ 자동롤링 (hover 시 정지 포함)
|
||||
useEffect(() => {
|
||||
if (!canSlide || dragging || isHover) return;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setPage((prev) => (prev + 1 >= pageCount ? 0 : prev + 1));
|
||||
}, autoPlayMs);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [canSlide, dragging, isHover, pageCount, autoPlayMs]);
|
||||
|
||||
// ✅ swipe
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!canSlide) return;
|
||||
|
||||
setDragging(true);
|
||||
startX.current = e.clientX;
|
||||
deltaX.current = 0;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
deltaX.current = e.clientX - startX.current;
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (!dragging) return;
|
||||
|
||||
setDragging(false);
|
||||
|
||||
const threshold = 60; // 넘김 감도
|
||||
if (deltaX.current > threshold) go(-1);
|
||||
else if (deltaX.current < -threshold) go(1);
|
||||
const threshold = 50;
|
||||
|
||||
if (deltaX.current > threshold) goPrev();
|
||||
if (deltaX.current < -threshold) goNext();
|
||||
|
||||
deltaX.current = 0;
|
||||
};
|
||||
|
||||
if (!total) return null;
|
||||
if (!safeSlides.length) return null;
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<div className={`mx-auto ${maxWidthClassName}`}>
|
||||
<div className="relative">
|
||||
{/* 좌측 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
className="absolute left-[-60px] top-1/2 -translate-y-1/2 z-10
|
||||
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
|
||||
grid place-items-center shadow-sm hover:bg-black/[0.03]"
|
||||
>
|
||||
<Image
|
||||
src="/images/landing/icon_left_arrow.png"
|
||||
alt="이전"
|
||||
width={12}
|
||||
height={22}
|
||||
className="w-[12px] h-[22px]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 우측 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
className="absolute right-[-60px] top-1/2 -translate-y-1/2 z-10
|
||||
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
|
||||
grid place-items-center shadow-sm hover:bg-black/[0.03]"
|
||||
>
|
||||
<Image
|
||||
src="/images/landing/icon_right_arrow.png"
|
||||
alt="다음"
|
||||
width={12}
|
||||
height={22}
|
||||
className="w-[12px] h-[22px]"
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 뷰포트 */}
|
||||
<div
|
||||
className="overflow-hidden select-none touch-pan-y cursor-grab active:cursor-grabbing"
|
||||
onMouseEnter={() => setIsHover(true)} // ✅ hover 시작 → 정지
|
||||
onMouseLeave={() => setIsHover(false)} // ✅ hover 끝 → 재시작
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"overflow-hidden",
|
||||
"select-none touch-pan-y",
|
||||
"cursor-grab active:cursor-grabbing",
|
||||
"w-[583px] h-[400px] mx-auto",
|
||||
].join(" ")}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className="flex transition-transform duration-500 ease-out"
|
||||
style={{
|
||||
transform: `translateX(-${page * 100}%)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex transition-transform duration-300 ease-out"
|
||||
style={{ transform: `translateX(-${idx * 100}%)` }}
|
||||
>
|
||||
{safeSlides.map((s, i) => (
|
||||
<div key={`${s.src}-${i}`} className="min-w-full">
|
||||
<Image
|
||||
src={s.src}
|
||||
alt={s.alt ?? `slide-${i + 1}`}
|
||||
width={583}
|
||||
height={400}
|
||||
className="w-full h-full object-contain"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
draggable={false}
|
||||
/>
|
||||
{Array.from({ length: pageCount }).map((_, pageIndex) => {
|
||||
const pageSlides = safeSlides.slice(
|
||||
pageIndex * PER_VIEW,
|
||||
pageIndex * PER_VIEW + PER_VIEW
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pageIndex}
|
||||
className="grid min-w-full grid-cols-2 gap-5"
|
||||
>
|
||||
{pageSlides.map((slide, i) => (
|
||||
<div
|
||||
key={`${slide.src}-${i}`}
|
||||
className={`relative ${imageHeightClassName} overflow-hidden rounded-[20px] bg-neutral-100`}
|
||||
>
|
||||
<Image
|
||||
src={slide.src}
|
||||
alt={slide.alt ?? `slide-${pageIndex * PER_VIEW + i + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 50vw, 340px"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도트 */}
|
||||
{canSlide && (
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
{safeSlides.map((_, i) => {
|
||||
const active = i === idx;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setIdx(i)}
|
||||
aria-label={`${i + 1}번 슬라이드`}
|
||||
className={[
|
||||
"h-2 w-2 rounded-full",
|
||||
active ? "bg-black/70" : "bg-black/20 hover:bg-black/35",
|
||||
].join(" ")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="mt-5 flex items-center justify-center gap-[6px]">
|
||||
{Array.from({ length: pageCount }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setPage(i)}
|
||||
className={[
|
||||
"h-[8px] w-[8px] rounded-full transition-all",
|
||||
page === i ? "bg-[#18BCEB]" : "bg-[#D8DDE3]",
|
||||
].join(" ")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import Image from "next/image";
|
||||
* - BUGFIX 2) 슬라이드 중(busy) clipWidth 변화로 트랙 스냅(-W) 덮어쓰지 않게 분리
|
||||
*/
|
||||
|
||||
type TabKey = "rain" | "reuse" | "sewage" | "global";
|
||||
type TabKey = "rain" | "greywater" | "sewage" | "global";
|
||||
|
||||
type CaseItem = {
|
||||
id: string;
|
||||
@@ -34,7 +34,7 @@ type CaseItem = {
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: "rain", label: "빗물 재이용 사례" },
|
||||
{ key: "reuse", label: "중수 재이용 사례" },
|
||||
{ key: "greywater", label: "중수 재이용 사례" },
|
||||
{ key: "sewage", label: "하수 재이용 사례" },
|
||||
{ key: "global", label: "해외 재이용 사례" },
|
||||
];
|
||||
@@ -43,113 +43,143 @@ const TABS: { key: TabKey; label: string }[] = [
|
||||
const MOCK: Record<TabKey, CaseItem[]> = {
|
||||
rain: [
|
||||
{
|
||||
id: "rain-0",
|
||||
title: "경기도 수원시 빗물이용과 중수도",
|
||||
desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…",
|
||||
imageSrc: "/images/landing/rain_01.png",
|
||||
href: "/reuse/business/rain/suwon",
|
||||
id: "rain-1",
|
||||
title: "경기도 수원시 빗물 재이용",
|
||||
desc: "월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물공급기를 설치하여 축서기 도시 열섬효과 및 도로 비산먼지 저감",
|
||||
imageSrc: "/images/landing/business/rain/suwon_01.png",
|
||||
href: "/example#example-rain-suwon",
|
||||
},
|
||||
{
|
||||
id: "rain-2",
|
||||
title: "제주특별자치도 빗물 재이용",
|
||||
desc: "강우시 비닐하우스에서 발생하는 대체수자원인 빗물을 순환 활용하고자 하는 사업으로, 지하수 사용 저감과 집중호우시 도로와 농경지 침수피해 예방",
|
||||
imageSrc: "/images/landing/business/rain/jeju_01.png",
|
||||
href: "/example#example-rain-jeju",
|
||||
},
|
||||
],
|
||||
greywater: [
|
||||
{
|
||||
id: "greywater-1",
|
||||
title: "경기도 안산시 중수 재이용",
|
||||
desc: "수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현을 위해 안산시 중수 재이용 사업 추진, 공공기관 및 산업체에 중수 공급",
|
||||
imageSrc: "/images/landing/business/greywater/no_img.jpg",
|
||||
href: "/example#example-greywater-ansan",
|
||||
},
|
||||
{
|
||||
id: "greywater-2",
|
||||
title: "경기도 용인시 중수 재이용",
|
||||
desc: "물 부족 등의 비상상황에 대한 대비, 하수라도 사용할 집결 등 수자원의 효율적 활용과 지속가능성 도모를 위해 용인시 중수 재이용 사업 추진, 공공기관 및 산업체에 중수 공급",
|
||||
imageSrc: "/images/landing/business/greywater/no_img.jpg",
|
||||
href: "/example#example-greywater-yongin",
|
||||
},
|
||||
{
|
||||
id: "greywater-3",
|
||||
title: "경기도 평택시 중수 재이용",
|
||||
desc: "수자원을 효율적으로 활용하여 지속가능성 도모 및 물 절약 실현을 위해 평택시 중수 재이용 사업 추진, 공공기관 및 산업체에 중수 공급",
|
||||
imageSrc: "/images/landing/business/greywater/no_img.jpg",
|
||||
href: "/example#example-greywater-pyeongtaek",
|
||||
},
|
||||
// {
|
||||
// id: "rain-2",
|
||||
// title: "경기도 수원시 빗물이용과 중수도",
|
||||
// desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…",
|
||||
// imageSrc: "/images/landing/rain_01.png",
|
||||
// href: "#",
|
||||
// },
|
||||
],
|
||||
reuse: Array.from({ length: 6 }).map((_, i) => ({
|
||||
id: `reuse-${i}`,
|
||||
title: `중수 재이용 사례 ${i + 1}`,
|
||||
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
|
||||
imageSrc: "/images/landing/sample_case.png",
|
||||
href: "#",
|
||||
})),
|
||||
sewage: [
|
||||
{
|
||||
id: "sewage-1",
|
||||
title: "경기도 파주시 하수 재이용",
|
||||
desc: "운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에...",
|
||||
imageSrc: "/images/landing/sewage_01.png",
|
||||
href: "/reuse/business/sewage/paju",
|
||||
title: "구리시 하수 재이용",
|
||||
desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화",
|
||||
imageSrc: "/images/landing/business/sewage/guri_01.png",
|
||||
href: "/example#example-sewage-guri",
|
||||
},
|
||||
{
|
||||
id: "sewage-2",
|
||||
title: "충남보령 하수재이용",
|
||||
desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...",
|
||||
imageSrc: "/images/landing/sewage_02.png",
|
||||
href: "/reuse/business/sewage/bolyeong",
|
||||
title: "부천시 하수재이용",
|
||||
desc: "굴포하수처리시설 하수처리수 재이용수 생산시설 설치 운영을 통해 여월천 등 부천 관내 하천의 건천화 방지, 하천 생태계 보존 및 주민들에게 친수 공간 제공",
|
||||
imageSrc: "/images/landing/business/sewage/bucheon_01.png",
|
||||
href: "/example#example-sewage-bucheon",
|
||||
},
|
||||
{
|
||||
id: "sewage-3",
|
||||
title: "여수시 하수처리수 재이용",
|
||||
desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...",
|
||||
imageSrc: "/images/landing/sewage_03.png",
|
||||
href: "/reuse/business/sewage/yeosu",
|
||||
title: "수원시 하수처리수 재이용",
|
||||
desc: "황구지천 공공하수처리시설 하수처리수 재이용사업으로 황구 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전",
|
||||
imageSrc: "/images/landing/business/sewage/suwon_01.png",
|
||||
href: "/example#example-sewage-suwon",
|
||||
},
|
||||
{
|
||||
id: "sewage-4",
|
||||
title: "청주시 하수 재이용 시설",
|
||||
desc: "청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO)...",
|
||||
imageSrc: "/images/landing/sewage_04.png",
|
||||
href: "/reuse/business/sewage/cheongju",
|
||||
title: "파주시 하수 재이용 시설",
|
||||
desc: "운정 및 금촌하수처리시설의 하수처리수를 막공법으로 재처리하여 파주LCD산업단지 내 3개 업체에 공업용수를 공급(LG디스플레이, 희성전자, 엔비텍)",
|
||||
imageSrc: "/images/landing/business/sewage/paju_01.png",
|
||||
href: "/example#example-sewage-paju",
|
||||
},
|
||||
{
|
||||
id: "sewage-5",
|
||||
title: "수원시 황구지천 하천유지용수",
|
||||
desc: "황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로...",
|
||||
imageSrc: "/images/landing/sewage_05.png",
|
||||
href: "/reuse/business/sewage/hwanggu",
|
||||
title: "포항시 하수 재이용 시설",
|
||||
desc: "포항시 내 공단 개발 확대로 지속적이고 안정적인 공업용수 공급 추진(포스코, 동국산업 등), 포항하수처리장 하수처리수를 재처리하여 공업용수로 공급",
|
||||
imageSrc: "/images/landing/business/sewage/pohang_01.png",
|
||||
href: "/example#example-sewage-pohang",
|
||||
},
|
||||
{
|
||||
id: "sewage-6",
|
||||
title: "구리시 도로자동청소시스템",
|
||||
desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용...",
|
||||
imageSrc: "/images/landing/sewage_06.png",
|
||||
href: "/reuse/business/sewage/guri",
|
||||
title: "울산광역시 하수 재이용 시설",
|
||||
desc: "18년 갈수기시 낙동강 수질 악화로 울산석유화학단지 용수 공급 부족을 해소하기 위해 미포산단 내 한화케미칼 등 8개사 공업용수 공급",
|
||||
imageSrc: "/images/landing/business/sewage/ulsan_01.png",
|
||||
href: "/example#example-sewage-ulsan",
|
||||
},
|
||||
// ...Array.from({ length: 6 }).map((_, i) => ({
|
||||
// id: `sewage-x-${i}`,
|
||||
// title: `하수 재이용 사례 ${i + 4}`,
|
||||
// desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
|
||||
// imageSrc: "/images/landing/sample_case.png",
|
||||
// href: "#",
|
||||
// })),
|
||||
{
|
||||
id: "sewage-7",
|
||||
title: "여수시 하수 재이용 시설",
|
||||
desc: "수요자가 원하는 양질의 공업용수를 저렴하게 공급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선을 위한 여수국가산단 내 공업용수 공급(GS칼텍스, YNCC 등)",
|
||||
imageSrc: "/images/landing/business/sewage/yeosu_01.png",
|
||||
href: "/example#example-sewage-yeosu",
|
||||
},
|
||||
{
|
||||
id: "sewage-8",
|
||||
title: "보령시 하수 재이용 시설",
|
||||
desc: "한국중부발전(주)의 보령 및 신보령화력발전소에 10,000㎥/일의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진, 하수처리수 재이용을 통한 수자원 절약 및 지역 물 부족 해소",
|
||||
imageSrc: "/images/landing/business/sewage/boryeong_01.png",
|
||||
href: "/example#example-sewage-boryeong",
|
||||
},
|
||||
{
|
||||
id: "sewage-9",
|
||||
title: "아산시 하수 재이용 시설",
|
||||
desc: "신도시 개발에 따른 인구 급증으로 발생하는 하수를 재처리하여 탕정산업단지(삼성디스플레이)에 공업용수를 안정적으로 공급함으로써 지역기반 및 활성화를 도모",
|
||||
imageSrc: "/images/landing/business/sewage/asan_01.png",
|
||||
href: "/example#example-sewage-asan",
|
||||
}
|
||||
],
|
||||
global: [
|
||||
{
|
||||
id: "global-1",
|
||||
title: "해외 재이용 사례 개요",
|
||||
desc: "2024년 하수도 통계 기준 15.6%이며, 제2차 물 재이용 기본 계획을 고려한 2030년 목표는 17%, 우리나라의 하수 처리 재이용률은 15% 수준...",
|
||||
imageSrc: "/images/landing/overview_01.png",
|
||||
href: "reuse/business/globalCase/overview",
|
||||
desc: "전 세계적으로 물 재이용은 호주, 유럽, 아프리카, 미국 등 가뭄이 잦거나 심각한 가뭄이 발생한 국가에서 많이 이루어짐. 이스라엘, 쿠웨이트 등 중동 및 북아프리카 지역은 가용 수자원이 제한되어 있어 재이용량 비중도 매우 높은 편",
|
||||
imageSrc: "/images/landing/business/globalCase/overview_01.png",
|
||||
href: "/example#example-global-overview",
|
||||
},
|
||||
{
|
||||
id: "global-2",
|
||||
title: "[대만] 국가 주도의 공업용수 공급",
|
||||
desc: "대만은 물 재이용에 적극적이며 국가주도로 하수재이용 사업 시행(안핑), 안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능...",
|
||||
imageSrc: "/images/landing/taiwan_01.png",
|
||||
href: "/reuse/business/globalCase/taiwan",
|
||||
desc: "수자원 지속 가능성 향상, 재생 수자원 활용을 통해 물부족 문제 해결. 재생 수자원 정의·분류, 이용 의무, 이용 제한, 수질 기준 등",
|
||||
imageSrc: "/images/landing/business/globalCase/taiwan_01.png",
|
||||
href: "/example#example-global-taiwan",
|
||||
},
|
||||
{
|
||||
id: "global-3",
|
||||
title: "[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급",
|
||||
desc: "SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망, 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급...",
|
||||
imageSrc: "/images/landing/australia_01.png",
|
||||
href: "/reuse/business/globalCase/australia",
|
||||
title: "[싱가포르] 수요가 높은 산업의 물 재이용 의무화",
|
||||
desc: "2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설. NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용",
|
||||
imageSrc: "/images/landing/business/globalCase/singapore_01.png",
|
||||
href: "/example#example-global-singapore",
|
||||
},
|
||||
{
|
||||
id: "global-4",
|
||||
title: "[싱가포르] 수요가 높은 산업의 물 재이용 의무화",
|
||||
desc: "2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설, NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용...",
|
||||
imageSrc: "/images/landing/singapore_01.png",
|
||||
href: "/reuse/business/globalCase/singapore",
|
||||
title: "[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급",
|
||||
desc: "SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망. 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급",
|
||||
imageSrc: "/images/landing/business/globalCase/australia_01.png",
|
||||
href: "/example#example-global-australia",
|
||||
},
|
||||
{
|
||||
id: "global-5",
|
||||
title: "[EU] 법률을 제정, EU국가에서 물 재이용 유도",
|
||||
desc: "유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741), 2023년부터 모든 EU국가에서 시행...",
|
||||
imageSrc: "/images/landing/eu_01.png",
|
||||
href: "/reuse/business/globalCase/eu",
|
||||
desc: "유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741), 2023년부터 모든 EU국가에서 시행",
|
||||
imageSrc: "/images/landing/business/globalCase/eu_01.png",
|
||||
href: "/example#example-global-eu",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -249,7 +279,7 @@ export default function CaseTabsCarousel() {
|
||||
// 탭별 마지막 그룹 인덱스 기억
|
||||
const tabIndexRef = useRef<Record<TabKey, number>>({
|
||||
rain: 0,
|
||||
reuse: 0,
|
||||
greywater: 0,
|
||||
sewage: 0,
|
||||
global: 0,
|
||||
});
|
||||
|
||||
+33
-159
@@ -1,181 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
fetchAnonymousMenu,
|
||||
findActiveTopMenu,
|
||||
hasAnyChild,
|
||||
isMenuActive,
|
||||
type MenuItem,
|
||||
} from "@/app/store/common";
|
||||
|
||||
export type LnbItem = {
|
||||
export type LnbMenu = {
|
||||
label: string;
|
||||
href?: string;
|
||||
children?: LnbItem[];
|
||||
href: string;
|
||||
};
|
||||
|
||||
function findOpenMenuCode(items: MenuItem[], pathname: string): number | null {
|
||||
const matched = items.find((item) => {
|
||||
if (isMenuActive(pathname, item)) return true;
|
||||
return (item.children ?? []).some((child) => isMenuActive(pathname, child));
|
||||
});
|
||||
type Props = {
|
||||
menus?: LnbMenu[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
return matched?.menuCd ?? null;
|
||||
}
|
||||
const defaultMenus: LnbMenu[] = [
|
||||
{ label: "수요처", href: "/apply/dmdco" },
|
||||
{ label: "수요정보등록", href: "/apply/dmdinfo" },
|
||||
{ label: "수요신청현황", href: "/apply/status" },
|
||||
];
|
||||
|
||||
export default function Lnb() {
|
||||
export default function Lnb({ menus, className = "" }: Props) {
|
||||
const pathname = usePathname();
|
||||
const [menus, setMenus] = useState<MenuItem[]>([]);
|
||||
const [openMenuCode, setOpenMenuCode] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchAnonymousMenu();
|
||||
if (!isMounted) return;
|
||||
console.log("Fetched LNB menus:", loaded);
|
||||
setMenus(loaded);
|
||||
} catch (error) {
|
||||
console.error("Failed to load DN LNB menu", error);
|
||||
if (!isMounted) return;
|
||||
setMenus([]);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentTopMenu = useMemo(
|
||||
() => findActiveTopMenu(menus, pathname),
|
||||
[menus, pathname],
|
||||
);
|
||||
|
||||
const secondLevelMenus = useMemo(
|
||||
() => currentTopMenu?.children ?? [],
|
||||
[currentTopMenu],
|
||||
);
|
||||
// console.log("LNB render", { pathname, currentTopMenu, secondLevelMenus, menus });
|
||||
useEffect(() => {
|
||||
const defaultOpen = findOpenMenuCode(secondLevelMenus, pathname);
|
||||
setOpenMenuCode(defaultOpen);
|
||||
}, [pathname, secondLevelMenus]);
|
||||
const navMenus = menus ?? defaultMenus;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
"fixed left-0 top-[83px]", // ✅ 화면 고정 + TopNav 아래로
|
||||
"w-[260px] min-w-[260px] shrink-0",
|
||||
"h-[calc(100vh-83px)]", // ✅ 남은 높이만큼
|
||||
"overflow-y-auto", // ✅ LNB 내부 스크롤
|
||||
"bg-white z-50", // ✅ 겹침 방지
|
||||
].join(" ")}
|
||||
>
|
||||
{/* 타이틀 */}
|
||||
<div className="flex h-[120px] items-center justify-center bg-gradient-to-r from-cyan-500 to-green-500 text-[40px] font-bold text-white">
|
||||
{currentTopMenu?.menuKornNm?.trim().split(/\s+/).pop() ?? ""}
|
||||
</div>
|
||||
<aside className={`fixed right-2 top-[110px] z-50 w-[260px] ${className}`}>
|
||||
<ul className="space-y-1">
|
||||
{navMenus.map((menu) => {
|
||||
const isActive =
|
||||
pathname === menu.href ||
|
||||
pathname.startsWith(`${menu.href}/`);
|
||||
|
||||
{/* 메뉴 */}
|
||||
<ul>
|
||||
{secondLevelMenus.map((item) => {
|
||||
const hasChildren = hasAnyChild(item);
|
||||
const active = isMenuActive(pathname, item);
|
||||
|
||||
if (!hasChildren) {
|
||||
return (
|
||||
<li key={item.menuCd}>
|
||||
<li key={menu.href}>
|
||||
<Link
|
||||
href={item.urlPath || "#"}
|
||||
className={[
|
||||
"block border-b border-slate-300 px-5 py-4 text-center text-base font-semibold transition-colors",
|
||||
active
|
||||
? "cursor-default bg-white text-sky-500"
|
||||
: "bg-[#F0F0F0] text-[#767676] hover:bg-white hover:text-sky-500",
|
||||
].join(" ")}
|
||||
href={menu.href}
|
||||
className={`
|
||||
block text-[16px] font-medium transition px-5 py-2
|
||||
${
|
||||
isActive
|
||||
? "text-[#00AEEF]"
|
||||
: "text-[#767676] hover:text-[#00AEEF]"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.menuKornNm}
|
||||
{menu.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const isOpen = openMenuCode === item.menuCd;
|
||||
const childMenus = item.children ?? [];
|
||||
|
||||
return (
|
||||
<li key={item.menuCd}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOpenMenuCode((prev) => (prev === item.menuCd ? null : item.menuCd))
|
||||
}
|
||||
className={[
|
||||
"flex w-full items-center justify-between border-b border-slate-300 px-5 py-6 text-base font-semibold",
|
||||
active ? "bg-slate-100 text-slate-900" : "bg-slate-100 text-slate-500",
|
||||
].join(" ")}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="w-full text-center">{item.menuKornNm}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={[
|
||||
"ml-3 shrink-0 transition-transform duration-200",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<Image
|
||||
src="/images/landing/drop_arrow.svg"
|
||||
alt="menu toggle icon"
|
||||
width={28}
|
||||
height={28}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={[
|
||||
"grid transition-[grid-template-rows] duration-200 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="overflow-hidden bg-white">
|
||||
<ul>
|
||||
{childMenus.map((child) => {
|
||||
const childActive = isMenuActive(pathname, child);
|
||||
|
||||
return (
|
||||
<li key={child.menuCd}>
|
||||
<Link
|
||||
href={child.urlPath || "#"}
|
||||
className={[
|
||||
"block border-b border-slate-200 bg-slate-50 px-5 py-4 pl-[60px] text-[18px] font-medium transition-colors",
|
||||
childActive
|
||||
? "cursor-default bg-white text-sky-500"
|
||||
: "text-slate-500 hover:bg-white hover:text-sky-500",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="mr-2 text-current">-</span>
|
||||
{child.menuKornNm}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 로그인 modal
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
@@ -50,6 +53,16 @@ export default function LoginModal({
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setBizNo("");
|
||||
setWorkerNo("");
|
||||
setNoWorker(true);
|
||||
setPw("");
|
||||
setErrors({});
|
||||
setTouched({});
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -61,13 +74,10 @@ export default function LoginModal({
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden"; // 모달 오픈 시 배경 스크롤 방지
|
||||
|
||||
// 모달 오픈 시 에러/터치 초기화
|
||||
setErrors({});
|
||||
setTouched({});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = prev;
|
||||
resetForm();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
@@ -151,7 +161,7 @@ export default function LoginModal({
|
||||
const parsed = JSON.parse(sessionUser) as { dmdCoNm?: string };
|
||||
onSuccess?.({ userName: parsed.dmdCoNm ?? "수요처" });
|
||||
onClose();
|
||||
router.refresh();
|
||||
router.push("/apply/status");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -342,7 +352,7 @@ export default function LoginModal({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(REGISTER_HREF)}
|
||||
onClick={() => {onClose(); router.push(REGISTER_HREF);}}
|
||||
className="shrink-0 rounded-[4px] h-[48px] px-10 bg-[#004A94] text-white text-[18px] hover:opacity-95"
|
||||
>
|
||||
수요처등록
|
||||
|
||||
@@ -30,14 +30,16 @@ function useDebouncedValue<T>(value: T, delay = 250) {
|
||||
export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [q, setQ] = useState(""); // 주소 입력값
|
||||
const dq = useDebouncedValue(q, 250);
|
||||
|
||||
const [items, setItems] = useState<AddrItem[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [items, setItems] = useState<AddrItem[]>([]); // 검색 결과
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]); // 추천 주소 목록
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchMessage, setSearchMessage] = useState("");
|
||||
const [searchMessage, setSearchMessage] = useState(""); // 검색 실패/형식 오류/보조 안내 문구를 담는 상태값
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [searchedInput, setSearchedInput] = useState(""); // 주소 검색 입력란 저장
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -51,6 +53,7 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
setTotal(0);
|
||||
setLoading(false);
|
||||
setSearchMessage("");
|
||||
setIsInputFocused(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,7 +62,7 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
setTimeout(() => inputRef.current?.focus(), 50); // 주소입력란에 focus()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -131,6 +134,7 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
|
||||
setItems(result.items);
|
||||
setTotal(result.totalCount);
|
||||
setSearchedInput(keyword);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
setItems([]);
|
||||
@@ -149,7 +153,7 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
};
|
||||
}, [dq, open]);
|
||||
|
||||
const showSuggest = open && q.trim().length >= 1 && suggestions.length > 0;
|
||||
const showSuggest = open && isInputFocused && q.trim().length >= 1 && suggestions.length > 0 && dq.trim() !== searchedInput;
|
||||
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
@@ -190,6 +194,10 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setIsInputFocused(false), 100);
|
||||
}}
|
||||
placeholder="예) 테헤란로 152-1"
|
||||
className={[
|
||||
"w-full rounded-md border px-4 py-3 pr-12 text-[14px] outline-none",
|
||||
@@ -226,8 +234,9 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
key={s}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-[14px] hover:bg-slate-50"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
// ✅ 추천 클릭 → 입력값 채우고 드롭다운 닫기 + 포커스 유지
|
||||
// 추천 클릭 → 입력값 초기화 드롭다운 닫기 + 포커스 유지
|
||||
setQ(s);
|
||||
setSuggestions([]);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
@@ -263,7 +272,9 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
<button
|
||||
key={`${it.zipNo}-${it.roadAddr}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(it)}
|
||||
onClick={() => {setQ("");
|
||||
setSuggestions([]);
|
||||
onSelect(it)}}
|
||||
className={[
|
||||
"w-full rounded-lg border p-4 text-left transition",
|
||||
"border-slate-200 hover:border-sky-300 hover:bg-sky-50/30",
|
||||
|
||||
@@ -16,27 +16,21 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function SubPageHeader({
|
||||
title,
|
||||
// title,
|
||||
crumbs,
|
||||
note = "* 는 필수 값입니다.",
|
||||
// note = "* 는 필수 값입니다.",
|
||||
// underlineWidthClass = "w-40",
|
||||
className = "",
|
||||
wrapClassName = "",
|
||||
// hideFooterLine = false,
|
||||
hideGuideText = false,
|
||||
// hideGuideText = false,
|
||||
}: Props) {
|
||||
return (
|
||||
<section className={`text-black ${className}`}>
|
||||
<section className={`text-black pr-[100px] ${className}`}>
|
||||
<div className={`pb-5 ${wrapClassName}`}>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
{/* 좌측 타이틀 */}
|
||||
<div className="inline-block">
|
||||
<h1 className="relative z-10 px-2 text-[28px] font-extrabold tracking-tight text-slate-900 leading-none">{title}</h1>
|
||||
<div className="relative top-[-7px] -z-1 h-4 bg-gradient-to-r from-sky-300 to-green-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* 우측 브레드크럼 */}
|
||||
<nav className="pt-2 text-sm text-black/70" aria-label="breadcrumb">
|
||||
<nav className="text-sm text-black/70" aria-label="breadcrumb">
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={`${c.label}-${i}`}>
|
||||
{c.href ? (
|
||||
@@ -49,15 +43,15 @@ export default function SubPageHeader({
|
||||
{i < crumbs.length - 1 && <span className="mx-2">›</span>}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
</nav>
|
||||
</div>
|
||||
{/* 하단 라인 + 안내문 */}
|
||||
{!hideGuideText && (
|
||||
{/* {!hideGuideText && (
|
||||
<div className="mt-6 border-t border-black/10 pt-4 text-sm text-black/70">
|
||||
<span className="text-red-400">*</span>
|
||||
{note.replace("*", "")}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,10 @@ import { useDmStore } from "@/app/store";
|
||||
const LS_USER_KEY = "user";
|
||||
|
||||
const DEFAULT_NAV_ITEMS: Pick<MenuItem, "menuCd" | "menuKornNm" | "urlPath" | "newWndwYn">[] = [
|
||||
{ menuCd: 1, menuKornNm: "물 재이용 소개", urlPath: "/reuse/intro/law", newWndwYn: "N" },
|
||||
{ menuCd: 2, menuKornNm: "물 재이용 현황", urlPath: "/reuse/cases/rain", newWndwYn: "N" },
|
||||
{ menuCd: 3, menuKornNm: "물 재이용 사례", urlPath: "/reuse/business/rain/suwon", newWndwYn: "N" },
|
||||
{ menuCd: 4, menuKornNm: "수요신청", urlPath: "/reuse/apply/place", newWndwYn: "N" },
|
||||
{ menuCd: 1, menuKornNm: "물 재이용 소개", urlPath: "/intro", newWndwYn: "N" },
|
||||
{ menuCd: 2, menuKornNm: "물 재이용 현황", urlPath: "/status", newWndwYn: "N" },
|
||||
{ menuCd: 3, menuKornNm: "물 재이용 사례", urlPath: "/example", newWndwYn: "N" },
|
||||
{ menuCd: 4, menuKornNm: "수요신청", urlPath: "/apply/dmdco", newWndwYn: "N" },
|
||||
];
|
||||
|
||||
export default function TopNav() {
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function SearchInputText({
|
||||
value={textValue || ""}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full h-8 text-xs pl-2 pr-2 text-gray-600 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
className="w-full h-[3.2rem] !text-xl pl-3 text-gray-600 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`${label}을(를) 입력하세요`}
|
||||
onClick={() => {
|
||||
setTextValue("");
|
||||
@@ -109,10 +109,10 @@ export default function SearchInputText({
|
||||
searchList.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-full cursor-pointer px-3 py-2 text-left text-xs ${
|
||||
className={`w-full cursor-pointer overflow-auto px-5 py-3 text-left !text-xl ${
|
||||
highlightIndex === index
|
||||
? "bg-blue-50"
|
||||
: "hover:bg-gray-50"
|
||||
? "bg-blue-100"
|
||||
: "hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => handleSuggestionClick(item.bscFctNm)}
|
||||
role="button"
|
||||
@@ -127,7 +127,7 @@ export default function SearchInputText({
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs w-full px-3 py-2 hover:bg-gray-50 cursor-pointer text-gray-500">
|
||||
<div className="!text-xl w-full px-5 py-3 hover:bg-gray-100 cursor-pointer overflow-auto">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,51 +62,50 @@ export function SearchBar({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full px-4 py-2">
|
||||
<div className="flex items-center w-auto shadow-md px-4 py-2 space-x-2 bg-white/95 backdrop-blur-sm rounded-lg text-sm border border-gray-200">
|
||||
<div className="flex justify-center w-full px-4 py-3">
|
||||
<div className="flex items-center w-full max-w-screen-lg rounded-xl bg-white shadow-md px-6 py-4 space-x-3">
|
||||
<SelectBox
|
||||
labelClass="w-[50px] text-xs font-medium text-gray-700"
|
||||
className="w-[110px]"
|
||||
labelClass="w-[40px]"
|
||||
className="w-[200px]"
|
||||
label="시도"
|
||||
selectClass="rounded bg-white text-xs h-8"
|
||||
selectClass="rounded-lg bg-white"
|
||||
selectArray={Common.selectSidoData || []}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "ctpvCd")}
|
||||
value={sidoValue || Common.selectSidoData[0]?.val || ""}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[70px] text-xs font-medium text-gray-700"
|
||||
className="w-[110px]"
|
||||
labelClass="w-[65px]"
|
||||
className="w-[200px]"
|
||||
label="시군구"
|
||||
selectClass="rounded bg-white text-xs h-8"
|
||||
selectClass="rounded-lg bg-white"
|
||||
selectArray={filteredSigunData || []}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "sggCd")}
|
||||
value={sigunValue}
|
||||
/>
|
||||
<Label className="text-gray-700 text-xs font-medium pl-2 pr-1">하수재이용시설</Label>
|
||||
<div className="w-[180px]">
|
||||
<SearchInputText
|
||||
label="시설명 입력"
|
||||
initValue={undefined}
|
||||
setValue={(searchNm?: string) => {
|
||||
let sggOrCity = "";
|
||||
if (searchNm) {
|
||||
if (sigunValue === "0" || sigunValue === "") {
|
||||
sggOrCity = sidoValue;
|
||||
} else {
|
||||
sggOrCity = sigunValue;
|
||||
}
|
||||
findRuseFclt(sggOrCity, searchNm);
|
||||
<Label className="text-gray-600">하수재이용시설</Label>
|
||||
<SearchInputText
|
||||
label="시설명"
|
||||
initValue={undefined}
|
||||
setValue={(searchNm?: string) => {
|
||||
let sggOrCity = "";
|
||||
if (searchNm) {
|
||||
if (sigunValue === "0" || sigunValue === "") {
|
||||
sggOrCity = sidoValue;
|
||||
} else {
|
||||
sggOrCity = sigunValue;
|
||||
}
|
||||
}}
|
||||
setRtnData={(rtnData: any) => {
|
||||
setHasuPk(rtnData);
|
||||
}}
|
||||
searchList={map.ruseFcltList || []}
|
||||
/>
|
||||
</div>
|
||||
findRuseFclt(sggOrCity, searchNm);
|
||||
}
|
||||
}}
|
||||
setRtnData={(rtnData: any) => {
|
||||
setHasuPk(rtnData);
|
||||
}}
|
||||
searchList={map.ruseFcltList || []}
|
||||
/>
|
||||
<Button
|
||||
color="sky"
|
||||
className="ml-2 text-white !h-8 !py-0 !px-3 !text-xs font-semibold rounded"
|
||||
className="ml-2 text-white"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onApply();
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user