release: v0.10.0
All checks were successful
ci / build (push) Successful in 42s
release / release (push) Successful in 1m5s

This commit is contained in:
Yun Chan 2026-08-29 21:19:26 +09:00
parent 2b7fa32b5b
commit 6930906583
61 changed files with 15165 additions and 82 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View file

@ -0,0 +1,358 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>철판클럽 가맹 안내 — 운영 조건부터 확인하기</title>
<meta
name="description"
content="가상 버거 브랜드 철판클럽의 상권, 점포, 면적, 개설 절차와 비용 미정 항목을 먼저 살펴보는 가맹 문의 쇼케이스입니다."
/>
<meta property="og:title" content="철판클럽 가맹 안내 — 운영 조건부터" />
<meta
property="og:description"
content="수익 약속 없이 상권, 점포, 주방 동선과 비용 미정 항목을 먼저 보여 주는 국내형 가맹 안내 화면."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="../og.png" />
<meta property="og:image:alt" content="직원들이 철판 조리와 포장을 나누어 맡는 주방" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="철판클럽 가맹 안내 — 운영 조건부터" />
<meta
name="twitter:description"
content="상권, 점포, 주방 동선과 비용 미정 항목을 먼저 살펴보는 가맹 안내 쇼케이스."
/>
<meta name="twitter:image" content="../og.png" />
<meta name="theme-color" content="#fffdf8" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&display=swap"
/>
<link rel="stylesheet" href="../styles/tokens.css?v=1" />
<link rel="stylesheet" href="../styles/site.css?v=1" />
<link rel="stylesheet" href="../styles/franchise.css?v=1" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%232147e7'/%3E%3Cpath d='M6 9h20M8 16h16M10 23h12' stroke='%23fffdf8' stroke-width='3' stroke-linecap='square'/%3E%3C/svg%3E"
/>
<script>document.documentElement.classList.add("is-js");</script>
<script src="../scripts/franchise.js?v=1" defer></script>
</head>
<body class="franchise-page">
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
<div class="truth-bar" role="note">
<p>쇼케이스용 가상 브랜드 · 실제 가맹 모집, 상담, 계약을 제공하지 않음</p>
</div>
<header class="site-head">
<div class="wrap head-row franchise-head-row">
<a class="brand" href="../" aria-label="철판클럽 홈으로">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>철판클럽</span>
</a>
<nav class="head-nav" aria-label="가맹 안내 메뉴">
<a href="#model">운영 방식</a>
<a href="#site-fit">상권과 점포</a>
<a href="#process">개설 절차</a>
<a href="#cost">비용 항목</a>
</nav>
<a class="head-action" href="#inquiry">문의 준비하기</a>
</div>
</header>
<main id="main">
<section class="franchise-hero" aria-labelledby="franchise-hero-heading">
<div class="franchise-hero-copy">
<p class="section-kicker">철판클럽 가맹 안내</p>
<h1 id="franchise-hero-heading">매출 약속보다<br />운영 조건부터.</h1>
<p>
버거 사진만 보고 결정하지 않도록 상권, 점포, 전용 면적, 주방 동선과 아직 정할 수 없는 비용을 순서대로 보여 줍니다.
</p>
<div class="hero-actions">
<a class="button button-light" href="#site-fit">내 조건 살펴보기</a>
<a class="text-link light-link" href="#cost">비용 미정 항목 보기 <span aria-hidden="true">↓</span></a>
</div>
<div class="franchise-truth">
<strong>먼저 확인해 주세요</strong>
<p>실제 가맹 모집이 아닌 디자인 쇼케이스입니다. 금액, 수익, 매장 수와 개점 기간을 꾸며내지 않습니다.</p>
</div>
</div>
<figure class="franchise-hero-media image-shell">
<img
src="../assets/kitchen-flow.webp"
width="1400"
height="933"
alt="직원 두 명이 좁은 주방에서 철판 조리와 포장 작업을 나누어 맡는 모습"
fetchpriority="high"
/>
<span class="image-fallback" aria-hidden="true">주방 운영 동선 장면</span>
<figcaption>생성 이미지 · 실제 매장과 가맹 시설 아님</figcaption>
</figure>
</section>
<section class="fact-rail" aria-label="가맹 안내 원칙">
<div class="wrap fact-rail-grid">
<p><span>01</span> 수익률을 약속하지 않음</p>
<p><span>02</span> 점포를 보기 전 금액을 확정하지 않음</p>
<p><span>03</span> 실제 개인정보를 받지 않음</p>
</div>
</section>
<section class="section model-section" id="model" aria-labelledby="model-heading">
<div class="wrap">
<div class="section-head split-head">
<div class="model-heading-copy"><p class="section-kicker">어떤 운영 방식인지</p><h2 id="model-heading">좁은 주방에서<br />역할을 분명하게.</h2></div>
<p class="section-note">조리, 조립, 포장, 전달이 서로 겹치지 않는 흐름을 먼저 검토합니다. 실제 설비와 인원은 점포마다 달라집니다.</p>
</div>
<div class="flow-board">
<article><span>01</span><h3>재료 준비</h3><p>냉장 보관과 당일 준비 동선을 분리합니다.</p></article>
<article><span>02</span><h3>철판 조리</h3><p>패티와 양파가 움직이는 구간을 짧게 둡니다.</p></article>
<article><span>03</span><h3>조립과 포장</h3><p>주문표, 유산지, 상자를 한 방향에 둡니다.</p></article>
<article><span>04</span><h3>전달과 픽업</h3><p>매장 손님과 배달 픽업이 부딪히지 않게 나눕니다.</p></article>
</div>
<div class="operator-fit">
<div class="operator-heading-copy"><p class="section-kicker">운영자에게 필요한 태도</p><h3>직접 확인하고,<br />같은 품질을 반복하기.</h3></div>
<ul>
<li><strong>현장 참여</strong><span>조리와 응대 흐름을 직접 익힐 준비</span></li>
<li><strong>위생 기록</strong><span>보관, 조리, 마감 상태를 남길 준비</span></li>
<li><strong>지역 이해</strong><span>점심과 저녁 수요가 다른 상권을 살필 준비</span></li>
<li><strong>팀 운영</strong><span>작은 인원에서 역할을 나누고 교육할 준비</span></li>
</ul>
</div>
</div>
</section>
<section class="section site-fit-section" id="site-fit" aria-labelledby="site-fit-heading">
<div class="wrap">
<div class="section-head split-head">
<div class="site-fit-heading-copy"><p class="section-kicker">상권과 점포</p><h2 id="site-fit-heading">주소보다 먼저<br />운영 장면을 봅니다.</h2></div>
<p class="section-note">아래 분류는 적합 판정이 아니라 문의 때 빠뜨리지 않을 조건 목록입니다. 권장 면적 수치는 실제 검토 뒤에만 제시할 수 있습니다.</p>
</div>
<div class="fit-grid">
<article class="fit-main">
<p class="fit-number mono">A</p>
<h3>상권 흐름</h3>
<p>업무가, 주거지, 역 주변, 대학가, 상점가 또는 여러 수요가 섞인 곳인지 먼저 적습니다.</p>
<ul><li>점심과 저녁 수요 시간</li><li>도보와 차량 접근</li><li>포장 대기 공간</li><li>배달 픽업 접근</li></ul>
</article>
<article>
<p class="fit-number mono">B</p><h3>점포 상태</h3><p>점포 없음, 찾는 중, 협의 중, 보유함으로 나눕니다.</p>
</article>
<article>
<p class="fit-number mono">C</p><h3>전용 면적</h3><p>사용자가 입력한 ㎡와 환산한 평을 함께 봅니다. 적합 판정은 하지 않습니다.</p>
</article>
<article>
<p class="fit-number mono">D</p><h3>설비 조건</h3><p>전기, 배기, 급수, 배수와 반입 경로는 점포 자료로 따로 확인합니다.</p>
</article>
<article>
<p class="fit-number mono">E</p><h3>이전 용도</h3><p>음식점, 소매점, 빈 점포 등 이전 사용 상태를 확인합니다.</p>
</article>
</div>
</div>
</section>
<section class="section franchise-process" id="process" aria-labelledby="process-heading-franchise">
<div class="wrap">
<div class="section-head split-head">
<div class="process-heading-copy"><p class="section-kicker">개설 절차</p><h2 id="process-heading-franchise">기간을 꾸미지 않고<br />순서만 보여 줍니다.</h2></div>
<p class="section-note">점포, 자료, 협의와 공사 조건에 따라 기간이 달라집니다. 아래 흐름은 화면 설명용 예시이며 계약이나 개점을 보장하지 않습니다.</p>
</div>
<ol class="franchise-timeline">
<li><span>01</span><div><h3>문의 준비</h3><p>희망 지역과 시작 시기를 정리합니다.</p></div><small>준비 전</small></li>
<li><span>02</span><div><h3>희망 조건 확인</h3><p>상권 종류, 점포 상태와 운영 경험을 봅니다.</p></div><small>자료 필요</small></li>
<li><span>03</span><div><h3>상권과 점포 검토</h3><p>주소가 정해졌다면 면적, 설비와 동선을 확인합니다.</p></div><small>조건에 따라 다름</small></li>
<li><span>04</span><div><h3>정보 제공과 협의</h3><p>확인 가능한 조건과 미정 항목을 나눕니다.</p></div><small>검토 필요</small></li>
<li><span>05</span><div><h3>계약 내용 검토</h3><p>실제 자료와 조건을 받은 뒤 각 항목을 확인합니다.</p></div><small>자동 확정 아님</small></li>
<li><span>06</span><div><h3>설계와 공사 계획</h3><p>주방, 포장대와 픽업 동선을 점포에 맞춥니다.</p></div><small>점포별 다름</small></li>
<li><span>07</span><div><h3>조리와 응대 교육</h3><p>조리 순서와 위생 기록을 함께 익힙니다.</p></div><small>계획 필요</small></li>
<li><span>08</span><div><h3>개점 전 점검</h3><p>설비, 재료, 동선과 운영 준비를 확인합니다.</p></div><small>완료 보장 아님</small></li>
</ol>
</div>
</section>
<section class="section cost-section" id="cost" aria-labelledby="cost-heading">
<div class="wrap">
<div class="cost-head">
<div class="cost-heading-copy"><p class="section-kicker">비용 항목</p><h2 id="cost-heading">합계를 만들 수 없는<br />이유부터 적습니다.</h2></div>
<div class="unknown-cost">
<span>현재 상태</span>
<strong>비용 미정</strong>
<p>점포, 면적, 설비와 계약 자료가 확인되지 않아 금액을 제시하지 않습니다.</p>
</div>
</div>
<div class="cost-table-wrap" role="region" aria-label="비용 항목 표" tabindex="0">
<table class="cost-table">
<caption class="sr-only">가맹 비용 항목과 현재 확인 상태</caption>
<thead><tr><th scope="col">항목</th><th scope="col">현재 상태</th><th scope="col">금액</th><th scope="col">다음 확인</th></tr></thead>
<tbody>
<tr><th scope="row">가맹 관련 비용</th><td>자료 미제공</td><td>미정</td><td>실제 안내 자료 확인</td></tr>
<tr><th scope="row">교육 비용</th><td>범위 미정</td><td>미정</td><td>인원과 교육 범위 확인</td></tr>
<tr><th scope="row">계약 보증 관련 비용</th><td>조건 미정</td><td>미정</td><td>실제 계약 자료 확인</td></tr>
<tr><th scope="row">주방 설비</th><td>점포 필요</td><td>미정</td><td>전기, 배기와 설비 확인</td></tr>
<tr><th scope="row">가구와 집기</th><td>사양 필요</td><td>미정</td><td>좌석과 포장 방식 확인</td></tr>
<tr><th scope="row">인테리어</th><td>면적 필요</td><td>미정</td><td>전용 면적과 현장 확인</td></tr>
<tr><th scope="row">간판과 외부 표시</th><td>건물 조건 필요</td><td>미정</td><td>건물과 지역 조건 확인</td></tr>
<tr><th scope="row">주문 기기</th><td>운영 방식 필요</td><td>미정</td><td>주문과 결제 방식 확인</td></tr>
<tr><th scope="row">허가와 신고</th><td>지역 조건 필요</td><td>미정</td><td>관할 안내 확인</td></tr>
<tr><th scope="row">첫 물류</th><td>메뉴 범위 필요</td><td>미정</td><td>품목과 보관량 확인</td></tr>
<tr><th scope="row">임대 보증 관련 비용</th><td>점포 계약 필요</td><td>미정</td><td>임대 조건 직접 확인</td></tr>
</tbody>
<tfoot><tr><th scope="row" colspan="2">합계</th><td colspan="2">계산하지 않음 · 확인되지 않은 숫자를 더하지 않습니다</td></tr></tfoot>
</table>
</div>
<p class="cost-warning">평균 매출, 예상 수익, 회수 기간과 최저 비용은 근거 자료가 없어 제공하지 않습니다.</p>
</div>
</section>
<section class="section inquiry-section" id="inquiry" aria-labelledby="inquiry-heading">
<div class="wrap inquiry-layout">
<div class="inquiry-intro">
<p class="section-kicker">문의 준비 체험</p>
<h2 id="inquiry-heading">연락처 없이도<br />조건부터 정리하기.</h2>
<p>실제 이름과 연락처를 받지 않습니다. 화면 안에서 상권과 점포 조건을 정리하고 전송 전 검토 상태를 체험합니다.</p>
<div class="privacy-note" role="note">
<strong>실제 개인정보를 입력하지 마세요.</strong>
<p>입력 내용은 서버로 보내거나 저장하지 않습니다. 성공 화면도 실제 접수가 아닙니다.</p>
</div>
</div>
<div class="inquiry-panel">
<ol class="step-indicator" aria-label="문의 준비 단계">
<li class="is-current" data-step-indicator="1" aria-current="step"><span>1</span><small>상권</small></li>
<li data-step-indicator="2"><span>2</span><small>점포</small></li>
<li data-step-indicator="3"><span>3</span><small>검토</small></li>
</ol>
<form id="franchise-form" novalidate>
<section class="form-step" data-step="1" aria-labelledby="step-one-heading">
<p class="step-count">1 / 3</p>
<h3 id="step-one-heading">어느 지역에서 시작할까요?</h3>
<div class="form-field">
<label for="demo-name">예시 호칭</label>
<input id="demo-name" name="demoName" type="text" minlength="2" autocomplete="off" placeholder="실명 대신 예시 호칭" required />
<small>실제 이름을 입력하지 마세요.</small>
<p class="field-error" id="demo-name-error"></p>
</div>
<div class="form-field">
<label for="region">희망 지역</label>
<select id="region" name="region" required>
<option value="">지역 고르기</option><option>서울 동쪽</option><option>서울 서쪽</option><option>서울 남쪽</option><option>서울 북쪽</option><option>수도권</option><option>아직 모름</option>
</select>
<p class="field-error" id="region-error"></p>
</div>
<fieldset class="form-field">
<legend>희망 상권</legend>
<div class="radio-grid">
<label><input type="radio" name="areaKind" value="업무가" required /><span>업무가</span></label>
<label><input type="radio" name="areaKind" value="주거지" /><span>주거지</span></label>
<label><input type="radio" name="areaKind" value="역 주변" /><span>역 주변</span></label>
<label><input type="radio" name="areaKind" value="대학가" /><span>대학가</span></label>
<label><input type="radio" name="areaKind" value="상점가" /><span>상점가</span></label>
<label><input type="radio" name="areaKind" value="아직 모름" /><span>아직 모름</span></label>
</div>
<p class="field-error" id="area-kind-error"></p>
</fieldset>
<div class="form-actions"><button class="button button-primary" type="button" data-next>점포 조건으로</button></div>
</section>
<section class="form-step" data-step="2" aria-labelledby="step-two-heading">
<p class="step-count">2 / 3</p>
<h3 id="step-two-heading">점포는 어느 단계인가요?</h3>
<fieldset class="form-field">
<legend>점포 상태</legend>
<div class="radio-grid">
<label><input type="radio" name="storePossession" value="아직 없음" required /><span>아직 없음</span></label>
<label><input type="radio" name="storePossession" value="찾는 중" /><span>찾는 중</span></label>
<label><input type="radio" name="storePossession" value="협의 중" /><span>협의 중</span></label>
<label><input type="radio" name="storePossession" value="보유함" /><span>보유함</span></label>
</div>
<p class="field-error" id="store-possession-error"></p>
</fieldset>
<div class="form-field area-field" id="area-field" hidden>
<label for="exclusive-area">전용 면적</label>
<div class="unit-input"><input id="exclusive-area" name="exclusiveArea" type="number" min="0.1" step="0.1" inputmode="decimal" /><span>㎡</span></div>
<output id="area-conversion" for="exclusive-area">면적을 입력하면 평으로도 보여 드립니다.</output>
<small>환산값은 적합 판정이 아닙니다.</small>
<p class="field-error" id="exclusive-area-error"></p>
</div>
<div class="form-field">
<label for="opening-window">희망 시작 시기</label>
<select id="opening-window" name="openingWindow" required>
<option value="">시기 고르기</option><option>석 달 안</option><option>석 달에서 여섯 달</option><option>여섯 달에서 열두 달</option><option>아직 모름</option>
</select>
<p class="field-error" id="opening-window-error"></p>
</div>
<fieldset class="form-field">
<legend>매장 운영 경험</legend>
<div class="radio-grid radio-grid-two">
<label><input type="radio" name="experience" value="있음" required /><span>있음</span></label>
<label><input type="radio" name="experience" value="없음" /><span>없음</span></label>
</div>
<p class="field-error" id="experience-error"></p>
</fieldset>
<div class="form-actions split-actions"><button class="button button-ghost" type="button" data-back>이전</button><button class="button button-primary" type="button" data-next>입력 내용 검토</button></div>
</section>
<section class="form-step" data-step="3" aria-labelledby="step-three-heading">
<p class="step-count">3 / 3</p>
<h3 id="step-three-heading">보내기 전에 확인하세요.</h3>
<dl class="review-list" id="review-list">
<div><dt>예시 호칭</dt><dd data-review="demoName">—</dd></div>
<div><dt>희망 지역</dt><dd data-review="region">—</dd></div>
<div><dt>희망 상권</dt><dd data-review="areaKind">—</dd></div>
<div><dt>점포 상태</dt><dd data-review="storePossession">—</dd></div>
<div><dt>전용 면적</dt><dd data-review="exclusiveArea">해당 없음</dd></div>
<div><dt>희망 시기</dt><dd data-review="openingWindow">—</dd></div>
<div><dt>운영 경험</dt><dd data-review="experience">—</dd></div>
</dl>
<label class="consent-check"><input type="checkbox" id="demo-consent" required /><span>실제 문의가 아니며 개인정보를 입력하지 않았음을 확인했습니다.</span></label>
<p class="field-error" id="demo-consent-error"></p>
<div class="form-actions split-actions"><button class="button button-ghost" type="button" data-back>수정하기</button><button class="button button-primary" type="submit" id="submit-inquiry">문의 준비 마치기</button></div>
<p class="submit-state" id="submit-state" role="alert"></p>
</section>
</form>
<section class="inquiry-success" id="inquiry-success" hidden aria-labelledby="success-heading" tabindex="-1">
<span class="success-mark" aria-hidden="true">✓</span>
<p class="section-kicker">화면 체험 완료</p>
<h3 id="success-heading">문의 준비가 끝났어요.</h3>
<p>제출 절차를 보여 주는 예시이며 실제 담당자에게 전송되지 않았습니다.</p>
<div class="success-actions"><button class="button button-ghost" type="button" id="review-again">입력 내용 다시 보기</button><button class="button button-primary" type="button" id="restart-form">처음부터 보기</button></div>
</section>
</div>
</div>
</section>
<section class="franchise-close" aria-labelledby="close-heading">
<div class="wrap close-row">
<div class="close-heading-copy"><p class="section-kicker">철판클럽</p><h2 id="close-heading">숫자를 꾸미지 않아야<br />조건을 제대로 볼 수 있습니다.</h2></div>
<a class="button button-light" href="../">소비자 홈으로 돌아가기</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-grid franchise-foot-grid">
<div><a class="brand brand-foot" href="../"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span>철판클럽</span></a><p>눌러 굽고, 바로 건넨다.</p></div>
<div><strong>가맹 안내</strong><a href="#model">운영 방식</a><a href="#site-fit">상권과 점포</a><a href="#process">개설 절차</a><a href="#cost">비용 항목</a></div>
<div class="foot-truth"><strong>쇼케이스 고지</strong><p>가상 브랜드이며 실제 가맹 모집, 수익 안내, 계약과 문의 전송을 제공하지 않습니다.</p><p class="mono">DESIGN SHOWCASE · 2026</p></div>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,462 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>철판클럽 — 눌러 굽고, 바로 건넨다</title>
<meta
name="description"
content="서울의 빠른 한 끼를 위한 가상 눌림 버거 브랜드 철판클럽의 메뉴, 조합기, 예시 매장과 운영 방식을 살펴보세요."
/>
<meta property="og:title" content="철판클럽 — 눌러 굽고, 바로 건넨다" />
<meta
property="og:description"
content="메뉴를 고르고, 재료를 조합하고, 예시 매장 운영 상태까지 확인하는 국내형 버거 쇼케이스."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="./og.png" />
<meta property="og:image:alt" content="철판 주방에서 포장한 버거를 건네는 장면" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="철판클럽 — 눌러 굽고, 바로 건넨다" />
<meta
name="twitter:description"
content="메뉴를 고르고 재료를 조합해 보는 국내형 버거 브랜드 쇼케이스."
/>
<meta name="twitter:image" content="./og.png" />
<meta name="theme-color" content="#f4f1e9" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/tokens.css?v=1" />
<link rel="stylesheet" href="./styles/site.css?v=1" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%232147e7'/%3E%3Cpath d='M6 9h20M8 16h16M10 23h12' stroke='%23fffdf8' stroke-width='3' stroke-linecap='square'/%3E%3C/svg%3E"
/>
<script>document.documentElement.classList.add("is-js");</script>
<script src="./scripts/site.js?v=1" defer></script>
</head>
<body>
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
<div class="truth-bar" role="note">
<p>쇼케이스용 가상 브랜드 · 가격과 매장 정보는 화면 동작 확인용 예시</p>
</div>
<header class="site-head" id="top">
<div class="wrap head-row">
<a class="brand" href="#top" aria-label="철판클럽 처음으로">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>철판클럽</span>
</a>
<nav class="head-nav" aria-label="주요 메뉴">
<a href="#menu">메뉴</a>
<a href="#builder">조합하기</a>
<a href="#stores">매장 찾기</a>
<a href="./franchise/">가맹 안내</a>
</nav>
<a class="head-action" href="#stores">가까운 매장 보기</a>
</div>
</header>
<main id="main">
<section class="hero" aria-labelledby="hero-heading">
<div class="hero-copy">
<p class="section-kicker">서울 도심 눌림 버거</p>
<h1 id="hero-heading">눌러 굽고,<br />바로 건넨다.</h1>
<p class="hero-lead">
얇게 눌러 가장자리는 바삭하게. 주문 뒤 조립하고 포장해, 퇴근길 한 상자로 건넵니다.
</p>
<div class="hero-actions">
<a class="button button-primary" href="#menu">오늘 메뉴 보기</a>
<a class="text-link" href="#builder">내 버거 조합하기 <span aria-hidden="true">→</span></a>
</div>
<dl class="hero-facts">
<div><dt>굽기</dt><dd>얇은 패티를 주문 뒤 눌러 굽기</dd></div>
<div><dt>받기</dt><dd>포장과 배달 가능 여부를 매장별 표시</dd></div>
</dl>
</div>
<figure class="hero-media image-shell">
<picture>
<source media="(max-width: 820px)" srcset="./assets/hero-handoff-mobile.webp" />
<img
src="./assets/hero-handoff.webp"
width="1600"
height="900"
alt="밝은 철판 주방에서 직원이 포장한 버거를 건네는 모습"
fetchpriority="high"
/>
</picture>
<span class="image-fallback" aria-hidden="true">버거 포장 장면</span>
<figcaption>
<span>철판에서 상자까지</span>
<span>생성 이미지 · 실제 매장 아님</span>
</figcaption>
</figure>
<aside class="hero-order" aria-label="오늘 고르기">
<span class="order-index">01</span>
<p>철판 더블</p>
<strong>예시 단품 8,900원</strong>
<button type="button" data-preset="double">이 조합으로 시작</button>
</aside>
</section>
<section class="service-strip" aria-label="이용 방식">
<div class="wrap service-row">
<p><span aria-hidden="true">●</span> 주문 뒤 조리</p>
<p>단품과 세트</p>
<p>포장 여부 매장별 확인</p>
<p>배달 여부 매장별 확인</p>
<p class="mono">GRIDDLE CLUB</p>
</div>
</section>
<section class="section menu-section" id="menu" aria-labelledby="menu-heading">
<div class="wrap">
<div class="section-head split-head">
<div>
<p class="section-kicker">오늘 고를 메뉴</p>
<h2 id="menu-heading">버거가 먼저 보이게.</h2>
</div>
<p class="section-note">
모든 가격은 조합기 동작을 보여 주기 위한 예시입니다. 실제 판매와 주문은 이루어지지 않습니다.
</p>
</div>
<div class="menu-toolbar" role="group" aria-label="메뉴 분류">
<button type="button" class="filter-button is-active" data-menu-filter="all" aria-pressed="true">전체</button>
<button type="button" class="filter-button" data-menu-filter="burger" aria-pressed="false">버거</button>
<button type="button" class="filter-button" data-menu-filter="side" aria-pressed="false">곁들임</button>
<button type="button" class="filter-button" data-menu-filter="drink" aria-pressed="false">음료</button>
</div>
<div class="menu-grid" id="menu-grid">
<article class="menu-item menu-item-featured" data-menu-category="burger">
<div class="menu-photo image-shell">
<img
src="./assets/signature-double.webp"
width="1400"
height="933"
alt="철판 패티 두 장과 치즈, 구운 양파를 넣은 버거"
loading="lazy"
/>
<span class="image-fallback" aria-hidden="true">철판 더블 사진</span>
<span class="menu-number">01</span>
</div>
<div class="menu-body">
<div><p class="menu-state">대표 메뉴</p><h3>철판 더블</h3></div>
<p>얇은 패티 두 장, 치즈, 구운 양파, 피클, 집 소스.</p>
<div class="menu-foot">
<span><small>예시 단품</small> 8,900원</span>
<button type="button" data-preset="double">조합하기</button>
</div>
</div>
</article>
<article class="menu-item menu-item-graphic" data-menu-category="burger">
<div class="ingredient-stack" aria-hidden="true">
<span class="bun"></span><span class="pickle"></span><span class="cheese"></span><span class="patty"></span><span class="bun bottom"></span>
<b>02</b>
</div>
<div class="menu-body">
<div><p class="menu-state">가볍게</p><h3>철판 싱글</h3></div>
<p>패티 한 장, 치즈, 피클, 집 소스. 단품으로 빠르게.</p>
<div class="menu-foot">
<span><small>예시 단품</small> 6,900원</span>
<button type="button" data-preset="single">조합하기</button>
</div>
</div>
</article>
<article class="menu-item menu-item-blue" data-menu-category="burger">
<div class="menu-poster" aria-hidden="true"><span>03</span><i></i><i></i><i></i></div>
<div class="menu-body">
<div><p class="menu-state">매운 피클</p><h3>고추 피클 더블</h3></div>
<p>고추 피클과 매운 소스를 더한 더블. 맵기는 예시입니다.</p>
<div class="menu-foot">
<span><small>예시 단품</small> 9,400원</span>
<button type="button" data-preset="spicy">조합하기</button>
</div>
</div>
</article>
<article class="menu-item menu-item-small" data-menu-category="side">
<div class="menu-body">
<div><p class="menu-state">곁들임</p><h3>얇은 감자</h3></div>
<p>가늘게 튀겨 소금으로 마무리합니다.</p>
<div class="menu-foot"><span><small>예시 가격</small> 3,600원</span></div>
</div>
</article>
<article class="menu-item menu-item-small" data-menu-category="side">
<div class="menu-body">
<div><p class="menu-state">곁들임</p><h3>양파 한 컵</h3></div>
<p>철판에서 단맛을 낸 양파를 따로 담습니다.</p>
<div class="menu-foot"><span><small>예시 가격</small> 2,800원</span></div>
</div>
</article>
<article class="menu-item menu-item-small" data-menu-category="drink">
<div class="menu-body">
<div><p class="menu-state">음료</p><h3>탄산 한 잔</h3></div>
<p>매장에서 고른 음료 한 잔. 제품별 성분을 확인해 주세요.</p>
<div class="menu-foot"><span><small>예시 가격</small> 2,400원</span></div>
</div>
</article>
</div>
<div class="empty-state" id="menu-empty" hidden role="status">
<strong>이 분류에 준비된 예시 메뉴가 없어요.</strong>
<button type="button" data-show-all-menu>모든 메뉴 보기</button>
</div>
</div>
</section>
<section class="section builder-section" id="builder" aria-labelledby="builder-heading">
<div class="wrap">
<div class="section-head split-head">
<div>
<p class="section-kicker">버거 조합기</p>
<h2 id="builder-heading">상자에 담길 내용을<br />먼저 정하세요.</h2>
</div>
<p class="section-note">재료 선택과 예시 총액은 실제로 바뀝니다. 주문 전송 기능은 없습니다.</p>
</div>
<div class="builder-layout">
<form class="builder-form" id="burger-builder" novalidate>
<fieldset>
<legend><span>01</span> 맛 고르기</legend>
<div class="choice-row">
<label><input type="radio" name="flavor" value="classic" checked /><span><strong>기본</strong><small>집 소스와 피클</small></span></label>
<label><input type="radio" name="flavor" value="spicy" /><span><strong>매운 피클</strong><small>고추 피클과 매운 소스</small></span></label>
</div>
</fieldset>
<fieldset>
<legend><span>02</span> 크기와 치즈</legend>
<div class="choice-row choice-row-compact">
<label><input type="radio" name="patty" value="1" /><span>패티 한 장</span></label>
<label><input type="radio" name="patty" value="2" checked /><span>패티 두 장</span></label>
</div>
<label class="field-label" for="cheese">치즈</label>
<select id="cheese" name="cheese">
<option value="0">치즈 없음</option>
<option value="1" selected>치즈 한 장</option>
<option value="2">치즈 두 장</option>
</select>
</fieldset>
<fieldset>
<legend><span>03</span> 양파와 피클</legend>
<div class="select-grid">
<label>구운 양파
<select name="onion"><option value="none">없음</option><option value="regular" selected>기본</option><option value="extra">많이</option></select>
</label>
<label>피클
<select name="pickle"><option value="none">없음</option><option value="regular" selected>기본</option><option value="extra">많이</option></select>
</label>
<label>소스
<select name="sauce"><option value="none">없음</option><option value="regular" selected>기본</option><option value="side">따로</option></select>
</label>
</div>
</fieldset>
<fieldset>
<legend><span>04</span> 단품 또는 세트</legend>
<div class="choice-row choice-row-compact">
<label><input type="radio" name="serving" value="single" checked /><span>단품</span></label>
<label><input type="radio" name="serving" value="set" /><span>세트 더하기</span></label>
</div>
<div class="set-options" id="set-options">
<p id="set-heading">세트 구성</p>
<div class="select-grid">
<label>곁들임
<select name="side" disabled><option value="fries">얇은 감자</option><option value="onion">양파 한 컵</option></select>
</label>
<label>음료
<select name="drink" disabled><option value="cola">탄산 한 잔</option><option value="water">물 한 병</option></select>
</label>
</div>
</div>
</fieldset>
<fieldset>
<legend><span>05</span> 받을 예시 매장</legend>
<label class="field-label" for="builder-store">매장</label>
<select id="builder-store" name="store">
<option value="seongsu">성수 예시점 · 영업 중 예시</option>
<option value="yeonnam">연남 예시점 · 영업 종료 예시</option>
<option value="mullae">문래 예시점 · 오늘은 쉬어요</option>
</select>
<p class="field-help">매장과 영업 상태도 화면 동작용 예시입니다.</p>
</fieldset>
</form>
<aside class="builder-summary" aria-labelledby="summary-heading">
<div class="summary-photo image-shell">
<img
src="./assets/signature-double.webp"
width="1400"
height="933"
alt="조합 결과를 설명하는 철판 더블 버거"
loading="lazy"
/>
<span class="image-fallback" aria-hidden="true">조합 버거 사진</span>
<span class="summary-stamp">화면 예시</span>
</div>
<div class="summary-body">
<p class="section-kicker">고른 내용</p>
<h3 id="summary-heading">철판 더블</h3>
<p id="summary-lines">패티 두 장 / 치즈 한 장 / 양파 기본 / 피클 기본 / 소스 기본</p>
<dl class="summary-list">
<div><dt>받기</dt><dd id="summary-serving">단품</dd></div>
<div><dt>예시 매장</dt><dd id="summary-store">성수 예시점</dd></div>
<div><dt>상태</dt><dd id="summary-store-state">영업 중 예시</dd></div>
</dl>
<div class="allergen-note" id="allergen-note">
<strong>알레르기 예시</strong>
<p id="summary-allergens">알, 우유, 대두, 밀, 쇠고기</p>
<small>같은 철판과 조리 도구를 함께 씁니다. 재료를 빼도 알레르기 안전을 보장하지 않습니다.</small>
</div>
<div class="summary-total"><span>화면 계산용 예시 총액</span><strong id="summary-total">8,900원</strong></div>
<p class="truth-inline">실제 주문이나 결제로 전송되지 않습니다.</p>
<button class="button button-primary button-full" type="button" id="save-builder">선택 내용 저장</button>
<a class="button button-ghost button-full" href="#stores" id="other-store-link">다른 매장 찾기</a>
<p class="save-status" id="save-status" role="status" aria-live="polite"></p>
</div>
</aside>
</div>
</div>
</section>
<section class="section process-section" id="process" aria-labelledby="process-heading">
<div class="wrap process-grid">
<div class="process-copy">
<p class="section-kicker">철판에서 상자까지</p>
<h2 id="process-heading">빠르다는 말 대신,<br />순서를 보여 줍니다.</h2>
<ol class="process-list">
<li><span>01</span><div><strong>누르기</strong><p>얇은 패티를 뜨거운 철판에 눌러 가장자리를 만듭니다.</p></div></li>
<li><span>02</span><div><strong>겹치기</strong><p>치즈, 구운 양파, 피클을 고른 순서대로 올립니다.</p></div></li>
<li><span>03</span><div><strong>건네기</strong><p>유산지와 상자에 담고 포장 상태를 확인합니다.</p></div></li>
</ol>
</div>
<figure class="process-media image-shell">
<img
src="./assets/griddle-press.webp"
width="1400"
height="933"
alt="조리사가 철판에서 패티를 눌러 굽고 양파를 익히는 모습"
loading="lazy"
/>
<span class="image-fallback" aria-hidden="true">철판 조리 장면</span>
<figcaption>생성 이미지 · 실제 조리 시설 아님</figcaption>
</figure>
</div>
</section>
<section class="section stores-section" id="stores" aria-labelledby="stores-heading">
<div class="wrap">
<div class="section-head split-head">
<div><p class="section-kicker">매장 찾기</p><h2 id="stores-heading">지금 받을 수 있는지<br />먼저 확인하세요.</h2></div>
<p class="section-note">주소, 전화, 거리와 운영 상태를 꾸며내지 않습니다. 아래 세 곳은 상태 화면을 위한 예시 매장입니다.</p>
</div>
<div class="store-tools">
<div class="search-box">
<input id="store-search" type="search" placeholder="동네 또는 매장명 검색" autocomplete="off" aria-label="예시 매장 검색" />
<button type="button" id="clear-store-search" hidden>지우기</button>
</div>
<div class="region-filters" role="group" aria-label="지역 고르기">
<button type="button" class="is-active" data-region="all" aria-pressed="true">전체</button>
<button type="button" data-region="성동" aria-pressed="false">성동</button>
<button type="button" data-region="마포" aria-pressed="false">마포</button>
<button type="button" data-region="영등포" aria-pressed="false">영등포</button>
</div>
</div>
<div class="store-layout">
<div class="store-list" id="store-list">
<article class="store-card is-selected" data-store="seongsu" data-region="성동" data-search="성수 예시점 성동 성수동" tabindex="-1">
<div class="store-card-top"><span class="store-state state-open">영업 중 예시</span><span>예시 매장</span></div>
<h3>성수 예시점</h3>
<p>서울 성동구 성수동 일대 · 상세 주소 없음</p>
<dl><div><dt>운영</dt><dd>예시 11:00–21:30</dd></div><div><dt>마지막 주문</dt><dd>예시 21:00</dd></div></dl>
<ul aria-label="이용 방식"><li>포장 가능 예시</li><li>배달 가능 예시</li></ul>
<button type="button" data-select-store="seongsu">이 매장으로 조합 보기</button>
</article>
<article class="store-card" data-store="yeonnam" data-region="마포" data-search="연남 예시점 마포 연남동" tabindex="-1">
<div class="store-card-top"><span class="store-state state-closed">영업 종료 예시</span><span>예시 매장</span></div>
<h3>연남 예시점</h3>
<p>서울 마포구 연남동 일대 · 상세 주소 없음</p>
<dl><div><dt>운영</dt><dd>예시 11:30–20:30</dd></div><div><dt>마지막 주문</dt><dd>오늘 마감</dd></div></dl>
<ul aria-label="이용 방식"><li>포장 마감</li><li>배달 마감</li></ul>
<button type="button" data-select-store="yeonnam">종료 상태로 조합 보기</button>
</article>
<article class="store-card" data-store="mullae" data-region="영등포" data-search="문래 예시점 영등포 문래동" tabindex="-1">
<div class="store-card-top"><span class="store-state state-pause">오늘은 쉬어요</span><span>예시 매장</span></div>
<h3>문래 예시점</h3>
<p>서울 영등포구 문래동 일대 · 상세 주소 없음</p>
<dl><div><dt>운영</dt><dd>임시 휴무 예시</dd></div><div><dt>다음 확인</dt><dd>매장별 안내 필요</dd></div></dl>
<ul aria-label="이용 방식"><li>포장 불가</li><li>배달 불가</li></ul>
<button type="button" data-select-store="mullae">휴무 상태로 조합 보기</button>
</article>
</div>
<aside class="store-map" aria-label="예시 매장 분포 그림">
<div class="map-grid" aria-hidden="true"></div>
<span class="map-river" aria-hidden="true"></span>
<button type="button" class="map-pin pin-seongsu is-active" data-map-store="seongsu" aria-label="성수 예시점 고르기"><span>성수</span></button>
<button type="button" class="map-pin pin-yeonnam" data-map-store="yeonnam" aria-label="연남 예시점 고르기"><span>연남</span></button>
<button type="button" class="map-pin pin-mullae" data-map-store="mullae" aria-label="문래 예시점 고르기"><span>문래</span></button>
<p>실제 지도와 위치가 아닌 화면 설명용 도식</p>
</aside>
</div>
<div class="empty-state store-empty" id="store-empty" hidden role="status">
<strong>찾는 조건과 맞는 예시 매장이 없어요.</strong>
<p>검색어와 지역 조건을 지우고 다시 확인해 주세요.</p>
<button type="button" id="reset-store-search">검색 조건 지우기</button>
</div>
</div>
</section>
<section class="franchise-callout" aria-labelledby="franchise-heading">
<figure class="franchise-image image-shell">
<img
src="./assets/kitchen-flow.webp"
width="1400"
height="933"
alt="좁고 밝은 주방에서 두 직원이 철판 조리와 포장을 나누어 맡는 모습"
loading="lazy"
/>
<span class="image-fallback" aria-hidden="true">주방 운영 동선 장면</span>
</figure>
<div class="franchise-copy">
<p class="section-kicker">가맹을 생각한다면</p>
<h2 id="franchise-heading">매출 약속보다<br />운영 조건부터.</h2>
<p>상권, 점포, 면적, 조리 동선과 비용 미정 항목을 먼저 확인할 수 있게 분리했습니다.</p>
<a class="button button-light" href="./franchise/">가맹 운영 조건 보기 <span aria-hidden="true">→</span></a>
<small>실제 가맹 모집과 상담이 아닌 쇼케이스용 흐름입니다.</small>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-grid">
<div><a class="brand brand-foot" href="#top"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span>철판클럽</span></a><p>눌러 굽고, 바로 건넨다.</p></div>
<div><strong>살펴보기</strong><a href="#menu">메뉴</a><a href="#builder">조합기</a><a href="#stores">예시 매장</a><a href="./franchise/">가맹 안내</a></div>
<div><strong>정보 안내</strong><p>알레르기 정보도 예시입니다.</p><p>원산지와 영양 정보는 실제 제품 자료가 아닙니다.</p></div>
<div class="foot-truth"><strong>쇼케이스 고지</strong><p>가상 브랜드이며 실제 주문, 결제, 매장 방문, 가맹 문의를 제공하지 않습니다.</p><p class="mono">DESIGN SHOWCASE · 2026</p></div>
</div>
</footer>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

