diff --git a/src/components/Dropdown/AlarmDropdownContent.tsx b/src/components/Dropdown/AlarmDropdownContent.tsx new file mode 100644 index 0000000..633e1ff --- /dev/null +++ b/src/components/Dropdown/AlarmDropdownContent.tsx @@ -0,0 +1,56 @@ +import { useEffect } from "react"; + +import { useAlarmStore } from "@/store/useAlarmStore"; + +export default function AlarmDropdownContent({ + onClose, +}: { + onClose: () => void; +}) { + const { alarms, markAllAsRead } = useAlarmStore(); + + useEffect(() => { + markAllAsRead(); + }, [markAllAsRead]); + + return ( +
+
+ 알림 {alarms.length}개 + +
+ + {alarms.length === 0 ? ( +
알림이 없습니다.
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/Dropdown/DropdownPopover.tsx b/src/components/Dropdown/DropdownPopover.tsx new file mode 100644 index 0000000..073edd0 --- /dev/null +++ b/src/components/Dropdown/DropdownPopover.tsx @@ -0,0 +1,108 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import clsx from "clsx"; +import ReactDOM from "react-dom"; + +interface DropdownPopoverProps { + anchorRef: React.RefObject; + onClose: () => void; + align?: "left" | "right"; + variant?: "filter" | "alarm"; + children: React.ReactNode; +} + +const DropdownPopover = ({ + anchorRef, + onClose, + align = "left", + variant = "filter", + children, +}: DropdownPopoverProps) => { + const popoverRef = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 0 }); + + // 위치 계산 함수 + const calculatePosition = useCallback(() => { + const anchor = anchorRef.current; + const isMobile = window.innerWidth < 768; + const POPUP_WIDTH = variant === "alarm" ? 368 : 390; + + if (!anchor) return; + + const rect = anchor.getBoundingClientRect(); + + if (isMobile) { + setPosition({ top: 0, left: 0 }); + } else { + setPosition({ + top: rect.bottom + window.scrollY + 8, + left: + align === "right" + ? rect.right + window.scrollX - POPUP_WIDTH + : rect.left + window.scrollX, + }); + } + }, [anchorRef, align, variant]); + + // 창 크기 변경 대응 + useEffect(() => { + calculatePosition(); + window.addEventListener("resize", calculatePosition); + return () => { + window.removeEventListener("resize", calculatePosition); + }; + }, [calculatePosition]); + + // 클릭 외부 감지 → 드롭다운 닫기 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + anchorRef.current && + !anchorRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [anchorRef, onClose]); + + const isFilter = variant === "filter"; + + const sizeClass = isFilter + ? "md:w-[24.375rem] md:h-[52.75rem]" + : "md:w-[23rem] md:h-[26.25rem]"; + + const colorClass = isFilter + ? "bg-white border-gray-200" + : "bg-red-50 border-gray-300"; + + const popoverClass = clsx( + "z-[9999]", + "rounded-2xl p-6 pr-5 pb-6 pl-5", + "shadow-[0px_2px_8px_0px_#78748640]", + "border fixed md:absolute w-full h-full left-0 top-0", + colorClass, + sizeClass, + ); + + return ReactDOM.createPortal( +
+ {children} +
, + document.body, + ); +}; + +export default DropdownPopover; diff --git a/src/components/Dropdown/FilterDropdownContent.tsx b/src/components/Dropdown/FilterDropdownContent.tsx new file mode 100644 index 0000000..895ee78 --- /dev/null +++ b/src/components/Dropdown/FilterDropdownContent.tsx @@ -0,0 +1,125 @@ +import Button from "@/components/Button"; +import { useFilterStore } from "@/store/useFilterStore"; +import { SeoulDistricts } from "@/types/common"; + +function FilterDropdownContent({ onClose }: { onClose: () => void }) { + const { + selectedAreas, + startDate, + minPay, + setAreas, + setStartDate, + setMinPay, + reset, + } = useFilterStore(); + + const toggleArea = (area: string) => { + const updated = selectedAreas.includes(area) + ? selectedAreas.filter((a) => a !== area) + : [...selectedAreas, area]; + setAreas(updated); + }; + + return ( +
+
+ 상세 필터 + +
+ +
+

위치

+
+ {SeoulDistricts.map((area) => ( + + ))} +
+ + {selectedAreas.length > 0 && ( +
+ {selectedAreas.map((area) => ( + + {area} + + + ))} +
+ )} +
+ +
+ +
+

시작일

+ setStartDate(e.target.value)} + className="w-full h-[3.625rem] border border-gray-300 rounded-md px-4 text-base text-black placeholder:text-gray-400 bg-white" + placeholder="입력" + /> +
+ +
+ +
+

금액

+
+
+ setMinPay(Number(e.target.value))} + className="w-[10.5rem] h-[3.625rem] border border-gray-300 rounded-md px-4 pr-[2.5rem] text-base text-black placeholder:text-gray-400" + placeholder="입력" + /> + + 원 + +
+ 이상부터 +
+
+ +
+ + +
+
+ ); +} + +export default FilterDropdownContent; diff --git a/src/store/useAlarmStore.ts b/src/store/useAlarmStore.ts new file mode 100644 index 0000000..53525c9 --- /dev/null +++ b/src/store/useAlarmStore.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; + +export type Alarm = { + id: string; + message: string; + createdAt: string; + read: boolean; + status: "승인" | "거절"; +}; + +interface AlarmStore { + alarms: Alarm[]; + hasUnread: boolean; + setAlarms: (alarms: Alarm[]) => void; + markAllAsRead: () => void; +} + +export const useAlarmStore = create((set, get) => ({ + alarms: [], + hasUnread: false, + setAlarms: (alarms) => + set({ + alarms, + hasUnread: alarms.some((a) => !a.read), + }), + markAllAsRead: () => { + const updated = get().alarms.map((a) => ({ ...a, read: true })); + set({ alarms: updated, hasUnread: false }); + }, +})); diff --git a/src/store/useFilterStore.ts b/src/store/useFilterStore.ts new file mode 100644 index 0000000..a35fb45 --- /dev/null +++ b/src/store/useFilterStore.ts @@ -0,0 +1,28 @@ +import { create } from "zustand"; + +interface FilterState { + selectedAreas: string[]; + startDate: string | null; + minPay: number | null; + setAreas: (areas: string[]) => void; + setStartDate: (date: string) => void; + setMinPay: (pay: number | null) => void; + reset: () => void; +} + +export const useFilterStore = create((set) => ({ + selectedAreas: [], + startDate: null, + minPay: null, + + setAreas: (areas) => set({ selectedAreas: areas }), + setStartDate: (date) => set({ startDate: date }), + setMinPay: (pay) => set({ minPay: pay }), + + reset: () => + set({ + selectedAreas: [], + startDate: null, + minPay: null, + }), +}));