diff --git a/services/one-app/package.json b/services/one-app/package.json
index a987c945..84824a68 100644
--- a/services/one-app/package.json
+++ b/services/one-app/package.json
@@ -15,12 +15,14 @@
},
"dependencies": {
"@faker-js/faker": "^9.0.3",
+ "@radix-ui/react-dropdown-menu": "^2.1.2",
"@svgr/webpack": "^8.1.0",
"@tanstack/react-query": "^5.59.16",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"next": "14.2.16",
+ "nuqs": "^2.2.2",
"query-string": "^9.1.1",
"react": "^18",
"react-dom": "^18",
diff --git a/services/one-app/src/app/(site)/layout.tsx b/services/one-app/src/app/(site)/layout.tsx
index 0741d4ee..f55263a6 100644
--- a/services/one-app/src/app/(site)/layout.tsx
+++ b/services/one-app/src/app/(site)/layout.tsx
@@ -1,4 +1,6 @@
import type { Metadata } from 'next';
+import { NuqsAdapter } from 'nuqs/adapters/next/app';
+
import '../globals.css';
import { RQProvider } from '../_components/RQProvider';
import { MSWComponent } from '../_components/MSWComponent';
@@ -19,7 +21,9 @@ export default function RootLayout({
- {children}
+
+ {children}
+
);
diff --git a/services/one-app/src/app/(site)/lost-found/[lostId]/page.tsx b/services/one-app/src/app/(site)/lost-found/[lostId]/page.tsx
index 2d57ae3e..500c67b5 100644
--- a/services/one-app/src/app/(site)/lost-found/[lostId]/page.tsx
+++ b/services/one-app/src/app/(site)/lost-found/[lostId]/page.tsx
@@ -1,7 +1,8 @@
import Link from 'next/link';
+
+import LostFoundPostDetail from '../_components/postDetail/PostDetail';
import PlusIcon from '@/common/assets/icons/plus';
-import { SuspenseQueryBoundary } from '@/common/components';
-import LostFoundDetail from '../_components/LostFoundDetail';
+import { SuspenseQueryBoundary } from '@/common/components/SuspenseQueryBoundary/SuspenseQueryBoundary';
type Props = {
params: {
@@ -18,8 +19,11 @@ export default function LostFoundDetailPage({ params }: Props) {
- loading}>
-
+ error}
+ suspenseFallback={loading
}
+ >
+
diff --git a/services/one-app/src/app/(site)/lost-found/_components/LostFoundDetail.tsx b/services/one-app/src/app/(site)/lost-found/_components/LostFoundDetail.tsx
deleted file mode 100644
index 114f2298..00000000
--- a/services/one-app/src/app/(site)/lost-found/_components/LostFoundDetail.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-'use client';
-
-import React from 'react';
-import { useGetLostFoundDetail } from '../_lib/get';
-
-type Params = {
- lostId: string;
-};
-
-const LostFoundDetail = ({ lostId }: Params) => {
- const { data } = useGetLostFoundDetail(lostId);
-
- return {data.title}
;
-};
-
-export default LostFoundDetail;
diff --git a/services/one-app/src/app/(site)/lost-found/_components/LostFoundList.tsx b/services/one-app/src/app/(site)/lost-found/_components/LostFoundList.tsx
deleted file mode 100644
index e32e0bf7..00000000
--- a/services/one-app/src/app/(site)/lost-found/_components/LostFoundList.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-'use client';
-
-import React from 'react';
-import Link from 'next/link';
-
-import { useGetLostFoundList } from '../_lib/get';
-import { flattenInfinityList } from '@/common/utils/react-query';
-import { useIntersectionObserver } from '@/common/hooks/useIntersectionObserver';
-
-const LostFoundList = () => {
- const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
- useGetLostFoundList({
- pageSize: 40,
- lostType: 'ACQUIRE',
- });
-
- const lostArticles = flattenInfinityList(data);
- const intersectCallback = () => !isFetchingNextPage && fetchNextPage();
-
- const { ref } = useIntersectionObserver({
- callback: intersectCallback,
- });
-
- return (
- <>
- {lostArticles.map((item, idx) => (
-
- {/* item.id로만 하면 key 중복이 발생하고 있음. 확인 필요 */}
- {item.title}
-
- ))}
- {hasNextPage && 더 보기}
- >
- );
-};
-
-export default LostFoundList;
diff --git a/services/one-app/src/app/(site)/lost-found/_components/postDetail/PostDetail.tsx b/services/one-app/src/app/(site)/lost-found/_components/postDetail/PostDetail.tsx
new file mode 100644
index 00000000..04889aab
--- /dev/null
+++ b/services/one-app/src/app/(site)/lost-found/_components/postDetail/PostDetail.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import React from 'react';
+import { useGetLostFoundDetail } from '../../_lib/get';
+
+type Props = {
+ lostId: string;
+};
+
+const LostFoundPostDetail = ({ lostId }: Props) => {
+ const { data } = useGetLostFoundDetail(lostId);
+
+ return {data.title}
;
+};
+
+export default LostFoundPostDetail;
diff --git a/services/one-app/src/app/(site)/lost-found/_components/searchResults/filterList/FilterList.tsx b/services/one-app/src/app/(site)/lost-found/_components/searchResults/filterList/FilterList.tsx
new file mode 100644
index 00000000..5feeda16
--- /dev/null
+++ b/services/one-app/src/app/(site)/lost-found/_components/searchResults/filterList/FilterList.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { FilterState } from '@/store/filter';
+import { LostFoundFilters, LostFoundType } from '@/model/LostFound';
+import { DropdownFilter } from '@/common/components/Filters/DropdownFilter';
+import { subwayLineIdOptions } from '@/common/constants/subway';
+import ResetFilter from '@/common/components/Filters/ResetFilter';
+import SearchFilter from '@/common/components/Filters/SearchFilter';
+
+const lostTypeOptions = {
+ [LostFoundType.LOST]: '분실물',
+ [LostFoundType.ACQUIRE]: '습득물',
+};
+
+interface LostFoundFilterListProps
+ extends Omit, 'loaded'> {}
+
+const LostFoundFilterList = ({
+ filters,
+ activedCount,
+ handleSelect,
+ handleReset,
+}: LostFoundFilterListProps) => {
+ return (
+
+ );
+};
+
+export default LostFoundFilterList;
diff --git a/services/one-app/src/app/(site)/lost-found/_components/searchResults/searchedList/SearchedList.tsx b/services/one-app/src/app/(site)/lost-found/_components/searchResults/searchedList/SearchedList.tsx
new file mode 100644
index 00000000..d7207be8
--- /dev/null
+++ b/services/one-app/src/app/(site)/lost-found/_components/searchResults/searchedList/SearchedList.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import React, { useMemo } from 'react';
+import Link from 'next/link';
+
+import type { LostFoundFilters } from '@/model/LostFound';
+import { flattenInfinityList } from '@/common/utils/react-query';
+import { gneratetAccurateSubwayLineId } from '@/common/utils/subway';
+import { useIntersectionObserver } from '@/common/hooks/useIntersectionObserver';
+import { useGetLostFoundList } from '@/app/(site)/lost-found/_lib/get';
+
+interface Props {
+ keyword: string | null;
+ filters: LostFoundFilters;
+}
+
+const LostFoundSearchedList = ({ keyword, filters }: Props) => {
+ const temporaryUserFavoriteLineId = 1; // TODO: 추후 유저가 즐겨찾는 역 설장하는 피쳐 개발 후 수정하기
+ const subwayLineId = useMemo(
+ () =>
+ gneratetAccurateSubwayLineId(
+ filters.subwayLineId,
+ temporaryUserFavoriteLineId,
+ ),
+ [filters.subwayLineId],
+ );
+
+ const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
+ useGetLostFoundList({
+ pageSize: 40,
+ keyword,
+ subwayLineId,
+ lostType: filters.lostType,
+ });
+
+ const lostArticles = flattenInfinityList(data);
+ const intersectCallback = () => !isFetchingNextPage && fetchNextPage();
+ const { ref: loadMoreRef } = useIntersectionObserver({
+ callback: intersectCallback,
+ });
+
+ return (
+ <>
+ {lostArticles.map((item, idx) => (
+ // item.id로만 하면 key 중복이 발생하고 있음.
+ // 해결 필요
+
+ {item.title}
+
+ ))}
+ {hasNextPage && (
+
+ 더 보기
+
+ )}
+ >
+ );
+};
+
+export default LostFoundSearchedList;
diff --git a/services/one-app/src/app/(site)/lost-found/_lib/get.ts b/services/one-app/src/app/(site)/lost-found/_lib/get.ts
index 104e1f42..d0e14cc6 100644
--- a/services/one-app/src/app/(site)/lost-found/_lib/get.ts
+++ b/services/one-app/src/app/(site)/lost-found/_lib/get.ts
@@ -10,13 +10,13 @@ import type {
LostFoundPostDetail,
LostFoundListParams,
} from '@/model/LostFound';
-import { TIMESTAMP } from '@/constants/time';
+import { TIMESTAMP } from '@/common/constants/time';
import { generateQueryKey } from '@/common/utils/react-query';
import { objectToQueryString } from '@/common/utils/object';
const getLostFoundList = (params: LostFoundListParams) =>
apiClient.get>>(
- `/lost-posts?${objectToQueryString(params)}`,
+ `/lost-posts?${objectToQueryString(params, { removeZero: true })}`,
);
export const useGetLostFoundList = (params: LostFoundListParams) =>
diff --git a/services/one-app/src/app/(site)/lost-found/_lib/useLostFoundFilterStore.ts b/services/one-app/src/app/(site)/lost-found/_lib/useLostFoundFilterStore.ts
new file mode 100644
index 00000000..3980188f
--- /dev/null
+++ b/services/one-app/src/app/(site)/lost-found/_lib/useLostFoundFilterStore.ts
@@ -0,0 +1,42 @@
+'use client';
+
+import { useQueryState } from 'nuqs';
+
+import {
+ APP_UNIQUE_FILTER_ID_LIST,
+ FilterState,
+ createFilterStoreWithPersist,
+} from '@/store/filter';
+import { SubwayLineFilterOptions } from '@/model/Subway';
+import { LostFoundType, type LostFoundFilters } from '@/model/LostFound';
+
+const LOST_FOUND_FILTER_DEFAULT_VALUES: LostFoundFilters = {
+ lostType: LostFoundType.LOST,
+ subwayLineId: SubwayLineFilterOptions.ALL_LINES,
+} as const;
+
+export const useLostFoundFilters = () => {
+ const [keyword] = useQueryState('keyword');
+ const { filters, loaded, activedCount, handleSelect, handleReset } =
+ createFilterStoreWithPersist(
+ LOST_FOUND_FILTER_DEFAULT_VALUES,
+ APP_UNIQUE_FILTER_ID_LIST.LOST_FOUND,
+ )();
+
+ const boundaryKeys = [...Object.values(filters), keyword];
+
+ const getFilterProps = (): Omit, 'loaded'> => ({
+ filters,
+ activedCount,
+ handleSelect,
+ handleReset,
+ });
+
+ return {
+ loaded,
+ filters,
+ keyword,
+ boundaryKeys,
+ getFilterProps,
+ };
+};
diff --git a/services/one-app/src/app/(site)/lost-found/page.tsx b/services/one-app/src/app/(site)/lost-found/page.tsx
index b1c6ea24..27f622d2 100644
--- a/services/one-app/src/app/(site)/lost-found/page.tsx
+++ b/services/one-app/src/app/(site)/lost-found/page.tsx
@@ -1,19 +1,40 @@
-import Link from 'next/link';
-import PlusIcon from '@/common/assets/icons/plus';
-import LostFoundList from './_components/LostFoundList';
-import { SuspenseQueryBoundary } from '@/common/components';
+'use client';
+import { Suspense } from 'react';
-export default function LostFoundPage() {
- return (
+import LostFoundFilterList from './_components/searchResults/filterList/FilterList';
+import LostFoundSearchedList from './_components/searchResults/searchedList/SearchedList';
+import { useLostFoundFilters } from './_lib/useLostFoundFilterStore';
+import { SuspenseQueryBoundary } from '@/common/components/SuspenseQueryBoundary/SuspenseQueryBoundary';
+
+const LoadingPage = () => Loading...
;
+
+function LostFound() {
+ const { loaded, keyword, filters, boundaryKeys, getFilterProps } =
+ useLostFoundFilters();
+
+ return loaded ? (
}>
-
+
+ {}}
+ errorFallback={error
}
+ suspenseFallback={loading
}
+ >
+
+ ) : (
+
+ );
+}
+
+export default function LostFoundPage() {
+ return (
+ }>
+
+
);
}
diff --git a/services/one-app/src/common/assets/icons/chevron-down.tsx b/services/one-app/src/common/assets/icons/chevron-down.tsx
new file mode 100644
index 00000000..5ea5977c
--- /dev/null
+++ b/services/one-app/src/common/assets/icons/chevron-down.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+
+function ChevronDownIcon() {
+ return (
+
+ );
+}
+
+export default ChevronDownIcon;
diff --git a/services/one-app/src/common/components/ConditionalRender.tsx b/services/one-app/src/common/components/ConditionalRender.tsx
index 8eda01f7..e8bf0b0e 100644
--- a/services/one-app/src/common/components/ConditionalRender.tsx
+++ b/services/one-app/src/common/components/ConditionalRender.tsx
@@ -11,7 +11,6 @@ interface Props {
* 선언적으로 간편한 조건부 렌더링을 제공합니다.
* @param props
*/
-export const ConditionalRender = (props: Props) => {
- const { children, isRender } = props;
+export const ConditionalRender = ({ isRender, children }: Props) => {
return <>{isRender ? children : null}>;
};
diff --git a/services/one-app/src/common/components/Filters/DropdownFilter.tsx b/services/one-app/src/common/components/Filters/DropdownFilter.tsx
new file mode 100644
index 00000000..9cc2d46f
--- /dev/null
+++ b/services/one-app/src/common/components/Filters/DropdownFilter.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+
+import type { KeyOf } from '@/model/Utils';
+import { objectEntries } from '@/common/utils/object';
+import CheckIcon from '@/common/assets/icons/check';
+import ChevronDownIcon from '@/common/assets/icons/chevron-down';
+
+export interface DropdownFilterProps<
+ T extends Record,
+ K extends KeyOf,
+> {
+ name: K;
+ filters: T;
+ options: Record;
+ onSelect: (key: K, value: T[K]) => void;
+}
+
+export const DropdownFilter = <
+ T extends Record,
+ K extends KeyOf,
+>({
+ filters,
+ options,
+ onSelect,
+ name,
+}: DropdownFilterProps): React.ReactElement => {
+ const activeValue = filters[name];
+ const defaultValue = Object.keys(options)[0] as T[K];
+ const isActive = activeValue !== defaultValue;
+
+ return (
+
+
+
+
+
+ onSelect(name, newValue as T[K])}
+ >
+ {objectEntries(options).map(([val, label]) => (
+
+
+
+
+ {label}
+
+ ))}
+
+
+
+ );
+};
diff --git a/services/one-app/src/common/components/Filters/ResetFilter.tsx b/services/one-app/src/common/components/Filters/ResetFilter.tsx
new file mode 100644
index 00000000..d17bdeed
--- /dev/null
+++ b/services/one-app/src/common/components/Filters/ResetFilter.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import ChevronDownIcon from '@/common/assets/icons/chevron-down';
+import { ConditionalRender } from '../ConditionalRender';
+
+interface Props {
+ activedCount: number;
+ handleReset: () => void;
+}
+const ResetFilter = ({ activedCount, handleReset }: Props) => {
+ return (
+ 0}>
+
+
+
+
+
+
+
+ {activedCount}개 필터가 적용됨.
+
+
+ 모든 필터 지우기
+
+
+
+
+ );
+};
+
+export default ResetFilter;
diff --git a/services/one-app/src/common/components/Filters/SearchFilter.tsx b/services/one-app/src/common/components/Filters/SearchFilter.tsx
new file mode 100644
index 00000000..bebf78c3
--- /dev/null
+++ b/services/one-app/src/common/components/Filters/SearchFilter.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useQueryState } from 'nuqs';
+import { useDebounce } from '@/common/hooks/useDebounce';
+
+const SearchFilter = () => {
+ const [keyword, setKeyword] = useQueryState('keyword', {
+ scroll: true,
+ });
+ const [inputValue, setInputValue] = useState(keyword);
+
+ const debouncedSetKeyword = useDebounce((value: string) => {
+ setKeyword(value || null);
+ }, 300);
+
+ useEffect(() => {
+ setInputValue(keyword);
+ }, [keyword]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ setInputValue(value);
+ debouncedSetKeyword(value);
+ };
+
+ return (
+
+ );
+};
+
+export default SearchFilter;
diff --git a/services/one-app/src/common/components/SuspenseQueryBoundary/ErrorBoundary.tsx b/services/one-app/src/common/components/SuspenseQueryBoundary/ErrorBoundary.tsx
index 0c5c8983..4b55914f 100644
--- a/services/one-app/src/common/components/SuspenseQueryBoundary/ErrorBoundary.tsx
+++ b/services/one-app/src/common/components/SuspenseQueryBoundary/ErrorBoundary.tsx
@@ -3,20 +3,20 @@
import React from 'react';
import { isChangedArray } from '@/common/utils/array';
-type ErrorFallbackType = (
- props: ErrorFallbackProps,
-) => React.ReactNode;
+type ErrorFallbackProps = {
+ error: Error;
+ reset: () => void;
+};
-interface ErrorFallbackProps {
- error: ErrorType;
- reset: VoidFunction;
-}
+type ErrorFallbackType =
+ | React.ReactNode
+ | ((props: ErrorFallbackProps) => React.ReactNode);
interface ErrorBoundaryProps {
+ keys?: unknown[];
children: React.ReactNode;
- errorFallback?: ErrorFallbackType;
+ errorFallback: ErrorFallbackType;
resetError?: VoidFunction;
- keys?: unknown[];
}
interface ErrorBoundaryState {
@@ -66,11 +66,11 @@ export class BaseErrorBoundary extends React.Component<
const { hasError, error } = this.state;
const { children, errorFallback } = this.props;
- const isErrExist = hasError && error !== null;
- const fallbackUI = (err: ErrorFallbackProps['error']) =>
- errorFallback?.({ error: err, reset: this.resetBoundary });
-
- if (isErrExist) return fallbackUI(error);
+ if (hasError && error !== null) {
+ return typeof errorFallback === 'function'
+ ? errorFallback({ error, reset: this.resetBoundary })
+ : errorFallback;
+ }
return children;
}
}
diff --git a/services/one-app/src/constants/colors.ts b/services/one-app/src/common/constants/colors.ts
similarity index 100%
rename from services/one-app/src/constants/colors.ts
rename to services/one-app/src/common/constants/colors.ts
diff --git a/services/one-app/src/common/constants/subway.ts b/services/one-app/src/common/constants/subway.ts
new file mode 100644
index 00000000..cf8c00e7
--- /dev/null
+++ b/services/one-app/src/common/constants/subway.ts
@@ -0,0 +1,6 @@
+import { SubwayLineFilterOptions } from '@/model/Subway';
+
+export const subwayLineIdOptions = {
+ [SubwayLineFilterOptions.ALL_LINES]: '전체 호선 보기',
+ [SubwayLineFilterOptions.ONLY_MY_LINE]: '내 호선만 보기',
+} as const;
diff --git a/services/one-app/src/constants/time.ts b/services/one-app/src/common/constants/time.ts
similarity index 100%
rename from services/one-app/src/constants/time.ts
rename to services/one-app/src/common/constants/time.ts
diff --git a/services/one-app/src/common/utils/object.ts b/services/one-app/src/common/utils/object.ts
index b56398a6..b5466464 100644
--- a/services/one-app/src/common/utils/object.ts
+++ b/services/one-app/src/common/utils/object.ts
@@ -1,4 +1,5 @@
import queryString from 'query-string';
+import type { ObjectKeys } from '@/model/Utils';
export const isValidObject = (obj: unknown): obj is Record => {
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
@@ -35,3 +36,11 @@ export const objectToQueryString = >(
return queryString.stringify(removeFalsyValues(params, options));
};
+
+export function objectEntries>(
+ obj: Type,
+): Array<[ObjectKeys, Type[ObjectKeys]]> {
+ return Object.entries(obj) as Array<
+ [ObjectKeys, Type[ObjectKeys]]
+ >;
+}
diff --git a/services/one-app/src/common/utils/subway.ts b/services/one-app/src/common/utils/subway.ts
new file mode 100644
index 00000000..b3ea4a1d
--- /dev/null
+++ b/services/one-app/src/common/utils/subway.ts
@@ -0,0 +1,12 @@
+export const gneratetAccurateSubwayLineId = (
+ filtersSubwayLineId: string,
+ userFavoriteLineId: number,
+) => {
+ switch (filtersSubwayLineId) {
+ case 'ALL_LINES':
+ return 0;
+ case 'ONLY_MY_LINE':
+ default:
+ return userFavoriteLineId;
+ }
+};
diff --git a/services/one-app/src/model/LostFound.ts b/services/one-app/src/model/LostFound.ts
index 002bc84a..a6ab2fb7 100644
--- a/services/one-app/src/model/LostFound.ts
+++ b/services/one-app/src/model/LostFound.ts
@@ -1,9 +1,12 @@
import type { PostImage } from './PostImage';
-import type { WithSubwayLineId } from './Subway';
+import type { SubwayLineFilterOptions, WithSubwayLineId } from './Subway';
import type { Post, RecommendPost } from './Post';
import type { CursorBasedPaginationParams } from './Utils';
-export type LostType = 'LOST' | 'ACQUIRE';
+export enum LostFoundType {
+ LOST = 'LOST',
+ ACQUIRE = 'ACQUIRE',
+}
export type LostStatus = 'PROGRESS' | 'COMPLETE';
export interface LostFoundPost extends Post {
@@ -25,6 +28,17 @@ export interface LostFoundPostDetail extends LostFoundPost {
export interface LostFoundListParams
extends CursorBasedPaginationParams,
Partial {
- lostType: LostType;
- keyword?: string;
+ keyword: string | null;
+ lostType: LostFoundType;
}
+
+export type LostFoundFilterKeys = 'lostType' | 'subwayLineId';
+
+export type LostFoundFilterValues = {
+ lostType: LostFoundType;
+ subwayLineId: SubwayLineFilterOptions;
+};
+
+export type LostFoundFilters = {
+ [K in LostFoundFilterKeys]: LostFoundFilterValues[K];
+};
diff --git a/services/one-app/src/model/Subway.ts b/services/one-app/src/model/Subway.ts
index 0c901032..12e036bb 100644
--- a/services/one-app/src/model/Subway.ts
+++ b/services/one-app/src/model/Subway.ts
@@ -1,3 +1,8 @@
export interface WithSubwayLineId {
subwayLineId: number;
}
+
+export enum SubwayLineFilterOptions {
+ ALL_LINES = 'ALL_LINES',
+ ONLY_MY_LINE = 'ONLY_MY_LINE',
+}
diff --git a/services/one-app/src/model/Utils.ts b/services/one-app/src/model/Utils.ts
index dbb9385c..d68d7e3e 100644
--- a/services/one-app/src/model/Utils.ts
+++ b/services/one-app/src/model/Utils.ts
@@ -1,3 +1,9 @@
+export type KeyOf = keyof T;
+export type ValueOf = T[keyof T];
+export type StringRecord = Record;
+export type ObjectKeys> =
+ `${Exclude}`;
+
export interface IResponse {
code: string;
message: string;
diff --git a/services/one-app/src/store/filter.ts b/services/one-app/src/store/filter.ts
new file mode 100644
index 00000000..4522cff3
--- /dev/null
+++ b/services/one-app/src/store/filter.ts
@@ -0,0 +1,63 @@
+import { create, StateCreator } from 'zustand';
+import { persist } from 'zustand/middleware';
+import type { KeyOf, ValueOf, StringRecord } from '@/model/Utils';
+
+export const filterKey = 'AHAHCHUL_FILTER_STORAGE';
+
+export type AppUniqueFilterId = ValueOf;
+export enum APP_UNIQUE_FILTER_ID_LIST {
+ COMMUNITY = 'COMMUNITY',
+ LOST_FOUND = 'LOST_FOUND',
+}
+
+export type FilterState = {
+ filters: T;
+ loaded: boolean;
+ activedCount: number;
+ handleSelect: >(key: K, value: T[K]) => void;
+ handleReset: () => void;
+};
+
+type FilterStoreCreator = StateCreator>;
+
+const createFilterStoreWithPersist = (
+ defaultValues: T,
+ uniqueId: AppUniqueFilterId,
+) => {
+ const createStore: FilterStoreCreator = (set) => ({
+ filters: defaultValues,
+ activedCount: 0,
+ loaded: false,
+ handleSelect: (key, value) => {
+ set((state) => {
+ const isDefaultValue = value === defaultValues[key];
+ const wasDefaultValue = state.filters[key] === defaultValues[key];
+
+ const newFilters = { ...state.filters, [key]: value };
+ const newActiveFilterCount =
+ state.activedCount +
+ (isDefaultValue && !wasDefaultValue ? -1 : 0) +
+ (!isDefaultValue && wasDefaultValue ? 1 : 0);
+
+ return {
+ filters: newFilters,
+ activedCount: newActiveFilterCount,
+ };
+ });
+ },
+ handleReset: () => set({ filters: defaultValues, activedCount: 0 }),
+ });
+
+ return create>()(
+ persist(createStore, {
+ name: `${filterKey}-${uniqueId}`,
+ onRehydrateStorage: () => (state) => {
+ if (state) {
+ state.loaded = true;
+ }
+ },
+ }),
+ );
+};
+
+export { createFilterStoreWithPersist };
diff --git a/services/one-app/tailwind.config.ts b/services/one-app/tailwind.config.ts
index c832c023..042c5835 100644
--- a/services/one-app/tailwind.config.ts
+++ b/services/one-app/tailwind.config.ts
@@ -1,6 +1,6 @@
import type { Config } from 'tailwindcss';
-const colors = require('./src/constants/colors');
+const colors = require('./src/common/constants/colors');
const config: Config = {
content: [
diff --git a/yarn.lock b/yarn.lock
index e7b8bb61..96eeef47 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -56,6 +56,7 @@ __metadata:
dependencies:
"@faker-js/faker": "npm:^9.0.3"
"@mswjs/http-middleware": "npm:^0.10.2"
+ "@radix-ui/react-dropdown-menu": "npm:^2.1.2"
"@svgr/webpack": "npm:^8.1.0"
"@tanstack/react-query": "npm:^5.59.16"
"@tanstack/react-query-devtools": "npm:^5.59.16"
@@ -82,6 +83,7 @@ __metadata:
js-cookie: "npm:^3.0.5"
msw: "npm:^2.5.2"
next: "npm:14.2.16"
+ nuqs: "npm:^2.2.2"
postcss: "npm:^8"
query-string: "npm:^9.1.1"
react: "npm:^18"
@@ -6548,6 +6550,19 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-context@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-context@npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10c0/fc4ace9d79d7954c715ade765e06c95d7e1b12a63a536bcbe842fb904f03f88fc5bd6e38d44bd23243d37a270b4c44380fedddaeeae2d274f0b898a20665aba2
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-dialog@npm:^1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-dialog@npm:1.1.1"
@@ -6655,6 +6670,29 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-dismissable-layer@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-dismissable-layer@npm:1.1.1"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.0"
+ "@radix-ui/react-compose-refs": "npm:1.1.0"
+ "@radix-ui/react-primitive": "npm:2.0.0"
+ "@radix-ui/react-use-callback-ref": "npm:1.1.0"
+ "@radix-ui/react-use-escape-keydown": "npm:1.1.0"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/637f8d55437bd2269d5aa9fa48e869eade31082cd950b5efcc5f0d9ed016b46feb7fcfcc115ba9972dba68c4686b57873d84aca67ece76ab77463e7de995f6da
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-dropdown-menu@npm:^2.1.1":
version: 2.1.1
resolution: "@radix-ui/react-dropdown-menu@npm:2.1.1"
@@ -6680,6 +6718,31 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-dropdown-menu@npm:^2.1.2":
+ version: 2.1.2
+ resolution: "@radix-ui/react-dropdown-menu@npm:2.1.2"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.0"
+ "@radix-ui/react-compose-refs": "npm:1.1.0"
+ "@radix-ui/react-context": "npm:1.1.1"
+ "@radix-ui/react-id": "npm:1.1.0"
+ "@radix-ui/react-menu": "npm:2.1.2"
+ "@radix-ui/react-primitive": "npm:2.0.0"
+ "@radix-ui/react-use-controllable-state": "npm:1.1.0"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/28e84cb116a34c3a73cd9be774170fc920fad6254c1ce285e8e3d86e33c02011229adc5590e385a42106b41bced23e0a482e884e6894e37f68d7e87c76171279
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-focus-guards@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-focus-guards@npm:1.0.1"
@@ -6708,6 +6771,19 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-focus-guards@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-focus-guards@npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10c0/2e99750ca593083a530542a185d656b45b100752353a7a193a67566e3c256414a76fa9171d152f8c0167b8d6c1fdf62b2e07750d7af2974bf8ef39eb204aa537
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-focus-scope@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-focus-scope@npm:1.0.3"
@@ -6827,6 +6903,42 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-menu@npm:2.1.2":
+ version: 2.1.2
+ resolution: "@radix-ui/react-menu@npm:2.1.2"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.0"
+ "@radix-ui/react-collection": "npm:1.1.0"
+ "@radix-ui/react-compose-refs": "npm:1.1.0"
+ "@radix-ui/react-context": "npm:1.1.1"
+ "@radix-ui/react-direction": "npm:1.1.0"
+ "@radix-ui/react-dismissable-layer": "npm:1.1.1"
+ "@radix-ui/react-focus-guards": "npm:1.1.1"
+ "@radix-ui/react-focus-scope": "npm:1.1.0"
+ "@radix-ui/react-id": "npm:1.1.0"
+ "@radix-ui/react-popper": "npm:1.2.0"
+ "@radix-ui/react-portal": "npm:1.1.2"
+ "@radix-ui/react-presence": "npm:1.1.1"
+ "@radix-ui/react-primitive": "npm:2.0.0"
+ "@radix-ui/react-roving-focus": "npm:1.1.0"
+ "@radix-ui/react-slot": "npm:1.1.0"
+ "@radix-ui/react-use-callback-ref": "npm:1.1.0"
+ aria-hidden: "npm:^1.1.1"
+ react-remove-scroll: "npm:2.6.0"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/4259f6fbc63048d58bffab443abda9b56ea6b0a28f1e4ae91787a360b9a31e7604de06c8fc70be861c1aaa7abff2858c9314aa3fffbc375c27b0c9aa219a51af
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-popper@npm:1.1.2":
version: 1.1.2
resolution: "@radix-ui/react-popper@npm:1.1.2"
@@ -6924,6 +7036,26 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-portal@npm:1.1.2":
+ version: 1.1.2
+ resolution: "@radix-ui/react-portal@npm:1.1.2"
+ dependencies:
+ "@radix-ui/react-primitive": "npm:2.0.0"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.0"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/836967330893b16b85371775ed1a59e038ce99189f4851cfa976bde2710d704c2a9e49e0a5206e7ac3fcf8a67ddd2d126b8352a88f295d6ef49d04e269736ed1
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-presence@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-presence@npm:1.0.1"
@@ -6965,6 +7097,26 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-presence@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-presence@npm:1.1.1"
+ dependencies:
+ "@radix-ui/react-compose-refs": "npm:1.1.0"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.0"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/777cda0406450ff5ca0e49235e486237723323d046a3382e35a0e78eededccfc95a76a9b5fecd7404dac793264762f4bc10111af1e08f8cc2d4d571d7971220e
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-primitive@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-primitive@npm:1.0.3"
@@ -21944,6 +22096,13 @@ __metadata:
languageName: node
linkType: hard
+"mitt@npm:^3.0.1":
+ version: 3.0.1
+ resolution: "mitt@npm:3.0.1"
+ checksum: 10c0/3ab4fdecf3be8c5255536faa07064d05caa3dd332bd318ff02e04621f7b3069ca1de9106cfe8e7ced675abfc2bec2ce4c4ef321c4a1bb1fb29df8ae090741913
+ languageName: node
+ linkType: hard
+
"mkdirp-classic@npm:^0.5.2":
version: 0.5.3
resolution: "mkdirp-classic@npm:0.5.3"
@@ -22705,6 +22864,27 @@ __metadata:
languageName: node
linkType: hard
+"nuqs@npm:^2.2.2":
+ version: 2.2.2
+ resolution: "nuqs@npm:2.2.2"
+ dependencies:
+ mitt: "npm:^3.0.1"
+ peerDependencies:
+ "@remix-run/react": ">=2"
+ next: ">=14.2.0"
+ react: ">=18.2.0 || ^19.0.0-0"
+ react-router-dom: ">=6"
+ peerDependenciesMeta:
+ "@remix-run/react":
+ optional: true
+ next:
+ optional: true
+ react-router-dom:
+ optional: true
+ checksum: 10c0/d7c68eb22cf12b99b7e23204d9d979434f93e684ed47e293bf0851ca4f741d23fe8c5f338cf16a12b0c6712997433c9ab3f64b2055c21b9a6a4e84d30dac4bf8
+ languageName: node
+ linkType: hard
+
"nwsapi@npm:^2.2.0":
version: 2.2.7
resolution: "nwsapi@npm:2.2.7"
@@ -25201,7 +25381,7 @@ __metadata:
languageName: node
linkType: hard
-"react-remove-scroll-bar@npm:^2.3.4":
+"react-remove-scroll-bar@npm:^2.3.4, react-remove-scroll-bar@npm:^2.3.6":
version: 2.3.6
resolution: "react-remove-scroll-bar@npm:2.3.6"
dependencies:
@@ -25255,6 +25435,25 @@ __metadata:
languageName: node
linkType: hard
+"react-remove-scroll@npm:2.6.0":
+ version: 2.6.0
+ resolution: "react-remove-scroll@npm:2.6.0"
+ dependencies:
+ react-remove-scroll-bar: "npm:^2.3.6"
+ react-style-singleton: "npm:^2.2.1"
+ tslib: "npm:^2.1.0"
+ use-callback-ref: "npm:^1.3.0"
+ use-sidecar: "npm:^1.1.2"
+ peerDependencies:
+ "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10c0/c5881c537477d986e8d25d2588a9b6f7fe1254e05946fb4f4b55baeead502b0e1875fc3c42bb6f82736772cd96a50266e41d84e3c4cd25e9525bdfe2d838e96d
+ languageName: node
+ linkType: hard
+
"react-router-dom@npm:^6.21.3":
version: 6.22.3
resolution: "react-router-dom@npm:6.22.3"