o2o-site-AEO/solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.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

180 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 추천 일정 — 캔버스. 승차권 한 장이 정거장 하나다.
*
* ★ 셋을 합친 자리다(반나절 산책 · 여행 스케줄 · 계절별 추천 하루). 축이 계절이 아니라 **기간**이다.
* ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
* 출발을 당기면 하루가 통째로 밀린다.
*/
import {
currentSeasons,
inSeason,
itineraryDays,
itineraryDurations,
parseSectionData,
type ItineraryItem,
type PlannedStop,
} from '@o2o/shared';
import {SectionBody, SectionFrame, Rail} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
export function ItineraryTickets(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ItineraryItem>(section.type, section.data);
const live = currentSeasons();
const durations = itineraryDurations(parsed.items);
const groups: (string | undefined)[] =
durations.length > 0
? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
: [undefined];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="추천 일정" />
) : (
<div className="space-y-7">
{groups.map((duration) => {
const items = parsed.items.filter((item) =>
duration === undefined
? !item.duration?.trim()
: item.duration?.trim() === duration,
);
if (items.length === 0) return null;
return (
<div key={duration ?? 'etc'} className="space-y-4">
{duration && (
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{duration}
</h3>
)}
{items.map((item, index) => (
<div key={`${item.name}-${index}`} className="space-y-2.5">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>
{item.name}
</h4>
<span className="text-[11px] opacity-60">
{[item.audience, item.season].filter(Boolean).join(' · ')}
</span>
{/* 계절을 적으면 손님 화면에서는 그 계절에만 나간다 — 사장님이 알아야 한다. */}
{item.season && (
<span className="text-[11px] opacity-60">
{inSeason(item.season, live)
? '· 지금 나갑니다'
: `· 손님 화면에는 ${item.season} 에만 나갑니다`}
</span>
)}
</div>
{item.why && (
<p className="max-w-[52ch] text-[13px] leading-relaxed opacity-80">
{item.why}
</p>
)}
{itineraryDays(item).map((entry, dayIndex) => (
<div key={`${entry.label}-${dayIndex}`} className="space-y-1.5">
{entry.label && (
<p className="text-[11px] font-bold" style={{color: ITEM_ACCENT}}>
{entry.label}
<span className="ml-2 font-normal opacity-60">
{entry.day.from}–{entry.day.to}
</span>
</p>
)}
<Rail label={`${item.name} ${entry.label || '정거장'}`}>
{entry.day.stops.map((planned, stopIndex) => (
<Ticket
key={`${planned.stop.name}-${stopIndex}`}
planned={planned}
no={stopIndex + 1}
isLast={stopIndex === entry.day.stops.length - 1}
/>
))}
</Rail>
{entry.day.dropped > 0 && (
<p className="text-[11px] opacity-60">
밤 9시를 넘기는 {entry.day.dropped}곳은 화면에서 뺐습니다.
</p>
)}
</div>
))}
<SourceLine source={item.source} verified={item.verified} />
</div>
))}
</div>
);
})}
</div>
)}
</SectionBody>
</SectionFrame>
);
}
/** 승차권 한 장. 마지막 장에는 완주 도장(아티팩트의 스탬프투어 형식). */
function Ticket({planned, no, isLast}: {planned: PlannedStop; no: number; isLast: boolean}) {
const {stop, time, until, move} = planned;
return (
<article
className="w4-perf relative w-[214px] shrink-0 border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-baseline justify-between gap-2 border-b border-dashed px-3.5 py-2"
style={{borderColor: ITEM_BORDER}}
>
<span className="text-[10px] tracking-[0.2em] opacity-60">
NO.{String(no).padStart(2, '0')}
</span>
<span className="text-[13px] font-bold tabular-nums" style={{fontFamily: ITEM_HEADING}}>
{time}
<span className="opacity-55">–{until}</span>
</span>
</div>
<div className="space-y-1 px-3.5 pb-3.5 pt-2.5">
<h5 className="text-sm font-bold" style={{fontFamily: ITEM_HEADING}}>
{stop.name}
</h5>
{stop.note && <p className="text-[12px] leading-relaxed opacity-80">{stop.note}</p>}
{move > 0 && <p className="text-[10px] opacity-55">↓ 앞 칸에서 {move}분 이동</p>}
</div>
{isLast && (
<span
className="absolute bottom-2.5 right-2.5 grid size-10 rotate-[-12deg] place-items-center rounded-full border-2 text-[10px] font-bold"
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
aria-hidden
>
완주
</span>
)}
</article>
);
}