+ 여기까지 만든 내용은 그대로 있습니다. 편집한 것을 저장하고 발행하는 데 계정이 필요합니다. +
+이 레이아웃에는 직접 입력하는 문구가 없습니다. 수집된 데이터만 표시합니다.
@@ -160,6 +171,159 @@ function ContentTab() { ); } +/** + * 붙여넣기 아이템의 JSON 입력. + * + * ★ 원문을 그대로 저장한다. 깨진 JSON 도 담아 두고, 왜 깨졌는지만 아래에 말한다 — + * 저장을 막으면 사장님은 고칠 기회 없이 쓰던 걸 잃는다. + */ +function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionType: string}) { + const spec = dataSpecFor(sectionType); + const sections = useBuilderStore((s) => s.sections); + const updateSectionData = useBuilderStore((s) => s.updateSectionData); + const storeName = useBuilderStore((s) => s.storeName); + const location = useBuilderStore((s) => s.location); + const industry = useBuilderStore((s) => s.industry); + const [copied, setCopied] = useState(false); + const [showPrompt, setShowPrompt] = useState(false); + + const section = sections.find((item) => item.id === sectionId); + if (!spec || !section) return null; + + const raw = section.data ?? ''; + const parsed = parseSectionData(sectionType, raw); + const tooLong = raw.length > SECTION_DATA_MAX_CHARS; + + // 상호·주소가 이미 박혀 있는 프롬프트. 사장님이 빈칸을 채울 일이 없어야 한다. + const promptText = buildPrompt(spec, { + storeName, + location, + industryLabel: INDUSTRY_CONFIGS[industry].name, + }); + + const copyPrompt = async () => { + const text = promptText; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch { + // 클립보드가 막힌 브라우저 — 프롬프트를 화면에 띄워 직접 긁을 수 있게 한다. + window.prompt('아래 프롬프트를 복사해 ChatGPT 에 붙여넣으세요', text); + } + }; + + /** 유효한 JSON 만 2칸 들여쓰기로 다시 쓴다. 깨져 있으면 손대지 않는다. */ + const tidy = () => { + try { + updateSectionData(sectionId, JSON.stringify(JSON.parse(raw), null, 2)); + } catch { + // 그대로 둔다 — 아래 오류 줄이 이미 어디가 깨졌는지 말하고 있다. + } + }; + + return ( ++ 프롬프트를 복사해 ChatGPT·Claude 에 그대로 붙여넣으면 JSON 을 줍니다. 받은 JSON 을 아래에 붙여넣으세요. +
+ ++ 아래 내용이 그대로 복사됩니다. 상호와 지역은 이미 채워져 있습니다. +
++ 내용은 [콘텐츠] 탭에서 프롬프트를 복사해 받은 JSON 을 붙여넣어 채웁니다. +
+* 자물쇠 항목은 검색·AI 노출에 필요한 마크업이 달려 있어 고정입니다.
diff --git a/solution/frontend/src/features/builder/canvas/addable.ts b/solution/frontend/src/features/builder/canvas/addable.ts new file mode 100644 index 0000000..35c7814 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/addable.ts @@ -0,0 +1,51 @@ +/** + * 나중에 넣을 수 있는 섹션 — [+ 섹션 추가] 가 고르는 목록. + * + * 업종 시드(industryData)는 "처음부터 있는 것"이고 여기는 "필요하면 넣는 것"이다. + * 붙여넣기 아이템은 내용이 없으면 빈 섹션이라, 시드에 박아 두면 아무도 안 쓰는 칸이 늘 붙어 있다. + */ +import type {SectionItem} from '@o2o/shared'; +import {SECTION_DATA_SPEC} from './dataSpec'; +import {defaultVariant} from './registry'; +import type {ThumbKey} from './types'; + +export interface AddableSection { + type: string; + name: string; + description: string; + thumb: ThumbKey; +} + +/** + * 추가할 수 있는 섹션 목록. + * + * ★ dataSpec 이 단일 출처다 — 아이템을 하나 더 만들면 여기에도 자동으로 나타난다. + * 목록을 따로 들면 아이템을 추가하고 이 표를 잊어 "만들었는데 고를 수가 없는" 상태가 된다. + */ +export const ADDABLE_SECTIONS: AddableSection[] = Object.values(SECTION_DATA_SPEC).map((spec) => { + const variant = defaultVariant(spec.kind); + return { + type: spec.kind, + name: spec.label, + description: variant?.description ?? '', + thumb: variant?.thumb ?? 'cards', + }; +}); + +export function addableSection(type: string): AddableSection | undefined { + return ADDABLE_SECTIONS.find((item) => item.type === type); +} + +/** 목록에 새로 넣을 섹션 한 줄. id 는 타입과 같다 — 같은 아이템을 두 번 넣지 않는다. */ +export function newSectionOf(type: string): SectionItem | undefined { + const addable = addableSection(type); + if (!addable) return undefined; + return { + id: type, + type, + name: addable.name, + description: addable.description, + isLocked: false, + isEnabled: true, + }; +} diff --git a/solution/frontend/src/features/builder/canvas/dataSpec.ts b/solution/frontend/src/features/builder/canvas/dataSpec.ts new file mode 100644 index 0000000..5a5b0e0 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/dataSpec.ts @@ -0,0 +1,419 @@ +/** + * 붙여넣기 아이템 계약 — "이 섹션 타입은 어떤 JSON 을 먹나"의 단일 출처. + * + * 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. + * 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다. + */ + +/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */ +export type DataVerified = '확인' | '확인필요'; + +export interface DataSource { + name: string; + url?: string; +} + +export interface SongItem { + title: string; + artist?: string; + lyricist?: string; + composer?: string; + year?: number; + label?: string; + labelColor?: string; + story?: string; + connection?: string; + verified?: DataVerified; + source?: DataSource; +} + +export interface DailyItem { + monthDay: string; + category?: string; + title: string; + body?: string; + season?: string; + tags?: string[]; + verified?: DataVerified; + source?: DataSource; +} + +export interface CourseStop { + order?: number; + name: string; + minutes?: number; + note?: string; + searchQuery?: string; +} + +export interface CourseItem { + name: string; + duration?: string; + startsFrom?: string; + stops?: CourseStop[]; + verified?: DataVerified; + source?: DataSource; +} + +export interface SectionDataSpec { + /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ + kind: string; + label: string; + /** 없으면 그 줄을 통째로 버리는 키. 빈 껍데기가 화면에 줄만 남기는 걸 막는다. */ + requiredKey: string; + /** [예시 넣기] 가 그대로 넣는 값. */ + sample: string; + /** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */ + task: string; + /** 그 아이템에만 걸리는 금지·형식 규칙. */ + rules: string; +} + +// ★ 테마 전체 상한이 64KB 다(site_service._THEME_MAX_BYTES). 세 아이템이 각자 다 채우면 거절당하고, +// 거절은 발행 직전에야 드러난다. 그래서 한 섹션당 여기서 먼저 끊는다. +export const SECTION_DATA_MAX_CHARS = 12000; + +export interface PromptContext { + storeName: string; + /** 사업장 주소 원문. 여기서 시·군·구를 뽑아 '지역'으로 쓴다. */ + location: string; + /** 업종 이름('숙박' · '카페' …). 어떤 손님에게 쓰는 글인지 알려 준다. */ + industryLabel: string; +} + +/** + * 주소 원문에서 시·군·구 한 조각. + * + * ★ 주소를 통째로 넣으면 모델이 그 동네 안쪽만 뒤진다. 이야깃거리는 시 단위에 있다. + */ +export function regionOf(location: string): string { + const token = location + .trim() + .split(/\s+/) + .find((word) => /[시군구]$/.test(word) && word.length >= 2); + return token ?? location.trim(); +} + +const PROMPT_RULES = ` +[공통 규칙] +1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다. +2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다. +3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다. +4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다. 애매한 걸 "확인" 으로 올리지 않는다. +5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다. +6. 설명 문장은 항목당 두 문장을 넘기지 않는다. +7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다. +`; + +/** + * 붙여넣으면 바로 답이 나오는 프롬프트. + * + * ★ `[지역]` 같은 빈칸을 남기지 않는다. 빈칸이 있으면 사장님이 못 채우고 그대로 보내고, + * 모델은 빈칸을 지명으로 착각해 엉뚱한 곳 이야기를 지어낸다. + */ +export function buildPrompt(spec: SectionDataSpec, ctx: PromptContext): string { + const store = ctx.storeName.trim() || '(가게 이름을 먼저 입력해 주세요)'; + const region = regionOf(ctx.location) || '(주소를 먼저 입력해 주세요)'; + const address = ctx.location.trim(); + + const head = `너는 지역 콘텐츠 리서처다. 아래 조건에 맞는 JSON 하나만 출력한다. + +[업소] ${store} (${ctx.industryLabel}) +[지역] ${region}${address && address !== region ? `\n[주소] ${address}` : ''} + +아래 '해야 할 일'에서 [지역] = ${region}, [업소] = ${store}. +`; + + return `${head} +${spec.task} +${PROMPT_RULES}${spec.rules}`; +} + +export const SECTION_DATA_SPEC: Record+ {no} +
++ {stop.note} +
+ )} + ++ 정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요. +
+ ) : ( ++ {parsed.subtitle || section.description} +
+ )} +{month}月
++ {String(Number(day) || day)} +
+ {dow &&{dow}曜
} +{item.category}
+ )} ++ {item.body} +
+ )} + {item.tags && item.tags.length > 0 && ( +{item.tags.join(' ')}
+ )} ++ {parsed.subtitle || section.description} +
+ )} ++ 오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장 + {parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`} +
++ {verified && ( + + {verified} + + )} + {source?.name && ( + + 출처 ·{' '} + {source.url ? ( + event.stopPropagation()} + className="underline underline-offset-2" + > + {source.name} + + ) : ( + source.name + )} + + )} +
+ ); +} + +/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */ +export function useCarousel{label} 내용이 아직 없습니다
++ 오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다. +
+붙여넣은 JSON 을 읽지 못했습니다
+{message}
+33⅓ RPM
++ {parsed.subtitle || section.description} +
+ )} ++ A면 · {playing + 1} / {parsed.items.length} +
++ {[ + current.artist, + current.year ? String(current.year) : undefined, + current.lyricist || current.composer + ? `작사 ${current.lyricist ?? '미상'} / 작곡 ${current.composer ?? '미상'}` + : undefined, + current.label, + ] + .filter(Boolean) + .join(' · ')} +
+ {current.story && ( ++ {current.story} +
+ )} + {current.connection && ( ++ {current.connection} +
+ )} + + ◎ 가사 대신 이야기 — 원문은 싣지 않습니다 + ++ Section +
++ 오래 머무는 자리 +
++ 제목은 {template.fontStyle}, 본문은 이 서체로 나갑니다. +
+ +
{template.description}
diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts
index b9fd3cd..609cd07 100644
--- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts
+++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts
@@ -132,12 +132,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
+ // ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
+ // 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
if (!getAccessToken()) {
- setState({
- ...INITIAL,
- phase: 'unavailable',
- unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.',
- });
+ setState({...INITIAL});
return;
}
diff --git a/solution/frontend/src/features/publish/siteTheme.ts b/solution/frontend/src/features/publish/siteTheme.ts
index f86c96a..2b45ff0 100644
--- a/solution/frontend/src/features/publish/siteTheme.ts
+++ b/solution/frontend/src/features/publish/siteTheme.ts
@@ -42,12 +42,22 @@ export interface SiteThemePayload {
colorPaletteId?: string | null;
sections: {
id: string;
+ /**
+ * 섹션 타입.
+ *
+ * ★ 예전엔 안 실었다 — 시드에 다 있으니 id 로 찾으면 됐다. [+ 섹션 추가] 가 생기면서
+ * 시드에 없는 섹션이 저장되기 시작했고, type 이 없으면 복원 때 그게 뭐였는지 알 길이 없어
+ * 통째로 버려진다(사장님이 채운 JSON 까지 같이).
+ */
+ type?: string;
name: string;
enabled: boolean;
locked: boolean;
variantId?: string;
description?: string;
body?: string;
+ /** 붙여넣기 아이템의 원문 JSON. 서버는 이 값도 해석하지 않고 그대로 보관한다. */
+ data?: string;
}[];
customInfoFields?: InfoField[];
visiblePhotoIds?: string[];
@@ -74,11 +84,13 @@ export function toThemePayload(
colorPaletteId,
sections: sections.map((s) => ({
id: s.id,
+ type: s.type,
name: s.name,
enabled: s.isEnabled,
locked: s.isLocked,
...(s.description ? {description: s.description} : {}),
...(s.body ? {body: s.body} : {}),
+ ...(s.data ? {data: s.data} : {}),
...(s.variantId ? {variantId: s.variantId} : {}),
})),
customInfoFields: infoFields.filter((field) => field.id.startsWith('custom_')),
diff --git a/solution/frontend/src/index.css b/solution/frontend/src/index.css
index c644c6d..92c927d 100644
--- a/solution/frontend/src/index.css
+++ b/solution/frontend/src/index.css
@@ -60,8 +60,19 @@ body {
★ 폴백이 inherit 이라 --tpl-font-heading 이 없으면 예전과 똑같이 본문 서체를 그대로 물려받는다. */
.site-canvas h1,
.site-canvas h2,
- .site-canvas h3 {
+ .site-canvas h3,
+ .site-canvas h4 {
font-family: var(--tpl-font-heading, inherit);
+ letter-spacing: var(--tpl-heading-tracking, normal);
+ }
+ /* ★ 굵기는 폴백을 inherit 로 두지 않는다. 간판체(Gugi)는 굵기가 한 벌뿐이라 700 을 주면
+ 브라우저가 가짜 볼드를 씌워 획이 뭉갠다 — 템플릿이 400 을 지정할 수 있어야 한다. */
+ .site-canvas :is(h1, h2, h3, h4).tpl-title {
+ font-weight: var(--tpl-heading-weight, 700);
+ }
+ /* 카드·패널 테두리 두께도 템플릿이 정한다. 레트로는 2px 라야 인쇄물처럼 보인다. */
+ .site-canvas .border {
+ border-width: var(--tpl-border-width, 1px);
}
}
diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx
index eb9259d..b487b07 100644
--- a/solution/frontend/src/pages/BuilderPage.tsx
+++ b/solution/frontend/src/pages/BuilderPage.tsx
@@ -2,7 +2,9 @@ import {useEffect, useRef} from 'react';
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared';
+import {getAccessToken} from '@/api';
import {AppShell} from '@/components/layout/AppShell';
+import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import {
Step1Industry,
Step2PlaceSearch,
@@ -12,6 +14,7 @@ import {
} from '@/features/onboarding';
import {EditorLayout} from '@/features/builder';
import {usePlaceSync} from '@/hooks/usePlaceSync';
+import {useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
@@ -78,7 +81,11 @@ export function BuilderPage() {
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
const step = useBuilderStore((s) => s.step);
+ const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName);
+ // ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
+ const authUser = useAuthStore((s) => s.user);
+ const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId);
@@ -115,6 +122,11 @@ export function BuilderPage() {
);
}
+ // 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
+ if (step === EDITOR_STEP && !isSignedIn) {
+ return