View file

@ -0,0 +1,300 @@
(() => {
"use strict";
const fixture = new URLSearchParams(window.location.search).get("fixture") || "";
const form = document.querySelector("#franchise-form");
const formSteps = [...document.querySelectorAll("[data-step]")];
const indicators = [...document.querySelectorAll("[data-step-indicator]")];
const indicatorList = document.querySelector(".step-indicator");
const areaField = document.querySelector("#area-field");
const areaInput = document.querySelector("#exclusive-area");
const areaOutput = document.querySelector("#area-conversion");
const successPanel = document.querySelector("#inquiry-success");
const submitState = document.querySelector("#submit-state");
const submitButton = document.querySelector("#submit-inquiry");
let currentStep = 1;
const markBrokenImage = (image) => {
const shell = image.closest(".image-shell");
if (shell) shell.classList.add("is-broken");
};
document.querySelectorAll(".image-shell img").forEach((image) => {
image.addEventListener("error", () => markBrokenImage(image));
if (image.complete && image.naturalWidth === 0) markBrokenImage(image);
});
if (!form) return;
const getRadioValue = (name) => form.querySelector(`input[name="${name}"]:checked`)?.value || "";
const getSelectValue = (name) => {
const field = form.elements.namedItem(name);
return field instanceof HTMLSelectElement ? field.value : "";
};
const getInputValue = (name) => {
const field = form.elements.namedItem(name);
return field instanceof HTMLInputElement ? field.value.trim() : "";
};
const setStep = (step) => {
currentStep = step;
formSteps.forEach((section) => section.classList.toggle("is-current", Number(section.dataset.step) === step));
indicators.forEach((item) => {
const active = Number(item.dataset.stepIndicator) === step;
item.classList.toggle("is-current", active);
if (active) item.setAttribute("aria-current", "step");
else item.removeAttribute("aria-current");
});
const heading = form.querySelector(`[data-step="${step}"] h3`);
if (heading instanceof HTMLElement) {
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
}
};
const clearFieldError = (fieldId, errorId) => {
const field = document.querySelector(`#${fieldId}`);
const error = document.querySelector(`#${errorId}`);
const wrapper = field?.closest(".form-field");
wrapper?.classList.remove("has-error");
field?.removeAttribute("aria-invalid");
field?.removeAttribute("aria-describedby");
if (error) error.textContent = "";
};
const setFieldError = (fieldId, errorId, message) => {
const field = document.querySelector(`#${fieldId}`);
const error = document.querySelector(`#${errorId}`);
const wrapper = field?.closest(".form-field");
wrapper?.classList.add("has-error");
field?.setAttribute("aria-invalid", "true");
field?.setAttribute("aria-describedby", errorId);
if (error) error.textContent = message;
return field;
};
const clearRadioError = (name, errorId) => {
const radios = [...form.querySelectorAll(`input[name="${name}"]`)];
const wrapper = radios[0]?.closest(".form-field");
wrapper?.classList.remove("has-error");
radios.forEach((radio) => {
radio.removeAttribute("aria-invalid");
radio.removeAttribute("aria-describedby");
});
const error = document.querySelector(`#${errorId}`);
if (error) error.textContent = "";
};
const setRadioError = (name, errorId, message) => {
const radios = [...form.querySelectorAll(`input[name="${name}"]`)];
const wrapper = radios[0]?.closest(".form-field");
wrapper?.classList.add("has-error");
radios.forEach((radio) => {
radio.setAttribute("aria-invalid", "true");
radio.setAttribute("aria-describedby", errorId);
});
const error = document.querySelector(`#${errorId}`);
if (error) error.textContent = message;
return radios[0];
};
const validateStepOne = () => {
const invalid = [];
clearFieldError("demo-name", "demo-name-error");
clearFieldError("region", "region-error");
clearRadioError("areaKind", "area-kind-error");
if (getInputValue("demoName").length < 2) {
invalid.push(setFieldError("demo-name", "demo-name-error", "예시 호칭을 두 글자 이상 입력해 주세요."));
}
if (!getSelectValue("region")) {
invalid.push(setFieldError("region", "region-error", "희망 지역을 골라 주세요."));
}
if (!getRadioValue("areaKind")) {
invalid.push(setRadioError("areaKind", "area-kind-error", "희망 상권을 골라 주세요."));
}
invalid[0]?.focus();
return invalid.length === 0;
};
const requiresArea = () => ["협의 중", "보유함"].includes(getRadioValue("storePossession"));
const updateAreaField = () => {
const required = requiresArea();
if (areaField) areaField.hidden = !required;
if (areaInput instanceof HTMLInputElement) {
areaInput.required = required;
if (!required) {
areaInput.value = "";
clearFieldError("exclusive-area", "exclusive-area-error");
if (areaOutput) areaOutput.textContent = "면적을 입력하면 평으로도 보여 드립니다.";
}
}
};
const updateAreaConversion = () => {
if (!(areaInput instanceof HTMLInputElement) || !areaOutput) return;
const squareMeters = Number(areaInput.value);
if (!Number.isFinite(squareMeters) || squareMeters <= 0) {
areaOutput.textContent = "면적을 입력하면 평으로도 보여 드립니다.";
return;
}
const pyeong = Math.round((squareMeters / 3.3058) * 10) / 10;
areaOutput.textContent = `${squareMeters.toLocaleString("ko-KR")}㎡ · 약 ${pyeong.toLocaleString("ko-KR")}평`;
};
const validateStepTwo = () => {
const invalid = [];
clearRadioError("storePossession", "store-possession-error");
clearFieldError("exclusive-area", "exclusive-area-error");
clearFieldError("opening-window", "opening-window-error");
clearRadioError("experience", "experience-error");
if (!getRadioValue("storePossession")) {
invalid.push(setRadioError("storePossession", "store-possession-error", "점포 상태를 골라 주세요."));
}
if (requiresArea()) {
const value = Number(getInputValue("exclusiveArea"));
if (!Number.isFinite(value) || value <= 0) {
invalid.push(setFieldError("exclusive-area", "exclusive-area-error", "면적은 0보다 큰 숫자로 입력해 주세요."));
}
}
if (!getSelectValue("openingWindow")) {
invalid.push(setFieldError("opening-window", "opening-window-error", "희망 시작 시기를 골라 주세요."));
}
if (!getRadioValue("experience")) {
invalid.push(setRadioError("experience", "experience-error", "운영 경험 여부를 골라 주세요."));
}
invalid[0]?.focus();
return invalid.length === 0;
};
const reviewValues = () => {
const squareMeters = Number(getInputValue("exclusiveArea"));
const areaLabel = requiresArea() && squareMeters > 0
? `${squareMeters.toLocaleString("ko-KR")}㎡ · 약 ${(Math.round((squareMeters / 3.3058) * 10) / 10).toLocaleString("ko-KR")}평`
: "해당 없음";
const values = {
demoName: getInputValue("demoName"),
region: getSelectValue("region"),
areaKind: getRadioValue("areaKind"),
storePossession: getRadioValue("storePossession"),
exclusiveArea: areaLabel,
openingWindow: getSelectValue("openingWindow"),
experience: getRadioValue("experience"),
};
Object.entries(values).forEach(([key, value]) => {
const target = document.querySelector(`[data-review="${key}"]`);
if (target) target.textContent = value || "—";
});
};
form.querySelectorAll("[data-next]").forEach((button) => {
button.addEventListener("click", () => {
if (currentStep === 1 && validateStepOne()) setStep(2);
else if (currentStep === 2 && validateStepTwo()) {
reviewValues();
setStep(3);
}
});
});
form.querySelectorAll("[data-back]").forEach((button) => {
button.addEventListener("click", () => setStep(Math.max(1, currentStep - 1)));
});
form.querySelectorAll("input[name='storePossession']").forEach((input) => {
input.addEventListener("change", updateAreaField);
});
areaInput?.addEventListener("input", updateAreaConversion);
form.addEventListener("input", (event) => {
const field = event.target;
if (!(field instanceof HTMLElement) || !field.id) return;
const errorId = `${field.id}-error`;
if (document.querySelector(`#${errorId}`)) clearFieldError(field.id, errorId);
});
form.addEventListener("change", (event) => {
const field = event.target;
if (!(field instanceof HTMLInputElement)) return;
if (field.type === "radio") {
const names = {
areaKind: "area-kind-error",
storePossession: "store-possession-error",
experience: "experience-error",
};
if (names[field.name]) clearRadioError(field.name, names[field.name]);
}
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const consent = document.querySelector("#demo-consent");
const consentError = document.querySelector("#demo-consent-error");
if (consent instanceof HTMLInputElement) {
consent.removeAttribute("aria-invalid");
consent.removeAttribute("aria-describedby");
}
if (consentError) consentError.textContent = "";
if (!(consent instanceof HTMLInputElement) || !consent.checked) {
consent?.setAttribute("aria-invalid", "true");
consent?.setAttribute("aria-describedby", "demo-consent-error");
if (consentError) consentError.textContent = "문의 체험 안내를 확인해 주세요.";
consent?.focus();
return;
}
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = true;
submitButton.textContent = "화면 상태 확인 중";
}
if (submitState) submitState.textContent = "";
window.setTimeout(() => {
if (fixture === "inquiry-fail") {
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = false;
submitButton.textContent = "다시 시도하기";
}
if (submitState) {
submitState.textContent = "지금은 문의 준비 상태를 바꾸지 못했어요. 입력 내용은 화면에 그대로 두었습니다.";
}
return;
}
form.hidden = true;
if (indicatorList) indicatorList.hidden = true;
if (successPanel) {
successPanel.hidden = false;
successPanel.focus();
}
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = false;
submitButton.textContent = "문의 준비 마치기";
}
}, 450);
});
document.querySelector("#review-again")?.addEventListener("click", () => {
if (successPanel) successPanel.hidden = true;
form.hidden = false;
if (indicatorList) indicatorList.hidden = false;
setStep(3);
});
document.querySelector("#restart-form")?.addEventListener("click", () => {
form.reset();
form.hidden = false;
if (successPanel) successPanel.hidden = true;
if (indicatorList) indicatorList.hidden = false;
if (submitState) submitState.textContent = "";
document.querySelectorAll(".field-error").forEach((error) => { error.textContent = ""; });
document.querySelectorAll(".has-error").forEach((field) => field.classList.remove("has-error"));
updateAreaField();
setStep(1);
});
updateAreaField();
setStep(1);
})();

