o2o-site-AEO/solution/site/src/layouts/boutique/Around.tsx
Mina Choi 4831e2d25d [fix] solution/site: 사진 카드를 PhotoCard 하나로 통일 — 템플릿마다 제각각이던 축제·명소 카드
같은 템플릿 안에서도 축제 카드(흰 바탕·글 안쪽)와 명소 카드(바탕 없음·글 아래)가 따로 놀았다.
실측(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>
2026-10-01 14:33:17 +09:00

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>
</>
);
}