diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 59ab905..ba03472 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -6,6 +6,52 @@ 존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서 달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다. +- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다. +- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용). +- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. +- 레이아웃은 `basic`과 `paper` 둘만 남겼다. 연결 안 된 레이아웃 5개, 배치 고르기, 서체 선택, 빌더 캔버스를 지웠다. +- 템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼지고, 빌더에 그 안내가 뜬다. +- 바뀐 동작: 저장된 모양(look)과 배치 선택은 무시한다. 레트로 사진은 캐러셀에서 그리드로 바뀐다. + 병원은 날씨·주변 정보가 기본으로 꺼진다. 모두 재발행할 때부터 적용된다. + +- 고택(`paper`)을 `/s/stay2` 시안과 같게 다시 만들었다. stay2의 CSS를 `layouts/paper/paper.css`로 + 옮기고, 섹션마다 stay2 마크업으로 그린다. 하위 페이지는 탭으로 바꿔 한 HTML 안에 둔다. + 같은 머뭄 데이터로 구워 1280px·390px에서 stay2와 나란히 찍어 비교했다. +- 프로젝트 코드 주석을 한 줄로 줄이고 히스토리 주석을 지웠다(파일 478개). 파이썬은 정리 전후 문법 + 트리가 같은지, TS는 주석을 뺀 토큰이 같은지 대조했다. + +구조와 새 템플릿 추가 방법은 [TEMPLATES.md](TEMPLATES.md). + +**검증** — shared·site·frontend·admin `tsc`, site `eslint`·`vitest` 105개, frontend `vite build` 통과. +백엔드는 DB 없이 도는 테스트 41개 통과, DB가 필요한 테스트는 로컬 DB 접속 문제로 못 돌렸다. + +## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로 + +admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안 +갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트 +하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다. + +- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부 + `RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` / + `crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다 + (`UserRole` 주석 원칙을 내부 화면에도 지킨다). +- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`). + `AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다. + ★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐, + 빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다. + 실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다. +- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만. + +**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트· +스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` · +`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요. + +## 2026-09-28 — 템플릿 정의를 한 파일로 모았다 + +템플릿 정보가 빌더, 렌더러, 백엔드에 따로따로 적혀 있어서 서로 어긋나 있었다. 백엔드 기본값이 +존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서 +달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다. + - 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다. - 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용). - 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. diff --git a/docs/TEMPLATES.md b/docs/TEMPLATES.md index 2b80df1..ed54e74 100644 --- a/docs/TEMPLATES.md +++ b/docs/TEMPLATES.md @@ -46,8 +46,10 @@ | `retro` | 레트로 | basic | | `paper` | 고택 | paper | -심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택만 머리글, 첫 화면, 객실 목록, -섹션 제목 모양이 따로 있다. +심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택은 손으로 만든 시안 `/s/stay2`를 +그대로 옮긴 것이라 뼈대부터 다르다. 폭 640px짜리 한 단에, 위쪽 탭 네 개(소개 · 지역 소개 · +이용안내·예약 · 이야기)로 화면을 나눈다. 탭을 눌러도 페이지를 새로 받지 않는다. 내용은 전부 한 +HTML 안에 있고 보이는 묶음만 바뀐다. 검색엔진은 네 묶음을 다 읽는다. 업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가 기본이고, 병원은 심플과 매거진만 쓸 수 있다. @@ -60,8 +62,12 @@ - `SectionHead`: 섹션 제목 모양. 없으면 공용 제목을 쓴다. - `sections`: 섹션별로 바꿔 그릴 컴포넌트. 여기 안 적은 섹션은 공용 컴포넌트를 그대로 쓴다. -고택은 첫 화면·객실·제목만 바꿨고, FAQ나 오시는 길은 심플과 같은 컴포넌트를 쓴다. 새 -레이아웃을 만들 때도 전부 새로 그릴 필요 없이 다르게 보여야 하는 섹션만 만들면 된다. +새 레이아웃을 만들 때 전부 새로 그릴 필요는 없다. 다르게 보여야 하는 섹션만 만들면 된다. +고택은 시안과 똑같이 맞추느라 대부분의 섹션을 따로 그렸다. 데이터를 고르는 계산(이용안내 표, +예약 달력, 예약 요청 전송)은 공용 섹션의 함수를 그대로 가져다 쓰고, 모양만 따로 그린다. + +고택에서 섹션이 어느 탭에 속하는지는 `layouts/paper/paper.css` 맨 아래 규칙이 정한다. 새 섹션을 +만들고 이 규칙에 적지 않으면 첫 탭(소개)에 나온다. ### 이상한 값이 들어오면 멈춘다 diff --git a/solution/shared/src/data/templates.json b/solution/shared/src/data/templates.json index 6953f9a..930c45b 100644 --- a/solution/shared/src/data/templates.json +++ b/solution/shared/src/data/templates.json @@ -89,7 +89,7 @@ "layout": "paper", "colors": { "primary": "#1f1d19", - "secondary": "#726c61", + "secondary": "#5b564e", "bg": "#fdfcfa", "card": "#f2efe8", "text": "#1f1d19", @@ -97,13 +97,13 @@ }, "look": { "fontHeading": "'Noto Serif KR', 'AppleMyungjo', 'Nanum Myeongjo', serif", - "fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", - "radius": "0px", + "fontBody": "-apple-system, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif", + "radius": "4px", "borderWidth": "1px", "shadow": "none", "headingTracking": "0.03em", "headingWeight": "400", - "sectionSpace": "4.5rem" + "sectionSpace": "2.5rem" }, "addSections": [] } diff --git a/solution/site/src/app.test.tsx b/solution/site/src/app.test.tsx index 0740f58..125bcf6 100644 --- a/solution/site/src/app.test.tsx +++ b/solution/site/src/app.test.tsx @@ -14,7 +14,8 @@ it('템플릿이 가리키는 레이아웃의 Frame으로 그린다', () => { const basic = renderToStaticMarkup(); const paper = renderToStaticMarkup(); expect(basic).toContain('shell flex h-16'); - expect(paper).toContain('shell flex h-14'); + expect(paper).toContain('class="w4p"'); + expect(basic).not.toContain('class="w4p"'); }); it('여러 템플릿이 같은 레이아웃을 쓴다', () => { diff --git a/solution/site/src/layouts/index.ts b/solution/site/src/layouts/index.ts index a20e4b9..cbef1e4 100644 --- a/solution/site/src/layouts/index.ts +++ b/solution/site/src/layouts/index.ts @@ -1,10 +1,25 @@ import type {LayoutId} from '@o2o/shared'; import type {Layout} from '@site/lib/layout'; import {Frame as BasicFrame} from './basic/Frame'; +import {Around as PaperAround} from './paper/Around'; +import {Booking as PaperBooking} from './paper/Booking'; +import {Faq as PaperFaq} from './paper/Faq'; +import {Festival as PaperFestival} from './paper/Festival'; import {Frame as PaperFrame} from './paper/Frame'; +import {Gallery as PaperGallery} from './paper/Gallery'; import {Hero as PaperHero} from './paper/Hero'; +import {Info as PaperInfo} from './paper/Info'; +import {Intro as PaperIntro} from './paper/Intro'; +import {Itinerary as PaperItinerary} from './paper/Itinerary'; +import {Location as PaperLocation} from './paper/Location'; import {Rooms as PaperRooms} from './paper/Rooms'; import {SectionHead as PaperSectionHead} from './paper/SectionHead'; +import {Story as PaperStory} from './paper/Story'; +import {StoryTail as PaperStoryTail} from './paper/StoryTail'; +import {Video as PaperVideo} from './paper/Video'; +import {Weather as PaperWeather} from './paper/Weather'; + +const Nothing = () => null; // 등록 안 한 섹션은 공용 컴포넌트를 쓴다. export const LAYOUTS: Record = { @@ -12,6 +27,26 @@ export const LAYOUTS: Record = { paper: { Frame: PaperFrame, SectionHead: PaperSectionHead, - sections: {hero: PaperHero, rooms: PaperRooms, menu: PaperRooms, programs: PaperRooms}, + sections: { + hero: PaperHero, + weather: PaperWeather, + intro: PaperIntro, + blog: Nothing, + rooms: PaperRooms, + menu: PaperRooms, + programs: PaperRooms, + photos: PaperGallery, + video: PaperVideo, + local: PaperAround, + booking: PaperBooking, + info: PaperInfo, + map: PaperLocation, + faq: PaperFaq, + festival: PaperFestival, + itinerary: PaperItinerary, + story: PaperStory, + reviews: PaperStoryTail, + postcardMaker: Nothing, + }, }, }; diff --git a/solution/site/src/layouts/paper/Around.tsx b/solution/site/src/layouts/paper/Around.tsx new file mode 100644 index 0000000..f59acde --- /dev/null +++ b/solution/site/src/layouts/paper/Around.tsx @@ -0,0 +1,174 @@ +import {useState} from 'react'; +import type {LocalPlace} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {naverMapSearchUrl} from '@site/lib/format'; +import {Car} from './Car'; +import {Sheet} from './Sheet'; + +const WALK = 80; +const RANGES = [ + {id: 'all', label: '전체', test: () => true}, + {id: 'w5', label: '걸어서 5분 이내', test: (m: number) => m <= 5 * WALK}, + {id: 'w10', label: '걸어서 10분 이내', test: (m: number) => m <= 10 * WALK}, + {id: 'far', label: '걸어서 10분 이상', test: (m: number) => m > 10 * WALK}, +] as const; +const FIRST_ROWS = 8; + +function meters(place: LocalPlace): number { + if (place.distanceMeters != null) return place.distanceMeters; + const m = /^([\d.]+)\s*(km|m)$/i.exec((place.distanceText ?? '').trim()); + return m ? Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1) : Infinity; +} + +const byDistance = (a: LocalPlace, b: LocalPlace) => meters(a) - meters(b); + +function RangeTabs({places, value, onChange}: {places: LocalPlace[]; value: string; onChange: (id: string) => void}) { + const tabs = RANGES.filter((r, i) => { + const n = places.filter((p) => r.test(meters(p))).length; + return i === 0 || (n > 0 && n < places.length); + }); + if (tabs.length < 2) return null; + return ( +
+ {tabs.map((r) => ( + + ))} +
+ ); +} + +const inRange = (id: string) => RANGES.find((r) => r.id === id)?.test ?? (() => true); + +export function Around() { + const payload = useSite(); + const {local} = payload; + const spots = [...local.attractions].sort(byDistance); + const eats = [...local.restaurants].sort(byDistance); + const rail = [...spots, ...eats].filter((place) => place.imageUrl).sort(byDistance); + const [open, setOpen] = useState(null); + const [spotRange, setSpotRange] = useState('all'); + const [eatRange, setEatRange] = useState('all'); + if (rail.length === 0 && eats.length === 0) return null; + + const shownSpots = spots.filter((p) => inRange(spotRange)(meters(p))); + const shownEats = eats.filter((p) => inRange(eatRange)(meters(p))); + const row = (place: LocalPlace) => ( + + ); + + return ( + <> + {rail.length > 0 && ( +
+
+

AROUND

+
+

주변 안내

+ 지역 소개 → +
+

+ 걸어서 닿는 명소와 맛집입니다. +

+ ( + + ))} + /> +
+
+
+ )} + +
+ {spots.length > 0 && ( + <> +
+

AROUND

+

+ 주변 명소 {spots.length}곳 +

+

+ 걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다. +

+ +
+ {shownSpots.map((place) => ( + + ))} +
+
+
+ + )} + {eats.length > 0 && ( + <> +
+

EAT

+

+ 주변 맛집 {eats.length}곳 +

+ + {shownEats.slice(0, FIRST_ROWS).map(row)} + {shownEats.length > FIRST_ROWS && ( +
+ + {shownEats.length - FIRST_ROWS}곳 더 보기 + + {shownEats.slice(FIRST_ROWS).map(row)} +
+ )} +
+
+ + )} +
+ + setOpen(null)} label={open?.name ?? ''}> + {open && ( + <> + {open.imageUrl && {open.name}} +

{open.name}

+

{[open.distanceText, open.category].filter(Boolean).join(' · ')}

+ {open.description &&

{open.description}

} + + 네이버에서 보기 + + + )} +
+ + ); +} diff --git a/solution/site/src/layouts/paper/Booking.tsx b/solution/site/src/layouts/paper/Booking.tsx new file mode 100644 index 0000000..3b9af2c --- /dev/null +++ b/solution/site/src/layouts/paper/Booking.tsx @@ -0,0 +1,70 @@ +import {PlaceCategory} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {bookingActionLabel, bookingLinks, stayBookingView} from '@site/lib/derive'; +import {BOOK_EVENT} from './Rooms'; + +export function Booking() { + const payload = useSite(); + const links = bookingLinks(payload); + const stay = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null; + const phone = payload.place.phone; + const book = () => window.dispatchEvent(new Event(BOOK_EVENT)); + + return ( + <> +
+
+

RESERVATION

+

이용안내 및 예약

+

+ 요금과 예약 방법을 안내합니다. +

+ {links.slice(0, 1).map((link) => ( + + {bookingActionLabel(link)} + + ))} + + 이용안내 · 예약 요청 + +
+
+ +
+
+

RESERVATION

+

이용안내 및 예약

+

+ 요금과 예약 방법을 안내합니다. +

+ {links.map((link) => ( + + {bookingActionLabel(link)} + + ))} + {phone && ( + + {phone} + + )} + {stay && ( + + )} +
+
+
+ + ); +} diff --git a/solution/site/src/layouts/paper/BookingForm.tsx b/solution/site/src/layouts/paper/BookingForm.tsx new file mode 100644 index 0000000..80e60f2 --- /dev/null +++ b/solution/site/src/layouts/paper/BookingForm.tsx @@ -0,0 +1,261 @@ +import {useEffect, useMemo, useState} from 'react'; +import {selectPublishable} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {useBookingRequest} from '@site/sections/BookingRequestSection'; +import { + buildArrivalSlots, + buildMonth, + demoUnits, + MONTH_SPAN, + shortUnitName, + WEEKDAY_LABEL, + type DayCell, +} from '@site/sections/StayBookingDemo'; + +function CalendarIcon() { + return ( + + + + + ); +} + +const label = {display: 'block', margin: '14px 0 6px', fontSize: '14.5px', color: 'var(--sub)'} as const; + +export function BookingForm() { + const payload = useSite(); + const units = useMemo(() => demoUnits(payload), [payload]); + const checkIn = selectPublishable(payload.facts).find((fact) => fact.key === 'check_in_time')?.value ?? undefined; + const slots = useMemo(() => buildArrivalSlots(checkIn), [checkIn]); + const [today, setToday] = useState(null); + const [offset, setOffset] = useState(0); + const [openStep, setOpenStep] = useState(false); + const [dateIso, setDateIso] = useState(null); + const [slot, setSlot] = useState(null); + const [unitId, setUnitId] = useState(units[0]?.unitId ?? null); + const [guests, setGuests] = useState(2); + const [confirmed, setConfirmed] = useState(false); + useEffect(() => setToday(new Date()), []); + + const cursor = today ? new Date(today.getFullYear(), today.getMonth() + offset, 1) : null; + const cells = today && cursor ? buildMonth(cursor.getFullYear(), cursor.getMonth(), today) : []; + const day = cells.find((cell): cell is DayCell => cell !== null && cell.iso === dateIso) ?? null; + const unit = units.find((u) => u.unitId === unitId) ?? null; + const maxGuests = unit?.maxCapacity ?? 8; + const price = + day && unit ? (day.isWeekend ? (unit.weekendPrice ?? unit.weekdayPrice) : unit.weekdayPrice) : undefined; + const ready = Boolean(dateIso && unit && (slots.length === 0 || slot)); + const stay = day && cursor ? `${cursor.getMonth() + 1}월 ${day.day}일${slot ? ` ${slot} 도착` : ''}` : undefined; + const who = unit ? `${shortUnitName(unit.name)} · ${guests}명` : `${guests}명`; + const {submit, sending, done} = useBookingRequest(stay, who); + + useEffect(() => { + setGuests((current) => Math.min(current, unit?.maxCapacity ?? 8)); + }, [unit]); + + if (units.length === 0) return null; + + if (done?.ok) { + return ( +
+

{done.message}

+

예약이 확정된 것은 아닙니다. 사장님이 확인 후 연락드립니다.

+
+ ); + } + + return ( +
+

+ 빈 방을 확인해 사장님이 직접 연락드립니다. +

+

+ 예약이 확정되는 것은 아닙니다. 사장님 확인 후 연락드립니다. +

+
+ + +
+
+ {stay ? ( + <> +

+ {stay} · {who} +

+ {price != null &&

{price.toLocaleString('ko-KR')}원

} + {price != null && ( +

1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다.

+ )} + + ) : ( +

+ 날짜를 골라 주세요 +

+ )} +
+ {!confirmed ? ( + + ) : ( +
+ + + + + + + +