From 38fbe0ee7b33f771f8d521c41858f357091115ce Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 15:17:21 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/frontend:=20=EC=97=AC?= =?UTF-8?q?=ED=96=89=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C=20=E2=80=94=20=EB=8C=80=ED=95=A9=EC=8B=A4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가요다방·일력·승차권에 이어 네 번째 붙여넣기 아이템이다. 승차권(course)은 '어디를 도는가'라 순번이 축인데, 손님이 실제로 묻는 건 '몇 시에 뭘 하나'다. 시각을 축으로 하는 칸이 없었다. - dataSpec/schedule: ScheduleItem·ScheduleSlot 과 작성 규칙. time 은 "HH:MM" 만, 장소에 url 대신 searchQuery — 지어낸 주소를 링크하지 않는 규약 그대로다 - registry/schedule.timetable: 역 대합실 플립보드(가운데 접힘선)로 시각을 먼저 읽힌다 - industryData: 레트로 템플릿 시드에 schedule 추가 [+ 섹션 추가] 목록은 dataSpec 에서 파생돼(addable.ts) 따로 손댈 곳이 없다. tsc·vite build 통과. --- solution/frontend/src/data/industryData.ts | 2 +- .../src/features/builder/canvas/dataSpec.ts | 70 +++++++ .../src/features/builder/canvas/registry.ts | 12 ++ .../variants/schedule/ScheduleTimetable.tsx | 174 ++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index 5e2498c..2134e32 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -103,7 +103,7 @@ function templatesFor( fontStyle: '옛 간판체', look: LOOK.retro, // 이 템플릿이 팔려는 게 바로 이 아이템들이다. - defaultSectionTypes: ['songs', 'daily', 'course'], + defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'], }, ]; } diff --git a/solution/frontend/src/features/builder/canvas/dataSpec.ts b/solution/frontend/src/features/builder/canvas/dataSpec.ts index 5a5b0e0..7b1fd77 100644 --- a/solution/frontend/src/features/builder/canvas/dataSpec.ts +++ b/solution/frontend/src/features/builder/canvas/dataSpec.ts @@ -55,6 +55,26 @@ export interface CourseItem { source?: DataSource; } +export interface ScheduleSlot { + /** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */ + time: string; + title: string; + place?: string; + minutes?: number; + note?: string; + searchQuery?: string; +} + +export interface ScheduleItem { + name: string; + /** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */ + audience?: string; + season?: string; + slots?: ScheduleSlot[]; + verified?: DataVerified; + source?: DataSource; +} + export interface SectionDataSpec { /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ kind: string; @@ -302,6 +322,56 @@ export const SECTION_DATA_SPEC: Record = { · minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다. · 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`, }, + + schedule: { + kind: 'schedule', + label: '여행 스케줄', + requiredKey: 'name', + sample: JSON.stringify( + { + kind: 'schedule', + version: 1, + title: '여행 스케줄', + items: [ + { + name: '비 오는 날의 하루', + audience: '혼자 온 손님', + season: '장마', + slots: [ + {time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'}, + {time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'}, + {time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'}, + {time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'}, + {time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'}, + ], + verified: '확인필요', + source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'}, + }, + ], + }, + null, + 2, + ), + task: `[해야 할 일] +[업소]에 묵거나 들른 손님이 [지역]에서 하루를 어떻게 보내면 좋을지 여행 스케줄을 2~3개 만든다. +스케줄마다 시간대 5~7개. 아침부터 저녁까지 시각 순서로 배열한다. +스케줄은 서로 성격이 달라야 한다 — 날씨(비 오는 날)·동행(아이와 함께)·계절 중 하나로 가른다. + +[스키마] +{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[ + { "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨", + "slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소", + "minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ], + "verified":"확인|확인필요", + "source":{"name":"출처명","url":"https://..."} } ] }`, + rules: ` +[이 아이템만의 규칙] +· time 은 24시간 "HH:MM" 로만 적는다. "오전 9시" 처럼 쓰지 않는다. +· 그 장소의 영업시간·휴무일을 안다고 가정하지 않는다. 바뀌면 손님이 헛걸음한다. +· 첫 칸은 [업소]에서 시작하고, 이동은 걸어서 또는 대중교통으로 갈 수 있는 범위로 짠다. +· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다. +· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`, + }, }; export function dataSpecFor(sectionType: string): SectionDataSpec | undefined { diff --git a/solution/frontend/src/features/builder/canvas/registry.ts b/solution/frontend/src/features/builder/canvas/registry.ts index c74c1b0..ab28cf0 100644 --- a/solution/frontend/src/features/builder/canvas/registry.ts +++ b/solution/frontend/src/features/builder/canvas/registry.ts @@ -67,6 +67,7 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice'; import {SongsTurntable} from './variants/songs/SongsTurntable'; import {DailyCalendar} from './variants/daily/DailyCalendar'; import {CourseTickets} from './variants/course/CourseTickets'; +import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable'; export const SECTION_VARIANTS: Record = { hero: [ @@ -435,6 +436,17 @@ export const SECTION_VARIANTS: Record = { }, ], + schedule: [ + { + id: 'schedule.timetable', + name: '대합실 시간표', + description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.', + thumb: 'carousel', + Component: ScheduleTimetable, + isDefault: true, + }, + ], + exhibition: [ { id: 'exhibition.gallery', diff --git a/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx b/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx new file mode 100644 index 0000000..deeb7bf --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/schedule/ScheduleTimetable.tsx @@ -0,0 +1,174 @@ +/** + * 여행 스케줄 — 대합실 시간표 캐러셀. + * + * 칸 하나가 시간대 하나다. 위쪽 검은 판은 역 대합실의 플립보드(가운데 접힘선)를 그대로 옮긴 것 — + * 시각이 먼저 읽히고 무엇을 하는지가 뒤따라야 시간표로 읽힌다. + * ★ 승차권(course)과 일부러 다르게 짰다. 저쪽은 '어디를 도는가'(순번), 여기는 '언제 무엇을'(시각)이다. + * ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약. + */ +import {SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec'; +import { + CarouselNav, + ParseError, + PasteHint, + RETRO_BODY, + RETRO_INK, + RETRO_LINE, + RETRO_PAPER_LIGHT, + RETRO_RED, + RETRO_SIGN, + SourceLine, + useCarousel, +} from '../retro/common'; +import '../retro/retro.css'; + +/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */ +function splitTime(time: string): {head: string; tail?: string} { + const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!match) return {head: time.trim()}; + return {head: match[1].padStart(2, '0'), tail: match[2]}; +} + +function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) { + const {head, tail} = splitTime(slot.time ?? ''); + + return ( +
+
+ {/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */} +
+ + {head} + {tail && :{tail}} + + +
+ +
+

+ {slot.title} +

+ {slot.place && ( +

+ {slot.place} +

+ )} + {slot.note && ( +

+ {slot.note} +

+ )} +
+ +
+ {slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'} + {slot.searchQuery && 지도 검색 · {slot.searchQuery}} +
+
+ + {/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */} + {!isLast && ( +
+ +
+ )} +
+ ); +} + +function ScheduleRow({schedule}: {schedule: ScheduleItem}) { + const {ref, scrollBy} = useCarousel(); + const slots = schedule.slots ?? []; + const span = + slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined; + + return ( +
+
+
+

+ {schedule.name} +

+ + {[schedule.audience, schedule.season, span, `${slots.length}칸`] + .filter(Boolean) + .join(' · ')} + +
+ {slots.length > 2 && ( + scrollBy(-1)} onNext={() => scrollBy(1)} label={schedule.name} /> + )} +
+ + {slots.length === 0 ? ( +

+ 시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요. +

+ ) : ( +
+ {slots.map((slot, index) => ( + + ))} +
+ )} + + +
+ ); +} + +export function ScheduleTimetable(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + + return ( + + +
+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : parsed.items.length === 0 ? ( + + ) : ( +
+ {parsed.items.map((schedule, index) => ( + + ))} +
+ )} +
+
+ ); +}