Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/web/src/app/(navigation)/search/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client'

import { ClipLoader } from 'react-spinners'

export default function Loading() {
return (
<div className="flex h-screen items-center justify-center bg-white">
<ClipLoader color="#FF6B6B" size={50} />
</div>
Comment thread
HeesooJun marked this conversation as resolved.
Outdated
)
}
10 changes: 10 additions & 0 deletions apps/web/src/app/(navigation)/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SearchScreen from '@/components/search/SearchScreen'

type Props = { searchParams?: { query?: string; category?: string } }

export default function Page({ searchParams }: Props) {
const initialQuery = (searchParams?.query ?? '').toString()
const initialCategory = (searchParams?.category ?? '').toString()
Comment thread
HeesooJun marked this conversation as resolved.
Outdated

return <SearchScreen initialQuery={initialQuery} initialCategory={initialCategory} />
}
19 changes: 19 additions & 0 deletions apps/web/src/components/icon/Categoryicon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { StaticImageData } from 'next/image'

import 문화행사 from './문화행사.png'
import 스포츠 from './스포츠.png'
import 야외활동 from './야외활동.png'
import 음악 from './음악.png'
import 취업 from './취업.png'
import 팝업행사 from './팝업행사.png'
import 패션 from './패션.png'

export const CATEGORY_ICON: Record<string, StaticImageData> = {
문화행사,
스포츠,
야외활동,
음악,
취업,
팝업행사,
패션,
}
Comment on lines +26 to +49

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 행사 카테고리 중 해당 카테고리들을 선택한 이유가 있을까요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

화면만 구성하려고 임시로 다 넣은거라 나중에 추가하려고 했습니다!!

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
143 changes: 143 additions & 0 deletions apps/web/src/components/search/CategoryTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
'use client'

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Image from 'next/image'
import { CATEGORY_ICON } from '@/components/icon/Categoryicon'

// 1) 허용 카테고리(아이콘이 있는 것만)
const VALID = ['문화행사', '스포츠', '야외활동', '음악', '취업', '팝업행사', '패션'] as const

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 기존 카테고리 이름들이 있는데, 이렇게 사용하는 이유가 있을까요?

type CategoryName = (typeof VALID)[number]

// 2) 별칭(들어오면 매핑해서 사용) — 필요 시 추가
const ALIAS: Record<string, CategoryName> = {
취미: '문화행사',
레저: '야외활동',
공연음악: '음악',
}

const VALID_SET = new Set<string>(VALID)

function normalizeList(list: string[]): CategoryName[] {
// 별칭→정규명 → 유효한 것만 → 중복 제거
const mapped = list
.map(n => ALIAS[n] ?? n) // 별칭 치환
.filter((n): n is CategoryName => VALID_SET.has(n)) // 유효만
return Array.from(new Set(mapped))
}

function normalizeOne(name?: string): CategoryName | undefined {
if (!name) return undefined
const n = ALIAS[name] ?? name
return VALID_SET.has(n) ? (n as CategoryName) : undefined
}

