[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 통과
This commit is contained in:
Mina Choi 2026-09-03 10:20:57 +09:00
parent 41e49c693b
commit a61f9724ea
34 changed files with 1137 additions and 443 deletions

View File

@ -56,6 +56,7 @@ export * from './listLinksParams';
export * from './listMediaParams';
export * from './listMySitesParams';
export * from './listPlacesParams';
export * from './listShowcaseParams';
export * from './localContentData';
export * from './localContentDataBody';
export * from './localContentDataCollectedAt';
@ -110,6 +111,10 @@ export * from './placeDataPhone';
export * from './placeDataRegionCode';
export * from './placeDataRoadAddress';
export * from './placeDataVerifiedAt';
export * from './placeSearchItem';
export * from './placeSearchItemCategory';
export * from './placeSearchItemCategoryName';
export * from './placeSearchItemRoadAddress';
export * from './placeStatus';
export * from './publishAction';
export * from './publishLogData';
@ -231,6 +236,9 @@ export * from './resPlaceList';
export * from './resPlaceListMsg';
export * from './resPlaceMsg';
export * from './resPlacePlace';
export * from './resPlaceSearch';
export * from './resPlaceSearchMsg';
export * from './resPlaceSearchSource';
export * from './resPublishLogs';
export * from './resPublishLogsMsg';
export * from './resRefreshToken';
@ -239,6 +247,8 @@ export * from './resSeoAudit';
export * from './resSeoAuditMsg';
export * from './resSeoAuditSummary';
export * from './resSeoAuditVisibility';
export * from './resShowcase';
export * from './resShowcaseMsg';
export * from './resSite';
export * from './resSiteCurrentVersion';
export * from './resSiteMsg';
@ -284,6 +294,10 @@ export * from './resVerifyCandidatesSource';
export * from './resWeather';
export * from './resWeatherMsg';
export * from './resWeatherWeather';
export * from './searchPlacesPublicParams';
export * from './showcaseItem';
export * from './showcaseItemRegion';
export * from './showcaseItemThumbnailUrl';
export * from './siteData';
export * from './siteDataCurrentVersionId';
export * from './siteDataDomain';
@ -291,6 +305,7 @@ export * from './siteDataPublishedAt';
export * from './siteDataTemplateId';
export * from './siteDataTheme';
export * from './siteDataThemeAnyOf';
export * from './siteDataThumbnailUrl';
export * from './siteStatus';
export * from './siteVersionData';
export * from './siteVersionDataBuildError';

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListShowcaseParams = {
/**
*
* @minimum 1
* @maximum 48
*/
limit?: number;
};

View File

@ -0,0 +1,24 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceSearchItemRoadAddress } from './placeSearchItemRoadAddress';
import type { PlaceSearchItemCategoryName } from './placeSearchItemCategoryName';
import type { PlaceSearchItemCategory } from './placeSearchItemCategory';
/**
* 1.
DB . DB (place_id·company_id·)
.
· . '어느 가게인지 고르게 하는 것',
(POST /place verify) .
*/
export interface PlaceSearchItem {
name?: string;
road_address?: PlaceSearchItemRoadAddress;
category_name?: PlaceSearchItemCategoryName;
category?: PlaceSearchItemCategory;
}

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceCategory } from './placeCategory';
export type PlaceSearchItemCategory = PlaceCategory | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PlaceSearchItemCategoryName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PlaceSearchItemRoadAddress = string | null;

View File

