같은 템플릿 안에서도 축제 카드(흰 바탕·글 안쪽)와 명소 카드(바탕 없음·글 아래)가 따로 놀았다. 실측(9종 전수): 제목 굵기 400/500/700/900, 사진 모서리 0/12/18/22px 가 한 화면에 섞여 있었다. - kit/Card.tsx·card.css: 사진 3:2 + 제목 1줄 + 거리 1줄 + 설명 2줄. 템플릿 차이는 --kc-* 변수만 - 9종 Around·Festival, 고택 인물: 사진 카드를 PhotoCard 로 교체. 맛집·홈 목록 행은 그대로 - 템플릿 CSS: 모서리·글꼴·강조색 변수와 그리드 간격. 일러스트는 상자형, 빅타이포는 선 격자 - 모바일 캐러셀 화살표가 카드를 가려 1024px 미만에서 숨김 - docs/TEMPLATE_DESIGN.md: 사진 카드 규칙 tsc·eslint 통과 · vitest 123 passed · 12개 사이트 굽기 · 카드 모양 템플릿당 1종 확인 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
6.1 KiB
TypeScript
158 lines
6.1 KiB
TypeScript
import {useState} from 'react';
|
|
import type {LocalPlace} from '@o2o/shared';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {naverMapSearchUrl} from '@site/lib/format';
|
|
import {PhotoCard} from '../kit/Card';
|
|
import {Sheet} from '../paper/Sheet';
|
|
|
|
const WALK = 80;
|
|
const RANGES = [
|
|
{id: 'all', label: '전체', test: () => true},
|
|
{id: 'w5', label: '걸어서 5분 이내', test: (m: number) => m <= 5 * WALK},
|
|
{id: 'w10', label: '걸어서 10분 이내', test: (m: number) => m <= 10 * WALK},
|
|
{id: 'far', label: '걸어서 10분 이상', test: (m: number) => m > 10 * WALK},
|
|
] as const;
|
|
const FIRST_SPOTS = 6;
|
|
const FIRST_EATS = 5;
|
|
const HOME_ROWS = 3;
|
|
|
|
function meters(place: LocalPlace): number {
|
|
if (place.distanceMeters != null) return place.distanceMeters;
|
|
const m = /^([\d.]+)\s*(km|m)$/i.exec((place.distanceText ?? '').trim());
|
|
return m ? Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1) : Infinity;
|
|
}
|
|
|
|
const byDistance = (a: LocalPlace, b: LocalPlace) => meters(a) - meters(b);
|
|
const inRange = (id: string) => RANGES.find((r) => r.id === id)?.test ?? (() => true);
|
|
|
|
function RangeTabs({places, value, onChange}: {places: LocalPlace[]; value: string; onChange: (id: string) => void}) {
|
|
const tabs = RANGES.filter((r, i) => {
|
|
const n = places.filter((p) => r.test(meters(p))).length;
|
|
return i === 0 || (n > 0 && n < places.length);
|
|
});
|
|
if (tabs.length < 2) return null;
|
|
return (
|
|
<div className="chips" role="tablist">
|
|
{tabs.map((r) => (
|
|
<button key={r.id} type="button" role="tab" aria-current={r.id === value} onClick={() => onChange(r.id)}>
|
|
{r.label} {places.filter((p) => r.test(meters(p))).length}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Around() {
|
|
const payload = useSite();
|
|
const {local} = payload;
|
|
const spots = [...local.attractions].sort(byDistance);
|
|
const eats = [...local.restaurants].sort(byDistance);
|
|
const rail = [...spots, ...eats].filter((place) => place.imageUrl).sort(byDistance);
|
|
const [open, setOpen] = useState<LocalPlace | null>(null);
|
|
const [spotRange, setSpotRange] = useState('all');
|
|
const [eatRange, setEatRange] = useState('all');
|
|
const firstSpots = FIRST_SPOTS;
|
|
if (rail.length === 0 && eats.length === 0) return null;
|
|
|
|
const shownSpots = spots.filter((p) => inRange(spotRange)(meters(p)));
|
|
const shownEats = eats.filter((p) => inRange(eatRange)(meters(p)));
|
|
const place = (item: LocalPlace, withText = true) => (
|
|
<button key={item.name} type="button" className={item.imageUrl ? 'place' : 'place noph'} onClick={() => setOpen(item)}>
|
|
{item.imageUrl && (
|
|
<span className="ph">
|
|
<img src={item.imageUrl} alt={item.name} loading="lazy" />
|
|
</span>
|
|
)}
|
|
<span className="tx">
|
|
{item.distanceText && <small>{item.distanceText}</small>}
|
|
<b className="nm">{item.name}</b>
|
|
{withText && item.description && <span className="ds">{item.description}</span>}
|
|
</span>
|
|
</button>
|
|
);
|
|
|
|
const spot = (item: LocalPlace) => (
|
|
<PhotoCard
|
|
key={item.name}
|
|
image={item.imageUrl}
|
|
title={item.name}
|
|
meta={item.distanceText}
|
|
desc={item.description}
|
|
onClick={() => setOpen(item)}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{rail.length > 0 && (
|
|
<div data-pg="home">
|
|
<section className="wrap" id="around" aria-labelledby="around-heading">
|
|
<p className="label">AROUND</p>
|
|
<h2 id="around-heading">주변 안내</h2>
|
|
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p>
|
|
<div className="guide guide--card">{rail.slice(0, HOME_ROWS).map(spot)}</div>
|
|
<a className="link b-more--c" href="#/area">
|
|
지역 소개에서 모두 보기
|
|
</a>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
<div data-pg="area" id="guide">
|
|
{spots.length > 0 && (
|
|
<section className="wrap" id="walk">
|
|
<p className="label">AROUND</p>
|
|
<h2>
|
|
주변 명소 <span className="cnt">{spots.length}곳</span>
|
|
</h2>
|
|
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
|
|
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
|
|
<div className="guide guide--card">{shownSpots.slice(0, firstSpots).map(spot)}</div>
|
|
{shownSpots.length > firstSpots && (
|
|
<details className="fold" key={spotRange}>
|
|
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary>
|
|
<div className="guide guide--card">{shownSpots.slice(firstSpots).map(spot)}</div>
|
|
</details>
|
|
)}
|
|
</section>
|
|
)}
|
|
{eats.length > 0 && (
|
|
<section className="wrap" id="eat">
|
|
<p className="label">EAT</p>
|
|
<h2>
|
|
주변 맛집 <span className="cnt">{eats.length}곳</span>
|
|
</h2>
|
|
<RangeTabs places={eats} value={eatRange} onChange={setEatRange} />
|
|
<div className="guide">{shownEats.slice(0, FIRST_EATS).map((item) => place(item, false))}</div>
|
|
{shownEats.length > FIRST_EATS && (
|
|
<details className="fold" key={eatRange}>
|
|
<summary className="link">{shownEats.length - FIRST_EATS}곳 더 보기</summary>
|
|
<div className="guide">{shownEats.slice(FIRST_EATS).map((item) => place(item, false))}</div>
|
|
</details>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
|
|
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
|
|
{open && (
|
|
<>
|
|
{open.imageUrl && <img src={open.imageUrl} alt={open.name} />}
|
|
<h3>{open.name}</h3>
|
|
<p className="meta">{[open.distanceText, open.category].filter(Boolean).join(' · ')}</p>
|
|
{open.description && <p>{open.description}</p>}
|
|
<a
|
|
className="out"
|
|
href={naverMapSearchUrl(open.searchQuery ?? open.name)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
네이버에서 보기
|
|
</a>
|
|
</>
|
|
)}
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|