Compare commits
5 Commits
238d4c25c4
...
256de3f5e6
| Author | SHA1 | Date | |
|---|---|---|---|
| 256de3f5e6 | |||
| 7660c3b574 | |||
| cc4c23b325 | |||
| ee284ba7a6 | |||
| a1d8a2e943 |
@ -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 />},
|
||||
|
||||
@ -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[]}) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -101,7 +101,7 @@ export function runPublishGate(input: GateInput): GateResult {
|
||||
severity: 'block',
|
||||
title: '상호명이 비어 있습니다',
|
||||
detail: '상호명은 제목·구조화 데이터·주소 슬러그의 기준값입니다.',
|
||||
fix: '2단계에서 상호명을 입력해 주세요.',
|
||||
fix: '1단계에서 상호명을 입력해 주세요.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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 />;
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user