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

85 lines
3.7 KiB
TypeScript

/**
* 히어로 · 표지 — 사진 한 장 위, 글은 좌하단.
*
* ★ 왜 가운데가 아닌가
* 사진이 주인공인 화면에서 글자를 한가운데 얹으면 피사체를 정확히 가린다. 숙소 사진은
* 가운데에 방이나 사람이 오는데, 상호가 그 위에 앉으면 둘 다 못 읽는다. 아래로 내리고
* 그쪽만 어둡게 덮으면 사진은 사진대로 남고 글자는 글자대로 읽힌다.
*
* ★ 버튼을 세우지 않는다
* 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다. 예약 버튼은 아래 예약 섹션이 맡고,
* 여기서는 다음 섹션으로 눈을 내려보내기만 한다.
*
* ★ 색을 직접 쓰지 않는다
* `bg-stone-900` 같은 고정색을 두면 '옛 항구'(갱지)를 골라도 첫 화면만 검게 남는다.
* 어두운 면은 `--tpl-inverse`, 그 위 글자는 `--tpl-bg` 다 — 팔레트가 바뀌면 같이 바뀐다.
*/
import {ChevronDown, MapPin} from 'lucide-react';
import {SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {primaryPhoto} from '../common';
/** 사진 위 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */
const SCRIM =
'linear-gradient(to top,' +
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' +
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%,' +
' transparent 66%)';
export function HeroCover(props: SectionRenderProps) {
const {section, isSelected, onSelect, storeName, location, photos} = props;
// 사진이 없으면 <img> 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다.
const cover = primaryPhoto(photos);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div
className="relative flex w-full select-none items-end overflow-hidden"
style={{
backgroundColor: 'var(--tpl-inverse, #1c1917)',
color: 'var(--tpl-bg, #ffffff)',
minHeight: 'clamp(24rem, 62vh, 36rem)',
}}
>
{cover?.url && (
<div className="absolute inset-0 z-0">
<img src={cover.url} alt={storeName} className="size-full object-cover object-center" />
<div className="absolute inset-0" style={{background: SCRIM}} />
</div>
)}
<div className="shell relative z-10 pb-10 pt-24 sm:pb-12">
{location && (
<p className="mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80">
<MapPin className="size-3.5" aria-hidden />
{location}
</p>
)}
{/* ★ tpl-title — 굵기를 템플릿이 정한다. 간판체(Gugi)는 굵기가 한 벌뿐이라
700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. */}
<h1
className="tpl-title leading-[1.15]"
style={{fontSize: 'var(--fs-display)'}}
>
{storeName}
</h1>
{/*
* ★ 여기에 `section.description` 을 쓰지 않는다.
* 그 칸은 에디터의 섹션 설명("상단 메인 비주얼과 대표 문구")이라, 캔버스에 그리면
* 사장님 화면에 우리 UI 안내문이 자기 소개문처럼 박힌다. 실제로 그렇게 나갔다.
* 대표 문구는 수집·생성된 값이 생기기 전까지 **아무것도 그리지 않는다.**
*/}
<p className="mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] opacity-80">
객실 보기
<ChevronDown className="size-4" aria-hidden />
</p>
</div>
</div>
</SectionFrame>
);
}