o2o-site-AEO/solution/site/src/sections/items/StorySection.tsx
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00

119 lines
5.6 KiB
TypeScript

/**
* 지역 이야기 — 레트로 아이템 다섯을 한 섹션에 묶고 탭으로 가른다.
*
* ★ 왜 묶나 (2026-09-04, 사장님 요청)
* 가요 다방·인물 열전·시간의 골목·지역 읽기·오늘의 엽서가 각각 섹션으로 서 있었다.
* 실측 390px 에서 다섯이 합쳐 3,470px — 전체의 17% 이고, 예약하러 온 손님은 이 구간을
* 다섯 화면 넘게 밀어야 아래(주변·FAQ)에 닿았다. 성격이 같은 것들이니 한 자리에 묶고
* **한 번에 하나만** 편다.
* ★ 다섯 다 늘 그린다 — 안 고른 것은 `hidden` 이다. 잘라내면 검색·AI 가 나머지 넷을 못 읽는다.
* 이 사이트의 존재 이유가 인용이라, 화면에서 접는 것과 문서에서 지우는 것은 다른 일이다.
* ★ 각자의 <h2> 는 그대로 둔다. 탭 이름과 겹쳐 보이지만, 펼쳐 놓고 읽는 기계에게는
* 그 제목이 다섯 덩이를 가르는 유일한 표지다.
*/
import {useState} from 'react';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {SongsSection} from './SongsSection';
import {PeopleSection} from './PeopleSection';
import {ChronicleSection} from './ChronicleSection';
import {ReadingSection} from './ReadingSection';
import {PostcardSection} from './PostcardSection';
import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK} from './common';
/*
* ★ 숫자를 문장에 박지 않는다 (2026-09-04, 사장님 지적: "다섯 갈래인데 4개잖아")
* 탭은 데이터가 있는 것만 선다 — 사장님이 아이템 하나를 끄면 문장이 곧바로 거짓말이 된다.
* 실제로 '가요 다방'과 '오늘의 한 장'을 뺐더니 셋인데 "다섯 갈래"라고 적혀 있었다.
*/
const COUNT_WORD: Record<number, string> = {
1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯', 6: '여섯', 7: '일곱',
};
/* ★ '읽기' 만 라벨에 지명이 든다 — 나머지 넷은 어느 도시에서도 같은 이름이지만
이건 "그 도시를 읽는다" 가 이름의 뜻이라 지명을 빼면 무엇을 읽는지가 사라진다.
지명을 코드에 박지 않는다(`군산 읽기` 는 시연본 한 곳의 값이다). */
const tabsOf = (readingLabel: string) =>
[
{id: 'songs', label: '가요 다방', Component: SongsSection},
{id: 'people', label: '인물 열전', Component: PeopleSection},
{id: 'chronicle', label: '시간의 골목', Component: ChronicleSection},
{id: 'reading', label: readingLabel, Component: ReadingSection},
{id: 'postcard', label: '오늘의 엽서', Component: PostcardSection},
] as const;
export function StorySection() {
const payload = useSite();
const region = payload.place.addressLocality ?? '지역';
const title = sectionName(payload, 'story', `${region} 이야기`);
// 탭 라벨은 안의 제목과 같은 값을 쓴다 — 각자 계산하면 "군산시 읽기" 탭에
// "군산 읽기" 본문처럼 갈릴 수 있다(ReadingSection.tsx 의 parsed.title 우선 규칙과 같다).
const readingLabel = sectionItems(payload, 'reading').title || `${region} 읽기`;
// 데이터가 있는 것만 탭이 된다 — 눌러서 빈 화면을 보게 하지 않는다.
const tabs = tabsOf(readingLabel).filter((tab) => sectionItems(payload, tab.id).items.length > 0);
const [active, setActive] = useState(0);
if (tabs.length === 0) return null;
return (
<>
<section
id="story"
aria-labelledby="story-heading"
className="paper w-full"
style={{
backgroundColor: 'var(--tpl-surface, #fafafa)',
color: ITEM_INK,
paddingTop: 'var(--section-space)',
/*
* ★ 아래 여백을 준다 (2026-09-04, 사장님: "탭이랑 밑의 섹션 간격")
* 처음엔 0 으로 두고 "다음 덩이가 제 여백을 들고 온다"고 봤다. 그런데 고른 덩이가
* 어두운 면(인물 열전)이면 그 여백도 검은색이라, 탭 바 바로 밑에서 검은 띠가
* 칼로 자른 듯 시작한다. 탭과 내용은 한 벌이니 붙되, 붙어 있지는 않아야 한다.
*/
paddingBottom: 'calc(var(--section-space) * 0.55)',
}}
>
<div className="shell">
<h2 id="story-heading" className="h2">
{title}
</h2>
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-100">
이 도시를 {COUNT_WORD[tabs.length] ?? `${tabs.length}`} 갈래로 봅니다. 하나씩 골라 보세요.
</p>
<div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label={title}>
{tabs.map((tab, index) => {
const on = index === active;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={on}
aria-controls={tab.id}
onClick={() => setActive(index)}
className="rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-100"
style={
on
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{tab.label}
</button>
);
})}
</div>
</div>
</section>
{tabs.map((tab, index) => (
<div key={tab.id} hidden={index !== active}>
<tab.Component />
</div>
))}
</>
);
}