[feat] solution/frontend: 템플릿이 색만 바꾸던 걸 끝낸다 — 업종당 5개에서 3개로

업종마다 템플릿이 다섯인데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다. 고르는 화면의
미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 — 뭐가 다른지 알 수
없으니 아무거나 골랐다.

- shared/TemplateItem.look: 제목·본문 서체, 모서리, 테두리 두께, 그림자, 제목 자간·굵기, 섹션 여백.
  CSS 에 그대로 들어가는 문자열로 들고 있다 — 숫자로 두면 쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다
- CanvasView: Tailwind v4 의 --radius-* · --shadow-* 를 캔버스 안에서만 덮는다. 변이 파일 40여 개에
  흩어진 rounded-* · shadow-* 를 한 줄도 안 고치고 전부 템플릿을 따르게 된다. 배수는 Tailwind
  기본 비율 그대로라 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같다
- index.css: .site-canvas 는 --tpl-font-heading/body 를 이미 읽고 있었는데 아무도 넣지 않았다.
  서체가 안 갈리던 진짜 이유가 이 빠진 고리다. 제목 굵기도 토큰으로 뺐다 — 간판체(Gugi)는 굵기가
  한 벌뿐이라 700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다
- industryData: templatesFor() 팩토리 하나로 심플·매거진·레트로 셋. 업종은 accent 하나만 바꾼다 —
  생김새는 업종이 아니라 취향의 문제다. 537줄 → 237줄
- Step4Template: 미리보기를 그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다
- index.html: Noto Serif KR 추가. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다
- SectionFrame: 세로 여백을 --tpl-section-space 로. 이 값 하나로 페이지의 호흡이 바뀐다

옛 템플릿 id 가 DB 에 남아 있어도 resolveTemplate 이 첫 템플릿으로 떨어뜨린다.

tsc·eslint·vite build 통과(frontend·admin·site), 템플릿 12벌 look 전량 대조 + 폴백 확인
This commit is contained in:
Mina Choi 2026-09-02 08:49:38 +09:00
parent e6ffdad888
commit 4debeaed3a
9 changed files with 315 additions and 407 deletions

View File

@ -5,6 +5,41 @@
--- ---
## 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 — 붙여넣기 아이템 셋: 가요 다방 · 오늘의 한 장 · 반나절 산책 ## 2026-09-01 — 붙여넣기 아이템 셋: 가요 다방 · 오늘의 한 장 · 반나절 산책
**한 일** **한 일**

View File

@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&display=swap" href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<!-- 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다 --> <!-- 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다 -->

View File

