Skip to content
68 changes: 39 additions & 29 deletions src/components/Alert/Alert.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";

import AlertCard from "./AlertCard";
import useAlarm from "./hooks/useAlarm";
import useAlerts from "./hooks/useAlerts";

import {
Active as ActiveAlarmIcon,
Expand All @@ -10,25 +10,29 @@ import {
} from "@/assets/icon";
import useIntersection from "@/hooks/useIntersection";
import useOutsideClick from "@/hooks/useOutsideClick";
import useRemoveTopPageScroll from "@/hooks/useRemoveTopPageScroll";

interface AlertProps {
userId: string;
}

function Alert({ userId }: AlertProps) {
const [showDropdown, setShowDropdown] = useState(false);
const { isLoading, hasNext, refetch, alerts, totalCount } = useAlarm({
userId,
});

const hasAlarm = useMemo(
() => alerts.filter(({ read }) => !read).length > 0,
[alerts],
);
const AlarmIcon = hasAlarm ? ActiveAlarmIcon : InactiveAlarmIcon;
const { isLoading, hasNext, hasAlarm, refetch, alerts, totalCount } =
useAlerts({
userId,
});
const [showDropdown, setShowDropdown] = useState<boolean>(false);
const [hasUnreadAlarm, setHasUnreadAlarm] = useState<boolean>(false);

const buttonRef = useRef<HTMLButtonElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const targetRef = useIntersection<HTMLDivElement>({
callback: ([entry]) => {
if (entry.isIntersecting && !isLoading && hasNext) {
refetch();
}
},
});

const onToggleAlarm = () => {
setShowDropdown((prev) => !prev);
Expand All @@ -39,44 +43,50 @@ function Alert({ userId }: AlertProps) {
callback: () => setShowDropdown(false),
});

const targetRef = useIntersection({
callback: ([entry]) => {
if (entry.isIntersecting && !isLoading && hasNext) {
refetch();
}
},
useRemoveTopPageScroll({
observeDevices: ["mobile"],
condition: showDropdown,
});

useEffect(() => {
setHasUnreadAlarm(hasAlarm);
}, [hasAlarm]);

useEffect(() => {
setHasUnreadAlarm(false);
}, [showDropdown]);

return (
<div className="relative flex items-center justify-center">
<button
ref={buttonRef}
className="cursor-pointer"
onClick={onToggleAlarm}
>
<AlarmIcon className="h-6" />
{hasUnreadAlarm ? (
<ActiveAlarmIcon className="h-6" />
) : (
<InactiveAlarmIcon className="h-6" />
)}
{/* <AlarmIcon className="h-6" /> */}
</button>
{showDropdown && (
<div
ref={wrapperRef}
className="fixed sm:absolute inset-0 sm:inset-auto sm:top-8 sm:right-0 z-20 sm:w-[23rem] sm:h-[26.875rem] py-6 px-5 rounded-[0.625rem] bg-red-10 border border-gray-30 text-left"
className="fixed sm:absolute inset-0 sm:inset-auto sm:top-8 sm:right-0 z-20 sm:w-[23rem] sm:h-[26.875rem] py-6 px-5 sm:rounded-[0.625rem] bg-red-10 border border-gray-30 text-left"
>
<p className="flex justify-between mb-4 text-left text-xl">
알림 {totalCount}개{" "}
알림 {totalCount}개
<Close
className="md:hidden w-6 h-6 cursor-pointer"
className="sm:hidden w-6 h-6 cursor-pointer"
onClick={onToggleAlarm}
/>
</p>
<ul className="flex flex-col gap-2 h-[calc(100%-2.75rem)] sm:h-[20.625rem] font-normal overflow-y-auto">
{alerts.map((alert, index) => (
<AlertCard
key={alert.id}
ref={index === alerts.length - 1 ? targetRef : null}
userId={userId}
alert={alert}
/>
{alerts.map((alert) => (
<AlertCard key={alert.id} userId={userId} alert={alert} />
))}
<div ref={targetRef} />
{isLoading && "로딩 중 ..."}
</ul>
</div>
Expand Down
19 changes: 7 additions & 12 deletions src/components/Alert/AlertCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { forwardRef, Ref, useState } from "react";
import { forwardRef, Ref } from "react";

import { putAlert } from "@/apis/services/alertService";
import useIntersection from "@/hooks/useIntersection";
Expand All @@ -14,16 +14,13 @@ interface AlertCardProps {
const AlertCard = forwardRef(
({ alert, userId }: AlertCardProps, ref: Ref<HTMLLIElement>) => {
const { id, shop, notice, read, result, createdAt } = alert;
const [readStatus, setReadStatus] = useState(read);

const isAccepted = result === "accepted";

const targetRef = useIntersection({
callback: async ([entry]) => {
if (entry.isIntersecting && userId && !read) {
const result = await putAlert(userId, id);

if (result.status === 200) {
setReadStatus(result.data.items[0].item.read);
}
await putAlert(userId, id);
}
},
});
Expand All @@ -36,17 +33,15 @@ const AlertCard = forwardRef(
<span
className={cn(
"inline-block my-1 w-[0.3125rem] h-[0.3125rem] rounded-full",
readStatus ? "bg-blue-20" : "bg-red-40",
isAccepted ? "bg-blue-20" : "bg-red-40",
)}
/>
<p className="text-sm">
{shop.item.name}(
{formatTimeRange(notice.item.startsAt, notice.item.workhour)}) 공고
지원이{" "}
<strong
className={result === "accepted" ? "text-blue-20" : "text-red-40"}
>
{result === "accepted" ? "승인" : "거절"}
<strong className={isAccepted ? "text-blue-20" : "text-red-40"}>
{isAccepted ? "승인" : "거절"}
</strong>
되었어요.
</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";

import { getAlerts } from "@/apis/services/alertService";
import { AlertItem } from "@/types/alert";
Expand All @@ -9,18 +9,17 @@ interface UseAlarmParams {
limit?: number;
}

const useAlarm = ({ userId, offset = 5, limit = 5 }: UseAlarmParams) => {
const useAlerts = ({ userId, offset = 10, limit = 10 }: UseAlarmParams) => {
const [isLoading, setIsLoading] = useState(false);
const [totalCount, setTotalCount] = useState<number>(0);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [hasNext, setHasNext] = useState(false);
const [hasAlarm, setHasAlarm] = useState(false);

const hasFetched = useRef(false);
const pageRef = useRef(1);

const fetchAlerts = async () => {
const fetchAlerts = useCallback(async () => {
setIsLoading(true);

try {
const fetchedAlerts = await getAlerts(
userId,
Expand All @@ -29,8 +28,11 @@ const useAlarm = ({ userId, offset = 5, limit = 5 }: UseAlarmParams) => {
);
const nextAlerts = fetchedAlerts.data.items.map(({ item }) => item);

const hasUnreadAlert = nextAlerts.filter(({ read }) => !read).length > 0;

setAlerts((prev) => [...prev, ...nextAlerts]);
setHasNext(fetchedAlerts.data.hasNext);
setHasAlarm(hasUnreadAlert);
setTotalCount(fetchedAlerts.data.count);

if (fetchedAlerts.data.hasNext) {
Expand All @@ -39,22 +41,20 @@ const useAlarm = ({ userId, offset = 5, limit = 5 }: UseAlarmParams) => {
} finally {
setIsLoading(false);
}
};
}, [userId, offset, limit]);

useEffect(() => {
if (hasFetched.current) return;
hasFetched.current = true;

fetchAlerts();
}, [userId, offset, limit]);
}, [userId, fetchAlerts]);

return {
refetch: fetchAlerts,
hasNext,
alerts,
isLoading,
totalCount,
hasNext,
hasAlarm,
isLoading,
};
};

export default useAlarm;
export default useAlerts;
7 changes: 5 additions & 2 deletions src/components/Dropdown/FilterDropdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import { extractDigits, numberCommaFormatter } from "@/utils/number";
import "react-datepicker/dist/react-datepicker.css";

interface FilterDropdownContentProps {
onClickApplyButton: () => void;
refetch: () => void;
onClose: () => void;
onClickApplyButton: () => void;
}

function FilterDropdownContent({
refetch,
onClose,
onClickApplyButton,
}: FilterDropdownContentProps) {
Expand Down Expand Up @@ -75,11 +77,12 @@ function FilterDropdownContent({
setPayFilter(null);
setStartDateFilter(null);
setAreasFilter([]);
refetch();
};

return (
<div className="flex flex-col w-full h-full sm:w-[24.375rem] text-black text-sm relative">
<div className="pt-6 pb-20 sm:pt-6 px-5 overflow-y-auto">
<div className="pt-6 pb-20 sm:pb-0 sm:pt-6 px-5 overflow-y-auto">
<div className="pb-6 flex justify-between items-center">
<span className="text-xl font-bold">상세 필터</span>
<button onClick={onClose} className="cursor-pointer">
Expand Down
9 changes: 8 additions & 1 deletion src/components/NoticeSearchResultHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Button from "@/components/Button";
import FilterDropdownContent from "@/components/Dropdown/FilterDropdownContent";
import Select from "@/components/Select";
import useOutsideClick from "@/hooks/useOutsideClick";
import useRemoveTopPageScroll from "@/hooks/useRemoveTopPageScroll";
import { SORT_OPTIONS } from "@/pages/NoticeSearchPage/constantsCopy";
import { useFilterStore } from "@/store/useFilterStore";
import type { SortKey } from "@/types/notice";
Expand Down Expand Up @@ -44,6 +45,11 @@ export default function NoticeSearchResultHeader({
callback: () => setShowFilter(false),
});

useRemoveTopPageScroll({
observeDevices: ["mobile"],
condition: showFilter,
});

const handleSortChange = (value: string) => {
onChangeSort(value as SortKey);
};
Expand Down Expand Up @@ -94,10 +100,11 @@ export default function NoticeSearchResultHeader({
ref={wrapperRef}
className={cn(
"fixed inset-0 sm:inset-auto sm:absolute sm:top-12 sm:right-0 z-10 bg-white",
"border border-gray-20 shadow-xl rounded-[0.625rem] overflow-hidden",
"border border-gray-20 shadow-xl sm:rounded-[0.625rem] overflow-hidden",
)}
>
<FilterDropdownContent
refetch={refetch}
onClickApplyButton={clickFilterConfirmHandler}
onClose={toggleShowFilter}
/>
Expand Down
23 changes: 23 additions & 0 deletions src/hooks/useBreakpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";

import { debounce } from "@/utils/debounce";
import { DeviceType, getDeviceType } from "@/utils/device";

function useBreakpoint(): DeviceType {
const [device, setDevice] = useState<DeviceType>(() =>
getDeviceType(window.innerWidth),
);

useEffect(() => {
const handleResize = debounce(() => {
setDevice(getDeviceType(window.innerWidth));
}, 200);

window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);

return device;
}

export default useBreakpoint;
7 changes: 5 additions & 2 deletions src/hooks/useIntersection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ interface UseIntersectionParams {
deps?: DependencyList;
}

const useIntersection = ({ callback, deps }: UseIntersectionParams) => {
const useIntersection = <T extends HTMLElement>({
callback,
deps,
}: UseIntersectionParams) => {
const dependencies = deps ?? [];
const targetRef = useRef<HTMLLIElement>(null);
const targetRef = useRef<T>(null);

useEffect(() => {
const observer = new IntersectionObserver(callback);
Expand Down
28 changes: 28 additions & 0 deletions src/hooks/useRemoveTopPageScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useEffect } from "react";

import useBreakpoint from "./useBreakpoint";

import { DeviceType } from "@/utils/device";

interface UseRemoveTopPageScrollParams {
condition: boolean;
observeDevices: DeviceType[];
}

function useRemoveTopPageScroll({
observeDevices,
condition,
}: UseRemoveTopPageScrollParams) {
const device = useBreakpoint();

useEffect(() => {
const deviceCondition = observeDevices.includes(device);
if (condition && deviceCondition) {
document.body.classList.add("overflow-hidden");
} else {
document.body.classList.remove("overflow-hidden");
}
}, [device, condition, observeDevices]);
}

export default useRemoveTopPageScroll;
16 changes: 16 additions & 0 deletions src/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function debounce<T extends (...args: unknown[]) => void>(
func: T,
delay: number,
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;

return (...args: Parameters<T>) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}

timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
}
Loading