@ -0,0 +1,23 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResPlaceSearchMsg } from './resPlaceSearchMsg';
import type { ResPlaceSearchSource } from './resPlaceSearchSource';
import type { PlaceSearchItem } from './placeSearchItem';
/**
* .
.
( ) .
*/
export interface ResPlaceSearch {
result?: ErrorInfo;
msg?: ResPlaceSearchMsg;
source?: ResPlaceSearchSource;
items?: PlaceSearchItem[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResPlaceSearchMsg = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ExternalPlaceSource } from './externalPlaceSource';
export type ResPlaceSearchSource = ExternalPlaceSource | null;

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResShowcaseMsg } from './resShowcaseMsg';
import type { ShowcaseItem } from './showcaseItem';
export interface ResShowcase {
result?: ErrorInfo;
msg?: ResShowcaseMsg;
items?: ShowcaseItem[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResShowcaseMsg = string | null;

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type SearchPlacesPublicParams = {
/**
*
* @minLength 2
* @maxLength 100
*/
q: string;
};

View File

@ -0,0 +1,24 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceCategory } from './placeCategory';
import type { ShowcaseItemRegion } from './showcaseItemRegion';
import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl';
/**
* . ** .**
.
place_id·company_id··
. ·· .
*/
export interface ShowcaseItem {
name: string;
category: PlaceCategory;
region?: ShowcaseItemRegion;
url: string;
thumbnail_url?: ShowcaseItemThumbnailUrl;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ShowcaseItemRegion = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ShowcaseItemThumbnailUrl = string | null;

View File

@ -10,6 +10,7 @@ import type { SiteDataTemplateId } from './siteDataTemplateId';
import type { SiteDataTheme } from './siteDataTheme';
import type { SiteDataCurrentVersionId } from './siteDataCurrentVersionId';
import type { SiteDataPublishedAt } from './siteDataPublishedAt';
import type { SiteDataThumbnailUrl } from './siteDataThumbnailUrl';
export interface SiteData {
site_id: string;
@ -20,4 +21,5 @@ export interface SiteData {
theme?: SiteDataTheme;
current_version_id?: SiteDataCurrentVersionId;
published_at?: SiteDataPublishedAt;
thumbnail_url?: SiteDataThumbnailUrl;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type SiteDataThumbnailUrl = string | null;

View File

@ -41,12 +41,14 @@ import type {
ResLinkList,
ResPlace,
ResPlaceList,
ResPlaceSearch,
ResStartCollect,
ResStartCopy,
ResStartVision,
ResUnit,
ResUnitList,
ResVerifyCandidates,
SearchPlacesPublicParams,
VerifyCandidatesParams
} from '.././model';
@ -150,6 +152,100 @@ export function useListPlaces<TData = Awaited<ReturnType<typeof listPlaces>>, TE
/**
* DB( ) . . , DB . category ** **. None , .
* @summary
*/
export const searchPlacesPublic = (
params: SearchPlacesPublicParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResPlaceSearch>(
{url: `/v1/place/search`, method: 'GET',
params, signal
},
options);
}
export const getSearchPlacesPublicQueryKey = (params?: SearchPlacesPublicParams,) => {
return [
`/v1/place/search`, ...(params ? [params]: [])
] as const;
}
export const getSearchPlacesPublicQueryOptions = <TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getSearchPlacesPublicQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof searchPlacesPublic>>> = ({ signal }) => searchPlacesPublic(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type SearchPlacesPublicQueryResult = NonNullable<Awaited<ReturnType<typeof searchPlacesPublic>>>
export type SearchPlacesPublicQueryError = void | HTTPValidationError
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
params: SearchPlacesPublicParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof searchPlacesPublic>>,
TError,
Awaited<ReturnType<typeof searchPlacesPublic>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof searchPlacesPublic>>,
TError,
Awaited<ReturnType<typeof searchPlacesPublic>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary
*/
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getSearchPlacesPublicQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* . · (verify) .
* @summary

View File

@ -0,0 +1,128 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import {
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
HTTPValidationError,
ListShowcaseParams,
ResShowcase
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* . ··(··)· · ·· . , .
* @summary ()
*/
export const listShowcase = (
params?: ListShowcaseParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResShowcase>(
{url: `/v1/showcase`, method: 'GET',
params, signal
},
options);
}
export const getListShowcaseQueryKey = (params?: ListShowcaseParams,) => {
return [
`/v1/showcase`, ...(params ? [params]: [])
] as const;
}
export const getListShowcaseQueryOptions = <TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListShowcaseQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listShowcase>>> = ({ signal }) => listShowcase(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListShowcaseQueryResult = NonNullable<Awaited<ReturnType<typeof listShowcase>>>
export type ListShowcaseQueryError = void | HTTPValidationError
export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(
params: undefined | ListShowcaseParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listShowcase>>,
TError,
Awaited<ReturnType<typeof listShowcase>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(
params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listShowcase>>,
TError,
Awaited<ReturnType<typeof listShowcase>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(
params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary ()
*/
export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(
params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListShowcaseQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -13,7 +13,6 @@ export function EditorHeader() {
const isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
const togglePreview = useBuilderStore((s) => s.togglePreview);
const openPublishModal = useBuilderStore((s) => s.openPublishModal);
const reset = useBuilderStore((s) => s.reset);
const placeId = useBuilderStore((s) => s.placeId);
const unverifiedCount = useUnverifiedFields().length;
@ -37,20 +36,19 @@ export function EditorHeader() {
404 , .
reset() 그대로다: 편집하던
.
( "내 사이트 관리" ARCHITECTURE.md 4) */}
( "내 사이트 관리" ARCHITECTURE.md 4)
`?new=1`
, ( ). */}
{placeId ? (
<span className="group flex items-center gap-2">
// 편집 중이어도 로고는 홈으로 간다 — 눌리지 않는 로고는 고장으로 읽힌다.
// 입력값은 서버에 저장되므로 나갔다 들어와도 그대로다.
<Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
<LogoMark />
</span>
</Link>
) : (
<button
type="button"
onClick={reset}
title="처음으로 이동"
className="group flex cursor-pointer items-center gap-2"
>
<Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
<LogoMark />
</button>
</Link>
)}
<span className="hidden h-3.5 w-px bg-border sm:block" />

View File

@ -1,18 +1,54 @@
import {useState} from 'react';
import {Check} from 'lucide-react';
import type {IndustryType} from '@o2o/shared';
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 {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps';
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
/**
* `?step=industry`.
*
* .
* (services/place_category.py), ** ** .
* 1 '내 가게 확인' .
*
* .
* ,
* .
*/
export function Step1Industry() {
const industry = useBuilderStore((s) => s.industry);
const pendingPick = useBuilderStore((s) => s.pendingPick);
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
const goToStep = useBuilderStore((s) => s.goToStep);
const setPendingPick = useBuilderStore((s) => s.setPendingPick);
const [, goToStep] = useWizardStep();
/** 검색이 업종을 못 정해 넘어온 경우 — 고르기 전에는 진행시키지 않는다. */
const mustChoose = Boolean(pendingPick && !pendingPick.industry);
const [touched, setTouched] = useState(false);
const showSelection = touched || !mustChoose;
const pick = (id: IndustryType) => {
setTouched(true);
selectIndustry(id);
};
/**
* .
*
* ( )
* . .
*/
const goBack = () => {
if (pendingPick) setPendingPick({...pendingPick, industry});
goToStep('search');
};
return (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
@ -22,10 +58,12 @@ export function Step1Industry() {
<div className="mb-10 text-center sm:mb-12">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
?
{mustChoose ? '이 가게는 어떤 업종인가요?' : '업종을 바꿀까요?'}
</h1>
<p className="mx-auto mt-2.5 max-w-lg text-sm text-muted-foreground sm:text-base">
, .
{mustChoose
? '지도 분류만으로는 업종을 정하지 못했습니다. 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'
: '업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'}
</p>
</div>
@ -33,13 +71,13 @@ export function Step1Industry() {
{INDUSTRY_ORDER.map((id) => {
const config = INDUSTRY_CONFIGS[id];
const Icon = INDUSTRY_ICONS[id];
const isSelected = industry === id;
const isSelected = showSelection && industry === id;
return (
<button
key={id}
type="button"
onClick={() => selectIndustry(id)}
onClick={() => pick(id)}
aria-pressed={isSelected}
className={cn(
'flex cursor-pointer flex-col justify-between rounded-2xl border bg-card p-6 text-left transition-all',
@ -90,16 +128,22 @@ export function Step1Industry() {
<WizardFooter
hint={
<>
:{' '}
<strong className="font-semibold text-foreground">
{INDUSTRY_CONFIGS[industry].name}
</strong>{' '}
({INDUSTRY_CONFIGS[industry].subName})
</>
showSelection ? (
<>
:{' '}
<strong className="font-semibold text-foreground">
{INDUSTRY_CONFIGS[industry].name}
</strong>{' '}
({INDUSTRY_CONFIGS[industry].subName})
</>
) : (
'업종을 하나 골라 주세요.'
)
}
onNext={() => goToStep(2)}
nextLabel="다음: 내 가게 찾기"
onPrev={() => goToStep('search')}
onNext={goBack}
nextDisabled={!showSelection}
nextLabel={pendingPick ? '이 업종으로 계속하기' : '내 가게 찾기로 돌아가기'}
/>
</div>
);

View File

@ -1,19 +1,23 @@
import {useState} from 'react';
import {useSearchParams} from 'react-router';
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react';
import {getAccessToken, type PlaceCandidate} from '@/api';
import {useEffect, useRef, useState} from 'react';
import {ArrowRight, Check, MapPin, Search, TriangleAlert} from 'lucide-react';
import type {IndustryType} from '@o2o/shared';
import type {PlaceSearchItem} from '@/api';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {CATEGORY_TO_INDUSTRY} from '@/features/builder/placeAdapter';
import {cn} from '@/lib/utils';
import type {PendingPick} from '@/stores/builder';
import {useBuilderStore} from '@/stores/builder';
import {PlaceUrlBox} from './PlaceUrlBox';
import {usePlaceSearch} from './usePlaceSearch';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter';
import {WizardStage, WizardWaiting} from './WizardStage';
const STAGE_TITLE: Record<string, string> = {
input: '상호명을 검색해 내 가게를 찾아주세요',
input: '가게 이름을 알려주세요',
searching: '내 가게를 찾는 중입니다',
picking: '이 중에 사장님 가게가 있나요?',
unavailable: '지도 검색을 사용할 수 없습니다',
@ -21,22 +25,20 @@ const STAGE_TITLE: Record<string, string> = {
};
const STAGE_DESCRIPTION: Record<string, string> = {
input: '지도·플레이스에 등록된 가게 중에서 사장님이 직접 고른 한 곳만 기준이 됩니다.',
input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.',
searching: '',
picking: '고른 가게의 상호·주소·전화가 이 사이트의 기준 정보가 됩니다.',
picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.',
unavailable: '',
confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.',
};
/** outcome 코드 → 사장님이 읽을 한 줄. 서버 판정은 문구를 고르는 데만 쓴다. */
const OUTCOME_TEXT: Record<string, string> = {
matched: '이 가게로 보입니다. 맞는지 한 번만 확인해 주세요.',
ambiguous: '비슷한 이름의 가게가 여럿입니다. 어느 쪽이 사장님 가게인가요?',
no_candidate: '이 이름으로는 찾지 못했습니다.',
};
/**
* 2 .
* 1 . ** .**
*
* . 100% ****,
* ("우리는 카페인가 음식점인가").
* , (services/place_category.py).
* (`?step=industry`) .
*
* 묻는다: 상호를 · · · .
*/
@ -45,117 +47,144 @@ export function Step2PlaceSearch() {
const storeName = useBuilderStore((s) => s.storeName);
const location = useBuilderStore((s) => s.location);
const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity);
const pendingPick = useBuilderStore((s) => s.pendingPick);
const setStoreName = useBuilderStore((s) => s.setStoreName);
const setLocation = useBuilderStore((s) => s.setLocation);
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
const setPendingPick = useBuilderStore((s) => s.setPendingPick);
const confirmIdentity = useBuilderStore((s) => s.confirmIdentity);
const clearIdentity = useBuilderStore((s) => s.clearIdentity);
const goToStep = useBuilderStore((s) => s.goToStep);
// 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다).
const search = usePlaceSearch(industry, confirmedIdentity?.placeId ?? null);
const [searchParams, setSearchParams] = useSearchParams();
/**
* `?placeId=...`.
*
* .
* (stores/builder ):
* . (usePlaceSync),
* .
*/
const rememberPlace = (placeId: string | null) => {
const next = new URLSearchParams(searchParams);
if (placeId) {
next.set('placeId', placeId);
// 개발 서버 재시작/HMR로 BuilderPage가 다시 마운트돼도 신규 등록 흐름임을 보존한다.
next.set('flow', 'onboarding');
} else {
next.delete('placeId');
next.delete('flow');
}
setSearchParams(next, {replace: true});
};
const [pickedIndex, setPickedIndex] = useState<number | null>(null);
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [, goToStep] = useWizardStep();
const [placeUrl, setPlaceUrl] = useState('');
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
const runSearch = () => {
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
setPickedIndex(null);
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데,
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다.
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다.
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
/**
* .
*
* `?placeId=` .
* (stores/builder ): .
* (usePlaceSync), .
* ** ** . "placeId 는 붙었는데 아직 1단계"
* , .
*/
const advance = (placeId: string | null) => {
goToStep('collect', {
params: {placeId, flow: placeId ? 'onboarding' : null},
});
};
const finishPick = async (pick: PendingPick, nextIndustry: IndustryType) => {
const identity = await search.confirmPick(pick, nextIndustry);
setPendingPick(null);
if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다
confirmIdentity(identity);
advance(identity.placeId);
};
/**
* .
*
* **** . ( )
* .
*/
const resumed = useRef(false);
useEffect(() => {
const industryFromPicker = pendingPick?.industry;
if (!pendingPick || !industryFromPicker || resumed.current) return;
resumed.current = true;
void finishPick(pendingPick, industryFromPicker);
// eslint-disable-next-line react-hooks/exhaustive-deps -- 한 번만 이어붙이는 일이라 pendingPick 만 본다
}, [pendingPick]);
/**
* .
*
* `category` **** ,
* (RemoveNoneResponse null ).
* JSON-LD .
*/
const choose = (item: PlaceSearchItem) => {
const pick: PendingPick = {
name: item.name ?? '',
address: item.road_address ?? '',
};
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
if (!guessed) {
setPendingPick(pick);
goToStep('industry');
return;
}
void search.search(storeName, location);
selectIndustry(guessed);
void finishPick(pick, guessed);
};
const runSearch = () => {
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
void search.searchPublic(storeName, location);
};
const backToInput = () => {
clearIdentity();
// 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다.
rememberPlace(null);
setPickedIndex(null);
goToStep('search', {params: {placeId: null, flow: null}, replace: true});
search.reset();
};
const pick = async (candidate: PlaceCandidate, index: number) => {
// 지역검색 후보만 있고 네이버 플레이스를 못 찾았으면 확정하지 않는다.
// URL 확정 경로가 상호·주소·좌표와 수집 채널을 한 번에 보장한다.
if (!candidate.naver_place_url) return;
setPickedIndex(index);
const identity = await search.confirmByUrl(candidate.naver_place_url);
if (!identity) {
setPickedIndex(null);
return;
}
confirmIdentity(identity);
rememberPlace(identity.placeId);
goToStep(3);
};
/** 네이버 플레이스 URL 로 확정 — 가장 확실한 경로다(usePlaceSearch.confirmByUrl 주석 참고). */
const pickByUrl = async () => {
const url = placeUrl.trim();
if (!url) return;
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다.
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
const identity = await search.confirmByUrl(url);
const identity = await search.confirmByUrl(url, industry);
if (!identity) return;
confirmIdentity(identity);
rememberPlace(identity.placeId);
goToStep(3);
advance(identity.placeId);
};
/**
* .
*
* ** .** places.category
* PATCH (Req_UpdatePlace category )
* , .
* ( ) .
*/
const identityIsOnServer = Boolean(confirmedIdentity?.placeId);
const changeIndustry = () => {
if (identityIsOnServer) {
clearIdentity();
goToStep('industry', {params: {placeId: null, flow: null}});
return;
}
goToStep('industry');
};
// ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ──────────────
const stage = confirmedIdentity
? 'confirmed'
: search.phase === 'searching'
? 'searching'
: search.phase === 'unavailable'
? 'unavailable'
: search.phase === 'done'
? 'picking'
: 'input';
? 'searching'
: search.phase === 'unavailable'
? 'unavailable'
: search.phase === 'done'
? 'picking'
: 'input';
const searchQuery = [storeName, location].filter(Boolean).join(' ');
return (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage
current={2}
current={1}
wide={stage === 'picking'}
title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]}
>
{stage === 'input' && (
<div className="space-y-4">
<div className="space-y-4 rounded-2xl border border-border bg-card p-6">
<div className="space-y-4 rounded-2xl border border-border bg-card p-6">
<div>
<label htmlFor="store-name" className="mb-1.5 block text-xs font-semibold">
<span className="text-destructive">*</span>
@ -200,7 +229,6 @@ export function Step2PlaceSearch() {
<Search />
<span> </span>
</Button>
</div>
</div>
)}
@ -216,72 +244,51 @@ export function Step2PlaceSearch() {
<p
className={cn(
'rounded-xl p-3 text-xs leading-relaxed',
search.candidates.length > 0
search.items.length > 0
? 'border border-border bg-card text-muted-foreground'
: 'border border-warning/30 bg-warning/10 text-warning',
)}
>
{OUTCOME_TEXT[search.outcome] ?? '아래 목록에서 사장님 가게를 골라 주세요.'}
{search.sourceLabel && (
<Badge variant="outline" className="ml-2 text-[10px]">
{search.sourceLabel}
</Badge>
)}
{search.items.length > 0
? '아래 목록에서 사장님 가게를 골라 주세요.'
: '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
</p>
{search.candidates.length > 0 &&
!search.candidates.some((candidate) => candidate.naver_place_url) && (
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-xs font-medium leading-relaxed text-warning">
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
.
URL을 .
</p>
)}
{search.pickError && (
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-xs font-medium leading-relaxed text-warning">
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
{search.pickError}
</p>
)}
<ul className="space-y-2">
{search.candidates.map((candidate, index) => (
<li key={`${candidate.external_place_id ?? candidate.name}-${index}`}>
{search.items.map((item, index) => (
<li key={`${item.name ?? ''}-${item.road_address ?? ''}-${index}`}>
<button
type="button"
onClick={() => void pick(candidate, index)}
disabled={search.isConfirming || !candidate.naver_place_url}
onClick={() => choose(item)}
disabled={search.isConfirming}
className="w-full cursor-pointer rounded-xl border border-border bg-card p-4 text-left transition-all hover:border-primary hover:bg-accent disabled:opacity-60"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">{candidate.name}</span>
{candidate.category_name && (
<span className="text-sm font-bold">{item.name}</span>
{item.category_name && (
<Badge variant="outline" className="text-[10px]">
{candidate.category_name}
{item.category_name}
</Badge>
)}
<Badge
variant={candidate.naver_place_url ? 'success' : 'warning'}
className="text-[10px]"
>
{candidate.naver_place_url
? '네이버 플레이스 찾음'
: '네이버 플레이스 못 찾음'}
</Badge>
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
<IndustryBadge category={item.category} />
</div>
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
<MapPin className="mt-px size-3 shrink-0" />
<span>{candidate.road_address ?? candidate.address ?? '주소 없음'}</span>
<span>{item.road_address ?? '주소 없음'}</span>
</p>
{candidate.phone && (
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Phone className="size-3 shrink-0" />
{candidate.phone}
</p>
)}
</div>
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground">
{!candidate.naver_place_url
? 'URL 필요'
: search.isConfirming && pickedIndex === index
? '확정 중...'
: '이 가게예요'}
{search.isConfirming ? '확정 중...' : '이 가게예요'}
</span>
</div>
</button>
@ -289,25 +296,26 @@ export function Step2PlaceSearch() {
))}
</ul>
{!search.candidates.some((candidate) => candidate.naver_place_url) ? (
<PlaceUrlBox
value={placeUrl}
onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming}
searchQuery={[storeName, location].filter(Boolean).join(' ')}
/>
) : (
<PlaceUrlBox
value={placeUrl}
onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming}
compact
searchQuery={[storeName, location].filter(Boolean).join(' ')}
/>
)}
{/* 목록에서 고르면 업종은 그 후보가 정한다 — 이 줄은 아래 '주소로 확정' 경로용이다. */}
<IndustryLine
label="주소로 확정할 때 쓸 업종"
industry={industry}
onChange={changeIndustry}
/>
<PlaceUrlBox
value={placeUrl}
onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming}
compact={search.items.length > 0 && !search.pickError}
searchQuery={searchQuery}
/>
<Button variant="ghost" size="sm" className="w-full" onClick={backToInput}>
<Search />
<span> </span>
</Button>
</div>
)}
@ -317,12 +325,19 @@ export function Step2PlaceSearch() {
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
{search.unavailableReason}
</p>
<IndustryLine
label="주소로 확정할 때 쓸 업종"
industry={industry}
onChange={changeIndustry}
/>
<PlaceUrlBox
value={placeUrl}
onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming}
searchQuery={[storeName, location].filter(Boolean).join(' ')}
searchQuery={searchQuery}
/>
<Button variant="outline" className="w-full" onClick={runSearch}>
@ -357,17 +372,25 @@ export function Step2PlaceSearch() {
</>
) : (
<Badge variant="warning" className="text-[10px]">
·
{confirmedIdentity.sourceLabel} ·
</Badge>
)}
</div>
</div>
<IndustryLine
industry={industry}
onChange={changeIndustry}
note={
identityIsOnServer ? '업종을 바꾸면 가게 확인을 다시 받습니다.' : undefined
}
/>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={() => goToStep(3)}
onClick={() => advance(confirmedIdentity.placeId)}
>
<span>다음: </span>
<ArrowRight />
@ -386,11 +409,61 @@ export function Step2PlaceSearch() {
? `${confirmedIdentity.name} 기준으로 진행합니다.`
: '내 가게를 확인해야 다음 단계로 갈 수 있습니다.'
}
onPrev={() => goToStep(1)}
onNext={() => goToStep(3)}
onNext={() => advance(confirmedIdentity?.placeId ?? null)}
nextLabel="다음: 데이터 수집"
nextDisabled={!confirmedIdentity}
/>
</div>
);
}
/** 후보 카드의 추정 업종. 못 정한 후보는 "고르면 묻는다"를 미리 알려 준다. */
function IndustryBadge({category}: {category: PickedCategory}) {
const industry = category != null ? (CATEGORY_TO_INDUSTRY[category] ?? null) : null;
return industry ? (
<Badge variant="success" className="text-[10px]">
{INDUSTRY_CONFIGS[industry].name}
</Badge>
) : (
<Badge variant="warning" className="text-[10px]">
</Badge>
);
}
/** PlaceSearchItem.category — 서버가 못 정하면 키 자체가 없다(RemoveNoneResponse). */
type PickedCategory = PlaceSearchItem['category'];
/**
* + .
*
* , ** .**
* .
*/
function IndustryLine({
industry,
onChange,
label = '업종',
note,
}: {
industry: IndustryType;
onChange: () => void;
label?: string;
note?: string;
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-border bg-card px-3 py-2 text-xs">
<span>
{label}: <strong className="font-semibold">{INDUSTRY_CONFIGS[industry].name}</strong>
{note && <span className="ml-2 text-[11px] text-muted-foreground">{note}</span>}
</span>
<button
type="button"
onClick={onChange}
className="cursor-pointer rounded-md px-2 py-1 font-medium text-primary transition-colors hover:bg-accent"
>
</button>
</div>
);
}

View File

@ -10,6 +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 {WizardFooter} from './WizardFooter';
import {WizardStage, WizardWaiting} from './WizardStage';
@ -29,7 +30,7 @@ const STAGE_DESCRIPTION: Record<string, string> = {
review: '찾은 값은 전부 출처와 함께 보여드립니다. 사장님이 [맞아요]를 누른 값만 사이트와 AI 검색 답변에 나갑니다.',
};
/** 채널 URL 확인과 수집 결과 검토 단계. */
/** 채널 URL 확인과 수집 결과 검토 단계(진행 표시 2번). */
export function Step3DataReview() {
const industry = useBuilderStore((s) => s.industry);
const storeName = useBuilderStore((s) => s.storeName);
@ -37,7 +38,7 @@ export function Step3DataReview() {
const gatherCompleted = useBuilderStore((s) => s.gatherCompleted);
const infoFields = useBuilderStore((s) => s.infoFields);
const photos = useBuilderStore((s) => s.photos);
const goToStep = useBuilderStore((s) => s.goToStep);
const [, goToStep] = useWizardStep();
const unverified = useUnverifiedFields();
const placeId = confirmedIdentity?.placeId ?? null;
@ -91,7 +92,7 @@ export function Step3DataReview() {
return (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage
current={3}
current={2}
wide={stage === 'review' || stage === 'channels'}
title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]}
@ -178,14 +179,14 @@ export function Step3DataReview() {
2
.
</p>
<Button variant="outline" className="w-full" onClick={() => goToStep(4)}>
<Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
<span> </span>
<ArrowRight />
</Button>
</>
)}
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep(2)}>
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep('search')}>
</Button>
</div>
@ -302,7 +303,7 @@ export function Step3DataReview() {
</div>
)}
<Button variant="primary" size="lg" className="w-full" onClick={() => goToStep(4)}>
<Button variant="primary" size="lg" className="w-full" onClick={() => goToStep('template')}>
<span>다음: 템플릿 </span>
<ArrowRight />
</Button>
@ -354,8 +355,8 @@ export function Step3DataReview() {
'수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.'
)
}
onPrev={() => goToStep(2)}
onNext={() => goToStep(4)}
onPrev={() => goToStep('search')}
onNext={() => goToStep('template')}
nextLabel="다음: 템플릿 선택"
/**
* .

View File

@ -4,6 +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 {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps';
@ -85,7 +86,7 @@ export function Step4Template() {
const templateId = useBuilderStore((s) => s.templateId);
const placeId = useBuilderStore((s) => s.placeId);
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
const goToStep = useBuilderStore((s) => s.goToStep);
const [, goToStep] = useWizardStep();
const startGenerating = useBuilderStore((s) => s.startGenerating);
const config = INDUSTRY_CONFIGS[industry];
@ -105,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={4} />
<WizardSteps current={3} />
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
@ -166,12 +167,13 @@ export function Step4Template() {
</div>
<WizardFooter
onPrev={() => goToStep(3)}
onPrev={() => goToStep('collect')}
onNext={() => {
// ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 —
// 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다.
queueSiteTemplateSave(placeId, templateId);
startGenerating();
goToStep('generating');
}}
nextLabel="이 템플릿으로 사이트 생성하기"
/>

View File

@ -1,12 +1,12 @@
import {useCallback, useEffect} from 'react';
import {useSearchParams} from 'react-router';
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 {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
import {useBuilderStore} from '@/stores/builder';
import {EDITOR_STEP, useWizardStep} from './wizardUrl';
const BUILD_STEPS = [
'수집된 사진 분류 및 대체 텍스트 생성',
@ -30,9 +30,8 @@ export function Step5Generating() {
const storeName = useBuilderStore((s) => s.storeName);
const stage = useBuilderStore((s) => s.generateStage);
const setGenerateStage = useBuilderStore((s) => s.setGenerateStage);
const goToStep = useBuilderStore((s) => s.goToStep);
const placeId = useBuilderStore((s) => s.placeId);
const [searchParams, setSearchParams] = useSearchParams();
const [, goToStep] = useWizardStep();
/**
* .
@ -43,11 +42,10 @@ export function Step5Generating() {
* '목록으로 보내는 것' .
*/
const finishOnboarding = useCallback(() => {
const next = new URLSearchParams(searchParams);
next.delete('flow');
setSearchParams(next, {replace: true});
goToStep(EDITOR_STEP);
}, [goToStep, searchParams, setSearchParams]);
// ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인
// 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다.
goToStep(EDITOR_STEP, {params: {flow: null}, replace: true});
}, [goToStep]);
/**
* ·FAQ (JobType.COPY). `POST /v1/place/{id}/copy` .

View File

@ -4,14 +4,17 @@ import {cn} from '@/lib/utils';
/**
* .
*
* 2( ) 3( )
* 1( ) 2( )
* "가게가 맞나" "값이 맞나" .
*
* . ( 1 ),
* "우리는 카페인가 음식점인가"
* "가게 이름" .
*/
const STEPS = [
{step: 1, label: '업종'},
{step: 2, label: '내 가게 확인'},
{step: 3, label: '수집 정보 확인'},
{step: 4, label: '템플릿'},
{step: 1, label: '내 가게 확인'},
{step: 2, label: '수집 정보 확인'},
{step: 3, label: '템플릿'},
] as const;
export function WizardSteps({current}: {current: number}) {

View File

@ -1,3 +1,5 @@
// 위저드 단계는 주소창이 소유한다 — 화면을 고르는 쪽(BuilderPage)이 이 계약을 읽는다.
export {defaultStep, EDITOR_STEP, useWizardStep, type WizardStep} from './wizardUrl';
export {Step1Industry} from './Step1Industry';
export {Step2PlaceSearch} from './Step2PlaceSearch';
export {Step3DataReview} from './Step3DataReview';

View File

@ -1,21 +1,21 @@
import {useCallback, useRef, useState} from 'react';
import type {IndustryType} from '@o2o/shared';
import type {ExternalPlaceSource as ExternalPlaceSourceType, PlaceCandidate, PlaceCategory} from '@/api';
import type {PlaceCategory, PlaceSearchItem} from '@/api';
import {
createPlace,
ExternalPlaceSource,
getAccessToken,
PlaceCategory as PlaceCategoryEnum,
searchPlacesPublic,
updatePlace,
verifyCandidates,
verifyPlace,
verifyPlaceByUrl,
} from '@/api';
import type {ConfirmedIdentity} from '@/stores/builder';
import {ensureAutoSession} from '@/lib/autoSession';
import {describeError} from '@/lib/errorMessages';
import {notify, notifyApiError} from '@/lib/notify';
/** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */
/** 빌더 업종 → places.category. 반대 방향은 placeAdapter 의 CATEGORY_TO_INDUSTRY 다. */
const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
stay: PlaceCategoryEnum.LODGING,
cafe: PlaceCategoryEnum.CAFE,
@ -23,21 +23,15 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
clinic: PlaceCategoryEnum.CLINIC,
};
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
const SOURCE_LABEL: Record<number, string> = {
[ExternalPlaceSource.KAKAO]: '카카오맵',
[ExternalPlaceSource.NAVER]: '네이버 지도',
};
export type SearchPhase =
/** 아직 검색 전. */
| 'idle'
/** 사업장 생성 + 후보 조회 중. */
/** 공개 검색 중. */
| 'searching'
/** 후보를 받았다(0건일 수도 있다 — outcome 이 no_candidate). */
/** 후보를 받았다(0건일 수도 있다). */
| 'done'
/**
* DB .
* DB , , .
* .
* .
*/
@ -45,41 +39,47 @@ export type SearchPhase =
export interface PlaceSearchState {
phase: SearchPhase;
/** 외부 장소 DB 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
candidates: PlaceCandidate[];
/** matched | ambiguous | no_candidate — 서버 판정. 문구를 고르는 데만 쓴다. */
outcome: string;
/** true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다. */
autoSelectable: boolean;
/** 후보를 준 장소 DB 이름. 없으면 빈 문자열. */
sourceLabel: string;
source: ExternalPlaceSourceType | null;
/** 검색을 못 한 이유(로그인 필요 등). phase === 'unavailable' 일 때만 채워진다. */
/** 공개 검색 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
items: PlaceSearchItem[];
/** 검색을 못 한 이유. phase === 'unavailable' 일 때만 채워진다. */
unavailableReason: string;
/**
* .
* "눌렀는데 아무 일도 안 일어난다"
* URL .
*/
pickError: string;
}
const INITIAL: PlaceSearchState = {
phase: 'idle',
candidates: [],
outcome: '',
autoSelectable: false,
sourceLabel: '',
source: null,
items: [],
unavailableReason: '',
pickError: '',
};
/** 상호명 비교용. 공백·괄호 표기가 후보마다 달라 그대로 비교하면 같은 가게도 안 맞는다. */
function normalizeName(name: string | undefined | null): string {
return (name ?? '').replace(/\s+/g, '').toLowerCase();
}
/**
* .
* .
*
* :
* 1) POST /v1/place () place_id
* 2) GET /v1/place/{id}/verify/candidates?query= DB
* 3) POST /v1/place/{id}/verify
* ** **(`GET /v1/place/search`). ,
* (category)
* API . (verify/candidates)
* , ** URL ** .
*
* :
* 1) POST /v1/place ()
* 2) GET /v1/place/{id}/verify/candidates?query= URL
* 3) POST /v1/place/{id}/verify/by-url URL
*
* 3 (place.py: "동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다").
* .
* · .
*/
export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | null = null) {
export function usePlaceSearch(existingPlaceId: string | null = null) {
const [state, setState] = useState<PlaceSearchState>(INITIAL);
const [isConfirming, setIsConfirming] = useState(false);
/**
@ -88,8 +88,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* .
*/
const placeIdRef = useRef<string | null>(existingPlaceId);
/** 그 사업장을 만들 때 쓴 업종. 업종이 바뀌면 재사용할 수 없다(아래 ensurePlace). */
const placeCategoryRef = useRef<PlaceCategory | null>(null);
const inflight = useRef<AbortController | null>(null);
/** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
/** 마지막으로 확정 시도한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
const nameRef = useRef<string>('');
const reset = useCallback(() => {
@ -98,128 +100,76 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
setState(INITIAL);
}, []);
/** 사업장 껍데기 확보. 이미 만들었으면 상호만 맞춰 둔다(같은 위저드에서 두 번 만들지 않는다). */
/**
* .
*
* ** .** places.category PATCH
* (Req_UpdatePlace category ).
* JSON-LD .
*/
const ensurePlace = useCallback(
async (name: string, signal: AbortSignal): Promise<string | null> => {
if (placeIdRef.current) {
async (name: string, category: PlaceCategory, signal: AbortSignal): Promise<string | null> => {
const reusable =
placeIdRef.current !== null &&
(placeCategoryRef.current === null || placeCategoryRef.current === category);
if (reusable && placeIdRef.current) {
// PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다.
await updatePlace(placeIdRef.current, {name});
if (signal.aborted) return null;
return placeIdRef.current;
}
const created = await createPlace(
{name, category: INDUSTRY_TO_CATEGORY[industry]},
undefined,
signal,
);
const created = await createPlace({name, category}, undefined, signal);
placeIdRef.current = created.place?.place_id ?? null;
placeCategoryRef.current = category;
return placeIdRef.current;
},
[industry],
[],
);
const search = useCallback(
async (name: string, location: string) => {
nameRef.current = name.trim();
const query = [name.trim(), location.trim()].filter(Boolean).join(' ');
if (!name.trim()) return;
/**
* ** **.
*
* . "내 가게가 여기 있나" ,
* .
*/
const searchPublic = useCallback(async (name: string, location: string) => {
const query = [name.trim(), location.trim()].filter(Boolean).join(' ');
if (query.length < 2) return;
inflight.current?.abort();
const controller = new AbortController();
inflight.current = controller;
inflight.current?.abort();
const controller = new AbortController();
inflight.current = controller;
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
setState({...INITIAL, phase: 'searching'});
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
if (!getAccessToken()) {
setState({...INITIAL});
return;
}
try {
const res = await searchPlacesPublic({q: query}, undefined, controller.signal);
if (controller.signal.aborted) return;
setState({...INITIAL, phase: 'searching'});
try {
const placeId = await ensurePlace(name.trim(), controller.signal);
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
if (controller.signal.aborted) return;
const source = res.source ?? null;
setState({
phase: 'done',
candidates: res.candidates ?? [],
outcome: res.outcome ?? '',
autoSelectable: res.auto_selectable ?? false,
source,
sourceLabel: source ? (SOURCE_LABEL[source] ?? '외부 장소 DB') : '외부 장소 DB',
unavailableReason: '',
});
} catch (error) {
if (controller.signal.aborted) return;
notifyApiError(error, '상호를 검색하지 못했습니다.');
// ★ 이 엔드포인트의 거절은 HTTP 200 + result.success=false 로 온다(RemoveNoneResponse).
// 상태코드만 보면 "후보 0건"과 "분당 20회 초과"가 같은 화면이 된다.
if (res.result?.success === false) {
setState({
...INITIAL,
phase: 'unavailable',
unavailableReason:
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
describeError(res.result.desc) ?? '지도 검색을 사용할 수 없습니다.',
});
return;
}
},
[ensurePlace],
);
/**
* (POST /verify).
* null.
*/
const confirm = useCallback(
async (candidate: PlaceCandidate): Promise<ConfirmedIdentity | null> => {
const placeId = placeIdRef.current;
const address = candidate.road_address ?? candidate.address ?? '';
if (!placeId) return null;
setIsConfirming(true);
try {
await verifyPlace(placeId, {
source: state.source ?? ExternalPlaceSource.KAKAO,
// 네이버는 고유 id 를 주지 않는다 — 그 경우 상호명 + 도로명주소가 중복 판정 키다.
external_place_id: candidate.external_place_id ?? '',
road_address: candidate.road_address ?? null,
address: candidate.address ?? null,
phone: candidate.phone ?? null,
latitude: candidate.latitude ?? null,
longitude: candidate.longitude ?? null,
place_url: candidate.place_url ?? null,
});
// ★ 상호도 후보 쪽으로 맞춘다. 사장님이 그 이름이 적힌 카드를 보고 "이 가게예요"를
// 눌렀는데 DB 에는 검색어로 친 이름이 남으면, 화면과 서버가 다른 상호를 들고 있게 된다.
const officialName = candidate.name?.trim();
if (officialName) await updatePlace(placeId, {name: officialName});
return {
placeId,
name: candidate.name ?? '',
address,
phone: candidate.phone ?? undefined,
origin: 'external',
sourceLabel: state.sourceLabel || '외부 장소 DB',
externalPlaceId: candidate.external_place_id ?? undefined,
placeUrl: candidate.place_url ?? undefined,
};
} catch (error) {
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
return null;
} finally {
setIsConfirming(false);
}
},
[state.source, state.sourceLabel],
);
setState({...INITIAL, phase: 'done', items: res.items ?? []});
} catch (error) {
if (controller.signal.aborted) return;
notifyApiError(error, '상호를 검색하지 못했습니다.');
setState({
...INITIAL,
phase: 'unavailable',
unavailableReason:
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
});
}
}, []);
/**
* URL .
@ -230,10 +180,14 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* · URL ( ).
*/
const confirmByUrl = useCallback(
async (url: string): Promise<ConfirmedIdentity | null> => {
async (url: string, industry: IndustryType): Promise<ConfirmedIdentity | null> => {
setIsConfirming(true);
try {
const placeId = await ensurePlace(nameRef.current || '내 가게', new AbortController().signal);
const placeId = await ensurePlace(
nameRef.current || '내 가게',
INDUSTRY_TO_CATEGORY[industry],
new AbortController().signal,
);
if (!placeId) return null;
const res = await verifyPlaceByUrl(placeId, {url});
const place = res.place;
@ -261,20 +215,81 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
);
/**
* DB .
* .
*
* (POST /verify) . URL
* . .
* . .
*/
const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => {
return {
const confirmManual = useCallback(
(name: string, address: string, sourceLabel = '직접 입력'): ConfirmedIdentity => ({
placeId: placeIdRef.current,
name: name.trim(),
address: address.trim(),
origin: 'owner',
sourceLabel: '직접 입력',
};
}, []);
sourceLabel,
}),
[],
);
return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset};
/**
* .
*
* . (b94daa9),
* · .
* ** ·** ( ).
* ( URL)
* URL .
*/
const confirmPick = useCallback(
async (
pick: {name: string; address: string},
industry: IndustryType,
): Promise<ConfirmedIdentity | null> => {
nameRef.current = pick.name.trim();
setState((prev) => ({...prev, pickError: ''}));
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
// 누른 확정이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
if (!getAccessToken()) return confirmManual(pick.name, pick.address, '지도 검색');
setIsConfirming(true);
try {
const controller = new AbortController();
const placeId = await ensurePlace(
pick.name.trim(),
INDUSTRY_TO_CATEGORY[industry],
controller.signal,
);
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
// 상호만으로는 동명 업소가 섞인다 — 사장님이 고른 그 주소까지 붙여 좁힌다.
const query = [pick.name.trim(), pick.address.trim()].filter(Boolean).join(' ');
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
const candidates = res.candidates ?? [];
const hit =
candidates.find(
(c) => c.naver_place_url && normalizeName(c.name) === normalizeName(pick.name),
) ?? candidates.find((c) => c.naver_place_url);
if (!hit?.naver_place_url) {
setState((prev) => ({
...prev,
pickError:
'이 가게의 네이버 플레이스를 자동으로 찾지 못했습니다. 아래에서 지도 주소를 붙여넣어 주세요.',
}));
return null;
}
return await confirmByUrl(hit.naver_place_url, industry);
} catch (error) {
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
return null;
} finally {
setIsConfirming(false);
}
},
[confirmByUrl, confirmManual, ensurePlace],
);
return {...state, isConfirming, searchPublic, confirmPick, confirmByUrl, confirmManual, reset};
}

View File

@ -0,0 +1,96 @@
import {useCallback} from 'react';
import {useSearchParams} from 'react-router';
/**
* ** ** `/builder?step=<이름>`.
*
* (zustand) `step` .
* **** , .
* . (
* ) · .
*/
export const WIZARD_STEPS = [
/** 상호명으로 내 가게 찾기 — 위저드의 시작점이다. */
'search',
/** 업종 직접 고르기. 검색 결과가 업종을 못 정했을 때, 또는 [바꾸기] 로 들어온다. */
'industry',
'collect',
'template',
'generating',
'editor',
] as const;
export type WizardStep = (typeof WIZARD_STEPS)[number];
/** 위저드가 끝나고 편집기로 넘어가는 단계. 숫자를 화면마다 외우지 않게 한 곳에 둔다. */
export const EDITOR_STEP: WizardStep = 'editor';
/**
* (WizardSteps) .
*
* '내 가게 확인' **** 1.
* ,
* .
* (0 "표시할 번호가 없다" ).
*/
export const STEP_NUMBER: Record<WizardStep, number> = {
search: 1,
industry: 1,
collect: 2,
template: 3,
generating: 0,
editor: 0,
};
function parseStep(value: string | null): WizardStep | null {
return WIZARD_STEPS.includes(value as WizardStep) ? (value as WizardStep) : null;
}
/**
* `step` .
*
* `?placeId=` ** **( [ ])
* . (`flow=onboarding`) :
* ·릿 step .
*/
export function defaultStep(params: URLSearchParams): WizardStep {
return params.get('placeId') && params.get('flow') !== 'onboarding' ? EDITOR_STEP : 'search';
}
interface GoOptions {
/** 히스토리에 쌓지 않는다 — 화면이 바뀌지 않는 정정(주소창 정리)일 때만 쓴다. */
replace?: boolean;
/** 같은 이동에서 함께 바꿀 쿼리. null 이면 지운다. 단계와 함께 한 번에 바꿔야 중간 주소가 안 생긴다. */
params?: Record<string, string | null>;
}
/**
* , .
*
* (`placeId`·`flow`·`q`) `step`
* .
*/
export function useWizardStep(): [WizardStep, (next: WizardStep, options?: GoOptions) => void] {
const [searchParams, setSearchParams] = useSearchParams();
const step = parseStep(searchParams.get('step')) ?? defaultStep(searchParams);
const go = useCallback(
(next: WizardStep, options?: GoOptions) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set('step', next);
for (const [key, value] of Object.entries(options?.params ?? {})) {
if (value === null) params.delete(key);
else params.set(key, value);
}
return params;
},
{replace: options?.replace},
);
},
[setSearchParams],
);
return [step, go];
}

View File

@ -17,7 +17,7 @@ import {useBuilderStore} from '@/stores/builder';
import type {ApplyPlaceOptions} from '@/stores/builderTypes';
export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions) {
const enterEditor = options?.enterEditor ?? true;
const isOnboarding = options?.isOnboarding ?? false;
const enabled = Boolean(placeId);
const id = placeId ?? '';
@ -42,8 +42,8 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
/**
* useEffect useLayoutEffect .
* effect , 1
* (). .
* effect ,
* (). .
*/
useLayoutEffect(() => {
if (!placeId) {
@ -53,8 +53,10 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
if (!place) return;
// fact 가 아직 안 왔어도 먼저 온 place 로 상호·주소부터 얹는다.
// fact/스키마가 도착하면 이 effect 가 한 번 더 돌아 정보 표를 채운다.
applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {enterEditor});
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, enterEditor]);
applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {
isOnboarding,
});
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, isOnboarding]);
/**
* .

View File

@ -26,6 +26,10 @@ export const ERROR_MESSAGE: Record<string, string> = {
PLACE_NOT_VERIFIED: '동일 업소 검증을 먼저 끝내야 합니다. 검증 전에는 수집·발행이 열리지 않습니다.',
PLACE_VERIFY_NO_CANDIDATE: '외부 장소 정보에서 이 상호를 찾지 못했습니다.',
PLACE_VERIFY_AMBIGUOUS: '같은 이름의 업소가 여럿입니다. 어느 곳인지 골라 주세요.',
// 상호명 공개 검색(로그인 전 첫 화면)이 만나는 세 가지. 인증이 없는 경로라 분당 상한이 걸려 있다.
HTTP_TO_MANY_REQUEST: '검색을 너무 자주 했습니다. 잠시 뒤 다시 시도해 주세요.',
LOCAL_NOT_CONFIGURED: '지도 검색이 아직 설정되지 않았습니다. 네이버 지도 주소를 붙여넣어 진행해 주세요.',
LOCAL_FETCH_FAILED: '지도에서 가게를 찾지 못했습니다. 잠시 뒤 다시 시도하거나 지도 주소를 붙여넣어 주세요.',
LINK_NOT_CONFIRMED: '확정한 채널 URL 만 수집 대상이 됩니다.',
// fact

View File

@ -1,25 +1,34 @@
import {useEffect, useRef} from 'react';
import {useEffect} from 'react';
import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared';
import {SiteStatus, type IndustryType} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import {
EDITOR_STEP,
Step1Industry,
Step2PlaceSearch,
Step3DataReview,
Step4Template,
Step5Generating,
useWizardStep,
} from '@/features/onboarding';
import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {userLabel, useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
import {useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
/** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */
const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
function parseIndustry(value: string | null): IndustryType | null {
return INDUSTRY_VALUES.includes(value as IndustryType) ? (value as IndustryType) : null;
}
/**
* "발행본 사이트 열기" .
*
@ -36,32 +45,55 @@ function siteUrl(domain: string | null | undefined): string | null {
export function BuilderPage() {
useAutoLogin();
/**
* `/builder?placeId=<uuid>`.
* ** .**
*
* (`/builder/:placeId`) 이유: 빌더는
* (router.tsx ), placeId .
* .
* placeId .
* ?step= ( wizardUrl.defaultStep )
* ?placeId= (`/builder/:placeId`)
* placeId .
* ?flow=onboarding ( )
* ?new=1 ?q= ?industry= . .
*/
const [searchParams, setSearchParams] = useSearchParams();
const [step, goToStep] = useWizardStep();
const urlPlaceId = searchParams.get('placeId');
const isOnboarding = searchParams.get('flow') === 'onboarding';
/**
* `?new=1` 1( ) .
* `?new=1` · `?q=` · `?industry=`.
*
*
* . ,
* .
* `?new=1` .
* .
* , · .
* ** .** `setSearchParams({})` ,
* `?new=1&q=...` .
*/
const reset = useBuilderStore((s) => s.reset);
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
const setStoreName = useBuilderStore((s) => s.setStoreName);
const isNew = searchParams.get('new') === '1';
const seedQuery = searchParams.get('q');
const seedIndustry = searchParams.get('industry');
useEffect(() => {
if (!isNew) return;
reset();
setSearchParams({}, {replace: true});
}, [isNew, reset, setSearchParams]);
if (!isNew && seedQuery === null && seedIndustry === null) return;
if (isNew) reset();
// 순서가 뒤집히면 안 된다 — selectIndustry 는 시드를 통째로 갈아 상호를 비운다.
const industry = parseIndustry(seedIndustry);
if (industry) selectIndustry(industry);
if (seedQuery) setStoreName(seedQuery);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('new');
next.delete('q');
next.delete('industry');
return next;
},
{replace: true},
);
}, [isNew, seedQuery, seedIndustry, reset, selectIndustry, setStoreName, setSearchParams]);
/**
* 2 . placeId .
* 1 . placeId .
*
* ** **
* 27 '독채 3개 동' . .
@ -69,21 +101,8 @@ export function BuilderPage() {
*/
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
const placeId = urlPlaceId ?? wizardPlaceId;
/**
* .
*
* ** ** . 2 `?placeId=`
* ( ), "URL 붙여넣고 확인 → 곧장 에디터"
* 3·4·5 . .
* ( ) .
*/
const enteredWithPlace = useRef(
Boolean(urlPlaceId) && searchParams.get('flow') !== 'onboarding',
).current;
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
const sync = usePlaceSync(placeId, {isOnboarding});
const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName);
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user);
@ -128,7 +147,7 @@ export function BuilderPage() {
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
if (step === EDITOR_STEP && !isSignedIn) {
return <EditorSignInGate onBack={() => goToStep(4)} />;
return <EditorSignInGate onBack={() => goToStep('template')} />;
}
if (step === EDITOR_STEP) {
@ -225,7 +244,9 @@ export function BuilderPage() {
return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
<Link to="/" className="transition-opacity hover:opacity-60">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
</Link>
{/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */}
{isSignedIn ? (
<Link
@ -246,11 +267,11 @@ export function BuilderPage() {
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{step === 1 && <Step1Industry />}
{step === 2 && <Step2PlaceSearch />}
{step === 3 && <Step3DataReview />}
{step === 4 && <Step4Template />}
{step === 5 && <Step5Generating />}
{step === 'search' && <Step2PlaceSearch />}
{step === 'industry' && <Step1Industry />}
{step === 'collect' && <Step3DataReview />}
{step === 'template' && <Step4Template />}
{step === 'generating' && <Step5Generating />}
</div>
</div>
);

View File

@ -23,9 +23,9 @@ import type {
ConfirmedIdentity,
FactRef,
LivePlaceInput,
PendingPick,
RightTab,
WeatherLocation,
WizardStep,
} from '@/stores/builderTypes';
/**
@ -38,16 +38,14 @@ export type {
ConfirmedIdentity,
FactRef,
LivePlaceInput,
PendingPick,
RightTab,
WeatherLocation,
WizardStep,
};
export {EDITOR_STEP} from '@/stores/builderTypes';
import {EDITOR_STEP} from '@/stores/builderTypes';
interface BuilderState {
// ── 위저드 ────────────────────────────────────────────
step: WizardStep;
// ★ 단계(step)는 여기 없다 — 주소창이 소유한다(features/onboarding/wizardUrl).
industry: IndustryType;
/**
* . null .
@ -65,13 +63,15 @@ interface BuilderState {
* · , 3 .
*/
confirmedIdentity: ConfirmedIdentity | null;
/** 업종을 못 정해 업종 화면으로 넘긴 후보. 돌아와서 확정을 이어갈 때만 쓴다. */
pendingPick: PendingPick | null;
// 수집(Step 2)
// 수집
isGathering: boolean;
gatherStage: number;
gatherCompleted: boolean;
// 템플릿(Step 3) · 생성(Step 4)
// 템플릿 · 생성
templateId: string;
colorPaletteId: string | null;
generateStage: number;
@ -97,7 +97,6 @@ interface BuilderState {
publishedUrl: string | null;
// ── 액션 ──────────────────────────────────────────────
goToStep: (step: WizardStep) => void;
selectIndustry: (industry: IndustryType) => void;
/** 실사업장 데이터를 캔버스에 얹는다(같은 사업장을 다시 읽어도 편집을 지우지 않는다). */
@ -114,12 +113,16 @@ interface BuilderState {
/** 확정을 물린다 — 상호를 다시 검색하러 갈 때. */
clearIdentity: () => void;
/** 업종 화면으로 넘길 후보를 들려 보낸다(null 이면 비운다). */
setPendingPick: (pick: PendingPick | null) => void;
startGather: () => void;
setGatherStage: (stage: number) => void;
finishGather: () => void;
selectTemplate: (templateId: string) => void;
selectColorPalette: (paletteId: string | null) => void;
/** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */
startGenerating: () => void;
setGenerateStage: (stage: number) => void;
@ -288,10 +291,10 @@ for (const key of ['o2osite.builder.wizard.v1', 'o2osite.builder.wizard.v2']) {
let factSaver: FactSaver;
export const useBuilderStore = create<BuilderState>((set, get) => ({
step: 1,
...seedFor(FALLBACK_INDUSTRY),
placeId: null,
confirmedIdentity: null,
pendingPick: null,
savingFieldIds: [],
isGathering: false,
@ -306,8 +309,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
isPublishModalOpen: false,
publishedUrl: null,
goToStep: (step) => set({step}),
// 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면
// 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다.
selectIndustry: (industry) => {
@ -327,7 +328,16 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
location: identity.address,
infoFields: withConfirmedIdentity(seed.infoFields, identity),
}
: null),
: {
/**
* · .
* ,
* 받는다: 지우면 .
*/
storeName: state.storeName,
location: state.location,
infoFields: withOwnerIdentity(seed.infoFields, state.storeName, state.location),
}),
// 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면
// "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다.
placeId: null,
@ -350,7 +360,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
* '확인 필요' , (withPendingEdits).
*/
applyPlace: (input, options) => {
const enterEditor = options?.enterEditor ?? true;
const isOnboarding = options?.isOnboarding ?? false;
// 처음 여는 사업장인가. 리페치(같은 placeId)면 섹션·단계를 건드리지 않는다.
const isNewPlace = get().placeId !== input.placeId;
// 사업장을 갈아타면 앞 사업장의 저장 대기분은 버린다 — 들고 가면 남의 값이 이 화면에 얹힌다.
@ -366,11 +376,14 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
return {
// 업종이 바뀔 때만 시드를 갈아끼운다(카페 사업장에 '객실 안내'가 남지 않게).
...(needsReseed ? seedFor(input.industry) : null),
// 이미 등록된 사업장을 여는 것이므로 수집 위저드를 다시 걷게 하지 않는다.
...(isNewPlace
? {
step: enterEditor ? EDITOR_STEP : 3,
...(!enterEditor
/**
* (
* ). 3 "아직 가게를 안 골랐다"
* .
*/
...(isOnboarding
? {
confirmedIdentity: {
placeId: input.placeId,
@ -384,18 +397,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
selectedSectionId: null,
isGathering: false,
}
: /**
* ** ** (`/builder?placeId=...`).
*
* ,
* [ ] (5)
* .
* . ( enterEditor
* EDITOR_STEP .)
*/
enterEditor
? {step: EDITOR_STEP}
: null),
: null),
/**
* "수집 완료" fact .
*
@ -454,6 +456,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
clearIdentity: () => set({confirmedIdentity: null}),
setPendingPick: (pendingPick) => set({pendingPick}),
toggleChannel: (channelId) =>
set((state) => ({
selectedChannels: state.selectedChannels.includes(channelId)
@ -496,9 +500,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
set({colorPaletteId});
persistTheme();
},
// ★ 생성 화면(5)으로 보낸다. 단계 번호는 EDITOR_STEP 과 함께 WizardStep 주석이 기준이다 —
// 여기 숫자가 자기 화면(4)이면 [사이트 생성하기]가 아무 일도 안 하는 것처럼 보인다.
startGenerating: () => set({step: 5, generateStage: 1}),
// 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다.
startGenerating: () => set({generateStage: 1}),
setGenerateStage: (generateStage) => set({generateStage}),
selectSection: (selectedSectionId) => set({selectedSectionId}),
@ -747,11 +750,11 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
factSaver.clear();
clearThemeSaves();
set({
step: 1,
...seedFor(get().industry),
placeId: null,
// [처음부터]는 "이 가게가 맞다"까지 물린다 — 상호부터 다시 확인받는다.
confirmedIdentity: null,
pendingPick: null,
isGathering: false,
gatherStage: 1,
gatherCompleted: false,

View File

@ -8,23 +8,9 @@
import type {FactStatus, IndustryType, InfoField, PhotoItem} from '@o2o/shared';
/**
* .
*
* 1
* 2 ** **
* 3 []/[]
* 4 릿
* 5
* 6
*
* 2 3 .
* 2 "이 가게가 맞나" (), 3 "이 값이 맞나" ().
* .
* (`features/onboarding/wizardUrl`).
* · .
*/
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6;
/** 위저드가 끝나고 에디터로 넘어가는 단계. 숫자를 화면마다 외우지 않게 한 곳에 둔다. */
export const EDITOR_STEP = 6 satisfies WizardStep;
export type RightTab = 'content' | 'design' | 'photos' | 'info' | 'faq' | 'verify';
/**
@ -97,11 +83,26 @@ export interface ConfirmedIdentity {
export interface ApplyPlaceOptions {
/**
* .
* (`?flow=onboarding`).
*
* true (
* ). 3 ** **
* false . .
* (`?step=`) . :
* ,
* . 3 "가게를 아직 안 골랐다" .
*/
enterEditor?: boolean;
isOnboarding?: boolean;
}
/**
* , ** ** .
*
* (`?step=industry`) .
* ( category ).
* ( ).
* .
*/
export interface PendingPick {
name: string;
address: string;
/** 업종 화면에서 고른 값. 이게 채워져야 확정이 이어진다. */
industry?: IndustryType;
}