모달 스크롤 버그를 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>
333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
/**
|
||
* 소식 — 발행본. 이벤트와 공지를 한 자리에서 받는다.
|
||
*
|
||
* ★ 인스타에 올라온 것을 **손으로 옮겨 적는다.** 계정 페이지는 로그인·봇 탐지 뒤에 있어
|
||
* 읽어 올 수 없고, 우회는 이 레포에서 영구 금지다(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>
|
||
);
|
||
}
|