o2o-site-AEO/solution/frontend/src/features/onboarding/Step4Template.tsx
Mina Choi a61f9724ea [feat] solution/frontend: 온보딩을 상호명부터 — 단계를 주소창으로 옮기고 업종은 검색이 정한다
업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은
100% 안다. 그리고 업종은 AI 를 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가
이미 들어 있고(category_group_code), 지금까지 받아 놓고 안 썼다.

- 단계가 스토어에서 주소창으로: ?step=search|industry|collect|template|generating|editor.
  번호가 아니라 이름인 이유 — 단계가 4→3 으로 줄어 옛 북마크가 다른 화면을 연다
- 시작점이 상호명 검색(Step2PlaceSearch)이다. 업종 선택은 못 정했을 때의 갈래로 남는다
- 업종은 후보의 category 로 잡히고, 못 정하면 고르게 하고, 정해져도 [바꾸기] 로 바꾼다.
  ★ 확정 뒤 변경은 신원 확인부터 다시 받는다 — Req_UpdatePlace 에 category 가 없어
    PATCH 로 못 고치고, placeAdapter 가 리페치마다 덮어써서 조용히 되돌아간다
- 랜딩 진입: ?new=1 · ?q=<상호명> · ?industry=<업종>. 한 번 읽고 replace 로 지운다
- ★ ?new=1 이 setSearchParams({}) 로 **모든 쿼리를 날리던 것**을 고쳤다 — q 가 읽히기 전에 사라졌다
- selectIndustry 가 사장님이 친 상호·위치를 지우던 것도 고쳤다(업종이 첫 화면일 땐 늘 빈 값이라 안 보였다)
- 로고는 어디서나 / 로 간다. 에디터에서는 span 이라 아예 안 눌렸다

tsc·eslint·vite build 통과
2026-09-03 10:20:57 +09:00

183 lines
6.8 KiB
TypeScript

import {Check} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter';
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() {
const industry = useBuilderStore((s) => s.industry);
const templateId = useBuilderStore((s) => s.templateId);
const placeId = useBuilderStore((s) => s.placeId);
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
const [, goToStep] = useWizardStep();
const startGenerating = useBuilderStore((s) => s.startGenerating);
const config = INDUSTRY_CONFIGS[industry];
/**
* 고른 순간 서버에도 남긴다.
*
* ★ 화면 상태로만 두면 발행 잡이 읽을 곳이 없어 업종 기본 템플릿으로 굽는다 —
* 사장님이 고른 디자인과 실제 발행본이 갈린다. 저장은 화면을 기다리게 하지 않는다.
*/
const chooseTemplate = (id: string) => {
selectTemplate(id);
queueSiteTemplateSave(placeId, id);
};
return (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
<div className="flex flex-1 flex-col items-center justify-center px-4 py-10 sm:px-6 sm:py-14 lg:px-8">
<div className="mx-auto flex w-full max-w-5xl flex-col">
<WizardSteps current={3} />
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
원하시는 디자인 스타일을 선택해주세요
</h1>
<p className="mx-auto mt-2 max-w-lg text-xs text-muted-foreground sm:text-sm">
{config.name}에 맞춘 템플릿입니다. 색상과 서체는 에디터에서 언제든 바꿀 수 있고,
어떤 템플릿을 골라도 구조화 데이터와 필수 섹션은 동일하게 들어갑니다.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-5 lg:grid-cols-3">
{config.templates.map((template) => {
const isSelected = templateId === template.id;
return (
<button
key={template.id}
type="button"
onClick={() => chooseTemplate(template.id)}
aria-pressed={isSelected}
className={cn(
'flex cursor-pointer flex-col justify-between rounded-2xl border bg-card p-5 text-left transition-all',
isSelected
? 'border-primary ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/30',
)}
>
<div className="mb-3 flex items-start justify-between">
<div>
<h2 className="text-base font-bold">{template.name}</h2>
<span className="text-xs font-medium text-muted-foreground">
{template.toneLabel} · {template.fontStyle}
</span>
</div>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full transition-all',
isSelected
? 'bg-primary text-primary-foreground'
: 'border border-border bg-card',
)}
>
{isSelected && <Check className="size-3" strokeWidth={3} />}
</span>
</div>
<TemplatePreview template={template} />
<p className="text-xs leading-relaxed text-muted-foreground">
{template.description}
</p>
</button>
);
})}
</div>
</div>
</div>
<WizardFooter
onPrev={() => goToStep('collect')}
onNext={() => {
// ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 —
// 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다.
queueSiteTemplateSave(placeId, templateId);
startGenerating();
goToStep('generating');
}}
nextLabel="이 템플릿으로 사이트 생성하기"
/>
</div>
);
}