View file

@ -0,0 +1,261 @@
(() => {
"use strict";
const fixture = new URLSearchParams(window.location.search).get("fixture") || "";
const formatWon = (value) => `${new Intl.NumberFormat("ko-KR").format(value)}원`;
const markBrokenImage = (image) => {
const shell = image.closest(".image-shell");
if (shell) shell.classList.add("is-broken");
};
document.querySelectorAll(".image-shell img").forEach((image) => {
image.addEventListener("error", () => markBrokenImage(image));
if (image.complete && image.naturalWidth === 0) markBrokenImage(image);
});
const menuButtons = [...document.querySelectorAll("[data-menu-filter]")];
const menuItems = [...document.querySelectorAll("[data-menu-category]")];
const menuEmpty = document.querySelector("#menu-empty");
const setMenuFilter = (filter) => {
const forceMenuEmpty = new URLSearchParams(window.location.search).get("fixture") === "menu-empty";
let visibleCount = 0;
menuButtons.forEach((button) => {
const active = button.dataset.menuFilter === filter;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", String(active));
});
menuItems.forEach((item) => {
const visible = !forceMenuEmpty && (filter === "all" || item.dataset.menuCategory === filter);
item.hidden = !visible;
if (visible) visibleCount += 1;
});
if (menuEmpty) menuEmpty.hidden = visibleCount > 0;
};
menuButtons.forEach((button) => {
button.addEventListener("click", () => setMenuFilter(button.dataset.menuFilter || "all"));
});
document.querySelector("[data-show-all-menu]")?.addEventListener("click", () => {
if (fixture === "menu-empty") {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.delete("fixture");
window.history.replaceState({}, "", nextUrl);
}
setMenuFilter("all");
});
if (fixture === "menu-empty") setMenuFilter("all");
const builder = document.querySelector("#burger-builder");
const builderSection = document.querySelector("#builder");
const setOptions = document.querySelector("#set-options");
const summaryHeading = document.querySelector("#summary-heading");
const summaryLines = document.querySelector("#summary-lines");
const summaryServing = document.querySelector("#summary-serving");
const summaryStore = document.querySelector("#summary-store");
const summaryStoreState = document.querySelector("#summary-store-state");
const summaryAllergens = document.querySelector("#summary-allergens");
const summaryTotal = document.querySelector("#summary-total");
const saveButton = document.querySelector("#save-builder");
const saveStatus = document.querySelector("#save-status");
const stores = {
seongsu: { label: "성수 예시점", state: "영업 중 예시", available: true },
yeonnam: { label: "연남 예시점", state: "오늘 영업 종료 예시", available: false },
mullae: { label: "문래 예시점", state: "오늘은 쉬어요", available: false },
};
const fieldValue = (name) => {
const field = builder?.elements.namedItem(name);
if (!field) return "";
if (field instanceof RadioNodeList) return field.value;
return field.value;
};
const setRadio = (name, value) => {
const radio = builder?.querySelector(`input[name="${name}"][value="${value}"]`);
if (radio instanceof HTMLInputElement) radio.checked = true;
};
const builderPrice = ({ flavor, patty, cheese, serving }) => {
const flavorBase = flavor === "spicy" ? 7400 : 6900;
const pattyAdd = Number(patty) === 2 ? 2000 : 0;
const cheeseAdd = Number(cheese) === 0 ? -400 : Number(cheese) === 2 ? 400 : 0;
const setAdd = serving === "set" ? 4100 : 0;
return flavorBase + pattyAdd + cheeseAdd + setAdd;
};
const updateBuilder = () => {
if (!builder) return;
const flavor = fieldValue("flavor");
const patty = fieldValue("patty");
const cheese = fieldValue("cheese");
const onion = fieldValue("onion");
const pickle = fieldValue("pickle");
const sauce = fieldValue("sauce");
const serving = fieldValue("serving");
const storeKey = fieldValue("store");
const store = stores[storeKey] || stores.seongsu;
const soldOut = fixture === "sold-out";
const setEnabled = serving === "set";
builder.querySelectorAll("select[name='side'], select[name='drink']").forEach((select) => {
select.disabled = !setEnabled;
});
setOptions?.classList.toggle("is-disabled", !setEnabled);
const names = {
onion: { none: "양파 없음", regular: "양파 기본", extra: "양파 많이" },
pickle: { none: "피클 없음", regular: "피클 기본", extra: "피클 많이" },
sauce: { none: "소스 없음", regular: "소스 기본", side: "소스 따로" },
};
const pattyLabel = Number(patty) === 2 ? "더블" : "싱글";
const flavorLabel = flavor === "spicy" ? "고추 피클" : "철판";
const cheeseLabel = Number(cheese) === 0 ? "치즈 없음" : `치즈 ${cheese === "2" ? "두" : "한"} 장`;
const productName = `${flavorLabel} ${pattyLabel}`;
const servingLabel = serving === "set" ? "세트 · 얇은 감자와 음료" : "단품";
if (summaryHeading) summaryHeading.textContent = productName;
if (summaryLines) {
summaryLines.textContent = `패티 ${patty === "2" ? "두" : "한"} 장 / ${cheeseLabel} / ${names.onion[onion]} / ${names.pickle[pickle]} / ${names.sauce[sauce]}`;
}
if (summaryServing) summaryServing.textContent = servingLabel;
if (summaryStore) summaryStore.textContent = store.label;
if (summaryStoreState) summaryStoreState.textContent = soldOut ? "오늘 품절 예시" : store.state;
const allergens = ["알", "대두", "밀", "쇠고기"];
if (Number(cheese) > 0) allergens.splice(1, 0, "우유");
if (flavor === "spicy") allergens.push("토마토");
if (summaryAllergens) summaryAllergens.textContent = allergens.join(", ");
if (summaryTotal) summaryTotal.textContent = formatWon(builderPrice({ flavor, patty, cheese, serving }));
if (saveButton) {
saveButton.disabled = soldOut || !store.available;
if (soldOut) saveButton.textContent = "오늘 품절 예시";
else if (!store.available) saveButton.textContent = "다른 매장을 골라 주세요";
else saveButton.textContent = "선택 내용 저장";
}
if (saveStatus) {
saveStatus.textContent = "";
saveStatus.classList.remove("is-error");
}
};
builder?.addEventListener("change", updateBuilder);
const applyPreset = (preset) => {
if (!builder) return;
if (preset === "single") {
setRadio("flavor", "classic");
setRadio("patty", "1");
} else if (preset === "spicy") {
setRadio("flavor", "spicy");
setRadio("patty", "2");
} else {
setRadio("flavor", "classic");
setRadio("patty", "2");
}
updateBuilder();
builderSection?.scrollIntoView({ behavior: "smooth", block: "start" });
window.setTimeout(() => builder.querySelector("input:checked")?.focus({ preventScroll: true }), 350);
};
document.querySelectorAll("[data-preset]").forEach((button) => {
button.addEventListener("click", () => applyPreset(button.dataset.preset || "double"));
});
saveButton?.addEventListener("click", () => {
if (!builder || !saveStatus) return;
const snapshot = Object.fromEntries(new FormData(builder).entries());
try {
if (fixture === "save-fail") throw new Error("fixture");
sessionStorage.setItem("griddle-club-demo-selection", JSON.stringify(snapshot));
saveStatus.classList.remove("is-error");
saveStatus.textContent = "이 기기의 현재 탭에 선택 내용을 남겼어요. 실제 주문으로 전송되지 않습니다.";
} catch {
saveStatus.classList.add("is-error");
saveStatus.textContent = "선택 내용을 남기지 못했어요. 고른 내용은 화면에 그대로 두었습니다.";
}
});
const storeCards = [...document.querySelectorAll("[data-store]")];
const mapPins = [...document.querySelectorAll("[data-map-store]")];
const storeSearch = document.querySelector("#store-search");
const clearStoreSearch = document.querySelector("#clear-store-search");
const storeEmpty = document.querySelector("#store-empty");
const regionButtons = [...document.querySelectorAll("[data-region]")];
let activeRegion = "all";
const selectStore = (storeKey, shouldMove = false) => {
const select = builder?.elements.namedItem("store");
if (select instanceof HTMLSelectElement) select.value = storeKey;
storeCards.forEach((card) => card.classList.toggle("is-selected", card.dataset.store === storeKey));
mapPins.forEach((pin) => pin.classList.toggle("is-active", pin.dataset.mapStore === storeKey));
updateBuilder();
if (shouldMove) builderSection?.scrollIntoView({ behavior: "smooth", block: "start" });
};
document.querySelectorAll("[data-select-store]").forEach((button) => {
button.addEventListener("click", () => selectStore(button.dataset.selectStore, true));
});
mapPins.forEach((button) => {
button.addEventListener("click", () => selectStore(button.dataset.mapStore, false));
});
const filterStores = () => {
const query = storeSearch instanceof HTMLInputElement ? storeSearch.value.trim().toLocaleLowerCase("ko-KR") : "";
let visibleCount = 0;
storeCards.forEach((card) => {
const regionMatch = activeRegion === "all" || card.dataset.region === activeRegion;
const queryMatch = !query || (card.dataset.search || "").toLocaleLowerCase("ko-KR").includes(query);
const visible = fixture !== "store-none" && regionMatch && queryMatch;
card.hidden = !visible;
if (visible) visibleCount += 1;
const pin = mapPins.find((item) => item.dataset.mapStore === card.dataset.store);
if (pin) pin.hidden = !visible;
});
if (storeEmpty) storeEmpty.hidden = visibleCount > 0;
if (clearStoreSearch) clearStoreSearch.hidden = !query;
};
storeSearch?.addEventListener("input", filterStores);
clearStoreSearch?.addEventListener("click", () => {
if (storeSearch instanceof HTMLInputElement) {
storeSearch.value = "";
storeSearch.focus();
}
filterStores();
});
regionButtons.forEach((button) => {
button.addEventListener("click", () => {
activeRegion = button.dataset.region || "all";
regionButtons.forEach((item) => {
const active = item === button;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", String(active));
});
filterStores();
});
});
document.querySelector("#reset-store-search")?.addEventListener("click", () => {
activeRegion = "all";
if (storeSearch instanceof HTMLInputElement) storeSearch.value = "";
regionButtons.forEach((button) => {
const active = button.dataset.region === "all";
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", String(active));
});
filterStores();
storeSearch?.focus();
});
updateBuilder();
filterStores();
})();

View file

