Upgrade RFS and Saebyeok showcases
Some checks failed
ci / build (push) Has been cancelled

This commit is contained in:
Yun Chan 2026-08-25 04:41:51 +09:00
parent 9529e3747c
commit 413486958c
13 changed files with 1410 additions and 88 deletions

View file

@ -24,7 +24,8 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css" />
<link rel="stylesheet" href="./styles/tokens.css?v=2" />
<link rel="stylesheet" href="./styles/main.css?v=2" />
<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='%2316181A'/%3E%3Cpath d='M6 20h20' stroke='%237FA8D9' stroke-width='2'/%3E%3Ccircle cx='13' cy='14' r='4.5' fill='none' stroke='%23F3F1EC' stroke-width='2'/%3E%3Cpath d='M13 9.5V7' stroke='%23F3F1EC' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"
@ -97,7 +98,21 @@
</thead>
<tbody>
<tr>
<th scope="row">백도 복숭아</th>
<th scope="row">
<button
class="board-select"
type="button"
data-market-row
data-name="백도 복숭아"
data-origin="경북 청도"
data-grade="특 · 12.5 Bx"
data-pack="4.5 kg"
data-price="38,000원"
data-delta="▲ 2,000원"
data-auction="02:00 경매"
aria-pressed="true"
>백도 복숭아</button>
</th>
<td data-label="산지">경북 청도</td>
<td data-label="등급" class="c-num mono">특</td>
<td data-label="당도" class="c-num mono">12.5<span class="unit">Bx</span></td>
@ -106,7 +121,21 @@
<td data-label="전일 대비" class="c-num mono delta up">▲ 2,000</td>
</tr>
<tr>
<th scope="row">샤인머스캣</th>
<th scope="row">
<button
class="board-select"
type="button"
data-market-row
data-name="샤인머스캣"
data-origin="경북 상주"
data-grade="특 · 18.2 Bx"
data-pack="2 kg"
data-price="26,500원"
data-delta="▼ 1,500원"
data-auction="02:00 경매"
aria-pressed="false"
>샤인머스캣</button>
</th>
<td data-label="산지">경북 상주</td>
<td data-label="등급" class="c-num mono">특</td>
<td data-label="당도" class="c-num mono">18.2<span class="unit">Bx</span></td>
@ -115,7 +144,21 @@
<td data-label="전일 대비" class="c-num mono delta down">▼ 1,500</td>
</tr>
<tr>
<th scope="row">캠벨얼리</th>
<th scope="row">
<button
class="board-select"
type="button"
data-market-row
data-name="캠벨얼리"
data-origin="충북 영동"
data-grade="상 · 15.4 Bx"
data-pack="5 kg"
data-price="31,000원"
data-delta="보합"
data-auction="02:00 경매"
aria-pressed="false"
>캠벨얼리</button>
</th>
<td data-label="산지">충북 영동</td>
<td data-label="등급" class="c-num mono">상</td>
<td data-label="당도" class="c-num mono">15.4<span class="unit">Bx</span></td>
@ -124,7 +167,21 @@
<td data-label="전일 대비" class="c-num mono delta flat">보합</td>
</tr>
<tr>
<th scope="row">홍로 사과</th>
<th scope="row">
<button
class="board-select"
type="button"
data-market-row
data-name="홍로 사과"
data-origin="경북 영주"
data-grade="특 · 14.1 Bx"
data-pack="5 kg"
data-price="44,000원"
data-delta="▲ 3,500원"
data-auction="08:30 경매"
aria-pressed="false"
>홍로 사과</button>
</th>
<td data-label="산지">경북 영주</td>
<td data-label="등급" class="c-num mono">특</td>
<td data-label="당도" class="c-num mono">14.1<span class="unit">Bx</span></td>
@ -133,7 +190,21 @@
<td data-label="전일 대비" class="c-num mono delta up">▲ 3,500</td>
</tr>
<tr>
<th scope="row">원황 배</th>
<th scope="row">
<button
class="board-select"
type="button"
data-market-row
data-name="원황 배"
data-origin="전남 나주"
data-grade="상 · 12.8 Bx"
data-pack="7.5 kg"
data-price="52,000원"
data-delta="▼ 2,000원"
data-auction="08:30 경매"
aria-pressed="false"
>원황 배</button>
</th>
<td data-label="산지">전남 나주</td>
<td data-label="등급" class="c-num mono">상</td>
<td data-label="당도" class="c-num mono">12.8<span class="unit">Bx</span></td>
@ -142,7 +213,21 @@
<td data-label="전일 대비" class="c-num mono delta down">▼ 2,000</td>
</tr>
<tr>
<th scope="row">무화과</th>
<th scope="row">
<button
class="board-select"
type="button"
data-market-row
data-name="무화과"
data-origin="전남 영암"
data-grade="특 · 당도 표기 대상 아님"
data-pack="1.2 kg"
data-price="24,000원"
data-delta="▲ 1,000원"
data-auction="02:00 경매"
aria-pressed="false"
>무화과</button>
</th>
<td data-label="산지">전남 영암</td>
<td data-label="등급" class="c-num mono">특</td>
<td data-label="당도" class="c-num mono blank" title="당도 표시 대상 품목이 아닙니다">—</td>
@ -153,6 +238,37 @@
</tbody>
</table>
<div class="lot-inspector" aria-live="polite" aria-atomic="true">
<div class="lot-inspector-head">
<p class="mono">선택 로트 읽기</p>
<p class="mono" id="lotAuction">02:00 경매</p>
</div>
<div class="lot-inspector-body">
<div class="lot-title">
<p id="lotName">백도 복숭아</p>
<p id="lotOrigin">경북 청도</p>
</div>
<dl class="lot-spec">
<div>
<dt>등급 · 당도</dt>
<dd id="lotGrade">특 · 12.5 Bx</dd>
</div>
<div>
<dt>규격</dt>
<dd id="lotPack">4.5 kg</dd>
</div>
<div>
<dt>낙찰가</dt>
<dd id="lotPrice">38,000원</dd>
</div>
<div>
<dt>전일 대비</dt>
<dd id="lotDelta">▲ 2,000원</dd>
</div>
</dl>
</div>
</div>
<ul class="board-notes">
<li>
사과와 배는 같은 날 <span class="mono">08:30</span> 경매입니다. 나머지 넷은
@ -276,6 +392,18 @@
판에 오릅니다.
</p>
<div class="season-control">
<div class="season-buttons" role="group" aria-label="제철 달 선택">
<button type="button" data-season-dir="-1" aria-label="이전 달">이전 달</button>
<button type="button" data-season-dir="1" aria-label="다음 달">다음 달</button>
</div>
<p class="season-status mono" aria-live="polite" aria-atomic="true">
<output id="seasonMonth">8월</output>
<span aria-hidden="true">·</span>
<span id="seasonCount">제철 품목을 읽는 중입니다</span>
</p>
</div>
<div class="season-scroll">
<table class="season">
<caption class="sr-only">품목별 제철 달력 — 가로축은 1월부터 12월</caption>
@ -507,9 +635,47 @@
<dd>품목당 상자 <span class="mono">3</span> 개부터</dd>
</div>
</dl>
<a class="btn" href="mailto:order@saebyeok-cheonggwa.example?subject=%EB%82%A9%ED%92%88%20%EB%AC%B8%EC%9D%98">
납품 조건 문의
</a>
<section class="order-draft" aria-labelledby="orderDraftHeading">
<div class="order-draft-head">
<h3 id="orderDraftHeading">발주 메모 만들기</h3>
<p class="mono">쇼케이스 예시</p>
</div>
<div class="order-controls">
<label>
<span>품목</span>
<select id="orderItem">
<option value="백도 복숭아" data-price="38000" data-pack="4.5 kg">백도 복숭아 · 특 · 4.5 kg</option>
<option value="샤인머스캣" data-price="26500" data-pack="2 kg">샤인머스캣 · 특 · 2 kg</option>
<option value="캠벨얼리" data-price="31000" data-pack="5 kg">캠벨얼리 · 상 · 5 kg</option>
<option value="홍로 사과" data-price="44000" data-pack="5 kg">홍로 사과 · 특 · 5 kg</option>
<option value="원황 배" data-price="52000" data-pack="7.5 kg">원황 배 · 상 · 7.5 kg</option>
<option value="무화과" data-price="24000" data-pack="1.2 kg">무화과 · 특 · 1.2 kg</option>
</select>
</label>
<label>
<span>상자 수</span>
<input id="orderQty" type="number" min="3" max="50" step="1" value="3" inputmode="numeric" />
</label>
</div>
<dl class="order-summary">
<div>
<dt>규격</dt>
<dd class="mono" id="orderPack">4.5 kg × 3상자</dd>
</div>
<div>
<dt>예시 낙찰가 합계</dt>
<dd class="mono" id="orderTotal">114,000원</dd>
</div>
</dl>
<p class="order-fine">
위 합계는 판에 적힌 예시 낙찰가만 더합니다. 선별·포장·배송과 실제 납품가는 포함하지 않습니다.
</p>
<div class="order-actions">
<button class="btn" id="copyOrder" type="button">발주 메모 복사</button>
</div>
<p class="order-status" id="orderStatus" role="status"></p>
</section>
</div>
</div>
</section>
@ -559,14 +725,168 @@
</footer>
<script>
/* 이번 달 칸에 표시를 남긴다. 달이 바뀌면 저절로 따라가야 하므로 이것만 스크립트다. */
(() => {
const m = new Date().getMonth() + 1;
document.querySelectorAll(`.season [data-m="${m}"]`).forEach((el) => {
el.classList.add("is-now");
const marketTime = new Date();
const dateParts = Object.fromEntries(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(marketTime)
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value]),
);
const currentMonth = Number(dateParts.month);
const stamp = document.querySelector(".hero-stamp time");
if (stamp) {
stamp.dateTime = `${dateParts.year}-${dateParts.month}-${dateParts.day}T02:00+09:00`;
stamp.textContent = `${dateParts.month}월 ${dateParts.day}일 02:00`;
}
/* 판의 한 행을 고르면 같은 값으로 로트 판독기가 바뀐다. */
const marketRows = [...document.querySelectorAll("[data-market-row]")];
const lotFields = {
auction: document.getElementById("lotAuction"),
name: document.getElementById("lotName"),
origin: document.getElementById("lotOrigin"),
grade: document.getElementById("lotGrade"),
pack: document.getElementById("lotPack"),
price: document.getElementById("lotPrice"),
delta: document.getElementById("lotDelta"),
};
function showLot(button) {
for (const row of marketRows) row.setAttribute("aria-pressed", String(row === button));
for (const [key, field] of Object.entries(lotFields)) {
if (field) field.textContent = button.dataset[key] || "";
}
if (orderItem) orderItem.value = button.dataset.name || orderItem.value;
updateOrder();
}
for (const row of marketRows) row.addEventListener("click", () => showLot(row));
/* 제철 띠는 이번 달에서 시작하지만 앞뒤 달도 직접 읽을 수 있다. */
let selectedMonth = currentMonth;
const season = document.querySelector(".season");
const seasonScroll = document.querySelector(".season-scroll");
const seasonMonth = document.getElementById("seasonMonth");
const seasonCount = document.getElementById("seasonCount");
function showMonth(month, moveViewport) {
selectedMonth = month;
season?.querySelectorAll(".is-focus").forEach((cell) => cell.classList.remove("is-focus"));
season?.querySelectorAll("[aria-current='date']").forEach((cell) => cell.removeAttribute("aria-current"));
season?.querySelectorAll(`[data-m="${month}"]`).forEach((cell) => cell.classList.add("is-focus"));
const head = season?.querySelector(`thead [data-m="${month}"]`);
if (month === currentMonth) head?.setAttribute("aria-current", "date");
const items = [...(season?.querySelectorAll("tbody tr") || [])]
.filter((row) => row.querySelector(`td.on[data-m="${month}"]`))
.map((row) => row.querySelector("th")?.textContent.trim())
.filter(Boolean);
if (seasonMonth) seasonMonth.textContent = `${month}월`;
if (seasonCount) seasonCount.textContent = `${items.length}품목 · ${items.join(" · ")}`;
if (moveViewport && head && seasonScroll) {
const left = Math.max(0, head.offsetLeft - seasonScroll.clientWidth / 2 + head.clientWidth / 2);
seasonScroll.scrollTo({ left, behavior: "smooth" });
}
}
document.querySelectorAll("[data-season-dir]").forEach((button) => {
button.addEventListener("click", () => {
const next = ((selectedMonth - 1 + Number(button.dataset.seasonDir) + 12) % 12) + 1;
showMonth(next, true);
});
});
const head = document.querySelector(`.season thead [data-m="${m}"]`);
if (head) head.setAttribute("aria-current", "date");
showMonth(currentMonth, false);
/* 낙찰가 예시로 발주 메모를 만든다. 납품가처럼 보이지 않게 항목을 분리한다. */
const orderItem = document.getElementById("orderItem");
const orderQty = document.getElementById("orderQty");
const orderPack = document.getElementById("orderPack");
const orderTotal = document.getElementById("orderTotal");
const orderStatus = document.getElementById("orderStatus");
function orderValues() {
const option = orderItem?.selectedOptions[0];
const quantity = Math.min(50, Math.max(3, Number(orderQty?.value) || 3));
const price = Number(option?.dataset.price) || 0;
return {
name: option?.value || "",
pack: option?.dataset.pack || "",
quantity,
total: price * quantity,
};
}
function updateOrder() {
const value = orderValues();
if (orderPack) orderPack.textContent = `${value.pack} × ${value.quantity}상자`;
if (orderTotal) orderTotal.textContent = `${value.total.toLocaleString("ko-KR")}원`;
if (orderStatus) orderStatus.textContent = "";
}
orderItem?.addEventListener("change", updateOrder);
orderQty?.addEventListener("input", updateOrder);
orderQty?.addEventListener("change", () => {
if (orderQty) orderQty.value = String(orderValues().quantity);
updateOrder();
});
async function copyText(text) {
if (navigator.clipboard?.writeText) {
try {
await Promise.race([
navigator.clipboard.writeText(text),
new Promise((_, reject) => window.setTimeout(() => reject(new Error("clipboard timeout")), 1200)),
]);
return true;
} catch {
// 권한 대화상자가 없거나 응답하지 않는 브라우저는 아래 선택 복사로 전환합니다.
}
}
const field = document.createElement("textarea");
field.value = text;
field.setAttribute("readonly", "");
field.style.position = "fixed";
field.style.inset = "0 auto auto -9999px";
document.body.append(field);
field.select();
const copied = document.execCommand("copy");
field.remove();
return copied;
}
document.getElementById("copyOrder")?.addEventListener("click", async () => {
const value = orderValues();
const memo = [
"새벽청과 납품 문의",
`품목: ${value.name}`,
`규격: ${value.pack}`,
`수량: ${value.quantity}상자`,
`예시 낙찰가 합계: ${value.total.toLocaleString("ko-KR")}원`,
"실제 납품가는 선별·포장·배송 조건을 확인한 뒤 정합니다.",
].join("\n");
try {
const copied = await copyText(memo);
if (orderStatus) {
orderStatus.textContent = copied
? "발주 메모를 복사했습니다."
: "복사하지 못했습니다. 브라우저의 클립보드 권한을 확인해 주세요.";
}
} catch {
if (orderStatus) orderStatus.textContent = "복사하지 못했습니다. 브라우저의 클립보드 권한을 확인해 주세요.";
}
});
updateOrder();
})();
</script>
</body>