Compare commits

...

5 Commits

Author SHA1 Message Date
256de3f5e6 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
2026-09-04 16:56:09 +09:00
7660c3b574 [fix] solution/frontend: 로그인·가입 직후엔 내 사이트로 — '/' 는 이제 랜딩이다
'/' 를 로그인 여부와 무관하게 랜딩으로 바꾸면서, 로그인 기본 도착지가 '/' 인 채로 남아
방금 로그인한 사장님이 판매 화면을 보게 됐다.

- LoginPage: 기본 도착지를 selfServe 면 /sites. 내부 운영 앱은 '/' 가 자기 홈으로 가므로 그대로
- SignupPage: 가입 직후도 /sites
2026-09-04 16:54:41 +09:00
cc4c23b325 [fix] solution/frontend: 헤더의 [무료로 만들기] 제거 — 히어로 입력 카드가 이미 그 자리다 2026-09-04 16:49:20 +09:00
ee284ba7a6 [fix] solution/frontend: 로그인하면 랜딩·요금에 갈 길이 없던 것
/ 가 로그인 상태면 /sites 로 튕겼다. 그래서 로그인한 사장님은 랜딩도 요금도 볼 수 없었다 —
로고를 눌러도 목록으로 되돌아온다. 앱 사이드바에도 요금 링크가 한 곳도 없었다.

- / 는 로그인 여부와 무관하게 랜딩이다. 로그인 상태는 헤더가 말한다([로그인] 대신 [내 사이트])
- AppShell 사이드바에 [요금] 추가
2026-09-04 16:48:03 +09:00
a1d8a2e943 [fix] solution/frontend: 후보 고르기를 별도 단계로 — 한 칸에 화면 둘이라 뒤로가기가 밖으로 나갔다
1번 칸이 화면 두 개를 담고 있었다: 상호 입력과 후보 목록. 후보 목록은 컴포넌트 state 라
주소가 안 바뀌고, 그래서 목록에서 뒤로가기를 누르면 이전 화면이 아니라 **위저드 밖**으로
나갔다(진입 직전 화면 = /sites). 진행 표시도 그대로 1이라 앞 화면이 되돌아온 것처럼 보인다.

- wizardUrl: `pick` 단계 추가. 진행 번호는 1 찾기 · 2 고르기 · 3 수집 · 4 템플릿.
  업종은 여전히 칸이 아니다(2번의 갈래) — 한 칸에 화면이 둘이어도 되는 건 **둘 다 주소를
  가져서**다. 대부분의 사장님은 안 겪는 화면이라 칸으로 세우지 않는다
- Step2PlaceSearch 가 두 단계를 함께 그린다. 후보 목록이 훅 state 라 컴포넌트를 가르면
  단계를 오갈 때마다 사라진다
- 상호 입력으로 돌아오면 후보를 버리고, 후보 단계로 곧장 들어왔는데 후보가 없으면
  상호로 다시 찾는다. 상호도 없으면 첫 화면으로 replace — 빈 화면을 히스토리에 남기지 않는다
- 업종 화면은 `pick` 으로 돌아온다(상호 입력까지 되돌리면 방금 고른 가게가 사라진다).
  돌아온 직후의 빈 목록은 '확정하는 중' 대기 화면으로 덮는다
- 진행 번호는 각 화면이 아니라 wizardUrl(STEP_NUMBER)이 소유한다 — 칸이 늘고 줄 때마다
  화면 네 곳을 같이 고치지 않게
- publishGate: '2단계에서 상호명을' → 1단계

검증 — tsc·eslint·vite build 통과. 브라우저에서 확인: 검색 → `?step=pick`,
뒤로가기가 상호 입력으로(입력값 유지), 앞으로가기는 재검색해 목록 복원,
`?step=pick` 직접 진입은 `?step=search` 로 되돌림, 확정하면 3단계로 진행
2026-09-04 09:28:54 +09:00
13 changed files with 133 additions and 91 deletions

View File

@ -1,5 +1,4 @@
import {createBrowserRouter, Navigate} from 'react-router';
import {Loader2} from 'lucide-react';
import {createBrowserRouter} from 'react-router';
import {AccountPage} from '@/pages/AccountPage';
import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage';
@ -11,38 +10,18 @@ import {ShowcasePage} from '@/pages/ShowcasePage';
import {SignupPage} from '@/pages/SignupPage';
import {SitesPage} from '@/pages/SitesPage';
import {RequireAuth} from '@/components/layout/RequireAuth';
import {useAuthStore} from '@/stores/auth';
/**
* 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라
* "내 것 고치기"다. 비로그인은 **랜딩**을 본다.
*
* ★ 예전엔 비로그인을 곧장 위저드로 보냈다. 그러면 이 제품이 무엇을 파는 물건인지 말할 자리가
* 한 곳도 없다 — 처음 온 사람이 업종 선택 화면부터 만난다.
* ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 화면이 한 번 번쩍이고 목록으로 튄다.
*/
function Home() {
const isRestoring = useAuthStore((s) => s.isRestoring);
const user = useAuthStore((s) => s.user);
if (isRestoring) {
return (
<div className="flex h-screen items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
);
}
if (user) return <Navigate to="/sites" replace />;
return <LandingPage />;
}
export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
{path: '/', element: <Home />},
/**
* ★ 로그인해도 랜딩이다. 예전엔 로그인 상태면 /sites 로 튕겼는데, 그러면 로그인한 사장님은
* 랜딩·요금을 **볼 방법이 아예 없다** — 로고를 눌러도 목록으로 되돌아온다.
* 로그인 여부는 헤더가 말한다(MarketingShell 이 [로그인] 대신 [내 사이트] 를 그린다).
*/
{path: '/', element: <LandingPage />},
// 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
{path: '/pricing', element: <PricingPage />},