export default function CategoryTabs({ items, current, onChange }: { items: string[]; current?: string; onChange: (value: string) => void }) {
const wrapRef = useRef<HTMLDivElement>(null)
const rowRef = useRef<HTMLDivElement>(null)
const btnRefs = useRef<(HTMLButtonElement | null)[]>([])
const lblRefs = useRef<(HTMLSpanElement | null)[]>([])

// 정규화된 목록/현재값
const normItems = useMemo(() => normalizeList(items), [items])
const normCurrent = useMemo(() => normalizeOne(current), [current])

const INDICATOR_W = 60
const [left, setLeft] = useState(0)

const activeIndex = useMemo(() => {
const idx = normItems.findIndex(x => x === normCurrent)
return idx >= 0 ? idx : 0
}, [normItems, normCurrent])

const measure = useCallback(() => {
const row = rowRef.current
const btn = btnRefs.current[activeIndex]
const lbl = lblRefs.current[activeIndex]
if (!row || !btn || !lbl) return

const labelW = lbl.offsetWidth
const labelCenter = (btn.offsetWidth - labelW) / 2 + labelW / 2
const x = btn.offsetLeft - row.scrollLeft + labelCenter - INDICATOR_W / 2
setLeft(x)
}, [activeIndex])

useEffect(() => {
const row = rowRef.current
const wrap = wrapRef.current
if (!row || !wrap) return

const onScroll = () => requestAnimationFrame(measure)
const ro = new ResizeObserver(() => requestAnimationFrame(measure))

ro.observe(wrap)
ro.observe(row)
row.addEventListener('scroll', onScroll, { passive: true })

const raf = requestAnimationFrame(measure)

return () => {
cancelAnimationFrame(raf)
ro.disconnect()
row.removeEventListener('scroll', onScroll)
}
}, [measure, activeIndex])

return (
<div className="select-none">
<div ref={wrapRef} className="relative px-8">
<div ref={rowRef} className="flex items-center gap-6 overflow-x-auto pb-3 no-scrollbar">
{normItems.map((name, i) => {
const active = normCurrent === name
return (
<button
key={name}
ref={el => {
btnRefs.current[i] = el
}}
onClick={() => onChange(active ? '' : name)}
className="flex flex-col items-center px-2 py-1 shrink-0"
>
<div className="mb-2 leading-none">{iconByName(name, active)}</div>
<span
ref={el => {
lblRefs.current[i] = el
}}
className={`text-[12px] ${active ? 'font-semibold text-black' : 'text-gray-500'}`}
>
{name}
</span>
</button>
)
})}
</div>

{/* 회색 베이스 라인 */}
<div className="absolute left-[-24px] right-[-24px] bottom-0 h-[2px] bg-gray-100 z-0" />
Comment thread
HeesooJun marked this conversation as resolved.

{/* 주황 인디케이터 라인 */}
<span
className="absolute bottom-0 h-[3px] rounded-full bg-[#FF9575] z-10 transition-[left] duration-200"
style={{ left, width: INDICATOR_W }}
/>
</div>
</div>
)
}

function iconByName(name: CategoryName, active: boolean) {
const src = CATEGORY_ICON[name] ?? CATEGORY_ICON['문화행사']
const size = name === '팝업행사' ? 21 : 20

return (
<Image
src={src}
alt=""
width={size}
height={size}
draggable={false}
priority={false}
className={`block ${active ? '' : 'opacity-45'}`}
style={{ imageRendering: 'crisp-edges' }}
/>
)
}
15 changes: 15 additions & 0 deletions apps/web/src/components/search/SearchResultCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client'

export default function SearchResultCard({ title, region, period, image }: { title: string; region: string; period: string; image: string }) {
return (
<article className="w-full">
<div className="rounded-2xl overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt={title} className="w-full h-[200px] object-cover" loading="lazy" />
</div>
Comment thread
HeesooJun marked this conversation as resolved.
Outdated
<h2 className="mt-3 text-[16px] font-semibold">{title}</h2>
<p className="mt-1 text-[13px] text-gray-500">{region}</p>
<p className="mt-1 text-[13px] text-gray-500">{period}</p>
</article>
)
}
167 changes: 167 additions & 0 deletions apps/web/src/components/search/SearchScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
'use client'

import { useEffect, useMemo, useState } from 'react'
import CategoryTabs from '@/components/search/CategoryTabs'
import { SearchIcon } from '@/components/icon'
import { ClipLoader } from 'react-spinners'

type Props = { initialQuery?: string; initialCategory?: string }

type EventItem = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 행사 데이터와 동일한 형태로 예시를 수정하는 것이 좋아보여요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정해보겠습니다

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이부분도 반영되지 않은 것 같습니다

id: string
title: string
region: string
period: { start: string; end: string }
image: string
category: string
}

const MOCK: EventItem[] = [
{
id: '1',
title: '진해 벚꽃 축제',
region: '경남 창원시 진해구',
period: { start: '2025-04-01', end: '2025-04-10' },
image: 'https://www.hygn.go.kr/_res/tour/img/sub/04/CherryBlossom_img02.jpg',
category: '팝업행사',
},
{
id: '2',
title: '서울 세계 불꽃 축제',
region: '서울 송파구',
period: { start: '2025-05-20', end: '2025-06-02' },
image: 'https://www.hanwha.co.kr/upload/news/press/2024/10/02/1727831937311_62.jpg',
category: '문화행사',
},
{
id: '3',
title: '벚꽃',
region: '부산 금정구',
period: { start: '2025-04-05', end: '2025-04-07' },
image: 'https://mediahub.seoul.go.kr/uploads/mediahub/2025/04/auKJgrjhemWLuzyMwmtLrYSuxKfuhNYk.jpg',
category: '패션',
},
]

