Compare commits

...

4 Commits

Author SHA1 Message Date
f2dad65792 [fix] site,deploy: 발행본 목록의 정본 주소를 /s 로 — 끝 슬래시 제거
`/s` 는 nginx `location ^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어진다.
그래서 404 가 아니라 **빌더 SPA 셸이 200 으로** 나가고 있었다 — 실측 2026-09-08:
`/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록. 404 도 목록도 아닌 세 번째
페이지가 오리진에 있었던 셈이다. 목록만 슬래시가 붙어 있던 이유도 이것 하나였다.

색인 요청·사이트맵이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한
표준 태그가 있음)" 로 분류한다 — 슬러그 쪽에서 이미 밟은 함정인데(prerender.ts
주석) 목록만 반대 형태로 남아 있었다.

- nginx/site.conf.example: `location = /s` 로 목록 index.html 직접 서빙, `/s/` 는 301.
  `^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
- 같은 파일: `absolute_redirect off`. TLS 를 앞단 Apache 가 끊어 nginx 의 `$scheme` 는
  늘 `http` 다 — 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려간다
- prerender.ts: `indexUrl` 의 `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가
  이 값 하나를 쓴다
- directory.ts · AGENTS.md · DEVLOG.md: 슬래시 규칙과 근거 갱신

검증: `nginx -t` 통과 · `tsc --noEmit` 통과 · 컨테이너 실측
`/s`→200 목록 · `/s/`→301 `Location: /s`(상대) · `/s/<slug>`→200 · `/s/<slug>/`→200 ·
`/nope`→200 앱 셸(변화 없음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129XqVdjDk9JmMNFAepBJvs
2026-09-08 13:02:11 +09:00
9f16c3224b [feat] solution/backend,frontend: 내 사이트 목록을 카드로 — 썸네일·주소·시각 · 발행마다 그림 갱신
목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 road_address·created_at·
published_at 을 주는데 화면이 안 썼다. 실측(계정 test): 35줄 중 34줄이 발행 전이고
같은 상호 '버터브루' 가 4줄이라 어느 게 어느 건지 가릴 단서가 화면에 없었다.

리서치 — Wix My Sites 는 이름·URL·Premium·협업자만 두고 검색·그리드/리스트 전환·폴더가 있다.
Sites API 문서가 권하는 조합은 displayName·thumbnail·viewUrl·editUrl 이다.
아임웹 내사이트는 **실제 화면을 열어 봤다**(imweb.me 가이드): 줄 왼쪽에 큰 가로형 썸네일,
상호 아래 도메인, 그리고 도메인/SSL·PG 신청처럼 **안 끝난 설정**을 줄 안에 배지로 늘어놓는다.
공통 원칙은 목록이 ① 구분 ② 상태 ③ 여는 길 셋만 한다는 것 — 통계는 사이트 안 대시보드다.

- protocol·site_service: `MySiteData.thumbnail_url` 추가. 목록이 사이트 행을 이미 조인해
  읽고 있어서 쿼리는 그대로다
- site_thumbnail: 공개 주소에 `?v=<발행 버전>`. 블롭 이름은 고정이고 내용만 덮어쓰므로
  주소가 안 변하면 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보인다
  (CACHE_CONTROL 60초로는 그 60초를 못 막는다). 이름에 버전을 넣지 않은 이유는
  사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없어서다
- site_thumbnail: 썸네일 전용 저장소 스위치(`THUMBNAIL_BLOB_*`). 예전엔 키 하나가
  사이트 전체 업로드(azure_static)까지 같이 켰다 — 둘은 필요한 저장소가 다르다
  (사이트는 정적 호스팅 `$web`, 썸네일은 이미지 버킷이면 된다)
- SitesPage: 줄 → **카드 그리드**. 썸네일은 16:10(브라우저 창 비율 — 사이트 미리보기를
  1:1 로 자르면 무슨 사이트인지 못 알아본다). 검색(상호·주소, 공백 무시) + 상태 칸
  `전체/발행됨/발행 전` 에 건수. 판정은 `bucketOf` 하나가 소유한다(배지·필터·정렬이 갈라지면
  건수가 어긋나 목록을 못 믿게 된다). 검색 0건 화면을 처음 온 사람의 빈 화면과 분리했다 —
  35개 있는데 "아직 없습니다" 라고 말하던 자리다
- 카드 골격은 **상태와 무관하게 같다**. 발행 전 카드에만 줄이 하나 더 붙어 높이와 버튼
  위치가 어긋났다(사장님 지적). 버튼 문구도 '편집' 하나로 — 하는 일이 같은데 글자만 달랐다

아직 그림이 없는 사이트가 대부분이다. 썸네일은 발행에 성공해야 생긴다.

검증: 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 전 줄에
`thumbnail_url` 키가 아예 없는지, 재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지 4건 추가.
프론트 tsc+eslint 통과. 실제 발행으로 블롭 업로드(232KB) → 공개 주소 200 → 목록 반영 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:47 +09:00
94551afdaf [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프
가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.

- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
  body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
  실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
  잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
  순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
  `places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
  회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
  스코프가 없으면 아무에게도 안 보이는 유령이다.
  실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
  격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다

남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.

검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:14 +09:00
00e13bca7f [feat] solution/site,shared: 발행본을 /s/stay 목업에 맞춘다 — 잃어버린 CSS 토큰 복원 + 섹션 마크업 이식
목업(/s/stay)이 참조하는 스타일시트가 서버에서 사라져(404) 그 화면을 기준으로 삼을 수
없었다. 브라우저 캐시에 남아 있던 규칙을 꺼내 레포에 되돌리고, 섹션 마크업을 목업 HTML
에서 그대로 옮겼다. Playwright 대조로 섹션 래퍼·제목 16/17, 안쪽 구조 14/17 일치.

- index.css: --fs-display~--fs-xs(유동 타이포 7) · --section-space · --color-line/muted ·
  .h2 .h3 .panel .paper .measure .border-line .text-muted .divide-line .slider-viewport/track
  ★ 이 토큰이 없던 동안 컴포넌트가 var(--fs-display)를 써도 브라우저가 조용히 무시했다 —
    히어로 제목이 본문 크기로 나오던 원인
- HeroSection: 가운데 → 좌하단. 사진 위에 글자를 한가운데 얹으면 피사체를 정확히 가린다
- AnswerBlock·EssentialInfo·Units·Gallery·Location·Faq·LocalGuide·Weather·About:
  목업 마크업 그대로. embla 를 scroll-snap 으로 바꿔 스크립트 없이도 레일이 밀린다
- FestivalSection(계절 탭) · StorySection(이야기 탭 래퍼) · items/TripMap(OSM 타일 지도) 신설
  ★ 지도는 iframe 이 아니라 타일을 직접 깐다 — iframe 은 핀을 하나밖에 못 찍어
    "어떤 순서로 도는가" 를 그릴 수 없다
- items: itinerary·event·video kind 추가(파서에 없어 payload 까지 실려 오고도 화면에서
  사라지던 것) · Rail 을 slider-viewport/track 으로 · SongsSection 을 턴테이블로
- color.ts: 선 색을 secondary → mix(bg, text, .2). secondary 는 '본문 다음으로 진한
  글자색' 이라 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다
- seo/head.ts: --tpl-texture. 없으면 색만 갱지고 면은 매끈해서 인쇄물로 안 보인다
- seo/verify.ts: unitCode·numberOfRooms 를 화면 대조에서 뺀다
  ★ unitCode 는 ㎡의 ISO 코드로 priceCurrency 와 같은 성격인데 예외에 없었다. 그래서
    room_size 가 있는 사업장은 발행 게이트가 전부 막았다(실측: 가은채 객실 12개).
    stay 는 객실 2개 + room_size 없음이라 우연히 통과했다
- shared: LocalPlace.imageUrl/distanceMeters · FestivalEntry.imageUrl ·
  ItineraryStop 좌표 · TemplateLook.texture · people/chronicle/postcard imageUrl
  ★ 좌표·사진은 모델이 만드는 칸이 아니다. 공식 API 로 조회해 채운다

검증: tsc 통과 · Playwright 대조(목업 CSS 주입) 섹션 래퍼·제목 16/17, 안쪽 14/17.
남은 차이는 데이터 한계다 — people 사진은 위키에 원본이 없고(original:false),
weather.note 는 정적 페이지에 날씨 문장을 박는 문제라 보류.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP1ykMnWZFtLFpm2mow1Sw
2026-09-08 09:53:19 +09:00
105 changed files with 3121 additions and 1349 deletions

View File

@ -73,8 +73,15 @@
- **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`).
- **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()` ↔
`solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다.
- **디렉토리 요청 → `index.html`.** `/s/<slug>` 가 **끝 슬래시 없이** 열려야 한다.
정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다.
- **★ 발행본 주소는 끝 슬래시가 없다 — 목록 페이지 `/s` 도 마찬가지다.**
canonical · 사이트맵 · llms.txt · 서치콘솔 색인 요청이 전부 이 형태여야 한다. 어긋나면
구글이 제출분을 **"대체 페이지(적절한 표준 태그가 있음)"** 로 분류한다 — 색인은 되는데
제출 URL 은 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
→ nginx 는 `location = /s` 로 목록 index.html 을 **직접** 주고 `/s/` 는 거기로 301 한다.
이 블록을 지우면 `/s` 가 맨 아래 `location /` 로 떨어져 **빌더 SPA 셸이 200 으로 나간다**
— 404 도 목록도 아닌 세 번째 페이지가 크롤러에 잡힌다(실측 2026-09-08).
→ 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단
Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
## 코드 규약

View File

@ -186,10 +186,11 @@ USER role=1 → 403 DEVELOPER role=3 → 200
OWNER role=2 → 403
```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기
회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고
`places.owner_user_id` 가 자기 계정인 사업장만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
(2026-09-08 전에는 이 스코프가 회사(테넌트)였다 — DECISIONS.md 2절)
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가

View File

@ -88,9 +88,9 @@
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
| DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 |
| 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 |
| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.companies`/`company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 |
| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 |
| 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 |
| `companies` 테이블 유지 | 유지 | 보일러플레이트의 멀티테넌트 스코프 키(`UserInfo.company_id`)가 전 계층에 박혀 있다. 대행사/운영사 단위로 그대로 쓴다 |
| `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 |
| ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 |
| 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 |
| 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 |

View File

@ -5,6 +5,113 @@
---
## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다
**무슨 일**
사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지
(`/s/<slug>`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다.
목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/` 는 **슬래시로 시작하는 것만** 잡고,
`/s` 는 맨 아래 `location /` 로 떨어진다.
**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다
(실측: `/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도
목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다.
**바꾼 것**
- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/` 는
거기로 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
- `absolute_redirect off`: TLS 를 앞단 Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다.
기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려가는 301 이 나간다
- `prerender.ts` `indexUrl`: `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를
쓰므로 전부 같이 따라온다
**왜 형태를 맞추나**
색인 요청·사이트맵 URL 이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준
태그가 있음)" 로 분류한다 — 색인은 되는데 제출 URL 은 0건으로 보인다. 슬러그 쪽에서 한 번
밟은 함정이고(`prerender.ts` 주석), 목록만 반대 형태로 남아 있었다.
**남은 것**
`/s/<slug>/` 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서
크롤러가 스스로 만들어낼 주소는 아니다.
---
## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다
**무슨 일**
목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 `road_address`·`created_at`·
`published_at` 을 주고 있는데 화면이 안 썼다. 한 계정에 '버터브루' 가 4줄 있으면 어느 게
어느 건지 가릴 단서가 화면에 하나도 없다.
**리서치** (Wix · 아임웹)
- Wix `My Sites` 줄에 보이는 건 이름·URL·Premium·협업자뿐이고 **썸네일도 수정일도 없다.**
대신 Sites API 문서가 "이렇게 그려라" 로 지목한 조합은 `displayName · thumbnail · viewUrl ·
editUrl` 이고, 정렬은 최근 수정순이다 — 화면보다 API 권고 쪽이 우리 상황에 맞다.
- 아임웹 내사이트는 기본 정보 + 액션(관리자 접속·복제·템플릿 변경·소유권 이전),
리셀러 목록은 **만료일**을 목록에서 바로 본다. 방문자·주문 숫자는 목록이 아니라
사이트 안 대시보드에 있다.
- 공통: 목록은 **구분 · 상태 · 여는 길** 셋만 한다. 그리고 **둘 다 생성일을 안 쓴다** —
구분은 그림·주소·이름이 하고, 시각은 "마지막으로 뭔가 한 시각" 이 쓰인다.
**바꾼 것**
- `MySiteData.thumbnail_url` 추가(`site_service._my_site_row`). 목록이 사이트 행을 이미
조인해 읽고 있어서 쿼리는 그대로다
- 줄 앞에 썸네일. 없으면 업종 아이콘으로 떨어지고, 로드 실패해도 아이콘으로 되돌린다 —
블롭이 지워진 옛 주소에서 깨진 그림이 뜨는 것보다 낫다
- 줄 아래 한 칸: `도로명 주소 · 시각`. 시각은 **발행됐으면 발행일, 아니면 만든 날** 하나만
쓴다(위 리서치의 결론). 올해면 연도를 뗀다 — 줄이 좁아 주소가 먼저 잘린다
**썸네일이 발행마다 바뀌게** (`site_thumbnail.public_url`)
블롭 이름은 `thumbs/<slug>.<ext>` 로 고정이고 내용만 `overwrite=True` 로 덮어쓴다. 그래서
주소가 안 변했고, 사장님이 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보였다
(`CACHE_CONTROL` 60초만으로는 그 60초를 못 막는다). 주소에 `?v=<발행 버전>` 을 붙인다.
→ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없다.
→ `scripts/backfill_thumbnails.py` 처럼 그 시점 버전이 없는 경로는 `version=None` 으로
그냥 붙이지 않는다.
**아직 그림이 한 장도 없다** — 로컬·현재 DB 의 사이트 39개 전부 `thumbnail_url` 이 NULL 이다.
버그가 아니라 `AZURE_STORAGE_CONNECTION_STRING` 이 비어 `site_thumbnail.is_configured()` 가
False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음 발행부터 채워진다.
**검증** — 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 안 한 줄에
`thumbnail_url` 키가 아예 없는지, **재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지** 4건 추가.
프론트 `tsc + eslint` 통과.
---
## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다
**무슨 일**
사장님이 가입하면 회사가 하나 생기고 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고,
에디터 헤더에는 "이름 · 회사명" 이 붙었다. 쓰는 사람은 사장님 한 명인데.
**왜 그랬나**
보일러플레이트(negodata)의 멀티테넌트 스코프 키를 그대로 물려받았다. DECISIONS.md 2절이
"대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다 — 2026-09-08 철회했다.
**바꾼 것**
- 스코프 키가 `company_id` → `places.owner_user_id` 다. `UserInfo` 에서 `company_id` 를 뺐고
(JWT 클레임도 같이 사라진다), `place_crud`·`site_crud` 의 WHERE 가 전부 주인으로 바뀌었다
- **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 — body 로 받으면 남의
계정을 적어 만들자마자 남의 목록에 넣을 수 있다. 실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고,
스코프는 회사가 대신 하고 있었다
- 잡 페이로드 키 `company_id` → `owner_user_id`. 워커가 세우는 `UserInfo.user_id` 는 이제
**사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid 순으로 채웠는데, 그 랜덤 uuid 가
스코프 키가 되는 순간 "남의 사업장" 이 되어 fact 조회가 0건이 된다
- `company.companies` 테이블 · `users.company_id` · `Res_Me.company` · 가입 폼의 상호 칸 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나. 격리 테스트는
`auth_headers("o2")` 를 한 번 더 부르면 그게 남이다
**마이그레이션** (`init.sql` 끝, 재실행 안전)
백필 → NOT NULL → 컬럼 삭제 순서다. 회사에 계정이 여럿이던 경우는 **가장 먼저 만든 계정**에게
몰아준다. 주인을 못 찾은 행은 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다.
실측(로컬): 92건 → 91건(고아 1건 삭제), `demoebf050` 56 · `test` 35.
**남긴 것** — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.
---
## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
**무슨 일**

View File

@ -44,10 +44,30 @@ server {
image/svg+xml;
# ── 발행 사이트 ────────────────────────────────────────────
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
# `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다.
absolute_redirect off;
# ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/<slug>`)와 형태를 맞춘다.
# 이 블록이 없으면 `/s` 는 `^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어지고
# **빌더 SPA 셸이 200 으로 나간다.** 404 도 목록도 아닌 세 번째 페이지가 크롤러에
# 잡힌다(실측 2026-09-08: `/s` 3.1KB 앱 셸 · `/s/` 6.7KB 목록).
location = /s {
root /srv/sites;
try_files /s/index.html =404;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# 옛 주소. 사이트맵·서치콘솔에 `/s/` 로 제출된 것이 남아 있다.
location = /s/ {
return 301 /s;
}
# ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다.
location ^~ /s/ {
# ★ `/s/` 자체(발행본 목록 페이지)를 위해 필요하다. try_files 의 첫 인자 `$uri` 가
# 끝 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
# ★ `/s/<slug>/`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝
# 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
# index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
index index.html;
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를

View File

@ -5,7 +5,7 @@
-- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다.
-- postgres (1개 서버, 5432)
-- └── web4ai_db
-- ├── company : companies, users
-- ├── company : users
-- ├── place : places, place_aliases, place_links, units, media
-- ├── fact : facts, faqs
-- ├── local : local_contents, routes, nearby_links
@ -46,28 +46,12 @@ CREATE SCHEMA IF NOT EXISTS site;
CREATE SCHEMA IF NOT EXISTS job;
-- ============================================================
-- company : 회사 / 내부 유저
-- company : 계정
-- ★ 스키마 이름만 company 다. 회사(테넌트) 개념은 2026-09-08 에 걷어냈다 —
-- 스키마 rename 은 모든 모델의 __table_args__ 를 건드려야 해서 따로 둔다.
-- ============================================================
CREATE TABLE IF NOT EXISTS company.companies (
company_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 회사 식별자(PK)
name VARCHAR(100) NOT NULL, -- 회사명
business_number VARCHAR(30) NULL, -- 사업자등록번호
code INTEGER NULL, -- 회사코드 (내부 인덱스용)
representative_name VARCHAR(50) NULL, -- 대표자명
email VARCHAR(255) NULL, -- 대표 이메일
contact_number VARCHAR(20) NULL, -- 대표 연락처
website_url VARCHAR(255) NULL, -- 홈페이지 URL
industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 )
status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
settings JSONB NULL, -- 회사별 커스터마이징: branding(CI)/labels(용어)/features(동작)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
CREATE TABLE IF NOT EXISTS company.users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK)
company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id)
id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_<sub>)
password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL
name VARCHAR(50) NULL, -- 이름
@ -88,8 +72,7 @@ CREATE TABLE IF NOT EXISTS company.users (
-- ============================================================
CREATE TABLE IF NOT EXISTS place.places (
place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK)
company_id uuid NOT NULL, -- 테넌트(company.companies.company_id)
owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id)
owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.users.user_id)
name VARCHAR(200) NOT NULL, -- 상호명(입력값)
category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과
status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended
@ -355,15 +338,11 @@ CREATE TABLE IF NOT EXISTS job.jobs (
-- ============================================================
-- 인덱스
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE;
-- place
CREATE INDEX IF NOT EXISTS idx_places_company_id ON place.places (company_id);
CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON place.places (owner_user_id);
CREATE INDEX IF NOT EXISTS idx_places_region_code ON place.places (region_code) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_place_aliases_place ON place.place_aliases (place_id);
@ -448,3 +427,26 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provid
-- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다
-- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다).
ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL;
-- 2026-09-08 회사(테넌트) 제거. 쓰는 사람은 사장님 혼자인데 가입 한 번이 회사를 하나 만들고
-- 그 회사의 직원이 되는 구조였다. 사업장을 계정에 직접 매단다.
-- ★ 순서가 중요하다 — 백필 → NOT NULL → 컬럼 삭제. 반대로 하면 주인을 잃은 행이 남는다.
-- ★ 회사에 계정이 여럿이던 경우(내부 운영 계정)는 **가장 먼저 만든 계정**에게 몰아준다.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='place' AND table_name='places' AND column_name='company_id') THEN
UPDATE place.places p
SET owner_user_id = (
SELECT u.user_id FROM company.users u
WHERE u.company_id = p.company_id AND u.deleted = FALSE
ORDER BY u.created_at LIMIT 1)
WHERE p.owner_user_id IS NULL;
-- 주인을 못 찾은 행(회사가 통째로 지워진 경우)은 남겨 둘 수 없다 — 스코프가 없으면 아무에게도 안 보인다.
DELETE FROM place.places WHERE owner_user_id IS NULL;
ALTER TABLE place.places ALTER COLUMN owner_user_id SET NOT NULL;
ALTER TABLE place.places DROP COLUMN company_id;
END IF;
END $$;
ALTER TABLE company.users DROP COLUMN IF EXISTS company_id;
DROP TABLE IF EXISTS company.companies;

View File

@ -10,7 +10,6 @@ from common.enums import (
DBType,
UserStatus,
UserRole,
CompanyStatus,
PlaceStatus,
FactStatus,
MediaStatus,
@ -45,30 +44,11 @@ class MainTableMixin(_DBTypeMixin):
# ERD 도메인 모델
class companies(MainTableMixin, MAIN_BASE):
__tablename__ = "companies"
__table_args__ = {"schema": "company"}
company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
business_number = Column(String(30), nullable=True)
code = Column(Integer, nullable=True) # 내부 인덱스용
representative_name = Column(String(50), nullable=True)
email = Column(String(255), nullable=True)
contact_number = Column(String(20), nullable=True)
website_url = Column(String(255), nullable=True)
industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT)
status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus
# 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작)
settings = Column(JSONB, nullable=True)
class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users"
__table_args__ = {"schema": "company"}
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
# sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다.
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디
@ -102,8 +82,8 @@ class places(MainTableMixin, MAIN_BASE):
)
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 테넌트 스코프(company.companies)
owner_user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 사장님 계정(company.users)
# ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다.
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(company.users)
name = Column(String(200), nullable=False) # 상호명(입력값)
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)

View File

@ -67,9 +67,8 @@ class PageParams:
class UserInfo(StructModel):
"""JWT subject 로 인코딩되는 유저 식별 정보."""
user_id: str # users.user_id (uuid) — 데이터 스코프 키
user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다
id: str # users.id (로그인 아이디) — get_me 재조회 키
company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
def __init__(self, *args, **kwargs) -> None:

View File

@ -14,7 +14,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from common.database.model.models import MAIN_BASE
from common.enums import CompanyStatus
from common.enums import UserRole, UserStatus
from config.server_configs import main_db_config
@ -92,7 +92,7 @@ async def db_engine(_test_db_lifecycle):
# 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
await conn.execute(
text(
"TRUNCATE TABLE users, companies, places, place_aliases, place_links, units, media, "
"TRUNCATE TABLE users, places, place_aliases, place_links, units, media, "
"facts, faqs, local_contents, routes, nearby_links, "
"sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE"
)
@ -102,30 +102,24 @@ async def db_engine(_test_db_lifecycle):
@pytest_asyncio.fixture
async def company_id(db_engine) -> str:
"""테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다.
users 는 company_id 를 요구하므로 계정 생성 테스트의 선행 조건이다.
async def owner_id(db_engine) -> str:
"""사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다.
★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로
계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다.
"""
cid = uuid.uuid4()
uid = uuid.uuid4()
async with db_engine.begin() as conn:
# status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
# status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
await conn.execute(
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
{"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value},
text(
"INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) "
"VALUES (:uid, :id, NULL, :name, :status, :role, now())"
),
{"uid": uid, "id": f"seed{uid.hex[:8]}", "name": "시드사장",
"status": UserStatus.ACTIVE.value, "role": UserRole.USER.value},
)
return str(cid)
@pytest_asyncio.fixture
async def other_company_id(db_engine) -> str:
"""company_id 와 다른 소속사 1개(회사 스코프/IDOR 격리 테스트용)."""
cid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
{"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value},
)
return str(cid)
return str(uid)
@pytest_asyncio.fixture
@ -139,28 +133,27 @@ async def client(db_engine):
@pytest_asyncio.fixture
async def auth_headers(db_engine, client, company_id):
async def auth_headers(db_engine, client):
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.
계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다.
company 미지정 시 기본 소속사(company_id 픽스처). role 로 OWNER 계정도 만들 수 있다.
호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`.
★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다
— 격리 테스트는 `await auth_headers("o2")` 하나면 된다.
호출: `h = await auth_headers("user1")`.
"""
from common.enums import UserRole, UserStatus
from router.v1.validator.dependencies import GetHashedPW
async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"):
cid = company or company_id
async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"):
hashed = await GetHashedPW(password)
async with db_engine.begin() as conn:
# status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시(companies.status 와 동일).
# status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시.
await conn.execute(
text(
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
"VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())"
"INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) "
"VALUES (:uid, :id, :pw, :name, :status, :role, now())"
),
{
"uid": uuid.uuid4(), "cid": uuid.UUID(cid), "id": login_id, "pw": hashed,
"uid": uuid.uuid4(), "id": login_id, "pw": hashed,
"name": name, "status": UserStatus.ACTIVE.value, "role": role,
},
)

View File

@ -11,26 +11,26 @@ from common.logger import LOG
from common.utils.gtime import GTime
# 사업장 CRUD. 모든 조회는 company_id(테넌트)로 스코프한다 — 남의 회사 사업장이 보이면 안 된다.
# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다.
class IPlaceCRUD(ABC):
@abstractmethod
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
pass
@abstractmethod
async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]:
async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]:
pass
@abstractmethod
async def list_places(self, cdb: AsyncSession, company_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]:
async def list_places(self, cdb: AsyncSession, owner_user_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]:
async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]:
async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
@ -73,11 +73,11 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]:
async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]:
try:
query = (
select(places)
.where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712
.where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -91,11 +91,11 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED, None
async def list_places(
self, cdb: AsyncSession, company_id, search: Optional[str], category: Optional[int],
self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int],
status: Optional[int], skip: int, limit: int,
) -> Tuple[ErrorType, list, int]:
try:
conditions = [places.deleted == False, places.company_id == company_id] # noqa: E712
conditions = [places.deleted == False, places.owner_user_id == owner_user_id] # noqa: E712
if category is not None:
conditions.append(places.category == category)
if status is not None:
@ -126,14 +126,14 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]:
async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
"""회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수)."""
try:
if not data:
return ErrorType.SUCCESS, 0
query = (
update(places)
.where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712
.where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
.values(**data, updated_at=GTime.UTC())
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
@ -141,12 +141,12 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]:
async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
"""사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다."""
try:
query = (
delete(places)
.where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712
.where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:

View File

@ -22,7 +22,7 @@ class ISiteCRUD(ABC):
pass
@abstractmethod
async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]:
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
@ -93,13 +93,13 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
try:
where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712
where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where))
if cnt_err != ErrorType.SUCCESS:

