diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 0604186..42f415c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -43,6 +43,85 @@ --- +## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개) + +**왜** +업종마다 템플릿이 다섯이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다. +고르는 화면의 미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 — +사장님은 뭐가 다른지 알 수 없으니 아무거나 골랐다. 사용자 말: "가라 UI 로 되어 있어서 뭐가뭔지 모르겠음". + +**한 일** +- `TemplateItem.look`(`TemplateLook`) 신설: 제목·본문 서체, 모서리, 테두리 두께, 그림자, + 제목 자간·굵기, 섹션 여백. **CSS 에 그대로 들어가는 문자열**로 들고 있다 — 숫자로 두면 + 쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다. +- 업종당 **3개**로 정리: 심플(고딕·둥근·그림자) · 매거진(명조 제목·각짐·그림자 없음·여백 큼) · + 레트로(간판체·2px 테두리·오프셋 그림자·갱지). `templatesFor()` 팩토리 하나가 찍어내고 + **업종은 accent 하나만 바꾼다** — 생김새는 업종이 아니라 취향의 문제다. + `industryData.ts` 537줄 → 237줄. +- 고르는 화면의 미리보기를 **그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다**(`TemplatePreview`). + +**핵심 수법 — Tailwind 테마 변수를 캔버스 안에서만 덮는다** +`.site-canvas` 에 `--radius-*` · `--shadow-*` 를 내려보내면, 변이 파일 40여 개에 흩어진 +`rounded-*` · `shadow-*` 를 **한 줄도 안 고치고** 전부 템플릿을 따르게 된다. +배수는 Tailwind 기본 비율을 그대로 옮겨, 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다. + +**밟은 함정** +- `.site-canvas` 는 이미 `--tpl-font-heading/body` 를 **읽고 있었는데 아무도 넣지 않았다.** + 서체가 갈리지 않던 진짜 이유가 이 빠진 고리였다. +- 간판체(Gugi)는 굵기가 한 벌뿐이라 `font-weight:700` 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. + → `--tpl-heading-weight` 로 템플릿이 400 을 지정할 수 있게 했다. +- `Noto Serif KR` 을 안 불러오고 있었다. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다. +- 옛 템플릿 id(`stay-warm-wood` 등)가 DB 에 남아 있어도 `resolveTemplate` 이 첫 템플릿으로 떨어뜨린다. + +**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site). 템플릿 12벌의 look 전량 대조, +옛 id 폴백·레트로만 아이템을 데려오는지 확인. + +--- + +## 2026-09-01 — 붙여넣기 아이템 셋: 가요 다방 · 오늘의 한 장 · 반나절 산책 + +**한 일** +- 섹션 타입 3개 추가(`songs` · `daily` · `course`). 데이터가 수집(fact)이 아니라 + **사장님이 붙여넣은 JSON** 에서 온다 — 새 갈래다. +- `canvas/dataSpec.ts` 신설: 스키마·예시·프롬프트가 한 표에 모인다. 배리에이션 레지스트리와 같은 결이라 + 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. +- `SectionItem.data?: string` 추가. **파싱본이 아니라 원문 문자열**을 담는다. +- [콘텐츠] 탭에 JSON 칸 + [프롬프트 복사] [프롬프트 보기] [예시 넣기] [줄맞춤]. +- 업종 시드 넷 모두에 세 섹션을 **꺼진 채로** 넣었다. + +**왜 이 모양인가** +`gunsan_365_story_db.xlsx`(365행)를 분석한 결과 **고유 주제는 52개고 한 주제가 7회씩 돈다** +(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 — +그래서 묶는 축을 주제로 잡고, 날짜는 일력 한 장에만 썼다. +같은 시트 `DB_Guide` 가 **가사·현대문학 원문 전재를 금지**해서 가요 스키마에 `lyrics` 필드를 +아예 두지 않았다. 없는 칸은 채울 수 없다. + +**밟은 함정** +- **테마 상한 64KB**(`site_service._THEME_MAX_BYTES`). 세 섹션이 각자 JSON 을 채우면 넘고, + 거절은 발행 직전에야 드러난다. → `SECTION_DATA_MAX_CHARS`(12,000자)로 화면에서 먼저 끊는다. +- **`JSON.parse` 오류 메시지가 두 형식이다.** `position N (line L column C)` 형과, 위치 없이 + 깨진 조각만 인용하는 형. 앞의 것만 보면 후자에서 위치를 통째로 잃는다 — 조각을 원문에서 되찾아 센다. +- 파싱은 **절대 throw 하지 않는다.** 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. + +**섹션 관리에 붙인 것** +- 좌측 패널 하단 **[+ 섹션 추가]** → 목록에서 골라 넣는다. 시드에 박아 두지 않는 이유는, + 붙여넣기 아이템은 내용이 없으면 빈 칸이라 아무도 안 쓰는 항목이 늘 붙어 있게 되기 때문이다. +- 나중에 넣은 섹션만 휴지통으로 뺄 수 있다(업종 기본 섹션은 스위치로 끈다). +- **레트로 템플릿**(업종마다 하나: 옛 항구 · 옛 다방 · 노포 · 시간여행)을 고르면 세 아이템이 함께 들어온다. + `TemplateItem.defaultSectionTypes` 가 그 계약이고, **넣기만 하고 빼지 않는다** — + 템플릿을 눌러 보다 넣어 둔 섹션이 사라지면 사장님은 자기가 지웠다고 생각한다. +- 저장 payload 에 `type` 을 실었다. 시드에 없는 섹션은 복원 때 `id` 로 못 찾아 **통째로 버려졌다** + (사장님이 채운 JSON 까지 같이). 이제 `type` 으로 되살린다. + +**아직 안 한 것** +- 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다. +- 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다). + +**검증** — `tsc --noEmit` · `eslint` · `vite build` 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인, +파서 경계 12건 + 추가·삭제·템플릿·저장복원 왕복 12건 확인. + +--- + ## 2026-09-01 — 설정을 `.env` 하나로 모았다 **한 일** diff --git a/solution/frontend/index.html b/solution/frontend/index.html index c243534..f5ab125 100644 --- a/solution/frontend/index.html +++ b/solution/frontend/index.html @@ -9,7 +9,12 @@ + + diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index 8ac5e8c..5e2498c 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -1,4 +1,113 @@ -import type {IndustryData, IndustryType} from '@o2o/shared'; +import type {IndustryData, IndustryType, TemplateItem} from '@o2o/shared'; +/** + * 템플릿 세 벌. + * + * ★ 예전엔 업종마다 다섯 벌이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다 — + * 골라도 뭐가 달라지는지 알 수 없었다. 지금은 서체·모서리·테두리·그림자·여백까지 갈린다. + * 같은 얼굴을 색만 바꿔 늘리지 않는다. + * ★ 업종이 바꾸는 건 accent 하나다. 생김새는 업종이 아니라 취향의 문제다. + */ +const LOOK = { + simple: { + fontHeading: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", + fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", + radius: '0.75rem', + borderWidth: '1px', + shadow: '0 1px 2px rgb(0 0 0 / 0.06)', + headingTracking: '-0.02em', + headingWeight: '700', + sectionSpace: '3.5rem', + }, + magazine: { + fontHeading: "'Noto Serif KR', 'Batang', 'Times New Roman', serif", + fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", + radius: '0px', + borderWidth: '1px', + shadow: 'none', + headingTracking: '-0.03em', + headingWeight: '700', + sectionSpace: '5rem', + }, + retro: { + fontHeading: "'Gugi', 'Noto Sans KR', sans-serif", + fontBody: "'Gowun Batang', 'Noto Serif KR', serif", + radius: '0px', + borderWidth: '2px', + shadow: '4px 4px 0 rgb(27 26 21 / 0.16)', + // 간판체는 자간을 벌리면 글자가 흩어진다. 굵기도 한 벌뿐이라 700 을 주면 가짜 볼드가 씌워진다. + headingTracking: '0em', + headingWeight: '400', + sectionSpace: '4rem', + }, +} as const; + +/** 업종 하나의 템플릿 세 벌. accent 만 업종이 정한다. */ +function templatesFor( + industryId: IndustryType, + accent: string, + retro: {name: string; description: string}, +): TemplateItem[] { + return [ + { + id: `${industryId}-simple`, + industryId, + name: '심플', + tone: 'info', + toneLabel: '깔끔한 기본', + description: '흰 바탕에 고딕. 읽기 쉽고 어디에도 어울립니다. 무엇을 고를지 모르겠으면 이것.', + colors: { + primary: '#18181b', + secondary: '#52525b', + bg: '#ffffff', + card: '#fafafa', + text: '#09090b', + accent, + }, + fontStyle: '고딕', + look: LOOK.simple, + }, + { + id: `${industryId}-magazine`, + industryId, + name: '매거진', + tone: 'photo', + toneLabel: '잡지 편집', + description: '명조 제목에 각진 모서리, 그림자 없이 선으로만. 사진과 글이 많을 때 품이 납니다.', + colors: { + primary: '#111111', + secondary: '#57534e', + bg: '#ffffff', + card: '#f7f6f4', + text: '#111111', + accent, + }, + fontStyle: '명조 제목', + look: LOOK.magazine, + }, + { + id: `${industryId}-retro`, + industryId, + name: retro.name, + tone: 'photo', + toneLabel: '레트로 감성', + description: retro.description, + colors: { + primary: '#1b1a15', + secondary: '#4c4739', + bg: '#e4dac0', + card: '#f2ebd9', + text: '#1b1a15', + // 레트로의 정체성이 이 주(朱) 잉크다 — 업종 accent 로 갈아끼우지 않는다. + accent: '#bf2f1b', + }, + fontStyle: '옛 간판체', + look: LOOK.retro, + // 이 템플릿이 팔려는 게 바로 이 아이템들이다. + defaultSectionTypes: ['songs', 'daily', 'course'], + }, + ]; +} + export const INDUSTRY_CONFIGS: Record = { stay: { @@ -24,93 +133,10 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' }, ], - templates: [ - { - id: 'stay-o2o-editorial', - industryId: 'stay', - name: 'O2O 에디토리얼', - tone: 'photo', - toneLabel: '차분한 모던', - description: '절제된 타이포그래피와 여백, 차분한 슬레이트 톤으로 신뢰감과 감성을 함께 전합니다.', - colors: { - primary: '#18181b', - secondary: '#52525b', - bg: '#ffffff', - card: '#fafafa', - text: '#09090b', - accent: '#2563eb', - }, - fontStyle: 'Modern Editorial', - }, - { - id: 'stay-quiet-margin', - industryId: 'stay', - name: '조용한 여백', - tone: 'photo', - toneLabel: '사진 중심', - description: '넓은 여백과 감성적인 사진 배치로 여유로운 휴식 분위기를 전달합니다.', - colors: { - primary: '#1e293b', - secondary: '#64748b', - bg: '#ffffff', - card: '#f8fafc', - text: '#0f172a', - accent: '#0284c7', - }, - fontStyle: 'Clean Minimalist', - }, - { - id: 'stay-warm-wood', - industryId: 'stay', - name: '따뜻한 목조', - tone: 'photo', - toneLabel: '사진 중심', - description: '아늑하고 내추럴한 톤으로 자연 친화적인 공간의 따스함을 극대화합니다.', - colors: { - primary: '#43302b', - secondary: '#786055', - bg: '#faf8f5', - card: '#f2ece4', - text: '#29201d', - accent: '#c27847', - }, - fontStyle: 'Warm Natural', - }, - { - id: 'stay-clear-info', - industryId: 'stay', - name: '또렷한 정보', - tone: 'info', - toneLabel: '정보 중심', - description: '객실 옵션, 구비 시설, 체크인 규정을 한눈에 파악할 수 있는 실용적인 구조입니다.', - colors: { - primary: '#0f172a', - secondary: '#475569', - bg: '#ffffff', - card: '#f1f5f9', - text: '#020617', - accent: '#2563eb', - }, - fontStyle: 'Crisp Modern', - }, - { - id: 'stay-booking-focus', - industryId: 'stay', - name: '예약 중심', - tone: 'book', - toneLabel: '예약·신청 강조', - description: '객실별 예약 버튼과 실시간 문의 CTA를 전면에 배치하여 전환율을 높입니다.', - colors: { - primary: '#18181b', - secondary: '#52525b', - bg: '#ffffff', - card: '#fafafa', - text: '#09090b', - accent: '#059669', - }, - fontStyle: 'Conversion Driven', - }, - ], + templates: templatesFor('stay', '#2563eb', { + name: '옛 항구', + description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.', + }), }, cafe: { @@ -136,76 +162,10 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'local', type: 'local', name: '주변 나들이', isLocked: false, isEnabled: true, description: '양평 드라이브 코스 및 명소' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' }, ], - templates: [ - { - id: 'cafe-modern-espresso', - industryId: 'cafe', - name: '모던 에스프레소', - tone: 'photo', - toneLabel: '사진 중심', - description: '다크 차콜 톤과 고해상도 커피 샷으로 프로페셔널한 스페셜티 이미지를 완성합니다.', - colors: { - primary: '#1c1917', - secondary: '#78716c', - bg: '#ffffff', - card: '#fafaf9', - text: '#0c0a09', - accent: '#b45309', - }, - fontStyle: 'Sleek Roast', - }, - { - id: 'cafe-sun-terrace', - industryId: 'cafe', - name: '햇살과 테라스', - tone: 'photo', - toneLabel: '사진 중심', - description: '산뜻한 화이트와 그린 포인트로 자연 채광 가득한 힐링 테라스 분위기를 연출합니다.', - colors: { - primary: '#14532d', - secondary: '#4b5563', - bg: '#ffffff', - card: '#f0fdf4', - text: '#052e16', - accent: '#16a34a', - }, - fontStyle: 'Garden Terrace', - }, - { - id: 'cafe-roastery-story', - industryId: 'cafe', - name: '감성 로스터리', - tone: 'info', - toneLabel: '정보 중심', - description: '원두 컵노트, 로스팅 프로파일, 브루잉 가이드를 체계적으로 정리해 전달합니다.', - colors: { - primary: '#332924', - secondary: '#6c584c', - bg: '#fcfaf8', - card: '#f4ede6', - text: '#201815', - accent: '#8c5e3d', - }, - fontStyle: 'Artisan Roast', - }, - { - id: 'cafe-quick-order', - industryId: 'cafe', - name: '퀵 오더 & 예약', - tone: 'book', - toneLabel: '예약·신청 강조', - description: '시그니처 원두 패키지 스마트스토어 링크 및 단체 대관 접수를 최우선으로 노출합니다.', - colors: { - primary: '#0f172a', - secondary: '#475569', - bg: '#ffffff', - card: '#f8fafc', - text: '#020617', - accent: '#ea580c', - }, - fontStyle: 'Dynamic Commerce', - }, - ], + templates: templatesFor('cafe', '#b45309', { + name: '옛 다방', + description: '갱지 바탕에 간판체. LP 와 손글씨로, 다방 시절의 인상으로 지금의 커피를 이야기합니다.', + }), }, restaurant: { @@ -231,76 +191,10 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'local', type: 'local', name: '주변 안내', isLocked: false, isEnabled: true, description: '청담 명품거리 및 갤러리 안내' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' }, ], - templates: [ - { - id: 'rest-neat-table', - industryId: 'restaurant', - name: '정갈한 한상', - tone: 'photo', - toneLabel: '사진 중심', - description: '단아한 여백과 요리 고유의 색감을 살려 프리미엄 다이닝의 격을 높입니다.', - colors: { - primary: '#1c1917', - secondary: '#57534e', - bg: '#ffffff', - card: '#fafaf9', - text: '#0c0a09', - accent: '#b45309', - }, - fontStyle: 'Sophisticated Table', - }, - { - id: 'rest-modern-dining', - industryId: 'restaurant', - name: '모던 다이닝', - tone: 'photo', - toneLabel: '사진 중심', - description: '세련된 다크 모던 톤으로 주류 페어링과 비스트로의 트렌디함을 강조합니다.', - colors: { - primary: '#09090b', - secondary: '#52525b', - bg: '#ffffff', - card: '#f4f4f5', - text: '#09090b', - accent: '#e11d48', - }, - fontStyle: 'Chic Bistro', - }, - { - id: 'rest-taste-origin', - industryId: 'restaurant', - name: '맛과 원산지', - tone: 'info', - toneLabel: '정보 중심', - description: '계절 코스 구성, 식재료 산지 지도, 조리 과정을 상세히 기록하여 신뢰도를 높입니다.', - colors: { - primary: '#1e293b', - secondary: '#475569', - bg: '#ffffff', - card: '#f1f5f9', - text: '#0f172a', - accent: '#0d9488', - }, - fontStyle: 'Artisan Story', - }, - { - id: 'rest-booking-type', - industryId: 'restaurant', - name: '테이블 예약형', - tone: 'book', - toneLabel: '예약·신청 강조', - description: '시간대별 룸 예약 위젯과 잔여석 상태를 직관적으로 노출합니다.', - colors: { - primary: '#0f172a', - secondary: '#334155', - bg: '#ffffff', - card: '#f8fafc', - text: '#020617', - accent: '#2563eb', - }, - fontStyle: 'Direct Reserve', - }, - ], + templates: templatesFor('restaurant', '#16a34a', { + name: '노포', + description: '갱지 바탕에 간판체. 오래 해온 집이라는 사실 자체가 메뉴판이 됩니다.', + }), }, tour: { @@ -326,76 +220,10 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' }, ], - templates: [ - { - id: 'tour-visual-tour', - industryId: 'tour', - name: '비주얼 투어', - tone: 'photo', - toneLabel: '사진 중심', - description: '체험 현장의 생생한 사진과 감각적인 그리드로 방문 욕구를 자극합니다.', - colors: { - primary: '#0f172a', - secondary: '#475569', - bg: '#ffffff', - card: '#f8fafc', - text: '#020617', - accent: '#0284c7', - }, - fontStyle: 'Visual Journey', - }, - { - id: 'tour-gallery-archive', - industryId: 'tour', - name: '갤러리 아카이브', - tone: 'photo', - toneLabel: '사진 중심', - description: '단아한 미술관 도록 스타일로 공예품과 전시 작품의 미학을 돋보이게 합니다.', - colors: { - primary: '#27272a', - secondary: '#71717a', - bg: '#fafafa', - card: '#f4f4f5', - text: '#18181b', - accent: '#d97706', - }, - fontStyle: 'Art Museum', - }, - { - id: 'tour-detailed-guide', - industryId: 'tour', - name: '상세 안내형', - tone: 'info', - toneLabel: '정보 중심', - description: '연령대별 추천 코스, 단계별 커리큘럼, 준비물 안내를 명확하게 정리합니다.', - colors: { - primary: '#1e293b', - secondary: '#64748b', - bg: '#ffffff', - card: '#f1f5f9', - text: '#0f172a', - accent: '#4f46e5', - }, - fontStyle: 'Curriculum Guide', - }, - { - id: 'tour-ticket-booking', - industryId: 'tour', - name: '티켓 예매형', - tone: 'book', - toneLabel: '예약·신청 강조', - description: '클래스 날짜 선택과 즉시 티켓 결제 연동에 최적화된 고전환 레이아웃입니다.', - colors: { - primary: '#0f172a', - secondary: '#334155', - bg: '#ffffff', - card: '#f8fafc', - text: '#020617', - accent: '#059669', - }, - fontStyle: 'Instant Booking', - }, - ], + templates: templatesFor('tour', '#7c3aed', { + name: '시간여행', + description: '갱지 바탕에 간판체. 일력과 승차권으로 지역의 시간을 걸어 다니게 만듭니다.', + }), }, }; diff --git a/solution/frontend/src/features/auth/EditorSignInGate.tsx b/solution/frontend/src/features/auth/EditorSignInGate.tsx new file mode 100644 index 0000000..c432851 --- /dev/null +++ b/solution/frontend/src/features/auth/EditorSignInGate.tsx @@ -0,0 +1,37 @@ +/** + * 에디터 앞의 로그인 관문. + * + * ★ 위저드(1~5단계)는 로그인을 요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. + * 에디터부터는 편집한 것을 저장하고 발행해야 하는데 그게 전부 토큰을 쓴다. 토큰 없이 들여보내면 + * 저장이 조용히 실패하고 사장님은 발행하고 나서야 안다. + * ★ /login 으로 튕기지 않는다. 위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없다. + */ +import {SignInForm} from './SignInForm'; + +export function EditorSignInGate({onBack}: {onBack: () => void}) { + return ( +
+ + Web4Ai +

