최상단을 프로젝트 단위로 평평하게 둔다 — 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
248 lines
14 KiB
HTML
248 lines
14 KiB
HTML
<!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="닫기">×</button>
|
||
<button type="button" class="nav prev" aria-label="이전 사진">‹</button>
|
||
<button type="button" class="nav next" aria-label="다음 사진">›</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>
|