Merge branch 'iipbs/dm/dev'

This commit is contained in:
2026-05-08 09:01:04 +09:00
180 changed files with 12025 additions and 3468 deletions
+242
View File
@@ -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>
);
}
+96 -113
View File
@@ -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>
);
}
}
+100 -70
View File
@@ -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
View File
@@ -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>
);
}
}
+16 -6
View File
@@ -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"
>
수요처등록
+19 -8
View File
@@ -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",
+9 -15
View File
@@ -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>
);
+4 -4
View File
@@ -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();
}}