release: v0.10.0
This commit is contained in:
parent
2b7fa32b5b
commit
6930906583
61 changed files with 15165 additions and 82 deletions
608
apps/site/public/work/hannam-jari/scripts/admin.js
Normal file
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
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();
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue