diff --git a/apps/web/src/app/(backButton)/search/loading.tsx b/apps/web/src/app/(backButton)/search/loading.tsx new file mode 100644 index 00000000..ff6b581b --- /dev/null +++ b/apps/web/src/app/(backButton)/search/loading.tsx @@ -0,0 +1,14 @@ +'use client' + +import { ClipLoader } from 'react-spinners' + +export default function Loading() { + return ( +
+
+ +
+
+
+ ) +} diff --git a/apps/web/src/app/(backButton)/search/page.tsx b/apps/web/src/app/(backButton)/search/page.tsx new file mode 100644 index 00000000..8e33ddfa --- /dev/null +++ b/apps/web/src/app/(backButton)/search/page.tsx @@ -0,0 +1,15 @@ +// apps/web/src/app/(backButton)/search/page.tsx +import SearchScreen from '@/components/search/SearchScreen' + +type SearchParams = Promise> + +export default async function Page({ searchParams }: { searchParams?: SearchParams }) { + const sp = (await searchParams) ?? {} + + const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : (v ?? '')) + + const initialQuery = first(sp.query) + const initialCategory = first(sp.category) + + return +} diff --git a/apps/web/src/components/events/event.tsx b/apps/web/src/components/events/event.tsx index 2f1e4299..c3c56f80 100644 --- a/apps/web/src/components/events/event.tsx +++ b/apps/web/src/components/events/event.tsx @@ -24,6 +24,20 @@ export default function Event({ event }: Props) { ) } + const toDate = (value: Date | string | undefined) => { + if (!value) return undefined + const d = value instanceof Date ? value : new Date(value) + return Number.isNaN(d.getTime()) ? undefined : d + } + + const formatRange = (start: Date | string | undefined, end: Date | string | undefined) => { + const s = toDate(start) + const e = toDate(end) + if (!s && !e) return '날짜 미정' + const render = (d?: Date) => (d ? format(d, 'yyyy-MM-dd') : '미정') + return `${render(s)}~${render(e)}` + } + const { setEvent } = eventStore() const { setTitle } = titleStore() const onClick = () => { @@ -39,7 +53,7 @@ export default function Event({ event }: Props) { >
{event.name}
-
{`${format(event.startDate, 'yyyy-MM-dd')}~${format(event.endDate, 'yyyy-MM-dd')}`}
+
{formatRange(event.startDate, event.endDate)}
{event.address}
diff --git a/apps/web/src/components/events/photoUploader.tsx b/apps/web/src/components/events/photoUploader.tsx index 2953c0a8..961b0c04 100644 --- a/apps/web/src/components/events/photoUploader.tsx +++ b/apps/web/src/components/events/photoUploader.tsx @@ -1,3 +1,4 @@ +/* eslint-disable prettier/prettier */ import React, { useRef } from 'react' import { ClipLoader } from 'react-spinners' @@ -57,6 +58,7 @@ const ImagePreview: React.FC<{ s3Key: string; onRemove: () => void }> = ({ s3Key return (
+ {/* eslint-disable-next-line @next/next/no-img-element */} {`Uploaded + ) + })} +
+ +
+ + +
+
+ ) +} + +function hasIcon(name: string) { + return Object.prototype.hasOwnProperty.call(CATEGORY_ICON, name) +} + +function iconByName(name: string, active: boolean) { + const src: StaticImageData | undefined = CATEGORY_ICON[name as keyof typeof CATEGORY_ICON] + if (!src) return null + const size = 20 + return ( + + ) +} diff --git a/apps/web/src/components/search/SearchResultCard.tsx b/apps/web/src/components/search/SearchResultCard.tsx new file mode 100644 index 00000000..6b7cc999 --- /dev/null +++ b/apps/web/src/components/search/SearchResultCard.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useMemo } from 'react' +import { ClipLoader } from 'react-spinners' + +import { useImageLoader } from '@/hooks/s3/useImageLoader' + +const FALLBACK_IMAGE = 'https://picsum.photos/seed/fallback/600/400' + +export default function SearchResultCard({ + title, + region, + period, + image, + imageHeight, +}: { + title: string + region: string + period: string + image: string + imageHeight?: number +}) { + const { imageUrl, isLoading } = useImageLoader(image) + + const resolvedSrc = useMemo(() => { + if (imageUrl) return imageUrl + return FALLBACK_IMAGE + }, [imageUrl]) + + return ( +
+
+ {isLoading ? ( +
+ +
+ ) : ( + // eslint-disable-next-line @next/next/no-img-element + {title} + )} +
+ +

{title}

+

{region}

+

{period}

+
+ ) +} diff --git a/apps/web/src/components/search/SearchScreen.tsx b/apps/web/src/components/search/SearchScreen.tsx new file mode 100644 index 00000000..7d32f16e --- /dev/null +++ b/apps/web/src/components/search/SearchScreen.tsx @@ -0,0 +1,233 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { getEventsByCategory, getEventsCategories } from '@/api/event' +import { fixedCategory, IEvent } from '@fienmee/types' +import CategoryTabs from '@/components/search/CategoryTabs' +import SearchResultCard from '@/components/search/SearchResultCard' +import { SearchIcon } from '@/components/icon' +import { ClipLoader } from 'react-spinners' + +type Props = { initialQuery?: string; initialCategory?: string } + +type EventItem = { + id: string + title: string + region: string + period: { start: string; end: string } + image: string + category: string +} + +// 기간 포맷 +const fmtPeriod = ({ start, end }: { start: string; end: string }) => { + const s = start?.trim() + const e = end?.trim() + if (!s && !e) return '일정 미정' + return `${s || '미정'} ~ ${e || '미정'}` +} + +// 주소창 파라미터만 얕게 갱신 +const shallowSetParam = (next: Record) => { + const url = new URL(window.location.href) + Object.entries(next).forEach(([k, v]) => { + if (v) url.searchParams.set(k, v as string) + else url.searchParams.delete(k) + }) + window.history.replaceState(null, '', url.toString()) +} + +export default function SearchScreen({ initialQuery = '', initialCategory = '' }: Props) { + const [query, setQuery] = useState(initialQuery) + const [category, setCategory] = useState(initialCategory) + const [dynCategories, setDynCategories] = useState([]) + const [isCatLoading, setIsCatLoading] = useState(true) + + const [items, setItems] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const lastReq = useRef(0) + + // 카테고리 불러오기(결과 있는 것만 노출, 없으면 첫 번째 자동선택) + useEffect(() => { + let alive = true + ;(async () => { + try { + setIsCatLoading(true) + const res = await getEventsCategories() + const titles = [ + ...(res.favoriteCategories?.map(c => c.title) ?? []), + ...(res.categories?.map(c => c.title) ?? []), + ...(res.defaultCategories?.map(c => c.title) ?? []), + ] + const deduped = Array.from(new Set(titles)).filter(Boolean) + const available = await filterAvailableCategories(deduped) + if (alive) { + setDynCategories(available) + if (category && !available.includes(category)) setCategory('') + if (!category && available.length > 0) setCategory(available[0]) + } + } catch { + if (alive) setDynCategories([]) + } finally { + if (alive) setIsCatLoading(false) + } + })() + return () => { + alive = false + } + }, []) + + // 카테고리 변경 시 결과 로드 (중복 호출 가드) + useEffect(() => { + let alive = true + const load = async () => { + setIsLoading(true) + const myReq = ++lastReq.current + try { + if (category) { + const code = titleToCode(category) + if (code) { + const res = await getEventsByCategory(code, 1, 10) + const mapped = res.events.map(mapEventToItem) + if (!alive || myReq !== lastReq.current) return + setItems(mapped) + } else { + if (!alive || myReq !== lastReq.current) return + setItems([]) + } + } else { + if (!alive || myReq !== lastReq.current) return + setItems([]) + } + } catch { + if (!alive || myReq !== lastReq.current) return + setItems([]) + } finally { + if (!alive || myReq !== lastReq.current) return + setIsLoading(false) + } + } + const id = setTimeout(load, 200) + return () => { + alive = false + clearTimeout(id) + } + }, [category]) + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault() + shallowSetParam({ query, category }) + } + + const hasNoResult = useMemo(() => !isLoading && items.length === 0, [isLoading, items]) + + return ( +
+ {/* 검색창 */} +
+
+ setQuery(e.target.value)} + placeholder="검색어를 입력하세요" + className="flex-1 bg-transparent outline-none text-[15px] indent-4" + aria-label="검색" + /> + +
+
+ + {isCatLoading ? ( +
+ +
+ ) : ( + <> + {/* 카테고리 탭 */} + { + const next = c === category ? '' : c + setCategory(next) + shallowSetParam({ category: next, query }) + }} + /> + +
+ + {/* 결과 목록 */} +
+ {isLoading ? ( +
+ +
+ ) : hasNoResult ? ( +

검색 결과가 없습니다.

+ ) : ( +
    + {items.map(it => ( +
  • + +
  • + ))} +
+ )} +
+ + )} + +
+
+ ) +} + +// 도우미 +function titleToCode(title: string): string | undefined { + const entries = Object.entries(fixedCategory) as [string, { title: string; code: string }][] + const hit = entries.find(([, v]) => v.title === title) + return hit?.[1].code +} + +function mapEventToItem(e: IEvent): EventItem { + const img = Array.isArray(e.photo) && e.photo.length > 0 ? e.photo[0] : '' + const formatDate = (d: Date | string | undefined) => { + if (!d) return '' + const dt = d instanceof Date ? d : new Date(d) + return Number.isNaN(dt.getTime()) ? '' : dt.toISOString().slice(0, 10) + } + return { + id: e._id, + title: e.name, + region: e.address ?? '', + period: { start: formatDate(e.startDate), end: formatDate(e.endDate) }, + image: img, + category: Array.isArray(e.category) && e.category[0]?.title ? e.category[0].title : '', + } +} + +async function filterAvailableCategories(titles: string[]): Promise { + const entries = titles.map(t => ({ title: t, code: titleToCode(t) })) + const checks = await Promise.allSettled( + entries.map(async ({ title, code }) => { + if (!code) return { title, ok: false } + try { + const res = await getEventsByCategory(code, 1, 1) + return { title, ok: (res.events?.length ?? 0) > 0 } + } catch { + return { title, ok: false } + } + }), + ) + return checks + .filter(r => r.status === 'fulfilled' && (r as PromiseFulfilledResult<{ title: string; ok: boolean }>).value.ok) + .map(r => (r as PromiseFulfilledResult<{ title: string; ok: boolean }>).value.title) +} diff --git a/apps/web/src/components/searchBar.tsx b/apps/web/src/components/searchBar.tsx index 5479f65d..fce3671f 100644 --- a/apps/web/src/components/searchBar.tsx +++ b/apps/web/src/components/searchBar.tsx @@ -2,22 +2,27 @@ import { SearchIcon } from '@/components/icon' import { SubmitHandler, useForm } from 'react-hook-form' +import { useRouter } from 'next/navigation' interface Inputs { text: string } export default function SearchBar() { + const router = useRouter() + const { register, handleSubmit, formState: { isSubmitting }, } = useForm() - const onSubmit: SubmitHandler = (data: Inputs) => { - // TODO: add search logic - console.log(data) + const onSubmit: SubmitHandler = ({ text }) => { + const q = text.trim() + if (!q) return + router.push(`/search?query=${encodeURIComponent(q)}`) } + return (
value.trim() !== '' || 'title is required', + validate: v => v.trim() !== '' || 'title is required', })} /> -