o2o-site-AEO/solution/site/src/sections/items/EventSection.tsx
Mina Choi b386982f34 [fix] site: 모달·갤러리·소식 스크롤 잠금을 훅 하나로 통합 — 복사된 스크롤 버그 재발 방지
모달 스크롤 버그를 Modal.tsx 한 곳만 고쳤더니 같은 잠금·복귀 코드가 그대로 복사돼
있던 GallerySection·EventSection 에는 버그가 그대로 남아 있었다(2026-09-21 실측).
복사한 코드는 복사한 곳마다 따로 고쳐야 하므로 훅 하나로 뺀다.

- lib/ui/use-scroll-lock.ts: 스크롤 잠금·복귀 로직 신규 — scroll-behavior:auto 강제 포함
- lib/ui/Modal.tsx, sections/GallerySection.tsx, sections/items/EventSection.tsx:
  중복 잠금 코드 제거, useScrollLock() 호출로 교체
- lib/ui/index.ts: useScrollLock export 추가

tsc 통과, vitest 100 passed(무관한 날씨 조건 테스트 2건은 이 변경 전부터 실패 중)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:57:33 +09:00

333 lines
15 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.

/**
* 소식 — 발행본. 이벤트와 공지를 한 자리에서 받는다.
*
* ★ 인스타에 올라온 것을 **손으로 옮겨 적는다.** 계정 페이지는 로그인·봇 탐지 뒤에 있어
* 읽어 올 수 없고, 우회는 이 레포에서 영구 금지다(DECISIONS 1-1). 대신 원문 주소를 달아
* 손님이 정확한 것을 그쪽에서 보게 한다.
* ★ 이벤트와 공지를 나누지 않는다 (2026-09-04, 사장님 결정)
* 손님이 볼 곳이 둘로 갈리면 어디를 봐야 하는지 모른다 — '이용 및 예약 안내'와 '예약 안내'가
* 나란히 서서 겪었던 그 일이다. 한 섹션에 두고 **배지로만** 가른다.
* ★ 끝난 것도 **HTML 에서 지우지 않는다.** 이 사이트의 존재 이유가 인용이라, 지난 이벤트가
* 남아 있어야 "이 집은 이런 걸 한다"를 검색·AI 가 읽는다. 화면에서는 빛바랜 전단으로 눕힌다.
* ★ 진행 여부는 **브라우저에서** 판정한다. 정적으로 구운 페이지는 몇 달을 사는데, 굽는 날의
* '오늘'을 박으면 12월에도 9월 이벤트가 진행 중으로 뜬다(계절별 코스와 같은 수법).
* 첫 렌더에는 배지를 안 그린다 — 서버 렌더와 달라지면 하이드레이션이 어긋난다.
*
* ★ 생김새는 **게시판에 붙은 전단**이다. 갱지 결 위에 도장 하나, 참여 방법은 절취선 안에.
* 이 팩의 다른 섹션(가요 다방의 도넛판, 계절별 하루의 승차권)과 같은 규칙이다 —
* 카드가 무엇을 흉내 내는지 한눈에 보여야 목록이 아니라 물건으로 읽힌다.
*/
import {useCallback, useEffect, useState} from 'react';
import {ShowMore, useScrollLock} from '@site/lib/ui';
import {X} from 'lucide-react';
import type {EventItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {
ITEM_ACCENT,
ITEM_BORDER,
ITEM_CARD,
ITEM_INK,
ITEM_INVERSE_INK,
ItemSection,
} from './common';
type Phase = 'open' | 'soon' | 'closed';
const PHASE_LABEL: Record<Phase, string> = {open: '진행 중', soon: '곧 시작', closed: '종료'};
/** 'YYYY-MM-DD' 두 개로 오늘이 어디쯤인지. 문자열 비교로 끝난다 — 시간대 계산이 필요 없다. */
function phaseOf(item: EventItem, today: string): Phase {
if (item.endDate && item.endDate < today) return 'closed';
if (item.startDate && item.startDate > today) return 'soon';
return 'open';
}
/**
* 도장 한 칸.
*
* ★ 날짜가 지났으면 **종류와 무관하게 '종료'** 다 (2026-09-04, 사장님 지적: "디자인이 서로 맞지 않음")
* 전에는 kind 가 '공지' 면 무조건 '공지' 를 찍었다. 그런데 작년 축제 안내가 흑백으로 바래
* 지나간 것처럼 보이면서 도장만 '공지' 라, 카드가 두 가지 말을 동시에 했다.
* 날짜가 있는 것은 날짜가 말하게 하고, 날짜가 없는 상시 공지만 '공지' 로 남는다.
*/
function badgeOf(item: EventItem, phase: Phase | undefined): string | undefined {
if (item.kind === '공지' && !item.startDate && !item.endDate) return '공지';
return phase && PHASE_LABEL[phase];
}
/** "9월 1일 – 9월 30일". 끝 날짜가 없으면 상시다. */
function periodText(item: EventItem): string | undefined {
const short = (iso?: string) => {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? '');
return m ? `${Number(m[2])}월 ${Number(m[3])}일` : undefined;
};
const from = short(item.startDate);
const to = short(item.endDate);
// 하루짜리 행사는 "8월 16일 – 8월 16일" 이 된다 — 같은 날이면 한 번만 적는다.
if (from && to) return from === to ? from : `${from} – ${to}`;
if (from) return `${from}부터`;
if (to) return `${to}까지`;
return undefined;
}
export function EventSection() {
const payload = useSite();
const parsed = sectionItems<EventItem>(payload, 'event');
const [today, setToday] = useState<string>();
// 'sv-SE' 로케일이 'YYYY-MM-DD' 를 준다 — toISOString 은 UTC 라 한국 자정 뒤 하루가 밀린다.
useEffect(() => setToday(new Date().toLocaleDateString('sv-SE')), []);
/*
* ★ 눌러서 펴는 창 (2026-09-04, 사장님 지시)
* 공지는 지켜야 할 것이 여러 줄이라 카드 안에 다 담으면 카드가 세로로 길어지고,
* 그러면 옆 카드까지 같이 늘어나 섹션이 화면 몇 개가 된다. 카드는 요약만 세우고
* 전문은 창에서 편다. **본문은 카드 DOM 에 이미 다 있다**(line-clamp 로 시각만 자름) —
* 창이 닫혀 있어도 검색·AI 는 전문을 읽는다.
*/
const [open, setOpen] = useState<number | null>(null);
const close = useCallback(() => setOpen(null), []);
// 창이 열려 있는 동안 Esc 로 닫고 뒤 배경 스크롤을 잠근다 — 안 잠그면 창 위에서 뒤가 밀린다.
useScrollLock(open !== null);
useEffect(() => {
if (open === null) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
if (parsed.items.length === 0) return null;
const shown = open === null ? undefined : parsed.items[open];
const shownPhase = shown && today ? phaseOf(shown, today) : undefined;
return (
<ItemSection
id="event"
name={parsed.title || sectionName(payload, 'event', `${payload.place.name} 소식`)}
subtitle={parsed.subtitle}
link={parsed.linkUrl ? {url: parsed.linkUrl, label: parsed.linkLabel ?? '더 보기'} : undefined}
count="자세한 내용과 최신 소식은 원문에서 확인해 주세요."
>
{/*
★ 키를 맞춘다 (2026-09-04, 사장님 지적: "디자인이 서로 맞지 않음 · 통일되게")
한때 items-start 로 제각각 두었다 — 전단은 원래 크기가 다르니까. 그런데 사진 있는 카드와
없는 카드가 섞이자 줄마다 아래가 들쭉날쭉해져서 게시판이 아니라 어질러진 것으로 보였다.
같은 줄에서는 키를 맞추고, 대신 **카드 안 순서를 사진 유무와 무관하게 똑같이** 만든다.
*/}
<ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<ShowMore initial={2} label={(rest) => `소식 ${rest}개 더 보기`}>
{parsed.items.map((item, index) => {
const phase = today ? phaseOf(item, today) : undefined;
const badge = badgeOf(item, phase);
const period = periodText(item);
const done = phase === 'closed';
return (
<li
key={`${item.title}-${index}`}
className="w4-paper relative flex flex-col border-2"
style={{
backgroundColor: ITEM_CARD,
borderColor: ITEM_BORDER,
// 지난 전단은 빛이 바랜다. 지우지는 않는다 — 문서에는 그대로 남아 있다.
opacity: done ? 0.6 : 1,
boxShadow: done ? 'none' : '3px 3px 0 color-mix(in oklab, currentcolor 12%, transparent)',
}}
>
{/* 카드 전체가 누르는 자리다 — 글은 그대로 읽히고 위에 투명한 버튼만 덮는다. */}
<button
type="button"
onClick={() => setOpen(index)}
aria-label={`${item.title} 자세히 보기`}
className="absolute inset-0 z-20 cursor-pointer"
/>
{/*
★ 사진 자리를 활자 판으로 채워 봤다가 접었다 (2026-09-04)
주변 안내 카드처럼 "사진 없으면 이름 활자"를 넣었더니, 바로 아래 <h3> 와
같은 문장이 두 번 서서 카드 절반이 제목이 됐다. 공지는 **읽을 글이 많은 쪽**이라
남는 자리를 장식이 아니라 본문으로 채우는 게 맞다 — 아래 line-clamp 를 늘린다.
*/}
<div
className={item.imageUrl ? 'relative overflow-hidden border-b-2' : 'contents'}
style={item.imageUrl ? {borderColor: ITEM_BORDER} : undefined}
>
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={`${item.title} 안내 이미지`}
loading="lazy"
decoding="async"
className="aspect-4/3 w-full object-cover"
// 끝난 것은 흑백으로. 진행 중인 것 하나만 색을 갖는다.
style={done ? {filter: 'grayscale(1)'} : undefined}
/>
) : null}
</div>
<div className="flex flex-1 flex-col gap-2.5 p-5">
{/*
도장과 날짜가 한 줄. ★ 예전에는 도장을 사진 위 왼쪽 어깨에 절대배치했는데,
사진 없는 카드에서는 그 자리가 빈 여백이라 두 종류 카드의 생김새가 갈렸다.
본문 첫 줄로 내리면 사진이 있든 없든 **읽는 순서가 똑같다**.
★ 살짝 기울인다 — 손으로 찍은 도장은 반듯하지 않다. 각도가 커지면 장난이 된다.
*/}
<div className="flex flex-wrap items-center gap-2">
{badge && (
<span
className="border-2 px-2 py-0.5 text-[length:var(--fs-xs)] font-bold tracking-[0.08em]"
style={{
transform: 'rotate(-3deg)',
backgroundColor: phase === 'open' && badge !== '공지' ? ITEM_ACCENT : ITEM_CARD,
color: phase === 'open' && badge !== '공지' ? ITEM_INVERSE_INK : ITEM_INK,
borderColor: phase === 'open' && badge !== '공지' ? ITEM_ACCENT : ITEM_BORDER,
}}
>
{badge}
</span>
)}
{period && (
<span className="text-[length:var(--fs-xs)] font-semibold tabular-nums opacity-100">{period}</span>
)}
</div>
<h3 className="serif text-lg font-bold leading-snug">{item.title}</h3>
{item.summary && (
<p className="line-clamp-2 text-[length:var(--fs-sm)] leading-relaxed opacity-100">{item.summary}</p>
)}
{/* 참여 방법은 절취선 안에 — 손님이 오려서 들고 오는 칸이라는 뜻이다. */}
{item.howTo && (
<p
className="border-2 border-dashed px-3 py-2 text-[length:var(--fs-sm)] leading-relaxed"
style={{borderColor: ITEM_BORDER}}
>
{item.howTo}
</p>
)}
{/*
★ 긴 본문은 카드에서 잘라 보인다 — **자르는 건 화면뿐이고 DOM 에는 다 있다**
(line-clamp 는 시각 처리다). 검색·AI 는 전문을 읽고, 손님은 눌러서 편다.
*/}
{item.body && (
<p
className={`whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-100 ${
item.imageUrl ? 'line-clamp-3' : 'line-clamp-[9]'
}`}
>
{item.body}
</p>
)}
<p className="mt-auto pt-2 text-[length:var(--fs-sm)] font-bold opacity-100">자세히 보기 →</p>
</div>
</li>
);
})}
</ShowMore>
</ul>
{shown && (
<div
role="dialog"
aria-modal="true"
aria-label={shown.title}
className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6"
onClick={close}
>
<article
onClick={(event) => event.stopPropagation()}
className="w4-paper max-h-[88vh] w-full max-w-2xl overflow-y-auto border-2"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER, color: ITEM_INK}}
>
{shown.imageUrl && (
<img
src={shown.imageUrl}
alt={`${shown.title} 안내 이미지`}
className="max-h-[46vh] w-full object-contain"
style={{backgroundColor: 'color-mix(in oklab, currentcolor 6%, transparent)'}}
/>
)}
<div className="space-y-4 p-6 sm:p-8">
<div className="flex flex-wrap items-center gap-2">
{badgeOf(shown, shownPhase) && (
<span
className="border-2 px-2.5 py-1 text-[length:var(--fs-xs)] font-bold tracking-[0.08em]"
style={{borderColor: ITEM_BORDER}}
>
{badgeOf(shown, shownPhase)}
</span>
)}
{periodText(shown) && (
<span className="text-[length:var(--fs-sm)] font-semibold tabular-nums opacity-100">
{periodText(shown)}
</span>
)}
</div>
<h3 className="serif text-[length:var(--fs-h3)] font-bold leading-snug">
{shown.title}
</h3>
{shown.summary && (
<p className="text-[length:var(--fs-sm)] leading-relaxed opacity-100">
{shown.summary}
</p>
)}
{shown.howTo && (
<p
className="border-2 border-dashed px-4 py-3 text-[length:var(--fs-sm)] leading-relaxed"
style={{borderColor: ITEM_BORDER}}
>
{shown.howTo}
</p>
)}
{/* 줄바꿈을 살린다 — 공지는 항목이 줄로 갈려 있어야 읽힌다. */}
{shown.body && (
<p className="whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-100">
{shown.body}
</p>
)}
<div className="flex items-center justify-between gap-4 pt-2">
{shown.postUrl ? (
<a
href={shown.postUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline"
>
원문 보기 →
</a>
) : (
<span />
)}
<button
type="button"
onClick={close}
className="tap inline-flex items-center gap-1.5 border-2 px-4 text-[length:var(--fs-sm)] font-bold"
style={{borderColor: ITEM_BORDER}}
>
<X className="size-4" />
<span>닫기</span>
</button>
</div>
</div>
</article>
</div>
)}
</ItemSection>
);
}