View File

@ -1,12 +1,12 @@
from abc import ABC, abstractmethod
from typing import Optional, Tuple
from typing import Tuple
from sqlalchemy import select, func, and_, or_, update
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users, companies
from common.enums import ErrorType, UserRole
from common.database.model.models import users
from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -39,22 +39,6 @@ class IUserCRUD(ABC):
async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType:
pass
@abstractmethod
async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType:
pass
@abstractmethod
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
pass
@abstractmethod
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
pass
@abstractmethod
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit, hide_dev: bool = False) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
pass
@ -145,72 +129,6 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, company)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
try:
query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
try:
query = (
update(companies)
.where(companies.company_id == company_id, companies.deleted == False) # noqa: E712
.values(settings=settings, updated_at=GTime.UTC())
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def list_by_company(
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int, hide_dev: bool = False
) -> Tuple[ErrorType, list, int]:
try:
conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712
# 개발자(내부 운영) 계정은 고객사에 존재 자체가 보이면 안 된다 — 목록에서 빼고 총계에도 넣지 않는다.
if hide_dev:
conditions.append(users.role != UserRole.DEVELOPER.value)
if search:
conditions.append(
or_(
users.id.ilike(f"%{search}%"),
users.name.ilike(f"%{search}%"),
users.email.ilike(f"%{search}%"),
)
)
where = and_(*conditions)
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
list_err, rows = await DB_SESSION_MNG.execute(
cdb,
select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit),
)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
try:
query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712

View File

@ -1,7 +1,5 @@
from typing import Optional
from pydantic import Field
from common.enums import AuthProvider, UserRole
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -17,7 +15,7 @@ class Req_Login(AuthProtocol):
class Req_Signup(AuthProtocol):
"""id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다.
"""id/pw 가입. 가입 = 계정 1개다.
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
@ -27,7 +25,6 @@ class Req_Signup(AuthProtocol):
password: str = ""
name: Optional[str] = None
email: str = ""
company_name: Optional[str] = None # 상호. 비우면 이름 → 아이디 순으로 채운다
class Req_GoogleLogin(AuthProtocol):
@ -46,7 +43,7 @@ class Res_Login(Res_WebPacketProtocol):
class Req_UpdateMe(AuthProtocol):
# 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가).
# 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가).
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
@ -58,11 +55,6 @@ class Res_RefreshToken(Res_WebPacketProtocol):
token_type: str = "bearer"
class CompanyData(WebPacketProtocol):
company_id: str = ""
name: str = ""
class Res_Me(Res_WebPacketProtocol):
user_id: str = ""
id: str = ""
@ -73,4 +65,3 @@ class Res_Me(Res_WebPacketProtocol):
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다)
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
provider: AuthProvider = AuthProvider.LOCAL
company: Optional[CompanyData] = Field(default=None)

View File

@ -31,7 +31,7 @@ from .protocol import (
Res_UnitList,
)
# 사업장 라우터. 모든 조회·변경은 토큰의 회사(company_id)로 스코프된다.
# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다.
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})

View File

@ -18,7 +18,8 @@ class Req_CreatePlace(PlaceProtocol):
# 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다.
name: str = ""
category: PlaceCategory = PlaceCategory.LODGING
owner_user_id: Optional[uuid.UUID] = None
# ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면
# 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
class Req_VerifyPlace(PlaceProtocol):
@ -58,8 +59,8 @@ class Req_VerifyPlaceByUrl(PlaceProtocol):
class Req_UpdatePlace(PlaceProtocol):
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
name: Optional[str] = None
owner_user_id: Optional[uuid.UUID] = None
status: Optional[PlaceStatus] = None
@ -212,7 +213,7 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
class PlaceSearchItem(WebPacketProtocol):
"""공개 검색 결과 1건.
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다."""

View File

@ -85,6 +85,8 @@ class MySiteData(WebPacketProtocol):
domain: Optional[str] = None
template_id: Optional[str] = None
published_at: Optional[datetime] = None
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
thumbnail_url: Optional[str] = None
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
needs_rebuild: bool = False
@ -235,7 +237,7 @@ class ShowcaseItem(WebPacketProtocol):
"""랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다."""
name: str

View File

@ -53,13 +53,11 @@ async def main():
engine = create_async_engine(dsn)
from router.v1.validator.dependencies import GetHashedPW
cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
async with engine.begin() as c:
await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"),
{"c": cid, "n": "데모대행사"})
await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) "
"VALUES (:u,:c,:i,:p,:n,:s,:r,now())"),
{"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
"VALUES (:u,:i,:p,:n,:s,:r,now())"),
{"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
from router.router import app

View File

@ -41,12 +41,11 @@ async def main():
engine = create_async_engine(dsn)
from router.v1.validator.dependencies import GetHashedPW
cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
async with engine.begin() as c:
await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), {"c": cid, "n": "데모대행사"})
await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) "
"VALUES (:u,:c,:i,:p,:n,:s,:r,now())"),
{"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
"VALUES (:u,:i,:p,:n,:s,:r,now())"),
{"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
await engine.dispose()

View File

@ -4,13 +4,12 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import companies, users
from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus
from common.database.model.models import users
from common.enums import AuthProvider, DBWRType, ErrorType, UserRole, UserStatus
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import (
CompanyData,
Req_GoogleLogin,
Req_Signup,
Req_UpdateMe,
@ -71,11 +70,11 @@ class AuthService:
@staticmethod
def _user_info(user: users) -> UserInfo:
# uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다.
# uuid → str (JWT json 직렬화 위해). 기능 라우터는 user_id 로 스코프한다
# — 사업장이 places.owner_user_id 로 이 값에 매여 있다.
return UserInfo(
user_id=str(user.user_id),
id=user.id,
company_id=str(user.company_id),
role=user.role,
)
@ -140,26 +139,17 @@ class AuthService:
password_hash: str | None,
name: str | None,
email: str | None,
company_name: str,
provider: AuthProvider,
provider_uid: str | None,
) -> tuple[ErrorType, users]:
"""회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다.
"""계정 1개를 만든다.
★ 가입은 곧 새 테넌트다. users.company_id 가 NOT NULL 이고 모든 도메인(사업장·사이트)이
company_id 로 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다.
★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서, 그 전에
company.company_id 를 읽으면 None 이다 — 그대로 넣으면 NOT NULL 위반이다."""
company_uuid = uuid.uuid4()
company = companies(
company_id=company_uuid,
name=_fit(company_name, 100),
email=_fit(email, 255),
status=CompanyStatus.ACTIVE.value,
)
★ 예전엔 가입 한 번이 **회사(테넌트) 하나**를 같이 만들었고 모든 도메인이 그 회사로
스코프됐다. 쓰는 사람은 사장님 혼자인데 자기 회사에 소속된 직원이 되는 구조라
걷어냈다(2026-09-08) — 이제 사업장이 `places.owner_user_id` 로 이 계정에 직접 매인다.
★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서 그 전에 읽으면 None 이다."""
user = users(
user_id=uuid.uuid4(),
company_id=company_uuid,
id=login_id,
password=password_hash,
name=_fit(name, 50),
@ -171,10 +161,7 @@ class AuthService:
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[
lambda s: self.user_crud.add_company(s, company),
lambda s: self.user_crud.add_user(s, user),
],
[lambda s: self.user_crud.add_user(s, user)],
)
if err_type != ErrorType.SUCCESS:
return err_type, None
@ -226,7 +213,6 @@ class AuthService:
password_hash=await GetHashedPW(req.password),
name=name,
email=email,
company_name=(req.company_name or "").strip() or name or login_id,
provider=AuthProvider.LOCAL,
provider_uid=None,
)
@ -268,13 +254,12 @@ class AuthService:
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
return res
# 3) 첫 방문 — 계정과 회사를 만든다.
# 3) 첫 방문 — 계정을 만든다.
err_type, user = await self._create_account(
login_id=_google_login_id(account.sub),
password_hash=None,
name=account.name or None,
email=account.email or None,
company_name=account.name or account.email or _google_login_id(account.sub),
provider=AuthProvider.GOOGLE,
provider_uid=account.sub,
)
@ -299,16 +284,6 @@ class AuthService:
return res
user: users
# 2) 소속사 조회 (없어도 치명적 아님)
company = None
c_err, company_row = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_company(s, user.company_id),
)
if c_err == ErrorType.SUCCESS and company_row is not None:
company = CompanyData(company_id=str(company_row.company_id), name=company_row.name)
res.user_id = str(user.user_id)
res.id = user.id
res.name = user.name
@ -316,7 +291,6 @@ class AuthService:
res.contact_number = user.contact_number
res.role = UserRole(user.role)
res.provider = AuthProvider(user.provider)
res.company = company
return res
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:

View File

@ -97,18 +97,18 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
async def run_build(job: dict) -> dict:
"""BUILD 잡 핸들러. payload: {place_id, company_id, publish?, requested_by?}
"""BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?}
publish=True 면 게이트를 통과했을 때 바로 발행까지 한다."""
payload = job["payload"]
place_id = payload["place_id"]
company_id = payload["company_id"]
owner_user_id = payload["owner_user_id"]
want_publish = bool(payload.get("publish"))
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -246,7 +246,7 @@ async def run_build(job: dict) -> dict:
result["azure"] = azure_result
# ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다.
# 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다).
thumbnail_url = await site_thumbnail.store(slug, snapshot)
thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no)
if thumbnail_url:
result["thumbnail_url"] = thumbnail_url
# ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다.
@ -293,7 +293,7 @@ async def run_build(job: dict) -> dict:
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: _place_crud.update_place(
s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
),
)
await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,

View File

@ -431,12 +431,12 @@ async def run_collect(job: dict) -> dict:
"""COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다."""
payload = job["payload"]
place_id = payload["place_id"]
company_id = payload["company_id"]
owner_user_id = payload["owner_user_id"]
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -464,7 +464,7 @@ async def run_collect(job: dict) -> dict:
if not targets:
result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)"
await _finish(place_id, company_id, PlaceStatus.REVIEW)
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
return result
# ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때).
@ -472,16 +472,19 @@ async def run_collect(job: dict) -> dict:
if before["enough"] and not payload.get("force"):
result["coverage"] = before
result["note"] = "이미 필수 항목이 다 차 있다 — 크롤링 생략(force=true 로 강제 가능)"
await _finish(place_id, company_id, PlaceStatus.REVIEW)
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 충분함 place={place_id} {before['covered']}/{before['total']} — 크롤링 생략")
return result
from common.models.gmodel import UserInfo
actor = UserInfo(
user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()),
# ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로
# 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다.
# 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가
# 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다.
user_id=owner_user_id,
id="collector",
company_id=company_id,
role=1,
)
@ -538,24 +541,24 @@ async def run_collect(job: dict) -> dict:
# 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다.
# (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.)
if result["media"]["stored"] > 0:
result["vision_job_id"] = await _enqueue_vision(place_id, company_id)
result["vision_job_id"] = await _enqueue_vision(place_id, owner_user_id)
await _finish(place_id, company_id, PlaceStatus.REVIEW)
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
return result
async def _finish(place_id: str, company_id: str, status: PlaceStatus):
async def _finish(place_id: str, owner_user_id: str, status: PlaceStatus):
"""수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다."""
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: _place_crud.update_place(
s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": status.value}
s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": status.value}
),
)
async def _enqueue_vision(place_id: str, company_id: str) -> str | None:
async def _enqueue_vision(place_id: str, owner_user_id: str) -> str | None:
"""사진 분석 잡을 적재한다. 키가 없거나 중복이면 조용히 건너뛴다(수집 자체는 성공이다)."""
from common.enums import JobType
from crud.job_crud import JobQueue
@ -567,7 +570,7 @@ async def _enqueue_vision(place_id: str, company_id: str) -> str | None:
return None
job_id, _created = await enqueue_job(
JobQueue(), JobType.VISION,
{"place_id": place_id, "company_id": company_id},
{"place_id": place_id, "owner_user_id": owner_user_id},
dedupe_key=f"vision:{place_id}",
)
return job_id

View File

@ -40,10 +40,10 @@ class CopyAborted(RuntimeError):
async def run_copy(job: dict) -> dict:
"""COPY 잡 핸들러. payload: {place_id, company_id, requested_by?}"""
"""COPY 잡 핸들러. payload: {place_id, owner_user_id, requested_by?}"""
payload = job["payload"]
place_id = payload["place_id"]
company_id = payload["company_id"]
owner_user_id = payload["owner_user_id"]
if not gemini_text.is_configured():
raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다")
@ -51,7 +51,7 @@ async def run_copy(job: dict) -> dict:
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -165,9 +165,12 @@ async def run_copy(job: dict) -> dict:
}
actor = UserInfo(
user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()),
# ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로
# 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다.
# 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가
# 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다.
user_id=owner_user_id,
id="generator",
company_id=company_id,
role=1,
)
service = FactService(_fact_crud, _place_crud)

View File

@ -73,7 +73,7 @@ class FactService:
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None

View File

@ -38,14 +38,14 @@ class FaqService:
self.crud = crud
self.place_crud = place_crud
# ---- 사업장 로드(회사 스코프) ----
# ---- 사업장 로드(사장님 스코프) ----
async def _load_place(self, user_info: UserInfo, place_id: str):
"""company_id 를 WHERE 에 걸어 조회한다 — 남의 회사 place_id 를 넣으면 PLACE_NOT_FOUND.
"""owner_user_id 를 WHERE 에 걸어 조회한다 — 남의 place_id 를 넣으면 PLACE_NOT_FOUND.
'없다'와 '권한 없다'를 구분해 주지 않는 것도 의도다(존재 여부를 흘리지 않는다)."""
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None

View File

@ -41,7 +41,7 @@ class MediaService:
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None

View File

@ -70,12 +70,12 @@ class PlaceService:
# ---- 조회 ----
async def list_places(self, user_info: UserInfo, pg: PageParams, search=None, category=None, status=None) -> Res_PlaceList:
res = Res_PlaceList(page=pg.page, size=pg.size)
cid = uuid.UUID(user_info.company_id)
uid = uuid.UUID(user_info.user_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_places(
s, cid, search,
s, uid, search,
category.value if isinstance(category, PlaceCategory) else category,
status.value if isinstance(status, PlaceStatus) else status,
pg.skip, pg.size,
@ -102,7 +102,7 @@ class PlaceService:
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
lambda s: self.crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
@ -122,8 +122,11 @@ class PlaceService:
return res
place = places(
company_id=uuid.UUID(user_info.company_id),
owner_user_id=req.owner_user_id,
# ★ 주인은 **토큰이 정한다.** 예전엔 요청 body 의 owner_user_id 를 그대로 넣었는데,
# 그 값은 아무도 안 보내서 92건 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다.
# 회사를 걷어내면서 이 컬럼이 스코프 키가 됐다 — body 로 남의 계정을 적을 수 있으면
# 만들자마자 남의 목록에 들어간다.
owner_user_id=uuid.UUID(user_info.user_id),
name=req.name.strip(),
category=req.category.value,
status=PlaceStatus.DRAFT.value,
@ -149,7 +152,7 @@ class PlaceService:
if data:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.update_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), data),
lambda s: self.crud.update_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), data),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@ -164,7 +167,7 @@ class PlaceService:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.delete_place(
s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)
s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)
),
)
if err_type != ErrorType.SUCCESS:
@ -251,7 +254,7 @@ class PlaceService:
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.update_place(
s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), {"name": official}
s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), {"name": official}
),
)
if verified.place:
@ -292,7 +295,7 @@ class PlaceService:
res.result.SetResult(err_type)
return res
cid = uuid.UUID(user_info.company_id)
uid = uuid.UUID(user_info.user_id)
now = GTime.UTC()
data = {
"external_source": req.source.value,
@ -320,7 +323,7 @@ class PlaceService:
}
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.update_place(s, cid, uuid.UUID(place_id), data),
lambda s: self.crud.update_place(s, uid, uuid.UUID(place_id), data),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@ -508,7 +511,7 @@ class PlaceService:
payload = {
"place_id": place_id,
"company_id": user_info.company_id,
"owner_user_id": user_info.user_id,
"category": place.category,
# 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재
# 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가
@ -537,7 +540,7 @@ class PlaceService:
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.update_place(
s, uuid.UUID(user_info.company_id), uuid.UUID(place_id),
s, uuid.UUID(user_info.user_id), uuid.UUID(place_id),
{"status": PlaceStatus.COLLECTING.value},
),
)
@ -584,7 +587,7 @@ class PlaceService:
job_id, created = await enqueue_job(
self.queue, JobType.VISION,
{"place_id": place_id, "company_id": user_info.company_id, "force": req.force},
{"place_id": place_id, "owner_user_id": user_info.user_id, "force": req.force},
dedupe_key=f"vision:{place_id}",
)
if job_id is None:
@ -844,7 +847,7 @@ class PlaceService:
job_id, created = await enqueue_job(
self.queue, JobType.COPY,
{"place_id": place_id, "company_id": user_info.company_id, "requested_by": user_info.user_id},
{"place_id": place_id, "owner_user_id": user_info.user_id, "requested_by": user_info.user_id},
dedupe_key=f"copy:{place_id}",
)
if job_id is None:

View File

@ -5,7 +5,7 @@
고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로
긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다.
나가지 않는 것: place_id · company_id · site_id · 전화번호 · 상세 주소 · 좌표.
나가지 않는 것: place_id · 소유자 · site_id · 전화번호 · 상세 주소 · 좌표.
"""
from common.database.db_session_manager import DB_SESSION_MNG

View File

@ -104,7 +104,7 @@ class SiteService:
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
@ -420,16 +420,16 @@ class SiteService:
return cleaned
async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites:
"""로그인한 계정(회사)이 가진 사이트 전부.
"""로그인한 사장님이 가진 사이트 전부.
사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라
'발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다."""
res = Res_MySites(page=pg.page, size=pg.size)
cid = uuid.UUID(user_info.company_id)
uid = uuid.UUID(user_info.user_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size),
lambda s: self.crud.list_owner_sites(s, uid, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@ -454,6 +454,7 @@ class SiteService:
domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", None),
thumbnail_url=getattr(site, "thumbnail_url", None),
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
)
@ -526,7 +527,7 @@ class SiteService:
job_id, created = await enqueue_job(
self.queue, JobType.BUILD,
{
"place_id": place_id, "company_id": user_info.company_id,
"place_id": place_id, "owner_user_id": user_info.user_id,
"publish": req.publish, "requested_by": user_info.user_id,
},
dedupe_key=f"build:{place_id}",

View File

@ -18,7 +18,7 @@ import asyncio
import os
import httpx
from azure.storage.blob import BlobServiceClient, ContentSettings
from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings
from common.logger import LOG
from services import azure_static, site_payload
@ -46,19 +46,71 @@ MAX_BYTES = 5 * 1024 * 1024
CACHE_CONTROL = "public, max-age=60, must-revalidate"
# ── 썸네일 전용 저장소 ────────────────────────────────────────────────────────
# ★ 왜 스위치를 따로 두나
# 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을
# **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고
# 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간
# **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은
# negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다.
#
# ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`.
# 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) —
# 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다.
#
# ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가
# 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 —
# web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다.
_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너>
_SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다)
_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai
def _blob_base() -> str:
return os.environ.get(_BASE_ENV, "").strip().rstrip("/")
def _blob_sas() -> str:
return os.environ.get(_SAS_ENV, "").strip().lstrip("?")
def _blob_root() -> str:
return os.environ.get(_ROOT_ENV, "").strip().strip("/")
def uses_blob_store() -> bool:
"""썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다."""
return bool(_blob_base() and _blob_sas())
def is_configured() -> bool:
return azure_static.is_configured()
return uses_blob_store() or azure_static.is_configured()
def blob_name(slug: str, ext: str) -> str:
if uses_blob_store():
# base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다.
return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part)
prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/")
return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part)
def public_url(slug: str, ext: str) -> str:
def public_url(slug: str, ext: str, version: int | None = None) -> str:
"""공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다
(CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다')."""
return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
(CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').
★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로
(overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.**
아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이
아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다.
★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다.
★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로
(scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다."""
# ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면
# 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다.
base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \
else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
return f"{base}?v={version}" if version is not None else base
async def _fetch(url: str) -> tuple[bytes, str, str] | None:
@ -109,6 +161,18 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None:
def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
name = blob_name(slug, ext)
settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL)
if uses_blob_store():
# ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다.
# 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다:
# 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다).
BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob(
data, overwrite=True, content_settings=settings
)
return name
connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip()
container_name = (
os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip()
@ -116,18 +180,12 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
)
service = BlobServiceClient.from_connection_string(connection_string)
container = service.get_container_client(container_name)
name = blob_name(slug, ext)
container.upload_blob(
name=name,
data=data,
overwrite=True,
# cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL),
)
# cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings)
return name
async def store(slug: str, snapshot: dict) -> str | None:
async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None:
"""대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다).
SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로,
@ -153,4 +211,4 @@ async def store(slug: str, snapshot: dict) -> str | None:
return None
LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})")
return public_url(slug, ext)
return public_url(slug, ext, version)

View File

@ -28,10 +28,10 @@ class VisionAborted(RuntimeError):
async def run_vision(job: dict) -> dict:
"""VISION 잡 핸들러. payload: {place_id, company_id, force?}"""
"""VISION 잡 핸들러. payload: {place_id, owner_user_id, force?}"""
payload = job["payload"]
place_id = payload["place_id"]
company_id = payload["company_id"]
owner_user_id = payload["owner_user_id"]
force = bool(payload.get("force"))
if not gemini.is_configured():
@ -40,7 +40,7 @@ async def run_vision(job: dict) -> dict:
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}")

View File

