release: v0.10.0
BIN
apps/site/public/work/griddle-club/assets/griddle-press.webp
Normal file
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/site/public/work/griddle-club/assets/hero-handoff.webp
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/site/public/work/griddle-club/assets/kitchen-flow.webp
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/site/public/work/griddle-club/assets/signature-double.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
358
apps/site/public/work/griddle-club/franchise/index.html
Normal 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>
|
||||
462
apps/site/public/work/griddle-club/index.html
Normal 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>
|
||||
BIN
apps/site/public/work/griddle-club/og.png
Normal file
|
After Width: | Height: | Size: 477 KiB |
300
apps/site/public/work/griddle-club/scripts/franchise.js
Normal 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);
|
||||
})();
|
||||
261
apps/site/public/work/griddle-club/scripts/site.js
Normal 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();
|
||||
})();
|
||||
979
apps/site/public/work/griddle-club/styles/franchise.css
Normal 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;
|
||||
}
|
||||
}
|
||||
1807
apps/site/public/work/griddle-club/styles/site.css
Normal file
43
apps/site/public/work/griddle-club/styles/tokens.css
Normal 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;
|
||||
}
|
||||
}
|
||||
294
apps/site/public/work/hannam-jari/admin/index.html
Normal 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>
|
||||
|
After Width: | Height: | Size: 123 KiB |
BIN
apps/site/public/work/hannam-jari/assets/hannam-evening-640.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/site/public/work/hannam-jari/assets/hannam-evening-960.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 22 KiB |
225
apps/site/public/work/hannam-jari/index.html
Normal 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>
|
||||
BIN
apps/site/public/work/hannam-jari/og.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
608
apps/site/public/work/hannam-jari/scripts/admin.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
590
apps/site/public/work/hannam-jari/scripts/landing.js
Normal 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();
|
||||
})();
|
||||
2172
apps/site/public/work/hannam-jari/styles/admin.css
Normal file
1741
apps/site/public/work/hannam-jari/styles/landing.css
Normal file
312
apps/site/public/work/hannam-jari/styles/tokens.css
Normal 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;
|
||||
}
|
||||
}
|
||||