o2o-site-AEO/solution/backend/demo_site.html
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +09:00

248 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>핑크비치펜션 - 숙박</title>
<meta name="description" content="하조대 해변에서 도보 3분 거리에 있는 2개 동 규모의 펜션입니다. 모든 객실에서 바다가 보이며, 취사가 가능하고 야외 바비큐 공간을 갖추고 있습니다.">
<style>
:root{--fg:#1a1a1a;--muted:#666;--line:#e5e5e5;--bg:#fff}
*{box-sizing:border-box}
body{margin:0;padding:0 16px 48px;font:16px/1.7 system-ui,-apple-system,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;color:var(--fg);background:var(--bg)}
main{max-width:760px;margin:0 auto}
header{padding:32px 0 16px;border-bottom:2px solid var(--fg)}
h1{margin:0 0 4px;font-size:1.75rem;line-height:1.3}
.category{margin:0;color:var(--muted);font-size:.9rem}
section{padding:24px 0;border-bottom:1px solid var(--line)}
section:last-child{border-bottom:0}
h2{margin:0 0 12px;font-size:1.1rem}
h3{margin:0 0 8px;font-size:1rem}
dl{display:grid;grid-template-columns:minmax(7rem,auto) 1fr;gap:6px 16px;margin:0}
dt{color:var(--muted)}
dd{margin:0}
article{padding:12px 0;border-top:1px dashed var(--line)}
article:first-of-type{border-top:0}
figure{margin:0}
.photos{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px}
img{width:100%;height:auto;display:block;border-radius:6px}
figcaption{margin-top:4px;color:var(--muted);font-size:.85rem}
.faq details{border-top:1px dashed var(--line);padding:10px 0}
.faq details:first-of-type{border-top:0}
.faq summary{cursor:pointer;font-weight:600;list-style:none;display:flex;justify-content:space-between;gap:12px}
.faq summary::-webkit-details-marker{display:none}
.faq summary::after{content:"";color:var(--muted);flex:none}
.faq details:not([open]) summary::after{content:"+"}
.faq p{margin:8px 0 0;color:var(--muted)}
a{color:inherit}
/* 앵커 네비 — 긴 페이지에서 원하는 정보로 바로 간다 */
html{scroll-behavior:smooth}
nav.toc{position:sticky;top:0;z-index:5;display:flex;gap:4px;overflow-x:auto;
padding:10px 0;margin-bottom:-1px;background:var(--bg);border-bottom:1px solid var(--line);
-webkit-overflow-scrolling:touch;scrollbar-width:none}
nav.toc::-webkit-scrollbar{display:none}
nav.toc a{flex:none;padding:6px 12px;border:1px solid var(--line);border-radius:999px;
font-size:.85rem;text-decoration:none;color:var(--muted);white-space:nowrap}
nav.toc a:hover,nav.toc a:focus-visible{border-color:var(--fg);color:var(--fg)}
section{scroll-margin-top:56px}
/* 사진 — 클릭하면 크게. JS 가 없으면 그냥 그리드로 다 보인다 */
.photos button{all:unset;display:block;cursor:zoom-in;width:100%}
.photos button:focus-visible img{outline:2px solid var(--fg);outline-offset:2px}
.lightbox{position:fixed;inset:0;z-index:50;display:none;place-items:center;
background:rgba(0,0,0,.92);padding:16px}
.lightbox[open],.lightbox.on{display:grid}
.lightbox img{max-width:100%;max-height:78vh;width:auto;border-radius:4px}
.lightbox figcaption{margin-top:12px;color:#eee;text-align:center;font-size:.9rem}
.lightbox .nav{position:absolute;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);color:#fff;border:0;border-radius:999px;
width:44px;height:44px;font-size:1.4rem;cursor:pointer}
.lightbox .prev{left:12px}.lightbox .next{right:12px}
.lightbox .close{position:absolute;top:12px;right:12px;background:none;border:0;
color:#fff;font-size:1.8rem;line-height:1;cursor:pointer;padding:8px}
/* 하단 고정 연락 바 — 모바일에서 전화가 제일 중요한 행동이다 */
.cta{position:fixed;left:0;right:0;bottom:0;z-index:10;display:flex;gap:8px;
padding:10px 16px calc(10px + env(safe-area-inset-bottom));
background:var(--bg);border-top:1px solid var(--line)}
.cta a,.cta button{flex:1;padding:12px;border-radius:8px;border:1px solid var(--fg);
text-align:center;text-decoration:none;font-weight:600;font-size:.95rem;
background:var(--bg);color:var(--fg);cursor:pointer;font-family:inherit}
.cta a.primary{background:var(--fg);color:var(--bg)}
body:has(.cta){padding-bottom:96px}
@media(min-width:761px){.cta{position:static;max-width:760px;margin:24px auto 0;border:0;padding:0}
body:has(.cta){padding-bottom:48px}}
</style>
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"LodgingBusiness","name":"핑크비치펜션","address":{"@type":"PostalAddress","streetAddress":"강원특별자치도 양양군 현북면 하조대3길 11","addressCountry":"KR"},"telephone":"033-672-0000","geo":{"@type":"GeoCoordinates","latitude":38.0219217,"longitude":128.7221449},"image":["https://picsum.photos/seed/pension-out/1200/800","https://picsum.photos/seed/pension-bed/1200/800","https://picsum.photos/seed/pension-liv/1200/800","https://picsum.photos/seed/pension-bbq/1200/800","https://picsum.photos/seed/pension-duo/1200/800"],"description":"하조대 해변에서 도보 3분 거리에 있는 2개 동 규모의 펜션입니다. 모든 객실에서 바다가 보이며, 취사가 가능하고 야외 바비큐 공간을 갖추고 있습니다.","checkinTime":"15:00","checkoutTime":"11:00","petsAllowed":false,"amenityFeature":[{"@type":"LocationFeatureSpecification","name":"와이파이","value":true},{"@type":"LocationFeatureSpecification","name":"주차 가능","value":true},{"@type":"LocationFeatureSpecification","name":"조식 제공","value":false},{"@type":"LocationFeatureSpecification","name":"바비큐 이용","value":true}],"makesOffer":[{"@type":"Offer","name":"A동 스탠다드","price":150000,"priceCurrency":"KRW"},{"@type":"Offer","name":"B동 복층","price":220000,"priceCurrency":"KRW"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"체크인·체크아웃은 몇 시인가요?","acceptedAnswer":{"@type":"Answer","text":"체크인은 15:00, 체크아웃은 11:00 입니다."}},{"@type":"Question","name":"반려동물과 함께 갈 수 있나요?","acceptedAnswer":{"@type":"Answer","text":"반려동물 동반은 불가합니다."}},{"@type":"Question","name":"객실에서 취사가 가능한가요?","acceptedAnswer":{"@type":"Answer","text":"취사가 가능하며 모든 객실에 주방이 있습니다."}},{"@type":"Question","name":"바비큐를 이용하려면 얼마인가요?","acceptedAnswer":{"@type":"Answer","text":"바비큐 이용료는 20,000원입니다."}},{"@type":"Question","name":"주차는 가능한가요?","acceptedAnswer":{"@type":"Answer","text":"주차가 가능하며 6대까지 주차할 수 있습니다."}}]}]}</script>
</head>
<body>
<main>
<nav class="toc"><a href="#intro">소개</a><a href="#info">이용 정보</a><a href="#units">객실</a><a href="#photos">사진</a><a href="#faq">자주 묻는 질문</a><a href="#contact">위치·연락처</a></nav>
<header>
<h1 data-ld="name">핑크비치펜션</h1>
<p class="category">숙박</p>
</header>
<section id="intro">
<p data-ld="description">하조대 해변에서 도보 3분 거리에 있는 2개 동 규모의 펜션입니다. 모든 객실에서 바다가 보이며, 취사가 가능하고 야외 바비큐 공간을 갖추고 있습니다.</p>
</section>
<section id="contact">
<h2>위치·연락처</h2>
<dl>
<dt>주소</dt><dd data-ld="streetAddress">강원특별자치도 양양군 현북면 하조대3길 11</dd>
<dt>전화</dt><dd><a data-ld="telephone" href="tel:0336720000">033-672-0000</a></dd>
</dl>
</section>
<section id="info">
<h2>이용 정보</h2>
<dl>
<dt>체크인 시간</dt><dd data-ld="checkinTime">15:00</dd>
<dt>체크아웃 시간</dt><dd data-ld="checkoutTime">11:00</dd>
<dt>취소·환불 규정</dt><dd>이용 7일 전 100% 환불, 3일 전 50% 환불, 당일 취소 불가</dd>
<dt>취사 가능</dt><dd>가능</dd>
<dt>반려동물 동반</dt><dd data-ld="petsAllowed">불가</dd>
<dt>흡연 가능</dt><dd>불가</dd>
<dt>프런트 운영시간</dt><dd>09:00 - 21:00</dd>
<dt>주차 가능</dt><dd data-ld-amenity="주차 가능">가능</dd>
<dt>주차 대수</dt><dd>6대</dd>
<dt>와이파이</dt><dd data-ld-amenity="와이파이">가능</dd>
<dt>바비큐 이용</dt><dd data-ld-amenity="바비큐 이용">가능</dd>
<dt>바비큐 이용료</dt><dd>20,000원</dd>
<dt>조식 제공</dt><dd data-ld-amenity="조식 제공">불가</dd>
</dl>
</section>
<section id="units">
<h2>객실</h2>
<article>
<h3>A동 스탠다드</h3>
<dl>
<dt>객실 타입</dt><dd>스탠다드</dd>
<dt>기준 인원</dt><dd>2명</dd>
<dt>최대 인원</dt><dd>4명</dd>
<dt>침대 구성</dt><dd>퀸 1</dd>
<dt>주방 여부</dt><dd>가능</dd>
<dt>전망</dt><dd>오션뷰</dd>
<dt>주중 요금</dt><dd data-ld-offer="A동 스탠다드">150,000원</dd>
</dl>
</article>
<article>
<h3>B동 복층</h3>
<dl>
<dt>객실 타입</dt><dd>복층</dd>
<dt>기준 인원</dt><dd>4명</dd>
<dt>최대 인원</dt><dd>6명</dd>
<dt>침대 구성</dt><dd>퀸 1 + 싱글 2</dd>
<dt>주방 여부</dt><dd>가능</dd>
<dt>전망</dt><dd>오션뷰</dd>
<dt>주중 요금</dt><dd data-ld-offer="B동 복층">220,000원</dd>
</dl>
</article>
</section>
<section id="photos">
<h2>사진</h2>
<div class="photos">
<figure>
<button type="button">
<img src="https://picsum.photos/seed/pension-out/1200/800" alt="2층 목조 건물 외관과 앞마당 잔디" data-caption="외관" loading="lazy">
</button>
<figcaption>외관</figcaption>
</figure>
<figure>
<button type="button">
<img src="https://picsum.photos/seed/pension-bed/1200/800" alt="퀸 침대와 창밖으로 바다가 보이는 침실" data-caption="A동 침실" loading="lazy">
</button>
<figcaption>A동 침실</figcaption>
</figure>
<figure>
<button type="button">
<img src="https://picsum.photos/seed/pension-liv/1200/800" alt="소파와 원목 테이블이 있는 거실" data-caption="A동 거실" loading="lazy">
</button>
<figcaption>A동 거실</figcaption>
</figure>
<figure>
<button type="button">
<img src="https://picsum.photos/seed/pension-bbq/1200/800" alt="지붕이 있는 야외 바비큐 공간" data-caption="바비큐장" loading="lazy">
</button>
<figcaption>바비큐장</figcaption>
</figure>
<figure>
<button type="button">
<img src="https://picsum.photos/seed/pension-duo/1200/800" alt="계단으로 이어진 복층 구조의 객실" data-caption="B동 복층" loading="lazy">
</button>
<figcaption>B동 복층</figcaption>
</figure>
</div>
</section>
<section id="faq" class="faq">
<h2>자주 묻는 질문</h2>
<details open>
<summary>체크인·체크아웃은 몇 시인가요?</summary>
<p>체크인은 15:00, 체크아웃은 11:00 입니다.</p>
</details>
<details open>
<summary>반려동물과 함께 갈 수 있나요?</summary>
<p>반려동물 동반은 불가합니다.</p>
</details>
<details open>
<summary>객실에서 취사가 가능한가요?</summary>
<p>취사가 가능하며 모든 객실에 주방이 있습니다.</p>
</details>
<details open>
<summary>바비큐를 이용하려면 얼마인가요?</summary>
<p>바비큐 이용료는 20,000원입니다.</p>
</details>
<details open>
<summary>주차는 가능한가요?</summary>
<p>주차가 가능하며 6대까지 주차할 수 있습니다.</p>
</details>
</section>
</main>
<div class="lightbox" aria-hidden="true">
<button type="button" class="close" aria-label="닫기">&times;</button>
<button type="button" class="nav prev" aria-label="이전 사진">&#8249;</button>
<button type="button" class="nav next" aria-label="다음 사진">&#8250;</button>
<figure>
<img src="" alt="">
<figcaption></figcaption>
</figure>
</div>
<div class="cta"><a class="primary" href="tel:0336720000">전화하기</a><button type="button" data-copy="강원특별자치도 양양군 현북면 하조대3길 11">주소 복사</button></div>
<script>
(function(){try{
var box=document.querySelector('.lightbox');
if(box){
var img=box.querySelector('img'),cap=box.querySelector('figcaption'),i=0,
shots=[].slice.call(document.querySelectorAll('.photos button'));
var show=function(n){i=(n+shots.length)%shots.length;var t=shots[i].querySelector('img');
img.src=t.src;img.alt=t.alt;cap.textContent=t.dataset.caption||t.alt;};
var open=function(n){show(n);box.classList.add('on');document.body.style.overflow='hidden';
box.querySelector('.close').focus();};
var close=function(){box.classList.remove('on');document.body.style.overflow='';
if(shots[i])shots[i].focus();};
shots.forEach(function(b,n){b.addEventListener('click',function(){open(n);});});
box.querySelector('.close').addEventListener('click',close);
box.querySelector('.prev').addEventListener('click',function(){show(i-1);});
box.querySelector('.next').addEventListener('click',function(){show(i+1);});
box.addEventListener('click',function(e){if(e.target===box)close();});
document.addEventListener('keydown',function(e){
if(!box.classList.contains('on'))return;
if(e.key==='Escape')close();
if(e.key==='ArrowLeft')show(i-1);
if(e.key==='ArrowRight')show(i+1);});
var x0=null;
box.addEventListener('touchstart',function(e){x0=e.touches[0].clientX;},{passive:true});
box.addEventListener('touchend',function(e){
if(x0===null)return;var d=e.changedTouches[0].clientX-x0;x0=null;
if(Math.abs(d)>40)show(d<0?i+1:i-1);},{passive:true});
}
var copy=document.querySelector('[data-copy]');
if(copy&&navigator.clipboard){
copy.addEventListener('click',function(){
navigator.clipboard.writeText(copy.dataset.copy).then(function(){
var t=copy.textContent;copy.textContent='복사됨';
setTimeout(function(){copy.textContent=t;},1500);});});
}else if(copy){copy.remove();}
}catch(e){}})();
</script>
</body>
</html>