@ -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<IndustryType, IndustryData> = { export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
stay: { stay: {
@ -24,112 +133,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' }, { id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' },
], ],
templates: [ templates: templatesFor('stay', '#2563eb', {
{ name: '옛 항구',
id: 'stay-o2o-editorial', description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.',
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',
},
{
id: 'stay-retro-port',
industryId: 'stay',
name: '옛 항구',
tone: 'photo',
toneLabel: '레트로 감성',
description: '도넛판과 일력, 승차권. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.',
colors: {
primary: '#1b1a15',
secondary: '#4c4739',
bg: '#e4dac0',
card: '#f2ebd9',
text: '#1b1a15',
accent: '#bf2f1b',
},
fontStyle: 'Retro Signage',
// 레트로 템플릿을 고르면 7080 아이템이 함께 들어온다 — 이 템플릿이 팔려는 게 바로 그것이다.
defaultSectionTypes: ['songs', 'daily', 'course'],
},
],
}, },
cafe: { cafe: {
@ -155,95 +162,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'local', type: 'local', name: '주변 나들이', isLocked: false, isEnabled: true, description: '양평 드라이브 코스 및 명소' }, { id: 'local', type: 'local', name: '주변 나들이', isLocked: false, isEnabled: true, description: '양평 드라이브 코스 및 명소' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' },
], ],
templates: [ templates: templatesFor('cafe', '#b45309', {
{ name: '옛 다방',
id: 'cafe-modern-espresso', description: '갱지 바탕에 간판체. LP 와 손글씨로, 다방 시절의 인상으로 지금의 커피를 이야기합니다.',
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',
},
{
id: 'cafe-retro-dabang',
industryId: 'cafe',
name: '옛 다방',
tone: 'photo',
toneLabel: '레트로 감성',
description: 'LP 와 갱지, 손글씨. 다방 시절의 인상으로 지금의 커피를 이야기합니다.',
colors: {
primary: '#1b1a15',
secondary: '#4c4739',
bg: '#e4dac0',
card: '#f2ebd9',
text: '#1b1a15',
accent: '#bf2f1b',
},
fontStyle: 'Retro Signage',
// 레트로 템플릿을 고르면 7080 아이템이 함께 들어온다 — 이 템플릿이 팔려는 게 바로 그것이다.
defaultSectionTypes: ['songs', 'daily', 'course'],
},
],
}, },
restaurant: { restaurant: {
@ -269,95 +191,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'local', type: 'local', name: '주변 안내', isLocked: false, isEnabled: true, description: '청담 명품거리 및 갤러리 안내' }, { id: 'local', type: 'local', name: '주변 안내', isLocked: false, isEnabled: true, description: '청담 명품거리 및 갤러리 안내' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' },
], ],
templates: [ templates: templatesFor('restaurant', '#16a34a', {
{ name: '노포',
id: 'rest-neat-table', description: '갱지 바탕에 간판체. 오래 해온 집이라는 사실 자체가 메뉴판이 됩니다.',
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',
},
{
id: 'restaurant-retro-nopo',
industryId: 'restaurant',
name: '노포',
tone: 'photo',
toneLabel: '레트로 감성',
description: '간판 활자와 성냥갑 톤. 오래 해온 집이라는 사실 자체가 메뉴판입니다.',
colors: {
primary: '#1b1a15',
secondary: '#4c4739',
bg: '#e4dac0',
card: '#f2ebd9',
text: '#1b1a15',
accent: '#bf2f1b',
},
fontStyle: 'Retro Signage',
// 레트로 템플릿을 고르면 7080 아이템이 함께 들어온다 — 이 템플릿이 팔려는 게 바로 그것이다.
defaultSectionTypes: ['songs', 'daily', 'course'],
},
],
}, },
tour: { tour: {
@ -383,95 +220,10 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' }, { id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' },
], ],
templates: [ templates: templatesFor('tour', '#7c3aed', {
{ name: '시간여행',
id: 'tour-visual-tour', description: '갱지 바탕에 간판체. 일력과 승차권으로 지역의 시간을 걸어 다니게 만듭니다.',
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',
},
{
id: 'tour-retro-timetrip',
industryId: 'tour',
name: '시간여행',
tone: 'photo',
toneLabel: '레트로 감성',
description: '연표와 승차권, 일력. 지역의 시간을 걸어 다니는 체험으로 엮습니다.',
colors: {
primary: '#1b1a15',
secondary: '#4c4739',
bg: '#e4dac0',
card: '#f2ebd9',
text: '#1b1a15',
accent: '#bf2f1b',
},
fontStyle: 'Retro Signage',
// 레트로 템플릿을 고르면 7080 아이템이 함께 들어온다 — 이 템플릿이 팔려는 게 바로 그것이다.
defaultSectionTypes: ['songs', 'daily', 'course'],
},
],
}, },
}; };

View File

