[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:
parent
41e49c693b
commit
a61f9724ea
@ -56,6 +56,7 @@ export * from './listLinksParams';
|
||||
export * from './listMediaParams';
|
||||
export * from './listMySitesParams';
|
||||
export * from './listPlacesParams';
|
||||
export * from './listShowcaseParams';
|
||||
export * from './localContentData';
|
||||
export * from './localContentDataBody';
|
||||
export * from './localContentDataCollectedAt';
|
||||
@ -110,6 +111,10 @@ export * from './placeDataPhone';
|
||||
export * from './placeDataRegionCode';
|
||||
export * from './placeDataRoadAddress';
|
||||
export * from './placeDataVerifiedAt';
|
||||
export * from './placeSearchItem';
|
||||
export * from './placeSearchItemCategory';
|
||||
export * from './placeSearchItemCategoryName';
|
||||
export * from './placeSearchItemRoadAddress';
|
||||
export * from './placeStatus';
|
||||
export * from './publishAction';
|
||||
export * from './publishLogData';
|
||||
@ -231,6 +236,9 @@ export * from './resPlaceList';
|
||||
export * from './resPlaceListMsg';
|
||||
export * from './resPlaceMsg';
|
||||
export * from './resPlacePlace';
|
||||
export * from './resPlaceSearch';
|
||||
export * from './resPlaceSearchMsg';
|
||||
export * from './resPlaceSearchSource';
|
||||
export * from './resPublishLogs';
|
||||
export * from './resPublishLogsMsg';
|
||||
export * from './resRefreshToken';
|
||||
@ -239,6 +247,8 @@ export * from './resSeoAudit';
|
||||
export * from './resSeoAuditMsg';
|
||||
export * from './resSeoAuditSummary';
|
||||
export * from './resSeoAuditVisibility';
|
||||
export * from './resShowcase';
|
||||
export * from './resShowcaseMsg';
|
||||
export * from './resSite';
|
||||
export * from './resSiteCurrentVersion';
|
||||
export * from './resSiteMsg';
|
||||
@ -284,6 +294,10 @@ export * from './resVerifyCandidatesSource';
|
||||
export * from './resWeather';
|
||||
export * from './resWeatherMsg';
|
||||
export * from './resWeatherWeather';
|
||||
export * from './searchPlacesPublicParams';
|
||||
export * from './showcaseItem';
|
||||
export * from './showcaseItemRegion';
|
||||
export * from './showcaseItemThumbnailUrl';
|
||||
export * from './siteData';
|
||||
export * from './siteDataCurrentVersionId';
|
||||
export * from './siteDataDomain';
|
||||
@ -291,6 +305,7 @@ export * from './siteDataPublishedAt';
|
||||
export * from './siteDataTemplateId';
|
||||
export * from './siteDataTheme';
|
||||
export * from './siteDataThemeAnyOf';
|
||||
export * from './siteDataThumbnailUrl';
|
||||
export * from './siteStatus';
|
||||
export * from './siteVersionData';
|
||||
export * from './siteVersionDataBuildError';
|
||||
|
||||
@ -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;
|
||||
};
|
||||
24
solution/frontend/src/api/generated/model/placeSearchItem.ts
Normal file
24
solution/frontend/src/api/generated/model/placeSearchItem.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
23
solution/frontend/src/api/generated/model/resPlaceSearch.ts
Normal file
23
solution/frontend/src/api/generated/model/resPlaceSearch.ts
Normal 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[];
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
15
solution/frontend/src/api/generated/model/resShowcase.ts
Normal file
15
solution/frontend/src/api/generated/model/resShowcase.ts
Normal 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[];
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
};
|
||||
24
solution/frontend/src/api/generated/model/showcaseItem.ts
Normal file
24
solution/frontend/src/api/generated/model/showcaseItem.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -10,6 +10,7 @@ import type { SiteDataTemplateId } from './siteDataTemplateId';
|
||||
import type { SiteDataTheme } from './siteDataTheme';
|
||||
import type { SiteDataCurrentVersionId } from './siteDataCurrentVersionId';
|
||||
import type { SiteDataPublishedAt } from './siteDataPublishedAt';
|
||||
import type { SiteDataThumbnailUrl } from './siteDataThumbnailUrl';
|
||||
|
||||
export interface SiteData {
|
||||
site_id: string;
|
||||
@ -20,4 +21,5 @@ export interface SiteData {
|
||||
theme?: SiteDataTheme;
|
||||
current_version_id?: SiteDataCurrentVersionId;
|
||||
published_at?: SiteDataPublishedAt;
|
||||
thumbnail_url?: SiteDataThumbnailUrl;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -41,12 +41,14 @@ import type {
|
||||
ResLinkList,
|
||||
ResPlace,
|
||||
ResPlaceList,
|
||||
ResPlaceSearch,
|
||||
ResStartCollect,
|
||||
ResStartCopy,
|
||||
ResStartVision,
|
||||
ResUnit,
|
||||
ResUnitList,
|
||||
ResVerifyCandidates,
|
||||
SearchPlacesPublicParams,
|
||||
VerifyCandidatesParams
|
||||
} from '.././model';
|
||||
|
||||
@ -150,6 +152,100 @@ export function useListPlaces<TData = Awaited<ReturnType<typeof listPlaces>>, TE
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 상호명으로 외부 장소 DB(카카오 → 없으면 네이버)를 찾아 후보를 그대로 돌려준다. ★ 인증이 없다 — 랜딩 첫 화면이 부른다. 사업장을 만들지도, 우리 DB 를 읽지도 않는다. ★ 응답의 category 는 외부 분류에서 **추정한 기본값**이다. None 이면 못 정한 것이고, 값이 있어도 확정이 아니다 — 화면은 언제나 바꿀 수 있게 둔다.
|
||||
* @summary 상호명 공개 검색
|
||||
*/
|
||||
export const searchPlacesPublic = (
|
||||
params: SearchPlacesPublicParams,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResPlaceSearch>(
|
||||
{url: `/v1/place/search`, method: 'GET',
|
||||
params, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getSearchPlacesPublicQueryKey = (params?: SearchPlacesPublicParams,) => {
|
||||
return [
|
||||
`/v1/place/search`, ...(params ? [params]: [])
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getSearchPlacesPublicQueryOptions = <TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getSearchPlacesPublicQueryKey(params);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof searchPlacesPublic>>> = ({ signal }) => searchPlacesPublic(params, requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type SearchPlacesPublicQueryResult = NonNullable<Awaited<ReturnType<typeof searchPlacesPublic>>>
|
||||
export type SearchPlacesPublicQueryError = void | HTTPValidationError
|
||||
|
||||
|
||||
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
|
||||
params: SearchPlacesPublicParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof searchPlacesPublic>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof searchPlacesPublic>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
|
||||
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof searchPlacesPublic>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof searchPlacesPublic>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
|
||||
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 상호명 공개 검색
|
||||
*/
|
||||
|
||||
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
|
||||
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getSearchPlacesPublicQueryOptions(params,options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 상호명 하나로 시작한다. 주소·좌표는 동일 업소 검증(verify)이 채운다.
|
||||
* @summary 사업장 등록
|
||||
|
||||
128
solution/frontend/src/api/generated/showcase/showcase.ts
Normal file
128
solution/frontend/src/api/generated/showcase/showcase.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ export function EditorHeader() {
|
||||
const isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
|
||||
const togglePreview = useBuilderStore((s) => s.togglePreview);
|
||||
const openPublishModal = useBuilderStore((s) => s.openPublishModal);
|
||||
const reset = useBuilderStore((s) => s.reset);
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
|
||||
const unverifiedCount = useUnverifiedFields().length;
|
||||
@ -37,20 +36,19 @@ export function EditorHeader() {
|
||||
여기 링크를 남기면 사장님 앱에서 404 이고, 내부 경로 이름이 사장님 번들에도 남는다.
|
||||
reset() 을 부르지 않는 이유는 그대로다: 편집하던 가게를 떠나 데모 위저드로
|
||||
떨어지면 사장님 눈에는 자기 가게가 사라진 것으로 보인다.
|
||||
(사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절) */}
|
||||
(사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절)
|
||||
★ 데모에서 돌아가는 길은 `?new=1` 이다 — 스토어를 직접 비우면 화면만 위저드로 바뀌고
|
||||
주소는 그대로라, 뒤로가기가 편집기로 돌아오지 않는다(단계는 주소창이 소유한다). */}
|
||||
{placeId ? (
|
||||
<span className="group flex items-center gap-2">
|
||||
// 편집 중이어도 로고는 홈으로 간다 — 눌리지 않는 로고는 고장으로 읽힌다.
|
||||
// 입력값은 서버에 저장되므로 나갔다 들어와도 그대로다.
|
||||
<Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
|
||||
<LogoMark />
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
title="처음으로 이동"
|
||||
className="group flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
|
||||
<LogoMark />
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<span className="hidden h-3.5 w-px bg-border sm:block" />
|
||||
|
||||
@ -1,18 +1,54 @@
|
||||
import {useState} from 'react';
|
||||
import {Check} from 'lucide-react';
|
||||
import type {IndustryType} from '@o2o/shared';
|
||||
import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {INDUSTRY_ICONS} from './industryIcons';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardSteps} from './WizardSteps';
|
||||
|
||||
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
|
||||
|
||||
/**
|
||||
* 업종 직접 고르기 — `?step=industry`.
|
||||
*
|
||||
* ★ 이 화면은 더 이상 위저드의 첫 화면이 아니다. 업종은 상호 검색 결과의 분류가 정하고
|
||||
* (services/place_category.py), 여기는 **못 정했을 때와 바꿀 때만** 들른다.
|
||||
* 진행 표시에서 1번을 유지하는 이유도 같다 — 이건 '내 가게 확인' 안의 갈래다.
|
||||
*
|
||||
* ★ 못 정해서 들어온 경우에는 아무 카드도 선택된 것으로 보이지 않게 한다. 스토어에는 언제나
|
||||
* 기본 업종이 들어 있어서, 그걸 그대로 칠하면 사장님은 자기가 고르지도 않은 업종이
|
||||
* 이미 정해진 것으로 읽는다.
|
||||
*/
|
||||
export function Step1Industry() {
|
||||
const industry = useBuilderStore((s) => s.industry);
|
||||
const pendingPick = useBuilderStore((s) => s.pendingPick);
|
||||
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const setPendingPick = useBuilderStore((s) => s.setPendingPick);
|
||||
const [, goToStep] = useWizardStep();
|
||||
|
||||
/** 검색이 업종을 못 정해 넘어온 경우 — 고르기 전에는 진행시키지 않는다. */
|
||||
const mustChoose = Boolean(pendingPick && !pendingPick.industry);
|
||||
const [touched, setTouched] = useState(false);
|
||||
const showSelection = touched || !mustChoose;
|
||||
|
||||
const pick = (id: IndustryType) => {
|
||||
setTouched(true);
|
||||
selectIndustry(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 고른 업종을 들고 검색 화면으로 돌아간다.
|
||||
*
|
||||
* ★ 후보를 들고 왔으면 그 후보에 업종을 찍어 돌려보낸다 — 확정(사업장 생성)은 검색 화면
|
||||
* 한 곳에서만 한다. 두 화면이 각자 확정하면 같은 가게가 두 번 만들어지는 길이 생긴다.
|
||||
*/
|
||||
const goBack = () => {
|
||||
if (pendingPick) setPendingPick({...pendingPick, industry});
|
||||
goToStep('search');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
@ -22,10 +58,12 @@ export function Step1Industry() {
|
||||
|
||||
<div className="mb-10 text-center sm:mb-12">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
|
||||
어떤 업종의 홈페이지를 만들까요?
|
||||
{mustChoose ? '이 가게는 어떤 업종인가요?' : '업종을 바꿀까요?'}
|
||||
</h1>
|
||||
<p className="mx-auto mt-2.5 max-w-lg text-sm text-muted-foreground sm:text-base">
|
||||
업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.
|
||||
{mustChoose
|
||||
? '지도 분류만으로는 업종을 정하지 못했습니다. 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'
|
||||
: '업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -33,13 +71,13 @@ export function Step1Industry() {
|
||||
{INDUSTRY_ORDER.map((id) => {
|
||||
const config = INDUSTRY_CONFIGS[id];
|
||||
const Icon = INDUSTRY_ICONS[id];
|
||||
const isSelected = industry === id;
|
||||
const isSelected = showSelection && industry === id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => selectIndustry(id)}
|
||||
onClick={() => pick(id)}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col justify-between rounded-2xl border bg-card p-6 text-left transition-all',
|
||||
@ -90,16 +128,22 @@ export function Step1Industry() {
|
||||
|
||||
<WizardFooter
|
||||
hint={
|
||||
<>
|
||||
선택된 업종:{' '}
|
||||
<strong className="font-semibold text-foreground">
|
||||
{INDUSTRY_CONFIGS[industry].name}
|
||||
</strong>{' '}
|
||||
({INDUSTRY_CONFIGS[industry].subName})
|
||||
</>
|
||||
showSelection ? (
|
||||
<>
|
||||
선택된 업종:{' '}
|
||||
<strong className="font-semibold text-foreground">
|
||||
{INDUSTRY_CONFIGS[industry].name}
|
||||
</strong>{' '}
|
||||
({INDUSTRY_CONFIGS[industry].subName})
|
||||
</>
|
||||
) : (
|
||||
'업종을 하나 골라 주세요.'
|
||||
)
|
||||
}
|
||||
onNext={() => goToStep(2)}
|
||||
nextLabel="다음: 내 가게 찾기"
|
||||
onPrev={() => goToStep('search')}
|
||||
onNext={goBack}
|
||||
nextDisabled={!showSelection}
|
||||
nextLabel={pendingPick ? '이 업종으로 계속하기' : '내 가게 찾기로 돌아가기'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
import {useState} from 'react';
|
||||
import {useSearchParams} from 'react-router';
|
||||
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react';
|
||||
import {getAccessToken, type PlaceCandidate} from '@/api';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {ArrowRight, Check, MapPin, Search, TriangleAlert} from 'lucide-react';
|
||||
import type {IndustryType} from '@o2o/shared';
|
||||
import type {PlaceSearchItem} from '@/api';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {CATEGORY_TO_INDUSTRY} from '@/features/builder/placeAdapter';
|
||||
import {cn} from '@/lib/utils';
|
||||
import type {PendingPick} from '@/stores/builder';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {PlaceUrlBox} from './PlaceUrlBox';
|
||||
import {usePlaceSearch} from './usePlaceSearch';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardStage, WizardWaiting} from './WizardStage';
|
||||
|
||||
const STAGE_TITLE: Record<string, string> = {
|
||||
input: '상호명을 검색해 내 가게를 찾아주세요',
|
||||
input: '가게 이름을 알려주세요',
|
||||
searching: '내 가게를 찾는 중입니다',
|
||||
picking: '이 중에 사장님 가게가 있나요?',
|
||||
unavailable: '지도 검색을 사용할 수 없습니다',
|
||||
@ -21,22 +25,20 @@ const STAGE_TITLE: Record<string, string> = {
|
||||
};
|
||||
|
||||
const STAGE_DESCRIPTION: Record<string, string> = {
|
||||
input: '지도·플레이스에 등록된 가게 중에서 사장님이 직접 고른 한 곳만 기준이 됩니다.',
|
||||
input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.',
|
||||
searching: '',
|
||||
picking: '고른 가게의 상호·주소·전화가 이 사이트의 기준 정보가 됩니다.',
|
||||
picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.',
|
||||
unavailable: '',
|
||||
confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.',
|
||||
};
|
||||
|
||||
/** outcome 코드 → 사장님이 읽을 한 줄. 서버 판정은 문구를 고르는 데만 쓴다. */
|
||||
const OUTCOME_TEXT: Record<string, string> = {
|
||||
matched: '이 가게로 보입니다. 맞는지 한 번만 확인해 주세요.',
|
||||
ambiguous: '비슷한 이름의 가게가 여럿입니다. 어느 쪽이 사장님 가게인가요?',
|
||||
no_candidate: '이 이름으로는 찾지 못했습니다.',
|
||||
};
|
||||
|
||||
/**
|
||||
* 2단계 — 내 가게 확인.
|
||||
* 1단계 — 내 가게 확인. **위저드의 시작점이다.**
|
||||
*
|
||||
* ★ 예전에는 업종 선택이 앞에 있었다. 그런데 사장님이 100% 아는 건 자기 가게 **이름**이고,
|
||||
* 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그래서 순서를 뒤집었다 —
|
||||
* 상호명을 먼저 받고, 업종은 검색 결과의 분류가 정한다(services/place_category.py).
|
||||
* 못 정했을 때만 업종 화면(`?step=industry`)으로 넘긴다.
|
||||
*
|
||||
* 한 화면에 한 가지만 묻는다: 상호를 묻거나 · 기다리거나 · 후보를 고르게 하거나 · 확정을 보여준다.
|
||||
*/
|
||||
@ -45,117 +47,144 @@ export function Step2PlaceSearch() {
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
const location = useBuilderStore((s) => s.location);
|
||||
const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity);
|
||||
const pendingPick = useBuilderStore((s) => s.pendingPick);
|
||||
const setStoreName = useBuilderStore((s) => s.setStoreName);
|
||||
const setLocation = useBuilderStore((s) => s.setLocation);
|
||||
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
|
||||
const setPendingPick = useBuilderStore((s) => s.setPendingPick);
|
||||
const confirmIdentity = useBuilderStore((s) => s.confirmIdentity);
|
||||
const clearIdentity = useBuilderStore((s) => s.clearIdentity);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
|
||||
// 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다).
|
||||
const search = usePlaceSearch(industry, confirmedIdentity?.placeId ?? null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
/**
|
||||
* 확정된 사업장을 주소창에 남긴다 — `?placeId=...`.
|
||||
*
|
||||
* ★ 이게 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다
|
||||
* (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로
|
||||
* 떠 버린다. 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로
|
||||
* 열면 깨끗하다 — 두 요구를 동시에 만족하는 자리는 여기뿐이다.
|
||||
*/
|
||||
const rememberPlace = (placeId: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (placeId) {
|
||||
next.set('placeId', placeId);
|
||||
// 개발 서버 재시작/HMR로 BuilderPage가 다시 마운트돼도 신규 등록 흐름임을 보존한다.
|
||||
next.set('flow', 'onboarding');
|
||||
} else {
|
||||
next.delete('placeId');
|
||||
next.delete('flow');
|
||||
}
|
||||
setSearchParams(next, {replace: true});
|
||||
};
|
||||
const [pickedIndex, setPickedIndex] = useState<number | null>(null);
|
||||
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
|
||||
const [, goToStep] = useWizardStep();
|
||||
const [placeUrl, setPlaceUrl] = useState('');
|
||||
|
||||
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
|
||||
|
||||
const runSearch = () => {
|
||||
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
|
||||
setPickedIndex(null);
|
||||
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데,
|
||||
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다.
|
||||
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다.
|
||||
if (!getAccessToken()) {
|
||||
confirmIdentity(search.confirmManual(storeName, location));
|
||||
goToStep(3);
|
||||
/**
|
||||
* 확정된 사업장을 주소창에 남기며 다음 단계로.
|
||||
*
|
||||
* ★ `?placeId=` 가 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다
|
||||
* (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로 떠 버린다.
|
||||
* 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로 열면 깨끗하다.
|
||||
* ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 1단계"인
|
||||
* 주소가 히스토리에 한 칸 생기고, 뒤로가기가 그 칸에 걸린다.
|
||||
*/
|
||||
const advance = (placeId: string | null) => {
|
||||
goToStep('collect', {
|
||||
params: {placeId, flow: placeId ? 'onboarding' : null},
|
||||
});
|
||||
};
|
||||
|
||||
const finishPick = async (pick: PendingPick, nextIndustry: IndustryType) => {
|
||||
const identity = await search.confirmPick(pick, nextIndustry);
|
||||
setPendingPick(null);
|
||||
if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다
|
||||
confirmIdentity(identity);
|
||||
advance(identity.placeId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 업종 화면을 다녀온 후보를 이어서 확정한다.
|
||||
*
|
||||
* ★ 업종이 **찍혀서** 돌아온 것만 이어간다. 뒤로가기로 돌아온 경우(업종을 안 고른 경우)까지
|
||||
* 확정하면 사장님이 고르지도 않은 기본 업종으로 사업장이 만들어진다.
|
||||
*/
|
||||
const resumed = useRef(false);
|
||||
useEffect(() => {
|
||||
const industryFromPicker = pendingPick?.industry;
|
||||
if (!pendingPick || !industryFromPicker || resumed.current) return;
|
||||
resumed.current = true;
|
||||
void finishPick(pendingPick, industryFromPicker);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 한 번만 이어붙이는 일이라 pendingPick 만 본다
|
||||
}, [pendingPick]);
|
||||
|
||||
/**
|
||||
* 후보 하나를 골랐다 — 여기서 업종이 정해진다.
|
||||
*
|
||||
* ★ `category` 는 서버가 외부 분류에서 **추정한** 값이고, 못 정하면 키 자체가 없다
|
||||
* (RemoveNoneResponse 가 null 필드를 지운다). 억지로 하나를 고르지 않고 사장님에게 묻는다 —
|
||||
* 업종은 수집 스키마와 JSON-LD 타입을 통째로 정하는 값이라 틀리면 되돌리는 값이 비싸다.
|
||||
*/
|
||||
const choose = (item: PlaceSearchItem) => {
|
||||
const pick: PendingPick = {
|
||||
name: item.name ?? '',
|
||||
address: item.road_address ?? '',
|
||||
};
|
||||
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
|
||||
if (!guessed) {
|
||||
setPendingPick(pick);
|
||||
goToStep('industry');
|
||||
return;
|
||||
}
|
||||
void search.search(storeName, location);
|
||||
selectIndustry(guessed);
|
||||
void finishPick(pick, guessed);
|
||||
};
|
||||
|
||||
const runSearch = () => {
|
||||
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
|
||||
void search.searchPublic(storeName, location);
|
||||
};
|
||||
|
||||
const backToInput = () => {
|
||||
clearIdentity();
|
||||
// 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다.
|
||||
rememberPlace(null);
|
||||
setPickedIndex(null);
|
||||
goToStep('search', {params: {placeId: null, flow: null}, replace: true});
|
||||
search.reset();
|
||||
};
|
||||
|
||||
const pick = async (candidate: PlaceCandidate, index: number) => {
|
||||
// 지역검색 후보만 있고 네이버 플레이스를 못 찾았으면 확정하지 않는다.
|
||||
// URL 확정 경로가 상호·주소·좌표와 수집 채널을 한 번에 보장한다.
|
||||
if (!candidate.naver_place_url) return;
|
||||
setPickedIndex(index);
|
||||
const identity = await search.confirmByUrl(candidate.naver_place_url);
|
||||
if (!identity) {
|
||||
setPickedIndex(null);
|
||||
return;
|
||||
}
|
||||
confirmIdentity(identity);
|
||||
rememberPlace(identity.placeId);
|
||||
goToStep(3);
|
||||
};
|
||||
|
||||
/** 네이버 플레이스 URL 로 확정 — 가장 확실한 경로다(usePlaceSearch.confirmByUrl 주석 참고). */
|
||||
const pickByUrl = async () => {
|
||||
const url = placeUrl.trim();
|
||||
if (!url) return;
|
||||
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다.
|
||||
if (!getAccessToken()) {
|
||||
confirmIdentity(search.confirmManual(storeName, location));
|
||||
goToStep(3);
|
||||
return;
|
||||
}
|
||||
const identity = await search.confirmByUrl(url);
|
||||
const identity = await search.confirmByUrl(url, industry);
|
||||
if (!identity) return;
|
||||
confirmIdentity(identity);
|
||||
rememberPlace(identity.placeId);
|
||||
goToStep(3);
|
||||
advance(identity.placeId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 업종을 바꾸러 간다.
|
||||
*
|
||||
* ★ **서버에 사업장이 이미 만들어졌으면 신원 확인부터 다시 받는다.** places.category 는 만들 때
|
||||
* 정해지고 PATCH 로 못 고친다(Req_UpdatePlace 에 category 가 없다) — 화면에서만 바꾸면
|
||||
* 리페치 한 번에 서버 값으로 되돌아가서, 사장님 눈에는 바꾼 게 씹힌 것으로 보인다.
|
||||
* 확정 전(로그인 전 포함)에는 서버에 아무것도 없으니 업종만 갈아도 된다.
|
||||
*/
|
||||
const identityIsOnServer = Boolean(confirmedIdentity?.placeId);
|
||||
const changeIndustry = () => {
|
||||
if (identityIsOnServer) {
|
||||
clearIdentity();
|
||||
goToStep('industry', {params: {placeId: null, flow: null}});
|
||||
return;
|
||||
}
|
||||
goToStep('industry');
|
||||
};
|
||||
|
||||
// ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ──────────────
|
||||
const stage = confirmedIdentity
|
||||
? 'confirmed'
|
||||
: search.phase === 'searching'
|
||||
? 'searching'
|
||||
: search.phase === 'unavailable'
|
||||
? 'unavailable'
|
||||
: search.phase === 'done'
|
||||
? 'picking'
|
||||
: 'input';
|
||||
? 'searching'
|
||||
: search.phase === 'unavailable'
|
||||
? 'unavailable'
|
||||
: search.phase === 'done'
|
||||
? 'picking'
|
||||
: 'input';
|
||||
|
||||
const searchQuery = [storeName, location].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
<WizardStage
|
||||
current={2}
|
||||
current={1}
|
||||
wide={stage === 'picking'}
|
||||
title={STAGE_TITLE[stage]}
|
||||
description={STAGE_DESCRIPTION[stage]}
|
||||
>
|
||||
{stage === 'input' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 rounded-2xl border border-border bg-card p-6">
|
||||
<div className="space-y-4 rounded-2xl border border-border bg-card p-6">
|
||||
<div>
|
||||
<label htmlFor="store-name" className="mb-1.5 block text-xs font-semibold">
|
||||
상호명 <span className="text-destructive">*</span>
|
||||
@ -200,7 +229,6 @@ export function Step2PlaceSearch() {
|
||||
<Search />
|
||||
<span>상호로 찾아보기</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -216,72 +244,51 @@ export function Step2PlaceSearch() {
|
||||
<p
|
||||
className={cn(
|
||||
'rounded-xl p-3 text-xs leading-relaxed',
|
||||
search.candidates.length > 0
|
||||
search.items.length > 0
|
||||
? 'border border-border bg-card text-muted-foreground'
|
||||
: 'border border-warning/30 bg-warning/10 text-warning',
|
||||
)}
|
||||
>
|
||||
{OUTCOME_TEXT[search.outcome] ?? '아래 목록에서 사장님 가게를 골라 주세요.'}
|
||||
{search.sourceLabel && (
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||
{search.sourceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{search.items.length > 0
|
||||
? '아래 목록에서 사장님 가게를 골라 주세요.'
|
||||
: '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
|
||||
</p>
|
||||
|
||||
{search.candidates.length > 0 &&
|
||||
!search.candidates.some((candidate) => candidate.naver_place_url) && (
|
||||
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-xs font-medium leading-relaxed text-warning">
|
||||
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
|
||||
상호 후보는 찾았지만 네이버 플레이스는 찾지 못했습니다. 아래 버튼으로 네이버
|
||||
지도에서 가게를 검색한 뒤 플레이스 URL을 붙여넣어 주세요.
|
||||
</p>
|
||||
)}
|
||||
{search.pickError && (
|
||||
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-xs font-medium leading-relaxed text-warning">
|
||||
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
|
||||
{search.pickError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{search.candidates.map((candidate, index) => (
|
||||
<li key={`${candidate.external_place_id ?? candidate.name}-${index}`}>
|
||||
{search.items.map((item, index) => (
|
||||
<li key={`${item.name ?? ''}-${item.road_address ?? ''}-${index}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void pick(candidate, index)}
|
||||
disabled={search.isConfirming || !candidate.naver_place_url}
|
||||
onClick={() => choose(item)}
|
||||
disabled={search.isConfirming}
|
||||
className="w-full cursor-pointer rounded-xl border border-border bg-card p-4 text-left transition-all hover:border-primary hover:bg-accent disabled:opacity-60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-bold">{candidate.name}</span>
|
||||
{candidate.category_name && (
|
||||
<span className="text-sm font-bold">{item.name}</span>
|
||||
{item.category_name && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{candidate.category_name}
|
||||
{item.category_name}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={candidate.naver_place_url ? 'success' : 'warning'}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{candidate.naver_place_url
|
||||
? '네이버 플레이스 찾음'
|
||||
: '네이버 플레이스 못 찾음'}
|
||||
</Badge>
|
||||
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
|
||||
<IndustryBadge category={item.category} />
|
||||
</div>
|
||||
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="mt-px size-3 shrink-0" />
|
||||
<span>{candidate.road_address ?? candidate.address ?? '주소 없음'}</span>
|
||||
<span>{item.road_address ?? '주소 없음'}</span>
|
||||
</p>
|
||||
{candidate.phone && (
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Phone className="size-3 shrink-0" />
|
||||
{candidate.phone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground">
|
||||
{!candidate.naver_place_url
|
||||
? 'URL 필요'
|
||||
: search.isConfirming && pickedIndex === index
|
||||
? '확정 중...'
|
||||
: '이 가게예요'}
|
||||
{search.isConfirming ? '확정 중...' : '이 가게예요'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@ -289,25 +296,26 @@ export function Step2PlaceSearch() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!search.candidates.some((candidate) => candidate.naver_place_url) ? (
|
||||
<PlaceUrlBox
|
||||
value={placeUrl}
|
||||
onChange={setPlaceUrl}
|
||||
onSubmit={() => void pickByUrl()}
|
||||
isBusy={search.isConfirming}
|
||||
searchQuery={[storeName, location].filter(Boolean).join(' ')}
|
||||
/>
|
||||
) : (
|
||||
<PlaceUrlBox
|
||||
value={placeUrl}
|
||||
onChange={setPlaceUrl}
|
||||
onSubmit={() => void pickByUrl()}
|
||||
isBusy={search.isConfirming}
|
||||
compact
|
||||
searchQuery={[storeName, location].filter(Boolean).join(' ')}
|
||||
/>
|
||||
)}
|
||||
{/* 목록에서 고르면 업종은 그 후보가 정한다 — 이 줄은 아래 '주소로 확정' 경로용이다. */}
|
||||
<IndustryLine
|
||||
label="주소로 확정할 때 쓸 업종"
|
||||
industry={industry}
|
||||
onChange={changeIndustry}
|
||||
/>
|
||||
|
||||
<PlaceUrlBox
|
||||
value={placeUrl}
|
||||
onChange={setPlaceUrl}
|
||||
onSubmit={() => void pickByUrl()}
|
||||
isBusy={search.isConfirming}
|
||||
compact={search.items.length > 0 && !search.pickError}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={backToInput}>
|
||||
<Search />
|
||||
<span>다른 이름으로 다시 찾기</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -317,12 +325,19 @@ export function Step2PlaceSearch() {
|
||||
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
|
||||
{search.unavailableReason}
|
||||
</p>
|
||||
|
||||
<IndustryLine
|
||||
label="주소로 확정할 때 쓸 업종"
|
||||
industry={industry}
|
||||
onChange={changeIndustry}
|
||||
/>
|
||||
|
||||
<PlaceUrlBox
|
||||
value={placeUrl}
|
||||
onChange={setPlaceUrl}
|
||||
onSubmit={() => void pickByUrl()}
|
||||
isBusy={search.isConfirming}
|
||||
searchQuery={[storeName, location].filter(Boolean).join(' ')}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={runSearch}>
|
||||
@ -357,17 +372,25 @@ export function Step2PlaceSearch() {
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="warning" className="text-[10px]">
|
||||
직접 입력 · 동일 업소 미검증
|
||||
{confirmedIdentity.sourceLabel} · 동일 업소 미검증
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IndustryLine
|
||||
industry={industry}
|
||||
onChange={changeIndustry}
|
||||
note={
|
||||
identityIsOnServer ? '업종을 바꾸면 가게 확인을 다시 받습니다.' : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => goToStep(3)}
|
||||
onClick={() => advance(confirmedIdentity.placeId)}
|
||||
>
|
||||
<span>다음: 이 가게 정보 수집하기</span>
|
||||
<ArrowRight />
|
||||
@ -386,11 +409,61 @@ export function Step2PlaceSearch() {
|
||||
? `${confirmedIdentity.name} 기준으로 진행합니다.`
|
||||
: '내 가게를 확인해야 다음 단계로 갈 수 있습니다.'
|
||||
}
|
||||
onPrev={() => goToStep(1)}
|
||||
onNext={() => goToStep(3)}
|
||||
onNext={() => advance(confirmedIdentity?.placeId ?? null)}
|
||||
nextLabel="다음: 데이터 수집"
|
||||
nextDisabled={!confirmedIdentity}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 후보 카드의 추정 업종. 못 정한 후보는 "고르면 묻는다"를 미리 알려 준다. */
|
||||
function IndustryBadge({category}: {category: PickedCategory}) {
|
||||
const industry = category != null ? (CATEGORY_TO_INDUSTRY[category] ?? null) : null;
|
||||
return industry ? (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
{INDUSTRY_CONFIGS[industry].name}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning" className="text-[10px]">
|
||||
업종 직접 선택
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** PlaceSearchItem.category — 서버가 못 정하면 키 자체가 없다(RemoveNoneResponse). */
|
||||
type PickedCategory = PlaceSearchItem['category'];
|
||||
|
||||
/**
|
||||
* 지금 어떤 업종으로 진행 중인지 + 바꾸는 길.
|
||||
*
|
||||
* ★ 업종을 자동으로 정하기로 한 이상, **정해진 값이 화면에 보여야 한다.** 안 보이면 사장님은
|
||||
* 숙박 스키마로 카페 사이트를 만들고 있다는 걸 수집 결과를 보고서야 안다.
|
||||
*/
|
||||
function IndustryLine({
|
||||
industry,
|
||||
onChange,
|
||||
label = '업종',
|
||||
note,
|
||||
}: {
|
||||
industry: IndustryType;
|
||||
onChange: () => void;
|
||||
label?: string;
|
||||
note?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-border bg-card px-3 py-2 text-xs">
|
||||
<span>
|
||||
{label}: <strong className="font-semibold">{INDUSTRY_CONFIGS[industry].name}</strong>
|
||||
{note && <span className="ml-2 text-[11px] text-muted-foreground">{note}</span>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className="cursor-pointer rounded-md px-2 py-1 font-medium text-primary transition-colors hover:bg-accent"
|
||||
>
|
||||
바꾸기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {useBuilderStore, useUnverifiedFields} from '@/stores/builder';
|
||||
import {ChannelConfirmPanel} from './ChannelConfirmPanel';
|
||||
import {ChannelUrlInput} from './ChannelUrlInput';
|
||||
import {useCollectFlow} from './useCollectFlow';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardStage, WizardWaiting} from './WizardStage';
|
||||
|
||||
@ -29,7 +30,7 @@ const STAGE_DESCRIPTION: Record<string, string> = {
|
||||
review: '찾은 값은 전부 출처와 함께 보여드립니다. 사장님이 [맞아요]를 누른 값만 사이트와 AI 검색 답변에 나갑니다.',
|
||||
};
|
||||
|
||||
/** 채널 URL 확인과 수집 결과 검토 단계. */
|
||||
/** 채널 URL 확인과 수집 결과 검토 단계(진행 표시 2번). */
|
||||
export function Step3DataReview() {
|
||||
const industry = useBuilderStore((s) => s.industry);
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
@ -37,7 +38,7 @@ export function Step3DataReview() {
|
||||
const gatherCompleted = useBuilderStore((s) => s.gatherCompleted);
|
||||
const infoFields = useBuilderStore((s) => s.infoFields);
|
||||
const photos = useBuilderStore((s) => s.photos);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const [, goToStep] = useWizardStep();
|
||||
|
||||
const unverified = useUnverifiedFields();
|
||||
const placeId = confirmedIdentity?.placeId ?? null;
|
||||
@ -91,7 +92,7 @@ export function Step3DataReview() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
<WizardStage
|
||||
current={3}
|
||||
current={2}
|
||||
wide={stage === 'review' || stage === 'channels'}
|
||||
title={STAGE_TITLE[stage]}
|
||||
description={STAGE_DESCRIPTION[stage]}
|
||||
@ -178,14 +179,14 @@ export function Step3DataReview() {
|
||||
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
|
||||
주소 붙여넣기와 자동 찾기가 모두 열립니다.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => goToStep(4)}>
|
||||
<Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
|
||||
<span>수집 없이 다음 단계로</span>
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep(2)}>
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep('search')}>
|
||||
가게를 다시 찾을게요
|
||||
</Button>
|
||||
</div>
|
||||
@ -302,7 +303,7 @@ export function Step3DataReview() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="lg" className="w-full" onClick={() => goToStep(4)}>
|
||||
<Button variant="primary" size="lg" className="w-full" onClick={() => goToStep('template')}>
|
||||
<span>다음: 템플릿 고르기</span>
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
@ -354,8 +355,8 @@ export function Step3DataReview() {
|
||||
'수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.'
|
||||
)
|
||||
}
|
||||
onPrev={() => goToStep(2)}
|
||||
onNext={() => goToStep(4)}
|
||||
onPrev={() => goToStep('search')}
|
||||
onNext={() => goToStep('template')}
|
||||
nextLabel="다음: 템플릿 선택"
|
||||
/**
|
||||
* ★ 수집 전에는 잠근다.
|
||||
|
||||
@ -4,6 +4,7 @@ import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardSteps} from './WizardSteps';
|
||||
|
||||
@ -85,7 +86,7 @@ export function Step4Template() {
|
||||
const templateId = useBuilderStore((s) => s.templateId);
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const [, goToStep] = useWizardStep();
|
||||
const startGenerating = useBuilderStore((s) => s.startGenerating);
|
||||
|
||||
const config = INDUSTRY_CONFIGS[industry];
|
||||
@ -105,7 +106,7 @@ export function Step4Template() {
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4 py-10 sm:px-6 sm:py-14 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col">
|
||||
<WizardSteps current={4} />
|
||||
<WizardSteps current={3} />
|
||||
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
@ -166,12 +167,13 @@ export function Step4Template() {
|
||||
</div>
|
||||
|
||||
<WizardFooter
|
||||
onPrev={() => goToStep(3)}
|
||||
onPrev={() => goToStep('collect')}
|
||||
onNext={() => {
|
||||
// ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 —
|
||||
// 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다.
|
||||
queueSiteTemplateSave(placeId, templateId);
|
||||
startGenerating();
|
||||
goToStep('generating');
|
||||
}}
|
||||
nextLabel="이 템플릿으로 사이트 생성하기"
|
||||
/>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import {useCallback, useEffect} from 'react';
|
||||
import {useSearchParams} from 'react-router';
|
||||
import {Check, Loader2} from 'lucide-react';
|
||||
import {JobStatus} from '@o2o/shared';
|
||||
import {delay, getAccessToken, pollJob, startCopy} from '@/api';
|
||||
import {Progress} from '@/components/ui/progress';
|
||||
import {notify, notifyApiError} from '@/lib/notify';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {EDITOR_STEP, useWizardStep} from './wizardUrl';
|
||||
|
||||
const BUILD_STEPS = [
|
||||
'수집된 사진 분류 및 대체 텍스트 생성',
|
||||
@ -30,9 +30,8 @@ export function Step5Generating() {
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
const stage = useBuilderStore((s) => s.generateStage);
|
||||
const setGenerateStage = useBuilderStore((s) => s.setGenerateStage);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [, goToStep] = useWizardStep();
|
||||
|
||||
/**
|
||||
* 생성이 끝나면 곧바로 에디터다.
|
||||
@ -43,11 +42,10 @@ export function Step5Generating() {
|
||||
* '목록으로 보내는 것'은 다른 얘기다.
|
||||
*/
|
||||
const finishOnboarding = useCallback(() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('flow');
|
||||
setSearchParams(next, {replace: true});
|
||||
goToStep(EDITOR_STEP);
|
||||
}, [goToStep, searchParams, setSearchParams]);
|
||||
// ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인
|
||||
// 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다.
|
||||
goToStep(EDITOR_STEP, {params: {flow: null}, replace: true});
|
||||
}, [goToStep]);
|
||||
|
||||
/**
|
||||
* 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다.
|
||||
|
||||
@ -4,14 +4,17 @@ import {cn} from '@/lib/utils';
|
||||
/**
|
||||
* 위저드 진행 표시.
|
||||
*
|
||||
* 2(신원 확인)와 3(사실 확인)이 왜 따로인지 사장님이 한눈에 알게 하는 것이 이 줄의 목적이다 —
|
||||
* 1(신원 확인)과 2(사실 확인)가 왜 따로인지 사장님이 한눈에 알게 하는 것이 이 줄의 목적이다 —
|
||||
* "가게가 맞나" 를 먼저 끝내고 "값이 맞나" 로 넘어간다.
|
||||
*
|
||||
* ★ 업종은 더 이상 단계가 아니다. 검색 결과의 분류가 정하고(못 정할 때만 1단계 안에서 묻는다),
|
||||
* 그래서 네 칸이던 줄이 세 칸이 됐다 — 사장님이 처음 보는 화면이 "우리는 카페인가 음식점인가"
|
||||
* 가 아니라 "가게 이름"이 되게 하는 것이 이 변경의 목적이다.
|
||||
*/
|
||||
const STEPS = [
|
||||
{step: 1, label: '업종'},
|
||||
{step: 2, label: '내 가게 확인'},
|
||||
{step: 3, label: '수집 정보 확인'},
|
||||
{step: 4, label: '템플릿'},
|
||||
{step: 1, label: '내 가게 확인'},
|
||||
{step: 2, label: '수집 정보 확인'},
|
||||
{step: 3, label: '템플릿'},
|
||||
] as const;
|
||||
|
||||
export function WizardSteps({current}: {current: number}) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// 위저드 단계는 주소창이 소유한다 — 화면을 고르는 쪽(BuilderPage)이 이 계약을 읽는다.
|
||||
export {defaultStep, EDITOR_STEP, useWizardStep, type WizardStep} from './wizardUrl';
|
||||
export {Step1Industry} from './Step1Industry';
|
||||
export {Step2PlaceSearch} from './Step2PlaceSearch';
|
||||
export {Step3DataReview} from './Step3DataReview';
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
import {useCallback, useRef, useState} from 'react';
|
||||
import type {IndustryType} from '@o2o/shared';
|
||||
import type {ExternalPlaceSource as ExternalPlaceSourceType, PlaceCandidate, PlaceCategory} from '@/api';
|
||||
import type {PlaceCategory, PlaceSearchItem} from '@/api';
|
||||
import {
|
||||
createPlace,
|
||||
ExternalPlaceSource,
|
||||
getAccessToken,
|
||||
PlaceCategory as PlaceCategoryEnum,
|
||||
searchPlacesPublic,
|
||||
updatePlace,
|
||||
verifyCandidates,
|
||||
verifyPlace,
|
||||
verifyPlaceByUrl,
|
||||
} from '@/api';
|
||||
import type {ConfirmedIdentity} from '@/stores/builder';
|
||||
import {ensureAutoSession} from '@/lib/autoSession';
|
||||
import {describeError} from '@/lib/errorMessages';
|
||||
import {notify, notifyApiError} from '@/lib/notify';
|
||||
|
||||
/** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */
|
||||
/** 빌더 업종 → places.category. 반대 방향은 placeAdapter 의 CATEGORY_TO_INDUSTRY 다. */
|
||||
const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
|
||||
stay: PlaceCategoryEnum.LODGING,
|
||||
cafe: PlaceCategoryEnum.CAFE,
|
||||
@ -23,21 +23,15 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
|
||||
clinic: PlaceCategoryEnum.CLINIC,
|
||||
};
|
||||
|
||||
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
|
||||
const SOURCE_LABEL: Record<number, string> = {
|
||||
[ExternalPlaceSource.KAKAO]: '카카오맵',
|
||||
[ExternalPlaceSource.NAVER]: '네이버 지도',
|
||||
};
|
||||
|
||||
export type SearchPhase =
|
||||
/** 아직 검색 전. */
|
||||
| 'idle'
|
||||
/** 사업장 생성 + 후보 조회 중. */
|
||||
/** 공개 검색 중. */
|
||||
| 'searching'
|
||||
/** 후보를 받았다(0건일 수도 있다 — outcome 이 no_candidate). */
|
||||
/** 후보를 받았다(0건일 수도 있다). */
|
||||
| 'done'
|
||||
/**
|
||||
* 장소 DB 를 부를 수 없다 — 로그인이 없거나 백엔드가 응답하지 않는다.
|
||||
* 장소 DB 를 부를 수 없다 — 키가 없거나, 분당 호출을 넘겼거나, 백엔드가 응답하지 않는다.
|
||||
* ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는
|
||||
* 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다.
|
||||
*/
|
||||
@ -45,41 +39,47 @@ export type SearchPhase =
|
||||
|
||||
export interface PlaceSearchState {
|
||||
phase: SearchPhase;
|
||||
/** 외부 장소 DB 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
|
||||
candidates: PlaceCandidate[];
|
||||
/** matched | ambiguous | no_candidate — 서버 판정. 문구를 고르는 데만 쓴다. */
|
||||
outcome: string;
|
||||
/** true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다. */
|
||||
autoSelectable: boolean;
|
||||
/** 후보를 준 장소 DB 이름. 없으면 빈 문자열. */
|
||||
sourceLabel: string;
|
||||
source: ExternalPlaceSourceType | null;
|
||||
/** 검색을 못 한 이유(로그인 필요 등). phase === 'unavailable' 일 때만 채워진다. */
|
||||
/** 공개 검색 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
|
||||
items: PlaceSearchItem[];
|
||||
/** 검색을 못 한 이유. phase === 'unavailable' 일 때만 채워진다. */
|
||||
unavailableReason: string;
|
||||
/**
|
||||
* 고른 후보를 자동으로 확정하지 못한 이유.
|
||||
* ★ 실패를 조용히 삼키면 사장님은 "눌렀는데 아무 일도 안 일어난다"만 겪는다 —
|
||||
* 네이버 플레이스 URL 붙여넣기로 넘어가야 한다는 걸 여기서 말한다.
|
||||
*/
|
||||
pickError: string;
|
||||
}
|
||||
|
||||
const INITIAL: PlaceSearchState = {
|
||||
phase: 'idle',
|
||||
candidates: [],
|
||||
outcome: '',
|
||||
autoSelectable: false,
|
||||
sourceLabel: '',
|
||||
source: null,
|
||||
items: [],
|
||||
unavailableReason: '',
|
||||
pickError: '',
|
||||
};
|
||||
|
||||
/** 상호명 비교용. 공백·괄호 표기가 후보마다 달라 그대로 비교하면 같은 가게도 안 맞는다. */
|
||||
function normalizeName(name: string | undefined | null): string {
|
||||
return (name ?? '').replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상호 검색 → 동일 업소 후보 조회 → 사람이 고른 후보로 확정.
|
||||
* 상호명 공개 검색 → 사람이 고른 후보로 확정.
|
||||
*
|
||||
* 백엔드 순서가 그대로 화면 순서다:
|
||||
* 1) POST /v1/place 사업장(껍데기)을 만든다 — 후보 조회가 place_id 를 요구한다
|
||||
* 2) GET /v1/place/{id}/verify/candidates?query= 외부 장소 DB 후보
|
||||
* 3) POST /v1/place/{id}/verify 사람이 고른 후보로 동일 업소 확정
|
||||
* ★ 목록을 주는 건 **공개 검색**(`GET /v1/place/search`)이다. 인증이 없어 로그인 전에도 돌고,
|
||||
* 후보마다 추정 업종(category)이 함께 온다 — 업종을 사장님에게 먼저 묻지 않기로 한 결정이
|
||||
* API 까지 내려온 자리다. 인증이 필요한 후보 조회(verify/candidates)는 목록을 그리는 데
|
||||
* 쓰지 않고, 고른 뒤 **네이버 플레이스 URL 을 찾는 데만** 쓴다.
|
||||
*
|
||||
* 확정은 백엔드 순서 그대로다:
|
||||
* 1) POST /v1/place 사업장(껍데기) — 업종이 여기서 정해진다
|
||||
* 2) GET /v1/place/{id}/verify/candidates?query= 네이버 플레이스 URL 찾기
|
||||
* 3) POST /v1/place/{id}/verify/by-url 그 URL 로 동일 업소 확정
|
||||
*
|
||||
* ★ 3 을 통과해야 수집이 열린다(place.py: "동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다").
|
||||
* 그래서 직접 입력 경로는 수집 없이 사장님 입력만으로 사이트를 만든다 — 검증을 우회하지 않는다.
|
||||
* 그래서 로그인 전 경로는 수집 없이 사장님이 고른 상호·주소만으로 사이트를 만든다 — 검증을 우회하지 않는다.
|
||||
*/
|
||||
export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | null = null) {
|
||||
export function usePlaceSearch(existingPlaceId: string | null = null) {
|
||||
const [state, setState] = useState<PlaceSearchState>(INITIAL);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
/**
|
||||
@ -88,8 +88,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
* 되살아난 화면이 같은 가게를 한 번 더 만든다.
|
||||
*/
|
||||
const placeIdRef = useRef<string | null>(existingPlaceId);
|
||||
/** 그 사업장을 만들 때 쓴 업종. 업종이 바뀌면 재사용할 수 없다(아래 ensurePlace). */
|
||||
const placeCategoryRef = useRef<PlaceCategory | null>(null);
|
||||
const inflight = useRef<AbortController | null>(null);
|
||||
/** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
|
||||
/** 마지막으로 확정 시도한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
|
||||
const nameRef = useRef<string>('');
|
||||
|
||||
const reset = useCallback(() => {
|
||||
@ -98,128 +100,76 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
setState(INITIAL);
|
||||
}, []);
|
||||
|
||||
/** 사업장 껍데기 확보. 이미 만들었으면 상호만 맞춰 둔다(같은 위저드에서 두 번 만들지 않는다). */
|
||||
/**
|
||||
* 사업장 껍데기 확보.
|
||||
*
|
||||
* ★ 업종이 달라졌으면 **새로 만든다.** places.category 는 생성할 때만 정해지고 PATCH 로 고칠
|
||||
* 수 없다(Req_UpdatePlace 에 category 가 없다). 그대로 재사용하면 카페로 만든 사업장에
|
||||
* 숙박 스키마로 수집이 돌고 JSON-LD 타입도 어긋난다 — 화면도 빌드도 멀쩡한 채로 틀린다.
|
||||
*/
|
||||
const ensurePlace = useCallback(
|
||||
async (name: string, signal: AbortSignal): Promise<string | null> => {
|
||||
if (placeIdRef.current) {
|
||||
async (name: string, category: PlaceCategory, signal: AbortSignal): Promise<string | null> => {
|
||||
const reusable =
|
||||
placeIdRef.current !== null &&
|
||||
(placeCategoryRef.current === null || placeCategoryRef.current === category);
|
||||
if (reusable && placeIdRef.current) {
|
||||
// PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다.
|
||||
await updatePlace(placeIdRef.current, {name});
|
||||
if (signal.aborted) return null;
|
||||
return placeIdRef.current;
|
||||
}
|
||||
const created = await createPlace(
|
||||
{name, category: INDUSTRY_TO_CATEGORY[industry]},
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
const created = await createPlace({name, category}, undefined, signal);
|
||||
placeIdRef.current = created.place?.place_id ?? null;
|
||||
placeCategoryRef.current = category;
|
||||
return placeIdRef.current;
|
||||
},
|
||||
[industry],
|
||||
[],
|
||||
);
|
||||
|
||||
const search = useCallback(
|
||||
async (name: string, location: string) => {
|
||||
nameRef.current = name.trim();
|
||||
const query = [name.trim(), location.trim()].filter(Boolean).join(' ');
|
||||
if (!name.trim()) return;
|
||||
/**
|
||||
* 상호명으로 후보를 찾는다 — **로그인 없이**.
|
||||
*
|
||||
* ★ 사업장을 만들지 않는다. 아직 "내 가게가 여기 있나"를 보는 중이고, 여기서 행을 만들면
|
||||
* 검색만 해보고 떠난 사람만큼 빈 사업장이 쌓인다.
|
||||
*/
|
||||
const searchPublic = useCallback(async (name: string, location: string) => {
|
||||
const query = [name.trim(), location.trim()].filter(Boolean).join(' ');
|
||||
if (query.length < 2) return;
|
||||
|
||||
inflight.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inflight.current = controller;
|
||||
inflight.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inflight.current = controller;
|
||||
|
||||
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
|
||||
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
|
||||
await ensureAutoSession();
|
||||
setState({...INITIAL, phase: 'searching'});
|
||||
|
||||
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
|
||||
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
|
||||
if (!getAccessToken()) {
|
||||
setState({...INITIAL});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await searchPlacesPublic({q: query}, undefined, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
setState({...INITIAL, phase: 'searching'});
|
||||
|
||||
try {
|
||||
const placeId = await ensurePlace(name.trim(), controller.signal);
|
||||
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
|
||||
|
||||
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const source = res.source ?? null;
|
||||
setState({
|
||||
phase: 'done',
|
||||
candidates: res.candidates ?? [],
|
||||
outcome: res.outcome ?? '',
|
||||
autoSelectable: res.auto_selectable ?? false,
|
||||
source,
|
||||
sourceLabel: source ? (SOURCE_LABEL[source] ?? '외부 장소 DB') : '외부 장소 DB',
|
||||
unavailableReason: '',
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
notifyApiError(error, '상호를 검색하지 못했습니다.');
|
||||
// ★ 이 엔드포인트의 거절은 HTTP 200 + result.success=false 로 온다(RemoveNoneResponse).
|
||||
// 상태코드만 보면 "후보 0건"과 "분당 20회 초과"가 같은 화면이 된다.
|
||||
if (res.result?.success === false) {
|
||||
setState({
|
||||
...INITIAL,
|
||||
phase: 'unavailable',
|
||||
unavailableReason:
|
||||
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
|
||||
describeError(res.result.desc) ?? '지도 검색을 사용할 수 없습니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
[ensurePlace],
|
||||
);
|
||||
|
||||
/**
|
||||
* 사람이 고른 후보를 이 사업장의 신원으로 확정한다(POST /verify).
|
||||
* 성공하면 화면에 얹을 신원을 돌려준다 — 실패하면 null.
|
||||
*/
|
||||
const confirm = useCallback(
|
||||
async (candidate: PlaceCandidate): Promise<ConfirmedIdentity | null> => {
|
||||
const placeId = placeIdRef.current;
|
||||
const address = candidate.road_address ?? candidate.address ?? '';
|
||||
if (!placeId) return null;
|
||||
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
await verifyPlace(placeId, {
|
||||
source: state.source ?? ExternalPlaceSource.KAKAO,
|
||||
// 네이버는 고유 id 를 주지 않는다 — 그 경우 상호명 + 도로명주소가 중복 판정 키다.
|
||||
external_place_id: candidate.external_place_id ?? '',
|
||||
road_address: candidate.road_address ?? null,
|
||||
address: candidate.address ?? null,
|
||||
phone: candidate.phone ?? null,
|
||||
latitude: candidate.latitude ?? null,
|
||||
longitude: candidate.longitude ?? null,
|
||||
place_url: candidate.place_url ?? null,
|
||||
});
|
||||
|
||||
// ★ 상호도 후보 쪽으로 맞춘다. 사장님이 그 이름이 적힌 카드를 보고 "이 가게예요"를
|
||||
// 눌렀는데 DB 에는 검색어로 친 이름이 남으면, 화면과 서버가 다른 상호를 들고 있게 된다.
|
||||
const officialName = candidate.name?.trim();
|
||||
if (officialName) await updatePlace(placeId, {name: officialName});
|
||||
|
||||
return {
|
||||
placeId,
|
||||
name: candidate.name ?? '',
|
||||
address,
|
||||
phone: candidate.phone ?? undefined,
|
||||
origin: 'external',
|
||||
sourceLabel: state.sourceLabel || '외부 장소 DB',
|
||||
externalPlaceId: candidate.external_place_id ?? undefined,
|
||||
placeUrl: candidate.place_url ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
|
||||
return null;
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
},
|
||||
[state.source, state.sourceLabel],
|
||||
);
|
||||
setState({...INITIAL, phase: 'done', items: res.items ?? []});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
notifyApiError(error, '상호를 검색하지 못했습니다.');
|
||||
setState({
|
||||
...INITIAL,
|
||||
phase: 'unavailable',
|
||||
unavailableReason:
|
||||
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 네이버 플레이스 URL 로 확정한다.
|
||||
@ -230,10 +180,14 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
* 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다).
|
||||
*/
|
||||
const confirmByUrl = useCallback(
|
||||
async (url: string): Promise<ConfirmedIdentity | null> => {
|
||||
async (url: string, industry: IndustryType): Promise<ConfirmedIdentity | null> => {
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
const placeId = await ensurePlace(nameRef.current || '내 가게', new AbortController().signal);
|
||||
const placeId = await ensurePlace(
|
||||
nameRef.current || '내 가게',
|
||||
INDUSTRY_TO_CATEGORY[industry],
|
||||
new AbortController().signal,
|
||||
);
|
||||
if (!placeId) return null;
|
||||
const res = await verifyPlaceByUrl(placeId, {url});
|
||||
const place = res.place;
|
||||
@ -261,20 +215,81 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
);
|
||||
|
||||
/**
|
||||
* 목록에 없거나 장소 DB 를 못 부를 때 — 사장님이 직접 댄 값으로 신원을 세운다.
|
||||
* 목록에 없거나 로그인 전이라 검증을 못 걸 때 — 사장님이 고른 값으로 신원만 세운다.
|
||||
*
|
||||
* ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을
|
||||
* 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다.
|
||||
* 긁을 수 있다. 그래서 이 경로는 수집 없이 이 값들로만 사이트를 만든다.
|
||||
*/
|
||||
const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => {
|
||||
return {
|
||||
const confirmManual = useCallback(
|
||||
(name: string, address: string, sourceLabel = '직접 입력'): ConfirmedIdentity => ({
|
||||
placeId: placeIdRef.current,
|
||||
name: name.trim(),
|
||||
address: address.trim(),
|
||||
origin: 'owner',
|
||||
sourceLabel: '직접 입력',
|
||||
};
|
||||
}, []);
|
||||
sourceLabel,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset};
|
||||
/**
|
||||
* 공개 검색에서 고른 한 건을 이 위저드의 사업장으로 확정한다.
|
||||
*
|
||||
* ★ 로그인 전에는 서버를 부르지 않는다. 로그인 관문은 에디터 진입 하나이고(b94daa9),
|
||||
* 사업장 생성·검증은 전부 토큰을 요구한다 — 여기서 부르면 첫 화면이 로그인 벽이 된다.
|
||||
* 고른 후보의 **공식 상호·주소**로 신원을 세우고 넘어간다(검증은 로그인 뒤에 다시 걸 수 있다).
|
||||
* ★ 로그인 상태면 공개 후보에 없는 것(네이버 플레이스 URL)을 인증 경로에서 한 번 더 찾아
|
||||
* URL 확정까지 간다 — 그래야 수집이 열린다.
|
||||
*/
|
||||
const confirmPick = useCallback(
|
||||
async (
|
||||
pick: {name: string; address: string},
|
||||
industry: IndustryType,
|
||||
): Promise<ConfirmedIdentity | null> => {
|
||||
nameRef.current = pick.name.trim();
|
||||
setState((prev) => ({...prev, pickError: ''}));
|
||||
|
||||
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
|
||||
// 누른 확정이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
|
||||
await ensureAutoSession();
|
||||
if (!getAccessToken()) return confirmManual(pick.name, pick.address, '지도 검색');
|
||||
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const placeId = await ensurePlace(
|
||||
pick.name.trim(),
|
||||
INDUSTRY_TO_CATEGORY[industry],
|
||||
controller.signal,
|
||||
);
|
||||
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
|
||||
|
||||
// 상호만으로는 동명 업소가 섞인다 — 사장님이 고른 그 주소까지 붙여 좁힌다.
|
||||
const query = [pick.name.trim(), pick.address.trim()].filter(Boolean).join(' ');
|
||||
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
|
||||
const candidates = res.candidates ?? [];
|
||||
const hit =
|
||||
candidates.find(
|
||||
(c) => c.naver_place_url && normalizeName(c.name) === normalizeName(pick.name),
|
||||
) ?? candidates.find((c) => c.naver_place_url);
|
||||
|
||||
if (!hit?.naver_place_url) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
pickError:
|
||||
'이 가게의 네이버 플레이스를 자동으로 찾지 못했습니다. 아래에서 지도 주소를 붙여넣어 주세요.',
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
return await confirmByUrl(hit.naver_place_url, industry);
|
||||
} catch (error) {
|
||||
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
|
||||
return null;
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
},
|
||||
[confirmByUrl, confirmManual, ensurePlace],
|
||||
);
|
||||
|
||||
return {...state, isConfirming, searchPublic, confirmPick, confirmByUrl, confirmManual, reset};
|
||||
}
|
||||
|
||||
96
solution/frontend/src/features/onboarding/wizardUrl.ts
Normal file
96
solution/frontend/src/features/onboarding/wizardUrl.ts
Normal 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];
|
||||
}
|
||||
@ -17,7 +17,7 @@ import {useBuilderStore} from '@/stores/builder';
|
||||
import type {ApplyPlaceOptions} from '@/stores/builderTypes';
|
||||
|
||||
export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions) {
|
||||
const enterEditor = options?.enterEditor ?? true;
|
||||
const isOnboarding = options?.isOnboarding ?? false;
|
||||
const enabled = Boolean(placeId);
|
||||
const id = placeId ?? '';
|
||||
|
||||
@ -42,8 +42,8 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
|
||||
|
||||
/**
|
||||
* ★ useEffect 가 아니라 useLayoutEffect 다.
|
||||
* 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 위저드 1단계가 한 번
|
||||
* 그려졌다가 편집기로 바뀐다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다.
|
||||
* 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 빈 편집기가 한 번 그려졌다가
|
||||
* 값이 채워진다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (!placeId) {
|
||||
@ -53,8 +53,10 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
|
||||
if (!place) return;
|
||||
// fact 가 아직 안 왔어도 먼저 온 place 로 상호·주소부터 얹는다.
|
||||
// fact/스키마가 도착하면 이 effect 가 한 번 더 돌아 정보 표를 채운다.
|
||||
applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {enterEditor});
|
||||
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, enterEditor]);
|
||||
applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {
|
||||
isOnboarding,
|
||||
});
|
||||
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, isOnboarding]);
|
||||
|
||||
/**
|
||||
* 서버에 저장된 디자인을 얹는다.
|
||||
|
||||
@ -26,6 +26,10 @@ export const ERROR_MESSAGE: Record<string, string> = {
|
||||
PLACE_NOT_VERIFIED: '동일 업소 검증을 먼저 끝내야 합니다. 검증 전에는 수집·발행이 열리지 않습니다.',
|
||||
PLACE_VERIFY_NO_CANDIDATE: '외부 장소 정보에서 이 상호를 찾지 못했습니다.',
|
||||
PLACE_VERIFY_AMBIGUOUS: '같은 이름의 업소가 여럿입니다. 어느 곳인지 골라 주세요.',
|
||||
// 상호명 공개 검색(로그인 전 첫 화면)이 만나는 세 가지. 인증이 없는 경로라 분당 상한이 걸려 있다.
|
||||
HTTP_TO_MANY_REQUEST: '검색을 너무 자주 했습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
LOCAL_NOT_CONFIGURED: '지도 검색이 아직 설정되지 않았습니다. 네이버 지도 주소를 붙여넣어 진행해 주세요.',
|
||||
LOCAL_FETCH_FAILED: '지도에서 가게를 찾지 못했습니다. 잠시 뒤 다시 시도하거나 지도 주소를 붙여넣어 주세요.',
|
||||
LINK_NOT_CONFIRMED: '확정한 채널 URL 만 수집 대상이 됩니다.',
|
||||
|
||||
// fact
|
||||
|
||||
@ -1,25 +1,34 @@
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {useEffect} from 'react';
|
||||
import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
|
||||
import {Link, useSearchParams} from 'react-router';
|
||||
import {SiteStatus} from '@o2o/shared';
|
||||
import {SiteStatus, type IndustryType} from '@o2o/shared';
|
||||
import {getAccessToken} from '@/api';
|
||||
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
|
||||
import {
|
||||
EDITOR_STEP,
|
||||
Step1Industry,
|
||||
Step2PlaceSearch,
|
||||
Step3DataReview,
|
||||
Step4Template,
|
||||
Step5Generating,
|
||||
useWizardStep,
|
||||
} from '@/features/onboarding';
|
||||
import {EditorLayout} from '@/features/builder';
|
||||
import {useAutoLogin} from '@/hooks/useAutoLogin';
|
||||
import {usePlaceSync} from '@/hooks/usePlaceSync';
|
||||
import {userLabel, useAuthStore} from '@/stores/auth';
|
||||
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
|
||||
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
|
||||
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
|
||||
|
||||
/** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */
|
||||
const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
|
||||
|
||||
function parseIndustry(value: string | null): IndustryType | null {
|
||||
return INDUSTRY_VALUES.includes(value as IndustryType) ? (value as IndustryType) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "발행본 사이트 열기" 가 향할 주소.
|
||||
*
|
||||
@ -36,32 +45,55 @@ function siteUrl(domain: string | null | undefined): string | null {
|
||||
export function BuilderPage() {
|
||||
useAutoLogin();
|
||||
/**
|
||||
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`.
|
||||
* ★ 이 화면이 무엇을 그릴지는 **전부 주소창이 정한다.**
|
||||
*
|
||||
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고
|
||||
* (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면
|
||||
* 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다.
|
||||
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다.
|
||||
* ?step= 어느 단계인가(없으면 wizardUrl.defaultStep 이 정한다)
|
||||
* ?placeId= 어떤 사업장인가 — 라우트(`/builder/:placeId`)로 받지 않는 이유는
|
||||
* 빌더가 로그인 없이 도는 경로이고 placeId 는 있을 수도 없을 수도 있어서다.
|
||||
* ?flow=onboarding 위저드가 방금 만든 사업장이다(딥링크로 편집하러 온 것과 구분한다)
|
||||
* ?new=1 ?q= ?industry= 랜딩에서 넘어온 입구. 한 번 읽고 주소창에서 지운다.
|
||||
*/
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [step, goToStep] = useWizardStep();
|
||||
const urlPlaceId = searchParams.get('placeId');
|
||||
const isOnboarding = searchParams.get('flow') === 'onboarding';
|
||||
|
||||
/**
|
||||
* `?new=1` 로 들어오면 위저드를 1단계(업종 선택)부터 시작한다.
|
||||
* 랜딩에서 넘어온 입구를 한 번만 읽는다 — `?new=1` · `?q=` · `?industry=`.
|
||||
*
|
||||
* ★ 저장된 상태를 그대로 두면 지난번 에디터가 복원된다 — 새 가게를 만들러 온 사람에게는
|
||||
* 자기가 만든 적 없는 화면이 뜨는 셈이다. 비운 뒤에는 주소창에서 플래그를 지워,
|
||||
* 새로고침할 때마다 작업하던 내용이 날아가지 않게 한다.
|
||||
* ★ `?new=1` 은 저장된 상태를 비운다. 안 비우면 새 가게를 만들러 온 사람에게 지난번 에디터가
|
||||
* 복원돼 뜬다. 읽은 뒤 주소창에서 지우는 이유는 두 가지다 — 새로고침마다 작업하던 내용이
|
||||
* 날아가지 않게, 그리고 사장님이 화면에서 고친 상호·업종을 링크 값이 도로 덮지 않게.
|
||||
* ★ **다른 쿼리는 남긴다.** 예전엔 `setSearchParams({})` 로 통째로 비웠는데, 그러면
|
||||
* `?new=1&q=...` 로 들어온 상호가 읽히기도 전에 사라진다.
|
||||
*/
|
||||
const reset = useBuilderStore((s) => s.reset);
|
||||
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
|
||||
const setStoreName = useBuilderStore((s) => s.setStoreName);
|
||||
const isNew = searchParams.get('new') === '1';
|
||||
const seedQuery = searchParams.get('q');
|
||||
const seedIndustry = searchParams.get('industry');
|
||||
useEffect(() => {
|
||||
if (!isNew) return;
|
||||
reset();
|
||||
setSearchParams({}, {replace: true});
|
||||
}, [isNew, reset, setSearchParams]);
|
||||
if (!isNew && seedQuery === null && seedIndustry === null) return;
|
||||
if (isNew) reset();
|
||||
// 순서가 뒤집히면 안 된다 — selectIndustry 는 시드를 통째로 갈아 상호를 비운다.
|
||||
const industry = parseIndustry(seedIndustry);
|
||||
if (industry) selectIndustry(industry);
|
||||
if (seedQuery) setStoreName(seedQuery);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('new');
|
||||
next.delete('q');
|
||||
next.delete('industry');
|
||||
return next;
|
||||
},
|
||||
{replace: true},
|
||||
);
|
||||
}, [isNew, seedQuery, seedIndustry, reset, selectIndustry, setStoreName, setSearchParams]);
|
||||
|
||||
/**
|
||||
* 위저드 2단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다.
|
||||
* 위저드 1단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다.
|
||||
*
|
||||
* ★ 이게 없으면 위저드를 끝까지 걸어온 사장님이 에디터에서 **업종 예시값**을 본다 —
|
||||
* 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다.
|
||||
@ -69,21 +101,8 @@ export function BuilderPage() {
|
||||
*/
|
||||
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
|
||||
const placeId = urlPlaceId ?? wizardPlaceId;
|
||||
/**
|
||||
* 에디터로 바로 들어갈지.
|
||||
*
|
||||
* ★ **처음 들어온 순간**의 주소창만 본다. 위저드 2단계가 확정 뒤에 `?placeId=` 를 붙이는데
|
||||
* (새로고침 복원용), 그걸 실시간으로 보면 "URL 붙여넣고 확인 → 곧장 에디터" 가 된다 —
|
||||
* 수집 결과를 보여주는 3·4·5단계가 통째로 건너뛰어진다. 실제로 그렇게 됐다.
|
||||
* 사업장 목록에서 딥링크로 들어온 경우(이미 만든 가게를 여는 것)만 에디터로 보낸다.
|
||||
*/
|
||||
const enteredWithPlace = useRef(
|
||||
Boolean(urlPlaceId) && searchParams.get('flow') !== 'onboarding',
|
||||
).current;
|
||||
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
|
||||
const sync = usePlaceSync(placeId, {isOnboarding});
|
||||
|
||||
const step = useBuilderStore((s) => s.step);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@ -128,7 +147,7 @@ export function BuilderPage() {
|
||||
|
||||
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
|
||||
if (step === EDITOR_STEP && !isSignedIn) {
|
||||
return <EditorSignInGate onBack={() => goToStep(4)} />;
|
||||
return <EditorSignInGate onBack={() => goToStep('template')} />;
|
||||
}
|
||||
|
||||
if (step === EDITOR_STEP) {
|
||||
@ -225,7 +244,9 @@ export function BuilderPage() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
|
||||
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
|
||||
<Link to="/" className="transition-opacity hover:opacity-60">
|
||||
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
|
||||
</Link>
|
||||
{/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */}
|
||||
{isSignedIn ? (
|
||||
<Link
|
||||
@ -246,11 +267,11 @@ export function BuilderPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{step === 1 && <Step1Industry />}
|
||||
{step === 2 && <Step2PlaceSearch />}
|
||||
{step === 3 && <Step3DataReview />}
|
||||
{step === 4 && <Step4Template />}
|
||||
{step === 5 && <Step5Generating />}
|
||||
{step === 'search' && <Step2PlaceSearch />}
|
||||
{step === 'industry' && <Step1Industry />}
|
||||
{step === 'collect' && <Step3DataReview />}
|
||||
{step === 'template' && <Step4Template />}
|
||||
{step === 'generating' && <Step5Generating />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -23,9 +23,9 @@ import type {
|
||||
ConfirmedIdentity,
|
||||
FactRef,
|
||||
LivePlaceInput,
|
||||
PendingPick,
|
||||
RightTab,
|
||||
WeatherLocation,
|
||||
WizardStep,
|
||||
} from '@/stores/builderTypes';
|
||||
|
||||
/**
|
||||
@ -38,16 +38,14 @@ export type {
|
||||
ConfirmedIdentity,
|
||||
FactRef,
|
||||
LivePlaceInput,
|
||||
PendingPick,
|
||||
RightTab,
|
||||
WeatherLocation,
|
||||
WizardStep,
|
||||
};
|
||||
export {EDITOR_STEP} from '@/stores/builderTypes';
|
||||
import {EDITOR_STEP} from '@/stores/builderTypes';
|
||||
|
||||
interface BuilderState {
|
||||
// ── 위저드 ────────────────────────────────────────────
|
||||
step: WizardStep;
|
||||
// ★ 단계(step)는 여기 없다 — 주소창이 소유한다(features/onboarding/wizardUrl).
|
||||
industry: IndustryType;
|
||||
/**
|
||||
* 지금 편집 중인 실제 사업장. null 이면 아직 가게가 정해지지 않은 상태다.
|
||||
@ -65,13 +63,15 @@ interface BuilderState {
|
||||
* 화면의 상호·주소는 업종 예시일 뿐이라는 뜻이고, 3단계는 그 사실을 그대로 표시한다.
|
||||
*/
|
||||
confirmedIdentity: ConfirmedIdentity | null;
|
||||
/** 업종을 못 정해 업종 화면으로 넘긴 후보. 돌아와서 확정을 이어갈 때만 쓴다. */
|
||||
pendingPick: PendingPick | null;
|
||||
|
||||
// 수집(Step 2)
|
||||
// 수집
|
||||
isGathering: boolean;
|
||||
gatherStage: number;
|
||||
gatherCompleted: boolean;
|
||||
|
||||
// 템플릿(Step 3) · 생성(Step 4)
|
||||
// 템플릿 · 생성
|
||||
templateId: string;
|
||||
colorPaletteId: string | null;
|
||||
generateStage: number;
|
||||
@ -97,7 +97,6 @@ interface BuilderState {
|
||||
publishedUrl: string | null;
|
||||
|
||||
// ── 액션 ──────────────────────────────────────────────
|
||||
goToStep: (step: WizardStep) => void;
|
||||
selectIndustry: (industry: IndustryType) => void;
|
||||
|
||||
/** 실사업장 데이터를 캔버스에 얹는다(같은 사업장을 다시 읽어도 편집을 지우지 않는다). */
|
||||
@ -114,12 +113,16 @@ interface BuilderState {
|
||||
/** 확정을 물린다 — 상호를 다시 검색하러 갈 때. */
|
||||
clearIdentity: () => void;
|
||||
|
||||
/** 업종 화면으로 넘길 후보를 들려 보낸다(null 이면 비운다). */
|
||||
setPendingPick: (pick: PendingPick | null) => void;
|
||||
|
||||
startGather: () => void;
|
||||
setGatherStage: (stage: number) => void;
|
||||
finishGather: () => void;
|
||||
|
||||
selectTemplate: (templateId: string) => void;
|
||||
selectColorPalette: (paletteId: string | null) => void;
|
||||
/** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */
|
||||
startGenerating: () => void;
|
||||
setGenerateStage: (stage: number) => void;
|
||||
|
||||
@ -288,10 +291,10 @@ for (const key of ['o2osite.builder.wizard.v1', 'o2osite.builder.wizard.v2']) {
|
||||
let factSaver: FactSaver;
|
||||
|
||||
export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
step: 1,
|
||||
...seedFor(FALLBACK_INDUSTRY),
|
||||
placeId: null,
|
||||
confirmedIdentity: null,
|
||||
pendingPick: null,
|
||||
savingFieldIds: [],
|
||||
|
||||
isGathering: false,
|
||||
@ -306,8 +309,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
isPublishModalOpen: false,
|
||||
publishedUrl: null,
|
||||
|
||||
goToStep: (step) => set({step}),
|
||||
|
||||
// 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면
|
||||
// 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다.
|
||||
selectIndustry: (industry) => {
|
||||
@ -327,7 +328,16 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
location: identity.address,
|
||||
infoFields: withConfirmedIdentity(seed.infoFields, identity),
|
||||
}
|
||||
: null),
|
||||
: {
|
||||
/**
|
||||
* ★ 사장님이 친 상호·위치는 시드가 아니다 — 업종을 바꿨다고 지우면 안 된다.
|
||||
* 업종이 첫 화면이던 시절엔 여기가 늘 빈 값이라 티가 안 났는데, 지금은 상호를
|
||||
* 먼저 받는다: 지우면 검색어를 친 뒤 업종만 골라도 그 이름이 사라진다.
|
||||
*/
|
||||
storeName: state.storeName,
|
||||
location: state.location,
|
||||
infoFields: withOwnerIdentity(seed.infoFields, state.storeName, state.location),
|
||||
}),
|
||||
// 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면
|
||||
// "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다.
|
||||
placeId: null,
|
||||
@ -350,7 +360,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
* '확인 필요'로 튀어, 사장님 눈에는 클릭이 씹힌 것으로 보인다(withPendingEdits).
|
||||
*/
|
||||
applyPlace: (input, options) => {
|
||||
const enterEditor = options?.enterEditor ?? true;
|
||||
const isOnboarding = options?.isOnboarding ?? false;
|
||||
// 처음 여는 사업장인가. 리페치(같은 placeId)면 섹션·단계를 건드리지 않는다.
|
||||
const isNewPlace = get().placeId !== input.placeId;
|
||||
// 사업장을 갈아타면 앞 사업장의 저장 대기분은 버린다 — 들고 가면 남의 값이 이 화면에 얹힌다.
|
||||
@ -366,11 +376,14 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
return {
|
||||
// 업종이 바뀔 때만 시드를 갈아끼운다(카페 사업장에 '객실 안내'가 남지 않게).
|
||||
...(needsReseed ? seedFor(input.industry) : null),
|
||||
// 이미 등록된 사업장을 여는 것이므로 수집 위저드를 다시 걷게 하지 않는다.
|
||||
...(isNewPlace
|
||||
? {
|
||||
step: enterEditor ? EDITOR_STEP : 3,
|
||||
...(!enterEditor
|
||||
/**
|
||||
* ★ 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있다(브라우저에 저장하지
|
||||
* 않으므로). 그대로 두면 3단계가 "아직 가게를 안 골랐다"고 판단해 수집을 열지
|
||||
* 않는다 — 서버에서 읽어온 사업장으로 여기서 다시 세운다.
|
||||
*/
|
||||
...(isOnboarding
|
||||
? {
|
||||
confirmedIdentity: {
|
||||
placeId: input.placeId,
|
||||
@ -384,18 +397,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
selectedSectionId: null,
|
||||
isGathering: false,
|
||||
}
|
||||
: /**
|
||||
* 같은 사업장을 **딥링크로 다시 연** 경우다(`/builder?placeId=...`).
|
||||
*
|
||||
* ★ 단계를 다시 못 박지 않으면, 스토어에 남아 있던 위저드 단계가 그대로 뜬다 —
|
||||
* 사업장 목록에서 [사이트 편집] 을 눌렀는데 생성 화면(5단계)이 되살아나
|
||||
* 생성 잡이 한 번 더 들어가는 일이 실제로 생긴다. 편집하러 온 사람은
|
||||
* 언제나 편집기를 봐야 한다. (리페치는 enterEditor 여부와 무관하게 안전하다 —
|
||||
* 이미 EDITOR_STEP 이면 같은 값을 다시 쓰는 것뿐이다.)
|
||||
*/
|
||||
enterEditor
|
||||
? {step: EDITOR_STEP}
|
||||
: null),
|
||||
: null),
|
||||
/**
|
||||
* ★ "수집 완료"는 fact 가 있을 때만이다.
|
||||
*
|
||||
@ -454,6 +456,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
|
||||
clearIdentity: () => set({confirmedIdentity: null}),
|
||||
|
||||
setPendingPick: (pendingPick) => set({pendingPick}),
|
||||
|
||||
toggleChannel: (channelId) =>
|
||||
set((state) => ({
|
||||
selectedChannels: state.selectedChannels.includes(channelId)
|
||||
@ -496,9 +500,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
set({colorPaletteId});
|
||||
persistTheme();
|
||||
},
|
||||
// ★ 생성 화면(5)으로 보낸다. 단계 번호는 EDITOR_STEP 과 함께 WizardStep 주석이 기준이다 —
|
||||
// 여기 숫자가 자기 화면(4)이면 [사이트 생성하기]가 아무 일도 안 하는 것처럼 보인다.
|
||||
startGenerating: () => set({step: 5, generateStage: 1}),
|
||||
// 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다.
|
||||
startGenerating: () => set({generateStage: 1}),
|
||||
setGenerateStage: (generateStage) => set({generateStage}),
|
||||
|
||||
selectSection: (selectedSectionId) => set({selectedSectionId}),
|
||||
@ -747,11 +750,11 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
factSaver.clear();
|
||||
clearThemeSaves();
|
||||
set({
|
||||
step: 1,
|
||||
...seedFor(get().industry),
|
||||
placeId: null,
|
||||
// [처음부터]는 "이 가게가 맞다"까지 물린다 — 상호부터 다시 확인받는다.
|
||||
confirmedIdentity: null,
|
||||
pendingPick: null,
|
||||
isGathering: false,
|
||||
gatherStage: 1,
|
||||
gatherCompleted: false,
|
||||
|
||||
@ -8,23 +8,9 @@
|
||||
import type {FactStatus, IndustryType, InfoField, PhotoItem} from '@o2o/shared';
|
||||
|
||||
/**
|
||||
* 위저드 단계.
|
||||
*
|
||||
* 1 업종 선택
|
||||
* 2 상호 검색 → **후보 목록에서 내 가게 확인** ← 남의 가게가 섞이면 제일 비싼 실수다
|
||||
* 3 수집된 데이터 확인 ← [맞아요]/[아니에요] 가 여기서 끝난다
|
||||
* 4 템플릿 선택
|
||||
* 5 생성 중
|
||||
* 6 에디터
|
||||
*
|
||||
* ★ 2 와 3 은 성격이 다른 확인이라 한 화면에 두지 않는다.
|
||||
* 2 는 "이 가게가 맞나" (신원), 3 은 "이 값이 맞나" (사실). 신원이 틀린 채로
|
||||
* 사실을 확인시키면 사장님이 남의 가게 정보를 자기 것이라고 승인하게 된다.
|
||||
* ★ 위저드 단계는 여기 없다 — 주소창이 소유한다(`features/onboarding/wizardUrl`).
|
||||
* 스토어에 두면 뒤로가기·북마크가 단계를 못 따라온다.
|
||||
*/
|
||||
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
/** 위저드가 끝나고 에디터로 넘어가는 단계. 숫자를 화면마다 외우지 않게 한 곳에 둔다. */
|
||||
export const EDITOR_STEP = 6 satisfies WizardStep;
|
||||
export type RightTab = 'content' | 'design' | 'photos' | 'info' | 'faq' | 'verify';
|
||||
|
||||
/**
|
||||
@ -97,11 +83,26 @@ export interface ConfirmedIdentity {
|
||||
|
||||
export interface ApplyPlaceOptions {
|
||||
/**
|
||||
* 처음 얹는 사업장일 때 곧장 에디터로 보낼지.
|
||||
* 위저드 안에서 열린 사업장인가(`?flow=onboarding`).
|
||||
*
|
||||
* ★ 기본은 true — 사업장 목록에서 딥링크로 들어온 경우다(이미 등록된 가게를 여는 것이므로
|
||||
* 위저드를 다시 걷게 하지 않는다). 반대로 위저드 3단계는 **수집 결과를 위저드 안에서**
|
||||
* 보여줘야 하므로 false 로 부른다. 안 그러면 수집이 끝나는 순간 화면이 에디터로 튄다.
|
||||
* ★ 화면을 옮기지는 않는다 — 그건 주소창(`?step=`)의 일이다. 이 값이 하는 일은 하나뿐:
|
||||
* 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있으므로, 서버에서 읽은 사업장으로
|
||||
* 그걸 다시 세운다. 없으면 3단계가 "가게를 아직 안 골랐다"고 판단해 수집을 열지 않는다.
|
||||
*/
|
||||
enterEditor?: boolean;
|
||||
isOnboarding?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 검색에서 고른, **아직 업종이 안 정해진** 후보.
|
||||
*
|
||||
* ★ 업종 화면(`?step=industry`)을 다녀오는 동안만 산다. 사장님이 고른 업종을 들고 돌아와야
|
||||
* 그 업종으로 사업장을 만들 수 있어서다(사업장의 category 는 만들 때 정해진다).
|
||||
* ★ 브라우저에 저장하지 않는다(위저드 상태 규칙). 새로고침하면 사라지고 검색부터 다시 한다 —
|
||||
* 확정 전이라 서버에도 아직 아무것도 없다.
|
||||
*/
|
||||
export interface PendingPick {
|
||||
name: string;
|
||||
address: string;
|
||||
/** 업종 화면에서 고른 값. 이게 채워져야 확정이 이어진다. */
|
||||
industry?: IndustryType;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user