편집을 시작하려면 로그인해 주세요

+

+ 여기까지 만든 내용은 그대로 있습니다. 편집한 것을 저장하고 발행하는 데 계정이 필요합니다. +

+
+ } + footer={ + + } + /> + + ); +} diff --git a/solution/frontend/src/features/auth/SignInForm.tsx b/solution/frontend/src/features/auth/SignInForm.tsx new file mode 100644 index 0000000..b9d04de --- /dev/null +++ b/solution/frontend/src/features/auth/SignInForm.tsx @@ -0,0 +1,104 @@ +/** + * 로그인 폼 한 벌. + * + * ★ 로그인 화면과 에디터 진입 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서 + * (signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다. + */ +import {useState, type FormEvent, type ReactNode} from 'react'; +import {LogIn} from 'lucide-react'; +import {login, me, UserRole} from '@/api'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {notifyApiError} from '@/lib/notify'; +import {toAuthUser, useAuthStore} from '@/stores/auth'; + +interface SignInFormProps { + /** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */ + header: ReactNode; + /** 폼 아래 각주(빌더로 돌아가기 등). */ + footer?: ReactNode; + submitLabel?: string; + onSignedIn?: () => void; +} + +export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) { + const signIn = useAuthStore((s) => s.signIn); + const [id, setId] = useState(''); + const [password, setPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + try { + const res = await login({id, password}); + if (res.result?.success === false) { + notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); + return; + } + // ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. + // 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다. + if (!res.access_token || !res.refresh_token) { + notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); + return; + } + const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token}; + + // 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다. + signIn(tokens, {userId: '', id, role: UserRole.USER}); + + const meRes = await me(); + // 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다. + if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes)); + onSignedIn?.(); + } catch (error) { + notifyApiError(error, '로그인에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {header} + +
+
+ + setId(e.target.value)} + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ + + + {footer} +
+ ); +} diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index 7588037..4ed1789 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -60,6 +60,8 @@ export function CanvasView() { // 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다. const surfaces = deriveSurfaces(template.colors); + const look = template.look; + const themeVars = { '--tpl-surface': surfaces.surface, '--tpl-surface-alt': surfaces.surfaceAlt, @@ -71,6 +73,28 @@ export function CanvasView() { '--tpl-card': template.colors.card, '--tpl-text': template.colors.text, '--tpl-accent': template.colors.accent, + + // 색만 바꾸면 템플릿이 다 같아 보인다 — 생김새를 가르는 건 아래쪽이다. + '--tpl-font-heading': look.fontHeading, + '--tpl-font-body': look.fontBody, + '--tpl-heading-tracking': look.headingTracking, + '--tpl-heading-weight': look.headingWeight, + '--tpl-section-space': look.sectionSpace, + '--tpl-border-width': look.borderWidth, + + // ★ Tailwind v4 의 테마 변수를 캔버스 안에서만 덮는다. 이러면 변이 파일 40여 개에 흩어진 + // rounded-* · shadow-* 를 하나도 안 고치고 전부 템플릿을 따르게 된다. + // 배수는 Tailwind 기본 비율(0.25/0.375/0.5/0.75/1/1.5rem)을 그대로 옮긴 것이라, + // 기준값이 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다. + '--radius-sm': `calc(${look.radius} * 0.34)`, + '--radius-md': `calc(${look.radius} * 0.5)`, + '--radius-lg': `calc(${look.radius} * 0.67)`, + '--radius-xl': look.radius, + '--radius-2xl': `calc(${look.radius} * 1.34)`, + '--radius-3xl': `calc(${look.radius} * 2)`, + '--shadow-xs': look.shadow, + '--shadow-sm': look.shadow, + '--shadow-md': look.shadow, } as CSSProperties; /** diff --git a/solution/frontend/src/features/builder/RightTabsPanel.tsx b/solution/frontend/src/features/builder/RightTabsPanel.tsx index 8d7c5d8..c8ad7cb 100644 --- a/solution/frontend/src/features/builder/RightTabsPanel.tsx +++ b/solution/frontend/src/features/builder/RightTabsPanel.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import { AlertTriangle, Check, + ClipboardCopy, Image as ImageIcon, Info, FileText, @@ -10,7 +11,9 @@ import { Pencil, Plus, Shapes, + Sparkles, Trash2, + Wand2, X, } from 'lucide-react'; import type {InfoField} from '@o2o/shared'; @@ -20,9 +23,16 @@ import {Input} from '@/components/ui/input'; import {PasteFactsPanel, RecollectPanel} from '@/features/onboarding'; import {cn} from '@/lib/utils'; import {useBuilderStore, useUnverifiedFields, type RightTab} from '@/stores/builder'; +import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {FaqPanel} from './FaqPanel'; import {SectionDesignPanel} from './SectionDesignPanel'; import {resolveVariant} from './canvas/registry'; +import { + buildPrompt, + dataSpecFor, + parseSectionData, + SECTION_DATA_MAX_CHARS, +} from './canvas/dataSpec'; const TABS: {id: RightTab; label: string; icon: typeof Info}[] = [ {id: 'content', label: '콘텐츠', icon: FileText}, @@ -148,7 +158,8 @@ function ContentTab() { className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-xs leading-relaxed outline-none focus:border-ring focus:ring-2 focus:ring-ring/30" /> } - {!supportsTitle && !supportsDescription && !supportsBody && ( + {dataSpecFor(section.type) && } + {!supportsTitle && !supportsDescription && !supportsBody && !dataSpecFor(section.type) && (

이 레이아웃에는 직접 입력하는 문구가 없습니다. 수집된 데이터만 표시합니다.

@@ -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 ( +
+
+ + {spec.label} 내용 (JSON) +
+

+ 프롬프트를 복사해 ChatGPT·Claude 에 그대로 붙여넣으면 JSON 을 줍니다. 받은 JSON 을 아래에 붙여넣으세요. +

+ +
+ + + + +
+ + {showPrompt && ( +
+

+ 아래 내용이 그대로 복사됩니다. 상호와 지역은 이미 채워져 있습니다. +

+