@ -0,0 +1,979 @@
.franchise-page {
background: var(--paper-bright);
}
.franchise-hero {
min-height: min(820px, calc(100svh - var(--header-height) - 30px));
display: grid;
grid-template-columns: minmax(360px, 0.92fr) minmax(0, 1.08fr);
background: var(--ink);
color: white;
}
.franchise-hero-copy {
align-self: center;
padding: clamp(76px, 10vw, 140px) var(--gutter);
padding-left: max(var(--gutter), calc((100vw - var(--wrap)) / 2));
}
.franchise-hero-copy .section-kicker {
color: #8ea5ff;
}
.franchise-hero h1 {
max-width: 700px;
margin: 22px 0 0;
font-size: clamp(48px, 5.6vw, 80px);
font-weight: 900;
letter-spacing: -0.085em;
line-height: 0.98;
}
.franchise-hero-copy > p:not(.section-kicker) {
max-width: 550px;
margin-top: 30px;
color: #c9c4bc;
font-size: clamp(16px, 1.5vw, 20px);
line-height: 1.75;
}
.light-link {
border-color: white;
}
.franchise-hero .hero-actions > a {
min-height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0;
line-height: 1.2;
}
.franchise-hero .hero-actions .light-link {
padding: 0 2px;
border-bottom: 0;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 6px;
}
.franchise-truth {
max-width: 560px;
margin-top: 52px;
padding: 18px 20px;
border-left: 3px solid #8ea5ff;
background: #2b2926;
}
.franchise-truth strong {
font-size: 12px;
}
.franchise-truth p {
margin-top: 5px;
color: #aaa59d;
font-size: 11px;
}
.franchise-hero-media {
min-height: 680px;
overflow: hidden;
background: var(--steel);
}
.franchise-hero-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.franchise-hero-media figcaption {
position: absolute;
right: 16px;
bottom: 16px;
padding: 7px 10px;
background: var(--paper-bright);
color: var(--ink);
font-size: 10px;
font-weight: 700;
}
.fact-rail {
border-bottom: 1px solid var(--line);
background: var(--paper);
}
.fact-rail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.fact-rail p {
min-height: 92px;
display: flex;
align-items: center;
gap: 16px;
padding: 18px 28px;
border-right: 1px solid var(--line);
font-size: 13px;
font-weight: 750;
}
.fact-rail p:first-child {
padding-left: 0;
}
.fact-rail p:last-child {
border-right: 0;
}
.fact-rail span,
.fit-number {
color: var(--blue);
font-family: var(--font-mono);
font-size: 11px;
}
.model-section {
background: var(--paper-bright);
}
.flow-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
border: 1px solid var(--ink);
}
.flow-board article {
min-height: 340px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: clamp(22px, 3vw, 34px);
border-right: 1px solid var(--ink);
}
.flow-board article:last-child {
border-right: 0;
background: var(--blue);
color: white;
}
.flow-board article > span {
margin-bottom: auto;
color: var(--blue);
font-family: var(--font-mono);
font-size: 11px;
}
.flow-board article:last-child > span {
color: #c8d3ff;
}
.flow-board h3 {
font-size: 23px;
letter-spacing: -0.045em;
}
.flow-board p {
margin-top: 8px;
color: var(--ink-soft);
font-size: 13px;
}
.flow-board article:last-child p {
color: #dce4ff;
}
.operator-fit {
display: grid;
grid-template-columns: 0.8fr 1.2fr;
gap: 70px;
margin-top: clamp(64px, 9vw, 110px);
padding-top: 42px;
border-top: 1px solid var(--ink);
}
.operator-fit h3 {
margin-top: 13px;
font-size: clamp(34px, 4.2vw, 54px);
letter-spacing: -0.065em;
line-height: 1.12;
}
.operator-fit ul {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--line);
}
.operator-fit li {
display: grid;
grid-template-columns: 130px 1fr;
gap: 20px;
padding: 19px 0;
border-bottom: 1px solid var(--line);
font-size: 13px;
}
.operator-fit span {
color: var(--ink-soft);
}
.site-fit-section {
background: var(--steel);
}
.fit-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-top: 1px solid var(--ink);
border-left: 1px solid var(--ink);
}
.fit-grid article {
min-height: 300px;
padding: clamp(22px, 3vw, 34px);
border-right: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
background: var(--paper-bright);
}
.fit-grid .fit-main {
grid-column: span 2;
grid-row: span 2;
min-height: 600px;
display: flex;
flex-direction: column;
background: var(--ink);
color: white;
}
.fit-grid h3 {
margin-top: 46px;
font-size: clamp(28px, 3.5vw, 48px);
letter-spacing: -0.06em;
}
.fit-grid article > p:not(.fit-number) {
margin-top: 14px;
color: var(--ink-soft);
font-size: 13px;
}
.fit-grid .fit-main > p:not(.fit-number) {
max-width: 490px;
color: #d4cec5;
font-size: 16px;
}
.fit-main .fit-number {
color: #9db0ff;
}
.fit-main ul {
margin: auto 0 0;
padding: 24px 0 0;
border-top: 1px solid #625f5a;
list-style: none;
}
.fit-main li {
padding: 8px 0;
color: #e8e3db;
font-size: 13px;
}
.franchise-process {
background: var(--paper-bright);
}
.franchise-timeline {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--ink);
}
.franchise-timeline li {
display: grid;
grid-template-columns: 56px 1fr auto;
align-items: center;
gap: 28px;
min-height: 124px;
padding: 22px 0;
border-bottom: 1px solid var(--line);
}
.franchise-timeline li > span {
color: var(--blue);
font-family: var(--font-mono);
font-size: 12px;
}
.franchise-timeline h3 {
font-size: 24px;
letter-spacing: -0.045em;
}
.franchise-timeline p {
margin-top: 3px;
color: var(--ink-soft);
font-size: 13px;
}
.franchise-timeline small {
padding: 6px 9px;
border: 1px solid var(--line);
font-size: 10px;
font-weight: 700;
}
.cost-section {
background: var(--ink);
color: white;
}
.cost-section .section-kicker {
color: #8ea5ff;
}
.cost-head {
display: grid;
grid-template-columns: 1.25fr 0.75fr;
align-items: end;
gap: 50px;
margin-bottom: 64px;
}
.cost-head h2 {
margin-top: 14px;
}
.unknown-cost {
padding: 22px;
border: 1px solid #625f5a;
background: #2a2825;
}
.unknown-cost span {
color: #aaa59d;
font-size: 10px;
}
.unknown-cost strong {
display: block;
margin-top: 3px;
color: #8ea5ff;
font-size: 25px;
}
.unknown-cost p {
margin-top: 7px;
color: #bdb7af;
font-size: 12px;
}
.cost-table-wrap {
overflow-x: auto;
border-top: 1px solid #827d75;
}
.cost-table {
width: 100%;
min-width: 780px;
border-collapse: collapse;
font-size: 12px;
}
.cost-table th,
.cost-table td {
padding: 17px 14px;
border-bottom: 1px solid #4f4c47;
text-align: left;
}
.cost-table thead th {
color: #9f9a92;
font-size: 10px;
}
.cost-table tbody th {
color: white;
font-weight: 750;
}
.cost-table td {
color: #c4beb6;
}
.cost-table td:nth-child(3) {
color: #8ea5ff;
font-family: var(--font-mono);
font-weight: 600;
}
.cost-table tfoot th,
.cost-table tfoot td {
border-bottom: 0;
background: var(--blue);
color: white;
font-weight: 750;
}
.cost-warning {
margin-top: 18px;
color: #aaa59d;
font-size: 11px;
}
.inquiry-section {
background: var(--paper);
}
.inquiry-layout {
display: grid;
grid-template-columns: minmax(280px, 0.7fr) minmax(480px, 1.3fr);
gap: clamp(44px, 8vw, 110px);
align-items: start;
}
.inquiry-intro {
position: sticky;
top: calc(var(--header-height) + 32px);
}
.inquiry-intro h2 {
margin-top: 14px;
}
.inquiry-intro > p:not(.section-kicker) {
max-width: 460px;
margin-top: 26px;
color: var(--ink-soft);
font-size: 15px;
}
.privacy-note {
margin-top: 32px;
padding: 18px;
border-left: 3px solid var(--danger);
background: #fff0eb;
}
.privacy-note strong {
font-size: 12px;
}
.privacy-note p {
margin-top: 5px;
color: #733c33;
font-size: 11px;
}
.inquiry-panel {
border: 1px solid var(--line-strong);
background: var(--paper-bright);
box-shadow: var(--shadow);
}
.step-indicator {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin: 0;
padding: 0;
border-bottom: 1px solid var(--line);
list-style: none;
}
.step-indicator li {
min-height: 76px;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-right: 1px solid var(--line);
color: var(--ink-faint);
}
.step-indicator li:last-child {
border-right: 0;
}
.step-indicator li.is-current {
background: var(--blue);
color: white;
}
.step-indicator span {
font-family: var(--font-mono);
font-size: 12px;
}
.step-indicator small {
font-size: 11px;
font-weight: 750;
}
.form-step,
.inquiry-success {
padding: clamp(26px, 5vw, 50px);
}
.is-js .form-step:not(.is-current) {
display: none;
}
.step-count {
color: var(--blue);
font-family: var(--font-mono);
font-size: 11px;
}
.form-step h3,
.inquiry-success h3 {
margin-top: 8px;
font-size: clamp(29px, 4vw, 43px);
font-weight: 850;
letter-spacing: -0.06em;
line-height: 1.2;
}
.form-field {
margin-top: 30px;
}
.form-field > label,
.form-field > legend {
display: block;
margin-bottom: 9px;
font-size: 13px;
font-weight: 750;
}
.form-field input[type="text"],
.form-field input[type="number"],
.form-field select {
width: 100%;
min-height: 52px;
padding: 11px 13px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: white;
}
.form-field > small,
.form-field output {
display: block;
margin-top: 7px;
color: var(--ink-faint);
font-size: 10px;
}
.field-error {
min-height: 20px;
margin-top: 5px;
color: var(--danger);
font-size: 11px;
font-weight: 700;
}
.form-field.has-error input,
.form-field.has-error select,
.form-field.has-error .radio-grid {
border-color: var(--danger);
}
.radio-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.radio-grid-two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.radio-grid label {
position: relative;
}
.radio-grid input {
position: absolute;
z-index: 1;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.radio-grid span {
min-height: 48px;
display: grid;
place-items: center;
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 700;
text-align: center;
}
.radio-grid input:checked + span {
border-color: var(--blue);
background: var(--blue);
color: white;
}
.radio-grid input:focus-visible + span {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
.unit-input {
display: flex;
align-items: center;
border: 1px solid var(--line-strong);
background: white;
}
.unit-input input {
border: 0 !important;
}
.unit-input span {
padding-right: 14px;
font-size: 12px;
font-weight: 750;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 36px;
}
.split-actions {
justify-content: space-between;
gap: 12px;
}
.form-actions .button-ghost {
margin-top: 0;
background: white;
}
.review-list {
margin-top: 30px;
border-top: 1px solid var(--ink);
}
.review-list div {
display: grid;
grid-template-columns: 130px 1fr;
gap: 20px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
font-size: 12px;
}
.review-list dt {
color: var(--ink-faint);
}
.review-list dd {
font-weight: 750;
}
.consent-check {
position: relative;
min-height: 44px;
display: grid;
grid-template-columns: 20px 1fr;
align-items: start;
gap: 10px;
margin-top: 24px;
font-size: 12px;
font-weight: 650;
}
.consent-check input {
position: absolute;
z-index: 1;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.consent-check span {
position: relative;
grid-column: 2;
}
.consent-check span::before {
content: "";
position: absolute;
top: 2px;
left: -30px;
width: 18px;
height: 18px;
border: 1px solid var(--line-strong);
background: white;
}
.consent-check input:checked + span::before {
border-color: var(--blue);
background: var(--blue);
box-shadow: inset 0 0 0 4px white;
}
.consent-check input:focus-visible + span {
outline: 3px solid var(--focus);
outline-offset: 5px;
}
.submit-state {
min-height: 24px;
margin-top: 12px;
color: var(--danger);
font-size: 11px;
font-weight: 700;
}
.inquiry-success {
min-height: 580px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
}
.success-mark {
width: 58px;
height: 58px;
display: grid;
place-items: center;
margin-bottom: 26px;
background: var(--blue);
color: white;
font-size: 28px;
}
.inquiry-success > p:not(.section-kicker) {
max-width: 460px;
margin-top: 15px;
color: var(--ink-soft);
font-size: 14px;
}
.success-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 30px;
}
.success-actions .button {
margin-top: 0;
}
.franchise-close {
padding-block: var(--section);
background: var(--blue);
color: white;
}
.close-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
gap: 44px;
}
.franchise-close .section-kicker {
color: #c8d3ff;
}
.franchise-close h2 {
max-width: 850px;
margin-top: 14px;
font-size: clamp(42px, 6vw, 78px);
letter-spacing: -0.075em;
line-height: 1.06;
}
.franchise-foot-grid {
grid-template-columns: 1.2fr 0.8fr 1.4fr;
}
@media (max-width: 960px) {
.franchise-hero {
grid-template-columns: 0.95fr 1.05fr;
}
.franchise-hero-copy {
padding-left: var(--gutter);
}
.flow-board {
grid-template-columns: repeat(2, 1fr);
}
.flow-board article:nth-child(2) {
border-right: 0;
}
.flow-board article:nth-child(-n + 2) {
border-bottom: 1px solid var(--ink);
}
.fit-grid {
grid-template-columns: repeat(2, 1fr);
}
.cost-head,
.inquiry-layout {
grid-template-columns: 1fr;
}
.inquiry-intro {
position: static;
}
.franchise-foot-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.franchise-hero {
grid-template-columns: 1fr;
}
.franchise-hero-copy {
padding: 72px var(--gutter) 52px;
}
.franchise-hero h1 {
font-size: clamp(40px, 13.5vw, 60px);
}
.franchise-hero-media {
min-height: 560px;
}
.fact-rail-grid {
grid-template-columns: 1fr;
}
.fact-rail p,
.fact-rail p:first-child {
min-height: 64px;
padding: 14px 0;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.operator-fit {
grid-template-columns: 1fr;
gap: 34px;
}
.fit-grid .fit-main {
min-height: 520px;
}
.franchise-timeline li {
grid-template-columns: 42px 1fr;
}
.franchise-timeline small {
grid-column: 2;
justify-self: start;
}
.close-row {
grid-template-columns: 1fr;
align-items: start;
}
.franchise-foot-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 540px) {
.franchise-hero-media {
min-height: 460px;
}
.flow-board,
.fit-grid {
display: block;
border-left: 1px solid var(--ink);
}
.flow-board article,
.flow-board article:nth-child(2),
.fit-grid article {
border-right: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
}
.flow-board article,
.flow-board article:nth-child(2) {
min-height: 170px;
justify-content: flex-start;
}
.flow-board article > span {
margin-bottom: 30px;
}
.fit-grid .fit-main {
min-height: 500px;
}
.operator-fit li {
grid-template-columns: 1fr;
gap: 4px;
}
.step-indicator li {
min-height: 64px;
justify-content: center;
padding: 9px;
}
.step-indicator small {
display: inline;
font-size: 10px;
}
.radio-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-actions,
.split-actions,
.success-actions {
flex-direction: column-reverse;
}
.form-actions .button,
.success-actions .button {
width: 100%;
}
.review-list div {
grid-template-columns: 1fr;
gap: 3px;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,43 @@
:root {
color-scheme: light;
--paper: #f4f1e9;
--paper-bright: #fffdf8;
--ink: #211f1c;
--ink-soft: #56514a;
--ink-faint: #625d56;
--line: #c9c2b7;
--line-strong: #8a8379;
--blue: #2147e7;
--blue-dark: #1533ac;
--blue-pale: #dce4ff;
--steel: #d9dcda;
--cheese: #ffc642;
--danger: #b33124;
--success: #17683b;
--focus: #2147e7;
--font-sans: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
--wrap: 1180px;
--gutter: clamp(18px, 4vw, 52px);
--section: clamp(76px, 10vw, 144px);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 72px;
--radius-sm: 4px;
--radius: 10px;
--radius-lg: 18px;
--shadow: 0 18px 50px rgb(33 31 28 / 10%);
--header-height: 78px;
}
@media (max-width: 720px) {
:root {
--header-height: 64px;
--section: 72px;
}
}

View file

@ -0,0 +1,294 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f3f4f3">
<meta name="description" content="가상 공인중개 쇼케이스의 매물 사실, 표시 항목, AI 분석 근거, 변경 이력, 게시 가능 여부를 한 화면에서 검수하는 상세 관리자 운영 데모입니다.">
<meta name="robots" content="noindex, nofollow">
<meta property="og:title" content="한남자리 관리 데모 — 매물 검수">
<meta property="og:description" content="예시 매물의 표시 항목, AI 근거와 게시 방어선을 살피는 운영 화면">
<meta property="og:image" content="../og.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="한남자리 관리 데모 — 매물 검수">
<meta name="twitter:image" content="../og.png">
<title>한남자리 관리 데모 — 매물 검수</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23141413'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial' font-size='19' font-weight='700' fill='%23c6a15b'%3EHJ%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="../styles/tokens.css?v=20260829c">
<link rel="stylesheet" href="../styles/admin.css?v=20260829c">
<script src="../scripts/admin.js?v=20260829c" defer></script>
</head>
<body class="admin-body">
<a class="skip-link" href="#admin-main">본문 바로가기</a>
<div class="demo-strip" role="region" aria-label="쇼케이스 고지">쇼케이스 · 가상 브랜드 · 모든 사무소, 매물, 고객과 기록은 합성 예시입니다</div>
<div class="admin-shell">
<aside class="admin-sidebar" data-sidebar aria-label="관리 메뉴">
<div class="sidebar-brand-shell">
<a href="../" aria-label="한남자리 공개 쇼케이스로 이동"><span class="sidebar-mark">HJ</span><span><strong class="sidebar-brand">한남자리</strong><small>운영 화면 데모</small></span></a>
<button type="button" class="sidebar-close" data-close-sidebar aria-label="관리 메뉴 닫기">×</button>
</div>
<nav class="sidebar-nav" aria-label="관리 화면 목록">
<p>운영</p>
<a href="#queue" class="is-current" aria-current="page"><span aria-hidden="true">⌁</span>검수 대기열 <small>6</small></a>
<a href="#queue"><span aria-hidden="true">▤</span>전체 매물</a>
<a href="#inspector-inquiries"><span aria-hidden="true">◌</span>문의와 고객</a>
<a href="#inspector-ai"><span aria-hidden="true">✦</span>AI 분석</a>
<a href="#inspector-inquiries"><span aria-hidden="true">□</span>일정</a>
<p>관리</p>
<a href="#office-gate"><span aria-hidden="true">◇</span>사무소와 법정 정보 <small class="nav-alert">5</small></a>
<a href="#roles"><span aria-hidden="true">◎</span>팀과 권한</a>
<a href="#inspector-history"><span aria-hidden="true">↺</span>감사 로그</a>
<a href="#settings"><span aria-hidden="true">⚙</span>설정</a>
</nav>
<div class="sidebar-foot">
<span class="status-badge status-demo">데모 환경</span>
<p>실제 광고 게시와 외부 연락은 연결되지 않습니다.</p>
<a href="../">공개 화면 보기 ↗</a>
</div>
</aside>
<div class="sidebar-backdrop" data-sidebar-backdrop hidden></div>
<main class="admin-main" id="admin-main">
<header class="admin-topbar">
<button type="button" class="mobile-sidebar-button" aria-expanded="false" data-open-sidebar><span aria-hidden="true">☰</span><span class="sr-only">관리 메뉴 열기</span></button>
<div class="breadcrumb"><span>운영</span><span aria-hidden="true">/</span><strong>매물 검수</strong></div>
<div class="topbar-actions">
<button type="button" class="icon-button" aria-label="명령 메뉴 열기" data-command-button><span aria-hidden="true">⌕</span><kbd>Ctrl K</kbd></button>
<button type="button" class="icon-button" aria-label="알림 보기"><span aria-hidden="true">○</span><i aria-hidden="true"></i></button>
<button type="button" class="account-button" aria-label="예시 계정 메뉴"><span>김</span><span><strong>김유진</strong><small>공인중개사 · 예시</small></span></button>
</div>
</header>
<div class="admin-content">
<header class="page-heading">
<div>
<div class="heading-badges"><span class="status-badge status-demo">쇼케이스 · 가상 브랜드</span><span>자료 기준 2026.08.29</span></div>
<h1>매물 검수 대기열</h1>
<p>게시 전에 매물 사실, 표시 항목, AI 근거와 변경 이력을 한 자리에서 확인합니다.</p>
</div>
<div class="heading-actions">
<button type="button" class="button button-ghost" data-open-preview>쇼케이스 미리보기</button>
<button type="button" class="button button-primary" data-new-draft data-inspector-trigger>새 예시 초안</button>
</div>
</header>
<section class="system-banner" role="status" aria-live="polite" hidden data-system-banner>
<div><strong data-banner-title>운영 상태</strong><p data-banner-copy></p></div>
<button type="button" data-dismiss-banner>닫기</button>
</section>
<section class="queue-toolbar" aria-label="대기열 도구">
<label class="search-field"><span class="sr-only">매물 검색</span><span aria-hidden="true">⌕</span><input type="search" placeholder="매물명, 거래 형태, 담당 검색" data-queue-search></label>
<label><span class="sr-only">상태 필터</span><select data-status-filter><option value="all">모든 상태</option><option value="review">검수 필요</option><option value="visible">쇼케이스 공개</option><option value="paused">보류</option><option value="contracted">계약 완료</option><option value="draft">초안</option></select></label>
<label><span class="sr-only">정렬 기준</span><select data-sort-select><option value="updated-desc">최근 수정순</option><option value="updated-asc">오래된 수정순</option><option value="title-asc">매물명순</option><option value="status-asc">상태순</option></select></label>
<label class="state-simulator"><span>상태 시연</span><select data-state-simulator><option value="normal">정상</option><option value="loading">불러오는 중</option><option value="empty">빈 대기열</option><option value="error">불러오기 실패</option><option value="save-error">저장 실패</option><option value="stale">자료 오래됨</option><option value="conflict">버전 충돌</option><option value="permission">권한 제한</option></select></label>
</section>
<div class="admin-workbench">
<section class="queue-panel" id="queue" aria-labelledby="queue-title" aria-busy="false" data-queue-panel>
<header class="panel-heading">
<div><h2 id="queue-title">현재 대기열</h2><span data-result-count>6개 예시</span></div>
<div><span class="queue-legend"><i class="legend-block" aria-hidden="true"></i>차단</span><span class="queue-legend"><i class="legend-warn" aria-hidden="true"></i>확인</span></div>
</header>
<div class="table-scroll">
<table class="queue-table">
<caption class="sr-only">쇼케이스용 예시 매물 검수 대기열</caption>
<thead>
<tr><th scope="col">매물</th><th scope="col">거래</th><th scope="col">예시 조건</th><th scope="col">마지막 확인</th><th scope="col">표시 검수</th><th scope="col">AI 검토</th><th scope="col">담당</th></tr>
</thead>
<tbody data-queue-body>
<tr class="is-selected" data-queue-row data-id="DEMO-HJ-101" data-status="review" data-updated="2026-08-29T10:42:00+09:00" data-title="한남 생활권 정원 주거" aria-selected="true">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="한남 생활권 정원 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb"><img src="../assets/hannam-evening-640.webp" width="80" height="54" alt="서울 언덕 생활권의 주거 외관을 표현한 연출 이미지"></span><span><strong>한남 생활권 정원 주거</strong><small>DEMO-HJ-101 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>연립주택</small></td><td><strong>38억 원</strong><small>전용 118.42㎡</small></td><td><span>2026.08.27</span><small>2일 전 · 모의</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>2개 차단</span></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>검토 전</span></td><td><span class="assignee"><i>김</i>김유진</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-102" data-status="visible" data-updated="2026-08-29T09:18:00+09:00" data-title="한강 가까운 복층 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="한강 가까운 복층 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-plan"><i></i></span><span><strong>한강 가까운 복층 주거</strong><small>DEMO-HJ-102 · 예시 매물</small></span></span></td>
<td><span>전세</span><small>공동주택</small></td><td><strong>전세 22억</strong><small>전용 142.80㎡</small></td><td><span>2026.08.26</span><small>3일 전 · 모의</small></td><td><span class="cell-status is-pass"><i aria-hidden="true"></i>데모 통과</span></td><td><span class="cell-status is-pass"><i aria-hidden="true"></i>수정 승인</span></td><td><span class="assignee"><i>박</i>박소연</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-103" data-status="paused" data-updated="2026-08-28T17:25:00+09:00" data-title="조용한 골목의 테라스 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="조용한 골목의 테라스 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-stale"><i></i></span><span><strong>조용한 골목의 테라스 주거</strong><small>DEMO-HJ-103 · 예시 매물</small></span></span></td>
<td><span>월세</span><small>다세대주택</small></td><td><strong>5억 · 월 1,200만</strong><small>전용 140.84㎡</small></td><td><span>2026.08.11</span><small>18일 전 · 오래됨</small></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>다시 확인</span></td><td><span class="cell-status is-stale"><i aria-hidden="true"></i>오래됨</span></td><td><span class="assignee"><i>김</i>김유진</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-104" data-status="contracted" data-updated="2026-08-28T12:40:00+09:00" data-title="언덕 위 작은 정원 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="언덕 위 작은 정원 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-closed"><i></i></span><span><strong>언덕 위 작은 정원 주거</strong><small>DEMO-HJ-104 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>단독주택</small></td><td><strong>상담 종료</strong><small>공개 목록 제외</small></td><td><span>2026.08.28</span><small>1일 전 · 모의</small></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>게시 안 함</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>종료</span></td><td><span class="assignee"><i>박</i>박소연</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-105" data-status="draft" data-updated="2026-08-27T15:05:00+09:00" data-title="남산 가까운 가족 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="남산 가까운 가족 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-draft"><i></i></span><span><strong>남산 가까운 가족 주거</strong><small>DEMO-HJ-105 · 예시 매물</small></span></span></td>
<td><span>전세</span><small>공동주택</small></td><td><strong>조건 작성 중</strong><small>면적 입력됨</small></td><td><span>확인 전</span><small>모의 서류 없음</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>5개 차단</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>실행 전</span></td><td><span class="assignee"><i>최</i>최민서</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-106" data-status="review" data-updated="2026-08-26T11:20:00+09:00" data-title="독서당길 안쪽 복층 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="독서당길 안쪽 복층 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-review"><i></i></span><span><strong>독서당길 안쪽 복층 주거</strong><small>DEMO-HJ-106 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>공동주택</small></td><td><strong>41억 원</strong><small>전용 151.20㎡</small></td><td><span>2026.08.25</span><small>4일 전 · 모의</small></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>동의 대기</span></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>실행 실패</span></td><td><span class="assignee"><i>최</i>최민서</span></td>
</tr>
</tbody>
</table>
</div>
<div class="queue-loading" hidden data-queue-loading role="status">
<span></span><span></span><span></span><p>대기열을 불러오는 중입니다.</p>
</div>
<div class="queue-empty" hidden data-queue-empty>
<span aria-hidden="true">○</span><strong data-empty-title>현재 조건에 맞는 예시가 없습니다.</strong><p data-empty-copy>필터를 초기화하면 전체 대기열을 다시 볼 수 있습니다.</p><button type="button" class="button button-ghost" data-reset-filter>필터 초기화</button>
</div>
</section>
<div class="inspector-backdrop" data-inspector-backdrop hidden></div>
<aside class="inspector is-open" data-inspector aria-labelledby="inspector-title">
<header class="inspector-header">
<div><span class="status-badge status-warn" data-inspector-status>검수 필요</span><small data-inspector-id>DEMO-HJ-101 · 예시 매물</small><h2 id="inspector-title" data-inspector-title>한남 생활권 정원 주거</h2></div>
<button type="button" class="inspector-close" data-close-inspector aria-label="조사관과 선택 행 닫기">×</button>
</header>
<div class="inspector-media">
<img src="../assets/hannam-interior-720.webp" width="720" height="480" alt="큰 창과 목재 수납이 있는 거실의 연출 이미지" data-inspector-image>
<span>연출 이미지 · 실제 매물 사진 아님</span>
</div>
<div class="channel-gates" id="office-gate">
<button type="button" class="channel-gate is-pass" data-open-preview><span><i aria-hidden="true">✓</i><strong>쇼케이스</strong></span><small>미리보기 가능</small></button>
<button type="button" class="channel-gate is-blocked" data-block-publish><span><i aria-hidden="true">!</i><strong>실제 광고</strong></span><small>영구 차단 · 7개 이유</small></button>
</div>
<div class="inspector-tabs" role="tablist" aria-label="선택 매물 검수 항목">
<button type="button" role="tab" aria-selected="true" aria-controls="inspector-basic" id="tab-basic" data-tab="basic">기본</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-legal" id="tab-legal" tabindex="-1" data-tab="legal">표시·광고 <span>2</span></button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-docs" id="tab-docs" tabindex="-1" data-tab="docs">서류</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-ai" id="tab-ai" tabindex="-1" data-tab="ai">AI</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-inquiries" id="tab-inquiries" tabindex="-1" data-tab="inquiries">문의·일정</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-history" id="tab-history" tabindex="-1" data-tab="history">변경 이력</button>
</div>
<div class="inspector-scroll">
<section class="tab-panel" role="tabpanel" id="inspector-basic" aria-labelledby="tab-basic" data-panel="basic">
<div class="panel-section-heading"><div><span>기본 정보</span><h3>예시 거래 조건</h3></div><button type="button" class="text-button" data-simulate-save>수정 시연</button></div>
<dl class="data-grid" data-basic-grid>
<div><dt>거래 형태</dt><dd>매매</dd></div><div><dt>예시 가격</dt><dd>38억 원</dd></div><div><dt>유형</dt><dd>연립주택</dd></div><div><dt>전용 면적</dt><dd>118.42㎡ · 35.8평</dd></div><div><dt>방과 욕실</dt><dd>방 3 · 욕실 2</dd></div><div><dt>입주 가능</dt><dd>2026.12.20 이후 협의</dd></div>
</dl>
<div class="inline-notice"><span aria-hidden="true">i</span><p><strong>가상 데이터</strong> 가격과 면적은 쇼케이스 동작 검증용이며 실제 거래 조건이 아닙니다.</p></div>
<div class="panel-section-heading"><div><span>담당과 상태</span><h3>현재 업무 흐름</h3></div></div>
<ol class="state-path">
<li class="is-done"><span></span><div><strong>초안</strong><small>2026.08.27 · 예시</small></div></li><li class="is-done"><span></span><div><strong>접수 확인</strong><small>2026.08.28 · 예시</small></div></li><li class="is-current"><span></span><div><strong>검수 필요</strong><small>현재 · 2개 차단</small></div></li><li><span></span><div><strong>게시 준비</strong><small>차단 해제 후</small></div></li>
</ol>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-legal" aria-labelledby="tab-legal" data-panel="legal" hidden>
<div class="panel-section-heading"><div><span>이중 게시 게이트</span><h3>채널마다 다른 통과 조건</h3></div><button type="button" class="text-button" data-focus-missing>첫 누락으로 이동</button></div>
<article class="gate-detail is-pass">
<header><span aria-hidden="true">✓</span><div><strong>쇼케이스 미리보기</strong><small>6 / 6 통과</small></div></header>
<ul><li>상시 데모 표지</li><li>개별 예시 매물 배지</li><li>생성 이미지 출처</li><li>실제 연락 기능 비활성</li><li>정확한 주소와 등록번호 없음</li><li>AI 데모와 사람 검토 전 표시</li></ul>
</article>
<article class="gate-detail is-blocked" id="real-ad-gate">
<header><span aria-hidden="true">!</span><div><strong>실제 광고</strong><small>영구 차단 · 가상 브랜드</small></div></header>
<p>실제 사무소 등록정보와 중개의뢰 관계가 없으므로 이 데모에서 통과시킬 수 없습니다.</p>
<button type="button" class="button button-ghost button-small" data-block-publish>차단 이유 7개 보기</button>
</article>
<div class="check-list" data-legal-checks>
<div class="check-row is-pass"><span aria-hidden="true">✓</span><div><strong>소재지 · 면적 · 가격 · 유형 · 거래 형태</strong><small>쇼케이스 fixture에 구조화됨</small></div><button type="button">보기</button></div>
<div class="check-row is-pass"><span aria-hidden="true">✓</span><div><strong>총층수 · 사용승인일 · 방 · 욕실</strong><small>모의 서류 기준 · 실제 증빙 아님</small></div><button type="button">보기</button></div>
<div class="check-row is-blocked" id="missing-orientation" tabindex="-1"><span aria-hidden="true">!</span><div><strong>방향 판단 근거 미확인</strong><small>남동향 값은 있으나 현장 또는 실제 서류 근거 없음</small></div><button type="button" data-jump-field="missing-orientation">수정 위치</button></div>
<div class="check-row is-blocked"><span aria-hidden="true">!</span><div><strong>실제 중개의뢰 관계 없음</strong><small>가상 매물이므로 실제 광고 불가</small></div><button type="button" data-block-publish>이유</button></div>
<div class="check-row is-warn"><span aria-hidden="true">·</span><div><strong>관리비 포함·별도 항목</strong><small>모의 기준월과 항목을 표시함</small></div><button type="button">보기</button></div>
</div>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-docs" aria-labelledby="tab-docs" data-panel="docs" hidden>
<div class="panel-section-heading"><div><span>모의 서류</span><h3>증빙과 예시를 구분</h3></div></div>
<div class="document-list">
<article><span class="doc-icon" aria-hidden="true">▧</span><div><strong>건축물대장 형식 예시</strong><small>2026.08.27 추가 · 실제 공문서 아님</small></div><span class="status-badge status-demo">모의</span></article>
<article><span class="doc-icon" aria-hidden="true">▧</span><div><strong>의뢰 조건 메모</strong><small>2026.08.27 추가 · 가상 입력</small></div><span class="status-badge status-demo">모의</span></article>
<article class="is-missing"><span class="doc-icon" aria-hidden="true">×</span><div><strong>실제 등기사항</strong><small>실주소와 실제 권리 자료를 연결하지 않음</small></div><span class="status-badge status-blocked">없음</span></article>
<article class="is-missing"><span class="doc-icon" aria-hidden="true">×</span><div><strong>현장 확인 기록</strong><small>소음, 일조, 누수와 마감 상태 확인 안 됨</small></div><span class="status-badge status-warn">대기</span></article>
</div>
<div class="media-provenance"><img src="../assets/hannam-evening-640.webp" width="640" height="360" alt="서울 언덕길의 석재 주거 건물 연출 이미지"><div><span class="status-badge status-demo">생성 이미지</span><strong>실제 매물 증빙으로 사용 불가</strong><p>외관 분위기 전달용 연출 자산입니다. 위치, 조망, 일조와 건물 상태를 증명하지 않습니다.</p></div></div>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-ai" aria-labelledby="tab-ai" data-panel="ai" hidden>
<div class="panel-section-heading"><div><span>조건 비교 규칙 demo-0.3</span><h3>AI 참고 분석 데모</h3></div><span class="status-badge status-warn">사람 검토 전</span></div>
<dl class="run-meta"><div><dt>실행 번호</dt><dd>AIRUN-DEMO-0042</dd></div><div><dt>입력 버전</dt><dd>고객 3 · 매물 7</dd></div><div><dt>실행 방식</dt><dd>결정적 데모</dd></div><div><dt>공개 가능</dt><dd>아니요</dd></div></dl>
<div class="ai-summary"><span>현재 판단</span><strong>조건부 적합</strong><div><small>필수 충족 4 / 5</small><small>충돌 1</small><small>확인 필요 2</small></div></div>
<div class="claim-review">
<article class="is-fact"><header><span>사실</span><strong>예산 상한보다 2억 원 낮음</strong></header><p>가상 의뢰인 가격 입력 · 2026.08.27</p><footer><span>근거 강도 높음</span><button type="button" data-claim-action="approve">확인</button></footer></article>
<article class="is-fact"><header><span>사실</span><strong>전용면적이 희망 범위 안</strong></header><p>쇼케이스용 모의 서류 · 실제 공문서 아님</p><footer><span>근거 강도 높음</span><button type="button" data-claim-action="approve">확인</button></footer></article>
<article class="is-unknown"><header><span>확인 불가</span><strong>광화문 이동 시간</strong></header><p>현재 교통 자료가 연결되지 않아 추정하지 않음</p><footer><span>빈칸 유지</span><button type="button" data-claim-action="keep">유지</button></footer></article>
<article class="is-edited"><header><span>수정 필요</span><strong><s>조용한 주거 환경입니다.</s></strong></header><p>현장 근거가 없어 “소음 수준은 현장 확인이 필요합니다”로 바꿉니다.</p><footer><span>지원되지 않은 표현</span><button type="button" data-claim-action="edit">수정 승인</button></footer></article>
</div>
<button type="button" class="button button-primary inspector-wide-action" data-review-ai>사람 검토 완료 시연</button>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-inquiries" aria-labelledby="tab-inquiries" data-panel="inquiries" hidden>
<div class="panel-section-heading"><div><span>고객 조건 3판</span><h3>김○○ · 예시 고객</h3></div><span class="status-badge status-demo">연락처 마스킹</span></div>
<dl class="data-grid customer-grid"><div><dt>거래 형태</dt><dd>매매</dd></div><div><dt>예산 상한</dt><dd>40억 원 · 예시</dd></div><div><dt>전용 면적</dt><dd>110~130㎡</dd></div><div><dt>주차</dt><dd>2대 필수</dd></div><div><dt>입주 희망</dt><dd>2026.12.15까지</dd></div><div><dt>AI 비교 동의</dt><dd>예시 동의 있음</dd></div></dl>
<div class="consent-note"><span aria-hidden="true">i</span><p>상담 동의와 AI 비교 동의는 예시입니다. 홍보 수신은 거부 상태로 두었습니다.</p></div>
<div class="panel-section-heading"><div><span>일정</span><h3>전화 상담 · 예시</h3></div></div>
<article class="appointment-card"><time datetime="2026-08-29T15:30:00+09:00"><strong>29</strong><span>8월 · 토</span></time><div><strong>오후 3:30~4:00</strong><p>맞춤 조건과 권리 확인 순서 · 외부 알림 없음</p><small>한국 표준시 · 데모 일정</small></div><button type="button" data-reschedule>시간 변경 시연</button></article>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-history" aria-labelledby="tab-history" data-panel="history" hidden>
<div class="panel-section-heading"><div><span>수정 불가 기록</span><h3>변경 이력 8건</h3></div><button type="button" class="text-button" data-simulate-log-failure>실패 상태 보기</button></div>
<ol class="audit-list">
<li><span class="audit-dot is-blocked"></span><div><header><strong>실제 광고 게시 차단</strong><time datetime="2026-08-29T10:42:18+09:00">오늘 10:42</time></header><p>사무소 등록정보와 실제 중개의뢰 관계가 없음</p><small>김유진 · 예시 계정 · 버전 7</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>AI 주장 수정 후 승인</strong><time datetime="2026-08-29T10:31:00+09:00">오늘 10:31</time></header><p>“조용함”을 “현장 확인 필요”로 변경</p><small>김유진 · 예시 계정 · AIRUN-DEMO-0041</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>AI 분석 완료</strong><time datetime="2026-08-29T10:20:00+09:00">오늘 10:20</time></header><p>조건 비교 규칙 demo-0.3 · 입력 버전 고객 3 / 매물 7</p><small>데모 분석기</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>쇼케이스 미리보기 공개</strong><time datetime="2026-08-29T09:54:00+09:00">오늘 09:54</time></header><p>데모 고지와 이미지 출처 6개 항목 통과</p><small>박소연 · 예시 계정</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>모의 서류 추가</strong><time datetime="2026-08-28T17:22:00+09:00">어제 17:22</time></header><p>건축물대장 형식 예시 · 실제 공문서 아님</p><small>김유진 · 예시 계정</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>예시 가격 변경</strong><time datetime="2026-08-28T16:40:00+09:00">어제 16:40</time></header><p>40억 원 → 38억 원 · 가상 의뢰 조건 수정</p><small>김유진 · 예시 계정 · 버전 6 → 7</small></div></li>
<li><span class="audit-dot is-warn"></span><div><header><strong>오래된 자료로 자동 보류</strong><time datetime="2026-08-28T08:00:00+09:00">어제 08:00</time></header><p>DEMO-HJ-103 · 내부 운영 기준일 경과</p><small>데모 일정 작업</small></div></li>
<li><span class="audit-dot is-blocked"></span><div><header><strong>권한 없는 게시 시도 거부</strong><time datetime="2026-08-27T15:16:00+09:00">8월 27일</time></header><p>중개보조 예시 계정은 법정 검수 승인과 게시를 할 수 없음</p><small>권한 검사 · 요청 DEMO-77B</small></div></li>
</ol>
</section>
</div>
<footer class="inspector-actions">
<button type="button" class="button button-ghost" data-focus-missing>누락 항목 보기</button>
<button type="button" class="button button-primary" data-block-publish>실제 광고 게시</button>
</footer>
</aside>
</div>
<section class="role-proof" id="roles" aria-labelledby="roles-title">
<header><div><span>역할에 따라 달라지는 행동</span><h2 id="roles-title">권한 방어선</h2></div><p>버튼을 숨기는 데서 끝내지 않고 게시 시점에 역할, 게이트와 객체 버전을 다시 확인하는 모델입니다.</p></header>
<div class="role-grid"><article><strong>사무소 관리자</strong><p>사무소 연결, 역할, 모든 게시 관리</p><small>감사 로그 수정 불가</small></article><article><strong>공인중개사</strong><p>법정 검수, AI 승인, 게시와 중단</p><small>역할 관리자 변경 불가</small></article><article><strong>중개보조 계정</strong><p>초안, 문의 정리, 일정 등록</p><small>검수 승인과 게시 불가</small></article><article><strong>콘텐츠 편집자</strong><p>설명, 이미지, 미리보기</p><small>가격, 법정 필드와 고객 정보 수정 불가</small></article></div>
</section>
<section class="settings-proof" id="settings" aria-labelledby="settings-title"><h2 id="settings-title">데모 설정 원칙</h2><p>실제 연락, 저장 서버, 광고 채널과 등록정보 연결을 비활성 상태로 유지합니다.</p></section>
</div>
</main>
</div>
<dialog class="admin-dialog" data-block-dialog aria-labelledby="block-dialog-title">
<form method="dialog" class="admin-dialog-shell">
<header><div><span class="dialog-symbol is-blocked" aria-hidden="true">!</span><div><small>PUBLIC_REAL_AD</small><h2 id="block-dialog-title">실제 광고를 게시할 수 없습니다.</h2></div></div><button type="button" data-close-dialog aria-label="게시 차단 안내 닫기">×</button></header>
<div class="admin-dialog-content">
<p>이 화면은 가상 사무소와 합성 예시 매물로 만든 쇼케이스입니다. 아래 항목은 더미값으로 채워 통과시키지 않습니다.</p>
<ol><li><strong>실제 사무소 명칭</strong><span>연결 안 됨</span></li><li><strong>실제 사무소 소재지</strong><span>연결 안 됨</span></li><li><strong>실제 연락처</strong><span>연결 안 됨</span></li><li><strong>실제 등록번호</strong><span>연결 안 됨</span></li><li><strong>개업공인중개사 성명</strong><span>연결 안 됨</span></li><li><strong>실제 중개의뢰 관계</strong><span>없음</span></li><li><strong>실제 거래 가능 상태</strong><span>확인 불가</span></li></ol>
<div class="inline-notice"><span aria-hidden="true">i</span><p>쇼케이스 미리보기 채널은 데모 고지와 생성 이미지 출처를 갖춰 별도로 열 수 있습니다.</p></div>
</div>
<footer><button type="submit" class="button button-primary" value="close">확인</button></footer>
</form>
</dialog>
<dialog class="admin-dialog preview-dialog" data-preview-dialog aria-labelledby="preview-dialog-title">
<form method="dialog" class="admin-dialog-shell">
<header><div><span class="dialog-symbol is-pass" aria-hidden="true">✓</span><div><small>SHOWCASE</small><h2 id="preview-dialog-title">미리보기 채널은 열 수 있습니다.</h2></div></div><button type="button" data-close-dialog aria-label="쇼케이스 미리보기 안내 닫기">×</button></header>
<div class="admin-dialog-content"><p>상시 데모 고지, 개별 예시 배지, 생성 이미지 출처, 연락 기능 비활성, 실제 주소와 등록번호 없음, AI 데모 표시를 확인했습니다.</p><a class="button button-primary" href="../#listing-detail">공개 상세 보기</a></div>
</form>
</dialog>
<noscript>
<section class="admin-noscript"><strong>읽기 전용 화면입니다.</strong><p>자바스크립트가 없어 필터, 정렬, 선택, 탭과 대화상자를 쓸 수 없습니다. 기본 예시 대기열과 선택 매물의 정보는 계속 읽을 수 있습니다.</p></section>
</noscript>
<div class="live-region" role="status" aria-live="polite" hidden data-admin-live></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,225 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f3f0e8">
<meta name="description" content="서울 한남 생활권의 집을 예산, 면적, 입주, 주차 조건으로 비교하고 AI 브리핑 뒤 공인중개사가 사실을 다시 확인하는 스마트 중개 디자인 쇼케이스입니다.">
<meta property="og:title" content="한남자리 — 내 기준에 맞는 집부터">
<meta property="og:description" content="조건을 정리하고 후보를 비교한 뒤 공인중개사가 확인하는 개인 주거 브리핑 데스크">
<meta property="og:image" content="./og.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="한남자리 — 내 기준에 맞는 집부터">
<meta name="twitter:description" content="서울의 집을 내 조건으로 대조하는 개인 주거 브리핑 데스크">
<meta name="twitter:image" content="./og.png">
<title>한남자리 — 내 기준에 맞는 집부터</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23121311'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial' font-size='19' font-weight='700' fill='%23f3f0e8'%3EHJ%3C/text%3E%3C/svg%3E">
<link rel="preload" href="./assets/hannam-evening-1600.webp" as="image" type="image/webp" media="(min-width: 961px)">
<link rel="stylesheet" href="./styles/tokens.css?v=20260829d">
<link rel="stylesheet" href="./styles/landing.css?v=20260829d">
<script src="./scripts/landing.js?v=20260829d" defer></script>
</head>
<body>
<a class="skip-link" href="#main-content">본문 바로가기</a>
<div class="demo-strip" role="region" aria-label="쇼케이스 고지">디자인 쇼케이스 · 가상 브랜드 · 상담은 접수되지 않습니다</div>
<header class="site-header" data-site-header>
<a class="brand-link" href="#top" aria-label="한남자리 처음으로">
<span class="brand-mark" aria-hidden="true">HJ</span>
<span class="brand-lockup"><strong class="brand">한남자리</strong><small>개인 주거 브리핑 데스크</small></span>
</a>
<button class="menu-button" type="button" aria-expanded="false" aria-controls="site-nav" data-menu-button>
<span class="sr-only">메뉴 열기</span><i aria-hidden="true"></i><i aria-hidden="true"></i>
</button>
<nav class="site-nav" id="site-nav" aria-label="주요 메뉴" data-site-nav>
<a href="#briefing">맞춤 브리핑</a>
<a href="#homes">세 집 비교</a>
<a href="#dossier">선택 서류철</a>
<a href="#handoff">중개사 인계</a>
<a class="nav-admin" href="./admin/">관리 화면</a>
</nav>
</header>
<main id="main-content">
<section class="brief-hero" id="top" aria-labelledby="hero-title">
<div class="masthead">
<p class="eyebrow">한남 생활권 맞춤 중개</p>
<h1 id="hero-title">한남에서, 내 기준에 맞는 집부터.</h1>
<p class="hero-copy">예산과 면적, 입주 계획을 먼저 정리합니다. AI가 후보를 비교하고 공인중개사가 다시 확인합니다.</p>
<div class="hero-actions">
<button class="button button-primary" type="button" data-focus-condition>내 조건 분석하기</button>
<a class="text-link" href="#homes">매물 보기 <span aria-hidden="true">→</span></a>
</div>
<dl class="masthead-note">
<div><dt>대조 기준</dt><dd>가격 · 면적 · 입주 · 주차</dd></div>
<div><dt>마지막 판단</dt><dd>공인중개사 확인</dd></div>
</dl>
</div>
<figure class="hero-scene" data-image-frame>
<figcaption>연출 이미지</figcaption>
<picture>
<source media="(max-width: 640px)" srcset="./assets/hannam-evening-mobile.webp">
<source media="(max-width: 960px)" srcset="./assets/hannam-evening-960.webp">
<img src="./assets/hannam-evening-1600.webp" width="1600" height="1000" alt="서울 언덕 생활권의 현대 주거를 표현한 이미지" data-fallback-image>
</picture>
<div class="image-fallback" hidden data-image-fallback role="img" aria-label="주거 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div>
</figure>
<form class="brief-dock" data-condition-form aria-describedby="condition-note">
<div class="dock-heading"><span>내 기준</span><strong>먼저 다섯 가지만 정리해 보세요.</strong></div>
<label class="dock-field"><span>거래 형태</span><select name="trade" data-trade-input><option value="sale">매매</option><option value="jeonse">전세</option><option value="monthly">월세</option></select></label>
<label class="dock-field"><span>예산 상한</span><span class="input-suffix"><input name="budget" type="number" min="1" max="100" step="1" value="40" inputmode="decimal"><i>억 원</i></span></label>
<label class="dock-field"><span>최소 전용면적</span><span class="input-suffix"><input name="minArea" type="number" min="30" max="300" step="1" value="110" inputmode="decimal"><i>㎡</i></span></label>
<label class="dock-field"><span>입주 희망일</span><input name="moveIn" type="date" value="2026-12-20"></label>
<label class="dock-field"><span>필요 주차</span><select name="parking"><option value="0">상관없음</option><option value="1">1대 이상</option><option value="2" selected>2대 이상</option><option value="3">3대 이상</option></select></label>
<div class="dock-actions"><button class="button button-primary" type="submit">분석 결과 보기</button><button class="save-button" type="button" data-save-condition>이 브라우저에 저장</button></div>
<p class="condition-note" id="condition-note">입력은 전송되지 않으며, 저장을 선택하면 이 브라우저에만 보관됩니다.</p>
</form>
</section>
<section class="briefing-section" id="briefing" aria-labelledby="briefing-title">
<header class="section-heading briefing-heading">
<p class="eyebrow">내 조건 브리핑</p>
<h2 id="briefing-title">맞는 이유와<br>조정할 지점을 나눕니다.</h2>
<p>화면에 입력한 값과 후보 정보를 이 브라우저에서 대조합니다.</p>
</header>
<div class="briefing-sheet" data-analysis-result aria-live="polite" aria-atomic="false">
<header><span>예시 AI 브리핑 · 모의 조건 비교 · 공인중개사 확인 전</span><strong data-analysis-home>아직 분석하지 않았습니다</strong></header>
<div class="briefing-empty" data-analysis-empty>
<span aria-hidden="true">01</span>
<div><h3>내 조건을 먼저 알려 주세요.</h3><p>분석을 실행하면 세 후보 가운데 조건과 가까운 집을 고르고, 이유를 세 묶음으로 정리합니다.</p></div>
<button class="button button-light" type="button" data-focus-condition>조건 입력으로 이동</button>
</div>
<div class="briefing-groups" data-analysis-groups hidden>
<section><span class="group-number">01</span><h3>조건과 맞는 점</h3><ul data-fit-list></ul></section>
<section><span class="group-number">02</span><h3>조정이 필요한 점</h3><ul data-adjust-list></ul></section>
<section><span class="group-number">03</span><h3>중개사와 확인할 점</h3><ul data-check-list></ul></section>
</div>
</div>
</section>
<section class="compare-section" id="homes" aria-labelledby="compare-title">
<header class="section-heading compare-heading">
<div><p class="eyebrow">예시 매물 3곳</p><h2 id="compare-title">서울의 집을<br>한 장씩 대조합니다.</h2></div>
<p>후보를 바꾸면 가운데 장면과 오른쪽 다섯 조건이 함께 바뀝니다.</p>
<div class="unit-switches" role="group" aria-label="표시 단위">
<div role="group" aria-label="면적 단위"><button type="button" aria-pressed="true" data-area-unit="m2">㎡</button><button type="button" aria-pressed="false" data-area-unit="pyeong">평</button></div>
<div role="group" aria-label="가격 표시"><button type="button" aria-pressed="true" data-price-unit="total">총액</button><button type="button" aria-pressed="false" data-price-unit="unit">면적 단가</button></div>
</div>
</header>
<div class="compare-desk">
<div class="candidate-rail" data-candidate-rail role="group" aria-label="비교할 집 선택">
<div class="candidate-option is-selected"><button type="button" class="candidate-button" aria-label="정원형 연립주택 선택" aria-pressed="true" data-select-listing="home-1"></button><div class="candidate-content" aria-hidden="true"><span>01</span><strong>정원형 연립주택</strong><small>매매 · 118.42㎡</small></div></div>
<div class="candidate-option"><button type="button" class="candidate-button" aria-label="복층형 공동주택 선택" aria-pressed="false" data-select-listing="home-2"></button><div class="candidate-content" aria-hidden="true"><span>02</span><strong>복층형 공동주택</strong><small>전세 · 142.80㎡</small></div></div>
<div class="candidate-option"><button type="button" class="candidate-button" aria-label="테라스형 다세대주택 선택" aria-pressed="false" data-select-listing="home-3"></button><div class="candidate-content" aria-hidden="true"><span>03</span><strong>테라스형 다세대주택</strong><small>월세 · 140.84㎡</small></div></div>
</div>
<figure class="candidate-scene" data-image-frame>
<figcaption>연출 이미지</figcaption>
<img src="./assets/hannam-interior-1200.webp" width="1200" height="800" loading="lazy" alt="큰 창과 목재 수납이 있는 거실을 표현한 이미지" data-compare-image data-fallback-image>
<div class="scene-caption"><span data-compare-index>01</span><div><strong data-compare-name>정원형 연립주택</strong><small data-compare-life>한강진역 북쪽 생활권</small></div></div>
<div class="image-fallback" hidden data-image-fallback role="img" aria-label="후보 주거 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div>
</figure>
<dl class="condition-matrix" data-condition-matrix>
<div><dt>거래</dt><dd data-matrix-trade>매매</dd><dd class="matrix-note" data-matrix-trade-note>분석 후 거래 조건 대조</dd></div>
<div><dt>가격</dt><dd data-matrix-price>38억 원</dd><dd class="matrix-note" data-matrix-price-note>분석 후 예산과 대조</dd></div>
<div><dt>전용면적</dt><dd data-matrix-area>118.42㎡</dd><dd class="matrix-note" data-matrix-area-note>분석 후 면적과 대조</dd></div>
<div><dt>주차</dt><dd data-matrix-parking>2대</dd><dd class="matrix-note" data-matrix-parking-note>분석 후 주차와 대조</dd></div>
<div><dt>입주</dt><dd data-matrix-move>2026.12.20 이후</dd><dd class="matrix-note" data-matrix-move-note>분석 후 입주일과 대조</dd></div>
</dl>
</div>
</section>
<section class="dossier-section" id="dossier" aria-labelledby="dossier-title">
<header class="section-heading dossier-heading">
<p class="eyebrow">선택한 예시 집 검토 서류철</p>
<h2 id="dossier-title" data-dossier-title>정원형 연립주택</h2>
<p><strong data-dossier-life>한강진역 북쪽 생활권</strong><span>거리·소요 시간 미제공</span></p>
</header>
<div class="dossier-shell">
<div class="dossier-tabs" role="tablist" aria-label="선택한 집 검토 항목">
<button type="button" role="tab" aria-selected="true" aria-controls="dossier-basic" id="dossier-tab-basic" data-dossier-tab="basic">기본</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-price" id="dossier-tab-price" tabindex="-1" data-dossier-tab="price">가격</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-rights" id="dossier-tab-rights" tabindex="-1" data-dossier-tab="rights">권리</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-location" id="dossier-tab-location" tabindex="-1" data-dossier-tab="location">입지 확인</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-facility" id="dossier-tab-facility" tabindex="-1" data-dossier-tab="facility">시설·관리</button>
</div>
<div class="dossier-pages">
<section class="dossier-panel" role="tabpanel" id="dossier-basic" aria-labelledby="dossier-tab-basic" data-dossier-panel="basic">
<div class="image-filmstrip">
<figure data-image-frame><figcaption>연출 이미지</figcaption><img src="./assets/hannam-evening-960.webp" width="960" height="540" loading="lazy" alt="서울 언덕 생활권의 주거 외관을 표현한 이미지" data-fallback-image><div class="image-fallback" hidden data-image-fallback role="img" aria-label="외관 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div></figure>
<figure data-image-frame><figcaption>연출 이미지</figcaption><img src="./assets/hannam-interior-720.webp" width="720" height="480" loading="lazy" alt="큰 창과 목재 수납이 있는 거실을 표현한 이미지" data-fallback-image><div class="image-fallback" hidden data-image-fallback role="img" aria-label="실내 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div></figure>
</div>
<dl class="spec-strip" data-spec-strip>
<div><dt>거래</dt><dd>매매</dd></div><div><dt>가격</dt><dd>38억 원</dd></div><div><dt>전용</dt><dd>118.42㎡</dd></div><div><dt>주차</dt><dd>2대</dd></div><div><dt>입주</dt><dd>2026.12.20 이후</dd></div>
</dl>
<div class="decision-columns">
<section><h3>내 조건과 맞는 점</h3><ul data-dossier-fit><li>브리핑을 실행하면 입력 조건과의 관계를 표시합니다.</li></ul></section>
<section><h3>확인하고 결정할 점</h3><ul data-dossier-risk><li>등기와 권리 관계를 확인해야 합니다.</li><li>소음과 마감 상태는 현장에서 살펴야 합니다.</li><li>입주 가능일은 중개사와 다시 맞춰야 합니다.</li></ul></section>
</div>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-price" aria-labelledby="dossier-tab-price" data-dossier-panel="price" hidden>
<div class="page-heading"><span>가격 메모</span><h3>입력한 예산과 집의 가격을 따로 봅니다.</h3></div>
<dl class="page-ledger"><div><dt>입력한 예산 상한</dt><dd data-page-budget>40억 원</dd></div><div><dt>집의 거래 조건</dt><dd data-page-price>38억 원</dd></div><div><dt>기준</dt><dd>화면에 표시된 매물 정보</dd></div></dl>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-rights" aria-labelledby="dossier-tab-rights" data-dossier-panel="rights" hidden>
<div class="page-heading"><span>권리 확인</span><h3>계약 전 공인중개사가 다시 볼 항목입니다.</h3></div>
<ul class="review-list"><li><span>01</span><div><strong>등기와 소유 관계</strong><p>서류를 통해 현재 권리 상태를 확인합니다.</p></div></li><li><span>02</span><div><strong>계약 조건과 특약</strong><p>거래 형태에 맞는 보호 조건을 검토합니다.</p></div></li><li><span>03</span><div><strong>현재 거래 가능 상태</strong><p>상담 시점에 다시 확인합니다.</p></div></li></ul>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-location" aria-labelledby="dossier-tab-location" data-dossier-panel="location" hidden>
<div class="page-heading"><span>입지 확인</span><h3 data-location-heading>한강진역 북쪽 생활권을 직접 살핍니다.</h3></div>
<div class="location-memo"><p>역과 생활 편의 시설의 이용 방식은 현장에서 확인합니다.</p><p>출퇴근 경로는 실제 출발지와 시간대에 맞춰 다시 봅니다.</p><p>이미지의 배경은 조망이나 채광 판단에 사용하지 않습니다.</p></div>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-facility" aria-labelledby="dossier-tab-facility" data-dossier-panel="facility" hidden>
<div class="page-heading"><span>시설·관리</span><h3>생활 비용과 이용 조건을 함께 봅니다.</h3></div>
<dl class="page-ledger"><div><dt>관리비</dt><dd data-page-fee>월 48만 원</dd></div><div><dt>포함 항목</dt><dd data-page-included>공용 관리, 청소, 승강기</dd></div><div><dt>별도 항목</dt><dd data-page-excluded>전기, 가스, 수도</dd></div><div><dt>방과 욕실</dt><dd data-page-rooms>방 3 · 욕실 2</dd></div></dl>
</section>
</div>
</div>
</section>
<section class="handoff-section" id="handoff" aria-labelledby="handoff-title">
<header><p class="eyebrow">사람에게 넘기는 마감</p><h2 id="handoff-title">AI가 좁히고,<br>공인중개사가 확인합니다.</h2></header>
<ol class="handoff-steps">
<li><span>01</span><div><strong>조건 정리</strong><p>가격, 면적, 입주, 주차를 한 장에 모읍니다.</p></div></li>
<li><span>02</span><div><strong>후보 비교</strong><p>서로 다른 집의 차이를 같은 기준으로 대조합니다.</p></div></li>
<li><span>03</span><div><strong>권리·현장 확인</strong><p>서류와 현장에서 확인한 뒤 최종 판단합니다.</p></div></li>
</ol>
<button class="button button-light handoff-cta" type="button" data-open-consult>상담 흐름 미리보기</button>
</section>
</main>
<footer class="site-footer">
<div><strong>한남자리</strong><span>개인 주거 브리핑 데스크</span></div>
<p>한남자리는 가상 브랜드이며 등록 공인중개사무소나 거래 서비스를 제공하지 않습니다.</p>
<nav aria-label="보조 메뉴"><a href="#top">처음으로</a><a href="./admin/">관리 화면</a></nav>
</footer>
<dialog class="consult-dialog" data-consult-dialog aria-labelledby="consult-title">
<form method="dialog" class="dialog-shell" data-consult-form>
<header><div><span>상담 순서 살펴보기</span><h2 id="consult-title">중개사에게 무엇을 넘길까요?</h2></div><button class="dialog-close" type="button" aria-label="상담 흐름 닫기" data-close-consult>×</button></header>
<p class="dialog-privacy">연락처를 받거나 입력 내용을 저장하지 않습니다.</p>
<div class="dialog-content" data-consult-content>
<p><strong data-consult-home>정원형 연립주택</strong>을 기준으로 준비할 항목을 골라 보세요.</p>
<label><input type="checkbox" name="agenda" value="rights" checked> 권리 관계와 계약 조건</label>
<label><input type="checkbox" name="agenda" value="visit" checked> 현장 방문 때 볼 항목</label>
<label><input type="checkbox" name="agenda" value="move"> 입주 가능일 조율</label>
</div>
<footer><button class="button button-primary" type="button" data-finish-consult>다음 순서 보기</button></footer>
</form>
</dialog>
<div class="live-region" role="status" aria-live="polite" data-live-region hidden></div>
<noscript><div class="no-script"><strong>자바스크립트 없이 보는 중입니다.</strong><p>집 정보는 읽을 수 있지만 조건 비교, 후보 변경과 상담 미리보기는 작동하지 않습니다.</p></div></noscript>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View file

@ -0,0 +1,608 @@
(() => {
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const listingDetails = {
"DEMO-HJ-101": {
title: "한남 생활권 정원 주거",
status: "검수 필요",
statusClass: "status-warn",
trade: "매매",
type: "연립주택",
price: "38억 원",
area: "118.42㎡ · 35.8평",
rooms: "방 3 · 욕실 2",
moveIn: "2026.12.20 이후 협의",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "2개 차단",
ai: "사람 검토 전"
},
"DEMO-HJ-102": {
title: "한강 가까운 복층 주거",
status: "쇼케이스 공개",
statusClass: "status-pass",
trade: "전세",
type: "공동주택",
price: "전세 22억 원",
area: "142.80㎡ · 43.2평",
rooms: "방 4 · 욕실 2",
moveIn: "2026.11.30 이후 협의",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "데모 통과",
ai: "수정 후 승인"
},
"DEMO-HJ-103": {
title: "조용한 골목의 테라스 주거",
status: "자료 오래됨",
statusClass: "status-warn",
trade: "월세",
type: "다세대주택",
price: "보증금 5억 · 월 1,200만",
area: "140.84㎡ · 42.6평",
rooms: "방 3 · 욕실 2",
moveIn: "입주 시점 다시 확인",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "다시 확인",
ai: "입력 변경으로 오래됨"
},
"DEMO-HJ-104": {
title: "언덕 위 작은 정원 주거",
status: "계약 완료",
statusClass: "status-demo",
trade: "매매",
type: "단독주택",
price: "상담 종료",
area: "예시 공개 종료",
rooms: "공개 목록 제외",
moveIn: "해당 없음",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "게시 안 함",
ai: "종료"
},
"DEMO-HJ-105": {
title: "남산 가까운 가족 주거",
status: "초안",
statusClass: "status-demo",
trade: "전세",
type: "공동주택",
price: "조건 작성 중",
area: "132.00㎡ · 39.9평",
rooms: "입력 전",
moveIn: "입력 전",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "5개 차단",
ai: "실행 전"
},
"DEMO-HJ-106": {
title: "독서당길 안쪽 복층 주거",
status: "검수 필요",
statusClass: "status-warn",
trade: "매매",
type: "공동주택",
price: "41억 원",
area: "151.20㎡ · 45.7평",
rooms: "방 4 · 욕실 3",
moveIn: "2027.01 이후 협의",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "동의 대기",
ai: "직전 실행 실패"
}
};
const state = {
selectedId: "DEMO-HJ-101",
mode: "normal",
toastTimer: null,
draftCreated: false
};
const queuePanel = $("[data-queue-panel]");
const queueBody = $("[data-queue-body]");
const inspector = $("[data-inspector]");
const workbench = $(".admin-workbench");
const inspectorBackdrop = $("[data-inspector-backdrop]");
const sidebar = $("[data-sidebar]");
const sidebarBackdrop = $("[data-sidebar-backdrop]");
const announce = (message) => {
const region = $("[data-admin-live]");
if (!region) return;
region.textContent = message;
region.hidden = false;
window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => {
region.hidden = true;
region.textContent = "";
}, 3600);
};
const showBanner = (title, copy, kind = "warn") => {
const banner = $("[data-system-banner]");
if (!banner) return;
banner.classList.remove("is-error", "is-info");
if (kind === "error") banner.classList.add("is-error");
if (kind === "info") banner.classList.add("is-info");
$("[data-banner-title]", banner).textContent = title;
$("[data-banner-copy]", banner).textContent = copy;
banner.hidden = false;
};
const hideBanner = () => {
const banner = $("[data-system-banner]");
if (banner) banner.hidden = true;
};
const getRows = () => $$('[data-queue-row]', queueBody);
const isCompactInspector = () => window.matchMedia("(max-width: 76rem)").matches;
const updateInspectorBackdrop = () => {
if (!inspectorBackdrop) return;
inspectorBackdrop.hidden = !(isCompactInspector() && inspector?.classList.contains("is-open"));
};
const openInspector = (id, focus = false) => {
const detail = listingDetails[id] || listingDetails["DEMO-HJ-101"];
state.selectedId = id;
getRows().forEach((row) => {
const active = row.dataset.id === id;
row.classList.toggle("is-selected", active);
row.setAttribute("aria-selected", String(active));
});
if (!inspector) return;
inspector.classList.add("is-open");
workbench?.classList.remove("is-inspector-closed");
$("[data-inspector-id]", inspector).textContent = `${id} · 예시 매물`;
$("[data-inspector-title]", inspector).textContent = detail.title;
const status = $("[data-inspector-status]", inspector);
status.textContent = detail.status;
status.className = `status-badge ${detail.statusClass}`;
const image = $("[data-inspector-image]", inspector);
if (image) {
image.hidden = false;
image.src = detail.image;
image.alt = detail.imageAlt;
image.parentElement?.classList.remove("is-image-error");
}
const basicGrid = $("[data-basic-grid]", inspector);
if (basicGrid) {
const pairs = [
["거래 형태", detail.trade],
["예시 가격", detail.price],
["유형", detail.type],
["전용 면적", detail.area],
["방과 욕실", detail.rooms],
["입주 가능", detail.moveIn]
];
basicGrid.replaceChildren(...pairs.map(([label, value]) => {
const wrapper = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
wrapper.append(term, description);
return wrapper;
}));
}
updateInspectorBackdrop();
if (focus) $("[data-close-inspector]", inspector)?.focus();
announce(`${detail.title} 조사관을 열었습니다. ${detail.status} 상태입니다.`);
};
const closeInspector = ({ restoreFocus = false } = {}) => {
if (!inspector?.classList.contains("is-open")) return;
const previousId = state.selectedId;
inspector.classList.remove("is-open");
workbench?.classList.add("is-inspector-closed");
getRows().forEach((row) => {
row.classList.remove("is-selected");
row.setAttribute("aria-selected", "false");
});
if (inspectorBackdrop) inspectorBackdrop.hidden = true;
state.selectedId = null;
if (restoreFocus && previousId) {
$(`[data-queue-row][data-id="${previousId}"] [data-select-row]`)?.focus();
}
announce("조사관과 선택 행을 함께 닫았습니다.");
};
const activateTab = (name, focus = false) => {
$$('[role="tab"]', inspector).forEach((tab) => {
const active = tab.dataset.tab === name;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
if (active && focus) tab.focus();
});
$$('[data-panel]', inspector).forEach((panel) => {
panel.hidden = panel.dataset.panel !== name;
});
};
const focusMissing = () => {
if (!state.selectedId) openInspector("DEMO-HJ-101");
activateTab("legal");
window.requestAnimationFrame(() => {
const missing = $("#missing-orientation");
missing?.scrollIntoView({ block: "center", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
missing?.focus();
announce("첫 누락 항목인 방향 판단 근거로 이동했습니다.");
});
};
const closeSidebar = () => {
sidebar?.classList.remove("is-open");
if (sidebarBackdrop) sidebarBackdrop.hidden = true;
$("[data-open-sidebar]")?.setAttribute("aria-expanded", "false");
};
const openSidebar = () => {
sidebar?.classList.add("is-open");
if (sidebarBackdrop) sidebarBackdrop.hidden = false;
$("[data-open-sidebar]")?.setAttribute("aria-expanded", "true");
$("[data-close-sidebar]")?.focus();
};
const updateResultCount = () => {
const visible = getRows().filter((row) => !row.classList.contains("is-hidden"));
const result = $("[data-result-count]");
if (result) result.textContent = `${visible.length}개 예시`;
const empty = $("[data-queue-empty]");
if (state.mode === "normal") empty.hidden = visible.length !== 0;
return visible.length;
};
const applyFilters = () => {
const search = ($("[data-queue-search]")?.value || "").trim().toLocaleLowerCase("ko-KR");
const status = $("[data-status-filter]")?.value || "all";
getRows().forEach((row) => {
const matchesSearch = !search || row.textContent.toLocaleLowerCase("ko-KR").includes(search);
const matchesStatus = status === "all" || row.dataset.status === status;
row.classList.toggle("is-hidden", !(matchesSearch && matchesStatus));
});
const count = updateResultCount();
const emptyTitle = $("[data-empty-title]");
const emptyCopy = $("[data-empty-copy]");
if (count === 0 && state.mode === "normal") {
emptyTitle.textContent = "현재 조건에 맞는 예시가 없습니다.";
emptyCopy.textContent = "검색어와 필터를 초기화하면 전체 대기열을 다시 볼 수 있습니다.";
}
};
const sortRows = () => {
const sort = $("[data-sort-select]")?.value || "updated-desc";
const rows = getRows();
const compare = {
"updated-desc": (a, b) => b.dataset.updated.localeCompare(a.dataset.updated),
"updated-asc": (a, b) => a.dataset.updated.localeCompare(b.dataset.updated),
"title-asc": (a, b) => a.dataset.title.localeCompare(b.dataset.title, "ko"),
"status-asc": (a, b) => a.dataset.status.localeCompare(b.dataset.status, "ko")
}[sort];
rows.sort(compare).forEach((row) => queueBody.append(row));
announce("대기열 정렬을 바꿨습니다.");
};
const resetModes = () => {
queuePanel?.setAttribute("aria-busy", "false");
const loading = $("[data-queue-loading]");
const empty = $("[data-queue-empty]");
if (loading) loading.hidden = true;
if (empty) empty.hidden = true;
hideBanner();
document.body.classList.remove("is-permission-limited", "is-stale-demo");
$$('[data-review-ai], [data-simulate-save], [data-new-draft]').forEach((button) => button.disabled = false);
applyFilters();
};
const simulateState = (mode) => {
state.mode = mode;
resetModes();
if (mode === "loading") {
queuePanel?.setAttribute("aria-busy", "true");
$("[data-queue-loading]").hidden = false;
announce("대기열을 불러오는 중입니다.");
return;
}
if (mode === "empty") {
const empty = $("[data-queue-empty]");
empty.hidden = false;
$("[data-empty-title]").textContent = "아직 접수된 예시 매물이 없습니다.";
$("[data-empty-copy]").textContent = "새 예시 초안을 만들거나 다른 상태를 시연할 수 있습니다.";
announce("빈 대기열 상태를 시연합니다.");
return;
}
if (mode === "error") {
showBanner("대기열 일부를 불러오지 못했습니다.", "마지막으로 확인한 예시 자료를 유지합니다. 재시도 전에는 게시 상태를 바꿀 수 없습니다.", "error");
announce("불러오기 실패 상태입니다. 마지막 확인 자료를 보여 줍니다.");
return;
}
if (mode === "save-error") {
showBanner("저장 경로를 확인할 수 없습니다.", "입력은 화면에 남지만 저장 성공으로 표시하지 않습니다. 수정 시연을 누르면 실패 처리를 확인할 수 있습니다.", "error");
announce("저장 실패 상태를 시연합니다.");
return;
}
if (mode === "stale") {
document.body.classList.add("is-stale-demo");
showBanner("선택 매물의 자료가 오래됐습니다.", "가격, 거래 가능 상태와 현장 확인을 다시 보기 전에는 게시 준비로 이동할 수 없습니다.");
if (!state.selectedId) openInspector("DEMO-HJ-103");
const status = $("[data-inspector-status]");
if (status) {
status.textContent = "자료 오래됨";
status.className = "status-badge status-warn";
}
announce("자료 오래됨 상태를 시연합니다.");
return;
}
if (mode === "conflict") {
showBanner("다른 예시 담당자가 먼저 수정했습니다.", "현재 버전 7과 서버 예시 버전 8의 차이를 확인한 뒤 다시 저장해야 합니다.", "error");
announce("버전 충돌 상태를 시연합니다.");
return;
}
if (mode === "permission") {
document.body.classList.add("is-permission-limited");
$$('[data-review-ai], [data-simulate-save], [data-new-draft]').forEach((button) => button.disabled = true);
showBanner("중개보조 예시 권한으로 보는 중입니다.", "초안과 일정은 정리할 수 있지만 법정 검수 승인, AI 최종 승인과 게시를 할 수 없습니다.", "info");
announce("권한 제한 상태를 시연합니다.");
return;
}
announce("정상 데모 상태로 돌아왔습니다.");
};
const openDialog = (dialog) => {
if (!dialog) return;
dialog.showModal();
document.body.classList.add("is-locked");
};
const closeDialog = (dialog) => {
if (dialog?.open) dialog.close();
};
const simulateSave = () => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 매물 정보를 수정할 수 없습니다.");
return;
}
if (state.mode === "conflict") {
showBanner("버전 충돌로 저장하지 않았습니다.", "서버 예시 버전 8을 다시 불러오거나 현재 수정을 별도 초안으로 남겨야 합니다.", "error");
announce("버전 충돌로 저장하지 않았습니다.");
return;
}
if (state.mode === "save-error") {
showBanner("저장에 실패했습니다.", "변경 내용은 화면에 남아 있습니다. 성공 기록과 감사 로그는 만들지 않았습니다.", "error");
announce("저장에 실패했습니다. 성공으로 처리하지 않았습니다.");
return;
}
announce("수정 시연을 마쳤습니다. 서버가 없는 데모라 실제 저장하지 않았습니다.");
};
const createDraft = () => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 새 초안을 만들 수 없습니다.");
return;
}
if (state.draftCreated) {
openInspector("DEMO-HJ-NEW", true);
return;
}
state.draftCreated = true;
listingDetails["DEMO-HJ-NEW"] = {
title: "제목 없는 새 예시 초안",
status: "초안",
statusClass: "status-demo",
trade: "미선택",
type: "미선택",
price: "입력 전",
area: "입력 전",
rooms: "입력 전",
moveIn: "입력 전",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "필수 입력 대기",
ai: "실행 전"
};
const row = document.createElement("tr");
row.dataset.queueRow = "";
row.dataset.id = "DEMO-HJ-NEW";
row.dataset.status = "draft";
row.dataset.updated = new Date().toISOString();
row.dataset.title = "제목 없는 새 예시 초안";
row.setAttribute("aria-selected", "false");
row.innerHTML = `
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="제목 없는 새 예시 초안 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-draft"><i></i></span><span><strong>제목 없는 새 예시 초안</strong><small>DEMO-HJ-NEW · 예시 매물</small></span></span></td>
<td><span>미선택</span><small>유형 미선택</small></td><td><strong>입력 전</strong><small>면적 입력 전</small></td><td><span>방금</span><small>데모 초안</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>입력 대기</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>실행 전</span></td><td><span class="assignee"><i>김</i>김유진</span></td>`;
queueBody.prepend(row);
applyFilters();
openInspector("DEMO-HJ-NEW", true);
announce("새 예시 초안을 만들었습니다. 외부 서버에는 저장하지 않았습니다.");
};
const handleRowSelection = (button) => {
const row = button.closest("[data-queue-row]");
if (row) openInspector(row.dataset.id, true);
};
const initTabs = () => {
const tabs = $$('[role="tab"]', inspector);
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
tab.addEventListener("keydown", (event) => {
let targetIndex = null;
if (event.key === "ArrowRight") targetIndex = (index + 1) % tabs.length;
if (event.key === "ArrowLeft") targetIndex = (index - 1 + tabs.length) % tabs.length;
if (event.key === "Home") targetIndex = 0;
if (event.key === "End") targetIndex = tabs.length - 1;
if (targetIndex !== null) {
event.preventDefault();
activateTab(tabs[targetIndex].dataset.tab, true);
}
});
});
};
const initDialogs = () => {
const blockDialog = $("[data-block-dialog]");
const previewDialog = $("[data-preview-dialog]");
$$('[data-block-publish]').forEach((button) => button.addEventListener("click", () => openDialog(blockDialog)));
$$('[data-open-preview]').forEach((button) => button.addEventListener("click", () => openDialog(previewDialog)));
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => closeDialog(button.closest("dialog"))));
$$('dialog').forEach((dialog) => {
dialog.addEventListener("click", (event) => {
if (event.target === dialog) closeDialog(dialog);
});
dialog.addEventListener("close", () => {
document.body.classList.remove("is-locked");
closeInspector();
});
dialog.addEventListener("cancel", () => document.body.classList.remove("is-locked"));
});
};
const initImageFallback = () => {
$$('img').forEach((image) => {
const handleError = () => {
image.hidden = true;
image.parentElement?.classList.add("is-image-error");
image.parentElement?.setAttribute("data-image-error", "연출 이미지를 불러오지 못했습니다");
};
image.addEventListener("error", handleError);
if (image.complete && image.naturalWidth === 0) handleError();
});
};
const initGlobalActions = () => {
$("[data-close-inspector]")?.addEventListener("click", () => closeInspector({ restoreFocus: true }));
inspectorBackdrop?.addEventListener("click", () => closeInspector({ restoreFocus: true }));
$("[data-open-sidebar]")?.addEventListener("click", openSidebar);
$("[data-close-sidebar]")?.addEventListener("click", closeSidebar);
sidebarBackdrop?.addEventListener("click", closeSidebar);
$("[data-dismiss-banner]")?.addEventListener("click", hideBanner);
$("[data-reset-filter]")?.addEventListener("click", () => {
$("[data-queue-search]").value = "";
$("[data-status-filter]").value = "all";
state.mode = "normal";
$("[data-state-simulator]").value = "normal";
resetModes();
announce("검색과 필터를 초기화했습니다.");
});
$("[data-queue-search]")?.addEventListener("input", applyFilters);
$("[data-status-filter]")?.addEventListener("change", applyFilters);
$("[data-sort-select]")?.addEventListener("change", sortRows);
$("[data-state-simulator]")?.addEventListener("change", (event) => simulateState(event.target.value));
$$('[data-focus-missing]').forEach((button) => button.addEventListener("click", focusMissing));
$$('[data-simulate-save]').forEach((button) => button.addEventListener("click", simulateSave));
$("[data-new-draft]")?.addEventListener("click", createDraft);
queueBody?.addEventListener("click", (event) => {
const button = event.target.closest("[data-select-row]");
if (button) handleRowSelection(button);
});
$("[data-review-ai]")?.addEventListener("click", (event) => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 AI 결과를 승인할 수 없습니다.");
return;
}
event.currentTarget.textContent = "수정 후 승인됨";
event.currentTarget.disabled = true;
const badge = $("#inspector-ai .status-badge");
if (badge) {
badge.textContent = "수정 후 승인";
badge.className = "status-badge status-pass";
}
announce("AI 참고 분석을 수정 후 승인 상태로 바꿨습니다. 데모 외부에는 게시하지 않았습니다.");
});
$$('[data-claim-action]').forEach((button) => button.addEventListener("click", () => {
button.textContent = button.dataset.claimAction === "edit" ? "수정 반영" : "확인됨";
button.disabled = true;
announce("주장 검토 상태를 데모 안에서만 바꿨습니다.");
}));
$("[data-reschedule]")?.addEventListener("click", (event) => {
const card = event.currentTarget.closest(".appointment-card");
const time = $("div > strong", card);
time.textContent = time.textContent.includes("3:30") ? "오후 5:00~5:30" : "오후 3:30~4:00";
announce("예시 상담 시간을 바꿨습니다. 외부 알림은 보내지 않았습니다.");
});
$("[data-simulate-log-failure]")?.addEventListener("click", () => {
showBanner("감사 로그를 불러오지 못했습니다.", "변경 기록을 확인할 수 없는 동안 저장과 게시를 성공으로 처리하지 않습니다.", "error");
announce("감사 로그 실패 상태입니다. 게시를 중지합니다.");
});
$("[data-command-button]")?.addEventListener("click", () => {
$("[data-queue-search]")?.focus();
announce("매물 검색으로 이동했습니다.");
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "k") {
event.preventDefault();
$("[data-queue-search]")?.focus();
}
if (event.key === "Escape" && !$('dialog[open]')) {
if (sidebar?.classList.contains("is-open")) closeSidebar();
else closeInspector({ restoreFocus: true });
}
});
document.addEventListener("click", (event) => {
if (!inspector?.classList.contains("is-open") || $('dialog[open]')) return;
const path = event.composedPath();
const matchesPath = (selector) => path.some((node) => node instanceof Element && node.matches(selector));
const keptOpen = matchesPath("[data-inspector], [data-select-row], [data-inspector-trigger], [data-inspector-backdrop], [data-open-preview], [data-block-publish], [data-focus-missing]");
if (!keptOpen && !matchesPath("[data-open-sidebar], [data-sidebar]")) closeInspector();
});
window.matchMedia("(max-width: 76rem)").addEventListener("change", updateInspectorBackdrop);
window.matchMedia("(min-width: 50.01rem)").addEventListener("change", (event) => {
if (event.matches) closeSidebar();
});
$$('.sidebar-nav a[href^="#inspector-"]').forEach((link) => link.addEventListener("click", (event) => {
event.preventDefault();
if (!state.selectedId) openInspector("DEMO-HJ-101");
activateTab(link.getAttribute("href").replace("#inspector-", ""), true);
closeSidebar();
}));
};
const init = () => {
initTabs();
initDialogs();
initImageFallback();
initGlobalActions();
openInspector("DEMO-HJ-101");
updateResultCount();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})();

