[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 './listMediaParams';
export * from './listMySitesParams'; export * from './listMySitesParams';
export * from './listPlacesParams'; export * from './listPlacesParams';
export * from './listShowcaseParams';
export * from './localContentData'; export * from './localContentData';
export * from './localContentDataBody'; export * from './localContentDataBody';
export * from './localContentDataCollectedAt'; export * from './localContentDataCollectedAt';
@ -110,6 +111,10 @@ export * from './placeDataPhone';
export * from './placeDataRegionCode'; export * from './placeDataRegionCode';
export * from './placeDataRoadAddress'; export * from './placeDataRoadAddress';
export * from './placeDataVerifiedAt'; export * from './placeDataVerifiedAt';
export * from './placeSearchItem';
export * from './placeSearchItemCategory';
export * from './placeSearchItemCategoryName';
export * from './placeSearchItemRoadAddress';
export * from './placeStatus'; export * from './placeStatus';
export * from './publishAction'; export * from './publishAction';
export * from './publishLogData'; export * from './publishLogData';
@ -231,6 +236,9 @@ export * from './resPlaceList';
export * from './resPlaceListMsg'; export * from './resPlaceListMsg';
export * from './resPlaceMsg'; export * from './resPlaceMsg';
export * from './resPlacePlace'; export * from './resPlacePlace';
export * from './resPlaceSearch';
export * from './resPlaceSearchMsg';
export * from './resPlaceSearchSource';
export * from './resPublishLogs'; export * from './resPublishLogs';
export * from './resPublishLogsMsg'; export * from './resPublishLogsMsg';
export * from './resRefreshToken'; export * from './resRefreshToken';
@ -239,6 +247,8 @@ export * from './resSeoAudit';
export * from './resSeoAuditMsg'; export * from './resSeoAuditMsg';
export * from './resSeoAuditSummary'; export * from './resSeoAuditSummary';
export * from './resSeoAuditVisibility'; export * from './resSeoAuditVisibility';
export * from './resShowcase';
export * from './resShowcaseMsg';
export * from './resSite'; export * from './resSite';
export * from './resSiteCurrentVersion'; export * from './resSiteCurrentVersion';
export * from './resSiteMsg'; export * from './resSiteMsg';
@ -284,6 +294,10 @@ export * from './resVerifyCandidatesSource';
export * from './resWeather'; export * from './resWeather';
export * from './resWeatherMsg'; export * from './resWeatherMsg';
export * from './resWeatherWeather'; export * from './resWeatherWeather';
export * from './searchPlacesPublicParams';
export * from './showcaseItem';
export * from './showcaseItemRegion';
export * from './showcaseItemThumbnailUrl';
export * from './siteData'; export * from './siteData';
export * from './siteDataCurrentVersionId'; export * from './siteDataCurrentVersionId';
export * from './siteDataDomain'; export * from './siteDataDomain';
@ -291,6 +305,7 @@ export * from './siteDataPublishedAt';
export * from './siteDataTemplateId'; export * from './siteDataTemplateId';
export * from './siteDataTheme'; export * from './siteDataTheme';
export * from './siteDataThemeAnyOf'; export * from './siteDataThemeAnyOf';
export * from './siteDataThumbnailUrl';
export * from './siteStatus'; export * from './siteStatus';
export * from './siteVersionData'; export * from './siteVersionData';
export * from './siteVersionDataBuildError'; 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 { SiteDataTheme } from './siteDataTheme';
import type { SiteDataCurrentVersionId } from './siteDataCurrentVersionId'; import type { SiteDataCurrentVersionId } from './siteDataCurrentVersionId';
import type { SiteDataPublishedAt } from './siteDataPublishedAt'; import type { SiteDataPublishedAt } from './siteDataPublishedAt';
import type { SiteDataThumbnailUrl } from './siteDataThumbnailUrl';
export interface SiteData { export interface SiteData {
site_id: string; site_id: string;
@ -20,4 +21,5 @@ export interface SiteData {
theme?: SiteDataTheme; theme?: SiteDataTheme;
current_version_id?: SiteDataCurrentVersionId; current_version_id?: SiteDataCurrentVersionId;
published_at?: SiteDataPublishedAt; 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, ResLinkList,
ResPlace, ResPlace,
ResPlaceList, ResPlaceList,
ResPlaceSearch,
ResStartCollect, ResStartCollect,
ResStartCopy, ResStartCopy,
ResStartVision, ResStartVision,
ResUnit, ResUnit,
ResUnitList, ResUnitList,
ResVerifyCandidates, ResVerifyCandidates,
SearchPlacesPublicParams,
VerifyCandidatesParams VerifyCandidatesParams
} from '.././model'; } 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)이 채운다. * 상호명 하나로 시작한다. 주소·좌표는 동일 업소 검증(verify)이 채운다.
* @summary 사업장 등록 * @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 isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
const togglePreview = useBuilderStore((s) => s.togglePreview); const togglePreview = useBuilderStore((s) => s.togglePreview);
const openPublishModal = useBuilderStore((s) => s.openPublishModal); const openPublishModal = useBuilderStore((s) => s.openPublishModal);
const reset = useBuilderStore((s) => s.reset);
const placeId = useBuilderStore((s) => s.placeId); const placeId = useBuilderStore((s) => s.placeId);
const unverifiedCount = useUnverifiedFields().length; const unverifiedCount = useUnverifiedFields().length;
@ -37,20 +36,19 @@ export function EditorHeader() {
여기 링크를 남기면 사장님 앱에서 404 이고, 내부 경로 이름이 사장님 번들에도 남는다. 여기 링크를 남기면 사장님 앱에서 404 이고, 내부 경로 이름이 사장님 번들에도 남는다.
reset() 을 부르지 않는 이유는 그대로다: 편집하던 가게를 떠나 데모 위저드로 reset() 을 부르지 않는 이유는 그대로다: 편집하던 가게를 떠나 데모 위저드로
떨어지면 사장님 눈에는 자기 가게가 사라진 것으로 보인다. 떨어지면 사장님 눈에는 자기 가게가 사라진 것으로 보인다.
(사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절) */} (사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절)
★ 데모에서 돌아가는 길은 `?new=1` 이다 — 스토어를 직접 비우면 화면만 위저드로 바뀌고
주소는 그대로라, 뒤로가기가 편집기로 돌아오지 않는다(단계는 주소창이 소유한다). */}
{placeId ? ( {placeId ? (
<span className="group flex items-center gap-2"> // 편집 중이어도 로고는 홈으로 간다 — 눌리지 않는 로고는 고장으로 읽힌다.
// 입력값은 서버에 저장되므로 나갔다 들어와도 그대로다.
<Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
<LogoMark /> <LogoMark />
</span> </Link>
) : ( ) : (
<button <Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
type="button"
onClick={reset}
title="처음으로 이동"
className="group flex cursor-pointer items-center gap-2"
>
<LogoMark /> <LogoMark />
</button> </Link>
)} )}
<span className="hidden h-3.5 w-px bg-border sm:block" /> <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 {Check} from 'lucide-react';
import type {IndustryType} from '@o2o/shared'; import type {IndustryType} from '@o2o/shared';
import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
import {INDUSTRY_ICONS} from './industryIcons'; import {INDUSTRY_ICONS} from './industryIcons';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps'; import {WizardSteps} from './WizardSteps';
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic']; const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
/**
* 업종 직접 고르기 — `?step=industry`.
*
* ★ 이 화면은 더 이상 위저드의 첫 화면이 아니다. 업종은 상호 검색 결과의 분류가 정하고
* (services/place_category.py), 여기는 **못 정했을 때와 바꿀 때만** 들른다.
* 진행 표시에서 1번을 유지하는 이유도 같다 — 이건 '내 가게 확인' 안의 갈래다.
*
* ★ 못 정해서 들어온 경우에는 아무 카드도 선택된 것으로 보이지 않게 한다. 스토어에는 언제나
* 기본 업종이 들어 있어서, 그걸 그대로 칠하면 사장님은 자기가 고르지도 않은 업종이
* 이미 정해진 것으로 읽는다.
*/
export function Step1Industry() { export function Step1Industry() {
const industry = useBuilderStore((s) => s.industry); const industry = useBuilderStore((s) => s.industry);
const pendingPick = useBuilderStore((s) => s.pendingPick);
const selectIndustry = useBuilderStore((s) => s.selectIndustry); 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 ( return (
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <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"> <div className="mb-10 text-center sm:mb-12">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
어떤 업종의 홈페이지를 만들까요? {mustChoose ? '이 가게는 어떤 업종인가요?' : '업종을 바꿀까요?'}
</h1> </h1>
<p className="mx-auto mt-2.5 max-w-lg text-sm text-muted-foreground sm:text-base"> <p className="mx-auto mt-2.5 max-w-lg text-sm text-muted-foreground sm:text-base">
업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다. {mustChoose
? '지도 분류만으로는 업종을 정하지 못했습니다. 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'
: '업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'}
</p> </p>
</div> </div>
@ -33,13 +71,13 @@ export function Step1Industry() {
{INDUSTRY_ORDER.map((id) => { {INDUSTRY_ORDER.map((id) => {
const config = INDUSTRY_CONFIGS[id]; const config = INDUSTRY_CONFIGS[id];
const Icon = INDUSTRY_ICONS[id]; const Icon = INDUSTRY_ICONS[id];
const isSelected = industry === id; const isSelected = showSelection && industry === id;
return ( return (
<button <button
key={id} key={id}
type="button" type="button"
onClick={() => selectIndustry(id)} onClick={() => pick(id)}
aria-pressed={isSelected} aria-pressed={isSelected}
className={cn( className={cn(
'flex cursor-pointer flex-col justify-between rounded-2xl border bg-card p-6 text-left transition-all', '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 <WizardFooter
hint={ hint={
<> showSelection ? (
선택된 업종:{' '} <>
<strong className="font-semibold text-foreground"> 선택된 업종:{' '}
{INDUSTRY_CONFIGS[industry].name} <strong className="font-semibold text-foreground">
</strong>{' '} {INDUSTRY_CONFIGS[industry].name}
({INDUSTRY_CONFIGS[industry].subName}) </strong>{' '}
</> ({INDUSTRY_CONFIGS[industry].subName})
</>
) : (
'업종을 하나 골라 주세요.'
)
} }
onNext={() => goToStep(2)} onPrev={() => goToStep('search')}
nextLabel="다음: 내 가게 찾기" onNext={goBack}
nextDisabled={!showSelection}
nextLabel={pendingPick ? '이 업종으로 계속하기' : '내 가게 찾기로 돌아가기'}
/> />
</div> </div>
); );

View File

@ -1,19 +1,23 @@
import {useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {useSearchParams} from 'react-router'; import {ArrowRight, Check, MapPin, Search, TriangleAlert} from 'lucide-react';
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react'; import type {IndustryType} from '@o2o/shared';
import {getAccessToken, type PlaceCandidate} from '@/api'; import type {PlaceSearchItem} from '@/api';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; 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 {cn} from '@/lib/utils';
import type {PendingPick} from '@/stores/builder';
import {useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
import {PlaceUrlBox} from './PlaceUrlBox'; import {PlaceUrlBox} from './PlaceUrlBox';
import {usePlaceSearch} from './usePlaceSearch'; import {usePlaceSearch} from './usePlaceSearch';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardStage, WizardWaiting} from './WizardStage'; import {WizardStage, WizardWaiting} from './WizardStage';
const STAGE_TITLE: Record<string, string> = { const STAGE_TITLE: Record<string, string> = {
input: '상호명을 검색해 내 가게를 찾아주세요', input: '가게 이름을 알려주세요',
searching: '내 가게를 찾는 중입니다', searching: '내 가게를 찾는 중입니다',
picking: '이 중에 사장님 가게가 있나요?', picking: '이 중에 사장님 가게가 있나요?',
unavailable: '지도 검색을 사용할 수 없습니다', unavailable: '지도 검색을 사용할 수 없습니다',
@ -21,22 +25,20 @@ const STAGE_TITLE: Record<string, string> = {
}; };
const STAGE_DESCRIPTION: Record<string, string> = { const STAGE_DESCRIPTION: Record<string, string> = {
input: '지도·플레이스에 등록된 가게 중에서 사장님이 직접 고른 한 곳만 기준이 됩니다.', input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.',
searching: '', searching: '',
picking: '고른 가게의 상호·주소·전화가 이 사이트의 기준 정보가 됩니다.', picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.',
unavailable: '', unavailable: '',
confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.', 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 storeName = useBuilderStore((s) => s.storeName);
const location = useBuilderStore((s) => s.location); const location = useBuilderStore((s) => s.location);
const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity); const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity);
const pendingPick = useBuilderStore((s) => s.pendingPick);
const setStoreName = useBuilderStore((s) => s.setStoreName); const setStoreName = useBuilderStore((s) => s.setStoreName);
const setLocation = useBuilderStore((s) => s.setLocation); 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 confirmIdentity = useBuilderStore((s) => s.confirmIdentity);
const clearIdentity = useBuilderStore((s) => s.clearIdentity); const clearIdentity = useBuilderStore((s) => s.clearIdentity);
const goToStep = useBuilderStore((s) => s.goToStep);
// 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다). // 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다).
const search = usePlaceSearch(industry, confirmedIdentity?.placeId ?? null); const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [searchParams, setSearchParams] = useSearchParams(); const [, goToStep] = useWizardStep();
/**
* 확정된 사업장을 주소창에 남긴다 — `?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 [placeUrl, setPlaceUrl] = useState(''); const [placeUrl, setPlaceUrl] = useState('');
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching'; const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
const runSearch = () => { /**
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다 * 확정된 사업장을 주소창에 남기며 다음 단계로.
setPickedIndex(null); *
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데, * ★ `?placeId=` 가 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다. * (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로 떠 버린다.
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다. * 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로 열면 깨끗하다.
if (!getAccessToken()) { * ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 1단계"인
confirmIdentity(search.confirmManual(storeName, location)); * 주소가 히스토리에 한 칸 생기고, 뒤로가기가 그 칸에 걸린다.
goToStep(3); */
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; return;
} }
void search.search(storeName, location); selectIndustry(guessed);
void finishPick(pick, guessed);
};
const runSearch = () => {
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
void search.searchPublic(storeName, location);
}; };
const backToInput = () => { const backToInput = () => {
clearIdentity(); clearIdentity();
// 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다. // 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다.
rememberPlace(null); goToStep('search', {params: {placeId: null, flow: null}, replace: true});
setPickedIndex(null);
search.reset(); 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 주석 참고). */ /** 네이버 플레이스 URL 로 확정 — 가장 확실한 경로다(usePlaceSearch.confirmByUrl 주석 참고). */
const pickByUrl = async () => { const pickByUrl = async () => {
const url = placeUrl.trim(); const url = placeUrl.trim();
if (!url) return; if (!url) return;
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다. const identity = await search.confirmByUrl(url, industry);
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
const identity = await search.confirmByUrl(url);
if (!identity) return; if (!identity) return;
confirmIdentity(identity); confirmIdentity(identity);
rememberPlace(identity.placeId); advance(identity.placeId);
goToStep(3); };
/**
* 업종을 바꾸러 간다.
*
* ★ **서버에 사업장이 이미 만들어졌으면 신원 확인부터 다시 받는다.** 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 const stage = confirmedIdentity
? 'confirmed' ? 'confirmed'
: search.phase === 'searching' : search.phase === 'searching'
? 'searching' ? 'searching'
: search.phase === 'unavailable' : search.phase === 'unavailable'
? 'unavailable' ? 'unavailable'
: search.phase === 'done' : search.phase === 'done'
? 'picking' ? 'picking'
: 'input'; : 'input';
const searchQuery = [storeName, location].filter(Boolean).join(' ');
return ( return (
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage <WizardStage
current={2} current={1}
wide={stage === 'picking'} wide={stage === 'picking'}
title={STAGE_TITLE[stage]} title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]} description={STAGE_DESCRIPTION[stage]}
> >
{stage === 'input' && ( {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> <div>
<label htmlFor="store-name" className="mb-1.5 block text-xs font-semibold"> <label htmlFor="store-name" className="mb-1.5 block text-xs font-semibold">
상호명 <span className="text-destructive">*</span> 상호명 <span className="text-destructive">*</span>
@ -200,7 +229,6 @@ export function Step2PlaceSearch() {
<Search /> <Search />
<span>상호로 찾아보기</span> <span>상호로 찾아보기</span>
</Button> </Button>
</div>
</div> </div>
)} )}
@ -216,72 +244,51 @@ export function Step2PlaceSearch() {
<p <p
className={cn( className={cn(
'rounded-xl p-3 text-xs leading-relaxed', '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-border bg-card text-muted-foreground'
: 'border border-warning/30 bg-warning/10 text-warning', : 'border border-warning/30 bg-warning/10 text-warning',
)} )}
> >
{OUTCOME_TEXT[search.outcome] ?? '아래 목록에서 사장님 가게를 골라 주세요.'} {search.items.length > 0
{search.sourceLabel && ( ? '아래 목록에서 사장님 가게를 골라 주세요.'
<Badge variant="outline" className="ml-2 text-[10px]"> : '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
{search.sourceLabel}
</Badge>
)}
</p> </p>
{search.candidates.length > 0 && {search.pickError && (
!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">
<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" />
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" /> {search.pickError}
상호 후보는 찾았지만 네이버 플레이스는 찾지 못했습니다. 아래 버튼으로 네이버 </p>
지도에서 가게를 검색한 뒤 플레이스 URL을 붙여넣어 주세요. )}
</p>
)}
<ul className="space-y-2"> <ul className="space-y-2">
{search.candidates.map((candidate, index) => ( {search.items.map((item, index) => (
<li key={`${candidate.external_place_id ?? candidate.name}-${index}`}> <li key={`${item.name ?? ''}-${item.road_address ?? ''}-${index}`}>
<button <button
type="button" type="button"
onClick={() => void pick(candidate, index)} onClick={() => choose(item)}
disabled={search.isConfirming || !candidate.naver_place_url} 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" 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="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">{candidate.name}</span> <span className="text-sm font-bold">{item.name}</span>
{candidate.category_name && ( {item.category_name && (
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{candidate.category_name} {item.category_name}
</Badge> </Badge>
)} )}
<Badge {/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
variant={candidate.naver_place_url ? 'success' : 'warning'} <IndustryBadge category={item.category} />
className="text-[10px]"
>
{candidate.naver_place_url
? '네이버 플레이스 찾음'
: '네이버 플레이스 못 찾음'}
</Badge>
</div> </div>
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground"> <p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
<MapPin className="mt-px size-3 shrink-0" /> <MapPin className="mt-px size-3 shrink-0" />
<span>{candidate.road_address ?? candidate.address ?? '주소 없음'}</span> <span>{item.road_address ?? '주소 없음'}</span>
</p> </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> </div>
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground"> <span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground">
{!candidate.naver_place_url {search.isConfirming ? '확정 중...' : '이 가게예요'}
? 'URL 필요'
: search.isConfirming && pickedIndex === index
? '확정 중...'
: '이 가게예요'}
</span> </span>
</div> </div>
</button> </button>
@ -289,25 +296,26 @@ export function Step2PlaceSearch() {
))} ))}
</ul> </ul>
{!search.candidates.some((candidate) => candidate.naver_place_url) ? ( {/* 목록에서 고르면 업종은 그 후보가 정한다 — 이 줄은 아래 '주소로 확정' 경로용이다. */}
<PlaceUrlBox <IndustryLine
value={placeUrl} label="주소로 확정할 때 쓸 업종"
onChange={setPlaceUrl} industry={industry}
onSubmit={() => void pickByUrl()} onChange={changeIndustry}
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(' ')}
/>
)}
<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> </div>
)} )}
@ -317,12 +325,19 @@ export function Step2PlaceSearch() {
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" /> <TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
{search.unavailableReason} {search.unavailableReason}
</p> </p>
<IndustryLine
label="주소로 확정할 때 쓸 업종"
industry={industry}
onChange={changeIndustry}
/>
<PlaceUrlBox <PlaceUrlBox
value={placeUrl} value={placeUrl}
onChange={setPlaceUrl} onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()} onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming} isBusy={search.isConfirming}
searchQuery={[storeName, location].filter(Boolean).join(' ')} searchQuery={searchQuery}
/> />
<Button variant="outline" className="w-full" onClick={runSearch}> <Button variant="outline" className="w-full" onClick={runSearch}>
@ -357,17 +372,25 @@ export function Step2PlaceSearch() {
</> </>
) : ( ) : (
<Badge variant="warning" className="text-[10px]"> <Badge variant="warning" className="text-[10px]">
직접 입력 · 동일 업소 미검증 {confirmedIdentity.sourceLabel} · 동일 업소 미검증
</Badge> </Badge>
)} )}
</div> </div>
</div> </div>
<IndustryLine
industry={industry}
onChange={changeIndustry}
note={
identityIsOnServer ? '업종을 바꾸면 가게 확인을 다시 받습니다.' : undefined
}
/>
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
className="w-full" className="w-full"
onClick={() => goToStep(3)} onClick={() => advance(confirmedIdentity.placeId)}
> >
<span>다음: 이 가게 정보 수집하기</span> <span>다음: 이 가게 정보 수집하기</span>
<ArrowRight /> <ArrowRight />
@ -386,11 +409,61 @@ export function Step2PlaceSearch() {
? `${confirmedIdentity.name} 기준으로 진행합니다.` ? `${confirmedIdentity.name} 기준으로 진행합니다.`
: '내 가게를 확인해야 다음 단계로 갈 수 있습니다.' : '내 가게를 확인해야 다음 단계로 갈 수 있습니다.'
} }
onPrev={() => goToStep(1)} onNext={() => advance(confirmedIdentity?.placeId ?? null)}
onNext={() => goToStep(3)}
nextLabel="다음: 데이터 수집" nextLabel="다음: 데이터 수집"
nextDisabled={!confirmedIdentity} nextDisabled={!confirmedIdentity}
/> />
</div> </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 {ChannelConfirmPanel} from './ChannelConfirmPanel';
import {ChannelUrlInput} from './ChannelUrlInput'; import {ChannelUrlInput} from './ChannelUrlInput';
import {useCollectFlow} from './useCollectFlow'; import {useCollectFlow} from './useCollectFlow';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardStage, WizardWaiting} from './WizardStage'; import {WizardStage, WizardWaiting} from './WizardStage';
@ -29,7 +30,7 @@ const STAGE_DESCRIPTION: Record<string, string> = {
review: '찾은 값은 전부 출처와 함께 보여드립니다. 사장님이 [맞아요]를 누른 값만 사이트와 AI 검색 답변에 나갑니다.', review: '찾은 값은 전부 출처와 함께 보여드립니다. 사장님이 [맞아요]를 누른 값만 사이트와 AI 검색 답변에 나갑니다.',
}; };
/** 채널 URL 확인과 수집 결과 검토 단계. */ /** 채널 URL 확인과 수집 결과 검토 단계(진행 표시 2번). */
export function Step3DataReview() { export function Step3DataReview() {
const industry = useBuilderStore((s) => s.industry); const industry = useBuilderStore((s) => s.industry);
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
@ -37,7 +38,7 @@ export function Step3DataReview() {
const gatherCompleted = useBuilderStore((s) => s.gatherCompleted); const gatherCompleted = useBuilderStore((s) => s.gatherCompleted);
const infoFields = useBuilderStore((s) => s.infoFields); const infoFields = useBuilderStore((s) => s.infoFields);
const photos = useBuilderStore((s) => s.photos); const photos = useBuilderStore((s) => s.photos);
const goToStep = useBuilderStore((s) => s.goToStep); const [, goToStep] = useWizardStep();
const unverified = useUnverifiedFields(); const unverified = useUnverifiedFields();
const placeId = confirmedIdentity?.placeId ?? null; const placeId = confirmedIdentity?.placeId ?? null;
@ -91,7 +92,7 @@ export function Step3DataReview() {
return ( return (
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage <WizardStage
current={3} current={2}
wide={stage === 'review' || stage === 'channels'} wide={stage === 'review' || stage === 'channels'}
title={STAGE_TITLE[stage]} title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]} description={STAGE_DESCRIPTION[stage]}
@ -178,14 +179,14 @@ export function Step3DataReview() {
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면 가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
주소 붙여넣기와 자동 찾기가 모두 열립니다. 주소 붙여넣기와 자동 찾기가 모두 열립니다.
</p> </p>
<Button variant="outline" className="w-full" onClick={() => goToStep(4)}> <Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
<span>수집 없이 다음 단계로</span> <span>수집 없이 다음 단계로</span>
<ArrowRight /> <ArrowRight />
</Button> </Button>
</> </>
)} )}
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep(2)}> <Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep('search')}>
가게를 다시 찾을게요 가게를 다시 찾을게요
</Button> </Button>
</div> </div>
@ -302,7 +303,7 @@ export function Step3DataReview() {
</div> </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> <span>다음: 템플릿 고르기</span>
<ArrowRight /> <ArrowRight />
</Button> </Button>
@ -354,8 +355,8 @@ export function Step3DataReview() {
'수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.' '수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.'
) )
} }
onPrev={() => goToStep(2)} onPrev={() => goToStep('search')}
onNext={() => goToStep(4)} onNext={() => goToStep('template')}
nextLabel="다음: 템플릿 선택" nextLabel="다음: 템플릿 선택"
/** /**
* ★ 수집 전에는 잠근다. * ★ 수집 전에는 잠근다.

View File

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

View File

@ -1,12 +1,12 @@
import {useCallback, useEffect} from 'react'; import {useCallback, useEffect} from 'react';
import {useSearchParams} from 'react-router';
import {Check, Loader2} from 'lucide-react'; import {Check, Loader2} from 'lucide-react';
import {JobStatus} from '@o2o/shared'; import {JobStatus} from '@o2o/shared';
import {delay, getAccessToken, pollJob, startCopy} from '@/api'; import {delay, getAccessToken, pollJob, startCopy} from '@/api';
import {Progress} from '@/components/ui/progress'; import {Progress} from '@/components/ui/progress';
import {notify, notifyApiError} from '@/lib/notify'; import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils'; 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 = [ const BUILD_STEPS = [
'수집된 사진 분류 및 대체 텍스트 생성', '수집된 사진 분류 및 대체 텍스트 생성',
@ -30,9 +30,8 @@ export function Step5Generating() {
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
const stage = useBuilderStore((s) => s.generateStage); const stage = useBuilderStore((s) => s.generateStage);
const setGenerateStage = useBuilderStore((s) => s.setGenerateStage); const setGenerateStage = useBuilderStore((s) => s.setGenerateStage);
const goToStep = useBuilderStore((s) => s.goToStep);
const placeId = useBuilderStore((s) => s.placeId); const placeId = useBuilderStore((s) => s.placeId);
const [searchParams, setSearchParams] = useSearchParams(); const [, goToStep] = useWizardStep();
/** /**
* 생성이 끝나면 곧바로 에디터다. * 생성이 끝나면 곧바로 에디터다.
@ -43,11 +42,10 @@ export function Step5Generating() {
* '목록으로 보내는 것'은 다른 얘기다. * '목록으로 보내는 것'은 다른 얘기다.
*/ */
const finishOnboarding = useCallback(() => { const finishOnboarding = useCallback(() => {
const next = new URLSearchParams(searchParams); // ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인
next.delete('flow'); // 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다.
setSearchParams(next, {replace: true}); goToStep(EDITOR_STEP, {params: {flow: null}, replace: true});
goToStep(EDITOR_STEP); }, [goToStep]);
}, [goToStep, searchParams, setSearchParams]);
/** /**
* 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다. * 소개문·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 = [ const STEPS = [
{step: 1, label: '업종'}, {step: 1, label: '내 가게 확인'},
{step: 2, label: '내 가게 확인'}, {step: 2, label: '수집 정보 확인'},
{step: 3, label: '수집 정보 확인'}, {step: 3, label: '템플릿'},
{step: 4, label: '템플릿'},
] as const; ] as const;
export function WizardSteps({current}: {current: number}) { 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 {Step1Industry} from './Step1Industry';
export {Step2PlaceSearch} from './Step2PlaceSearch'; export {Step2PlaceSearch} from './Step2PlaceSearch';
export {Step3DataReview} from './Step3DataReview'; export {Step3DataReview} from './Step3DataReview';

View File

@ -1,21 +1,21 @@
import {useCallback, useRef, useState} from 'react'; import {useCallback, useRef, useState} from 'react';
import type {IndustryType} from '@o2o/shared'; import type {IndustryType} from '@o2o/shared';
import type {ExternalPlaceSource as ExternalPlaceSourceType, PlaceCandidate, PlaceCategory} from '@/api'; import type {PlaceCategory, PlaceSearchItem} from '@/api';
import { import {
createPlace, createPlace,
ExternalPlaceSource,
getAccessToken, getAccessToken,
PlaceCategory as PlaceCategoryEnum, PlaceCategory as PlaceCategoryEnum,
searchPlacesPublic,
updatePlace, updatePlace,
verifyCandidates, verifyCandidates,
verifyPlace,
verifyPlaceByUrl, verifyPlaceByUrl,
} from '@/api'; } from '@/api';
import type {ConfirmedIdentity} from '@/stores/builder'; import type {ConfirmedIdentity} from '@/stores/builder';
import {ensureAutoSession} from '@/lib/autoSession'; import {ensureAutoSession} from '@/lib/autoSession';
import {describeError} from '@/lib/errorMessages';
import {notify, notifyApiError} from '@/lib/notify'; 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> = { const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
stay: PlaceCategoryEnum.LODGING, stay: PlaceCategoryEnum.LODGING,
cafe: PlaceCategoryEnum.CAFE, cafe: PlaceCategoryEnum.CAFE,
@ -23,21 +23,15 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
clinic: PlaceCategoryEnum.CLINIC, clinic: PlaceCategoryEnum.CLINIC,
}; };
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
const SOURCE_LABEL: Record<number, string> = {
[ExternalPlaceSource.KAKAO]: '카카오맵',
[ExternalPlaceSource.NAVER]: '네이버 지도',
};
export type SearchPhase = export type SearchPhase =
/** 아직 검색 전. */ /** 아직 검색 전. */
| 'idle' | 'idle'
/** 사업장 생성 + 후보 조회 중. */ /** 공개 검색 중. */
| 'searching' | 'searching'
/** 후보를 받았다(0건일 수도 있다 — outcome 이 no_candidate). */ /** 후보를 받았다(0건일 수도 있다). */
| 'done' | 'done'
/** /**
* 장소 DB 를 부를 수 없다 — 로그인이 없거나 백엔드가 응답하지 않는다. * 장소 DB 를 부를 수 없다 — 키가 없거나, 분당 호출을 넘겼거나, 백엔드가 응답하지 않는다.
* ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는 * ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는
* 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다. * 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다.
*/ */
@ -45,41 +39,47 @@ export type SearchPhase =
export interface PlaceSearchState { export interface PlaceSearchState {
phase: SearchPhase; phase: SearchPhase;
/** 외부 장소 DB 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */ /** 공개 검색 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
candidates: PlaceCandidate[]; items: PlaceSearchItem[];
/** matched | ambiguous | no_candidate — 서버 판정. 문구를 고르는 데만 쓴다. */ /** 검색을 못 한 이유. phase === 'unavailable' 일 때만 채워진다. */
outcome: string;
/** true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다. */
autoSelectable: boolean;
/** 후보를 준 장소 DB 이름. 없으면 빈 문자열. */
sourceLabel: string;
source: ExternalPlaceSourceType | null;
/** 검색을 못 한 이유(로그인 필요 등). phase === 'unavailable' 일 때만 채워진다. */
unavailableReason: string; unavailableReason: string;
/**
* 고른 후보를 자동으로 확정하지 못한 이유.
* ★ 실패를 조용히 삼키면 사장님은 "눌렀는데 아무 일도 안 일어난다"만 겪는다 —
* 네이버 플레이스 URL 붙여넣기로 넘어가야 한다는 걸 여기서 말한다.
*/
pickError: string;
} }
const INITIAL: PlaceSearchState = { const INITIAL: PlaceSearchState = {
phase: 'idle', phase: 'idle',
candidates: [], items: [],
outcome: '',
autoSelectable: false,
sourceLabel: '',
source: null,
unavailableReason: '', unavailableReason: '',
pickError: '',
}; };
/** 상호명 비교용. 공백·괄호 표기가 후보마다 달라 그대로 비교하면 같은 가게도 안 맞는다. */
function normalizeName(name: string | undefined | null): string {
return (name ?? '').replace(/\s+/g, '').toLowerCase();
}
/** /**
* 상호 검색 → 동일 업소 후보 조회 → 사람이 고른 후보로 확정. * 상호명 공개 검색 → 사람이 고른 후보로 확정.
* *
* 백엔드 순서가 그대로 화면 순서다: * ★ 목록을 주는 건 **공개 검색**(`GET /v1/place/search`)이다. 인증이 없어 로그인 전에도 돌고,
* 1) POST /v1/place 사업장(껍데기)을 만든다 — 후보 조회가 place_id 를 요구한다 * 후보마다 추정 업종(category)이 함께 온다 — 업종을 사장님에게 먼저 묻지 않기로 한 결정이
* 2) GET /v1/place/{id}/verify/candidates?query= 외부 장소 DB 후보 * API 까지 내려온 자리다. 인증이 필요한 후보 조회(verify/candidates)는 목록을 그리는 데
* 3) POST /v1/place/{id}/verify 사람이 고른 후보로 동일 업소 확정 * 쓰지 않고, 고른 뒤 **네이버 플레이스 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 확정이 끝나야 시작할 수 있다"). * ★ 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 [state, setState] = useState<PlaceSearchState>(INITIAL);
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
/** /**
@ -88,8 +88,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* 되살아난 화면이 같은 가게를 한 번 더 만든다. * 되살아난 화면이 같은 가게를 한 번 더 만든다.
*/ */
const placeIdRef = useRef<string | null>(existingPlaceId); const placeIdRef = useRef<string | null>(existingPlaceId);
/** 그 사업장을 만들 때 쓴 업종. 업종이 바뀌면 재사용할 수 없다(아래 ensurePlace). */
const placeCategoryRef = useRef<PlaceCategory | null>(null);
const inflight = useRef<AbortController | null>(null); const inflight = useRef<AbortController | null>(null);
/** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */ /** 마지막으로 확정 시도한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
const nameRef = useRef<string>(''); const nameRef = useRef<string>('');
const reset = useCallback(() => { const reset = useCallback(() => {
@ -98,128 +100,76 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
setState(INITIAL); setState(INITIAL);
}, []); }, []);
/** 사업장 껍데기 확보. 이미 만들었으면 상호만 맞춰 둔다(같은 위저드에서 두 번 만들지 않는다). */ /**
* 사업장 껍데기 확보.
*
* ★ 업종이 달라졌으면 **새로 만든다.** places.category 는 생성할 때만 정해지고 PATCH 로 고칠
* 수 없다(Req_UpdatePlace 에 category 가 없다). 그대로 재사용하면 카페로 만든 사업장에
* 숙박 스키마로 수집이 돌고 JSON-LD 타입도 어긋난다 — 화면도 빌드도 멀쩡한 채로 틀린다.
*/
const ensurePlace = useCallback( const ensurePlace = useCallback(
async (name: string, signal: AbortSignal): Promise<string | null> => { async (name: string, category: PlaceCategory, signal: AbortSignal): Promise<string | null> => {
if (placeIdRef.current) { const reusable =
placeIdRef.current !== null &&
(placeCategoryRef.current === null || placeCategoryRef.current === category);
if (reusable && placeIdRef.current) {
// PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다. // PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다.
await updatePlace(placeIdRef.current, {name}); await updatePlace(placeIdRef.current, {name});
if (signal.aborted) return null; if (signal.aborted) return null;
return placeIdRef.current; return placeIdRef.current;
} }
const created = await createPlace( const created = await createPlace({name, category}, undefined, signal);
{name, category: INDUSTRY_TO_CATEGORY[industry]},
undefined,
signal,
);
placeIdRef.current = created.place?.place_id ?? null; placeIdRef.current = created.place?.place_id ?? null;
placeCategoryRef.current = category;
return placeIdRef.current; 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(); inflight.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
inflight.current = controller; inflight.current = controller;
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자 setState({...INITIAL, phase: 'searching'});
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다. try {
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다. const res = await searchPlacesPublic({q: query}, undefined, controller.signal);
if (!getAccessToken()) { if (controller.signal.aborted) return;
setState({...INITIAL});
return;
}
setState({...INITIAL, phase: 'searching'}); // ★ 이 엔드포인트의 거절은 HTTP 200 + result.success=false 로 온다(RemoveNoneResponse).
// 상태코드만 보면 "후보 0건"과 "분당 20회 초과"가 같은 화면이 된다.
try { if (res.result?.success === false) {
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, '상호를 검색하지 못했습니다.');
setState({ setState({
...INITIAL, ...INITIAL,
phase: 'unavailable', phase: 'unavailable',
unavailableReason: unavailableReason:
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.', describeError(res.result.desc) ?? '지도 검색을 사용할 수 없습니다.',
}); });
return;
} }
},
[ensurePlace],
);
/** setState({...INITIAL, phase: 'done', items: res.items ?? []});
* 사람이 고른 후보를 이 사업장의 신원으로 확정한다(POST /verify). } catch (error) {
* 성공하면 화면에 얹을 신원을 돌려준다 — 실패하면 null. if (controller.signal.aborted) return;
*/ notifyApiError(error, '상호를 검색하지 못했습니다.');
const confirm = useCallback( setState({
async (candidate: PlaceCandidate): Promise<ConfirmedIdentity | null> => { ...INITIAL,
const placeId = placeIdRef.current; phase: 'unavailable',
const address = candidate.road_address ?? candidate.address ?? ''; unavailableReason:
if (!placeId) return null; '장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
});
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],
);
/** /**
* 네이버 플레이스 URL 로 확정한다. * 네이버 플레이스 URL 로 확정한다.
@ -230,10 +180,14 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다). * 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다).
*/ */
const confirmByUrl = useCallback( const confirmByUrl = useCallback(
async (url: string): Promise<ConfirmedIdentity | null> => { async (url: string, industry: IndustryType): Promise<ConfirmedIdentity | null> => {
setIsConfirming(true); setIsConfirming(true);
try { 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; if (!placeId) return null;
const res = await verifyPlaceByUrl(placeId, {url}); const res = await verifyPlaceByUrl(placeId, {url});
const place = res.place; const place = res.place;
@ -261,20 +215,81 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
); );
/** /**
* 목록에 없거나 장소 DB 를 못 부를 때 — 사장님이 직접 댄 값으로 신원을 세운다. * 목록에 없거나 로그인 전이라 검증을 못 걸 때 — 사장님이 고른 값으로 신원만 세운다.
* *
* ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을 * ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을
* 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다. * 긁을 수 있다. 그래서 이 경로는 수집 없이 이 값들로만 사이트를 만든다.
*/ */
const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => { const confirmManual = useCallback(
return { (name: string, address: string, sourceLabel = '직접 입력'): ConfirmedIdentity => ({
placeId: placeIdRef.current, placeId: placeIdRef.current,
name: name.trim(), name: name.trim(),
address: address.trim(), address: address.trim(),
origin: 'owner', 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'; import type {ApplyPlaceOptions} from '@/stores/builderTypes';
export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions) { export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions) {
const enterEditor = options?.enterEditor ?? true; const isOnboarding = options?.isOnboarding ?? false;
const enabled = Boolean(placeId); const enabled = Boolean(placeId);
const id = placeId ?? ''; const id = placeId ?? '';
@ -42,8 +42,8 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
/** /**
* ★ useEffect 가 아니라 useLayoutEffect 다. * ★ useEffect 가 아니라 useLayoutEffect 다.
* 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 위저드 1단계가 한 번 * 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 빈 편집기가 한 번 그려졌다가
* 그려졌다가 편집기로 바뀐다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다. * 값이 채워진다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다.
*/ */
useLayoutEffect(() => { useLayoutEffect(() => {
if (!placeId) { if (!placeId) {
@ -53,8 +53,10 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
if (!place) return; if (!place) return;
// fact 가 아직 안 왔어도 먼저 온 place 로 상호·주소부터 얹는다. // fact 가 아직 안 왔어도 먼저 온 place 로 상호·주소부터 얹는다.
// fact/스키마가 도착하면 이 effect 가 한 번 더 돌아 정보 표를 채운다. // fact/스키마가 도착하면 이 effect 가 한 번 더 돌아 정보 표를 채운다.
applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {enterEditor}); applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, enterEditor]); 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_NOT_VERIFIED: '동일 업소 검증을 먼저 끝내야 합니다. 검증 전에는 수집·발행이 열리지 않습니다.',
PLACE_VERIFY_NO_CANDIDATE: '외부 장소 정보에서 이 상호를 찾지 못했습니다.', PLACE_VERIFY_NO_CANDIDATE: '외부 장소 정보에서 이 상호를 찾지 못했습니다.',
PLACE_VERIFY_AMBIGUOUS: '같은 이름의 업소가 여럿입니다. 어느 곳인지 골라 주세요.', PLACE_VERIFY_AMBIGUOUS: '같은 이름의 업소가 여럿입니다. 어느 곳인지 골라 주세요.',
// 상호명 공개 검색(로그인 전 첫 화면)이 만나는 세 가지. 인증이 없는 경로라 분당 상한이 걸려 있다.
HTTP_TO_MANY_REQUEST: '검색을 너무 자주 했습니다. 잠시 뒤 다시 시도해 주세요.',
LOCAL_NOT_CONFIGURED: '지도 검색이 아직 설정되지 않았습니다. 네이버 지도 주소를 붙여넣어 진행해 주세요.',
LOCAL_FETCH_FAILED: '지도에서 가게를 찾지 못했습니다. 잠시 뒤 다시 시도하거나 지도 주소를 붙여넣어 주세요.',
LINK_NOT_CONFIRMED: '확정한 채널 URL 만 수집 대상이 됩니다.', LINK_NOT_CONFIRMED: '확정한 채널 URL 만 수집 대상이 됩니다.',
// fact // 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 {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router'; import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared'; import {SiteStatus, type IndustryType} from '@o2o/shared';
import {getAccessToken} from '@/api'; import {getAccessToken} from '@/api';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate'; import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import { import {
EDITOR_STEP,
Step1Industry, Step1Industry,
Step2PlaceSearch, Step2PlaceSearch,
Step3DataReview, Step3DataReview,
Step4Template, Step4Template,
Step5Generating, Step5Generating,
useWizardStep,
} from '@/features/onboarding'; } from '@/features/onboarding';
import {EditorLayout} from '@/features/builder'; import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin'; import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync'; import {usePlaceSync} from '@/hooks/usePlaceSync';
import {userLabel, useAuthStore} from '@/stores/auth'; 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; 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() { export function BuilderPage() {
useAutoLogin(); useAutoLogin();
/** /**
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`. * ★ 이 화면이 무엇을 그릴지는 **전부 주소창이 정한다.**
* *
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고 * ?step= 어느 단계인가(없으면 wizardUrl.defaultStep 이 정한다)
* (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면 * ?placeId= 어떤 사업장인가 — 라우트(`/builder/:placeId`)로 받지 않는 이유는
* 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다. * 빌더가 로그인 없이 도는 경로이고 placeId 는 있을 수도 없을 수도 있어서다.
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다. * ?flow=onboarding 위저드가 방금 만든 사업장이다(딥링크로 편집하러 온 것과 구분한다)
* ?new=1 ?q= ?industry= 랜딩에서 넘어온 입구. 한 번 읽고 주소창에서 지운다.
*/ */
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [step, goToStep] = useWizardStep();
const urlPlaceId = searchParams.get('placeId'); 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 reset = useBuilderStore((s) => s.reset);
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
const setStoreName = useBuilderStore((s) => s.setStoreName);
const isNew = searchParams.get('new') === '1'; const isNew = searchParams.get('new') === '1';
const seedQuery = searchParams.get('q');
const seedIndustry = searchParams.get('industry');
useEffect(() => { useEffect(() => {
if (!isNew) return; if (!isNew && seedQuery === null && seedIndustry === null) return;
reset(); if (isNew) reset();
setSearchParams({}, {replace: true}); // 순서가 뒤집히면 안 된다 — selectIndustry 는 시드를 통째로 갈아 상호를 비운다.
}, [isNew, reset, setSearchParams]); 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개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다. * 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다.
@ -69,21 +101,8 @@ export function BuilderPage() {
*/ */
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null); const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
const placeId = urlPlaceId ?? wizardPlaceId; const placeId = urlPlaceId ?? wizardPlaceId;
/** const sync = usePlaceSync(placeId, {isOnboarding});
* 에디터로 바로 들어갈지.
*
* ★ **처음 들어온 순간**의 주소창만 본다. 위저드 2단계가 확정 뒤에 `?placeId=` 를 붙이는데
* (새로고침 복원용), 그걸 실시간으로 보면 "URL 붙여넣고 확인 → 곧장 에디터" 가 된다 —
* 수집 결과를 보여주는 3·4·5단계가 통째로 건너뛰어진다. 실제로 그렇게 됐다.
* 사업장 목록에서 딥링크로 들어온 경우(이미 만든 가게를 여는 것)만 에디터로 보낸다.
*/
const enteredWithPlace = useRef(
Boolean(urlPlaceId) && searchParams.get('flow') !== 'onboarding',
).current;
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다. // 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@ -128,7 +147,7 @@ export function BuilderPage() {
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다. // 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
if (step === EDITOR_STEP && !isSignedIn) { if (step === EDITOR_STEP && !isSignedIn) {
return <EditorSignInGate onBack={() => goToStep(4)} />; return <EditorSignInGate onBack={() => goToStep('template')} />;
} }
if (step === EDITOR_STEP) { if (step === EDITOR_STEP) {
@ -225,7 +244,9 @@ export function BuilderPage() {
return ( return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground"> <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"> <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 ? ( {isSignedIn ? (
<Link <Link
@ -246,11 +267,11 @@ export function BuilderPage() {
</div> </div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{step === 1 && <Step1Industry />} {step === 'search' && <Step2PlaceSearch />}
{step === 2 && <Step2PlaceSearch />} {step === 'industry' && <Step1Industry />}
{step === 3 && <Step3DataReview />} {step === 'collect' && <Step3DataReview />}
{step === 4 && <Step4Template />} {step === 'template' && <Step4Template />}
{step === 5 && <Step5Generating />} {step === 'generating' && <Step5Generating />}
</div> </div>
</div> </div>
); );

View File

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

View File

@ -8,23 +8,9 @@
import type {FactStatus, IndustryType, InfoField, PhotoItem} from '@o2o/shared'; import type {FactStatus, IndustryType, InfoField, PhotoItem} from '@o2o/shared';
/** /**
* 위저드 단계. * ★ 위저드 단계는 여기 없다 — 주소창이 소유한다(`features/onboarding/wizardUrl`).
* * 스토어에 두면 뒤로가기·북마크가 단계를 못 따라온다.
* 1 업종 선택
* 2 상호 검색 → **후보 목록에서 내 가게 확인** ← 남의 가게가 섞이면 제일 비싼 실수다
* 3 수집된 데이터 확인 ← [맞아요]/[아니에요] 가 여기서 끝난다
* 4 템플릿 선택
* 5 생성 중
* 6 에디터
*
* ★ 2 와 3 은 성격이 다른 확인이라 한 화면에 두지 않는다.
* 2 는 "이 가게가 맞나" (신원), 3 은 "이 값이 맞나" (사실). 신원이 틀린 채로
* 사실을 확인시키면 사장님이 남의 가게 정보를 자기 것이라고 승인하게 된다.
*/ */
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'; export type RightTab = 'content' | 'design' | 'photos' | 'info' | 'faq' | 'verify';
/** /**
@ -97,11 +83,26 @@ export interface ConfirmedIdentity {
export interface ApplyPlaceOptions { export interface ApplyPlaceOptions {
/** /**
* 처음 얹는 사업장일 때 곧장 에디터로 보낼지. * 위저드 안에서 열린 사업장인가(`?flow=onboarding`).
* *
* ★ 기본은 true — 사업장 목록에서 딥링크로 들어온 경우다(이미 등록된 가게를 여는 것이므로 * ★ 화면을 옮기지는 않는다 — 그건 주소창(`?step=`)의 일이다. 이 값이 하는 일은 하나뿐:
* 위저드를 다시 걷게 하지 않는다). 반대로 위저드 3단계는 **수집 결과를 위저드 안에서** * 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있으므로, 서버에서 읽은 사업장으로
* 보여줘야 하므로 false 로 부른다. 안 그러면 수집이 끝나는 순간 화면이 에디터로 튄다. * 그걸 다시 세운다. 없으면 3단계가 "가게를 아직 안 골랐다"고 판단해 수집을 열지 않는다.
*/ */
enterEditor?: boolean; isOnboarding?: boolean;
}
/**
* 공개 검색에서 고른, **아직 업종이 안 정해진** 후보.
*
* ★ 업종 화면(`?step=industry`)을 다녀오는 동안만 산다. 사장님이 고른 업종을 들고 돌아와야
* 그 업종으로 사업장을 만들 수 있어서다(사업장의 category 는 만들 때 정해진다).
* ★ 브라우저에 저장하지 않는다(위저드 상태 규칙). 새로고침하면 사라지고 검색부터 다시 한다 —
* 확정 전이라 서버에도 아직 아무것도 없다.
*/
export interface PendingPick {
name: string;
address: string;
/** 업종 화면에서 고른 값. 이게 채워져야 확정이 이어진다. */
industry?: IndustryType;
} }