@ -4,9 +4,9 @@
"""
async def test_login_and_me_flow(auth_headers, client, company_id):
async def test_login_and_me_flow(auth_headers, client):
"""검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출.
기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환."""
기대결과: 200, 본인 id·name 이 그대로 반환."""
h = await auth_headers("user1", name="홍길동")
r = await client.get("/v1/auth/me", headers=h)
@ -14,7 +14,8 @@ async def test_login_and_me_flow(auth_headers, client, company_id):
me = r.json()
assert me["id"] == "user1"
assert me["name"] == "홍길동"
assert me["company"]["company_id"] == company_id
# ★ 소속사 필드는 없다. 회사(테넌트)를 걷어냈다(2026-09-08) — 쓰는 사람은 사장님 혼자다.
assert "company" not in me
async def test_login_with_wrong_password(auth_headers, client):
@ -49,7 +50,7 @@ _SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "emai
async def test_signup_creates_account_and_logs_in(client, db_engine):
"""검증: 가입 → 받은 토큰으로 곧바로 /me.
기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원과 **새로 생긴 소속사**를 돌려준다."""
기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원을 돌려준다."""
r = await client.post("/v1/auth/signup", json=_SIGNUP)
body = r.json()
assert body["result"]["success"] is True
@ -59,7 +60,6 @@ async def test_signup_creates_account_and_logs_in(client, db_engine):
assert me["id"] == "sajang1"
assert me["email"] == "boss@example.com"
assert me["provider"] == 1 # AuthProvider.LOCAL
assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다
async def test_signup_rejects_duplicate_id(client, db_engine):

View File

@ -264,12 +264,12 @@ async def test_versions_accumulate(auth_headers, client, db_engine):
assert [v["version"] for v in versions] == [2, 1]
async def test_site_is_scoped_to_company(auth_headers, client, other_company_id):
"""검증: 다른 회사 계정으로 남의 사이트를 본다.
async def test_site_is_scoped_to_owner(auth_headers, client):
"""검증: 다른 사장님 계정으로 남의 사이트를 본다.
기대결과: PLACE_NOT_FOUND."""
h1 = await auth_headers("o1")
pid = await _place(client, h1, "스코프펜션")
h2 = await auth_headers("o2", other_company_id)
h2 = await auth_headers("o2")
r = await client.get(f"/v1/place/{pid}/site", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -156,13 +156,13 @@ async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_he
assert job["payload"]["link_ids"] == [confirmed]
async def test_collect_is_scoped_to_company(auth_headers, client, other_company_id):
"""검증: 다른 회사 계정으로 남의 사업장 수집을 시작한다.
async def test_collect_is_scoped_to_owner(auth_headers, client):
"""검증: 다른 사장님 계정으로 남의 사업장 수집을 시작한다.
기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 수집도 못 건다."""
h1 = await auth_headers("o1")
pid = await _place(client, h1, kakao="c7")
await _confirmed_link(client, h1, pid)
h2 = await auth_headers("o2", other_company_id)
h2 = await auth_headers("o2")
r = await client.post(f"/v1/place/{pid}/collect", headers=h2, json={})
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -208,7 +208,7 @@ async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client):
assert check_in[0]["status"] == FactStatus.CORRECTED.value
async def test_pipeline_refuses_unverified_place(db_engine, company_id):
async def test_pipeline_refuses_unverified_place(db_engine, owner_id):
"""검증: 검증 안 된 사업장의 수집 잡이 큐에 직접 들어간 경우(잡 적재 후 검증이 취소된 상황).
기대결과: 잡이 실패한다 — ★ 잡 실행 시점에도 게이트를 다시 확인한다."""
from sqlalchemy import text
@ -216,13 +216,13 @@ async def test_pipeline_refuses_unverified_place(db_engine, company_id):
pid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO places (place_id, company_id, name, category, status) "
text("INSERT INTO places (place_id, owner_user_id, name, category, status) "
"VALUES (:pid, :cid, :n, 1, 1)"),
{"pid": pid, "cid": uuid.UUID(company_id), "n": "미검증펜션"},
{"pid": pid, "cid": uuid.UUID(owner_id), "n": "미검증펜션"},
)
q = JobQueue()
job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "company_id": company_id}, max_attempts=1)
job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "owner_user_id": owner_id}, max_attempts=1)
worker = Worker("test-worker", q, build_handler(), backoff_fn=lambda _a: 0)
await worker.process_one()

View File

@ -269,12 +269,12 @@ async def test_crawl_only_does_not_mark_rebuild(auth_headers, client):
assert place.get("content_updated_at") is None
async def test_facts_are_scoped_to_company(auth_headers, client, other_company_id):
"""검증: 다른 회사 계정으로 남의 사업장 fact 를 조회한다.
async def test_facts_are_scoped_to_owner(auth_headers, client):
"""검증: 다른 사장님 계정으로 남의 사업장 fact 를 조회한다.
기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 fact 도 안 보인다."""
h1 = await auth_headers("o1")
pid = await _verified_place(client, h1, kakao="p6")
h2 = await auth_headers("o2", other_company_id)
h2 = await auth_headers("o2")
r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -22,17 +22,17 @@ from common.enums import (
)
async def _seed_place(db_engine, company_id) -> str:
async def _seed_place(db_engine, owner_id) -> str:
"""검증까지 끝난 사업장 1개를 시드하고 place_id 를 돌려준다."""
pid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO places (place_id, company_id, name, category, status, external_place_id, verified_at) "
"INSERT INTO places (place_id, owner_user_id, name, category, status, external_place_id, verified_at) "
"VALUES (:pid, :cid, :name, :cat, :status, :kakao, now())"
),
{
"pid": pid, "cid": uuid.UUID(company_id), "name": "테스트펜션",
"pid": pid, "cid": uuid.UUID(owner_id), "name": "테스트펜션",
"cat": PlaceCategory.LODGING.value, "status": PlaceStatus.DRAFT.value,
"kakao": "12345678",
},
@ -54,20 +54,20 @@ async def _insert_fact(db_engine, place_id, key, value, status, unit_id=None):
)
async def test_published_fact_is_unique_per_place_and_key(db_engine, company_id):
async def test_published_fact_is_unique_per_place_and_key(db_engine, owner_id):
"""검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다.
기대결과: 두 번째 INSERT 가 유니크 인덱스에 막힌다(체크인 시간이 두 값으로 갈라지지 않는다)."""
place_id = await _seed_place(db_engine, company_id)
place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED)
with pytest.raises(IntegrityError):
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.CORRECTED)
async def test_candidates_coexist_with_published_value(db_engine, company_id):
async def test_candidates_coexist_with_published_value(db_engine, owner_id):
"""검증: 노출값이 있는 상태에서 재수집 후보를 여러 건 넣는다.
기대결과: 전부 공존한다 — ★ 재수집이 노출 중인 사실을 밀어내지 않는다."""
place_id = await _seed_place(db_engine, company_id)
place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED)
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.PENDING_OWNER)
await _insert_fact(db_engine, place_id, "check_in_time", "14:00", FactStatus.UNVERIFIED)
@ -80,10 +80,10 @@ async def test_candidates_coexist_with_published_value(db_engine, company_id):
assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다"
async def test_rejected_fact_frees_the_key(db_engine, company_id):
async def test_rejected_fact_frees_the_key(db_engine, owner_id):
"""검증: 기존 값을 REJECTED 로 내린 뒤 같은 key 를 새로 노출한다.
기대결과: 통과 — 틀린 값은 이력으로 남고, 새 값이 노출 자리를 차지한다."""
place_id = await _seed_place(db_engine, company_id)
place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.REJECTED)
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.VERIFIED)
@ -95,18 +95,18 @@ async def test_rejected_fact_frees_the_key(db_engine, company_id):
assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다"
async def test_expired_fact_frees_the_key(db_engine, company_id):
async def test_expired_fact_frees_the_key(db_engine, owner_id):
"""검증: 유효기간이 지나 EXPIRED 로 내린 값과 새 수집값의 공존.
기대결과: 통과 — EXPIRED 도 유니크에서 빠진다."""
place_id = await _seed_place(db_engine, company_id)
place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "cancel_policy", "구 규정", FactStatus.EXPIRED)
await _insert_fact(db_engine, place_id, "cancel_policy", "새 규정", FactStatus.VERIFIED)
async def test_same_key_allowed_across_units(db_engine, company_id):
async def test_same_key_allowed_across_units(db_engine, owner_id):
"""검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지.
기대결과: 통과 — A동·B동이 각자의 기준 인원을 갖는다."""
place_id = await _seed_place(db_engine, company_id)
place_id = await _seed_place(db_engine, owner_id)
unit_a, unit_b = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as conn:
for uid, name in ((unit_a, "A동"), (unit_b, "B동")):
@ -122,10 +122,10 @@ async def test_same_key_allowed_across_units(db_engine, company_id):
await _insert_fact(db_engine, place_id, "standard_capacity", "6", FactStatus.CORRECTED, unit_id=unit_a)
async def test_unit_fact_and_place_fact_are_separate(db_engine, company_id):
async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id):
"""검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우.
기대결과: 통과 — 부분 인덱스가 unit_id NULL 여부로 갈라져 있다."""
place_id = await _seed_place(db_engine, company_id)
place_id = await _seed_place(db_engine, owner_id)
unit_id = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(

View File

@ -56,14 +56,14 @@ async def test_generated_faq_is_pending_and_not_publishable(auth_headers, client
assert (await _list(client, h, pid, publishable_only=True)).get("faqs", []) == []
async def test_other_company_cannot_read_or_touch_faq(auth_headers, client, db_engine, other_company_id):
"""검증: 남의 회사 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine):
"""검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다."""
h = await auth_headers("u1")
pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid)
other = await auth_headers("u2", other_company_id)
other = await auth_headers("u2")
assert (await _list(client, other, pid))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value})
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -3,7 +3,7 @@
이 경로가 절대 하면 안 되는 것:
- 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면
사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다).
- 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
- 사장님 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
- 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른
답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다.
"""
@ -53,11 +53,47 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client):
assert row["status"] == SiteStatus.DRAFT.value
async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id):
"""검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다.
async def test_row_carries_what_the_card_draws(auth_headers, client, db_engine):
"""검증: 목록 줄이 카드가 그릴 값을 다 들고 온다 — 주소·생성일·썸네일.
★ 왜 이걸 본다: 같은 상호로 만든 사업장이 여러 줄일 때(실측: 한 계정에 '버터브루' 4줄)
이름과 상태 배지만으로는 어느 게 어느 건지 가릴 수 없다. 가르는 값은 주소와 시각이고,
**한 번이라도 발행한 줄은 그림**이다."""
h = await auth_headers("my2b")
pid = await _place(client, h, "카드펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE places SET road_address = :addr WHERE place_id = :pid"),
{"addr": "강원특별자치도 양양군 현북면 하조대3길 12", "pid": uuid.UUID(pid)},
)
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status, thumbnail_url) "
"VALUES (:s, :p, :d, :st, :t)"),
{"s": uuid.uuid4(), "p": uuid.UUID(pid), "d": "card-stay",
"st": SiteStatus.PUBLISHED.value, "t": "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"},
)
row = (await _list(client, h))["sites"][0]
assert row["road_address"] == "강원특별자치도 양양군 현북면 하조대3길 12"
assert row["created_at"]
assert row["thumbnail_url"] == "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"
async def test_row_without_a_site_has_no_thumbnail(auth_headers, client):
"""검증: 아직 발행 안 한 줄은 그림이 없다(키 자체가 없다).
기대결과: 화면이 '그림 없음' 자리를 그릴 근거가 된다 — 빈 문자열로 오면 깨진 이미지가 뜬다."""
h = await auth_headers("my2c")
await _place(client, h, "그림없는펜션")
row = (await _list(client, h))["sites"][0]
assert "thumbnail_url" not in row
async def test_other_owners_sites_are_not_listed(auth_headers, client):
"""검증: 사장님 스코프. 남의 사업장은 보이지 않는다.
기대결과: 각자 자기 것만 1건."""
mine = await auth_headers("my3")
theirs = await auth_headers("my3b", other_company_id)
theirs = await auth_headers("my3b")
await _place(client, mine, "내펜션")
await _place(client, theirs, "남의펜션")

View File

@ -1,8 +1,8 @@
"""places 도메인 e2e — 등록 / 동일 업소 검증 / 회사 스코프 / 채널 URL 확정 게이트.
"""places 도메인 e2e — 등록 / 동일 업소 검증 / 사장님 스코프 / 채널 URL 확정 게이트.
★ 이 도메인의 핵심 규칙 두 개를 고정한다:
1. 검증(verify) 전에는 채널 URL 을 확정할 수 없다 → 크롤링이 안 열린다
2. 남의 회사 사업장은 '없음'으로 보인다
2. 남의 사업장은 '없음'으로 보인다
"""
import uuid
@ -61,7 +61,7 @@ async def test_verify_place_opens_collection(auth_headers, client):
async def test_duplicate_kakao_place_is_allowed(auth_headers, client):
"""검증: 같은 회사에서 같은 카카오 장소를 두 사업장에 붙인다.
"""검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다.
기대결과: 둘 다 등록된다 — 한 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 수 있다."""
h = await auth_headers("u1")
first = (await _create_place(client, h, "A펜션"))["place"]["place_id"]
@ -138,13 +138,13 @@ async def test_verify_without_any_identifier_is_rejected(auth_headers, client):
assert r.json()["result"]["code"] == ErrorType.PLACE_VERIFY_NO_CANDIDATE.value
async def test_place_is_scoped_to_company(auth_headers, client, other_company_id):
"""검증: 다른 회사 계정으로 남의 사업장을 조회한다.
async def test_place_is_scoped_to_owner(auth_headers, client):
"""검증: 다른 사장님 계정으로 남의 사업장을 조회한다.
기대결과: PLACE_NOT_FOUND — 존재 자체가 보이지 않는다(IDOR 차단)."""
h1 = await auth_headers("owner1")
pid = (await _create_place(client, h1))["place"]["place_id"]
h2 = await auth_headers("owner2", other_company_id)
h2 = await auth_headers("owner2")
r = await client.get(f"/v1/place/{pid}", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -80,7 +80,7 @@ async def test_search_leaks_nothing_of_ours(client, monkeypatch):
item = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["items"][0]
for leaked in ("place_id", "company_id", "owner_user_id", "phone", "latitude", "longitude",
for leaked in ("place_id", "owner_user_id", "phone", "latitude", "longitude",
"external_place_id", "address"):
assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다"

View File

@ -31,7 +31,7 @@ def test_empty_site_returns_actionable_failures():
assert any(c["status"] == "fail" and c["recommendation"] for c in report["checks"])
async def test_audit_api_is_company_scoped(auth_headers, client, other_company_id):
async def test_audit_api_is_owner_scoped(auth_headers, client):
h1 = await auth_headers("audit-owner")
pid = (await client.post("/v1/place", headers=h1, json={"name": "진단가게", "category": 2})).json()["place"]["place_id"]
@ -41,6 +41,6 @@ async def test_audit_api_is_company_scoped(auth_headers, client, other_company_i
assert 0 <= own["aeo_score"] <= 100
assert own["checks"]
h2 = await auth_headers("audit-other", other_company_id)
h2 = await auth_headers("audit-other")
denied = (await client.get(f"/v1/place/{pid}/site/audit", headers=h2)).json()
assert denied["result"]["success"] is False

View File

@ -13,14 +13,14 @@ from sqlalchemy import text
from common.enums import PlaceCategory, PlaceStatus, SiteStatus
async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, minutes_ago=0):
async def _publish(db_engine, owner_id, name, *, status, domain, thumb=None, minutes_ago=0):
"""places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다."""
pid, sid = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO places (place_id, company_id, name, category, status, road_address, address, phone) "
text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, address, phone) "
"VALUES (:p,:c,:n,:cat,:st,:road,:addr,:phone)"),
{"p": pid, "c": uuid.UUID(company_id), "n": name, "cat": PlaceCategory.LODGING.value,
{"p": pid, "c": uuid.UUID(owner_id), "n": name, "cat": PlaceCategory.LODGING.value,
"st": PlaceStatus.PUBLISHED.value,
"road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2",
"phone": "033-672-0000"},
@ -34,12 +34,12 @@ async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, m
return str(pid)
async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id):
async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, owner_id):
"""검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다.
기대결과: 발행본만 나온다."""
await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value,
await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value,
domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg")
await _publish(db_engine, company_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet")
await _publish(db_engine, owner_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet")
res = await client.get("/v1/showcase")
@ -51,10 +51,10 @@ async def test_발행된_사이트만_로그인_없이_보인다(client, db_engi
assert items[0]["category"] == PlaceCategory.LODGING.value
async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, company_id):
async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, owner_id):
"""검증: 응답 항목의 키를 그대로 본다.
기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다."""
await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae")
await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae")
item = (await client.get("/v1/showcase")).json()["items"][0]
@ -66,10 +66,10 @@ async def test_개인정보와_내부값은_나가지_않는다(client, db_engin
assert "하조대3길" not in body
async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id):
async def test_썸네일이_없으면_키가_없다(client, db_engine, owner_id):
"""★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나).
그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다."""
await _publish(db_engine, company_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic")
await _publish(db_engine, owner_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic")
item = (await client.get("/v1/showcase")).json()["items"][0]
@ -77,9 +77,9 @@ async def test_썸네일이_없으면_키가_없다(client, db_engine, company_i
assert "thumbnail_url" not in item
async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id):
await _publish(db_engine, company_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60)
await _publish(db_engine, company_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1)
async def test_최신_발행순이고_limit_로_자른다(client, db_engine, owner_id):
await _publish(db_engine, owner_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60)
await _publish(db_engine, owner_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1)
items = (await client.get("/v1/showcase")).json()["items"]
assert [i["name"] for i in items] == ["나중", "먼저"]

View File

@ -129,11 +129,11 @@ async def test_published_site_slug_is_locked(auth_headers, client, db_engine):
assert same["site"]["domain"] == "published-stay"
async def test_other_company_place_is_blocked(auth_headers, client, other_company_id):
"""검증: 남의 회사 사업장 주소는 확인도 예약도 못 한다.
async def test_other_owners_place_is_blocked(auth_headers, client):
"""검증: 남의 사업장 주소는 확인도 예약도 못 한다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("slug5")
intruder = await auth_headers("slug6", other_company_id)
intruder = await auth_headers("slug6")
pid = await _place(client, h)
assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -99,11 +99,11 @@ async def test_published_site_template_is_not_locked(auth_headers, client, db_en
assert changed["needs_rebuild"] is True
async def test_other_company_place_is_blocked(auth_headers, client, other_company_id):
"""검증: 남의 회사 사업장의 템플릿은 바꿀 수 없다.
async def test_other_owners_place_is_blocked(auth_headers, client):
"""검증: 남의 사업장의 템플릿은 바꿀 수 없다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("tpl6")
intruder = await auth_headers("tpl7", other_company_id)
intruder = await auth_headers("tpl7")
pid = await _place(client, h)
blocked = await _set_template(client, intruder, pid, "stay-quiet-margin")

View File

@ -129,11 +129,11 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin
assert changed["needs_rebuild"] is True
async def test_other_company_place_is_blocked(auth_headers, client, other_company_id):
"""검증: 남의 회사 사업장의 디자인은 바꿀 수 없다.
async def test_other_owners_place_is_blocked(auth_headers, client):
"""검증: 남의 사업장의 디자인은 바꿀 수 없다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("thm7")
intruder = await auth_headers("thm8", other_company_id)
intruder = await auth_headers("thm8")
pid = await _place(client, h)
assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -110,9 +110,11 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa
_transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes"))
snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None})
url = await site_thumbnail.store("butter", snapshot)
url = await site_thumbnail.store("butter", snapshot, 3)
assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg"
# ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면
# 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url).
assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg?v=3"
name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"
assert set(blob.uploads) == {name}
assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/")
@ -131,8 +133,8 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head
from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts
async def _store(slug, snapshot):
return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg"
async def _store(slug, snapshot, version=None):
return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg?v={version}"
monkeypatch.setattr(build_service.site_thumbnail, "store", _store)
@ -146,6 +148,15 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head
site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"]
assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/")
assert site["thumbnail_url"].endswith("?v=1")
# ★ 재발행하면 주소가 바뀌어야 한다. 블롭 이름은 그대로 덮어쓰므로, 주소가 그대로면
# 사장님은 사진을 바꾸고 다시 발행해도 캐시에 남은 **지난 그림**을 계속 본다.
await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})
await _run()
again = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"]
assert again["thumbnail_url"].endswith("?v=2")
assert again["thumbnail_url"] != site["thumbnail_url"]
async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch):
@ -154,7 +165,7 @@ async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers
from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts
async def _store(slug, snapshot):
async def _store(slug, snapshot, version=None):
return None
monkeypatch.setattr(build_service.site_thumbnail, "store", _store)

View File

@ -10,13 +10,13 @@ from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType
from services.snapshot import build_snapshot
async def _seed(db_engine, company_id, category=PlaceCategory.LODGING):
async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING):
pid = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO places (place_id, company_id, name, category, status, road_address, phone, verified_at) "
text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, phone, verified_at) "
"VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"),
{"p": pid, "c": uuid.UUID(company_id), "n": "스냅샷펜션", "cat": category.value,
{"p": pid, "c": uuid.UUID(owner_id), "n": "스냅샷펜션", "cat": category.value,
"addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"},
)
return pid
@ -53,10 +53,10 @@ class _Place:
self.longitude = None
async def test_only_publishable_facts_enter_snapshot(db_engine, company_id):
async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id):
"""검증: 여러 상태의 fact 를 섞어 넣는다.
기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다."""
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED)
await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED)
@ -69,10 +69,10 @@ async def test_only_publishable_facts_enter_snapshot(db_engine, company_id):
assert keys == {"check_in_time", "wifi"}
async def test_only_approved_media_enters_snapshot(db_engine, company_id):
async def test_only_approved_media_enters_snapshot(db_engine, owner_id):
"""검증: 승인/확인대기/반려 사진을 섞어 넣는다.
기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다."""
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED)
await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW)
await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED)
@ -81,10 +81,10 @@ async def test_only_approved_media_enters_snapshot(db_engine, company_id):
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"]
async def test_media_without_alt_is_excluded(db_engine, company_id):
async def test_media_without_alt_is_excluded(db_engine, owner_id):
"""검증: 승인됐지만 alt 텍스트가 없는 사진.
기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다."""
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="")
await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진")
@ -92,10 +92,10 @@ async def test_media_without_alt_is_excluded(db_engine, company_id):
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"]
async def test_fact_labels_come_from_category_schema(db_engine, company_id):
async def test_fact_labels_come_from_category_schema(db_engine, owner_id):
"""검증: 스냅샷의 fact 라벨.
기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게."""
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
snap = await build_snapshot(_Place(pid))
@ -104,10 +104,10 @@ async def test_fact_labels_come_from_category_schema(db_engine, company_id):
assert f["scope"] == "place"
async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id):
async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id):
"""검증: 객실 단위 fact.
기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다."""
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
uid = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"),
@ -120,10 +120,10 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id):
assert snap["facts"][0]["scope"] == "unit"
async def test_empty_place_gives_empty_snapshot(db_engine, company_id):
async def test_empty_place_gives_empty_snapshot(db_engine, owner_id):
"""검증: 아무것도 없는 사업장.
기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다."""
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
snap = await build_snapshot(_Place(pid))
assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == []
assert snap["place"]["name"] == "스냅샷펜션"
@ -156,13 +156,13 @@ class _RegionPlace(_Place):
self.region_code = region_code
async def test_only_published_local_content_enters_snapshot(db_engine, company_id):
async def test_only_published_local_content_enters_snapshot(db_engine, owner_id):
"""검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.
기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
'미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙)."""
from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제")
@ -171,7 +171,7 @@ async def test_only_published_local_content_enters_snapshot(db_engine, company_i
assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"]
async def test_local_content_outside_display_window_is_excluded(db_engine, company_id):
async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id):
"""검증: 발행됐지만 노출 기간을 벗어난 지역 정보.
기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다."""
from datetime import datetime, timedelta, timezone
@ -179,7 +179,7 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa
from common.enums import LocalContentStatus, LocalContentType
now = datetime.now(timezone.utc)
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제",
display_end_at=now - timedelta(days=1))
@ -190,12 +190,12 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa
assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"]
async def test_local_content_is_scoped_to_the_places_region(db_engine, company_id):
async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id):
"""검증: 지역 캐시는 region_code 로 묶인다.
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제")
await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제")
@ -203,7 +203,7 @@ async def test_local_content_is_scoped_to_the_places_region(db_engine, company_i
assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"]
async def test_region_code_is_derived_from_the_address_when_missing(db_engine, company_id):
async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id):
"""검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.
기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는
코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다."""
@ -211,7 +211,7 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c
from services.external.naver import region_key
derived = region_key(_Place("x").road_address)
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제")
snap = await build_snapshot(_RegionPlace(pid, ""))
@ -219,12 +219,12 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c
assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"]
async def test_place_without_any_region_key_gets_no_local_content(db_engine, company_id):
async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id):
"""검증: 지역 코드도 읽을 만한 주소도 없는 사업장.
기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다."""
from common.enums import LocalContentStatus, LocalContentType
pid = await _seed(db_engine, company_id)
pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제")
place = _RegionPlace(pid, "")

View File

@ -136,13 +136,13 @@ async def test_candidates_require_configured_source(auth_headers, client, monkey
assert body["result"]["code"] == ErrorType.LOCAL_NOT_CONFIGURED.value
async def test_candidates_scoped_to_company(auth_headers, client, other_company_id, monkeypatch):
"""검증: 다른 회사 계정으로 남의 사업장 후보를 조회한다.
async def test_candidates_scoped_to_owner(auth_headers, client, monkeypatch):
"""검증: 다른 사장님 계정으로 남의 사업장 후보를 조회한다.
기대결과: PLACE_NOT_FOUND."""
_patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x"))
h1 = await auth_headers("o1")
pid = await _place(client, h1)
h2 = await auth_headers("o2", other_company_id)
h2 = await auth_headers("o2")
body = (await client.get(f"/v1/place/{pid}/verify/candidates", headers=h2)).json()
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -1,11 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export interface CompanyData {
company_id?: string;
name?: string;
}

View File

@ -10,7 +10,6 @@ export * from './auditCheckDataRecommendation';
export * from './authProvider';
export * from './buildStatus';
export * from './checkSlugParams';
export * from './companyData';
export * from './errorInfo';
export * from './errorInfoCode';
export * from './errorInfoDesc';
@ -83,6 +82,7 @@ export * from './mySiteData';
export * from './mySiteDataCreatedAt';
export * from './mySiteDataDomain';
export * from './mySiteDataPublishedAt';
export * from './mySiteDataThumbnailUrl';
export * from './mySiteDataRoadAddress';
export * from './mySiteDataSiteId';
export * from './mySiteDataStatus';
@ -139,7 +139,6 @@ export * from './reqGoogleLogin';
export * from './reqLogin';
export * from './reqPublishLocalContent';
export * from './reqSignup';
export * from './reqSignupCompanyName';
export * from './reqSignupName';
export * from './reqSiteSlug';
export * from './reqSiteStatus';
@ -222,7 +221,6 @@ export * from './resLocalContentListMsg';
export * from './resLogin';
export * from './resLoginMsg';
export * from './resMe';
export * from './resMeCompany';
export * from './resMeContactNumber';
export * from './resMeEmail';
export * from './resMeMsg';

View File

@ -13,6 +13,7 @@ import type { MySiteDataStatus } from './mySiteDataStatus';
import type { MySiteDataDomain } from './mySiteDataDomain';
import type { MySiteDataTemplateId } from './mySiteDataTemplateId';
import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt';
import type { MySiteDataThumbnailUrl } from './mySiteDataThumbnailUrl';
/**
* 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
@ -32,5 +33,7 @@ export interface MySiteData {
domain?: MySiteDataDomain;
template_id?: MySiteDataTemplateId;
published_at?: MySiteDataPublishedAt;
/** 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). */
thumbnail_url?: MySiteDataThumbnailUrl;
needs_rebuild?: boolean;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ReqSignupCompanyName = string | null;
export type MySiteDataThumbnailUrl = string | null;

View File