const CATEGORIES = ['팝업행사', '패션', '스포츠', '야외활동', '문화행사', '음악', '취미'] as const
const fmtPeriod = ({ start, end }: { start: string; end: string }) => `${start.replaceAll('-', '.')} ~ ${end.replaceAll('-', '.')}`

//주소창 업데이트
const shallowSetParam = (next: Record<string, string | ''>) => {
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 [items, setItems] = useState<EventItem[]>([])
const [isLoading, setIsLoading] = useState(true)

// 필터 함수 (실제 API 연동 시 fetch 대체)
const filterLocal = (q: string, c: string): EventItem[] => {
const ql = q.trim().toLowerCase()
let filtered = MOCK.filter(e => {
const byQ = !ql || e.title.toLowerCase().includes(ql) || e.region.toLowerCase().includes(ql)
const byC = !c || e.category === c
return byQ && byC
})
if (filtered.length < 3) {
const extra = MOCK.filter(m => !filtered.some(f => f.id === m.id))
filtered = [...filtered, ...extra].slice(0, 3)
}
return filtered
}

// 카테고리/검색어 변경 시
useEffect(() => {
let alive = true
setIsLoading(true)
const id = setTimeout(() => {
if (!alive) return
const next = filterLocal(query, category)
setItems(next)
setIsLoading(false)
}, 300)
return () => {
alive = false
clearTimeout(id)
}
}, [query, category])

// 검색 제출
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
shallowSetParam({ query, category })
}

// “검색 결과 없음” 판단
const hasNoResult = useMemo(() => !isLoading && items.length === 0, [isLoading, items])

return (
<div className="mx-auto w-full max-w-[420px] min-h-screen bg-white overflow-x-hidden">
<form onSubmit={onSubmit} className="sticky top-0 z-10 bg-white px-12 pt-4 pb-2 mb-4">
<div className="flex items-center gap-3 px-4 py-2 rounded-full bg-[#F5F5F5]">
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="검색어를 입력하세요"
className="flex-1 bg-transparent outline-none text-[15px] indent-4"
aria-label="검색어"
/>
<button type="submit" className="shrink-0 rounded-full p-1" aria-label="검색">
<SearchIcon width="20" height="20" />
</button>
</div>
</form>

{/* 카테고리 탭*/}
<CategoryTabs
items={CATEGORIES as unknown as string[]}
Comment thread
HeesooJun marked this conversation as resolved.
Outdated
current={category}
onChange={c => {
const next = c === category ? '' : c
setCategory(next)
shallowSetParam({ category: next, query })
}}
/>

{/* 카테고리와 결과 사이 간격 */}
<div className="h-2" />

<main className="px-12 py-4">
{isLoading ? (
<div className="flex justify-center py-10">
<ClipLoader color="#FF6B6B" size={28} />
</div>
) : hasNoResult ? (
<p className="mt-12 text-center text-gray-500">검색 결과가 없습니다.</p>
) : (
<ul className="space-y-8">
{items.map(it => (
<li key={it.id}>
<article className="w-full mt-2">
<div className="rounded-2xl overflow-hidden mt-1">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={it.image} alt={it.title} className="w-full h-[300px] object-cover" loading="lazy" />
</div>
<h2 className="mt-3 text-[16px] font-semibold">{it.title}</h2>
<p className="mt-1 text-[13px] text-gray-500">{it.region}</p>
<p className="mt-1 text-[13px] text-gray-500">{fmtPeriod(it.period)}</p>
</article>
</li>
Comment thread
HeesooJun marked this conversation as resolved.
Outdated
))}
</ul>
)}
</main>

<div className="h-16" />
</div>
)
}
Loading
Loading