@ -60,6 +60,8 @@ export function CanvasView() {
// 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다. // 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다.
const surfaces = deriveSurfaces(template.colors); const surfaces = deriveSurfaces(template.colors);
const look = template.look;
const themeVars = { const themeVars = {
'--tpl-surface': surfaces.surface, '--tpl-surface': surfaces.surface,
'--tpl-surface-alt': surfaces.surfaceAlt, '--tpl-surface-alt': surfaces.surfaceAlt,
@ -71,6 +73,28 @@ export function CanvasView() {
'--tpl-card': template.colors.card, '--tpl-card': template.colors.card,
'--tpl-text': template.colors.text, '--tpl-text': template.colors.text,
'--tpl-accent': template.colors.accent, '--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; } as CSSProperties;
/** /**

View File

@ -54,13 +54,18 @@ export function SectionFrame({
onClick={onSelect} onClick={onSelect}
className={cn( className={cn(
'relative cursor-pointer transition-all', 'relative cursor-pointer transition-all',
padded && 'px-4 py-12 sm:px-6 sm:py-16 md:px-8', padded && 'px-4 sm:px-6 md:px-8',
isSelected isSelected
? 'ring-2 ring-stone-900 ring-offset-2' ? 'ring-2 ring-stone-900 ring-offset-2'
: 'hover:outline-1 hover:outline-dashed hover:outline-stone-300', : 'hover:outline-1 hover:outline-dashed hover:outline-stone-300',
className, className,
)} )}
style={{backgroundColor: TONE_BG[tone]}} style={{
backgroundColor: TONE_BG[tone],
// 세로 여백은 템플릿이 정한다 — 이 값 하나로 페이지의 호흡이 바뀐다.
// 폴백은 걷어낸 py-12/py-16 과 같은 값이라, 토큰이 없는 곳에서는 지금까지와 동일하다.
...(padded ? {paddingBlock: 'var(--tpl-section-space, 3.5rem)'} : {}),
}}
> >
{isSelected && ( {isSelected && (
<span <span

View File

@ -52,7 +52,7 @@ export function SectionHeading({
</span> </span>
)} )}
<h2 <h2
className="serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl" className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
style={titleStyle} style={titleStyle}
> >
{title} {title}
@ -76,7 +76,7 @@ export function SectionHeading({
</span> </span>
)} )}
<h2 <h2
className="serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl" className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
style={titleStyle} style={titleStyle}
> >
{title} {title}
@ -93,7 +93,7 @@ export function SectionHeading({
return ( return (
<div className={cn('flex items-baseline justify-between gap-3', className)}> <div className={cn('flex items-baseline justify-between gap-3', className)}>
<h2 <h2
className="text-sm font-bold uppercase tracking-[0.18em] text-stone-500" className="tpl-title text-sm font-bold uppercase tracking-[0.18em] text-stone-500"
style={colors ? {color: colors.secondary} : undefined} style={colors ? {color: colors.secondary} : undefined}
> >
{title} {title}
@ -113,7 +113,7 @@ export function SectionHeading({
> >
<div> <div>
<h2 <h2
className="serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl" className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
style={titleStyle} style={titleStyle}
> >
{title} {title}

View File

@ -1,4 +1,5 @@
import {Check} from 'lucide-react'; import {Check} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate'; import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
@ -6,6 +7,79 @@ import {useBuilderStore} from '@/stores/builder';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps'; import {WizardSteps} from './WizardSteps';
/**
* 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다.
*
* ★ 예전에는 회색 막대 세 줄과 색 동그라미였다. 다섯 템플릿이 전부 같은 그림이라
* 무엇을 고르는지 알 수 없었고, 그래서 아무거나 골랐다.
*/
function TemplatePreview({template}: {template: TemplateItem}) {
const {look, colors} = template;
return (
<div
className="my-3 overflow-hidden p-3.5"
style={{
backgroundColor: colors.bg,
color: colors.text,
borderRadius: look.radius,
border: `${look.borderWidth} solid ${colors.secondary}22`,
}}
>
<p
className="text-[9px] uppercase"
style={{color: colors.accent, letterSpacing: '0.22em', fontFamily: look.fontBody}}
>
Section
</p>
<p
className="mt-1.5 text-base leading-tight"
style={{
fontFamily: look.fontHeading,
fontWeight: look.headingWeight,
letterSpacing: look.headingTracking,
}}
>
오래 머무는 자리
</p>
<p
className="mt-1.5 text-[10px] leading-relaxed"
style={{fontFamily: look.fontBody, color: colors.secondary}}
>
제목은 {template.fontStyle}, 본문은 이 서체로 나갑니다.
</p>
<div
className="mt-3 p-2"
style={{
backgroundColor: colors.card,
borderRadius: `calc(${look.radius} * 0.67)`,
border: `${look.borderWidth} solid ${colors.secondary}22`,
boxShadow: look.shadow,
}}
>
<span className="text-[9px]" style={{fontFamily: look.fontBody, color: colors.secondary}}>
카드 · 모서리 {look.radius === '0px' ? '각짐' : '둥금'} · 그림자{' '}
{look.shadow === 'none' ? '없음' : '있음'}
</span>
</div>
<span
className="mt-2.5 inline-block px-2.5 py-1 text-[9px] font-semibold"
style={{
backgroundColor: colors.accent,
color: colors.bg,
borderRadius: `calc(${look.radius} * 0.5)`,
fontFamily: look.fontBody,
}}
>
예약 문의
</span>
</div>
);
}
export function Step4Template() { export function Step4Template() {
const industry = useBuilderStore((s) => s.industry); const industry = useBuilderStore((s) => s.industry);
const templateId = useBuilderStore((s) => s.templateId); const templateId = useBuilderStore((s) => s.templateId);
@ -79,31 +153,7 @@ export function Step4Template() {
</span> </span>
</div> </div>
{/* 색·레이아웃 미리보기 와이어프레임 */} <TemplatePreview template={template} />
<div className="my-3 space-y-2.5 rounded-xl border border-border bg-muted/50 p-3.5">
<div className="flex items-center gap-1.5">
{(['primary', 'secondary', 'card'] as const).map((key) => (
<span
key={key}
className="size-3.5 rounded-full border border-black/10"
style={{backgroundColor: template.colors[key]}}
title={key}
/>
))}
<span className="ml-1 font-mono text-[10px] text-muted-foreground">
{template.colors.primary}
</span>
</div>
<div className="space-y-1.5 pt-1">
<div
className="h-2.5 w-2/3 rounded"
style={{backgroundColor: template.colors.primary, opacity: 0.85}}
/>
<div className="h-1.5 w-full rounded bg-border" />
<div className="h-1.5 w-4/5 rounded bg-border" />
</div>
</div>
<p className="text-xs leading-relaxed text-muted-foreground"> <p className="text-xs leading-relaxed text-muted-foreground">
{template.description} {template.description}

View File

@ -60,8 +60,19 @@ body {
★ 폴백이 inherit 이라 --tpl-font-heading 이 없으면 예전과 똑같이 본문 서체를 그대로 물려받는다. */ ★ 폴백이 inherit 이라 --tpl-font-heading 이 없으면 예전과 똑같이 본문 서체를 그대로 물려받는다. */
.site-canvas h1, .site-canvas h1,
.site-canvas h2, .site-canvas h2,
.site-canvas h3 { .site-canvas h3,
.site-canvas h4 {
font-family: var(--tpl-font-heading, inherit); 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);
} }
} }

View File

@ -67,7 +67,15 @@ export interface TemplateItem {
text: string; text: string;
accent: string; accent: string;
}; };
/** 사람에게 보이는 서체 이름('고딕' · '명조 제목'). 실제 서체는 look 이 정한다. */
fontStyle: string; fontStyle: string;
/**
* 실제로 화면을 가르는 값.
*
* ★ 예전엔 색 여섯 개뿐이라 템플릿을 바꿔도 색조만 달라졌다 — 다섯 개가 다 같아 보였다.
* 서체·모서리·테두리·그림자·여백이 여기 있고, 캔버스가 CSS 변수로 그대로 내려보낸다.
*/
look: TemplateLook;
/** /**
* 이 템플릿을 고르면 함께 들어오는 섹션(canvas/addable 의 타입). * 이 템플릿을 고르면 함께 들어오는 섹션(canvas/addable 의 타입).
* *
@ -77,6 +85,29 @@ export interface TemplateItem {
defaultSectionTypes?: string[]; defaultSectionTypes?: string[];
} }
/**
* 템플릿의 생김새. 값은 전부 CSS 에 그대로 들어가는 문자열이다 —
* 숫자로 들고 있다가 쓰는 쪽에서 단위를 붙이면, 빠뜨린 곳이 조용히 0 이 된다.
*/
export interface TemplateLook {
/** 제목 서체 스택. `--tpl-font-heading` */
fontHeading: string;
/** 본문 서체 스택. `--tpl-font-body` */
fontBody: string;
/** 모서리. Tailwind 의 `--radius-*` 를 캔버스 안에서만 덮어 rounded-* 전부가 따라온다. */
radius: string;
/** 카드·패널 테두리 두께. */
borderWidth: string;
/** 카드 그림자. Tailwind 의 `--shadow-*` 를 덮는다. 'none' 이면 평평해진다. */
shadow: string;
/** 제목 자간. */
headingTracking: string;
/** 제목 굵기. 간판체처럼 한 굵기뿐인 서체는 400 이어야 한다. */
headingWeight: string;
/** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */
sectionSpace: string;
}
export interface PhotoItem { export interface PhotoItem {
id: string; id: string;
url: string; url: string;