o2o-site-AEO/solution/site/src/sections/BookingSection.tsx
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(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>
2026-09-09 17:08:02 +09:00

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