도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
import {Phone} from 'lucide-react';
|
|
import {useSite} from '@/lib/site-context';
|
|
import {bookingLinks, bookingRows, bookingCtaLabel, sectionName} from '@/lib/derive';
|
|
import {ActionLink, Field, Section} from '@/lib/ui';
|
|
|
|
/**
|
|
* 예약 안내.
|
|
*
|
|
* ★ 무엇을 근거로 그리나
|
|
* 1) `payload.links` 중 예약을 실제로 받는 확정 채널(야놀자·여기어때·네이버 플레이스).
|
|
* 2) 예약 관련 확인 fact(예약 필수 / 예약 방법).
|
|
* 3) `place.phone` — 국내 소규모 사업장에서 전화는 여전히 1순위 예약 창구다.
|
|
*
|
|
* ★ 예약 링크를 지어내지 않는다
|
|
* "예약하기"를 눌렀는데 검색 결과가 뜨면 손님은 예약을 포기한다.
|
|
* 근거가 하나도 없으면 섹션째 그리지 않는다.
|
|
*
|
|
* ★ 예약 버튼은 표 아래가 아니라 **오른쪽 기둥**에 세운다. 표가 길어지면 버튼이
|
|
* 화면 밖으로 밀려, 정작 눌러야 할 것이 스크롤 두 번 뒤에 있었다.
|
|
*/
|
|
export function BookingSection() {
|
|
const payload = useSite();
|
|
const links = bookingLinks(payload);
|
|
const rows = bookingRows(payload);
|
|
const phone = payload.place.phone;
|
|
|
|
if (links.length === 0 && rows.length === 0 && !phone) return null;
|
|
|
|
return (
|
|
<Section
|
|
id="booking"
|
|
tone="alt"
|
|
title={sectionName(payload, 'booking', '예약 안내')}
|
|
lead={`${payload.place.name} 예약은 아래 경로로 받습니다.`}
|
|
>
|
|
<div className="grid gap-6 lg:grid-cols-12 lg:gap-10">
|
|
{rows.length > 0 && (
|
|
<dl className="panel divide-line divide-y overflow-hidden px-5 lg:col-span-7">
|
|
{rows.map((row) => (
|
|
<Field key={row.label} label={row.label} value={row.value} />
|
|
))}
|
|
</dl>
|
|
)}
|
|
|
|
<div className={rows.length > 0 ? 'lg:col-span-5' : 'lg:col-span-12'}>
|
|
{/* 표가 있을 때만 세로 기둥이다. 표가 없는데 세로로 세우면 버튼 하나가
|
|
1200px 폭을 가로지르는 띠가 된다. */}
|
|
<div
|
|
className={
|
|
rows.length > 0
|
|
? 'flex flex-col gap-2.5 sm:flex-row sm:flex-wrap lg:flex-col'
|
|
: 'flex flex-col items-start gap-2.5 sm:flex-row sm:flex-wrap'
|
|
}
|
|
>
|
|
{phone && (
|
|
<ActionLink href={`tel:${phone}`} icon={<Phone className="size-4" />}>
|
|
전화 예약 {phone}
|
|
</ActionLink>
|
|
)}
|
|
|
|
{/* 확정된 채널만 나간다 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다. */}
|
|
{links.map((link) => (
|
|
<ActionLink key={link.url} href={link.url} variant="outline" external>
|
|
{bookingCtaLabel(link)}
|
|
</ActionLink>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|