o2o-site-AEO/solution/frontend/src/features/onboarding/Step5Generating.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

195 lines
8.4 KiB
TypeScript

import {useCallback, useEffect} from 'react';
import {Check, Loader2} from 'lucide-react';
import {JobStatus} from '@o2o/shared';
import {delay, getAccessToken, pollJob, startCopy} from '@/api';
import {Progress} from '@/components/ui/progress';
import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {EDITOR_STEP, useWizardStep} from './wizardUrl';
const BUILD_STEPS = [
'수집된 사진 분류 및 대체 텍스트 생성',
'브랜드 컬러 시스템 및 타이포그래피 조합',
'확인된 사실만으로 소개 콘텐츠 구성',
'위치 기반 길찾기 및 주변 정보 연동',
'구조화 데이터(JSON-LD) 준비',
];
const STAGE_MS = 600;
/**
* ★ 잡은 PENDING/RUNNING/DONE 세 상태만 준다 — 단계별 progress 필드가 없다.
* 그래서 문구는 "지금 서버가 이 근처를 하고 있다"는 안내이고, 진짜 신호는 잡 상태다.
* RUNNING 동안 마지막 단계 직전까지만 올리고, 완료는 오직 DONE 이 만든다
* — 100%를 먼저 그려 놓고 기다리게 하지 않는다.
*/
const RUNNING_STAGE_CEILING = BUILD_STEPS.length - 1;
export function Step5Generating() {
const storeName = useBuilderStore((s) => s.storeName);
const stage = useBuilderStore((s) => s.generateStage);
const setGenerateStage = useBuilderStore((s) => s.setGenerateStage);
const placeId = useBuilderStore((s) => s.placeId);
const [, goToStep] = useWizardStep();
/**
* 생성이 끝나면 곧바로 에디터다.
*
* ★ 중간에 다른 화면을 끼우지 않는다. 위저드를 끝까지 걸어온 사람의 다음 용건은
* 자기 사이트를 보는 것이고, 목록을 한 번 거치게 하면 그게 한 번 더 클릭이 된다.
* 사업장은 이미 2단계에서 등록돼 목록에 들어가 있다 — '목록에 생기는 것'과
* '목록으로 보내는 것'은 다른 얘기다.
*/
const finishOnboarding = useCallback(() => {
// ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인
// 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다.
goToStep(EDITOR_STEP, {params: {flow: null}, replace: true});
}, [goToStep]);
/**
* 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다.
*
* ★ 정적 빌드(JobType.BUILD)는 여기가 아니라 발행 모달이 부른다 — 빌드는 발행 검수
* 게이트와 한 몸이고(services/build_service), 여기서 미리 구워 봐야 편집 전 스냅샷이라
* 버릴 버전만 쌓인다.
* ★ 폴백: placeId(데모 경로) 나 토큰이 없으면 백엔드를 아예 부르지 않고 타이머로 돈다.
*/
useEffect(() => {
const controller = new AbortController();
/** 타이머 시뮬레이션. 폴백 경로 전용 — 백엔드가 없어도 위저드는 끝까지 간다. */
const runSimulation = async () => {
for (let next = 1; next <= BUILD_STEPS.length; next += 1) {
await delay(STAGE_MS, controller.signal);
if (controller.signal.aborted) return;
setGenerateStage(next);
}
await delay(STAGE_MS, controller.signal);
if (!controller.signal.aborted) finishOnboarding();
};
/** RUNNING 인 동안 문구만 천천히 밀어 준다. 완료 판정은 하지 않는다. */
const creep = () =>
setGenerateStage(
Math.min(RUNNING_STAGE_CEILING, useBuilderStore.getState().generateStage + 1),
);
const runCopy = async (id: string) => {
let jobId: string;
try {
const started = await startCopy(id, {}, undefined, controller.signal);
// ★ 근거로 쓸 확인된 fact 가 0 이면 서버는 아무것도 쓰지 않는다 — LLM 은 사실을 만들지 않는다.
if (started.grounded_facts === 0) {
notify.warn(
'확인된 정보가 없어 소개문을 쓰지 않았습니다.',
'[확인] 탭에서 수집된 정보를 확정하면 그 사실만으로 소개문을 씁니다.',
);
}
if (!started.job_id) {
await runSimulation();
return;
}
jobId = started.job_id;
} catch (error) {
if (controller.signal.aborted) return;
notifyApiError(error, '소개문 생성을 시작하지 못했습니다.');
await runSimulation();
return;
}
setGenerateStage(1);
const outcome = await pollJob(jobId, {
signal: controller.signal,
onTick: (job) => {
if (job.status === JobStatus.RUNNING) creep();
},
});
if (outcome.kind === 'aborted') return;
if (outcome.kind === 'dead') {
notify.error('소개문을 만들지 못했습니다.', outcome.job.last_error ?? undefined);
} else if (outcome.kind === 'unreachable') {
notifyApiError(outcome.error, '생성 상태를 확인하지 못했습니다.');
} else if (outcome.kind === 'timeout') {
notify.warn('생성이 예상보다 오래 걸립니다.', '생성은 계속 진행됩니다.');
}
// ★ 실패해도 편집기로는 보낸다. 소개문은 없어도 확인된 fact 로 편집·발행이 되고,
// 여기서 멈추면 사장님은 되돌아갈 길이 없는 화면에 갇힌다.
setGenerateStage(BUILD_STEPS.length);
await delay(STAGE_MS, controller.signal);
if (!controller.signal.aborted) finishOnboarding();
};
if (!placeId || !getAccessToken()) void runSimulation();
else void runCopy(placeId);
return () => controller.abort();
}, [setGenerateStage, finishOnboarding, placeId]);
const progress = Math.min(100, Math.round((stage / BUILD_STEPS.length) * 100));
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/30 px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<div className="mx-auto flex w-full max-w-md flex-col items-center text-center">
<div className="mb-6 flex size-16 items-center justify-center rounded-2xl border border-border bg-card">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
<h1 className="mb-2 text-2xl font-bold tracking-tight sm:text-3xl">
</h1>
<p className="mb-8 max-w-md text-xs text-muted-foreground sm:text-sm">
<strong className="font-semibold text-foreground">{storeName}</strong>
.
</p>
<div className="w-full space-y-4 rounded-2xl border border-border bg-card p-6 text-left">
<div className="flex items-center justify-between text-xs">
<span className="font-bold">{BUILD_STEPS[Math.min(stage - 1, BUILD_STEPS.length - 1)]}</span>
<span className="font-mono font-bold">{progress}%</span>
</div>
<Progress value={progress} aria-label="사이트 생성 진행률" />
<ol className="space-y-2.5 pt-2">
{BUILD_STEPS.map((text, index) => {
const num = index + 1;
const isDone = stage > num;
const isCurrent = stage === num;
return (
<li
key={text}
className={cn(
'flex items-center gap-3 text-xs transition-colors',
isDone || isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
<span
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
isDone || isCurrent
? 'bg-primary text-primary-foreground'
: 'border border-border bg-muted',
isCurrent && 'ring-2 ring-primary/20',
)}
>
{isDone ? <Check className="size-2.5" strokeWidth={3} /> : num}
</span>
<span className="truncate">{text}</span>
</li>
);
})}
</ol>
</div>
<p className="mt-6 text-xs text-muted-foreground">
.
</p>
</div>
</div>
);
}