View File

@ -1,6 +1,6 @@
import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router';
import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react';
import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react';
import {cn} from '@/lib/utils';
import {userLabel, useAuthStore} from '@/stores/auth';
@ -25,6 +25,8 @@ export type NavItem = {
const OWNER_NAV: NavItem[] = [
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
// ★ 앱 안에서 요금으로 갈 길이 한 곳도 없었다 — 로그인하면 랜딩 헤더를 못 보기 때문이다.
{to: '/pricing', match: '/pricing', label: '요금', icon: Receipt},
];
export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) {

View File

@ -55,21 +55,14 @@ export function MarketingShell({children}: {children: ReactNode}) {
내 사이트
</Link>
) : (
<>
{/* 아임웹처럼 둘 다 버튼이다 — 로그인만 맨 텍스트면 눌리는 것으로 안 보인다. */}
<Link
to="/login"
className="rounded-md border border-border px-4 py-2 text-[13px] font-medium transition-colors hover:bg-muted"
>
로그인
</Link>
<Link
to="/builder?new=1"
className="rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
무료로 만들기
</Link>
</>
// ★ 헤더에 [무료로 만들기] 를 두지 않는다 — 히어로 입력 카드가 이미 그 자리다.
// 같은 행동을 한 화면에서 두 번 부르면 어느 쪽이 진짜인지 흐려진다.
<Link
to="/login"
className="rounded-md border border-border px-4 py-2 text-[13px] font-medium transition-colors hover:bg-muted"
>
로그인
</Link>
)}
</div>
</div>

View File