@ -11,7 +11,7 @@ import type { PlaceSearchItemCategory } from './placeSearchItemCategory';
/**
* 공개 검색 결과 1건.
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.

View File

@ -5,10 +5,9 @@
* OpenAPI spec version: 0.1.0
*/
import type { ReqSignupName } from './reqSignupName';
import type { ReqSignupCompanyName } from './reqSignupCompanyName';
/**
* id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다.
* id/pw 가입. 가입 = 계정 1개다.
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
@ -19,5 +18,4 @@ export interface ReqSignup {
password?: string;
name?: ReqSignupName;
email?: string;
company_name?: ReqSignupCompanyName;
}

View File

@ -11,7 +11,6 @@ import type { ResMeEmail } from './resMeEmail';
import type { ResMeContactNumber } from './resMeContactNumber';
import type { UserRole } from './userRole';
import type { AuthProvider } from './authProvider';
import type { ResMeCompany } from './resMeCompany';
export interface ResMe {
result?: ErrorInfo;
@ -23,5 +22,4 @@ export interface ResMe {
contact_number?: ResMeContactNumber;
role?: UserRole;
provider?: AuthProvider;
company?: ResMeCompany;
}

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { CompanyData } from './companyData';
export type ResMeCompany = CompanyData | null;

View File

@ -12,7 +12,7 @@ import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl';
* 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.
*/
export interface ShowcaseItem {

View File

@ -82,7 +82,6 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
{userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</Link>
) : (
<div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">

View File

@ -320,8 +320,20 @@ export function Step3DataReview() {
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
주소 붙여넣기와 자동 찾기가 모두 열립니다.
</p>
{/* ★ 이 길로 나가면 **발행까지 못 간다** — 서버에 사업장이 없어서 구울 대상이
없고, 발행 모달은 [내 가게 확인하러 가기] 만 내놓는다(PublishModal
PlaceFirstPanel). 예전에는 아무 말 없이 통과시켜서, 사장님은 사이트를 다
만든 뒤 발행을 누르는 자리에서야 그 사실을 알았다.
버튼은 남긴다 — 검증을 못 통과한 분이 화면을 구경할 길까지 막지는 않는다. */}
<p className="rounded-xl border border-border bg-muted/40 p-3 text-[11px] leading-relaxed text-muted-foreground">
<strong className="text-foreground">
이대로 진행하면 화면은 만들 수 있지만 발행은 되지 않습니다.
</strong>{' '}
발행본은 확인된 가게 한 곳에 묶여서, 가게 확인 전에는 만들어 둘 페이지가
없습니다. 둘러보신 뒤 언제든 가게를 확인하시면 그때 발행이 열립니다.
</p>
<Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
<span>수집 없이 다음 단계로</span>
<span>발행 없이 화면만 둘러보기</span>
<ArrowRight />
</Button>
</>

View File

@ -1,21 +1,29 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {useNavigate} from 'react-router';
import {
AlertTriangle,
ArrowUpRight,
Check,
Copy,
ExternalLink,
Eye,
Loader2,
RefreshCw,
Search,
ServerCrash,
ShieldAlert,
Store,
} from 'lucide-react';
import {publishUrlString, toSlug} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Dialog} from '@/components/ui/dialog';
import {SignInForm} from '@/features/auth/SignInForm';
import type {WizardStep} from '@/features/onboarding/wizardUrl';
import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder';
import {runPublishGate, type GateFinding} from './publishGate';
import {checkSiteSlug, reserveSiteSlug} from './siteSlug';
@ -34,6 +42,27 @@ import {
// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
/**
* 가게 확인이 안 끝난 사장님을 돌려보낼 곳.
*
* ★ 단계 이름은 wizardUrl 이 소유한다 — 문자열을 그냥 박아 두면 이름이 바뀌는 날
* 주소창의 step 을 아무도 못 알아보고 조용히 첫 화면이 열린다(defaultStep).
*/
const PLACE_SEARCH_STEP: WizardStep = 'search';
const PLACE_SEARCH_URL = `/builder?step=${PLACE_SEARCH_STEP}`;
/**
* 발행을 **시작조차 할 수 없는** 상태. 서버를 부를 수 없으므로 발행 버튼을 그리지 않는다.
*
* ★ 예전에는 이 두 경우가 [지금 발행하기] 로 그대로 흘러 들어와, 서버를 한 번도 부르지 않고
* 성공 토스트를 띄운 뒤 [사이트 열기] 까지 그렸다. 그 주소는 아무것도 굽지 않은 주소라
* 404 였고 내 사이트 목록에도 없었다 — 사장님만 발행됐다고 믿는 상태가 남았다.
* ★ 'place' 가 진짜 함정이다. 로그인은 했는데(에디터 관문을 지났다) 3단계의
* [발행 없이 화면만 둘러보기] 로 나오면 서버에 사업장이 없다 — 화면은 끝까지 도는데
* 발행만 안 된다.
*/
type PublishBlocker = 'signin' | 'place';
export function PublishModal() {
const isOpen = useBuilderStore((s) => s.isPublishModalOpen);
const close = useBuilderStore((s) => s.closePublishModal);
@ -57,11 +86,28 @@ export function PublishModal() {
/**
* 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링).
* ★ placeId 가 없는 데모 경로는 예전처럼 주소만 확정한다 — 없는 사업장을 서버에 빌드시킬 수 없다.
* ★ 그럴 수 없는 상태(로그인 전 · 사업장 미확정)에서는 발행 버튼 자체를 그리지 않는다.
* PublishBlocker 주석 참고.
*/
const publisher = usePublishSite(placeId);
const {state, reset} = publisher;
const navigate = useNavigate();
/*
* ★ 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. 로그인이 auth 스토어의 user 도
* 함께 심으므로(lib/session.establishSession) 그걸 **재렌더 신호로만** 구독한다 —
* 없으면 아래 로그인 폼으로 로그인을 마쳐도 모달은 계속 "로그인해 주세요" 로 남는다.
*/
useAuthStore((s) => s.user);
/** 판정 기준은 usePublishSite.isLive 와 같다(placeId + 토큰) — 갈리면 버튼과 실제가 어긋난다. */
const blocker: PublishBlocker | null = publisher.isLive
? null
: getAccessToken()
? 'place'
: 'signin';
const gate = useMemo(
() =>
runPublishGate({
@ -110,7 +156,12 @@ export function PublishModal() {
if (!isOpen) reset();
}, [isOpen, reset]);
const isDone = state.phase === 'published' || (!publisher.isLive && Boolean(publishedUrl));
/**
* ★ 서버가 발행을 확정한 것만 '발행됨'이다.
* 예전에는 `!isLive && publishedUrl` 도 완료로 쳤는데, 그 publishedUrl 을 채우던 것이
* 서버를 한 번도 부르지 않는 가짜 경로였다 — 굽지도 않은 주소에 [사이트 열기] 가 붙었다.
*/
const isDone = state.phase === 'published';
/**
* 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다.
@ -149,25 +200,22 @@ export function PublishModal() {
}, [slug, placeId]);
const handlePublish = async () => {
if (!gate.canPublish || publisher.isPublishing || !slugReady) return;
// ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 —
// 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다.
if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return;
if (publisher.isLive) {
// ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
// 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
if (!isSlugLocked) {
try {
await reserveSiteSlug(placeId ?? '', slug);
} catch (error) {
notifyApiError(error, '주소를 확정하지 못했습니다.');
return;
}
// ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
// 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
if (!isSlugLocked) {
try {
await reserveSiteSlug(placeId ?? '', slug);
} catch (error) {
notifyApiError(error, '주소를 확정하지 못했습니다.');
return;
}
// 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
publisher.publish();
return;
}
setPublishedUrl(url);
notify.success('발행 준비가 끝났습니다', '확인된 정보만 담긴 정적 페이지가 생성됩니다.');
// 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
publisher.publish();
};
const handleCopy = async () => {
@ -184,11 +232,25 @@ export function PublishModal() {
<Dialog
open={isOpen}
onClose={close}
title={isDone ? '발행 완료' : isRepublish ? '재발행 전 점검' : '발행 전 점검'}
title={
blocker === 'signin'
? '지금 화면은 미리보기입니다'
: blocker === 'place'
? '가게 확인이 먼저입니다'
: isDone
? '발행 완료'
: isRepublish
? '재발행 전 점검'
: '발행 전 점검'
}
description={
isDone
? '확인된 정보만 담긴 정적 페이지가 생성되었습니다.'
: '검색·AI 노출에 직접 영향을 주는 항목부터 확인합니다.'
blocker === 'signin'
? '만드신 화면은 아직 어디에도 올라가 있지 않습니다.'
: blocker === 'place'
? '발행본은 확인된 가게 한 곳에 묶입니다.'
: isDone
? '확인된 정보만 담긴 정적 페이지가 생성되었습니다.'
: '검색·AI 노출에 직접 영향을 주는 항목부터 확인합니다.'
}
className="max-w-lg"
footer={
@ -197,7 +259,23 @@ export function PublishModal() {
계속 편집하기
</Button>
{isDone ? (
{/* ★ 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의
폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */}
{blocker === 'signin' ? null : blocker === 'place' ? (
<Button
variant="primary"
className="flex-1"
onClick={() => {
// 모달을 먼저 닫는다 — 같은 화면(BuilderPage) 안에서 단계만 바뀌는 이동이라
// 열어 둔 채로 가면 가게 찾기 위에 이 모달이 그대로 떠 있는다.
close();
navigate(PLACE_SEARCH_URL);
}}
>
<Search />
<span>내 가게 확인하러 가기</span>
</Button>
) : isDone ? (
<Button
variant="primary"
className="flex-1"
@ -236,8 +314,11 @@ export function PublishModal() {
}
>
<div className="space-y-4">
{blocker === 'signin' && <SignInFirstPanel />}
{blocker === 'place' && <PlaceFirstPanel />}
{/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */}
{publisher.isLive && !isDone && (
{!blocker && !isDone && (
<SiteStatusRow
version={publisher.currentVersion?.version}
needsRebuild={publisher.needsRebuild}
@ -245,8 +326,10 @@ export function PublishModal() {
/>
)}
{/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다. */}
{!isDone && (
{/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다.
★ blocker 상태에서는 그리지 않는다. 주소 중복 확인이 사업장·토큰을 요구하므로
입력칸만 열어 두면 확인 버튼이 매번 실패한다. */}
{!blocker && !isDone && (
<SlugField
value={slug}
host={PUBLISH_HOST}
@ -261,7 +344,7 @@ export function PublishModal() {
/>
)}
{!isDone && gate.blockers.length > 0 && (
{!blocker && !isDone && gate.blockers.length > 0 && (
<section className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-bold text-destructive">
<ShieldAlert className="size-3.5" />
@ -273,7 +356,7 @@ export function PublishModal() {
</section>
)}
{!isDone && gate.warnings.length > 0 && (
{!blocker && !isDone && gate.warnings.length > 0 && (
<section className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-bold text-warning">
<AlertTriangle className="size-3.5" />
@ -285,7 +368,7 @@ export function PublishModal() {
</section>
)}
{!isDone && gate.findings.length === 0 && state.phase === 'idle' && (
{!blocker && !isDone && gate.findings.length === 0 && state.phase === 'idle' && (
<div className="flex items-center gap-2 rounded-lg border border-success/30 bg-success/8 p-3 text-xs text-success">
<Check className="size-4 shrink-0" />
<span>
@ -319,6 +402,71 @@ export function PublishModal() {
);
}
/**
* 로그인 전 — **미리보기라고 말하고, 그 자리에서 로그인을 받는다.**
*
* ★ /login 으로 튕기지 않는다. 위저드·에디터 상태는 브라우저에 저장하지 않으므로
* (stores/builder 주석) 화면을 떠나는 순간 지금까지 만든 것이 통째로 사라진다.
* 에디터 관문(EditorSignInGate)이 같은 이유로 같은 폼을 그 자리에 놓는다.
* ★ 로그인이 끝나면 폼이 심은 토큰으로 blocker 가 저절로 다시 계산된다 — 이 패널이 사라지고
* 발행 점검 화면이 뜬다(사업장이 아직 없으면 아래 PlaceFirstPanel 로 넘어간다).
*/
function SignInFirstPanel() {
return (
<div className="space-y-3">
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
<Eye className="size-3.5" />
<span>여기까지는 미리보기입니다</span>
</div>
<p className="leading-relaxed text-muted-foreground">
발행하면 검색·AI 가 읽는 정적 페이지(HTML)가 구워집니다. 그 페이지는 계정과 실제
가게에 묶이므로, 로그인과 내 가게 확인이 끝나야 시작할 수 있습니다.
</p>
<p className="mt-1.5 text-[11px] font-medium">
→ 여기서 로그인하시면 지금까지 만든 내용은 그대로 둔 채 이어집니다.
</p>
</div>
<div className="flex justify-center">
<SignInForm
submitLabel="로그인하고 발행 준비"
header={
<p className="text-center text-xs leading-relaxed text-muted-foreground">
발행본은 나중에 고치고 내릴 수 있어야 해서 계정에 묶어 둡니다.
</p>
}
/>
</div>
</div>
);
}
/**
* 로그인은 했는데 서버에 사업장이 없다 — 3단계에서 수집을 건너뛰고 나온 경로다.
*
* ★ 여기서 사업장을 몰래 만들지 않는다. 생성과 네이버 검증의 순서는
* features/onboarding/ensureServerPlace 한 곳이 소유한다 — 검증을 건너뛰고 만든 사업장은
* 수집도 발행도 못 하는 껍데기로 남고, 그 사실은 화면에 안 나온다.
*/
function PlaceFirstPanel() {
return (
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
<Store className="size-3.5" />
<span>내 가게가 아직 확인되지 않았습니다</span>
</div>
<p className="leading-relaxed text-muted-foreground">
발행본은 실제 가게 한 곳에 묶입니다 — 주소·전화·영업시간이 그 가게의 것이라는 근거가
있어야 검색·AI 가 이 페이지를 출처로 씁니다. 직접 입력으로만 진행하셨다면 그 가게가
아직 서버에 없어서, 발행을 눌러도 구울 대상이 없습니다.
</p>
<p className="mt-1.5 text-[11px] font-medium">
→ 상호로 내 가게를 찾아 확인하시면 그때부터 발행이 열립니다.
</p>
</div>
);
}
/** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */
/**
* 서버가 보는 현재 발행 상태 한 줄.

View File

@ -10,8 +10,8 @@ import {toAuthUser, useAuthStore} from '@/stores/auth';
/**
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
*
* ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게
* 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다.
* ★ 상호 칸은 없다. 회사(테넌트)를 걷어내면서(2026-09-08) 계정에 상호가 없어졌다 —
* 가게 이름은 사업장(place)이 갖는다.
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
* 입력칸 자체를 그리지 않는다.
*/
@ -77,13 +77,6 @@ export function AccountPage() {
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
</p>
</Field>
<Field label="상호">
<p className="text-sm">{data?.company?.name ?? '-'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
상호 변경은 고객센터로 문의해 주세요.
</p>
</Field>
</section>
<section className="space-y-4 rounded-xl border border-border bg-card p-5">

View File

@ -212,10 +212,9 @@ export function BuilderPage() {
<span className="h-3.5 w-px bg-white/20" />
<span
className="max-w-[14rem] truncate text-background/70"
title={user.companyName ? `${userLabel(user)} · ${user.companyName}` : userLabel(user)}
title={userLabel(user)}
>
{userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</span>
<button
type="button"

View File

@ -31,7 +31,6 @@ export function SignupPage() {
passwordConfirm: '',
name: '',
email: '',
companyName: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
@ -63,7 +62,6 @@ export function SignupPage() {
password: form.password,
name: form.name.trim() || null,
email: form.email.trim(),
company_name: form.companyName.trim() || null,
});
if (res.result?.success === false) {
notifyApiError({data: res}, '가입하지 못했습니다.');
@ -180,17 +178,6 @@ export function SignupPage() {
required
/>
</div>
<div>
<label htmlFor="signup-company" className="mb-1.5 block text-xs font-semibold">
상호 <span className="font-normal text-muted-foreground">(선택)</span>
</label>
<Input
id="signup-company"
value={form.companyName}
onChange={set('companyName')}
placeholder="비우면 이름으로 채웁니다"
/>
</div>
</div>
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>

View File

@ -1,4 +1,4 @@
import {useState} from 'react';
import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router';
import {
Building2,
@ -8,17 +8,22 @@ import {
MoreHorizontal,
Pencil,
Plus,
Search,
SearchX,
Stethoscope,
UtensilsCrossed,
Wand2,
X,
} from 'lucide-react';
import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared';
import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify';
import {PUBLISH_HOST} from '@/lib/site';
import {cn} from '@/lib/utils';
// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다.
// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
@ -32,24 +37,51 @@ const CATEGORY_ICON: Record<number, typeof Building2> = {
[PlaceCategory.CLINIC]: Stethoscope,
};
/**
* 줄이 속하는 칸. **배지·필터·정렬이 전부 이 하나로 판정한다.**
*
* ★ 판정을 갈라 쓰면 조용히 틀린다 — '발행됨(1)' 을 눌렀는데 '내림' 배지가 낀 줄이 같이
* 나오는 종류다. 건수까지 틀리므로 사장님은 목록을 못 믿게 된다.
* ★ 세 칸이 목록을 빈틈없이 나눈다. 하나라도 빠지면 '전체' 건수와 칸 건수의 합이 어긋난다.
*/
type SiteBucket = 'live' | 'draft';
type SiteFilter = SiteBucket | 'all';
/**
* ★ 칸은 **둘**이다. 예전엔 '만드는 중'(사이트 행 없음)을 따로 뒀는데, 사장님에게 그 둘은
* 같은 상태다 — "아직 안 나가 있다". `site_id` 가 있고 없고는 우리 DB 사정이지 사장님의
* 구분이 아니고, 칸이 셋이면 34개가 어디 있는지 두 번 세게 된다.
*/
const FILTER_LABEL: Record<SiteFilter, string> = {
all: '전체',
live: '발행됨',
draft: '발행 전',
};
const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft'];
/** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */
const BUCKET_ORDER: Record<SiteBucket, number> = {live: 0, draft: 1};
function bucketOf(row: MySiteData): SiteBucket {
return row.site_id && row.status === SiteStatus.PUBLISHED ? 'live' : 'draft';
}
/**
* 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는
* 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다.
*/
function statusBadge(row: MySiteData) {
if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const};
switch (row.status) {
case SiteStatus.PUBLISHED:
return row.needs_rebuild
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
: {label: '발행됨', variant: 'success' as const};
case SiteStatus.SUSPENDED:
return {label: '중지', variant: 'outline' as const};
case SiteStatus.UNPUBLISHED:
return {label: '내림', variant: 'outline' as const};
default:
return {label: '발행 전', variant: 'default' as const};
if (bucketOf(row) === 'live') {
return row.needs_rebuild
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
: {label: '발행됨', variant: 'success' as const};
}
// 같은 '발행 전' 이어도 **한 번 나갔다가 내린 것**은 말해 준다 — 되돌리는 일과 처음 내는 일은
// 사장님이 할 행동이 다르다. 그 외(초안·수집 중)는 전부 '발행 전' 한 마디다.
if (row.status === SiteStatus.SUSPENDED) return {label: '중지', variant: 'outline' as const};
if (row.status === SiteStatus.UNPUBLISHED) return {label: '내림', variant: 'outline' as const};
return {label: '발행 전', variant: 'default' as const};
}
/** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */
@ -58,19 +90,135 @@ function publishedUrl(row: MySiteData): string | null {
return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST);
}
/**
* 줄 뒤에 붙는 시각. **발행됐으면 발행일, 아니면 만든 날**이다.
*
* ★ 두 개를 같이 걸지 않는다. Wix·아임웹 목록이 시각을 한 칸만 쓰는 이유와 같다 —
* 목록에서 궁금한 건 "이게 언제 나갔나" 하나이고, 아직 안 나간 줄에만 만든 날이 의미가 있다.
* ★ 연도는 올해면 뗀다. 줄이 좁아 주소가 먼저 잘린다.
*/
function whenLabel(row: MySiteData): string {
const raw = row.published_at ?? row.created_at;
if (!raw) return '';
const at = new Date(raw);
if (Number.isNaN(at.getTime())) return '';
const now = new Date();
const date = at.toLocaleDateString('ko-KR', {
...(at.getFullYear() === now.getFullYear() ? {} : {year: 'numeric'}),
month: 'long',
day: 'numeric',
});
return row.published_at ? `${date} 발행` : `${date} 만듦`;
}
/** 정렬용 시각. whenLabel 이 고른 값과 같은 것을 쓴다 — 화면에 보이는 날짜와 순서가 갈라지면 안 된다. */
function rowTime(row: MySiteData): number {
const raw = row.published_at ?? row.created_at;
if (!raw) return 0;
const at = new Date(raw).getTime();
return Number.isNaN(at) ? 0 : at;
}
/**
* 검색 비교용 정규화. **공백을 지운다** — 실측(계정 test)에 같은 상호 '버터브루' 가 4줄인데
* 사장님은 '버터 브루' 로도 친다. 한국어 상호는 띄어쓰기가 원본마다 다르다.
*/
function normalizeText(value: string): string {
return value.toLowerCase().replace(/\s+/g, '');
}
/** 상호와 도로명 주소만 본다 — 상호가 겹치는 줄을 실제로 가르는 건 주소다. */
function matchesQuery(row: MySiteData, needle: string): boolean {
if (!needle) return true;
return normalizeText(row.name).includes(needle) || normalizeText(row.road_address ?? '').includes(needle);
}
/**
* 줄 앞의 그림. 발행에 성공한 사이트만 썸네일이 있고(sites.thumbnail_url),
* 없으면 업종 아이콘으로 떨어진다.
*
* ★ 주소에 `?v=<버전>` 이 붙어 있다 — 발행할 때마다 바뀐다(site_thumbnail.public_url).
* 그래서 <img> 에 캐시 무효화를 따로 걸지 않는다. 여기서 또 붙이면 발행하지 않은
* 재방문에도 매번 새로 받는다.
* ★ 못 받으면 아이콘으로 되돌린다. 블롭이 지워졌거나 옛 주소인 줄에서 깨진 그림이
* 뜨는 것보다 낫다.
*/
function SiteThumb({row, Icon}: {row: MySiteData; Icon: typeof Building2}) {
const [failed, setFailed] = useState(false);
const src = row.thumbnail_url;
// ★ 비율은 **16:10 — 브라우저 창 비율**이다. 이 그림은 사이트 미리보기라 1:1 로 자르면
// 위아래가 잘려 무슨 사이트인지 알아볼 수 없다(사진첩이 아니다).
// ★ 크기는 아임웹 내사이트 화면을 보고 키웠다(2026-09-08). 거기 썸네일도 줄 높이의 대부분을
// 차지한다 — 같은 상호가 여러 줄일 때 그림이 유일한 구분자인데 작으면 있으나 마나다.
if (!src || failed) {
return (
<div className="flex aspect-[16/10] w-full items-center justify-center border-b border-border bg-muted/60">
<Icon className="size-7 text-muted-foreground/60" />
</div>
);
}
return (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="aspect-[16/10] w-full border-b border-border object-cover"
/>
);
}
/**
* 내 사이트 — 로그인한 사장님의 홈이다.
*
* 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다.
* ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다.
*
* ★ 목록이 하는 일은 셋뿐이다 — ① 어느 게 어느 건지 가르고 ② 지금 상태를 말하고 ③ 여는 길을 준다.
* 방문자·주문 같은 숫자는 여기 넣지 않는다(Wix·아임웹도 사이트 안 대시보드에 둔다).
*/
export function SitesPage() {
const navigate = useNavigate();
const {data, isLoading, isError, error, refetch} = useListMySites({size: 50});
const [busyId, setBusyId] = useState<string | null>(null);
const [menuId, setMenuId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<SiteFilter>('all');
const rows = data?.sites ?? [];
const rows = useMemo(() => data?.sites ?? [], [data]);
// ★ 서버로 안 보낸다. 한 번에 50줄을 이미 다 받아 놓았고(실측 35줄), 서버 검색을 붙이면
// 글자마다 왕복 + 디바운스 + 늦게 온 응답이 최신 결과를 덮는 경합까지 따라온다.
// → 50줄을 넘어 페이지가 생기는 날이 오면 그때는 서버가 맞다(이 필터는 첫 페이지만 본다).
const found = useMemo(() => {
const needle = normalizeText(query);
return rows.filter((row) => matchesQuery(row, needle));
}, [rows, query]);
// ★ 건수는 **검색 결과 위에서** 센다. 검색어를 친 뒤 "발행됨 0 · 만드는 중 3" 이 보여야
// 찾는 게 어느 칸에 있는지 알 수 있다. 검색 전 건수를 그대로 두면 칸을 눌러 보고서야 안다.
const counts = useMemo(() => {
const tally: Record<SiteFilter, number> = {all: found.length, live: 0, draft: 0};
for (const row of found) tally[bucketOf(row)] += 1;
return tally;
}, [found]);
// 서버는 사업장 생성 역순으로만 준다(site_crud.list_owner_sites) — 발행 여부를 모른다.
// 나가 있는 것을 위로 올리는 건 여기서 한다.
const visible = useMemo(() => {
const picked = filter === 'all' ? found : found.filter((row) => bucketOf(row) === filter);
return [...picked].sort(
(a, b) => BUCKET_ORDER[bucketOf(a)] - BUCKET_ORDER[bucketOf(b)] || rowTime(b) - rowTime(a),
);
}, [found, filter]);
const isNarrowed = query.trim().length > 0 || filter !== 'all';
const resetView = () => {
setQuery('');
setFilter('all');
};
// 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면
// 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석).
@ -135,53 +283,145 @@ export function SitesPage() {
/>
)}
{rows.length > 0 && (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{rows.map((row) => {
{!isLoading && !isError && rows.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="상호나 주소로 찾기"
aria-label="상호나 주소로 찾기"
className="h-9 pr-8 pl-8"
/>
{query && (
<button
type="button"
aria-label="검색어 지우기"
onClick={() => setQuery('')}
className="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */}
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
{FILTER_TABS.map((key) => (
<button
key={key}
type="button"
aria-pressed={filter === key}
onClick={() => setFilter(key)}
className={cn(
'h-8 cursor-pointer rounded px-2.5 text-xs font-medium transition-colors',
filter === key
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{FILTER_LABEL[key]}
<span className="ml-1 tabular-nums opacity-60">{counts[key]}</span>
</button>
))}
</div>
</div>
)}
{/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면
사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */}
{!isLoading && !isError && rows.length > 0 && visible.length === 0 && (
<EmptyState
icon={SearchX}
title="조건에 맞는 사이트가 없습니다"
description={`가진 사이트 ${rows.length}개 중에 없습니다. 검색어를 줄이거나 다른 칸을 보세요.`}
action={
<Button size="sm" onClick={resetView}>
조건 지우기
</Button>
}
/>
)}
{visible.length > 0 && (
/* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은
줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라
(실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다.
비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((row) => {
const Icon = CATEGORY_ICON[row.category] ?? Building2;
const badge = statusBadge(row);
const url = publishedUrl(row);
const isLive = bucketOf(row) === 'live';
const editHref = `/builder?placeId=${row.place_id}`;
return (
<li key={row.place_id} className="relative flex items-center gap-3 px-4 py-3.5 hover:bg-muted/40">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<Link to={editHref} className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{row.name}</span>
<Badge variant={badge.variant}>{badge.label}</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
</p>
/* ★ 카드는 **상태와 무관하게 같은 골격**이다. 예전엔 발행 전 카드에만 '할 일'
줄이 하나 더 붙어서 같은 줄의 카드끼리 높이와 버튼 위치가 어긋났다(사장님 지적).
h-full + flex-col + mt-auto 로 액션 줄을 항상 카드 바닥에 붙인다. */
<li
key={row.place_id}
className="group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-shadow hover:shadow-md"
>
<Link to={editHref} className="block">
<SiteThumb row={row} Icon={Icon} />
</Link>
<div className="flex shrink-0 items-center gap-1.5">
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
{/* 배지는 그림 위에 얹는다 — 카드에서 상태는 제목보다 먼저 읽혀야 한다. */}
<Badge variant={badge.variant} className="absolute top-2.5 left-2.5 shadow-sm">
{badge.label}
</Badge>
<div className="flex flex-1 flex-col p-3.5">
<Link to={editHref} className="block min-w-0">
<p className="truncate text-sm font-semibold">{row.name}</p>
{/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라
같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */}
<p
className={cn(
'mt-1 truncate text-xs',
isLive ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
<ExternalLink className="size-3.5" />
사이트 열기
</a>
)}
<Button size="sm" onClick={() => navigate(editHref)}>
<Pencil />
{row.site_id ? '편집' : '이어서 만들기'}
</Button>
<Button
size="icon"
variant="ghost"
aria-label="더보기"
isLoading={busyId === row.place_id}
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)}
>
{busyId === row.place_id ? null : <MoreHorizontal />}
</Button>
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
</p>
{/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */}
<p className="mt-1 mb-3 truncate text-[11px] text-muted-foreground/70">
{[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')}
</p>
</Link>
<div className="mt-auto flex items-center gap-1.5 border-t border-border pt-3">
{/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이
같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */}
<Button size="sm" className="flex-1" onClick={() => navigate(editHref)}>
<Pencil />
편집
</Button>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ExternalLink className="size-3.5" />
열기
</a>
)}
<Button
size="icon"
variant="ghost"
aria-label="더보기"
isLoading={busyId === row.place_id}
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)}
>
{busyId === row.place_id ? null : <MoreHorizontal />}
</Button>
</div>
</div>
{menuId === row.place_id && (
@ -193,7 +433,7 @@ export function SitesPage() {
className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuId(null)}
/>
<div className="absolute right-4 top-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md">
<div className="absolute right-3.5 bottom-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md">
<button
type="button"
disabled={row.status !== SiteStatus.PUBLISHED}
@ -210,6 +450,7 @@ export function SitesPage() {
})}
</ul>
)}
</PageContainer>
</AppShell>
);

View File

@ -8,8 +8,6 @@ export interface AuthUser {
name?: string;
email?: string;
role: number;
companyId?: string;
companyName?: string;
}
/**
@ -26,8 +24,6 @@ export function toAuthUser(res: ResMe): AuthUser {
name: res.name ?? undefined,
email: res.email ?? undefined,
role: res.role ?? UserRole.USER,
companyId: res.company?.company_id,
companyName: res.company?.name,
};
}

View File

@ -33,6 +33,13 @@ export function deriveSurfaces(colors: {bg: string; card: string; text: string;
surface: mix(colors.bg, colors.text, 0.06),
surfaceAlt: colors.card,
inverse: '#1c1917',
border: colors.secondary,
/**
* 선 색.
*
* ★ secondary 를 그대로 쓰면 안 된다 — 그건 '본문 다음으로 진한 글자색' 이라
* 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다(갱지 팔레트에서 특히).
* 바탕에서 글자색 쪽으로 20%만 간 값이 목업의 선 색이다(#e4dac0 → #bcb49e).
*/
border: mix(colors.bg, colors.text, 0.2),
};
}

View File

@ -85,10 +85,16 @@ export interface PeopleItem {
role?: string;
oneLine?: string;
/**
* 사진 검색어. ★ 이미지 URL 을 받지 않는다 — 초상권·저작권 확인은 사장님 몫이고,
* 모델이 지어낸 주소를 링크하면 깨진 사진이 인물 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약).
* 사진 검색어. 모델은 이 값만 만든다 — 주소를 지어내면 깨진 사진이 인물 얼굴 자리에 남는다.
*/
imageQuery?: string;
/**
* 초상 사진.
*
* ★ **모델이 채우는 칸이 아니다.** 권리가 확인된 출처(위키미디어 등)에서 사람이 넣거나,
* 수집기가 라이선스를 확인하고 넣는다. 초상권은 지어낸 URL 로 감당할 수 없다.
*/
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -101,6 +107,8 @@ export interface ChronicleItem {
place?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean;
/** 사진. 재게시 권리가 확실한 출처(공공누리·위키미디어)만 싣는다. 없으면 글자만 나간다. */
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -124,6 +132,8 @@ export interface PostcardItem {
place?: string;
/** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */
postmark?: string;
/** 엽서 앞면 사진. 권리가 확실한 출처만. 없으면 글자만 있는 뒷면 한 장이 된다. */
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -138,6 +148,56 @@ export interface QuizItem {
source?: DataSource;
}
export interface ItineraryStop {
name: string;
minutes?: number;
note?: string;
searchQuery?: string;
/**
* 좌표.
*
* ★ **모델이 만드는 값이 아니다.** 지도에 핀을 찍는 값이라 한 자리만 틀려도 손님이
* 엉뚱한 데로 간다. 장소명으로 공식 API(TourAPI·카카오)를 조회해 채운다.
* 없으면 지도를 그리지 않고 타임라인만 낸다.
*/
latitude?: number;
longitude?: number;
/** 사진. 권리가 확실한 출처만. */
imageUrl?: string;
}
export interface ItineraryDay {
label: string;
startTime?: string;
stops?: ItineraryStop[];
}
/** 며칠 묵느냐로 갈리는 일정. days 가 하루씩이다. */
export interface ItineraryItem {
name: string;
duration?: string;
audience?: string;
why?: string;
days?: ItineraryDay[];
verified?: DataVerified;
source?: DataSource;
}
/** 사장님이 쓰는 공지·소식. 지역 리서치가 아니라 업소가 소유한 글이다. */
export interface EventItem {
kind?: string;
title: string;
summary?: string;
body?: string;
verified?: DataVerified;
}
export interface VideoItem {
url: string;
caption?: string;
verified?: DataVerified;
}
export interface PlannerStop {
name: string;
/** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */
@ -181,6 +241,10 @@ export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = {
postcard: 'line',
quiz: 'question',
planner: 'name',
// ★ 여기 없는 kind 는 파서가 통째로 건너뛴다 — payload 에 실려 와도 화면에서 사라진다.
itinerary: 'name',
event: 'title',
video: 'url',
};
export interface ParsedSectionData<T> {

View File

@ -106,6 +106,13 @@ export interface TemplateLook {
headingWeight: string;
/** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */
sectionSpace: string;
/**
* 종이 질감 — `background-image` 에 그대로 들어간다(`.paper`).
*
* ★ 레트로(갱지)의 인상이 사실상 이 값에서 온다. 없으면 색만 갱지고 면은 매끈해서
* 같은 팔레트인데도 인쇄물로 안 보인다. 비워 두면 아무것도 깔지 않는다.
*/
texture?: string;
}
export interface PhotoItem {

View File

@ -189,6 +189,14 @@ export interface LocalPlace {
description?: string;
/** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */
searchQuery: string;
/**
* 대표 사진. 재게시 권리가 확실한 출처(공공누리 등)에서 온 것만 싣는다.
*
* ★ 없으면 카드가 글자만 남는다 — 그래도 지어내지 않는다.
*/
imageUrl?: string;
/** 사업장에서 잰 직선거리(m). 도보 시간을 화면에서 환산하는 근거다. */
distanceMeters?: number;
}
export interface FestivalEntry {
@ -199,6 +207,8 @@ export interface FestivalEntry {
description?: string;
officialUrl?: string;
searchQuery: string;
/** 대표 사진. 권리가 확실한 출처만 — 없으면 카드가 글자만 남는다. */
imageUrl?: string;
}
export interface RouteEntry {

View File

@ -690,10 +690,12 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
})
.sort((a, b) => a.loc.localeCompare(b.loc));
// ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
// ★ `/s` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
// 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다.
// ★ 주소에 끝 슬래시가 있어야 한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡는다.
const indexUrl = joinUrl(origin, SITE_DIR) + '/';
// ★ 끝 슬래시를 붙이지 않는다 — 슬러그 페이지(`/s/<slug>`)와 같은 형태여야 한다.
// nginx 가 `location = /s` 로 이 파일을 직접 주고 `/s/` 는 여기로 301 한다
// (nginx/site.conf). 그 블록이 없으면 `/s` 는 빌더 SPA 셸을 200 으로 내준다.
const indexUrl = joinUrl(origin, SITE_DIR);
writeFileSync(join(sitesDir, 'index.html'), renderSiteIndex(origin, indexUrl, sites), 'utf-8');
// 사이트맵에는 랜딩·목록 페이지도 담는다. 랜딩은 이 호스트의 첫 페이지이고,

View File

@ -15,6 +15,28 @@
--tpl-card: #fafafa;
--tpl-text: #09090b;
--tpl-accent: #2563eb;
/*
* 유동 타이포 — 화면 폭에 따라 자라는 글자 크기.
*
* ★ 이 토큰이 없던 동안 히어로 제목이 본문과 같은 크기로 나왔다. 섹션 컴포넌트들이
* var(--fs-display) 를 쓰는데 정의가 어디에도 없어 그냥 무시됐다(브라우저는 조용히 넘어간다).
* ★ 값은 목업(/s/stay)이 쓰던 것을 그대로 옮겼다 — 화면이 달라지면 목업과 어긋난다.
*/
--fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem);
--fs-h2: clamp(1.3125rem, 1.15rem + 0.7vw, 1.75rem);
--fs-h3: clamp(1.0625rem, 1rem + 0.3vw, 1.25rem);
--fs-lead: clamp(0.9375rem, 0.9rem + 0.22vw, 1.0625rem);
--fs-body: clamp(0.9375rem, 0.91rem + 0.15vw, 1rem);
--fs-sm: clamp(0.8125rem, 0.8rem + 0.1vw, 0.875rem);
--fs-xs: clamp(0.75rem, 0.74rem + 0.06vw, 0.78125rem);
/* 섹션 세로 여백. 템플릿의 sectionSpace 를 상한으로 두고 좁은 화면에서만 줄인다. */
--section-space: clamp(2.25rem, 5vw, var(--tpl-section-space, 3.5rem));
/* 선·흐린 글자 — 템플릿 색에서 유도한다. 고정 회색을 쓰면 갱지 바탕에서 뜬다. */
--color-line: var(--tpl-border, color-mix(in oklab, var(--tpl-text, #09090b) 14%, transparent));
--color-muted: color-mix(in oklab, var(--tpl-text, #09090b) 60%, transparent);
}
@theme inline {
@ -68,4 +90,91 @@ body {
padding-inline: 1.5rem;
}
}
/* 본문 한 줄 길이 상한. 68자를 넘기면 눈이 다음 줄을 못 찾는다. */
.measure {
max-width: 68ch;
}
/* 종이 질감 — 템플릿이 texture 를 주면 깔린다(레트로 갱지). 없으면 아무것도 안 깔린다. */
.paper {
background-image: var(--tpl-texture, none);
}
/* 카드·패널. 바탕보다 한 겹 앞으로 나온 면. */
.panel {
background-color: color-mix(in oklab, currentcolor 5%, transparent);
border: var(--tpl-border-width, 1px) solid var(--color-line);
border-radius: var(--tpl-radius, 0.75rem);
box-shadow: var(--tpl-shadow, none);
}
.panel-sunken {
background-color: color-mix(in oklab, currentcolor 6%, transparent);
}
.border-line {
border-color: var(--color-line);
}
/* divide-y 의 사이 선도 같은 색으로. Tailwind 기본은 회색이라 갱지 위에서 뜬다. */
.divide-line > :not([hidden]) ~ :not([hidden]) {
border-color: var(--color-line);
}
.text-muted {
color: var(--color-muted);
}
/* 소제목. h1 은 히어로가 인라인으로 쓰고, 그 아래 단계는 이 두 클래스가 맡는다. */
.h2 {
font-family: var(--tpl-font-heading, var(--font-serif));
font-size: var(--fs-h2);
font-weight: var(--tpl-heading-weight, 700);
letter-spacing: var(--tpl-heading-tracking, -0.01em);
line-height: 1.2;
}
.h3 {
font-family: var(--tpl-font-heading, var(--font-serif));
font-size: var(--fs-h3);
font-weight: var(--tpl-heading-weight, 700);
letter-spacing: var(--tpl-heading-tracking, -0.005em);
line-height: 1.35;
}
/* 간판체처럼 굵기가 한 벌뿐인 서체에 가짜 볼드가 씌워지는 것을 막는다. */
.serif,
.h2,
.h3 {
font-synthesis-weight: none;
}
.label {
font-size: var(--fs-xs);
color: var(--color-muted);
font-weight: 600;
}
.tpl-shadow {
box-shadow: var(--tpl-shadow, none);
}
}
@layer components {
/*
* 가로 레일 — 목업과 같은 클래스 이름. 캐러셀 라이브러리 없이 스크롤 스냅으로만 만든다.
* ★ 스크립트가 없어도 손가락·트랙패드로 밀린다. 라이브러리를 얹으면 그게 죽었을 때
* 레일 전체가 한 줄로 굳는다.
*/
.slider-viewport {
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.75rem;
}
.slider-viewport::-webkit-scrollbar {
display: none;
}
.slider-track {
display: flex;
scroll-snap-type: x mandatory;
}
.slider-track > * {
scroll-snap-align: center;
}
}

View File

@ -5,6 +5,7 @@ import {
EssentialInfoSection,
ExhibitionSection,
FaqSection,
FestivalSection,
GallerySection,
HeroSection,
InquirySection,
@ -14,11 +15,20 @@ import {
LocationSection,
RulesSection,
SpaceSection,
StorySection,
UnitsSection,
} from '@/sections';
import {useSite} from '@/lib/site-context';
import {isSectionEnabled} from '@/lib/derive';
/**
* StorySection 이 탭으로 묶어 그리는 아이템.
*
* ★ `sections/StorySection.tsx` 의 STORY_KINDS 와 같은 목록이어야 한다.
* 어긋나면 어떤 아이템은 탭 밖에 따로 서고, 어떤 아이템은 어디에도 안 나온다.
*/
const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily'];
/**
* 홈.
*
@ -59,12 +69,16 @@ export function HomePage() {
exhibition: ExhibitionSection,
photos: GallerySection,
local: LocalGuideSection,
festival: FestivalSection,
weather: WeatherSection,
map: LocationSection,
faq: FaqSection,
// 붙여넣기 아이템 아홉. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다.
// 붙여넣기 아이템. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다.
// ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다.
...ITEM_SECTIONS,
// ★ 지역 이야기(가요·인물·연표·엽서·퀴즈·문학·일력)는 StorySection 이 탭으로 묶어 그린다.
// 그래서 그 일곱은 위 표에서 **가려낸다** — 여기 남겨 두면 탭 안과 밖에 두 번 나온다.
...Object.fromEntries(STORY_KINDS.map((kind) => [kind, StorySection])),
};
const rendered = new Set<string>();

View File

@ -1,6 +1,13 @@
import {useSite} from '@/lib/site-context';
import {galleryImages, sectionBody} from '@/lib/derive';
/**
* 소개.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="about">`)에서 그대로 옮긴 것이다.
* 비슷하게 다시 짜지 않는다 — 그리드 비율(7:5)·모서리(rounded-xl)·본문 행간(1.8)이
* 조금만 달라도 같은 페이지로 안 보인다.
*/
export function AboutSection() {
const payload = useSite();
const {narrative, place} = payload;
@ -17,15 +24,14 @@ export function AboutSection() {
<section
id="about"
aria-labelledby="about-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider opacity-50">About</p>
<div className="grid grid-cols-1 items-center gap-8 md:grid-cols-12 lg:gap-12">
<div className="grid grid-cols-1 items-center gap-8 lg:grid-cols-12 lg:gap-14">
{image && (
<figure className="md:col-span-6">
<div className="relative aspect-4/3 overflow-hidden rounded-2xl md:aspect-5/4">
<figure className="lg:col-span-7">
<div className="tpl-border border-line relative aspect-4/3 max-h-[26rem] overflow-hidden rounded-xl border lg:aspect-3/2">
<img
src={image.url}
alt={image.alt}
@ -36,20 +42,16 @@ export function AboutSection() {
className="size-full object-cover"
/>
</div>
{image.caption && (
<figcaption className="mt-2 text-xs opacity-60">{image.caption}</figcaption>
)}
</figure>
)}
<div className={image ? 'md:col-span-6' : 'md:col-span-12'}>
{/* 제목은 사장님이 붙인 섹션 이름이 먼저다. 이름이 비어 있을 때만
지금까지 쓰던 문구(한 줄 소개 → "○○ 소개")로 떨어진다. */}
<h2 id="about-heading" className="serif mb-4 text-2xl font-bold leading-snug tracking-tight sm:text-3xl lg:text-4xl">
{narrative.heroSubline ?? `${place.name} 소개`}
<div className={image ? 'lg:col-span-5' : 'lg:col-span-12'}>
<h2 id="about-heading" className="h2 mb-6 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{`${place.name} 소개`}</span>
</h2>
<div className="space-y-3 text-sm leading-relaxed opacity-80">
<div className="measure space-y-4 text-[length:var(--fs-lead)] leading-[1.8]">
{about.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}

View File

@ -11,6 +11,9 @@ import {formatKoreanDate} from '@/lib/format';
* 표(dl)로 두는 이유는 label-value 관계가 마크업으로 드러나야 기계가 짝을 짓기 때문이다.
*
* ★ 여기 나오는 값은 전부 확인된 fact 다. essentialRows() 가 걸러 준다.
* ★ 마크업은 목업(/s/stay 의 `<section id="summary">`)에서 그대로 옮겼다.
* 좌우 5:7 그리드에 왼쪽이 제목·요약, 오른쪽이 두 칸 표다. 제목은 h2 태그지만 h3 급으로
* 그린다 — 이 블록은 섹션이 아니라 히어로에 딸린 요약이라 같은 크기로 서면 위계가 무너진다.
*/
export function AnswerBlock() {
const payload = useSite();
@ -23,33 +26,36 @@ export function AnswerBlock() {
<section
id="summary"
aria-labelledby="summary-heading"
className="w-full border-b border-black/8 py-10 sm:py-12"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line w-full border-b py-10 sm:py-14"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
>
<div className="shell">
<h2 id="summary-heading" className="serif mb-1 text-lg font-bold sm:text-xl">
한눈에 보는 {payload.place.name}
</h2>
<p className="mb-5 text-xs opacity-60">
{formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다.
</p>
<div className="grid gap-8 lg:grid-cols-12 lg:gap-12">
<div className="lg:col-span-5">
<h2 id="summary-heading" className="h3">
한눈에 보는 {payload.place.name}
</h2>
{/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
<p className="measure mt-3 text-[length:var(--fs-body)] leading-relaxed">{summary}</p>
<p className="text-muted mt-3 text-[length:var(--fs-xs)]">
{formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다.
</p>
</div>
{/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
<p className="mb-5 max-w-3xl text-sm leading-relaxed sm:text-base">{summary}</p>
{rows.length > 0 && (
<dl className="grid grid-cols-1 gap-x-8 gap-y-0 sm:grid-cols-2">
{rows.map((row) => (
<div
key={row.label}
className="flex items-start justify-between gap-4 border-b border-black/8 py-2.5 text-sm"
>
<dt className="shrink-0 font-medium opacity-60">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
{rows.length > 0 && (
<dl className="grid grid-cols-1 gap-x-10 lg:col-span-7 xl:grid-cols-2">
{rows.map((row) => (
<div
key={row.label}
className="border-line flex flex-col gap-0.5 py-2.5 sm:flex-row sm:gap-4 sm:border-b"
>
<dt className="text-muted shrink-0 text-[length:var(--fs-sm)] sm:w-28">{row.label}</dt>
<dd className="text-[length:var(--fs-sm)] font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
</div>
</div>
</section>
);

View File

@ -28,8 +28,8 @@ export function BookingSection() {
<section
id="booking"
aria-labelledby="booking-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="paper border-line w-full border-b"
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -37,8 +37,9 @@ export function BookingSection() {
<span>Booking</span>
</p>
<h2 id="booking-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'booking', '예약 안내')}
<h2 id="booking-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'booking', '예약 안내')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
{payload.place.name} 예약은 아래 경로로 받습니다.

View File

@ -1,82 +1,137 @@
import {Info} from 'lucide-react';
import {Phone} from 'lucide-react';
import {selectPublishable} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {essentialRows} from '@/lib/derive';
import {bookingLinks, channelLabel, essentialRows} from '@/lib/derive';
import {formatKoreanDate} from '@/lib/format';
/**
* 이용 정보 표 전체.
* 이용안내 및 예약.
*
* AnswerBlock 이 상위 6개만 보여준다면 여기는 확인된 것 전부를 낸다.
* ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면
* 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다.
* ★ 마크업은 목업(/s/stay 의 `<section id="info">`)에서 그대로 옮겼다.
* ★ 표를 두 덩이로 가른다 — "예약 전 확인"(critical)과 "시설 · 편의".
* critical 은 틀리면 예약 클레임이 나는 항목이라 손님이 먼저 봐야 하고,
* 와이파이·주차와 같은 무게로 섞이면 그 위계가 사라진다.
* ★ 예약 버튼이 이 섹션 안에 있다. 규정을 읽은 자리에서 바로 예약으로 이어지지 않으면
* 손님은 다시 위로 올라가야 한다.
* ★ "확인 중인 항목 N개"를 숨기지 않는다 — 없는 척하면 손님이 다른 데서 틀린 값을 찾아 온다.
*/
export function EssentialInfoSection() {
const payload = useSite();
const rows = essentialRows(payload);
if (rows.length === 0) return null;
// critical 여부는 fact 에만 있다. 표 행(label/value)에 다시 붙여 둘로 가른다.
const criticalLabels = new Set(
selectPublishable(payload.facts)
.filter((fact) => fact.scope === 'place' && fact.critical)
.map((fact) => fact.label),
);
const groups = [
{title: '예약 전 확인', accent: true, rows: rows.filter((row) => criticalLabels.has(row.label))},
{title: '시설 · 편의', accent: false, rows: rows.filter((row) => !criticalLabels.has(row.label))},
].filter((group) => group.rows.length > 0);
const hiddenCount = payload.facts.filter(
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
).length;
if (rows.length === 0) return null;
const half = Math.ceil(rows.length / 2);
const links = bookingLinks(payload);
const phone = payload.place.phone;
return (
<section
id="info"
aria-labelledby="info-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<Info className="size-4" />
<span>Essential Information</span>
</p>
<div className="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h2 id="info-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl">
{'이용 및 예약 안내'}
</h2>
<p className="mt-1 text-xs opacity-60 sm:text-sm">
방문 전 확인이 필요한 운영 규정과 시설 안내입니다.
</p>
</div>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="info-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">이용안내 및 예약</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
방문 전 확인이 필요한 운영 규정과 시설 안내입니다.
</p>
</div>
</div>
</header>
</div>
<div
className="overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
>
<div className="grid grid-cols-1 divide-y divide-black/8 md:grid-cols-2 md:divide-x md:divide-y-0">
{[rows.slice(0, half), rows.slice(half)].map((column, columnIndex) => (
<dl key={columnIndex} className="divide-y divide-black/5">
{column.map((row) => (
<div className="space-y-8">
{groups.map((group) => (
<section key={group.title}>
<h3 className="border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
{group.accent && (
<i
aria-hidden="true"
className="h-[1em] w-[3px] shrink-0"
style={{backgroundColor: 'var(--color-accent)'}}
/>
)}
<span>{group.title}</span>
</h3>
<dl className="divide-line divide-y">
{group.rows.map((row) => (
<div
key={row.label}
className="flex flex-col justify-between gap-1 p-4 sm:flex-row sm:items-start sm:gap-4 sm:p-5"
className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6"
>
<dt className="shrink-0 text-xs font-bold sm:w-32 sm:text-sm">{row.label}</dt>
<dd className="flex-1 text-left sm:text-right">
<p className="text-xs font-medium sm:text-sm">{row.value}</p>
{row.note && <p className="mt-0.5 text-[11px] opacity-50">{row.note}</p>}
</dd>
<dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0">
{row.label}
</dt>
<dd className="measure text-[length:var(--fs-sm)] font-semibold">{row.value}</dd>
</div>
))}
</dl>
))}
</div>
</section>
))}
<div className="flex flex-col items-center justify-between gap-2 border-t border-black/8 px-4 py-3.5 text-xs opacity-60 sm:flex-row sm:px-6">
{(phone || links.length > 0) && (
<div className="border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[length:var(--fs-sm)] font-semibold">
{payload.place.name} 예약은 아래로 받습니다.
</p>
<div className="flex flex-wrap gap-2">
{phone && (
<a
href={`tel:${phone}`}
className="tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
>
<Phone className="size-4" aria-hidden="true" />
<span>{phone}</span>
</a>
)}
{links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{channelLabel(link)}로 예약
</a>
))}
</div>
</div>
)}
</div>
<p className="text-muted mt-5 text-[length:var(--fs-xs)]">
<span className="flex flex-col gap-1 sm:flex-row sm:justify-between">
<span>
{hiddenCount > 0
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
: '모든 항목이 사업자 확인을 거쳤습니다.'}
</span>
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span>
</div>
</div>
</span>
</p>
</div>
</section>
);

View File

@ -27,8 +27,8 @@ export function ExhibitionSection() {
<section
id="exhibition"
aria-labelledby="exhibition-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="paper border-line w-full border-b"
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -36,8 +36,9 @@ export function ExhibitionSection() {
<span>Exhibition</span>
</p>
<h2 id="exhibition-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'exhibition', '관람 안내')}
<h2 id="exhibition-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'exhibition', '관람 안내')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
방문 전 관람 조건을 확인해 주세요.

View File

@ -1,4 +1,3 @@
import {HelpCircle} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {faqList} from '@/lib/derive';
@ -20,36 +19,42 @@ export function FaqSection() {
<section
id="faq"
aria-labelledby="faq-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<HelpCircle className="size-4" />
<span>FAQ</span>
</p>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="faq-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">자주 묻는 질문</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
아래 답변은 모두 사업자가 확인한 내용입니다.
</p>
</div>
</div>
</header>
</div>
<h2 id="faq-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{'자주 묻는 질문'}
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
아래 답변은 모두 사업자가 확인한 내용입니다.
</p>
<div className="max-w-3xl space-y-3">
{/* ★ 두 칸으로 편다. 한 줄로 세우면 질문 열 개가 화면 두 개를 잡아먹는다.
`h-fit` 이라 펼친 카드만 길어지고 옆 카드는 따라 늘어나지 않는다. */}
<div className="grid gap-3 lg:grid-cols-2 lg:gap-4">
{faqs.map((faq, index) => (
<details
key={faq.faqId}
// 첫 항목만 펼쳐 둔다 — 나머지도 DOM 에는 그대로 있다.
open={index === 0}
className="overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
className="panel group h-fit overflow-hidden"
>
<summary className="flex cursor-pointer items-center gap-2.5 p-4 text-xs font-bold marker:content-none sm:p-5 sm:text-sm [&::-webkit-details-marker]:hidden">
<span className="serif shrink-0 opacity-50">Q.</span>
<span>{faq.question}</span>
<summary className="tap flex cursor-pointer items-center gap-3 px-5 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden">
<span className="serif text-muted shrink-0">Q.</span>
<span className="flex-1">{faq.question}</span>
<span className="text-muted shrink-0 transition-transform group-open:rotate-45">+</span>
</summary>
<div className="border-t border-black/5 px-4 pb-4 pt-3 text-xs leading-relaxed opacity-80 sm:px-5 sm:pb-5 sm:text-sm">
<div className="border-line border-t px-5 pb-5 pt-4 text-[length:var(--fs-sm)] leading-relaxed opacity-80">
{faq.answer}
</div>
</details>

View File

@ -0,0 +1,162 @@
/**
* 계절별 축제.
*
* ★ 왜 계절로 묶나 — 지역 축제는 날짜보다 '언제쯤'이 먼저다. 손님은 "가을에 뭐 있나"를 묻지
* "10월 3일에 뭐 있나"를 묻지 않는다. 그리고 정적 페이지는 몇 달 산다 — 날짜로 줄 세우면
* 구운 다음 날부터 지난 목록이 된다.
* ★ **전 계절을 HTML 에 굽고 화면에서만 접는다.** 접힌 계절이 HTML 에 없으면 검색·AI 가
* 나머지를 못 읽는다(items 규칙과 같다).
* ★ 링크는 검색으로 보낸다 — 축제 공식 페이지 주소를 우리가 지어내지 않는다.
*/
import {useState} from 'react';
import {ArrowUpRight, MapPin} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {sectionName} from '@/lib/derive';
const SEASONS = ['봄', '여름', '가을', '겨울'] as const;
type Season = (typeof SEASONS)[number] | '전체';
/** '3월' → 봄. 월을 못 읽으면 어느 계절로도 넣지 않는다(지어내지 않는다). */
function seasonOf(month?: string): (typeof SEASONS)[number] | null {
const n = Number((month ?? '').replace(/[^0-9]/g, ''));
if (!Number.isFinite(n) || n < 1 || n > 12) return null;
if (n >= 3 && n <= 5) return '봄';
if (n >= 6 && n <= 8) return '여름';
if (n >= 9 && n <= 11) return '가을';
return '겨울';
}
export function FestivalSection() {
const payload = useSite();
const festivals = payload.local.festivals ?? [];
const [active, setActive] = useState<Season>('전체');
const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
if (festivals.length === 0) return null;
const grouped = SEASONS.map((season) => ({
season,
items: festivals.filter((f) => seasonOf(f.month) === season),
})).filter((group) => group.items.length > 0);
const unknown = festivals.filter((f) => seasonOf(f.month) === null);
return (
<section
id="festival"
aria-labelledby="festival-heading"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<header className="mb-6 sm:mb-8">
<h2 id="festival-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'festival', '계절별 축제')}</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
{region}의 축제와 행사를 계절로 묶었습니다.
</p>
</header>
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="계절">
{[...grouped.map((g) => g.season), '전체' as const].map((season) => (
<button
key={season}
type="button"
role="tab"
aria-selected={season === active}
onClick={() => setActive(season)}
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
style={
season === active
? {
backgroundColor: 'var(--tpl-text, #09090b)',
color: 'var(--tpl-bg, #ffffff)',
borderColor: 'var(--tpl-text, #09090b)',
}
: undefined
}
>
{season}
</button>
))}
</div>
<div className="space-y-8">
{[...grouped, ...(unknown.length > 0 ? [{season: '그 밖에' as const, items: unknown}] : [])].map(
(group) => (
<div key={group.season} hidden={active !== '전체' && active !== group.season}>
<h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
{group.season}
</h3>
<ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 lg:gap-4">
{group.items.map((festival) => (
<li key={festival.name}>
<a
href={`https://search.naver.com/search.naver?query=${encodeURIComponent(festival.searchQuery)}`}
target="_blank"
rel="noopener noreferrer nofollow"
className="panel group flex h-full overflow-hidden transition-opacity hover:opacity-85 sm:flex-col"
>
{festival.imageUrl && (
<span className="relative block aspect-square w-28 shrink-0 overflow-hidden sm:aspect-4/3 sm:w-auto">
<img
src={festival.imageUrl}
alt={`${festival.name} 사진`}
loading="lazy"
decoding="async"
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<span
className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
style={{
backgroundColor:
'color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)',
color: 'var(--tpl-bg, #fff)',
}}
>
{festival.month}
</span>
</span>
)}
<span className="flex min-w-0 flex-1 flex-col gap-1 p-3 sm:p-3.5">
<span className="text-[length:var(--fs-sm)] font-bold">{festival.name}</span>
{/* ★ 기간은 있을 때만. 지난 날짜를 걸어두면 그것도 틀린 안내다. */}
{festival.period && (
<span className="text-[length:var(--fs-xs)] font-medium opacity-80">
{festival.period}
</span>
)}
{!festival.imageUrl && (
<span className="text-[length:var(--fs-xs)] font-bold opacity-60">
{festival.month}
</span>
)}
{festival.location && (
<span className="text-muted flex items-start gap-1 text-[length:var(--fs-xs)]">
<MapPin className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
<span>{festival.location}</span>
</span>
)}
{festival.description && (
<span className="text-muted line-clamp-2 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed sm:line-clamp-3">
{festival.description}
</span>
)}
<span className="text-muted mt-auto hidden items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100 sm:flex">
<span>검색으로 열기</span>
<ArrowUpRight className="size-3" aria-hidden="true" />
</span>
</span>
</a>
</li>
))}
</ul>
</div>
),
)}
</div>
</div>
</section>
);
}

View File

@ -1,38 +1,35 @@
import {useCallback, useEffect, useState} from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import {useRef, useState} from 'react';
import {ChevronLeft, ChevronRight, X} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {galleryImages} from '@/lib/derive';
import {galleryImages, sectionName} from '@/lib/derive';
/**
* 사진 갤러리.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="gallery">`)에서 그대로 옮겼다.
* ★ 좁은 화면은 가로로 미는 한 줄, `lg` 이상은 4열 격자다. 캐러셀 라이브러리를 쓰지 않는다 —
* `scroll-snap` 만으로 같은 동작이 나오고, 스크립트가 없어도 사진이 다 보인다.
* ★ 전 장을 HTML 에 편다. 접거나 지연 삽입하면 크롤러가 나머지를 못 읽는다.
*/
export function GallerySection() {
const payload = useSite();
const images = galleryImages(payload);
const setting = payload.theme.sections.find((section) => section.id === 'photos');
const variantId = setting?.variantId ?? 'photos.grid';
const railRef = useRef<HTMLUListElement>(null);
const [index, setIndex] = useState(0);
const [openIndex, setOpenIndex] = useState<number | null>(null);
/**
* ★ 슬라이드를 화면 폭보다 좁게 잡아 **양옆이 보이게** 한다.
* 100% 폭이면 정지 화면에서 사진 한 장과 구분이 안 된다 — 손님은 옆으로 더 있다는 걸
* 모르고 지나간다. 옆 사진이 살짝 걸쳐 보이는 것이 "밀어서 볼 수 있다"는 유일한 신호다.
*/
const [carouselRef, carouselApi] = useEmblaCarousel({loop: true, align: 'center', skipSnaps: false});
const [selected, setSelected] = useState(0);
// 화살표·드래그·키보드 어느 쪽으로 움직여도 인디케이터가 따라오게 한다.
useEffect(() => {
if (!carouselApi) return;
const sync = () => setSelected(carouselApi.selectedScrollSnap());
sync();
carouselApi.on('select', sync);
return () => {
carouselApi.off('select', sync);
};
}, [carouselApi]);
const scrollTo = useCallback((index: number) => carouselApi?.scrollTo(index), [carouselApi]);
if (images.length === 0) return null;
const onScroll = () => {
const rail = railRef.current;
if (!rail) return;
setIndex(Math.round(rail.scrollLeft / Math.max(1, rail.clientWidth)));
};
const move = (delta: number) => {
const rail = railRef.current;
if (!rail) return;
rail.scrollTo({left: (index + delta) * rail.clientWidth, behavior: 'smooth'});
};
const step = (delta: number) => {
setOpenIndex((current) =>
current === null ? null : (current + delta + images.length) % images.length,
@ -43,105 +40,91 @@ export function GallerySection() {
<section
id="gallery"
aria-labelledby="gallery-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider opacity-50">Gallery</p>
<h2 id="gallery-heading" className="serif mb-8 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{setting?.name || '공간 갤러리'}
</h2>
{/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */}
{variantId === 'photos.carousel' ? (
<div
className="relative mx-auto max-w-5xl"
role="group"
aria-roledescription="캐러셀"
aria-label={`사진 ${images.length}장`}
// 좌우 키로도 넘길 수 있어야 한다 — 마우스가 없는 사람에게 화살표 버튼만 남기지 않는다.
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') carouselApi?.scrollPrev();
if (event.key === 'ArrowRight') carouselApi?.scrollNext();
}}
>
{/* 양옆 사진이 걸쳐 보이도록 컨테이너 밖으로 넘치는 부분만 잘라낸다. */}
<div ref={carouselRef} className="overflow-hidden">
<ul className="-ml-3 flex touch-pan-y sm:-ml-4">
{images.map((image, index) => (
<li
key={image.mediaId}
className="min-w-0 flex-[0_0_88%] pl-3 sm:flex-[0_0_72%] sm:pl-4"
aria-roledescription="슬라이드"
aria-label={`${index + 1} / ${images.length}`}
>
<GalleryImage
image={image}
onOpen={() => setOpenIndex(index)}
// 지금 보고 있지 않은 사진은 눌러서 흐리게 — 어느 것이 현재인지 눈에 바로 들어온다.
className={`aspect-16/9 rounded-2xl transition-opacity duration-300 ${
index === selected ? 'opacity-100' : 'opacity-45'
}`}
/>
</li>
))}
</ul>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="gallery-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'photos', '사진 갤러리')}</span>
</h2>
</div>
</div>
</header>
</div>
{images.length > 1 && (
<>
<CarouselButton label="이전 사진" side="left" onClick={() => carouselApi?.scrollPrev()} />
<CarouselButton label="다음 사진" side="right" onClick={() => carouselApi?.scrollNext()} />
{/* 몇 장 중 몇 번째인지. 점만 있으면 사진이 많을 때 셀 수 없다. */}
<div className="mt-4 flex items-center justify-center gap-3">
<ul className="flex items-center gap-1.5">
{images.map((image, index) => (
<li key={image.mediaId}>
<button
type="button"
onClick={() => scrollTo(index)}
aria-label={`${index + 1}번째 사진으로`}
aria-current={index === selected}
className={`h-1.5 rounded-full transition-all ${
index === selected ? 'w-5 bg-black/70' : 'w-1.5 bg-black/20 hover:bg-black/40'
}`}
/>
</li>
))}
</ul>
<span className="text-xs tabular-nums opacity-50">
{selected + 1} / {images.length}
</span>
</div>
</>
)}
</div>
) : variantId === 'photos.masonry' ? (
<ul className="columns-2 gap-2 sm:columns-3 sm:gap-4 [&>li]:mb-2 sm:[&>li]:mb-4">
{images.map((image, index) => (
<li key={image.mediaId} className="break-inside-avoid">
<GalleryImage image={image} onOpen={() => setOpenIndex(index)} className="rounded-xl" free />
<div className="relative">
<ul
ref={railRef}
onScroll={onScroll}
className="flex snap-x snap-mandatory gap-2 overflow-x-auto [scrollbar-width:none] lg:grid lg:snap-none lg:grid-cols-4 lg:gap-3 lg:overflow-visible [&::-webkit-scrollbar]:hidden"
>
{images.map((image, i) => (
<li key={image.mediaId} className="w-full shrink-0 snap-center lg:w-auto">
<button
type="button"
onClick={() => setOpenIndex(i)}
className="border-line tpl-border group relative block aspect-4/3 w-full cursor-pointer overflow-hidden rounded-lg border"
aria-label={`${image.alt} 크게 보기`}
>
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
width={image.width}
height={image.height}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
</button>
</li>
))}
</ul>
) : (
<ul className="grid grid-cols-3 gap-2 sm:gap-4 md:grid-cols-4">
{images.map((image, index) => (
<li key={image.mediaId}>
<GalleryImage image={image} onOpen={() => setOpenIndex(index)} className="aspect-square rounded-xl" />
</li>
))}
</ul>
)}
{images.length > 1 && (
<>
<button
type="button"
aria-label="이전 사진"
aria-hidden={index === 0}
tabIndex={index === 0 ? -1 : 0}
onClick={() => move(-1)}
className={`absolute left-2 top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${
index === 0 ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="다음 사진"
aria-hidden={index >= images.length - 1}
tabIndex={index >= images.length - 1 ? -1 : 0}
onClick={() => move(1)}
className={`absolute right-2 top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${
index >= images.length - 1 ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<ChevronRight className="size-4" aria-hidden="true" />
</button>
<span className="pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/55 px-2.5 py-1 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white lg:hidden">
{index + 1} / {images.length}
</span>
</>
)}
</div>
</div>
{openIndex !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
role="dialog"
aria-modal="true"
aria-label="사진 크게 보기"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 backdrop-blur-md"
onClick={() => setOpenIndex(null)}
>
<button
@ -152,7 +135,6 @@ export function GallerySection() {
>
<X className="size-6" />
</button>
<button
type="button"
onClick={(event) => {
@ -164,7 +146,6 @@ export function GallerySection() {
>
<ChevronLeft className="size-6" />
</button>
<button
type="button"
onClick={(event) => {
@ -176,7 +157,6 @@ export function GallerySection() {
>
<ChevronRight className="size-6" />
</button>
<div
className="flex max-h-[85vh] max-w-4xl items-center justify-center"
onClick={(event) => event.stopPropagation()}
@ -192,43 +172,3 @@ export function GallerySection() {
</section>
);
}
function GalleryImage({image, onOpen, className, free = false}: {
image: ReturnType<typeof galleryImages>[number];
onOpen: () => void;
className: string;
free?: boolean;
}) {
return (
<button
type="button"
onClick={onOpen}
className={`group relative block w-full cursor-pointer overflow-hidden border border-black/8 ${className}`}
aria-label={`${image.alt} 크게 보기`}
>
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
width={image.width}
height={image.height}
className={free ? 'h-auto w-full' : 'size-full object-cover transition-transform duration-500 group-hover:scale-105'}
/>
</button>
);
}
function CarouselButton({label, side, onClick}: {label: string; side: 'left' | 'right'; onClick: () => void}) {
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/55 p-2.5 text-white backdrop-blur transition-colors hover:bg-black/75 ${side === 'left' ? 'left-3' : 'right-3'}`}
>
<Icon className="size-5" />
</button>
);
}

View File

@ -1,78 +1,102 @@
import {ArrowRight, MapPin} from 'lucide-react';
import {ChevronDown, MapPin} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {primaryImage} from '@/seo/meta';
import {unitSpec} from '@/lib/derive';
/**
* 첫 화면.
*
* ★ 가운데 정렬이 아니라 **좌하단**이다. 사진이 주인공인 화면에서 글자를 한가운데 얹으면
* 사진의 피사체를 정확히 가린다 — 실제로 그 이유로 좌하단으로 내렸다(/s/stay).
* ★ 색은 전부 템플릿 토큰이다. 여기에 zinc-950 같은 고정색을 쓰면 레트로(갱지)를 골라도
* 첫 화면만 검게 남는다.
* ★ 버튼을 두 개 세우지 않는다. 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다.
*/
export function HeroSection() {
const payload = useSite();
const {place, narrative} = payload;
const image = primaryImage(payload);
const spec = unitSpec(payload);
const region = place.addressLocality ?? place.addressRegion;
const subline = narrative.tagline ?? narrative.heroSubline;
// id="top" — 상단 로고와 하단 탭의 '홈' 이 여기로 돌아온다.
return (
<section
id="top"
className="relative flex min-h-[420px] w-full items-center justify-center overflow-hidden bg-zinc-950 md:min-h-[580px] md:h-[78vh]"
>
{image && (
<div className="absolute inset-0 z-0">
<img
src={image.url}
alt={image.alt}
// 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다.
fetchPriority="high"
decoding="async"
width={image.width}
height={image.height}
className="size-full object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/60 to-zinc-950/30" />
</div>
)}
<div className="shell relative z-10 flex flex-col items-center py-16 text-center text-white">
{(place.addressLocality ?? place.addressRegion) && (
<p className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs backdrop-blur-md sm:mb-6 sm:text-sm">
<MapPin className="size-3.5" />
<span>{place.addressLocality ?? place.addressRegion}</span>
</p>
<section id="top" className="w-full">
<div
className="relative flex w-full items-end overflow-hidden"
style={{
backgroundColor: 'var(--tpl-inverse, #1c1917)',
color: 'var(--tpl-bg, #ffffff)',
minHeight: 'clamp(24rem, 62vh, 36rem)',
}}
>
{image && (
<div className="absolute inset-0 z-0">
<img
src={image.url}
alt={image.alt}
// 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다.
fetchPriority="high"
decoding="async"
width={image.width}
height={image.height}
className="size-full object-cover object-center"
/>
{/* 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */}
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(to top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' +
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%, transparent 66%)',
}}
/>
</div>
)}
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
<h1 className="serif mb-4 text-2xl font-medium leading-tight tracking-tight sm:text-4xl md:text-5xl lg:text-6xl">
{narrative.heroHeadline ? (
<>
<span className="block">{narrative.heroHeadline}</span>
<span className="mt-1 block font-semibold">{place.name}</span>
</>
) : (
place.name
<div className="shell relative z-10 pb-10 pt-24 sm:pb-12">
{region && (
<p className="mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80">
<MapPin className="size-3.5" aria-hidden="true" />
<span>{region}</span>
</p>
)}
</h1>
{(narrative.tagline ?? narrative.heroSubline) && (
<p className="mb-8 max-w-2xl text-xs font-light leading-relaxed text-white/80 sm:text-base md:text-lg">
{narrative.tagline ?? narrative.heroSubline}
</p>
)}
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
<h1
className="serif"
style={{
fontSize: 'var(--fs-display)',
fontWeight: 'var(--tpl-heading-weight, 700)',
lineHeight: 1.15,
}}
>
{narrative.heroHeadline ? (
<>
<span className="block">{narrative.heroHeadline}</span>
<span className="mt-1 block">{place.name}</span>
</>
) : (
place.name
)}
</h1>
{subline && (
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85">
{subline}
</p>
)}
<div className="flex w-full flex-col items-center gap-3 sm:w-auto sm:flex-row">
{payload.units.length > 0 && (
<a
href="#units"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-xs font-medium text-zinc-950 transition-all hover:bg-white/90 sm:w-auto sm:text-sm"
className="mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] font-medium underline-offset-4 opacity-80 transition-opacity hover:opacity-100"
>
<span>{spec.label} 둘러보기</span>
<ArrowRight className="size-4" />
<span>{spec.label} 보기</span>
<ChevronDown className="size-4" aria-hidden="true" />
</a>
)}
<a
href="#info"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-white/30 bg-white/15 px-6 py-3 text-xs font-medium backdrop-blur-md transition-all hover:bg-white/25 sm:w-auto sm:text-sm"
>
<span>이용 정보 보기</span>
</a>
</div>
</div>
</section>

View File

@ -26,7 +26,7 @@ export function InquirySection() {
<section
id="inquiry"
aria-labelledby="inquiry-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="paper border-line w-full border-b"
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -34,8 +34,9 @@ export function InquirySection() {
<span>Contact</span>
</p>
<h2 id="inquiry-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'inquiry', '문의 안내')}
<h2 id="inquiry-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'inquiry', '문의 안내')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
궁금한 점은 아래로 연락 주시면 {place.name}에서 직접 안내해 드립니다.
@ -43,7 +44,7 @@ export function InquirySection() {
<ul
className="max-w-3xl divide-y divide-black/5 overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface-alt)'}}
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
{place.phone && (
<li>

View File

@ -1,145 +1,217 @@
import {ArrowUpRight, Calendar, MapPin, Utensils} from 'lucide-react';
import {useState} from 'react';
import {ArrowUpRight, MapPin, Utensils} from 'lucide-react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {formatKoreanDate, naverSearchUrl} from '@/lib/format';
import {sectionName} from '@/lib/derive';
/**
* 주변 정보.
* 주변 안내.
*
* "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다.
* ★ 마크업은 목업(/s/stay 의 `<section id="guide">`)에서 그대로 옮겼다.
* ★ "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다.
* ★ 우리가 남의 가게 URL 을 지어내지 않는다 — 검색 링크로만 보낸다.
* 틀린 링크는 사용자를 엉뚱한 데로 보내고, 그 책임은 이 홈페이지가 진다.
* ★ 거리 탭은 **좌표가 있을 때만** 뜬다. 거리 없이 "걸어서 5분"을 만들면 손님이 헛걸음한다.
*/
/** 분속 80m 로 환산한 도보 시간. 직선거리 기준이라 실제 길은 이보다 길다. */
const WALK_M_PER_MIN = 80;
function walkMinutes(place: LocalPlace): number | null {
return typeof place.distanceMeters === 'number'
? Math.max(1, Math.round(place.distanceMeters / WALK_M_PER_MIN))
: null;
}
function distanceLabel(place: LocalPlace): string | null {
const meters = place.distanceMeters;
if (typeof meters !== 'number') return place.distanceText ?? null;
return meters >= 1000 ? `${(meters / 1000).toFixed(1)}km` : `${Math.round(meters)}m`;
}
const BANDS = [
{key: 'all', label: '전체', test: () => true},
{key: 'w5', label: '걸어서 5분 이내', test: (m: number | null) => m !== null && m <= 5},
{key: 'w10', label: '걸어서 10분 이내', test: (m: number | null) => m !== null && m <= 10},
{key: 'far', label: '걸어서 10분 이상', test: (m: number | null) => m !== null && m > 10},
] as const;
function PlaceRail({items, label, on}: {items: LocalPlace[]; label: string; on: (p: LocalPlace) => boolean}) {
if (items.length === 0) return null;
return (
<div className="relative">
<div
className="flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="group"
aria-roledescription="캐러셀"
aria-label={label}
tabIndex={0}
>
{items.map((place) => {
const minutes = walkMinutes(place);
const distance = distanceLabel(place);
return (
<div
key={place.name}
hidden={!on(place)}
className="min-w-0 shrink-0 grow-0 basis-[76%] snap-start sm:basis-1/3 lg:basis-1/4"
aria-roledescription="슬라이드"
>
<a
href={naverSearchUrl(place.searchQuery)}
target="_blank"
rel="noopener noreferrer nofollow"
className="panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85"
>
{place.imageUrl && (
<span className="relative block aspect-4/3 overflow-hidden">
<img
src={place.imageUrl}
alt={`${place.name} 사진`}
loading="lazy"
decoding="async"
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
{minutes !== null && (
<span
className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
style={{
backgroundColor:
'color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)',
color: 'var(--tpl-bg, #fff)',
}}
>
도보 약 {minutes}분
{distance && <span className="ml-1 font-normal opacity-70">{distance}</span>}
</span>
)}
</span>
)}
<span className="flex flex-1 flex-col gap-1 p-3.5">
<span className="text-[length:var(--fs-sm)] font-bold">{place.name}</span>
{place.description && (
<span className="text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed">
{place.description}
</span>
)}
<span className="text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100">
<span>검색으로 열기</span>
<ArrowUpRight className="size-3" aria-hidden="true" />
</span>
</span>
</a>
</div>
);
})}
</div>
</div>
);
}
export function LocalGuideSection() {
const payload = useSite();
const {local} = payload;
const hasAnything =
local.restaurants.length > 0 || local.attractions.length > 0 || local.festivals.length > 0;
const [band, setBand] = useState<string>('all');
const hasAnything = local.restaurants.length > 0 || local.attractions.length > 0;
if (!hasAnything) return null;
const all = [...local.restaurants, ...local.attractions];
const hasDistance = all.some((place) => typeof place.distanceMeters === 'number');
const active = BANDS.find((b) => b.key === band) ?? BANDS[0];
// ★ 거르지 않고 **접는다**(hidden). 걸러 버리면 접힌 가게가 HTML 에서 사라져
// 검색·AI 가 못 읽는다 — 이 섹션을 둔 이유가 지역 고유명사를 본문에 두는 것이다.
const isOn = (place: LocalPlace) => band === 'all' || active.test(walkMinutes(place));
const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
const groups = [
{title: '주변 맛집', Icon: Utensils, items: local.restaurants},
{title: '주변 명소', Icon: MapPin, items: local.attractions},
].filter((group) => group.items.length > 0);
return (
<section
id="guide"
aria-labelledby="guide-heading"
className="w-full border-b border-black/8 py-12 sm:py-20"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell max-w-[880px]">
<div className="mb-8 flex flex-col justify-between gap-2 border-b border-black/8 pb-4 sm:flex-row sm:items-baseline">
<div>
<h2 id="guide-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl">
{'주변 안내'}
</h2>
<p className="mt-1 text-xs opacity-60 sm:text-sm">
{payload.place.addressLocality ?? '주변'} 지역의 맛집 · 명소 · 행사 안내입니다.
</p>
</div>
{local.syncedAt && (
<p className="self-start text-[11px] opacity-50 sm:self-auto">
{formatKoreanDate(local.syncedAt)} 갱신
</p>
)}
<div className="shell">
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="guide-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'local', '주변 안내')}</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
{region} 지역의 맛집 · 명소 안내입니다.
</p>
</div>
{local.syncedAt && (
<div className="shrink-0">
<p className="text-muted text-[length:var(--fs-xs)]">
{formatKoreanDate(local.syncedAt)} 갱신
</p>
</div>
)}
</div>
</header>
</div>
{local.restaurants.length > 0 && (
<PlaceList title="주변 맛집" icon={Utensils} places={local.restaurants} />
)}
{local.attractions.length > 0 && (
<PlaceList title="주변 명소" icon={MapPin} places={local.attractions} />
)}
{local.festivals.length > 0 && (
<div className="space-y-4">
<h3 className="serif flex items-center gap-2 text-base font-bold sm:text-lg">
<Calendar className="size-4 opacity-50" />
<span>지역 행사</span>
</h3>
<ul
className="divide-y divide-black/5 overflow-hidden rounded-xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
>
{local.festivals.map((festival) => (
<li key={festival.name} className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="rounded bg-black/5 px-1.5 py-0.5 text-[11px] font-bold">
{festival.month}
</span>
<h4 className="truncate text-xs font-bold sm:text-sm">{festival.name}</h4>
</div>
<p className="text-xs opacity-60">
{festival.description ?? festival.period}
</p>
</div>
<a
href={festival.officialUrl ?? naverSearchUrl(festival.searchQuery)}
target="_blank"
rel="noopener noreferrer nofollow"
className="shrink-0 rounded-lg border border-black/10 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-black/5"
>
자세히
</a>
</li>
))}
</ul>
{hasDistance && (
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="거리">
{BANDS.map((b) => {
const count =
b.key === 'all' ? all.length : all.filter((p) => b.test(walkMinutes(p))).length;
if (count === 0) return null;
const on = b.key === band;
return (
<button
key={b.key}
type="button"
role="tab"
aria-selected={on}
onClick={() => setBand(b.key)}
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
style={
on
? {
backgroundColor: 'var(--color-brand)',
color: 'var(--tpl-bg, #fff)',
borderColor: 'transparent',
}
: undefined
}
>
{b.label} <span className="tabular-nums opacity-70">{count}</span>
</button>
);
})}
</div>
)}
<div className="space-y-10">
{groups.map((group) => (
<div key={group.title}>
<h3 className="border-line mb-4 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
<group.Icon className="size-4" aria-hidden="true" />
<span>{group.title}</span>
</h3>
<PlaceRail items={group.items} label={group.title} on={isOn} />
</div>
))}
</div>
{hasDistance && (
<p className="text-muted measure mt-5 text-[length:var(--fs-xs)]">
도보 시간은 숙소에서 잰 직선거리를 분속 {WALK_M_PER_MIN}m 로 환산한 값입니다. 실제로 걷는
길은 이보다 깁니다.
</p>
)}
</div>
</section>
);
}
function PlaceList({
title,
icon: Icon,
places,
}: {
title: string;
icon: typeof MapPin;
places: LocalPlace[];
}) {
return (
<div className="mb-8 space-y-4">
<h3 className="serif flex items-center gap-2 text-base font-bold sm:text-lg">
<Icon className="size-4 opacity-50" />
<span>{title}</span>
</h3>
<ul
className="divide-y divide-black/5 overflow-hidden rounded-xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
>
{places.map((place) => (
<li key={place.name}>
<a
href={naverSearchUrl(place.searchQuery)}
target="_blank"
rel="noopener noreferrer nofollow"
className="group flex items-center justify-between gap-3 p-4 transition-colors hover:bg-black/[0.02]"
>
<div className="min-w-0 space-y-0.5 pr-3">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-bold sm:text-sm">{place.name}</span>
{(place.distanceText ?? place.durationText) && (
<span className="shrink-0 font-mono text-[10px] opacity-50">
{place.distanceText ?? place.durationText}
</span>
)}
</div>
{place.description && (
<p className="truncate text-xs opacity-60">{place.description}</p>
)}
</div>
<span className="flex shrink-0 items-center gap-0.5 text-xs opacity-40 transition-opacity group-hover:opacity-80">
<span>검색</span>
<ArrowUpRight className="size-3.5" />
</span>
</a>
</li>
))}
</ul>
</div>
);
}

View File

@ -1,8 +1,18 @@
import {useState} from 'react';
import {Car, Check, Copy, MapPin, Navigation} from 'lucide-react';
import {Car, Check, Copy, Navigation} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl} from '@/lib/format';
import {sectionName} from '@/lib/derive';
/**
* 오시는 길.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="location">`)에서 그대로 옮겼다.
* 지도 7 : 주소·길찾기 5 의 가로 배치다. 지도를 위에 눕히면 주소가 접힌 화면 밖으로 밀린다.
* ★ 길찾기 버튼 색은 각 서비스의 브랜드색을 그대로 쓴다(네이버 #03C75A · 카카오 #FEE500 ·
* 티맵 #0064FF). 템플릿 색으로 칠하면 어느 앱으로 가는지 손님이 못 알아본다.
* ★ 티맵은 앱 딥링크라 손가락 화면에서만 보인다(`only-touch`).
*/
export function LocationSection() {
const payload = useSite();
const {place, routes} = payload;
@ -15,6 +25,7 @@ export function LocationSection() {
// 버튼은 검색으로 떨어진다(format.ts) — 틀린 핀을 찍느니 안 찍는다.
const {latitude: lat, longitude: lng} = place;
const hasGeo = lat != null && lng != null;
const q = encodeURIComponent(place.name);
const handleCopy = async () => {
try {
@ -30,136 +41,120 @@ export function LocationSection() {
<section
id="location"
aria-labelledby="location-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<MapPin className="size-4" />
<span>Location</span>
</p>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="location-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'map', '오시는 길')}</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
주소와 주요 거점까지의 이동 시간을 안내합니다.
</p>
</div>
</div>
</header>
</div>
<h2 id="location-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{'오시는 길'}
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
주소와 주요 거점까지의 이동 시간을 안내합니다.
</p>
<div className="grid gap-6 lg:grid-cols-12 lg:gap-8">
{hasGeo && (
<div className="border-line tpl-border overflow-hidden rounded-xl border lg:col-span-7">
{/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를
남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */}
<iframe
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="block aspect-4/3 w-full border-0 sm:aspect-video lg:aspect-auto lg:h-full lg:min-h-[24rem]"
/>
</div>
)}
{hasGeo && (
<div className="mb-6 overflow-hidden rounded-2xl border border-black/8">
{/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를
남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */}
<iframe
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="block aspect-[16/9] w-full border-0"
/>
</div>
)}
<div
className="mb-8 rounded-2xl border border-black/8 p-5 sm:p-6"
style={{backgroundColor: 'var(--color-surface-alt)'}}
>
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<p className="text-[11px] font-bold uppercase tracking-wide opacity-50">주소</p>
<div className={hasGeo ? 'lg:col-span-5' : 'lg:col-span-12'}>
<div className="panel p-5 sm:p-6">
<p className="label mb-2">주소</p>
<div className="flex flex-wrap items-center gap-2">
{/* address 태그로 감싼다 — 기계가 "이게 이 업소의 주소"임을 안다. */}
<address className="text-base font-bold not-italic sm:text-lg">{address}</address>
<address className="text-[length:var(--fs-lead)] font-bold not-italic">{address}</address>
<button
type="button"
onClick={handleCopy}
title="주소 복사"
className="inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5"
style={{backgroundColor: 'var(--color-surface)'}}
className="border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/8 px-2.5 py-1.5 text-[length:var(--fs-xs)] font-medium transition-opacity hover:opacity-70"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span>{copied ? '복사완료' : '복사'}</span>
</button>
</div>
{place.phone && (
<p className="text-xs opacity-60">
문의: <a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">{place.phone}</a>
<p className="text-muted mt-2 text-[length:var(--fs-sm)]">
문의:{' '}
<a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">
{place.phone}
</a>
</p>
)}
</div>
<div className="flex items-center gap-2">
<a
href={naverDirectionsUrl(place.name, lat, lng)}
target="_blank"
rel="noopener noreferrer nofollow"
className="flex items-center gap-1.5 rounded-xl bg-emerald-600 px-4 py-2.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
>
<Navigation className="size-3.5" />
<span>네이버 길찾기</span>
</a>
<a
href={kakaoDirectionsUrl(place.name, lat, lng)}
target="_blank"
rel="noopener noreferrer nofollow"
className="flex items-center gap-1.5 rounded-xl bg-amber-400 px-4 py-2.5 text-xs font-semibold text-zinc-950 transition-opacity hover:opacity-90"
>
<Navigation className="size-3.5" />
<span>카카오 길찾기</span>
</a>
</div>
</div>
</div>
{routes.length > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold sm:text-base">
<Car className="size-4 opacity-60" />
<span>주요 거점 소요시간</span>
</h3>
<div
className="overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs sm:text-sm">
<caption className="sr-only">
{payload.place.name}에서 주요 거점까지의 이동 시간
</caption>
<thead
className="border-b border-black/8 font-semibold"
style={{backgroundColor: 'var(--color-surface-alt)'}}
<div className="mt-5 flex flex-wrap gap-2.5">
<a
href={naverDirectionsUrl(place.name, lat, lng)}
target="_blank"
rel="noopener noreferrer nofollow"
className="tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#03C75A] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90"
>
<Navigation className="size-3.5" aria-hidden="true" />
<span>네이버 길찾기</span>
</a>
<a
href={kakaoDirectionsUrl(place.name, lat, lng)}
target="_blank"
rel="noopener noreferrer nofollow"
className="tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#FEE500] px-4 text-[length:var(--fs-sm)] font-bold text-[#191600] transition-opacity hover:opacity-90"
>
<Navigation className="size-3.5" aria-hidden="true" />
<span>카카오 길찾기</span>
</a>
{hasGeo && (
<a
href={`tmap://route?goalname=${q}&goalx=${lng}&goaly=${lat}&rGoName=${q}&rGoX=${lng}&rGoY=${lat}`}
className="tap only-touch flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#0064FF] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90"
>
<tr>
<th scope="col" className="p-3.5 sm:p-4">목적지</th>
<th scope="col" className="p-3.5 sm:p-4">차량</th>
<th scope="col" className="p-3.5 sm:p-4">도보 / 대중교통</th>
<th scope="col" className="hidden p-3.5 sm:table-cell sm:p-4">거리</th>
<th scope="col" className="hidden p-3.5 md:table-cell sm:p-4">비고</th>
</tr>
</thead>
<tbody className="divide-y divide-black/5">
{routes.map((route) => (
<tr key={route.destination}>
<th scope="row" className="p-3.5 text-left font-bold sm:p-4">
{route.destination}
</th>
<td className="p-3.5 font-semibold sm:p-4">{route.byCar ?? '—'}</td>
<td className="p-3.5 opacity-70 sm:p-4">{route.byTransit ?? '—'}</td>
<td className="hidden p-3.5 font-mono opacity-50 sm:table-cell sm:p-4">
{route.distanceText ?? '—'}
</td>
<td className="hidden p-3.5 text-xs opacity-50 md:table-cell sm:p-4">
{route.note ?? ''}
</td>
</tr>
))}
</tbody>
</table>
<Navigation className="size-3.5" aria-hidden="true" />
<span>티맵 길찾기</span>
</a>
)}
</div>
</div>
{routes.length > 0 && (
<div className="mt-6">
<h3 className="mb-3 flex items-center gap-2 text-[length:var(--fs-sm)] font-bold">
<Car className="size-4 opacity-60" aria-hidden="true" />
<span>주요 거점 소요시간</span>
</h3>
<dl className="divide-line border-line divide-y border-t">
{routes.map((route) => (
<div
key={route.destination}
className="flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]"
>
<dt className="text-muted shrink-0">{route.destination}</dt>
<dd className="text-right font-semibold">
{[route.byCar, route.byTransit].filter(Boolean).join(' · ') || '—'}
</dd>
</div>
))}
</dl>
</div>
)}
</div>
)}
</div>
</div>
</section>
);

View File

@ -26,7 +26,7 @@ export function RulesSection() {
<section
id="rules"
aria-labelledby="rules-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="paper border-line w-full border-b"
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -34,8 +34,9 @@ export function RulesSection() {
<span>House Rules</span>
</p>
<h2 id="rules-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'rules', '이용 규정')}
<h2 id="rules-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'rules', '이용 규정')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
예약 전에 꼭 확인해 주세요. 아래 내용은 모두 사업자가 확인한 규정입니다.
@ -47,7 +48,7 @@ export function RulesSection() {
<div
key={row.label}
className="rounded-2xl border border-black/8 p-4 sm:p-5"
style={{backgroundColor: 'var(--color-surface-alt)'}}
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
<dt className="mb-1 text-xs font-bold sm:text-sm">{row.label}</dt>
<dd className="text-xs leading-relaxed opacity-80 sm:text-sm">{row.value}</dd>

View File

@ -0,0 +1,68 @@
/**
* 섹션 한 벌 — 바탕·여백·머리말.
*
* ★ 왜 공통으로 빼나
* 섹션마다 `py-24`, `text-2xl`, `font-bold` 를 손으로 적고 있었다. 그래서 템플릿이
* `sectionSpace: 4rem` 이나 `headingWeight: 400`(간판체)을 줘도 화면은 그대로였다 —
* 사장님이 레트로를 골라도 제목만 가짜 볼드로 굵어졌고, 여백은 언제나 96px 이었다.
* 여기 한 곳에서 토큰을 읽으면 템플릿이 실제로 화면을 바꾼다.
*
* ★ 제목 앞 세로 막대는 장식이 아니라 위계다. 같은 크기의 글자가 페이지에 여럿일 때
* "여기서 새 섹션이 시작한다"를 알려주는 유일한 표시다.
*/
import type {ReactNode} from 'react';
export function SectionHeading({id, name, subtitle, bar = true, right}: {
id: string;
name: string;
subtitle?: string;
/** 제목 앞 세로 막대. 아이템 섹션처럼 자체 머리말이 있는 곳은 끈다. */
bar?: boolean;
/** 제목 오른쪽에 붙는 것(갱신일·바깥 링크). */
right?: ReactNode;
}) {
return (
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id={`${id}-heading`} className={bar ? 'h2 flex items-center gap-2.5' : 'h2'}>
{bar && <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />}
<span className="min-w-0">{name}</span>
</h2>
{subtitle && (
<p
className={`text-muted measure mt-2 text-[length:var(--fs-sm)]${bar ? ' pl-[calc(3px+0.625rem)]' : ''}`}
>
{subtitle}
</p>
)}
</div>
{right}
</div>
</header>
);
}
export function SectionShell({id, children, tone = 'base', border = true, className}: {
id: string;
children: ReactNode;
/** base = 페이지 바탕, alt = 한 단계 다른 면(섹션이 이어질 때 경계를 만든다). */
tone?: 'base' | 'alt';
border?: boolean;
className?: string;
}) {
return (
<section
id={id}
aria-labelledby={`${id}-heading`}
className={`paper w-full${border ? ' border-line border-b' : ''}${className ? ` ${className}` : ''}`}
style={{
backgroundColor: tone === 'alt' ? 'var(--tpl-surface-alt, #fafafa)' : 'var(--tpl-surface, #ffffff)',
color: 'var(--tpl-text, #09090b)',
paddingBlock: 'var(--section-space)',
}}
>
<div className="shell">{children}</div>
</section>
);
}

View File

@ -28,7 +28,7 @@ export function SpaceSection() {
<section
id="space"
aria-labelledby="space-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="paper border-line w-full border-b"
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -36,8 +36,9 @@ export function SpaceSection() {
<span>Space</span>
</p>
<h2 id="space-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'space', '공간 안내')}
<h2 id="space-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'space', '공간 안내')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
좌석과 이용 환경 안내입니다.
@ -48,7 +49,7 @@ export function SpaceSection() {
<div
key={row.label}
className="rounded-2xl border border-black/8 p-4 sm:p-5"
style={{backgroundColor: 'var(--color-surface-alt)'}}
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
<dt className="mb-1 text-xs font-medium opacity-60">{row.label}</dt>
<dd className="text-sm font-bold sm:text-base">{row.value}</dd>

View File

@ -0,0 +1,101 @@
/**
* 지역 이야기 — 아이템 섹션들을 탭 하나로 묶는 머리.
*
* ★ 왜 묶나 — 가요·인물·연표·엽서·퀴즈가 각자 큰 섹션으로 서면 페이지가 다섯 배로 길어지고,
* 손님은 객실 정보에 닿기 전에 스크롤을 포기한다. 이야기는 '읽고 싶은 사람만' 여는 자리다.
* ★ **탭을 눌러 접는 것은 화면뿐이다.** 각 아이템 섹션은 그대로 HTML 에 다 구워진다 —
* 이 사이트의 존재 이유가 인용이라, 접힌 쪽이 HTML 에 없으면 만든 의미가 없다.
*/
import {useState} from 'react';
import type {ReactElement} from 'react';
import {useSite} from '@/lib/site-context';
import {isSectionEnabled, sectionItems, sectionName} from '@/lib/derive';
import {ITEM_SECTIONS} from './items';
/** 이야기 탭에 들어가는 아이템. 순서가 곧 탭 순서다. */
const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily'] as const;
const FALLBACK_LABEL: Record<string, string> = {
songs: '가요 다방',
people: '인물 열전',
chronicle: '시간의 골목',
literature: '문학 산책',
postcard: '오늘의 엽서',
quiz: '뒤집어 보는 질문',
daily: '오늘의 일력',
};
export function StorySection(): ReactElement | null {
const payload = useSite();
const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
// 켜져 있고 실제로 항목이 있는 것만 탭이 된다 — 빈 탭을 누르게 하지 않는다.
const tabs = STORY_KINDS.filter(
(kind) => isSectionEnabled(payload, kind) && sectionItems(payload, kind).items.length > 0,
);
const [active, setActive] = useState<string>(tabs[0] ?? '');
if (tabs.length === 0) return null;
return (
<>
<section
id="story"
aria-labelledby="story-heading"
className="paper w-full"
style={{
backgroundColor: 'var(--tpl-surface, #fafafa)',
color: 'var(--tpl-text, #09090b)',
paddingTop: 'var(--section-space)',
paddingBottom: 'calc(var(--section-space) * 0.55)',
}}
>
<div className="shell">
<h2 id="story-heading" className="h2">
{region} 이야기
</h2>
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70">
이 도시를 {tabs.length} 갈래로 봅니다. 하나씩 골라 보세요.
</p>
<div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label={`${region} 이야기`}>
{tabs.map((kind) => {
const on = kind === active;
return (
<button
key={kind}
type="button"
role="tab"
aria-selected={on}
aria-controls={kind}
onClick={() => setActive(kind)}
className="rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
style={
on
? {
backgroundColor: 'var(--tpl-text, #09090b)',
color: 'var(--tpl-bg, #ffffff)',
borderColor: 'var(--tpl-text, #09090b)',
}
: {borderColor: 'var(--tpl-border, #d6d3d1)'}
}
>
{sectionName(payload, kind, FALLBACK_LABEL[kind] ?? kind)}
</button>
);
})}
</div>
</div>
</section>
{tabs.map((kind) => {
const Component = ITEM_SECTIONS[kind];
if (!Component) return null;
return (
<div key={kind} hidden={kind !== active}>
<Component />
</div>
);
})}
</>
);
}

View File

@ -1,18 +1,92 @@
import {ChevronDown, Phone} from 'lucide-react';
import {useRef, useState} from 'react';
import {ChevronDown, ChevronLeft, ChevronRight} from 'lucide-react';
import type {MediaItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionName, unitSpec, unitViews} from '@/lib/derive';
/**
* 객실 · 메뉴 · 프로그램 — 업종에 따라 이름만 달라진다.
*
* ★ 예전에는 카드가 상세 **페이지**로 넘어갔다. 사이트를 한 장으로 합치면서
* 그 페이지의 내용(이용 정보 표 · 사진 전체 · 전화 문의)을 카드 안으로 들여왔다.
* 빼고 합쳤으면 확인된 fact 가 화면에서 사라지고, JSON-LD 에는 남아 있어
* 발행 게이트(화면 ↔ 구조화 데이터 대조)에 걸린다.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="units">`)에서 그대로 옮겼다.
* ★ 사진은 카드 안에서 가로로 민다. 격자로 늘어놓으면 카드가 세로로 길어져
* 객실 두 개가 한 화면에 안 들어온다.
* ★ 펼침은 <details> 다. 자바스크립트 없이 동작하고, 크롤러는 접힌 내용도 읽는다 —
* 조건부 렌더로 감추면 HTML 에 아예 없어서 AI 가 못 본다.
*/
function UnitPhotos({images, label}: {images: MediaItem[]; label: string}) {
const railRef = useRef<HTMLUListElement>(null);
const [index, setIndex] = useState(0);
// ★ 스크롤 위치로 현재 장을 읽는다. 상태로만 밀면 손가락으로 민 것과 어긋난다.
const onScroll = () => {
const rail = railRef.current;
if (!rail) return;
setIndex(Math.round(rail.scrollLeft / Math.max(1, rail.clientWidth)));
};
const move = (delta: number) => {
const rail = railRef.current;
if (!rail) return;
rail.scrollTo({left: (index + delta) * rail.clientWidth, behavior: 'smooth'});
};
return (
<div className="relative">
<ul
ref={railRef}
onScroll={onScroll}
aria-label={`${label} 사진`}
className="flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((image) => (
<li key={image.mediaId} className="w-full shrink-0 snap-center">
<div className="relative aspect-16/10 overflow-hidden">
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
</div>
</li>
))}
</ul>
{images.length > 1 && (
<>
<button
type="button"
aria-label="이전 사진"
aria-hidden={index === 0}
tabIndex={index === 0 ? -1 : 0}
onClick={() => move(-1)}
className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 left-2 ${
index === 0 ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="다음 사진"
aria-hidden={index >= images.length - 1}
tabIndex={index >= images.length - 1 ? -1 : 0}
onClick={() => move(1)}
className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 right-2 ${
index >= images.length - 1 ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<ChevronRight className="size-4" aria-hidden="true" />
</button>
<span className="pointer-events-none absolute bottom-2.5 right-2.5 rounded-full bg-black/55 px-2 py-0.5 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white">
{index + 1} / {images.length}
</span>
</>
)}
</div>
);
}
export function UnitsSection() {
const payload = useSite();
const spec = unitSpec(payload);
@ -24,130 +98,75 @@ export function UnitsSection() {
<section
id="units"
aria-labelledby="units-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<div className="mb-10 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider opacity-50">
{spec.label}
</p>
{/* 섹션 id 는 업종마다 다르다(rooms · menu · programs) — spec.path 가 그 id 다.
사장님이 이름을 안 바꿨으면 지금까지 쓰던 "객실 3개 안내" 꼴로 떨어진다. */}
<h2 id="units-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, spec.path, `${spec.label} ${units.length}개 안내`)}
</h2>
</div>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="units-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, spec.path, `${spec.label} 안내`)}</span>
</h2>
</div>
</div>
</header>
</div>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:gap-8">
{/* ★ items-start — 사진 없는 객실 카드가 옆 카드 높이를 따라가면 아래가 빈 칸으로 남는다.
목업은 전 객실에 사진이 있어 이 문제가 없었지만, 공공 API 사진은 일부 객실에만 붙는다. */}
<ul className="grid grid-cols-1 items-start gap-5 sm:grid-cols-2 lg:gap-7">
{units.map((unit) => (
<li
key={unit.unitId}
className="flex flex-col justify-between overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface-alt)'}}
>
<div>
{unit.images[0] && (
<div className="relative aspect-16/10 overflow-hidden sm:aspect-16/9">
<img
src={unit.images[0].url}
alt={unit.images[0].alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
{unit.priceText && (
<span
className="absolute bottom-3 right-3 rounded-lg px-2.5 py-1 text-xs font-bold"
style={{
backgroundColor: 'var(--color-surface)',
color: 'var(--color-ink)',
}}
>
{unit.priceText}
</span>
)}
</div>
)}
<li key={unit.unitId} className="panel flex flex-col overflow-hidden">
<div className="flex flex-1 flex-col">
{unit.images.length > 0 && <UnitPhotos images={unit.images} label={unit.name} />}
<div className="space-y-3 p-4 sm:space-y-4 sm:p-6">
<h3 className="serif text-base font-bold leading-snug sm:text-xl">
{unit.name}
</h3>
<div className="flex flex-1 flex-col gap-3 p-5">
<h3 className="h3">{unit.name}</h3>
{unit.chips.length > 0 && (
<ul className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<ul className="flex flex-wrap items-center gap-1.5">
{unit.chips.map((chip) => (
<li
key={chip.label}
className="rounded-lg border border-black/8 px-2.5 py-1 text-xs"
style={{backgroundColor: 'var(--color-surface)'}}
>
<span className="opacity-50">{chip.label} </span>
<span className="font-medium">{chip.value}</span>
<li key={chip.label}>
<span className="border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/5 px-2.5 py-1 text-[length:var(--fs-xs)]">
<span className="text-muted">{chip.label}</span>
<span className="font-medium">{chip.value}</span>
</span>
</li>
))}
</ul>
)}
{unit.intro && (
<p className="text-xs leading-relaxed opacity-70">{unit.intro}</p>
<p className="text-[length:var(--fs-sm)] leading-relaxed opacity-75">{unit.intro}</p>
)}
</div>
</div>
{(unit.rows.length > 0 || unit.images.length > 1) && (
<details className="group border-t border-black/8">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 p-4 text-xs font-semibold sm:p-6">
{unit.rows.length > 0 && (
<details className="group border-line border-t">
<summary className="tap flex cursor-pointer list-none items-center justify-between gap-2 px-5 text-[length:var(--fs-sm)] font-semibold">
<span>{unit.name} 자세히</span>
<ChevronDown className="size-4 transition-transform group-open:rotate-180" />
<ChevronDown
className="size-4 transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</summary>
<div className="space-y-5 px-4 pb-4 sm:px-6 sm:pb-6">
{unit.rows.length > 0 && (
<dl className="divide-y divide-black/5 border-t border-black/8">
{unit.rows.map((row) => (
<div
key={row.label}
className="flex items-start justify-between gap-4 py-2 text-xs"
>
<dt className="shrink-0 opacity-55">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
{unit.images.length > 1 && (
<ul className="grid grid-cols-2 gap-2">
{unit.images.slice(1).map((image) => (
<li
key={image.mediaId}
className="relative aspect-16/10 overflow-hidden rounded-xl"
>
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
</li>
))}
</ul>
)}
{payload.place.phone && (
<a
href={`tel:${payload.place.phone}`}
className="flex w-full items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)'}}
>
<Phone className="size-3.5" />
<span>{payload.place.phone} 문의</span>
</a>
)}
<div className="space-y-5 px-5 pb-5">
<dl className="divide-line border-line divide-y border-t">
{unit.rows.map((row) => (
<div
key={row.label}
className="flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]"
>
<dt className="text-muted shrink-0">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div>
))}
</dl>
</div>
</details>
)}

View File

@ -1,8 +1,25 @@
import {Cloud} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {useLiveWeather} from '@/lib/use-live-weather';
import {sectionName} from '@/lib/derive';
/**
* 오늘의 날씨 — 프리렌더 스냅샷으로 시작해 하이드레이션 후 최신값으로 갱신된다.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="weather">`)에서 그대로 옮겼다.
* ★ 기온은 히어로 제목과 같은 크기(`--fs-display`)로 크게 둔다. 이 섹션에서 손님이
* 찾는 값은 숫자 하나뿐이라, 그 숫자가 작으면 섹션을 둔 의미가 없다.
* ★ 정적 페이지는 몇 달 산다 — 관측 시각을 반드시 함께 적는다. 시각 없는 기온은
* 언제 잰 것인지 알 수 없어 그 자체가 틀린 정보가 된다.
*/
function observedLabel(iso?: string): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return `${d.getMonth() + 1}월 ${d.getDate()}일 ${String(d.getHours()).padStart(2, '0')}:${String(
d.getMinutes(),
).padStart(2, '0')} 관측`;
}
/** 프리렌더 스냅샷으로 시작해 하이드레이션 후 최신 캐시로 갱신되는 독립 날씨 섹션. */
export function WeatherSection() {
const payload = useSite();
const weather = useLiveWeather({
@ -13,33 +30,54 @@ export function WeatherSection() {
});
if (!weather) return null;
const observed = observedLabel(weather.observedAt);
return (
<section
id="weather"
aria-labelledby="weather-heading"
className="w-full border-b border-black/8 py-10 sm:py-14"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell max-w-[880px]">
<h2 id="weather-heading" className="serif mb-4 text-xl font-bold tracking-tight sm:text-2xl">
오늘의 날씨
</h2>
<div
className="rounded-xl border border-black/8 p-5 sm:p-6"
style={{backgroundColor: 'var(--color-surface)'}}
>
<div className="flex items-center gap-3">
<Cloud className="size-6 opacity-50" aria-hidden="true" />
<div className="flex items-baseline gap-2">
<span className="text-2xl font-extrabold sm:text-3xl">{weather.temperature}°C</span>
<span className="text-xs font-semibold opacity-70">{weather.condition}</span>
{weather.stale && <span className="text-[10px] text-amber-700">최근 관측값</span>}
<div className="shell">
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="weather-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'weather', '오늘의 날씨')}</span>
</h2>
</div>
</div>
</header>
</div>
<div className="panel relative overflow-hidden">
{/* 하늘 — 날씨에 따라 결이 달라진다. 색을 박지 않고 토큰에서 유도한다. */}
<div className="w4-sky" data-mood={weather.condition} aria-hidden="true" />
<div className="relative flex flex-col gap-5 p-6 sm:p-8">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<p className="flex items-baseline gap-2.5">
<span
className="serif tabular-nums leading-none"
style={{fontSize: 'var(--fs-display)'}}
>
{weather.temperature}
</span>
<span className="text-[length:var(--fs-lead)] opacity-70">°C</span>
<span className="text-[length:var(--fs-lead)] font-semibold">{weather.condition}</span>
</p>
{observed && (
<span className="text-muted text-[length:var(--fs-xs)] tabular-nums">{observed}</span>
)}
</div>
{weather.note && (
<p className="measure serif text-[length:var(--fs-lead)] leading-loose opacity-85">
{weather.note}
</p>
)}
</div>
{weather.note && (
<p className="mt-2 text-xs leading-relaxed opacity-70 sm:text-sm">{weather.note}</p>
)}
</div>
</div>
</section>

View File

@ -10,6 +10,8 @@ export {SpaceSection} from './SpaceSection';
export {InquirySection} from './InquirySection';
export {ExhibitionSection} from './ExhibitionSection';
export {LocalGuideSection} from './LocalGuideSection';
export {FestivalSection} from './FestivalSection';
export {StorySection} from './StorySection';
export {WeatherSection} from './WeatherSection';
export {GallerySection} from './GallerySection';
export {LocationSection} from './LocationSection';

View File

@ -24,7 +24,7 @@ export function ChronicleSection() {
<ItemSection
id="chronicle"
name={parsed.title || sectionName(payload, 'chronicle', '시간의 골목')}
subtitle={parsed.subtitle}
subtitle={parsed.subtitle ?? '이 도시를 만든 해들'}
count={`붉은 점은 도시의 성격이 바뀐 해입니다 · ${turning}개 / 전체 ${items.length}개`}
>
<Rail label="연표">
@ -51,11 +51,23 @@ export function ChronicleSection() {
}}
/>
</div>
<div className="space-y-1.5 pr-5">
<h3 className="serif text-base font-bold">{item.title}</h3>
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
<div className="flex gap-2.5 pr-5">
{item.imageUrl && (
<img
src={item.imageUrl}
alt={`${item.title} 사진`}
loading="lazy"
decoding="async"
className="size-14 shrink-0 border object-cover"
style={{borderColor: ITEM_BORDER}}
/>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<h3 className="serif text-base font-bold">{item.title}</h3>
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</article>
);

View File

@ -0,0 +1,54 @@
/**
* 소식 — 사장님이 쓰는 공지·안내.
*
* ★ 지역 리서치 아이템과 성격이 다르다. 여기 글의 주인은 사장님이고 출처도 사장님이다 —
* 그래서 source 를 요구하지 않는다(SourceLine 대신 verified 배지만).
*/
import type {EventItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, tally} from './common';
export function EventSection() {
const payload = useSite();
const parsed = sectionItems<EventItem>(payload, 'event');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="event"
name={parsed.title || sectionName(payload, 'event', '소식')}
subtitle={parsed.subtitle ?? '미리 알아 두시면 좋은 것들'}
count={tally(parsed.items.length, parsed.unverified, '건')}
>
<ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{parsed.items.map((notice, index) => (
<li
key={`${notice.title}-${index}`}
className="w4-paper flex flex-col border-2 p-5"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
{notice.kind && (
<span
className="mb-2 self-start border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
{notice.kind}
</span>
)}
<h3 className="serif text-[length:var(--fs-lead)] font-bold leading-snug">{notice.title}</h3>
{notice.summary && (
<p className="measure mt-2 text-[length:var(--fs-sm)] opacity-80">{notice.summary}</p>
)}
{notice.body && (
// 줄바꿈이 항목 구분이다. 사장님이 쓴 그대로 둔다.
<p className="mt-3 whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-75">
{notice.body}
</p>
)}
</li>
))}
</ul>
</ItemSection>
);
}

View File

@ -0,0 +1,210 @@
/**
* 추천 일정 — 며칠 묵느냐로 갈린다.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="itinerary">`)에서 그대로 옮겼다.
* 기간 탭 → 일정마다 날짜 카드를 가로로 밀고, 카드 안에 지도와 타임라인이 함께 있다.
* ★ 시각은 **계산한다.** 출발 시각과 머무는 분을 더해 나가므로, 출발을 당기면 하루가
* 통째로 밀린다. 모델이 시각을 적어 내면 합이 안 맞는 표가 나온다.
* ★ 전 일정을 HTML 에 편다. 탭은 화면에서만 접는다 — 접힌 쪽이 HTML 에 없으면
* 검색·AI 가 나머지 일정을 못 읽는다.
*/
import {useState} from 'react';
import type {ItineraryDay, ItineraryItem, ItineraryStop} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine, tally} from './common';
import {TripMap, type TripPoint} from './TripMap';
/** 'HH:MM' → 분. 형식이 아니면 null(시각을 지어내지 않는다). */
function toMinutes(time?: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec((time ?? '').trim());
if (!m) return null;
return Number(m[1]) * 60 + Number(m[2]);
}
/** '3시간 40분' — 첫 칸 시작부터 마지막 칸 끝까지. */
function spanLabel(rows: {from?: string; to?: string}[]): string | null {
const from = toMinutes(rows[0]?.from);
const to = toMinutes(rows[rows.length - 1]?.to);
if (from === null || to === null || to <= from) return null;
const total = to - from;
const h = Math.floor(total / 60);
const m = total % 60;
return [h ? `${h}시간` : '', m ? `${m}분` : ''].filter(Boolean).join(' ');
}
function toClock(minutes: number): string {
const h = Math.floor(minutes / 60) % 24;
return `${String(h).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
}
/** 정거장마다 시작·끝 시각을 계산한다. 출발 시각이 없으면 시각을 아예 안 그린다. */
function schedule(day: ItineraryDay): {stop: ItineraryStop; from?: string; to?: string}[] {
let cursor = toMinutes(day.startTime);
return (day.stops ?? []).map((stop) => {
if (cursor === null) return {stop};
const from = cursor;
const to = from + (stop.minutes ?? 0);
cursor = to;
return {stop, from: toClock(from), to: toClock(to)};
});
}
export function ItinerarySection() {
const payload = useSite();
const parsed = sectionItems<ItineraryItem>(payload, 'itinerary');
const durations = Array.from(
new Set(parsed.items.map((item) => item.duration?.trim()).filter(Boolean) as string[]),
);
const [active, setActive] = useState(durations[0] ?? '');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="itinerary"
name={parsed.title || sectionName(payload, 'itinerary', '추천 일정')}
subtitle={parsed.subtitle ?? '며칠 묵느냐에 따라 다르게 돕니다'}
count={`${tally(parsed.items.length, parsed.unverified, '개')} · 시각은 출발 시각과 머무는 시간으로 계산한 것입니다`}
>
{durations.length > 1 && (
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="묵는 기간">
{durations.map((duration) => (
<button
key={duration}
type="button"
role="tab"
aria-selected={duration === active}
onClick={() => setActive(duration)}
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
style={
duration === active
? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)', borderColor: 'transparent'}
: undefined
}
>
{duration}
<span className="ml-1.5 font-normal opacity-60">
{parsed.items.filter((i) => i.duration === duration).length}
</span>
</button>
))}
</div>
)}
<div className="space-y-10">
{parsed.items.map((plan, planIndex) => (
<div
key={`${plan.name}-${planIndex}`}
hidden={durations.length > 1 && plan.duration !== active}
className="space-y-3"
>
<div className="space-y-1">
<h3 className="serif text-lg font-bold">{plan.name}</h3>
{plan.audience && <p className="text-[13px] opacity-60">{plan.audience}</p>}
</div>
<div className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{(plan.days ?? []).map((day, dayIndex) => {
const rows = schedule(day);
const last = rows[rows.length - 1];
const points: TripPoint[] = (day.stops ?? [])
.filter((s): s is ItineraryStop & {latitude: number; longitude: number} =>
typeof s.latitude === 'number' && typeof s.longitude === 'number')
.map((s) => ({name: s.name, latitude: s.latitude, longitude: s.longitude, searchQuery: s.searchQuery}));
return (
<article
key={`${day.label}-${dayIndex}`}
className="w4-paper w-[320px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={{
backgroundColor: 'var(--tpl-text, #09090b)',
color: 'var(--tpl-bg, #ffffff)',
borderColor: 'var(--tpl-text, #09090b)',
}}
>
{day.label}
</span>
{rows[0]?.from && last?.to && (
<span className="text-[11px] opacity-60">
{rows[0].from}–{last.to}
{spanLabel(rows) && ` · ${spanLabel(rows)}`}
</span>
)}
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="serif text-lg font-bold">{plan.name}</h4>
{plan.why && (
<p className="text-[13px] leading-relaxed opacity-80">{plan.why}</p>
)}
</div>
{/* 지도는 카드 폭을 꽉 채운다 — 여백을 두면 접힌 카드에서 지도가 잘려 보인다. */}
{points.length > 0 && <TripMap points={points} />}
<ol className="mt-3 px-4 pb-3">
{rows.map(({stop, from, to}, stopIndex) => (
<li
key={`${stop.name}-${stopIndex}`}
className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5"
>
<span className="serif pt-2 text-[12px] tabular-nums opacity-75">{from ?? ''}</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
<div className="flex items-start gap-2.5 pt-1">
{stop.imageUrl && (
<img
src={stop.imageUrl}
alt={`${stop.name} 사진`}
loading="lazy"
decoding="async"
className="size-12 shrink-0 object-cover"
style={{border: `1px solid ${ITEM_BORDER}`}}
/>
)}
<span className="min-w-0">
<span className="block text-sm font-bold">
<span
className="mr-1.5 inline-grid size-[17px] translate-y-px place-items-center rounded-full text-[10px] tabular-nums"
style={{backgroundColor: ITEM_ACCENT, color: 'var(--tpl-bg, #ffffff)'}}
>
{stopIndex + 1}
</span>
{stop.name}
</span>
{stop.note && (
<span className="mt-0.5 block text-[12px] leading-relaxed opacity-75">
{stop.note}
</span>
)}
</span>
</div>
<p className="mt-0.5 text-[10px] opacity-50">
{from && to ? `${from}–${to}` : ''}
{stop.searchQuery && ` · 지도 검색 ${stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
<div className="border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={plan.source} verified={plan.verified} />
</div>
</article>
);
})}
</div>
</div>
))}
</div>
</ItemSection>
);
}

View File

@ -16,27 +16,45 @@ export function PeopleSection() {
<ItemSection
id="people"
name={parsed.title || sectionName(payload, 'people', '인물 열전')}
subtitle={parsed.subtitle}
subtitle={parsed.subtitle ?? '이 도시가 배출한 이름들'}
count={`${tally(parsed.items.length, parsed.unverified, '명')} · 사진이 없는 인물은 이름 활자로 대신합니다`}
dark
>
<div className="border border-white/10" style={{backgroundColor: ITEM_INVERSE}}>
<div
className="tpl-border border"
style={{
backgroundColor: ITEM_INVERSE,
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}
>
<div className="w4-film-perf h-2.5 opacity-70" />
<div className="px-3 py-4">
<Rail label="인물 목록">
{parsed.items.map((person, index) => (
<article key={`${person.name}-${index}`} className="w-[210px] shrink-0 snap-center">
<span
className="serif grid aspect-3/4 w-full place-items-center border text-5xl"
// 프레임 안쪽은 필름보다 한 단 밝게 — 색을 박지 않고 글자색을 섞어 만든다.
style={{
backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)',
borderColor: 'color-mix(in oklab, currentColor 28%, transparent)',
}}
aria-hidden
>
{person.name.trim().charAt(0)}
</span>
{person.imageUrl ? (
<img
src={person.imageUrl}
alt={`${person.name} 사진`}
loading="lazy"
decoding="async"
className="aspect-3/4 w-full border object-cover"
style={{borderColor: 'color-mix(in oklab, currentColor 28%, transparent)'}}
/>
) : (
<span
className="serif grid aspect-3/4 w-full place-items-center border text-5xl"
// 사진이 없으면 빈 상자 대신 활판 이니셜. 프레임 안쪽은 필름보다 한 단 밝게 —
// 색을 박지 않고 글자색을 섞어 만든다.
style={{
backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)',
borderColor: 'color-mix(in oklab, currentColor 28%, transparent)',
}}
aria-hidden
>
{person.name.trim().charAt(0)}
</span>
)}
<h3 className="serif mt-3 text-base font-bold">
{person.name}
{person.aka && <span className="ml-1.5 text-xs opacity-60">호 {person.aka}</span>}

View File

@ -17,7 +17,7 @@ export function PostcardSection() {
<ItemSection
id="postcard"
name={parsed.title || sectionName(payload, 'postcard', '오늘의 엽서')}
subtitle={parsed.subtitle}
subtitle={parsed.subtitle ?? '그대로 붙여 쓰는 한 문장'}
count={tally(parsed.items.length, parsed.unverified, '장')}
>
<Rail label="엽서 목록">
@ -27,6 +27,16 @@ export function PostcardSection() {
className="w4-paper w-[304px] shrink-0 snap-center border p-4"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
{card.imageUrl && (
<img
src={card.imageUrl}
alt={`${card.place ?? card.postmark ?? ''} 사진`}
loading="lazy"
decoding="async"
className="mb-3.5 block aspect-3/2 w-full border object-cover"
style={{borderColor: ITEM_BORDER}}
/>
)}
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>

View File

@ -1,78 +1,151 @@
/**
* 가요 다방 — 발행본.
*
* 캔버스는 턴테이블에 한 장만 얹지만 발행본은 곡마다 판을 하나씩 놓는다.
* 인용될 문장(story·connection)이 전부 HTML 에 있어야 하기 때문이다.
* ★ 마크업은 목업(/s/stay 의 `<section id="songs">`)에서 그대로 옮겼다.
* 턴테이블 하나에 판을 갈아 끼우고, 아래 미니 판으로 곡을 고른다.
* ★ **전 곡을 HTML 에 굽고 화면에서만 접는다**(`hidden`). 접힌 곡이 HTML 에 없으면
* 이 섹션을 둔 이유(인용)가 사라진다.
* ★ 가사는 어디에도 없다 — 스키마에 lyrics 칸 자체가 없다(원문 전재 금지).
* 그 사실을 화면에도 적는다. 손님이 "왜 가사가 없나" 를 묻지 않게.
*/
import {useState} from 'react';
import {Youtube} from 'lucide-react';
import type {SongItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common';
import {ITEM_ACCENT, ITEM_INVERSE, ItemSection, SourceLine, tally} from './common';
/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */
const FALLBACK_LABEL = ITEM_ACCENT;
/** 라벨 색을 안 주는 곡이 대부분이다. 순환시키면 미니 판이 서로 구별된다. */
const LABEL_COLORS = ['#d4551f', '#1d3c74', '#2a6053', '#7a3b6b', '#8a6d1f'];
export function SongsSection() {
const payload = useSite();
const parsed = sectionItems<SongItem>(payload, 'songs');
const [active, setActive] = useState(0);
if (parsed.items.length === 0) return null;
const labelOf = (song: SongItem, index: number) =>
song.labelColor || LABEL_COLORS[index % LABEL_COLORS.length];
return (
<ItemSection
id="songs"
name={parsed.title || sectionName(payload, 'songs', '가요 다방')}
subtitle={parsed.subtitle}
subtitle={parsed.subtitle ?? '이 도시를 노래한 곡들'}
count={tally(parsed.items.length, parsed.unverified, '곡')}
>
<Rail label="곡 목록">
{parsed.items.map((song, index) => (
<article
key={`${song.title}-${index}`}
className="w4-paper w-[280px] shrink-0 snap-center border p-5"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
<div
className="tpl-border grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[240px_minmax(0,1fr)]"
style={{
backgroundColor: ITEM_INVERSE,
color: 'var(--tpl-bg, #ffffff)',
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}
>
<div className="relative mx-auto size-[220px]">
<div
className="absolute inset-0 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 16%, transparent)'}}
/>
<div
className="w4-disc w4-spin absolute inset-2 rounded-full"
style={{
['--lbl' as string]: labelOf(parsed.items[active], active),
['--w4-vinyl' as string]: ITEM_INVERSE,
}}
>
<div
className="w4-disc mx-auto size-[132px] rounded-full"
style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
}}
>
<span
className="block size-full rounded-full"
style={{boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.06)'}}
/>
</div>
<span
className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 30%, transparent)'}}
/>
</div>
{/* 톤암 — 판 위에 얹힌 바늘. 이게 없으면 그냥 검은 원이다. */}
<div aria-hidden="true" className="absolute -right-1 top-3 h-2 w-[110px] origin-right rotate-6">
<span
className="absolute inset-y-[3px] left-0 right-4 rounded-sm"
style={{backgroundColor: 'color-mix(in oklab, currentColor 65%, transparent)'}}
/>
<span
className="absolute -top-2 right-0 size-6 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 55%, transparent)'}}
/>
</div>
</div>
<h3 className="serif mt-4 text-lg font-bold">{song.title}</h3>
<p className="mt-1 text-xs opacity-65">
{[
song.artist,
song.year ? String(song.year) : undefined,
song.lyricist || song.composer
? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}`
: undefined,
song.label,
]
.filter(Boolean)
.join(' · ')}
</p>
{song.story && <p className="mt-3 text-sm leading-relaxed opacity-85">{song.story}</p>}
{song.connection && (
<p className="mt-2 text-sm leading-relaxed" style={{color: ITEM_ACCENT}}>
{song.connection}
<div className="min-w-0">
{parsed.items.map((song, index) => (
<div key={`${song.title}-${index}`} hidden={index !== active} className="space-y-3">
<p className="text-[10px] tracking-[0.24em]" style={{color: ITEM_ACCENT}}>
A면 · {index + 1} / {parsed.items.length}
</p>
)}
<p className="mt-3 border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}>
◎ 가사 대신 이야기 — 원문은 싣지 않습니다
</p>
<div className="mt-2">
<h3 className="serif text-[length:var(--fs-h2)] leading-tight">{song.title}</h3>
<p className="text-[length:var(--fs-xs)] opacity-60">
{[
song.artist,
song.year ? String(song.year) : undefined,
song.lyricist || song.composer
? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}`
: undefined,
song.label,
]
.filter(Boolean)
.join(' · ')}
</p>
{song.story && (
<p className="measure text-[length:var(--fs-sm)] leading-relaxed opacity-85">{song.story}</p>
)}
{song.connection && (
<p className="measure text-[length:var(--fs-sm)] leading-relaxed" style={{color: ITEM_ACCENT}}>
{song.connection}
</p>
)}
<a
href={`https://www.youtube.com/results?search_query=${encodeURIComponent(
`${song.title} ${song.artist ?? ''}`.trim(),
)}`}
target="_blank"
rel="noopener noreferrer nofollow"
className="inline-flex items-center gap-1.5 text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline"
style={{color: ITEM_ACCENT}}
>
<Youtube className="size-4" aria-hidden="true" />
<span>유튜브에서 듣기</span>
</a>
<span
className="block w-fit border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60"
style={{borderColor: 'color-mix(in oklab, currentColor 35%, transparent)'}}
>
◎ 가사 대신 이야기 — 원문은 싣지 않습니다
</span>
<SourceLine source={song.source} verified={song.verified} />
</div>
</article>
))}
</Rail>
))}
</div>
</div>
{parsed.items.length > 1 && (
<div className="mt-4 flex flex-wrap justify-center gap-4">
{parsed.items.map((song, index) => (
<button
key={`${song.title}-pick-${index}`}
type="button"
aria-pressed={index === active}
onClick={() => setActive(index)}
className="w-24 text-center"
>
<span
className="w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105"
style={{
['--lbl' as string]: labelOf(song, index),
['--w4-vinyl' as string]: ITEM_INVERSE,
boxShadow: index === active ? `0 0 0 2px ${ITEM_ACCENT}` : undefined,
}}
/>
<span className="mt-2 block truncate text-[10px] leading-tight opacity-70">{song.title}</span>
</button>
))}
</div>
)}
</ItemSection>
);
}

View File

@ -0,0 +1,128 @@
/**
* 일정 지도 — OpenStreetMap 타일을 직접 깔고 그 위에 번호 핀을 찍는다.
*
* ★ 왜 iframe 이 아닌가
* iframe 임베드는 핀을 하나밖에 못 찍고, 순서(1→2→3)를 그릴 수 없다. 일정에서 중요한 건
* "어디"가 아니라 "어떤 순서로 도는가"라 경로가 보여야 한다.
* ★ 타일은 회색으로 눌러 깐다(`grayscale opacity .55`). 지도가 선명하면 핀과 경로가 묻힌다.
* ★ 핀을 누르면 네이버 길찾기로 간다 — 앞 정거장에서 이 정거장까지. 우리가 경로를
* 계산해 그리지 않는다(지어낸 시간을 그릴 수 없다).
*/
const TILE = 256;
/** 경위도 → Web Mercator 픽셀(줌 z 기준). */
function project(lat: number, lng: number, z: number) {
const n = 2 ** z;
const x = ((lng + 180) / 360) * n * TILE;
const s = Math.sin((lat * Math.PI) / 180);
const y = ((0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n) * TILE;
return {x, y};
}
export interface TripPoint {
name: string;
latitude: number;
longitude: number;
searchQuery?: string;
}
export function TripMap({points, width = 320, height = 168}: {
points: TripPoint[];
width?: number;
height?: number;
}) {
if (points.length === 0) return null;
const lats = points.map((p) => p.latitude);
const lngs = points.map((p) => p.longitude);
const center = {lat: (Math.min(...lats) + Math.max(...lats)) / 2, lng: (Math.min(...lngs) + Math.max(...lngs)) / 2};
// 모든 점이 상자 안에 들어오는 가장 큰 줌. 한 점뿐이면 동네가 보이는 15로 둔다.
let zoom = 15;
for (let z = 16; z >= 10; z -= 1) {
const xs = points.map((p) => project(p.latitude, p.longitude, z).x);
const ys = points.map((p) => project(p.latitude, p.longitude, z).y);
const w = Math.max(...xs) - Math.min(...xs);
const h = Math.max(...ys) - Math.min(...ys);
if (w <= width - 56 && h <= height - 56) {
zoom = z;
break;
}
}
const c = project(center.lat, center.lng, zoom);
const originX = c.x - width / 2;
const originY = c.y - height / 2;
// 화면을 덮는 타일만 깐다.
const tiles: {x: number; y: number; left: number; top: number}[] = [];
const tx0 = Math.floor(originX / TILE);
const ty0 = Math.floor(originY / TILE);
const tx1 = Math.floor((originX + width) / TILE);
const ty1 = Math.floor((originY + height) / TILE);
for (let tx = tx0; tx <= tx1; tx += 1) {
for (let ty = ty0; ty <= ty1; ty += 1) {
tiles.push({x: tx, y: ty, left: tx * TILE - originX, top: ty * TILE - originY});
}
}
const naverTo = (from: TripPoint | undefined, to: TripPoint) => {
const dest = `${to.longitude},${to.latitude},${encodeURIComponent(to.name)}`;
const start = from ? `${from.longitude},${from.latitude},${encodeURIComponent(from.name)}` : '-';
return `https://map.naver.com/p/directions/${start}/${dest}/-/transit`;
};
return (
<div
className="relative mt-3.5 overflow-hidden border-y"
style={{
height,
borderColor: 'var(--tpl-border, #d6d3d1)',
// ★ 타일이 로드되기 전에도 지도 자리가 비어 보이지 않게 — OSM 육지 색이다.
backgroundColor: '#e8e5df',
}}
aria-label="일정 지도"
>
{tiles.map((tile) => (
<img
key={`${tile.x}-${tile.y}`}
src={`https://tile.openstreetmap.org/${zoom}/${tile.x}/${tile.y}.png`}
alt=""
width={TILE}
height={TILE}
loading="lazy"
decoding="async"
className="pointer-events-none absolute max-w-none"
style={{left: tile.left, top: tile.top, filter: 'grayscale(1) contrast(0.95) opacity(0.55)'}}
/>
))}
{points.map((point, index) => {
const p = project(point.latitude, point.longitude, zoom);
return (
<a
key={`${point.name}-${index}`}
href={naverTo(points[index - 1], point)}
target="_blank"
rel="noopener noreferrer"
title={`${point.name} — 네이버 지도`}
className="absolute grid h-[22px] min-w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center whitespace-nowrap rounded-full px-1.5 text-[11px] font-bold shadow-sm transition-transform hover:scale-110"
style={{
left: p.x - originX,
top: p.y - originY,
backgroundColor: 'color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)',
color: 'var(--tpl-bg, #ffffff)',
border: '1.5px solid var(--tpl-bg, #ffffff)',
}}
>
{index + 1}
</a>
);
})}
<span className="absolute bottom-0.5 right-1 bg-white/70 px-1 text-[9px] leading-tight text-stone-600">
© OpenStreetMap
</span>
</div>
);
}

View File

@ -0,0 +1,50 @@
/**
* 영상.
*
* ★ 자동 재생하지 않고 iframe 도 심지 않는다 — 외부 스크립트를 끌어오면 첫 화면이 늦어지고
* 추적 쿠키가 따라 들어온다. 썸네일과 링크만 둔다.
*/
import {Play} from 'lucide-react';
import type {VideoItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_BORDER, ITEM_INVERSE, ITEM_INVERSE_INK, ItemSection, tally} from './common';
export function VideoSection() {
const payload = useSite();
const parsed = sectionItems<VideoItem>(payload, 'video');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="video"
name={parsed.title || sectionName(payload, 'video', '영상')}
subtitle={parsed.subtitle}
count={tally(parsed.items.length, parsed.unverified, '편')}
>
<ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{parsed.items.map((video, index) => (
<li key={`${video.url}-${index}`}>
<a
href={video.url}
target="_blank"
rel="noopener noreferrer"
className="group block border-2"
style={{borderColor: ITEM_BORDER}}
>
<div
className="flex aspect-video items-center justify-center"
style={{backgroundColor: ITEM_INVERSE, color: ITEM_INVERSE_INK}}
>
<Play className="size-10 opacity-70 transition-opacity group-hover:opacity-100" aria-hidden="true" />
</div>
{video.caption && (
<p className="px-3 py-2 text-[length:var(--fs-sm)] leading-snug">{video.caption}</p>
)}
</a>
</li>
))}
</ul>
</ItemSection>
);
}

View File

@ -56,35 +56,47 @@ export function ItemSection({
<section
id={id}
aria-labelledby={`${id}-heading`}
className="w-full border-b"
className="border-line paper w-full border-b"
style={{
backgroundColor: dark ? ITEM_INVERSE : 'var(--tpl-surface, #fafafa)',
color: dark ? ITEM_INVERSE_INK : ITEM_INK,
borderColor: 'color-mix(in oklab, currentColor 12%, transparent)',
paddingBlock: 'var(--tpl-section-space, 4rem)',
paddingBlock: 'var(--section-space)',
}}
>
<div className="shell">
<h2 id={`${id}-heading`} className="serif text-2xl tracking-tight sm:text-3xl lg:text-4xl">
{name}
</h2>
{subtitle && <p className="mt-2 text-sm opacity-70">{subtitle}</p>}
<div className="mt-8">{children}</div>
{count && <p className="mt-4 text-xs opacity-55">{count}</p>}
{/* ★ 머리말은 목업(/s/stay 의 아이템 섹션)과 같은 모양이다 — 다른 섹션과 달리
세로 막대를 달지 않는다. 이야기 섹션은 그 자체가 한 덩어리라 구분선이 필요 없다. */}
<header className="mb-8 sm:mb-10">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 id={`${id}-heading`} className="h2">
{name}
</h2>
</div>
{subtitle && (
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70">{subtitle}</p>
)}
</header>
{children}
{count && <p className="mt-5 text-[length:var(--fs-xs)] opacity-55">{count}</p>}
</div>
</section>
);
}
/** 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다. */
/**
* 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다.
*
* ★ 클래스 이름(`slider-viewport`/`slider-track`)은 목업과 같게 둔다. 스크롤 스냅만으로
* 같은 동작이 나오므로 캐러셀 라이브러리는 쓰지 않는다.
*/
export function Rail({children, label}: {children: ReactNode; label: string}) {
return (
<div
role="group"
aria-label={label}
className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3"
>
{children}
<div className="relative">
<div className="slider-viewport" role="group" aria-roledescription="캐러셀" aria-label={label} tabIndex={0}>
<div className="slider-track" style={{gap: '1rem'}}>
{children}
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More