View file

@ -0,0 +1,590 @@
(() => {
"use strict";
const homes = [
{
id: "home-1",
index: "01",
name: "정원형 연립주택",
trade: "sale",
tradeLabel: "매매",
priceEok: 38,
areaM2: 118.42,
areaPyeong: 35.8,
parking: 2,
moveDate: "2026-12-20",
moveLabel: "2026.12.20 이후 협의",
life: "한강진역 북쪽 생활권",
fee: "월 48만 원",
included: "공용 관리, 청소, 승강기",
excluded: "전기, 가스, 수도",
rooms: "방 3 · 욕실 2",
image: "./assets/hannam-interior-1200.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지"
},
{
id: "home-2",
index: "02",
name: "복층형 공동주택",
trade: "jeonse",
tradeLabel: "전세",
priceEok: 22,
areaM2: 142.8,
areaPyeong: 43.2,
parking: 2,
moveDate: "2027-01-15",
moveLabel: "2027.01.15 이후 협의",
life: "독서당길 남쪽 생활권",
fee: "월 56만 원",
included: "공용 관리, 보안, 승강기",
excluded: "전기, 가스, 수도, 냉난방",
rooms: "방 4 · 욕실 2",
image: "./assets/hannam-evening-1600.webp",
imageAlt: "서울 언덕 생활권의 현대 주거 외관을 표현한 이미지"
},
{
id: "home-3",
index: "03",
name: "테라스형 다세대주택",
trade: "monthly",
tradeLabel: "월세",
priceEok: 5,
monthlyManwon: 1200,
areaM2: 140.84,
areaPyeong: 42.6,
parking: 1,
moveDate: "2026-11-01",
moveLabel: "2026.11.01 이후 협의",
life: "이태원로 동쪽 생활권",
fee: "월 52만 원",
included: "공용 관리, 청소",
excluded: "전기, 가스, 수도, 승강기 점검",
rooms: "방 3 · 욕실 2",
image: "./assets/hannam-interior-1200.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지"
}
];
const STORAGE_KEY = "hannam-jari-conditions-v2";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const state = {
selectedId: "home-1",
areaUnit: "m2",
priceUnit: "total",
analyzed: false,
conditions: null,
toastTimer: null,
consultationComplete: false
};
const form = $("[data-condition-form]");
const dialog = $("[data-consult-dialog]");
const formatNumber = (value, digits = 0) => new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
const getHome = (id = state.selectedId) => homes.find((home) => home.id === id) || homes[0];
const formatArea = (home) => state.areaUnit === "m2"
? `${formatNumber(home.areaM2, 2)}㎡`
: `${formatNumber(home.areaPyeong, 1)}평`;
const unitPriceText = (home) => {
const perPyeong = Math.round(((home.priceEok * 10000) / home.areaPyeong) / 10) * 10;
if (home.trade === "monthly") return `보증금 평당 약 ${formatNumber(perPyeong)}만 · 월 ${formatNumber(home.monthlyManwon)}만`;
return `평당 약 ${formatNumber(perPyeong)}만 원`;
};
const totalPriceText = (home) => home.trade === "monthly"
? `보증금 ${formatNumber(home.priceEok)}억 · 월 ${formatNumber(home.monthlyManwon)}만`
: `${formatNumber(home.priceEok)}억 원`;
const formatPrice = (home) => state.priceUnit === "total" ? totalPriceText(home) : unitPriceText(home);
const readConditions = () => {
const data = new FormData(form);
return {
trade: String(data.get("trade") || "sale"),
budget: Number(data.get("budget") || 0),
minArea: Number(data.get("minArea") || 0),
moveIn: String(data.get("moveIn") || ""),
parking: Number(data.get("parking") || 0)
};
};
const announce = (message) => {
const region = $("[data-live-region]");
if (!region) return;
region.textContent = message;
region.hidden = false;
window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => {
region.hidden = true;
region.textContent = "";
}, 3200);
};
const claim = (title, text, source) => ({ title, text, source });
const evaluateHome = (home, conditions) => {
const fit = [];
const adjust = [];
const check = [];
if (home.trade === conditions.trade) {
fit.push(claim("거래 형태", `${home.tradeLabel} 조건과 같습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("거래 형태", `입력은 ${tradeLabel(conditions.trade)}, 선택한 집은 ${home.tradeLabel}입니다.`, "입력한 조건 · 매물 정보"));
}
if (home.trade === "monthly") {
if (home.priceEok > conditions.budget) {
adjust.push(claim("보증금 상한", `보증금이 입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("월 납부 조건", `보증금은 상한 안쪽이지만 월 ${formatNumber(home.monthlyManwon)}만 원을 별도로 살펴야 합니다.`, "입력한 조건 · 매물 정보"));
}
} else if (home.priceEok <= conditions.budget) {
fit.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(conditions.budget - home.priceEok)}억 원 낮습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보"));
}
if (home.areaM2 >= conditions.minArea) {
fit.push(claim("전용면적", `최소 면적보다 ${formatNumber(home.areaM2 - conditions.minArea, 2)}㎡ 넓습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("전용면적", `최소 면적보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작습니다.`, "입력한 조건 · 매물 정보"));
}
if (home.parking >= conditions.parking) {
fit.push(claim("주차", `${home.parking}대로 필요한 수를 충족합니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("주차", `필요한 수보다 ${conditions.parking - home.parking}대 부족합니다.`, "입력한 조건 · 매물 정보"));
}
if (!conditions.moveIn) {
adjust.push(claim("입주 희망일", "날짜를 정하면 입주 가능 시점과 대조할 수 있습니다.", "입력한 조건"));
} else if (home.moveDate <= conditions.moveIn) {
fit.push(claim("입주 시점", `${home.moveLabel}으로 희망일 전후 협의가 가능합니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("입주 시점", `${home.moveLabel}으로 입력한 희망일보다 늦습니다.`, "입력한 조건 · 매물 정보"));
}
check.push(claim("권리 관계", "등기와 계약 조건은 서류를 통해 다시 살펴야 합니다.", "공인중개사 확인"));
check.push(claim("현장 상태", "소음, 마감과 채광은 방문해서 확인해야 합니다.", "현장 확인"));
return { fit, adjust, check };
};
const tradeLabel = (trade) => ({ sale: "매매", jeonse: "전세", monthly: "월세" })[trade] || "미선택";
const scoreHome = (home, conditions) => {
let score = home.trade === conditions.trade ? 4 : 0;
if (home.trade === "monthly") score += home.priceEok <= conditions.budget ? 0.5 : 0;
else score += home.priceEok <= conditions.budget ? 1 : 0;
score += home.areaM2 >= conditions.minArea ? 1 : 0;
score += home.parking >= conditions.parking ? 1 : 0;
score += conditions.moveIn && home.moveDate <= conditions.moveIn ? 1 : 0;
return score;
};
const createClaimItem = ({ title, text, source }) => {
const item = document.createElement("li");
const strong = document.createElement("strong");
const span = document.createElement("span");
const small = document.createElement("small");
strong.textContent = title;
span.textContent = text;
small.textContent = source;
item.append(strong, span, small);
return item;
};
const fillClaimList = (selector, items, emptyText) => {
const list = $(selector);
if (!list) return;
const nextItems = items.length ? items : [claim(emptyText, "현재 입력에서 바로 조정할 항목이 없습니다.", "입력한 조건 · 매물 정보")];
list.replaceChildren(...nextItems.map(createClaimItem));
};
const renderBriefing = (home, conditions) => {
const result = evaluateHome(home, conditions);
$("[data-analysis-empty]").hidden = true;
$("[data-analysis-groups]").hidden = false;
$("[data-analysis-home]").textContent = `${home.name} 브리핑`;
fillClaimList("[data-fit-list]", result.fit, "바로 맞는 조건 없음");
fillClaimList("[data-adjust-list]", result.adjust, "바로 조정할 조건 없음");
fillClaimList("[data-check-list]", result.check, "추가 확인 항목 없음");
return result;
};
const resetBriefing = () => {
state.analyzed = false;
state.conditions = null;
$("[data-analysis-empty]").hidden = false;
$("[data-analysis-groups]").hidden = true;
$("[data-analysis-home]").textContent = "아직 분석하지 않았습니다";
};
const setMatrixNote = (selector, text, tone = "") => {
const node = $(selector);
if (!node) return;
node.textContent = text;
node.className = ["matrix-note", tone].filter(Boolean).join(" ");
};
const renderMatrix = (home, conditions = readConditions()) => {
$("[data-matrix-trade]").textContent = home.tradeLabel;
$("[data-matrix-price]").textContent = formatPrice(home);
$("[data-matrix-area]").textContent = formatArea(home);
$("[data-matrix-parking]").textContent = `${home.parking}대`;
$("[data-matrix-move]").textContent = home.moveLabel;
if (!state.analyzed) {
setMatrixNote("[data-matrix-trade-note]", "분석 후 거래 조건 대조");
setMatrixNote("[data-matrix-price-note]", "분석 후 예산과 대조");
setMatrixNote("[data-matrix-area-note]", "분석 후 면적과 대조");
setMatrixNote("[data-matrix-parking-note]", "분석 후 주차와 대조");
setMatrixNote("[data-matrix-move-note]", "분석 후 입주일과 대조");
return;
}
setMatrixNote("[data-matrix-trade-note]", home.trade === conditions.trade ? "입력과 같음" : `${tradeLabel(conditions.trade)}에서 조정 필요`, home.trade === conditions.trade ? "" : "is-adjust");
if (home.trade === "monthly") {
const depositNote = home.priceEok <= conditions.budget ? "보증금은 상한 안쪽 · 월 납부 별도" : `보증금이 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`;
setMatrixNote("[data-matrix-price-note]", depositNote, "is-check");
} else {
const pricePass = home.priceEok <= conditions.budget;
setMatrixNote("[data-matrix-price-note]", pricePass ? `예산 ${formatNumber(conditions.budget)}억 안쪽` : `예산보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`, pricePass ? "" : "is-adjust");
}
const areaPass = home.areaM2 >= conditions.minArea;
setMatrixNote("[data-matrix-area-note]", areaPass ? `최소 ${formatNumber(conditions.minArea)}㎡ 충족` : `최소보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작음`, areaPass ? "" : "is-adjust");
const parkingPass = home.parking >= conditions.parking;
setMatrixNote("[data-matrix-parking-note]", parkingPass ? `필요 ${conditions.parking}대 충족` : `${conditions.parking - home.parking}대 부족`, parkingPass ? "" : "is-adjust");
if (!conditions.moveIn) {
setMatrixNote("[data-matrix-move-note]", "희망일 입력 필요", "is-check");
} else {
const movePass = home.moveDate <= conditions.moveIn;
setMatrixNote("[data-matrix-move-note]", movePass ? "희망일 전후 협의 가능" : "희망일보다 늦음", movePass ? "" : "is-adjust");
}
};
const renderCandidateRail = () => {
$$('[data-select-listing]').forEach((button) => {
const home = getHome(button.dataset.selectListing);
const active = home.id === state.selectedId;
button.setAttribute("aria-pressed", String(active));
button.closest(".candidate-option")?.classList.toggle("is-selected", active);
const content = button.nextElementSibling;
const small = content?.querySelector("small");
if (small) small.textContent = `${home.tradeLabel} · ${formatArea(home)}`;
});
};
const renderCompareScene = (home) => {
const image = $("[data-compare-image]");
if (image) {
image.hidden = false;
image.src = home.image;
image.alt = home.imageAlt;
image.parentElement?.classList.remove("is-image-error");
$("[data-image-fallback]", image.parentElement).hidden = true;
}
$("[data-compare-index]").textContent = home.index;
$("[data-compare-name]").textContent = home.name;
$("[data-compare-life]").textContent = home.life;
};
const setSimpleList = (selector, items) => {
const list = $(selector);
if (!list) return;
list.replaceChildren(...items.map((text) => {
const item = document.createElement("li");
item.textContent = text;
return item;
}));
};
const renderSpecs = (home) => {
const root = $("[data-spec-strip]");
if (!root) return;
const pairs = [
["거래", home.tradeLabel],
["가격", formatPrice(home)],
["전용", formatArea(home)],
["주차", `${home.parking}대`],
["입주", home.moveLabel]
];
root.replaceChildren(...pairs.map(([label, value]) => {
const wrapper = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
wrapper.append(term, description);
return wrapper;
}));
};
const renderDossier = (home, conditions = readConditions()) => {
$("[data-dossier-title]").textContent = home.name;
$("[data-dossier-life]").textContent = home.life;
const consultHome = $("[data-consult-home]");
if (consultHome) consultHome.textContent = home.name;
$("[data-location-heading]").textContent = `${home.life}을 직접 살핍니다.`;
$("[data-page-budget]").textContent = `${formatNumber(conditions.budget)}억 원`;
$("[data-page-price]").textContent = totalPriceText(home);
$("[data-page-fee]").textContent = home.fee;
$("[data-page-included]").textContent = home.included;
$("[data-page-excluded]").textContent = home.excluded;
$("[data-page-rooms]").textContent = home.rooms;
renderSpecs(home);
if (!state.analyzed) {
setSimpleList("[data-dossier-fit]", ["브리핑을 실행하면 입력 조건과의 관계를 표시합니다."]);
setSimpleList("[data-dossier-risk]", ["등기와 권리 관계를 확인해야 합니다.", "소음과 마감 상태는 현장에서 살펴야 합니다.", "입주 가능일은 중개사와 다시 맞춰야 합니다."]);
return;
}
const result = evaluateHome(home, conditions);
const fits = result.fit.length ? result.fit.map((item) => item.text) : ["현재 입력과 바로 맞는 항목은 없습니다."];
const risks = [...result.adjust, ...result.check].map((item) => item.text).slice(0, 4);
setSimpleList("[data-dossier-fit]", fits);
setSimpleList("[data-dossier-risk]", risks);
};
const renderSelection = () => {
const home = getHome();
const conditions = state.conditions || readConditions();
renderCandidateRail();
renderCompareScene(home);
renderMatrix(home, conditions);
renderDossier(home, conditions);
if (state.analyzed) renderBriefing(home, conditions);
};
const selectListing = (id, announceChange = true) => {
const home = getHome(id);
state.selectedId = home.id;
renderSelection();
if (announceChange) announce(`${home.name}을 선택했습니다. 비교표와 서류철을 바꿨습니다.`);
};
const runAnalysis = () => {
if (!form.reportValidity()) return;
const conditions = readConditions();
state.conditions = conditions;
state.analyzed = true;
const best = homes.reduce((current, home) => scoreHome(home, conditions) > scoreHome(current, conditions) ? home : current, homes[0]);
state.selectedId = best.id;
renderSelection();
$("#briefing")?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
announce(`${best.name}을 가장 가까운 후보로 골라 세 묶음의 브리핑을 만들었습니다.`);
};
const markConditionsChanged = () => {
if (!state.analyzed) return;
resetBriefing();
renderSelection();
announce("조건이 바뀌어 이전 브리핑을 비웠습니다. 다시 분석해 주세요.");
};
const saveConditions = () => {
if (!form.reportValidity()) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(readConditions()));
announce("현재 조건을 이 브라우저에 저장했습니다.");
} catch {
announce("브라우저 저장소를 사용할 수 없어 조건을 저장하지 않았습니다.");
}
};
const restoreConditions = () => {
try {
const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "null");
if (!saved || typeof saved !== "object") return;
if (["sale", "jeonse", "monthly"].includes(saved.trade)) form.elements.trade.value = saved.trade;
if (Number.isFinite(saved.budget)) form.elements.budget.value = String(saved.budget);
if (Number.isFinite(saved.minArea)) form.elements.minArea.value = String(saved.minArea);
if (typeof saved.moveIn === "string") form.elements.moveIn.value = saved.moveIn;
if (Number.isFinite(saved.parking)) form.elements.parking.value = String(saved.parking);
} catch {
window.localStorage.removeItem(STORAGE_KEY);
}
};
const focusConditions = () => {
form.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "center" });
window.setTimeout(() => form.elements.trade?.focus(), 180);
};
const activateDossierTab = (name, focus = false) => {
$$('[data-dossier-tab]').forEach((tab) => {
const active = tab.dataset.dossierTab === name;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
if (active && focus) tab.focus();
});
$$('[data-dossier-panel]').forEach((panel) => {
panel.hidden = panel.dataset.dossierPanel !== name;
});
};
const initDossierTabs = () => {
const tabs = $$('[data-dossier-tab]');
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => activateDossierTab(tab.dataset.dossierTab));
tab.addEventListener("keydown", (event) => {
let target = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") target = (index + 1) % tabs.length;
if (event.key === "ArrowLeft" || event.key === "ArrowUp") target = (index - 1 + tabs.length) % tabs.length;
if (event.key === "Home") target = 0;
if (event.key === "End") target = tabs.length - 1;
if (target !== null) {
event.preventDefault();
activateDossierTab(tabs[target].dataset.dossierTab, true);
}
});
});
};
const resetConsultation = () => {
state.consultationComplete = false;
const content = $("[data-consult-content]", dialog);
content.replaceChildren();
const copy = document.createElement("p");
const strong = document.createElement("strong");
strong.dataset.consultHome = "";
strong.textContent = getHome().name;
copy.append(strong, "을 기준으로 준비할 항목을 골라 보세요.");
content.append(copy);
[["rights", "권리 관계와 계약 조건", true], ["visit", "현장 방문 때 볼 항목", true], ["move", "입주 가능일 조율", false]].forEach(([value, label, checked]) => {
const wrapper = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.name = "agenda";
input.value = value;
input.checked = checked;
wrapper.append(input, ` ${label}`);
content.append(wrapper);
});
$("[data-finish-consult]", dialog).textContent = "다음 순서 보기";
};
const openConsultation = () => {
resetConsultation();
dialog.showModal();
document.body.classList.add("is-locked");
};
const closeConsultation = () => {
if (dialog.open) dialog.close();
};
const finishConsultation = () => {
if (state.consultationComplete) {
resetConsultation();
return;
}
state.consultationComplete = true;
const content = $("[data-consult-content]", dialog);
const selected = $$('input[name="agenda"]:checked', content).map((input) => input.closest("label")?.textContent.trim()).filter(Boolean);
content.replaceChildren();
const heading = document.createElement("p");
const strong = document.createElement("strong");
strong.textContent = "준비할 순서";
heading.append(strong);
const list = document.createElement("ol");
const items = selected.length ? selected : ["집의 기본 조건 다시 보기"];
items.forEach((text) => {
const item = document.createElement("li");
item.textContent = text;
list.append(item);
});
content.append(heading, list);
$("[data-finish-consult]", dialog).textContent = "처음부터 보기";
};
const initImageFallback = () => {
$$('[data-fallback-image]').forEach((image) => {
const handleError = () => {
image.hidden = true;
const frame = image.closest("[data-image-frame]");
frame?.classList.add("is-image-error");
const fallback = $("[data-image-fallback]", frame);
if (fallback) fallback.hidden = false;
};
image.addEventListener("error", handleError);
if (image.complete && image.naturalWidth === 0) handleError();
});
};
const initMenu = () => {
const button = $("[data-menu-button]");
const nav = $("[data-site-nav]");
const close = () => {
nav.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button?.addEventListener("click", () => {
const open = nav.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
});
$$("a", nav).forEach((link) => link.addEventListener("click", close));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") close();
});
};
const init = () => {
restoreConditions();
initMenu();
initDossierTabs();
initImageFallback();
resetBriefing();
renderSelection();
form.addEventListener("submit", (event) => {
event.preventDefault();
runAnalysis();
});
form.addEventListener("input", markConditionsChanged);
form.addEventListener("change", markConditionsChanged);
$$('[data-save-condition]').forEach((button) => button.addEventListener("click", saveConditions));
$$('[data-focus-condition]').forEach((button) => button.addEventListener("click", focusConditions));
$$('[data-select-listing]').forEach((button) => button.addEventListener("click", () => selectListing(button.dataset.selectListing)));
$$('[data-area-unit]').forEach((button) => button.addEventListener("click", () => {
state.areaUnit = button.dataset.areaUnit;
$$('[data-area-unit]').forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
renderSelection();
announce(`면적을 ${state.areaUnit === "m2" ? "제곱미터" : "평"}으로 표시합니다.`);
}));
$$('[data-price-unit]').forEach((button) => button.addEventListener("click", () => {
state.priceUnit = button.dataset.priceUnit;
$$('[data-price-unit]').forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
renderSelection();
announce(`가격을 ${state.priceUnit === "total" ? "총액" : "면적 단가"}으로 표시합니다.`);
}));
$$('[data-open-consult]').forEach((button) => button.addEventListener("click", openConsultation));
$("[data-close-consult]")?.addEventListener("click", closeConsultation);
$("[data-finish-consult]")?.addEventListener("click", finishConsultation);
dialog?.addEventListener("click", (event) => {
if (event.target === dialog) closeConsultation();
});
dialog?.addEventListener("close", () => {
document.body.classList.remove("is-locked");
resetConsultation();
});
dialog?.addEventListener("cancel", () => document.body.classList.remove("is-locked"));
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init, { once: true });
else init();
})();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,312 @@
:root {
color-scheme: light;
--font-sans: "Pretendard Variable", Pretendard, SUIT, "Noto Sans KR", "Apple SD Gothic Neo", system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--ink-950: #0a0a09;
--ink-900: #121210;
--ink-800: #24231f;
--ink-700: #3e3c35;
--ink-600: #5d5a50;
--ink-500: #69655d;
--paper-50: #f8f7f3;
--paper-100: #f2f0e9;
--paper-200: #e8e4da;
--paper-300: #d8d2c5;
--gold-dark: #6f5420;
--gold: #9b762d;
--gold-light: #c6a15b;
--blue: #345d8c;
--blue-pale: #e7eef6;
--green: #2e684d;
--green-pale: #e5f0e9;
--red: #9d3f34;
--red-pale: #f6e8e5;
--amber: #7f5d18;
--amber-pale: #f4ecd8;
--white: #ffffff;
--line-light: rgba(10, 10, 9, 0.14);
--line-dark: rgba(255, 255, 255, 0.18);
--focus: #2e6ba8;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
--space-32: 8rem;
--content: min(100% - 2rem, 88rem);
--reading: 44rem;
--header-height: 4.5rem;
--radius-sm: 0.25rem;
--radius-md: 0.75rem;
--radius-lg: 1.25rem;
--transition: 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.6;
letter-spacing: -0.015em;
color: var(--ink-950);
background: var(--paper-50);
text-rendering: optimizeLegibility;
}
body.is-locked {
overflow: hidden;
}
img,
svg {
display: block;
max-width: 100%;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
select,
input[type="range"],
input[type="checkbox"],
input[type="radio"] {
cursor: pointer;
}
button:disabled,
input:disabled,
select:disabled {
cursor: not-allowed;
}
a {
color: inherit;
}
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
[hidden] {
display: none !important;
}
.skip-link {
position: fixed;
z-index: 200;
top: 0.75rem;
left: 0.75rem;
padding: 0.75rem 1rem;
color: var(--white);
background: var(--ink-950);
transform: translateY(-180%);
transition: transform var(--transition);
}
.skip-link:focus {
transform: translateY(0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.demo-strip {
min-height: 2rem;
padding: 0.35rem 1rem;
color: #f5ead5;
background: var(--ink-950);
border-bottom: 1px solid rgba(198, 161, 91, 0.4);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-align: center;
}
.button {
display: inline-flex;
min-height: 2.875rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.7rem 1.05rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-weight: 720;
line-height: 1.2;
text-decoration: none;
transition: color var(--transition), background-color var(--transition), border-color var(--transition), transform var(--transition);
}
.button:hover {
transform: translateY(-1px);
}
.button-primary {
color: var(--paper-50);
background: var(--ink-950);
border-color: var(--ink-950);
}
.button-primary:hover {
color: var(--ink-950);
background: var(--gold-light);
border-color: var(--gold-light);
}
.button-light {
color: var(--ink-950);
background: var(--paper-50);
border-color: var(--paper-50);
}
.button-outline {
color: inherit;
background: transparent;
border-color: currentColor;
}
.button-ghost {
color: inherit;
background: transparent;
border-color: var(--line-light);
}
.button-small {
min-height: 2.25rem;
padding: 0.45rem 0.7rem;
font-size: 0.8125rem;
}
.status-badge {
display: inline-flex;
min-height: 1.6rem;
align-items: center;
gap: 0.35rem;
width: max-content;
padding: 0.25rem 0.5rem;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 760;
line-height: 1;
letter-spacing: 0.01em;
}
.status-badge::before {
width: 0.38rem;
height: 0.38rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.status-demo {
color: var(--gold-dark);
background: #f7efdc;
}
.status-pass {
color: var(--green);
background: var(--green-pale);
}
.status-warn {
color: var(--amber);
background: var(--amber-pale);
}
.status-blocked {
color: var(--red);
background: var(--red-pale);
}
.field-label {
display: block;
margin-bottom: 0.45rem;
color: var(--ink-700);
font-size: 0.75rem;
font-weight: 760;
}
.text-input,
.select-input {
width: 100%;
min-height: 2.75rem;
padding: 0.65rem 0.75rem;
color: var(--ink-950);
background: var(--white);
border: 1px solid var(--line-light);
border-radius: var(--radius-sm);
}
.live-region {
position: fixed;
z-index: 300;
right: 1rem;
bottom: 1rem;
max-width: min(24rem, calc(100% - 2rem));
padding: 0.85rem 1rem;
color: var(--white);
background: var(--ink-950);
border-left: 3px solid var(--gold-light);
border-radius: var(--radius-sm);
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.22);
}
@media (min-width: 48rem) {
:root {
--content: min(100% - 4rem, 88rem);
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}