@ -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 (
<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-16 lg:px-8">
<div className="mx-auto flex w-full max-w-5xl flex-col">
<WizardSteps current={1} />
<WizardSteps current={STEP_NUMBER.industry} />
<div className="mb-10 text-center sm:mb-12">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">

View File

@ -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<string, string> = {
input: '가게 이름을 알려주세요',
searching: '내 가게를 찾는 중입니다',
confirming: '고른 가게를 확인하는 중입니다',
picking: '이 중에 사장님 가게가 있나요?',
unavailable: '지도 검색을 사용할 수 없습니다',
confirmed: '이 가게로 확인되었습니다',
@ -27,13 +28,14 @@ const STAGE_TITLE: Record<string, string> = {
const STAGE_DESCRIPTION: Record<string, string> = {
input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.',
searching: '',
confirming: '',
picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.',
unavailable: '',
confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.',
};
/**
* 1단계 — 내 가게 확인. **위저드의 시작점이다.**
* 1·2단계 — 내 가게 찾기(상호 입력) · 가게 고르기(후보). **위저드의 시작점이다.**
*
* ★ 예전에는 업종 선택이 앞에 있었다. 그런데 사장님이 100% 아는 건 자기 가게 **이름**이고,
* 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그래서 순서를 뒤집었다 —
@ -41,6 +43,10 @@ const STAGE_DESCRIPTION: Record<string, string> = {
* 못 정했을 때만 업종 화면(`?step=industry`)으로 넘긴다.
*
* 한 화면에 한 가지만 묻는다: 상호를 묻거나 · 기다리거나 · 후보를 고르게 하거나 · 확정을 보여준다.
*
* ★ **두 단계를 한 컴포넌트가 그린다** — `?step=search`(상호 입력)와 `?step=pick`(후보 고르기).
* 후보 목록은 훅의 state 라 컴포넌트가 갈리면 사라진다. 한 컴포넌트로 두면 두 단계를 오가도
* 검색 결과가 살아 있고, 주소는 화면마다 달라 뒤로가기가 화면 단위로 돈다.
*/
export function Step2PlaceSearch() {
const industry = useBuilderStore((s) => s.industry);
@ -57,7 +63,7 @@ export function Step2PlaceSearch() {
// 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다).
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [, goToStep] = useWizardStep();
const [step, goToStep] = useWizardStep();
const [placeUrl, setPlaceUrl] = useState('');
/** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 —
* 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가
@ -69,7 +75,10 @@ export function Step2PlaceSearch() {
const justConfirmed = useRef(false);
const foundCount = search.items.filter((i) => i.naver_place_url).length;
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';
/**
* 확정된 사업장을 주소창에 남기며 다음 단계로.
@ -77,7 +86,7 @@ export function Step2PlaceSearch() {
* ★ `?placeId=` 가 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다
* (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로 떠 버린다.
* 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로 열면 깨끗하다.
* ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 1단계"인
* ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 고르기"인
* 주소가 히스토리에 한 칸 생기고, 뒤로가기가 그 칸에 걸린다.
*/
const advance = (placeId: string | null) => {
@ -97,6 +106,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]);
/**
* 업종 화면을 다녀온 후보를 이어서 확정한다.
*
@ -137,9 +172,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 = () => {
@ -179,16 +221,20 @@ 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';
/**
* 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.**
@ -220,7 +266,7 @@ export function Step2PlaceSearch() {
return (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage
current={1}
current={STEP_NUMBER[step]}
wide={stage === 'picking'}
title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]}
@ -236,7 +282,7 @@ export function Step2PlaceSearch() {
value={storeName}
onChange={(e) => setStoreName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSearch) runSearch();
if (e.key === 'Enter' && canSearch) void runSearch();
}}
placeholder="가게 이름을 입력하세요"
autoFocus
@ -252,7 +298,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="예: 제주시 애월읍"
/>
@ -265,7 +311,7 @@ export function Step2PlaceSearch() {
variant="outline"
size="lg"
className="w-full"
onClick={runSearch}
onClick={() => void runSearch()}
disabled={!canSearch}
>
<Search />
@ -281,6 +327,13 @@ export function Step2PlaceSearch() {
/>
)}
{stage === 'confirming' && (
<WizardWaiting
title="고른 가게로 확정하는 중입니다"
description="업종까지 정해졌습니다 — 잠시만 기다려 주세요"
/>
)}
{stage === 'picking' && (
<div className="space-y-3">
<p
@ -413,7 +466,7 @@ export function Step2PlaceSearch() {
searchQuery={searchQuery}
/>
<Button variant="outline" className="w-full" onClick={runSearch}>
<Button variant="outline" className="w-full" onClick={() => void runSearch()}>
<Search />
<span>상호로 다시 검색하기</span>
</Button>

View File

@ -15,7 +15,7 @@ import {ChannelConfirmPanel} from './ChannelConfirmPanel';
import {ensureServerPlace} from './ensureServerPlace';
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';
@ -199,7 +199,7 @@ export function Step3DataReview() {
return (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage
current={2}
current={STEP_NUMBER.collect}
wide={stage === 'review' || stage === 'channels'}
title={stage === 'ready' && pendingUrl ? '네이버 플레이스를 찾았습니다' : STAGE_TITLE[stage]}
description={

View File

@ -4,7 +4,7 @@ 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 {STEP_NUMBER, useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps';
@ -106,7 +106,7 @@ export function Step4Template() {
<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} />
<WizardSteps current={STEP_NUMBER.template} />
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">

View File

@ -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}) {

View File

@ -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<WizardStep, number> = {
search: 1,
industry: 1,
collect: 2,
template: 3,
pick: 2,
industry: 2,
collect: 3,
template: 4,
generating: 0,
editor: 0,
};

View File

@ -101,7 +101,7 @@ export function runPublishGate(input: GateInput): GateResult {
severity: 'block',
title: '상호명이 비어 있습니다',
detail: '상호명은 제목·구조화 데이터·주소 슬러그의 기준값입니다.',
fix: '2단계에서 상호명을 입력해 주세요.',
fix: '1단계에서 상호명을 입력해 주세요.',
});
}

View File

@ -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() {
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{step === 'search' && <Step2PlaceSearch />}
{/* 상호 입력과 후보 고르기는 한 컴포넌트다 — 후보 목록이 훅 state 라 갈리면 사라진다. */}
{(step === 'search' || step === 'pick') && <Step2PlaceSearch />}
{step === 'industry' && <Step1Industry />}
{step === 'collect' && <Step3DataReview />}
{step === 'template' && <Step4Template />}

View File

@ -28,9 +28,11 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
// 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다.
const from = (location.state as {from?: string} | null)?.from ?? '/';
// ★ 로그인을 마친 사람의 용건은 "내 것"이다 — 사장님 앱은 /sites 로 보낸다.
// '/' 로 보내면 방금 로그인해 놓고 랜딩(판매 화면)을 보게 된다.
// 내부 운영 앱은 '/' 가 자기 홈으로 리다이렉트하므로 그대로 둔다.
const home = selfServe ? '/sites' : '/';
const from = (location.state as {from?: string} | null)?.from ?? home;
if (user) return <Navigate to={from} replace />;

View File

@ -76,7 +76,8 @@ export function SignupPage() {
return;
}
notify.success('가입이 완료되었습니다.');
navigate('/', {replace: true});
// 가입 직후 갈 곳도 내 것이다 — '/' 는 판매 화면(랜딩)이다.
navigate('/sites', {replace: true});
} catch (error) {
notifyApiError(error, '가입하지 못했습니다.');
} finally {
@ -97,7 +98,8 @@ export function SignupPage() {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
navigate('/', {replace: true});
// 가입 직후 갈 곳도 내 것이다 — '/' 는 판매 화면(랜딩)이다.
navigate('/sites', {replace: true});
} catch (error) {
notifyApiError(error, '구글로 가입하지 못했습니다.');
} finally {