diff --git a/solution/frontend/src/features/onboarding/Step1Industry.tsx b/solution/frontend/src/features/onboarding/Step1Industry.tsx index e42eeaf..ce1a86a 100644 --- a/solution/frontend/src/features/onboarding/Step1Industry.tsx +++ b/solution/frontend/src/features/onboarding/Step1Industry.tsx @@ -5,7 +5,7 @@ import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; import {INDUSTRY_ICONS} from './industryIcons'; -import {useWizardStep} from './wizardUrl'; +import {STEP_NUMBER, useWizardStep} from './wizardUrl'; import {WizardFooter} from './WizardFooter'; import {WizardSteps} from './WizardSteps'; @@ -47,14 +47,15 @@ export function Step1Industry() { */ const goBack = () => { if (pendingPick) setPendingPick({...pendingPick, industry}); - goToStep('search'); + // 후보를 고른 자리로 돌아간다 — 상호 입력까지 되돌리면 방금 고른 가게가 사라진다. + goToStep('pick'); }; return (
- +

diff --git a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx index 16b1947..4a1e301 100644 --- a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx +++ b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx @@ -12,13 +12,14 @@ import type {PendingPick} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder'; import {PlaceUrlBox} from './PlaceUrlBox'; import {usePlaceSearch} from './usePlaceSearch'; -import {useWizardStep} from './wizardUrl'; +import {STEP_NUMBER, useWizardStep} from './wizardUrl'; import {WizardFooter} from './WizardFooter'; import {WizardStage, WizardWaiting} from './WizardStage'; const STAGE_TITLE: Record = { input: '가게 이름을 알려주세요', searching: '내 가게를 찾는 중입니다', + confirming: '고른 가게를 확인하는 중입니다', picking: '이 중에 사장님 가게가 있나요?', unavailable: '지도 검색을 사용할 수 없습니다', confirmed: '이 가게로 확인되었습니다', @@ -27,13 +28,14 @@ const STAGE_TITLE: Record = { const STAGE_DESCRIPTION: Record = { input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.', searching: '', + confirming: '', picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.', unavailable: '', confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.', }; /** - * 1단계 — 내 가게 확인. **위저드의 시작점이다.** + * 1·2단계 — 내 가게 찾기(상호 입력) · 가게 고르기(후보). **위저드의 시작점이다.** * * ★ 예전에는 업종 선택이 앞에 있었다. 그런데 사장님이 100% 아는 건 자기 가게 **이름**이고, * 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그래서 순서를 뒤집었다 — @@ -41,6 +43,10 @@ const STAGE_DESCRIPTION: Record = { * 못 정했을 때만 업종 화면(`?step=industry`)으로 넘긴다. * * 한 화면에 한 가지만 묻는다: 상호를 묻거나 · 기다리거나 · 후보를 고르게 하거나 · 확정을 보여준다. + * + * ★ **두 단계를 한 컴포넌트가 그린다** — `?step=search`(상호 입력)와 `?step=pick`(후보 고르기). + * 후보 목록은 훅의 state 라 컴포넌트가 갈리면 사라진다. 한 컴포넌트로 두면 두 단계를 오가도 + * 검색 결과가 살아 있고, 주소는 화면마다 달라 뒤로가기가 화면 단위로 돈다. */ export function Step2PlaceSearch() { const industry = useBuilderStore((s) => s.industry); @@ -57,10 +63,13 @@ export function Step2PlaceSearch() { // 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다). const search = usePlaceSearch(confirmedIdentity?.placeId ?? null); - const [, goToStep] = useWizardStep(); + const [step, goToStep] = useWizardStep(); const [placeUrl, setPlaceUrl] = useState(''); - const canSearch = storeName.trim().length > 0 && search.phase !== 'searching'; + // ★ usePlaceSearch.searchPublic 과 같은 조건이다 — 여기서 더 느슨하면 눌러도 검색이 안 나가고 + // 화면만 후보 단계로 넘어간다(빈 목록). + const trimmedQuery = [storeName.trim(), location.trim()].filter(Boolean).join(' '); + const canSearch = trimmedQuery.length >= 2 && search.phase !== 'searching'; /** * 확정된 사업장을 주소창에 남기며 다음 단계로. @@ -68,7 +77,7 @@ export function Step2PlaceSearch() { * ★ `?placeId=` 가 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다 * (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로 떠 버린다. * 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로 열면 깨끗하다. - * ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 1단계"인 + * ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 고르기"인 * 주소가 히스토리에 한 칸 생기고, 뒤로가기가 그 칸에 걸린다. */ const advance = (placeId: string | null) => { @@ -85,6 +94,32 @@ export function Step2PlaceSearch() { advance(identity.placeId); }; + /** + * 상호 입력으로 **돌아왔으면**(뒤로가기 포함) 앞서 받은 후보를 버린다. + * + * ★ 안 버리면 상호를 고치지 않고 다시 [찾아보기] 를 눌렀을 때 옛 목록이 한 번 스쳐 보이고, + * 무엇보다 이 화면의 용건이 "다른 가게를 찾는 것"이라 결과를 들고 있을 이유가 없다. + */ + useEffect(() => { + if (step === 'search' && search.phase !== 'idle' && search.phase !== 'searching') search.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- 단계가 바뀌는 순간에만 판단한다 + }, [step]); + + /** + * 후보 단계인데 후보가 없다 — 새로고침이거나 앞으로가기다. + * + * 상호가 남아 있으면 조용히 다시 찾고, 그것도 없으면 첫 화면으로 되돌린다. + * ★ 되돌릴 때는 replace 다 — 빈 후보 화면을 히스토리에 남기면 뒤로가기가 그 칸에 걸린다. + * ★ 업종 화면을 다녀와 확정을 이어가는 중이면 손대지 않는다(아래 resume 이 곧 다음 단계로 넘긴다). + */ + const isResuming = Boolean(pendingPick?.industry); + useEffect(() => { + if (step !== 'pick' || search.phase !== 'idle' || isResuming) return; + if (trimmedQuery.length >= 2) void search.searchPublic(storeName, location); + else goToStep('search', {replace: true}); + // eslint-disable-next-line react-hooks/exhaustive-deps -- 단계·검색 상태가 바뀔 때만 본다 + }, [step, search.phase, isResuming]); + /** * 업종 화면을 다녀온 후보를 이어서 확정한다. * @@ -122,9 +157,16 @@ export function Step2PlaceSearch() { void finishPick(pick, guessed); }; - const runSearch = () => { + /** + * 상호로 찾고, 결과가 오면 후보 단계로 올린다. + * + * ★ 검색이 **끝난 뒤에** 옮긴다. 먼저 옮기면 '찾는 중' 화면이 2번 칸에서 돌아, 아직 고를 것도 + * 없는데 진행 표시만 앞서 나간다. + */ + const runSearch = async () => { clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다 - void search.searchPublic(storeName, location); + await search.searchPublic(storeName, location); + goToStep('pick'); }; const backToInput = () => { @@ -163,22 +205,27 @@ export function Step2PlaceSearch() { }; // ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ────────────── + // ★ 큰 갈래는 주소창(step)이 정하고, 그 안의 대기 상태만 훅이 정한다. const stage = confirmedIdentity ? 'confirmed' : search.phase === 'searching' ? 'searching' - : search.phase === 'unavailable' - ? 'unavailable' - : search.phase === 'done' - ? 'picking' - : 'input'; + : // 업종을 고르고 돌아온 직후 — 이 컴포넌트는 다시 마운트돼 후보가 비어 있다. + // 그대로 목록을 그리면 '못 찾았습니다' 가 한 번 번쩍인다. + isResuming && search.phase === 'idle' + ? 'confirming' + : step !== 'pick' + ? 'input' + : search.phase === 'unavailable' + ? 'unavailable' + : 'picking'; const searchQuery = [storeName, location].filter(Boolean).join(' '); return (
setStoreName(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && canSearch) runSearch(); + if (e.key === 'Enter' && canSearch) void runSearch(); }} placeholder="가게 이름을 입력하세요" autoFocus @@ -210,7 +257,7 @@ export function Step2PlaceSearch() { value={location} onChange={(e) => setLocation(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && canSearch) runSearch(); + if (e.key === 'Enter' && canSearch) void runSearch(); }} placeholder="예: 제주시 애월읍" /> @@ -223,7 +270,7 @@ export function Step2PlaceSearch() { variant="outline" size="lg" className="w-full" - onClick={runSearch} + onClick={() => void runSearch()} disabled={!canSearch} > @@ -239,6 +286,13 @@ export function Step2PlaceSearch() { /> )} + {stage === 'confirming' && ( + + )} + {stage === 'picking' && (

- diff --git a/solution/frontend/src/features/onboarding/Step3DataReview.tsx b/solution/frontend/src/features/onboarding/Step3DataReview.tsx index cd2c699..6f965c7 100644 --- a/solution/frontend/src/features/onboarding/Step3DataReview.tsx +++ b/solution/frontend/src/features/onboarding/Step3DataReview.tsx @@ -10,7 +10,7 @@ import {useBuilderStore, useUnverifiedFields} from '@/stores/builder'; import {ChannelConfirmPanel} from './ChannelConfirmPanel'; import {ChannelUrlInput} from './ChannelUrlInput'; import {useCollectFlow} from './useCollectFlow'; -import {useWizardStep} from './wizardUrl'; +import {STEP_NUMBER, useWizardStep} from './wizardUrl'; import {WizardFooter} from './WizardFooter'; import {WizardStage, WizardWaiting} from './WizardStage'; @@ -92,7 +92,7 @@ export function Step3DataReview() { return (

- +

diff --git a/solution/frontend/src/features/onboarding/WizardSteps.tsx b/solution/frontend/src/features/onboarding/WizardSteps.tsx index 6fe4b1c..07297c6 100644 --- a/solution/frontend/src/features/onboarding/WizardSteps.tsx +++ b/solution/frontend/src/features/onboarding/WizardSteps.tsx @@ -4,17 +4,17 @@ import {cn} from '@/lib/utils'; /** * 위저드 진행 표시. * - * 1(신원 확인)과 2(사실 확인)가 왜 따로인지 사장님이 한눈에 알게 하는 것이 이 줄의 목적이다 — - * "가게가 맞나" 를 먼저 끝내고 "값이 맞나" 로 넘어간다. + * "가게가 맞나"(1·2) 를 먼저 끝내고 "값이 맞나"(3) 로 넘어간다는 것을 한눈에 보이게 하는 줄이다. * - * ★ 업종은 더 이상 단계가 아니다. 검색 결과의 분류가 정하고(못 정할 때만 1단계 안에서 묻는다), - * 그래서 네 칸이던 줄이 세 칸이 됐다 — 사장님이 처음 보는 화면이 "우리는 카페인가 음식점인가" - * 가 아니라 "가게 이름"이 되게 하는 것이 이 변경의 목적이다. + * ★ 찾기와 고르기는 **따로 센다.** 한 칸에 화면 둘을 담으면 후보 목록에서 뒤로가기를 눌러도 + * 번호가 그대로라 사장님은 앞 화면이 되돌아온 것으로 읽는다. 화면이 바뀌면 칸도 바뀐다. + * ★ 업종은 칸이 아니다 — 검색 결과의 분류가 정하고, 못 정할 때만 2단계 안에서 묻는다. */ const STEPS = [ - {step: 1, label: '내 가게 확인'}, - {step: 2, label: '수집 정보 확인'}, - {step: 3, label: '템플릿'}, + {step: 1, label: '내 가게 찾기'}, + {step: 2, label: '가게 고르기'}, + {step: 3, label: '수집 정보 확인'}, + {step: 4, label: '템플릿'}, ] as const; export function WizardSteps({current}: {current: number}) { diff --git a/solution/frontend/src/features/onboarding/wizardUrl.ts b/solution/frontend/src/features/onboarding/wizardUrl.ts index 5190181..d412783 100644 --- a/solution/frontend/src/features/onboarding/wizardUrl.ts +++ b/solution/frontend/src/features/onboarding/wizardUrl.ts @@ -12,6 +12,14 @@ import {useSearchParams} from 'react-router'; export const WIZARD_STEPS = [ /** 상호명으로 내 가게 찾기 — 위저드의 시작점이다. */ 'search', + /** + * 찾은 후보 중에서 내 가게 고르기. + * + * ★ 예전엔 이 화면이 `search` 안의 상태였다(컴포넌트 state). 주소가 안 바뀌니 후보 목록에서 + * 뒤로가기를 누르면 이전 단계가 아니라 **위저드 밖으로** 나갔다 — 한 칸에 화면이 둘이라 + * 진행 표시도 그대로였다. 화면이 바뀌면 주소도 바뀐다. + */ + 'pick', /** 업종 직접 고르기. 검색 결과가 업종을 못 정했을 때, 또는 [바꾸기] 로 들어온다. */ 'industry', 'collect', @@ -28,16 +36,17 @@ export const EDITOR_STEP: WizardStep = 'editor'; /** * 진행 표시(WizardSteps)에 찍히는 번호. * - * ★ 업종 화면은 '내 가게 확인' 안의 **갈래**라 같은 1번이다. 업종은 검색 결과가 정하고 - * 못 정했을 때만 사람에게 묻는 것이라, 별도 단계로 세면 사장님은 안 겪을 수도 있는 단계를 - * 진행 표시에서 계속 보게 된다. + * ★ 업종 화면은 '가게 고르기' 안의 **갈래**라 같은 2번이다. 후보를 고른 직후 업종을 못 정했을 + * 때만 들르는 곳이고, 별도 칸으로 세우면 사장님 대부분이 안 겪는 단계를 계속 보게 된다. + * (한 칸에 화면이 둘이어도 되는 건 **둘 다 주소를 가져서**다 — 뒤로가기가 화면 단위로 돈다.) * ★ 생성 화면은 진행 표시를 그리지 않는다(0 은 "표시할 번호가 없다"는 뜻이다). */ export const STEP_NUMBER: Record = { search: 1, - industry: 1, - collect: 2, - template: 3, + pick: 2, + industry: 2, + collect: 3, + template: 4, generating: 0, editor: 0, }; diff --git a/solution/frontend/src/features/publish/publishGate.ts b/solution/frontend/src/features/publish/publishGate.ts index 4a70569..b5a4dff 100644 --- a/solution/frontend/src/features/publish/publishGate.ts +++ b/solution/frontend/src/features/publish/publishGate.ts @@ -101,7 +101,7 @@ export function runPublishGate(input: GateInput): GateResult { severity: 'block', title: '상호명이 비어 있습니다', detail: '상호명은 제목·구조화 데이터·주소 슬러그의 기준값입니다.', - fix: '2단계에서 상호명을 입력해 주세요.', + fix: '1단계에서 상호명을 입력해 주세요.', }); } diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index bedb166..b2d0719 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -93,7 +93,7 @@ export function BuilderPage() { }, [isNew, seedQuery, seedIndustry, reset, selectIndustry, setStoreName, setSearchParams]); /** - * 위저드 1단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다. + * 위저드 2단계(가게 고르기)에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다. * * ★ 이게 없으면 위저드를 끝까지 걸어온 사장님이 에디터에서 **업종 예시값**을 본다 — * 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다. @@ -267,7 +267,8 @@ export function BuilderPage() {

- {step === 'search' && } + {/* 상호 입력과 후보 고르기는 한 컴포넌트다 — 후보 목록이 훅 state 라 갈리면 사라진다. */} + {(step === 'search' || step === 'pick') && } {step === 'industry' && } {step === 'collect' && } {step === 'template' && }