[feat] solution/shared,site,frontend: 계절별 추천 하루는 지금 계절만 — 간절기엔 두 계절
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.
- shared/currentSeasons: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
계절 첫 달의 전반(1~15일)은 간절기로 보고 앞 계절과 함께 둘을 돌려준다 —
9월 초에 여름만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다
- site/PlannerSection: HTML 에는 전 계절을 굽고 화면에서만 접는다(hidden).
① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸려서,
계절 판정을 브라우저에서 한다(일력의 '오늘'과 같은 수법)
② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다
지금 계절에 코스가 없으면 접지 않고 전부 보여준다 — 빈 섹션보다 철 지난 코스가 낫다
- frontend/PlannerPodium: 탭은 그대로 두되 지금 계절로 열리고, '·지금' 표시와
"손님 화면에는 지금 계절만 나갑니다" 한 줄. 안 적으면 사장님은 손님도 넷을 다 본다고 오해한다
tsc·eslint 통과(frontend·site). 경계 12일자 확인(3/5 겨울·봄 · 9/2 여름·가을 · 9/16 가을).
실물 payload(스테이,머뭄 /s/stay, 9코스 4계절)로 구워 오늘 여름·가을만 보이고 봄·겨울은
hidden, HTML 에는 네 계절 전부 있는 것을 브라우저에서 확인.
This commit is contained in:
parent
71c0c1f6ab
commit
65705c5bed
@ -5,6 +5,31 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절
|
||||||
|
|
||||||
|
**왜**
|
||||||
|
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
|
||||||
|
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `shared/currentSeasons()` — 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
|
||||||
|
**계절 첫 달의 전반(1~15일)은 간절기**로 보고 앞 계절과 함께 둘을 돌려준다.
|
||||||
|
9월 초에 여름 코스만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다.
|
||||||
|
- 발행본은 **HTML 에 전 계절을 굽고 화면에서만 접는다**(`hidden`). 두 가지 이유다 —
|
||||||
|
① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸린다.
|
||||||
|
그래서 계절 판정을 **브라우저에서** 한다(일력의 '오늘'과 같은 수법).
|
||||||
|
② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다.
|
||||||
|
- 지금 계절에 코스가 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 —
|
||||||
|
빈 섹션보다 철 지난 코스가 낫다.
|
||||||
|
- 빌더는 탭을 그대로 두되 **지금 계절로 열리고**, 탭에 '·지금' 표시와
|
||||||
|
"손님 화면에는 지금 계절만 나갑니다" 한 줄을 붙였다. 안 적으면 사장님은 손님도 네 계절을
|
||||||
|
다 본다고 오해한다.
|
||||||
|
|
||||||
|
**검증** — `tsc·eslint` 통과(frontend·site). 경계 12일자 단위 확인
|
||||||
|
(3/5→겨울·봄, 3/20→봄, 6/7→봄·여름, 9/2→여름·가을, 9/16→가을, 12/10→가을·겨울).
|
||||||
|
실물 payload(스테이,머뭄 `/s/stay`, 9코스 4계절)로 구워 **오늘(9/2) 여름·가을만 보이고
|
||||||
|
봄·겨울은 `hidden`, HTML 에는 네 계절 전부** 있는 것을 브라우저에서 확인.
|
||||||
|
|
||||||
## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거
|
## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거
|
||||||
|
|
||||||
**왜**
|
**왜**
|
||||||
|
|||||||
@ -5,12 +5,22 @@
|
|||||||
* 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다.
|
* 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다.
|
||||||
* 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다).
|
* 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다).
|
||||||
* ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다.
|
* ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다.
|
||||||
|
* ★ 손님 화면에는 **지금 계절만** 나간다(간절기에는 둘). 여기 탭은 사장님이 나머지 계절을
|
||||||
|
* 확인하려고 있는 것이라, 처음 열면 지금 계절에 맞춰 두고 그 사실을 아래에 적어 둔다 —
|
||||||
|
* 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다.
|
||||||
*/
|
*/
|
||||||
import {useMemo, useState} from 'react';
|
import {useMemo, useState} from 'react';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {SectionBody, SectionFrame} from '../../primitives';
|
import {SectionBody, SectionFrame} from '../../primitives';
|
||||||
import type {SectionRenderProps} from '../../types';
|
import type {SectionRenderProps} from '../../types';
|
||||||
import {parseSectionData, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared';
|
import {
|
||||||
|
currentSeasons,
|
||||||
|
parseSectionData,
|
||||||
|
planDay,
|
||||||
|
plannerSeasons,
|
||||||
|
plannerTop,
|
||||||
|
type PlannerItem,
|
||||||
|
} from '@o2o/shared';
|
||||||
import {
|
import {
|
||||||
ITEM_ACCENT,
|
ITEM_ACCENT,
|
||||||
ITEM_BODY,
|
ITEM_BODY,
|
||||||
@ -126,10 +136,14 @@ export function PlannerPodium(props: SectionRenderProps) {
|
|||||||
const {section, isSelected, onSelect} = props;
|
const {section, isSelected, onSelect} = props;
|
||||||
const parsed = parseSectionData<PlannerItem>(section.type, section.data);
|
const parsed = parseSectionData<PlannerItem>(section.type, section.data);
|
||||||
const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]);
|
const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]);
|
||||||
const [picked, setPicked] = useState(0);
|
|
||||||
|
// 지금 계절(간절기면 둘 중 앞선 것)을 처음 탭으로. 손님이 보는 것과 같은 화면에서 시작한다.
|
||||||
|
const live = useMemo(() => currentSeasons().filter((s) => seasons.includes(s)), [seasons]);
|
||||||
|
const [picked, setPicked] = useState<number>();
|
||||||
|
const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? ''));
|
||||||
|
|
||||||
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
|
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
|
||||||
const season = seasons[picked] ?? seasons[0];
|
const season = seasons[index] ?? seasons[0];
|
||||||
const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]);
|
const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -154,31 +168,36 @@ export function PlannerPodium(props: SectionRenderProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{seasons.length > 1 && (
|
{seasons.length > 1 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{seasons.map((name, index) => (
|
{seasons.map((name, tabIndex) => (
|
||||||
<button
|
<button
|
||||||
key={name}
|
key={name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setPicked(index);
|
setPicked(tabIndex);
|
||||||
}}
|
}}
|
||||||
aria-current={index === picked}
|
aria-current={tabIndex === index}
|
||||||
className={cn('border px-3 py-1 text-xs transition-opacity', index !== picked && 'opacity-55')}
|
className={cn(
|
||||||
|
'border px-3 py-1 text-xs transition-opacity',
|
||||||
|
tabIndex !== index && 'opacity-55',
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
index === picked
|
tabIndex === index
|
||||||
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
|
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
|
||||||
: {borderColor: ITEM_BORDER}
|
: {borderColor: ITEM_BORDER}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
|
{/* 손님 화면에 지금 나가는 계절. 사장님이 '어느 게 실제로 보이나'를 눈으로 안다. */}
|
||||||
|
{live.includes(name) && <span className="ml-1 opacity-70">·지금</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
|
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
|
||||||
{top.map((item, index) => (
|
{top.map((item, rank) => (
|
||||||
<PlanCard key={`${item.name}-${index}`} item={item} rank={index} />
|
<PlanCard key={`${item.name}-${rank}`} item={item} rank={rank} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -186,6 +205,11 @@ export function PlannerPodium(props: SectionRenderProps) {
|
|||||||
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
|
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
|
||||||
{top.length}개 / 전체 {parsed.items.length}개
|
{top.length}개 / 전체 {parsed.items.length}개
|
||||||
</p>
|
</p>
|
||||||
|
{live.length > 0 && (
|
||||||
|
<p className="text-[11px] opacity-60">
|
||||||
|
손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SectionBody>
|
</SectionBody>
|
||||||
|
|||||||
@ -384,6 +384,28 @@ export function planDay(item: PlannerItem): PlannedDay {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지금 계절. **간절기에는 두 개**를 돌려준다.
|
||||||
|
*
|
||||||
|
* ★ 왜 둘인가 — 9월 초에 온 손님에게 여름 코스만 보이면 이미 지난 계절이고, 가을 코스만
|
||||||
|
* 보이면 아직 이른 코스다. 경계에서는 둘 다 보여야 손님이 고를 수 있다.
|
||||||
|
* ★ 경계는 계절 첫 달의 전반(1~15일)로 잡는다. 실측이 아니라 규약이라 이 한 곳에만 둔다 —
|
||||||
|
* 빌더와 발행본이 같은 날 다른 계절을 고르면 사장님이 본 것과 손님이 보는 것이 갈린다.
|
||||||
|
* ★ 절대 빌드 시각으로 계산하지 않는다. 발행본은 정적이라 한 번 구우면 몇 달을 사는데,
|
||||||
|
* 구운 날의 계절을 박으면 12월에도 가을 코스가 걸린다(일력이 '오늘'을 다루는 방식과 같다).
|
||||||
|
*/
|
||||||
|
export function currentSeasons(now: Date = new Date()): string[] {
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
// 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. 3월을 0 으로 당겨 3으로 끊는다.
|
||||||
|
const index = Math.floor(((month - 3 + 12) % 12) / 3);
|
||||||
|
const season = SEASON_ORDER[index];
|
||||||
|
const isFirstMonth = month % 3 === 0;
|
||||||
|
if (isFirstMonth && now.getDate() <= 15) {
|
||||||
|
return [SEASON_ORDER[(index + 3) % 4], season];
|
||||||
|
}
|
||||||
|
return [season];
|
||||||
|
}
|
||||||
|
|
||||||
/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */
|
/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */
|
||||||
export function plannerSeasons(items: PlannerItem[]): string[] {
|
export function plannerSeasons(items: PlannerItem[]): string[] {
|
||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* 계절별 추천 하루 — 발행본.
|
* 계절별 추천 하루 — 발행본.
|
||||||
*
|
*
|
||||||
* ★ 캔버스는 계절 탭으로 하나씩 보여주지만, 발행본은 **계절 전부를 편다.**
|
* ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 12월에 온 손님에게
|
||||||
* 탭 뒤에 숨은 계절은 HTML 에 없는 것과 같고, 이 사이트의 존재 이유가 인용이다.
|
* 봄·여름 코스까지 늘어놓으면 고를 것이 열두 개가 되고, 그건 추천이 아니라 목록이다.
|
||||||
|
* ★ 그런데 HTML 에는 **전 계절을 굽는다.** 두 가지 이유다 —
|
||||||
|
* ① 발행본은 정적이라 한 번 구우면 몇 달을 산다. 굽는 시점의 계절을 박으면 12월에도
|
||||||
|
* 가을 코스가 걸린다. 그래서 계절 판정은 **브라우저에서** 한다(일력의 '오늘'과 같은 수법).
|
||||||
|
* ② 이 사이트의 존재 이유가 인용이다. HTML 에서 지우면 검색·AI 가 나머지 계절을 못 읽는다.
|
||||||
|
* → 안 맞는 계절은 `hidden` 으로 접어 둔다. 문서에는 있고 화면에는 없다.
|
||||||
* ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다.
|
* ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다.
|
||||||
* 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다.
|
* 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다.
|
||||||
*/
|
*/
|
||||||
import {planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared';
|
import {useEffect, useState} from 'react';
|
||||||
|
import {currentSeasons, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared';
|
||||||
import {useSite} from '@/lib/site-context';
|
import {useSite} from '@/lib/site-context';
|
||||||
import {sectionItems, sectionName} from '@/lib/derive';
|
import {sectionItems, sectionName} from '@/lib/derive';
|
||||||
import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common';
|
import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common';
|
||||||
@ -85,25 +91,45 @@ function PlanCard({item, rank}: {item: PlannerItem; rank: number}) {
|
|||||||
export function PlannerSection() {
|
export function PlannerSection() {
|
||||||
const payload = useSite();
|
const payload = useSite();
|
||||||
const parsed = sectionItems<PlannerItem>(payload, 'planner');
|
const parsed = sectionItems<PlannerItem>(payload, 'planner');
|
||||||
|
|
||||||
|
// ★ 서버 렌더에서는 undefined 다 — SSR 과 첫 렌더가 어긋나면 하이드레이션이 깨진다.
|
||||||
|
// 브라우저에 올라온 뒤에야 계절이 정해지고, 그때 안 맞는 묶음이 접힌다.
|
||||||
|
const [now, setNow] = useState<string[]>();
|
||||||
|
useEffect(() => setNow(currentSeasons()), []);
|
||||||
|
|
||||||
if (parsed.items.length === 0) return null;
|
if (parsed.items.length === 0) return null;
|
||||||
|
|
||||||
const seasons = plannerSeasons(parsed.items);
|
const seasons = plannerSeasons(parsed.items);
|
||||||
// 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다.
|
// 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다.
|
||||||
const groups = seasons.length > 0 ? seasons : [undefined];
|
const groups = seasons.length > 0 ? seasons : [undefined];
|
||||||
|
|
||||||
|
// 지금 계절에 코스가 하나도 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 —
|
||||||
|
// 빈 섹션보다 철 지난 코스가 낫다.
|
||||||
|
const shown = now?.filter((season) => seasons.includes(season)) ?? [];
|
||||||
|
const filtering = shown.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemSection
|
<ItemSection
|
||||||
id="planner"
|
id="planner"
|
||||||
name={parsed.title || sectionName(payload, 'planner', '계절별 추천 하루')}
|
name={parsed.title || sectionName(payload, 'planner', '계절별 추천 하루')}
|
||||||
subtitle={parsed.subtitle}
|
subtitle={parsed.subtitle}
|
||||||
count="시각은 출발 시각과 머무는 시간으로 계산한 것입니다"
|
count={
|
||||||
|
filtering
|
||||||
|
? `지금은 ${shown.join(' · ')} 코스입니다 · 시각은 출발 시각과 머무는 시간으로 계산한 것입니다`
|
||||||
|
: '시각은 출발 시각과 머무는 시간으로 계산한 것입니다'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{groups.map((season) => {
|
{groups.map((season) => {
|
||||||
const top = plannerTop(parsed.items, season);
|
const top = plannerTop(parsed.items, season);
|
||||||
if (top.length === 0) return null;
|
if (top.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={season ?? 'all'} className="space-y-3">
|
<div
|
||||||
|
key={season ?? 'all'}
|
||||||
|
className="space-y-3"
|
||||||
|
// 문서에는 남기고 화면에서만 접는다 — 검색·AI 는 전 계절을 읽는다.
|
||||||
|
hidden={filtering && season !== undefined && !shown.includes(season)}
|
||||||
|
>
|
||||||
{season && <h3 className="serif text-lg font-bold">{season}</h3>}
|
{season && <h3 className="serif text-lg font-bold">{season}</h3>}
|
||||||
<Rail label={`${season ?? '추천'} 코스`}>
|
<Rail label={`${season ?? '추천'} 코스`}>
|
||||||
{top.map((item, index) => (
|
{top.map((item, index) => (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user