o2o-site-AEO/solution/site/src/sections/items/common.tsx
Mina Choi 4260e20a70 [fix] site: 모달 닫기 스크롤 버그 수정 — 이용안내 체크인·체크아웃 병합, UI 다듬기
모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려
scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다.

- lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정
  (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용)
- sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합
- sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx,
  items/*: 표시 폭·간격·라벨 정리
- lib/ui/Carousel.tsx: loop 이음매 간격 재점검
- scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지
  않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로

tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:23:20 +09:00

249 lines
10 KiB
TypeScript

/**
* 붙여넣기 아이템의 발행본 공통 조각.
*
* ★ 색과 서체를 박지 않는다. 전부 템플릿 토큰(`--tpl-*`, `seo/head.ts` 가 심는다)을 읽는다 —
* 한때 이 파일이 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고, 그 바람에
* 사장님이 템플릿을 바꿔도 아이템 섹션만 레트로로 남았다. 아이템은 레트로 전용 부품이 아니다.
* ★ 인상은 빌더 캔버스와 같게 그린다. 다만 **인터랙션은 옮기지 않는다** —
* 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다.
* 이 사이트의 존재 이유가 AI·검색의 인용이라, 발행본은 전 항목을 펴고 가로로만 민다.
*/
import {createContext, useContext} from 'react';
import type {ReactNode} from 'react';
import type {DataSource, DataVerified} from '@o2o/shared';
import {Carousel} from '@site/lib/ui';
import {useLayout} from '@site/lib/layout';
export const TabPanelContext = createContext(false);
// 안별 제목은 `lib/ui/Section` 과 **같은 파일**을 직접 가리킨다.
// `@/sections` 배럴로 돌아가면 이 파일이 그 배럴 안에 있어 순환이다.
import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead';
import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead';
import './items.css';
/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */
export const ITEM_CARD = 'var(--tpl-card, #fafafa)';
/** 선·테두리. */
export const ITEM_BORDER = 'var(--tpl-border, #d6d3d1)';
/**
* 강조 — 연표의 전환점, 일력의 날짜, 소인 도장.
*
* ★ 강조색을 그대로 쓰지 않고 둘레 글자색을 섞는다(빌더 캔버스와 같은 규칙). 팔레트에 따라
* accent 가 바탕과 같은 밝기면 큰 숫자와 배지가 화면에서 사라진다.
*/
export const ITEM_ACCENT = 'color-mix(in oklab, var(--tpl-accent, #2563eb) 55%, currentColor)';
/** 어두운 면(필름·플립보드). */
export const ITEM_INVERSE = 'var(--tpl-inverse, #1c1917)';
/** 어두운 면 위의 글자색 — 흰색을 박지 않고 템플릿의 밝은 면 색을 쓴다. */
export const ITEM_INVERSE_INK = 'var(--tpl-bg, #ffffff)';
/** 글자색. */
export const ITEM_INK = 'var(--tpl-text, #09090b)';
/**
* 시간표 칸 번호 — 승차권 카드(추천 일정 · 계절별 추천 하루)가 함께 쓴다.
* **지도 핀과 같은 번호**다. 둘을 잇는 끈이 이 숫자뿐이라 두 화면이 어긋나면 안 된다.
*/
export function StopNo({n}: {n: number}) {
return (
<span
className="mr-1.5 inline-grid size-[22px] shrink-0 place-items-center rounded-full text-[length:var(--fs-xs)] leading-none tabular-nums"
style={{backgroundColor: ITEM_ACCENT, color: ITEM_INVERSE_INK}}
>
{n}
</span>
);
}
/**
* 아이템 섹션 껍데기.
*
* 바탕은 `--tpl-surface`(섹션 바탕) 또는 `--tpl-inverse`(어두운 아이템)다.
* 다른 섹션들과 **같은 여백·같은 제목**을 쓴다(index.css 의 --section-space) —
* 아이템만 다른 리듬으로 들어가면 페이지가 두 사이트를 이어 붙인 것처럼 보인다.
*
* ★ 제목은 안(layout)에 맡긴다 (2026-09-04, 사장님이 3안 화면을 보고 지적)
* 여기서 `.h2` 를 직접 그렸더니, 소제목이 대괄호 안 작은 활자인 3안(oasi)에서
* '영상으로 보기'·'추천 일정' 만 큰 명조로 튀었다. 공용 `lib/ui/Section` 과 **같은 방식**으로
* 안별 `SectionHead` 에 넘긴다 — subtitle 은 lead 로, 바깥 링크는 aside 로 간다
* (다섯 안 모두 `{id, title, lead, aside}` 를 받고 `<h2 id="${id}-heading">` 을 직접 그린다).
* ★ 1안(default)만 아래 폴백으로 남긴다. 공용 `DefaultHead` 는 제목 앞에 강조선이 있고
* 여백(mb-6)·lead 들여쓰기가 달라서, 그걸 쓰면 **이미 발행된 1안의 생김새가 바뀐다.**
*/
export function ItemSection({
id,
name,
subtitle,
count,
link,
children,
dark,
}: {
id: string;
name: string;
subtitle?: string;
/** 섹션 머리에 거는 바깥 링크 — 이 섹션을 만든 서비스로 보낸다. 없으면 안 그린다. */
link?: {url: string; label: string};
/** 각주 한 줄("총 8곡 · 확인 필요 2"). 없으면 각주를 그리지 않는다. */
count?: string;
children: ReactNode;
dark?: boolean;
}) {
const layout = useLayout();
const inTabPanel = useContext(TabPanelContext);
const Head =
layout === 'reservation'
? ReservationHead
: layout === 'oasi'
? OasiHead
: layout === 'studio'
? StudioHead
: layout === 'pastel'
? PastelHead
: layout === 'editorial'
? EditorialHead
: null;
// 링크 마크업은 한 벌이다 — 안에 따라 서는 자리만 달라진다(1안은 제목 오른쪽, 나머지는 aside).
const linkNode = link ? (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-[length:var(--fs-sm)] font-semibold underline-offset-4 opacity-100 transition-opacity hover:opacity-100 hover:underline"
>
{link.label}
</a>
) : undefined;
return (
<section
id={id}
aria-labelledby={`${id}-heading`}
className="border-line paper w-full border-b"
style={{
backgroundColor: dark ? ITEM_INVERSE : 'var(--tpl-surface, #fafafa)',
color: dark ? ITEM_INVERSE_INK : ITEM_INK,
paddingBlock: 'var(--section-space)',
}}
>
<div className="shell">
{Head ? (
<Head id={id} title={name} lead={subtitle} aside={linkNode} />
) : (
<header className={inTabPanel ? '' : 'mb-8 sm:mb-10'}>
{/* 제목과 바깥 링크를 한 줄에. 좁은 화면에서는 링크가 아래로 떨어진다. */}
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 id={`${id}-heading`} className={inTabPanel ? 'sr-only' : 'h2'}>
{name}
</h2>
{linkNode}
</div>
{subtitle && (
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-100">{subtitle}</p>
)}
</header>
)}
{children}
{count && <p className="mt-6 text-[length:var(--fs-xs)] opacity-100">{count}</p>}
</div>
</section>
);
}
/**
* 가로로 미는 레일.
*
* ★ 예전에는 `overflow-x: auto` 상자였고 주석에 "화살표는 두지 않는다 — 손가락·트랙패드로
* 민다"고 적혀 있었다. 그 문장이 틀렸다. **휠 마우스에는 옆으로 미는 방법이 없다** —
* 데스크톱 손님은 첫 두세 장만 보고 지나갔다. 이제 드래그·화살표·점·키보드가 다 붙는다.
* ★ 슬라이드는 여전히 전부 HTML 에 있다(Carousel 참조). 인용이 이 사이트의 목적이다.
*/
export function Rail({
children,
label,
arrows = 'header',
onSelect,
onReady,
}: {
children: ReactNode;
label: string;
/**
* 액자(필름·책장) 안에 든 레일은 'overlay' 다 — 화살표를 액자 안에 한 줄로 두면
* 퍼포레이션과 프레임 사이에 버튼 띠가 생겨 필름이 아니라 위젯이 된다.
*/
arrows?: 'header' | 'overlay';
/** 지금 몇 번째 장인지 알아야 하는 레일에만 준다(추천 일정의 코스 줄). Carousel 주석 참조. */
onSelect?: (index: number) => void;
onReady?: (api: {scrollTo: (index: number) => void}) => void;
}) {
return (
<Carousel label={label} gap={1} arrows={arrows} onSelect={onSelect} onReady={onReady}>
{children}
</Carousel>
);
}
/**
* 출처 한 줄.
*
* ★ '확인 / 확인필요' 배지를 뺐다 (2026-09-03, 사장님 지적)
* 검수 상태는 **우리 살림이지 손님에게 할 말이 아니다.** 손님 화면에 '확인필요'가 붙으면
* 그 문장만 못 믿는 게 아니라 사이트 전체가 덜 만든 것으로 읽힌다.
* 검수는 에디터(빌더 캔버스)에서 계속 보인다 — 고칠 사람은 거기서 본다.
* ★ 출처는 남긴다. 인용될 때 근거를 대는 건 이 사이트의 존재 이유다.
*/
export function SourceLine({
source,
verified,
imageCredit,
}: {
source?: DataSource;
verified?: DataVerified;
/**
* 사진의 출처 표시. **글의 출처와 다른 것**이다 — 사진은 위키미디어(CC BY·BY-SA)나
* 공공누리 제1유형에서 오고, 둘 다 출처 표시가 **라이선스 조건**이다. 생략하면 침해다.
* 그래서 글 출처가 없어도 사진 출처만으로 이 줄이 선다.
*/
imageCredit?: string;
}) {
// verified 는 계약에 남겨 둔다(에디터·게이트가 읽는다). 화면에만 안 낸다.
void verified;
if (!source?.name && !imageCredit) return null;
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[length:var(--fs-xs)] opacity-100">
{source?.name && (
<span>
·{' '}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer nofollow"
className="underline underline-offset-2"
>
{source.name}
</a>
) : (
source.name
)}
</span>
)}
{imageCredit && <span> · {imageCredit}</span>}
</p>
);
}
/**
* "총 N개" 각주. 단위(곡·장·명)는 아이템마다 다르다.
*
* ★ 뒤에 "· 확인 필요 M" 을 붙였었다 — 손님에게 우리 검수 진도를 알릴 이유가 없다.
* 인자는 그대로 받는다(부르는 쪽 열 곳을 한꺼번에 고치지 않으려고).
*/
export function tally(total: number, unverified: number, unit: string): string {
void unverified;
return `${total}${unit}`;
}