Merge branch 'feature/variety-item' into main
This commit is contained in:
commit
1bcee68432
@ -61,6 +61,19 @@ Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx` 의 `Req
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
|
||||
|
||||
**왜**
|
||||
에디터의 소개 섹션 본문은 `sites.theme` 에 저장됐지만 발행 payload 경계에서 버려졌고,
|
||||
프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다.
|
||||
|
||||
**한 일**
|
||||
- `SectionSetting.body` 계약을 추가하고 저장값을 payload 까지 전달
|
||||
- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수
|
||||
- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리
|
||||
|
||||
**검증** — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가.
|
||||
|
||||
## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개)
|
||||
|
||||
**왜**
|
||||
|
||||
@ -201,6 +201,12 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
||||
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
|
||||
|
||||
count = 0
|
||||
intro = next(
|
||||
(s for s in (payload.get("theme") or {}).get("sections") or [] if s.get("id") == "intro"),
|
||||
None,
|
||||
)
|
||||
if intro and intro.get("enabled") and long(intro.get("body")):
|
||||
count += 1
|
||||
facts = list(payload.get("facts") or [])
|
||||
for unit in payload.get("units") or []:
|
||||
facts.extend(unit.get("facts") or [])
|
||||
|
||||
@ -88,8 +88,8 @@ class Req_SiteTheme(SiteProtocol):
|
||||
|
||||
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
|
||||
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
|
||||
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다.
|
||||
sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||
(별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다.
|
||||
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
|
||||
서버는 배달부지 심판이 아니다.
|
||||
|
||||
|
||||
@ -106,7 +106,7 @@ async def set_template(
|
||||
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
|
||||
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
|
||||
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
|
||||
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\"}]}}. "
|
||||
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. "
|
||||
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
|
||||
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
|
||||
"직렬화 크기(64KB)만 막는다. "
|
||||
|
||||
@ -200,7 +200,10 @@ async def run_build(job: dict) -> dict:
|
||||
)
|
||||
|
||||
mismatches = list(report.get("mismatches") or [])
|
||||
unique_count = report.get("uniqueContentCount") or 0
|
||||
# ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고,
|
||||
# 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다.
|
||||
unique_count_raw = report.get("uniqueContentCount")
|
||||
unique_count = unique_count_raw or 0
|
||||
jsonld = report.get("jsonld") or []
|
||||
result["unique_content_count"] = unique_count
|
||||
result["mismatches"] = mismatches[:20]
|
||||
@ -211,14 +214,25 @@ async def run_build(job: dict) -> dict:
|
||||
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
|
||||
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
|
||||
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
|
||||
gate = publish_gate.evaluate(PlaceCategory(place.category), snapshot["facts"], unique_count, mismatches)
|
||||
gate = publish_gate.evaluate(
|
||||
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches
|
||||
)
|
||||
result["gate"] = {"passed": gate.passed, **gate.as_log()}
|
||||
|
||||
if not gate.passed:
|
||||
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
|
||||
|
||||
if not report.get("ok"):
|
||||
# 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있는 쪽이다.
|
||||
# ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지
|
||||
# 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패"
|
||||
# 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다.
|
||||
thin = publish_gate.check_unique_content(unique_count_raw)
|
||||
if not thin.passed:
|
||||
# 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로
|
||||
# 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다.
|
||||
result["gate"] = {"passed": False, **thin.as_log()}
|
||||
return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp)
|
||||
# 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있다.
|
||||
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
|
||||
|
||||
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.
|
||||
|
||||
@ -63,11 +63,20 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult:
|
||||
return GateResult(True)
|
||||
|
||||
|
||||
def check_unique_content(unique_content_count: int) -> GateResult:
|
||||
"""고유 콘텐츠 품질 진단용. 발행 게이트에서는 더 이상 호출하지 않는다.
|
||||
def check_unique_content(unique_content_count: int | None) -> GateResult:
|
||||
"""렌더러가 '고유 콘텐츠 0건' 으로 거부했을 때 사유 코드를 붙이는 자리.
|
||||
|
||||
evaluate() 는 이걸 부르지 않는다(얇은 콘텐츠로 발행을 막지 않기로 했다).
|
||||
부르는 곳은 build_service — 렌더 보고서가 실패로 왔을 때 그 이유를 되짚는다.
|
||||
|
||||
이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면
|
||||
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다."""
|
||||
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.
|
||||
|
||||
★ None 은 '0건' 이 아니라 '재지 못했다' 다. 렌더러가 디스크·번들 문제로 죽으면 계수가
|
||||
없는 채로 보고서가 온다 — 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는
|
||||
엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 가 말한다."""
|
||||
if unique_content_count is None:
|
||||
return GateResult(True)
|
||||
if unique_content_count <= 0:
|
||||
return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count})
|
||||
return GateResult(True)
|
||||
@ -83,11 +92,17 @@ def check_jsonld_matches(mismatches: list) -> GateResult:
|
||||
return GateResult(True)
|
||||
|
||||
|
||||
def evaluate(category: PlaceCategory, facts: list, unique_content_count: int, mismatches: list) -> GateResult:
|
||||
def evaluate(
|
||||
category: PlaceCategory, facts: list, unique_content_count: int | None, mismatches: list
|
||||
) -> GateResult:
|
||||
"""게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다.
|
||||
|
||||
순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓).
|
||||
|
||||
★ unique_content_count 는 받되 여기서 막지 않는다 — 얇은 콘텐츠는 '틀린 것' 이 아니다
|
||||
(test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은
|
||||
렌더러이고, 그 사유는 build_service 가 check_unique_content 로 되짚어 라벨을 붙인다.
|
||||
|
||||
★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정).
|
||||
막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다.
|
||||
영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고,
|
||||
|
||||
@ -363,6 +363,15 @@ def _sections(saved_sections, default_spec) -> list:
|
||||
variant_id = _text(item.get("variantId"))
|
||||
if variant_id:
|
||||
entry["variantId"] = variant_id
|
||||
# ★ 사장님이 에디터에 직접 쓴 섹션 본문. variantId 와 같은 이유로 그대로 싣는다 —
|
||||
# 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 통째로 버려졌다.
|
||||
# 저장(sites.theme)은 되는데 발행본에는 안 나오고, 고유 콘텐츠로도 세지 않아
|
||||
# "소개를 썼는데 발행이 고유 콘텐츠 0건으로 막힌다" 가 됐다.
|
||||
# ★ fact 가 아니라 검증 대상이 아니다. 사장님이 자기 가게에 대해 쓴 자기 문장이고,
|
||||
# 섹션 제목(name)이 이미 같은 경로로 나간다.
|
||||
body = _text(item.get("body"))
|
||||
if body:
|
||||
entry["body"] = body
|
||||
out.append(entry)
|
||||
|
||||
for sid, label, locked in default_spec:
|
||||
|
||||
@ -136,6 +136,29 @@ async def test_no_unique_content_blocks_publish(auth_headers, client):
|
||||
assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name
|
||||
|
||||
|
||||
async def test_owner_written_intro_counts_as_unique_content(auth_headers, client):
|
||||
"""검증: fact·FAQ·사진 설명은 없지만 사장님이 소개 섹션 본문을 직접 썼다.
|
||||
기대결과: 실제 발행본에 표시되는 소개문 1건으로 계수되어 발행된다."""
|
||||
h = await auth_headers("owner-intro")
|
||||
pid = await _place(client, h, "직접소개펜션")
|
||||
for k, v in {**REQUIRED, "cancel_policy": "X"}.items():
|
||||
await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v})
|
||||
await client.post(f"/v1/place/{pid}/site/theme", headers=h, json={"theme": {"sections": [{
|
||||
"id": "intro", "name": "소개", "enabled": True, "locked": False,
|
||||
"body": "하조대의 아침 바다를 조용히 바라볼 수 있는 작은 숙소입니다.",
|
||||
}]}})
|
||||
|
||||
job_id = (await client.post(
|
||||
f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}
|
||||
)).json()["job_id"]
|
||||
await _run()
|
||||
|
||||
r = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]["result"]
|
||||
assert r["gate"]["passed"] is True
|
||||
assert r["unique_content_count"] == 1
|
||||
assert r["published"] is True
|
||||
|
||||
|
||||
async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine):
|
||||
"""검증: 취소 규정 없이 빌드한다.
|
||||
|
||||
|
||||
@ -79,6 +79,12 @@ def test_zero_unique_content_blocks_publish():
|
||||
assert check_unique_content(1).passed is True
|
||||
|
||||
|
||||
def test_unmeasured_unique_content_is_not_zero():
|
||||
"""검증: 렌더러가 계수를 못 재고(None) 실패했다.
|
||||
기대결과: 통과 — 디스크·번들 오류에 NO_UNIQUE_CONTENT 라는 엉뚱한 사유가 붙으면 안 된다."""
|
||||
assert check_unique_content(None).passed is True
|
||||
|
||||
|
||||
def test_jsonld_mismatch_blocks_publish():
|
||||
"""검증: 구조화 데이터 값이 화면 값과 다르다.
|
||||
기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다."""
|
||||
|
||||
@ -222,6 +222,18 @@ def test_owner_disabled_section_stays_disabled():
|
||||
assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False
|
||||
|
||||
|
||||
def test_owner_written_section_body_reaches_publish_payload():
|
||||
"""검증: 에디터에서 직접 쓴 소개 본문.
|
||||
기대결과: 발행 payload 에 그대로 남는다 — 저장만 되고 경계에서 버려지면 발행본과 계수에 못 쓴다."""
|
||||
theme = _payload_theme({"sections": [{
|
||||
"id": "intro", "name": "소개", "enabled": True, "locked": False,
|
||||
"body": "바다를 보며 조용히 쉬어가는 작은 숙소입니다.",
|
||||
}]})
|
||||
assert next(s for s in theme["sections"] if s["id"] == "intro")["body"] == (
|
||||
"바다를 보며 조용히 쉬어가는 작은 숙소입니다."
|
||||
)
|
||||
|
||||
|
||||
def test_partial_colors_are_filled_from_category_default():
|
||||
"""검증: 저장된 색이 일부 키만 담고 있을 때.
|
||||
기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로
|
||||
|
||||
@ -103,7 +103,7 @@ function templatesFor(
|
||||
fontStyle: '옛 간판체',
|
||||
look: LOOK.retro,
|
||||
// 이 템플릿이 팔려는 게 바로 이 아이템들이다.
|
||||
defaultSectionTypes: ['songs', 'daily', 'course'],
|
||||
defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -55,6 +55,26 @@ export interface CourseItem {
|
||||
source?: DataSource;
|
||||
}
|
||||
|
||||
export interface ScheduleSlot {
|
||||
/** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
|
||||
time: string;
|
||||
title: string;
|
||||
place?: string;
|
||||
minutes?: number;
|
||||
note?: string;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleItem {
|
||||
name: string;
|
||||
/** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
|
||||
audience?: string;
|
||||
season?: string;
|
||||
slots?: ScheduleSlot[];
|
||||
verified?: DataVerified;
|
||||
source?: DataSource;
|
||||
}
|
||||
|
||||
export interface SectionDataSpec {
|
||||
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
|
||||
kind: string;
|
||||
@ -302,6 +322,56 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
· minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다.
|
||||
· 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`,
|
||||
},
|
||||
|
||||
schedule: {
|
||||
kind: 'schedule',
|
||||
label: '여행 스케줄',
|
||||
requiredKey: 'name',
|
||||
sample: JSON.stringify(
|
||||
{
|
||||
kind: 'schedule',
|
||||
version: 1,
|
||||
title: '여행 스케줄',
|
||||
items: [
|
||||
{
|
||||
name: '비 오는 날의 하루',
|
||||
audience: '혼자 온 손님',
|
||||
season: '장마',
|
||||
slots: [
|
||||
{time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'},
|
||||
{time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'},
|
||||
{time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
|
||||
{time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'},
|
||||
{time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'},
|
||||
],
|
||||
verified: '확인필요',
|
||||
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'},
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
task: `[해야 할 일]
|
||||
[업소]에 묵거나 들른 손님이 [지역]에서 하루를 어떻게 보내면 좋을지 여행 스케줄을 2~3개 만든다.
|
||||
스케줄마다 시간대 5~7개. 아침부터 저녁까지 시각 순서로 배열한다.
|
||||
스케줄은 서로 성격이 달라야 한다 — 날씨(비 오는 날)·동행(아이와 함께)·계절 중 하나로 가른다.
|
||||
|
||||
[스키마]
|
||||
{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[
|
||||
{ "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨",
|
||||
"slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소",
|
||||
"minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ],
|
||||
"verified":"확인|확인필요",
|
||||
"source":{"name":"출처명","url":"https://..."} } ] }`,
|
||||
rules: `
|
||||
[이 아이템만의 규칙]
|
||||
· time 은 24시간 "HH:MM" 로만 적는다. "오전 9시" 처럼 쓰지 않는다.
|
||||
· 그 장소의 영업시간·휴무일을 안다고 가정하지 않는다. 바뀌면 손님이 헛걸음한다.
|
||||
· 첫 칸은 [업소]에서 시작하고, 이동은 걸어서 또는 대중교통으로 갈 수 있는 범위로 짠다.
|
||||
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
|
||||
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`,
|
||||
},
|
||||
};
|
||||
|
||||
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {
|
||||
|
||||
@ -67,6 +67,7 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
|
||||
import {SongsTurntable} from './variants/songs/SongsTurntable';
|
||||
import {DailyCalendar} from './variants/daily/DailyCalendar';
|
||||
import {CourseTickets} from './variants/course/CourseTickets';
|
||||
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
|
||||
|
||||
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
|
||||
hero: [
|
||||
@ -435,6 +436,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
|
||||
},
|
||||
],
|
||||
|
||||
schedule: [
|
||||
{
|
||||
id: 'schedule.timetable',
|
||||
name: '대합실 시간표',
|
||||
description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.',
|
||||
thumb: 'carousel',
|
||||
Component: ScheduleTimetable,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
|
||||
exhibition: [
|
||||
{
|
||||
id: 'exhibition.gallery',
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 여행 스케줄 — 대합실 시간표 캐러셀.
|
||||
*
|
||||
* 칸 하나가 시간대 하나다. 위쪽 검은 판은 역 대합실의 플립보드(가운데 접힘선)를 그대로 옮긴 것 —
|
||||
* 시각이 먼저 읽히고 무엇을 하는지가 뒤따라야 시간표로 읽힌다.
|
||||
* ★ 승차권(course)과 일부러 다르게 짰다. 저쪽은 '어디를 도는가'(순번), 여기는 '언제 무엇을'(시각)이다.
|
||||
* ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약.
|
||||
*/
|
||||
import {SectionBody, SectionFrame} from '../../primitives';
|
||||
import type {SectionRenderProps} from '../../types';
|
||||
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec';
|
||||
import {
|
||||
CarouselNav,
|
||||
ParseError,
|
||||
PasteHint,
|
||||
RETRO_BODY,
|
||||
RETRO_INK,
|
||||
RETRO_LINE,
|
||||
RETRO_PAPER_LIGHT,
|
||||
RETRO_RED,
|
||||
RETRO_SIGN,
|
||||
SourceLine,
|
||||
useCarousel,
|
||||
} from '../retro/common';
|
||||
import '../retro/retro.css';
|
||||
|
||||
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
|
||||
function splitTime(time: string): {head: string; tail?: string} {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|
||||
if (!match) return {head: time.trim()};
|
||||
return {head: match[1].padStart(2, '0'), tail: match[2]};
|
||||
}
|
||||
|
||||
function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
|
||||
const {head, tail} = splitTime(slot.time ?? '');
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 snap-center items-stretch">
|
||||
<div
|
||||
className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
|
||||
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_INK}}
|
||||
>
|
||||
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
|
||||
<div className="relative px-4 py-3 text-center" style={{backgroundColor: RETRO_INK}}>
|
||||
<span
|
||||
className="inline-flex items-baseline gap-1 leading-none text-[#f2ebd9]"
|
||||
style={{fontFamily: RETRO_SIGN, fontSize: 30}}
|
||||
>
|
||||
{head}
|
||||
{tail && <span className="text-[#c0b493]">:{tail}</span>}
|
||||
</span>
|
||||
<i
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
|
||||
style={{borderColor: 'rgba(242,235,217,.22)'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 px-4 pb-3 pt-3.5">
|
||||
<h4 className="text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}>
|
||||
{slot.title}
|
||||
</h4>
|
||||
{slot.place && (
|
||||
<p className="text-[12px] font-semibold" style={{fontFamily: RETRO_BODY, color: RETRO_RED}}>
|
||||
{slot.place}
|
||||
</p>
|
||||
)}
|
||||
{slot.note && (
|
||||
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}>
|
||||
{slot.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] text-stone-500"
|
||||
style={{borderColor: RETRO_LINE}}
|
||||
>
|
||||
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
|
||||
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
|
||||
{!isLast && (
|
||||
<div aria-hidden className="flex w-8 items-center justify-center">
|
||||
<i className="block h-px w-full border-t border-dashed" style={{borderColor: RETRO_LINE}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
|
||||
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
|
||||
const slots = schedule.slots ?? [];
|
||||
const span =
|
||||
slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="flex flex-wrap items-baseline gap-2.5">
|
||||
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}>
|
||||
{schedule.name}
|
||||
</h3>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
{[schedule.audience, schedule.season, span, `${slots.length}칸`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
{slots.length > 2 && (
|
||||
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={schedule.name} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{slots.length === 0 ? (
|
||||
<p
|
||||
className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500"
|
||||
style={{borderColor: RETRO_LINE}}
|
||||
>
|
||||
시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
|
||||
{slots.map((slot, index) => (
|
||||
<Slot
|
||||
key={`${slot.time}-${slot.title}-${index}`}
|
||||
slot={slot}
|
||||
isLast={index === slots.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SourceLine source={schedule.source} verified={schedule.verified} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleTimetable(props: SectionRenderProps) {
|
||||
const {section, isSelected, onSelect} = props;
|
||||
const parsed = parseSectionData<ScheduleItem>(section.type, section.data);
|
||||
|
||||
return (
|
||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
|
||||
<SectionBody width="wide">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
|
||||
{parsed.title || section.name}
|
||||
</h2>
|
||||
{(parsed.subtitle || section.description) && (
|
||||
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}>
|
||||
{parsed.subtitle || section.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsed.error ? (
|
||||
<ParseError message={parsed.error} />
|
||||
) : parsed.items.length === 0 ? (
|
||||
<PasteHint label="여행 스케줄" />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{parsed.items.map((schedule, index) => (
|
||||
<ScheduleRow key={`${schedule.name}-${index}`} schedule={schedule} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionBody>
|
||||
</SectionFrame>
|
||||
);
|
||||
}
|
||||
@ -249,4 +249,13 @@ export interface SectionSetting {
|
||||
* 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다.
|
||||
*/
|
||||
variantId?: string;
|
||||
/**
|
||||
* 사장님이 에디터에서 직접 쓴 섹션 본문.
|
||||
*
|
||||
* ★ variantId 와 같은 사연이다 — 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서
|
||||
* 버려졌다. 저장(sites.theme)은 되는데 발행본에 안 나왔고, 고유 콘텐츠로도 세지 않아
|
||||
* "소개를 썼는데 고유 콘텐츠 0건으로 발행이 막힌다" 가 됐다.
|
||||
* ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다.
|
||||
*/
|
||||
body?: string;
|
||||
}
|
||||
|
||||
@ -192,6 +192,24 @@ class VerifyError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고유 콘텐츠 0건. **VerifyError 와 갈라 둔다.**
|
||||
*
|
||||
* ★ 예전에는 이 사유를 VerifyError 의 mismatches 에 실어 던졌다. 백엔드 게이트는
|
||||
* mismatches 가 비지 않았다는 것만 보고 JSONLD_MISMATCH 로 판정했고, 사장님 화면에는
|
||||
* "구조화 데이터와 화면 값이 다릅니다" 라는 **틀린 문구**가 떴다 — 구조화 데이터는
|
||||
* 멀쩡했다. 사유가 다르면 예외도 갈라야 라벨이 안 섞인다.
|
||||
*/
|
||||
class NoUniqueContentError extends Error {
|
||||
/** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */
|
||||
readonly mismatches: string[] = [];
|
||||
|
||||
constructor(readonly uniqueContentCount: number) {
|
||||
super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다');
|
||||
this.name = 'NoUniqueContentError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로
|
||||
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
|
||||
@ -321,10 +339,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
|
||||
* 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다.
|
||||
*/
|
||||
if (uniqueContentCount <= 0) {
|
||||
throw new VerifyError(
|
||||
['고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다'],
|
||||
uniqueContentCount,
|
||||
);
|
||||
throw new NoUniqueContentError(uniqueContentCount);
|
||||
}
|
||||
|
||||
writeFile(siteDir, 'index.html', html);
|
||||
@ -377,6 +392,11 @@ function countUniqueContent(payload: SitePayload): number {
|
||||
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
|
||||
let count = 0;
|
||||
|
||||
// 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다.
|
||||
// ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다.
|
||||
const intro = payload.theme.sections.find((section) => section.id === 'intro');
|
||||
if (intro?.enabled && long(intro.body)) count += 1;
|
||||
|
||||
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
|
||||
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
|
||||
if (fact.type === 'text' && long(fact.value)) count += 1;
|
||||
@ -585,11 +605,14 @@ function main() {
|
||||
} catch (ex) {
|
||||
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
|
||||
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
|
||||
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
|
||||
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
|
||||
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
|
||||
fail(
|
||||
entry,
|
||||
ex instanceof Error ? ex.message : String(ex),
|
||||
ex instanceof VerifyError ? ex.mismatches : [],
|
||||
ex instanceof VerifyError ? ex.uniqueContentCount : null,
|
||||
counted?.mismatches ?? [],
|
||||
counted?.uniqueContentCount ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,6 +121,12 @@ export function isSectionEnabled(payload: SitePayload, id: string): boolean {
|
||||
return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false;
|
||||
}
|
||||
|
||||
/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */
|
||||
export function sectionBody(payload: SitePayload, id: string): string[] {
|
||||
const body = payload.theme.sections.find((section) => section.id === id)?.body;
|
||||
return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
export function unitSpec(payload: SitePayload) {
|
||||
return UNIT_SPEC[payload.place.category];
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {galleryImages} from '@/lib/derive';
|
||||
import {galleryImages, sectionBody} from '@/lib/derive';
|
||||
|
||||
export function AboutSection() {
|
||||
const payload = useSite();
|
||||
const {narrative, place} = payload;
|
||||
if (narrative.about.length === 0) return null;
|
||||
// 직접 입력한 소개가 있으면 그 문장이 이긴다. 없을 때만 수집·승인된 intro fact 로 떨어진다.
|
||||
// ★ 에디터 본문을 계수만 하고 화면에 안 내면 빈 페이지가 발행 게이트를 통과한다.
|
||||
const paragraphs = sectionBody(payload, 'intro');
|
||||
const about = paragraphs.length > 0 ? paragraphs : narrative.about;
|
||||
if (about.length === 0) return null;
|
||||
|
||||
// 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게.
|
||||
const image = galleryImages(payload).find((m) => !m.isPrimary);
|
||||
@ -46,7 +50,7 @@ export function AboutSection() {
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3 text-sm leading-relaxed opacity-80">
|
||||
{narrative.about.map((paragraph, index) => (
|
||||
{about.map((paragraph, index) => (
|
||||
<p key={index}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user