From a61f9724eadabced923080ff332db2d6c62a2628 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 3 Sep 2026 10:20:57 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/frontend:=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=EC=9D=84=20=EC=83=81=ED=98=B8=EB=AA=85?= =?UTF-8?q?=EB=B6=80=ED=84=B0=20=E2=80=94=20=EB=8B=A8=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=EC=B0=BD=EC=9C=BC=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B0=EA=B3=A0=20=EC=97=85=EC=A2=85=EC=9D=80=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=9D=B4=20=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은 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 통과 --- .../frontend/src/api/generated/model/index.ts | 15 + .../api/generated/model/listShowcaseParams.ts | 15 + .../api/generated/model/placeSearchItem.ts | 24 ++ .../model/placeSearchItemCategory.ts | 9 + .../model/placeSearchItemCategoryName.ts | 8 + .../model/placeSearchItemRoadAddress.ts | 8 + .../src/api/generated/model/resPlaceSearch.ts | 23 ++ .../api/generated/model/resPlaceSearchMsg.ts | 8 + .../generated/model/resPlaceSearchSource.ts | 9 + .../src/api/generated/model/resShowcase.ts | 15 + .../src/api/generated/model/resShowcaseMsg.ts | 8 + .../model/searchPlacesPublicParams.ts | 15 + .../src/api/generated/model/showcaseItem.ts | 24 ++ .../api/generated/model/showcaseItemRegion.ts | 8 + .../model/showcaseItemThumbnailUrl.ts | 8 + .../src/api/generated/model/siteData.ts | 2 + .../generated/model/siteDataThumbnailUrl.ts | 8 + .../frontend/src/api/generated/place/place.ts | 96 +++++ .../src/api/generated/showcase/showcase.ts | 128 ++++++ .../src/features/builder/EditorHeader.tsx | 20 +- .../src/features/onboarding/Step1Industry.tsx | 72 +++- .../features/onboarding/Step2PlaceSearch.tsx | 375 +++++++++++------- .../features/onboarding/Step3DataReview.tsx | 17 +- .../src/features/onboarding/Step4Template.tsx | 8 +- .../features/onboarding/Step5Generating.tsx | 16 +- .../src/features/onboarding/WizardSteps.tsx | 13 +- .../frontend/src/features/onboarding/index.ts | 2 + .../src/features/onboarding/usePlaceSearch.ts | 307 +++++++------- .../src/features/onboarding/wizardUrl.ts | 96 +++++ solution/frontend/src/hooks/usePlaceSync.ts | 12 +- solution/frontend/src/lib/errorMessages.ts | 4 + solution/frontend/src/pages/BuilderPage.tsx | 97 +++-- solution/frontend/src/stores/builder.ts | 67 ++-- solution/frontend/src/stores/builderTypes.ts | 43 +- 34 files changed, 1137 insertions(+), 443 deletions(-) create mode 100644 solution/frontend/src/api/generated/model/listShowcaseParams.ts create mode 100644 solution/frontend/src/api/generated/model/placeSearchItem.ts create mode 100644 solution/frontend/src/api/generated/model/placeSearchItemCategory.ts create mode 100644 solution/frontend/src/api/generated/model/placeSearchItemCategoryName.ts create mode 100644 solution/frontend/src/api/generated/model/placeSearchItemRoadAddress.ts create mode 100644 solution/frontend/src/api/generated/model/resPlaceSearch.ts create mode 100644 solution/frontend/src/api/generated/model/resPlaceSearchMsg.ts create mode 100644 solution/frontend/src/api/generated/model/resPlaceSearchSource.ts create mode 100644 solution/frontend/src/api/generated/model/resShowcase.ts create mode 100644 solution/frontend/src/api/generated/model/resShowcaseMsg.ts create mode 100644 solution/frontend/src/api/generated/model/searchPlacesPublicParams.ts create mode 100644 solution/frontend/src/api/generated/model/showcaseItem.ts create mode 100644 solution/frontend/src/api/generated/model/showcaseItemRegion.ts create mode 100644 solution/frontend/src/api/generated/model/showcaseItemThumbnailUrl.ts create mode 100644 solution/frontend/src/api/generated/model/siteDataThumbnailUrl.ts create mode 100644 solution/frontend/src/api/generated/showcase/showcase.ts create mode 100644 solution/frontend/src/features/onboarding/wizardUrl.ts diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index 87864fa..d616f6f 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -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'; diff --git a/solution/frontend/src/api/generated/model/listShowcaseParams.ts b/solution/frontend/src/api/generated/model/listShowcaseParams.ts new file mode 100644 index 0000000..303f4de --- /dev/null +++ b/solution/frontend/src/api/generated/model/listShowcaseParams.ts @@ -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; +}; diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts new file mode 100644 index 0000000..1aacf27 --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts @@ -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; +} diff --git a/solution/frontend/src/api/generated/model/placeSearchItemCategory.ts b/solution/frontend/src/api/generated/model/placeSearchItemCategory.ts new file mode 100644 index 0000000..4c4e071 --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeSearchItemCategory.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/placeSearchItemCategoryName.ts b/solution/frontend/src/api/generated/model/placeSearchItemCategoryName.ts new file mode 100644 index 0000000..feb298e --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeSearchItemCategoryName.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/placeSearchItemRoadAddress.ts b/solution/frontend/src/api/generated/model/placeSearchItemRoadAddress.ts new file mode 100644 index 0000000..e156c8f --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeSearchItemRoadAddress.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/resPlaceSearch.ts b/solution/frontend/src/api/generated/model/resPlaceSearch.ts new file mode 100644 index 0000000..beb75cc --- /dev/null +++ b/solution/frontend/src/api/generated/model/resPlaceSearch.ts @@ -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[]; +} diff --git a/solution/frontend/src/api/generated/model/resPlaceSearchMsg.ts b/solution/frontend/src/api/generated/model/resPlaceSearchMsg.ts new file mode 100644 index 0000000..ab2bd13 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resPlaceSearchMsg.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/resPlaceSearchSource.ts b/solution/frontend/src/api/generated/model/resPlaceSearchSource.ts new file mode 100644 index 0000000..c1ea375 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resPlaceSearchSource.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/resShowcase.ts b/solution/frontend/src/api/generated/model/resShowcase.ts new file mode 100644 index 0000000..7c01a7f --- /dev/null +++ b/solution/frontend/src/api/generated/model/resShowcase.ts @@ -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[]; +} diff --git a/solution/frontend/src/api/generated/model/resShowcaseMsg.ts b/solution/frontend/src/api/generated/model/resShowcaseMsg.ts new file mode 100644 index 0000000..b71fd15 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resShowcaseMsg.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/searchPlacesPublicParams.ts b/solution/frontend/src/api/generated/model/searchPlacesPublicParams.ts new file mode 100644 index 0000000..8d965fd --- /dev/null +++ b/solution/frontend/src/api/generated/model/searchPlacesPublicParams.ts @@ -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; +}; diff --git a/solution/frontend/src/api/generated/model/showcaseItem.ts b/solution/frontend/src/api/generated/model/showcaseItem.ts new file mode 100644 index 0000000..5d3d0a7 --- /dev/null +++ b/solution/frontend/src/api/generated/model/showcaseItem.ts @@ -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; +} diff --git a/solution/frontend/src/api/generated/model/showcaseItemRegion.ts b/solution/frontend/src/api/generated/model/showcaseItemRegion.ts new file mode 100644 index 0000000..d5090ae --- /dev/null +++ b/solution/frontend/src/api/generated/model/showcaseItemRegion.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/showcaseItemThumbnailUrl.ts b/solution/frontend/src/api/generated/model/showcaseItemThumbnailUrl.ts new file mode 100644 index 0000000..ffdcc72 --- /dev/null +++ b/solution/frontend/src/api/generated/model/showcaseItemThumbnailUrl.ts @@ -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; diff --git a/solution/frontend/src/api/generated/model/siteData.ts b/solution/frontend/src/api/generated/model/siteData.ts index f3a3279..f48299d 100644 --- a/solution/frontend/src/api/generated/model/siteData.ts +++ b/solution/frontend/src/api/generated/model/siteData.ts @@ -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; } diff --git a/solution/frontend/src/api/generated/model/siteDataThumbnailUrl.ts b/solution/frontend/src/api/generated/model/siteDataThumbnailUrl.ts new file mode 100644 index 0000000..2a3cb5c --- /dev/null +++ b/solution/frontend/src/api/generated/model/siteDataThumbnailUrl.ts @@ -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; diff --git a/solution/frontend/src/api/generated/place/place.ts b/solution/frontend/src/api/generated/place/place.ts index ee2d808..b0e67d7 100644 --- a/solution/frontend/src/api/generated/place/place.ts +++ b/solution/frontend/src/api/generated/place/place.ts @@ -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>, TE +/** + * 상호명으로 외부 장소 DB(카카오 → 없으면 네이버)를 찾아 후보를 그대로 돌려준다. ★ 인증이 없다 — 랜딩 첫 화면이 부른다. 사업장을 만들지도, 우리 DB 를 읽지도 않는다. ★ 응답의 category 는 외부 분류에서 **추정한 기본값**이다. None 이면 못 정한 것이고, 값이 있어도 확정이 아니다 — 화면은 언제나 바꿀 수 있게 둔다. + * @summary 상호명 공개 검색 + */ +export const searchPlacesPublic = ( + params: SearchPlacesPublicParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {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 = >, TError = void | HTTPValidationError>(params: SearchPlacesPublicParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getSearchPlacesPublicQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => searchPlacesPublic(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type SearchPlacesPublicQueryResult = NonNullable>> +export type SearchPlacesPublicQueryError = void | HTTPValidationError + + +export function useSearchPlacesPublic>, TError = void | HTTPValidationError>( + params: SearchPlacesPublicParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useSearchPlacesPublic>, TError = void | HTTPValidationError>( + params: SearchPlacesPublicParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useSearchPlacesPublic>, TError = void | HTTPValidationError>( + params: SearchPlacesPublicParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 상호명 공개 검색 + */ + +export function useSearchPlacesPublic>, TError = void | HTTPValidationError>( + params: SearchPlacesPublicParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getSearchPlacesPublicQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + /** * 상호명 하나로 시작한다. 주소·좌표는 동일 업소 검증(verify)이 채운다. * @summary 사업장 등록 diff --git a/solution/frontend/src/api/generated/showcase/showcase.ts b/solution/frontend/src/api/generated/showcase/showcase.ts new file mode 100644 index 0000000..2ed057a --- /dev/null +++ b/solution/frontend/src/api/generated/showcase/showcase.ts @@ -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 unknown> = Parameters[1]; + + + +/** + * 실제로 발행된 사이트를 최신순으로 준다. 상호명·업종·지역(시·군·구까지)·발행 주소·썸네일뿐이다 — 로그인 없이 나가므로 사업장 식별자·전화번호·상세 주소는 싣지 않는다. 썸네일은 그 사이트의 대표 사진이고, 만들지 못한 사이트는 키가 없다. + * @summary 발행 사이트 쇼케이스(공개) + */ +export const listShowcase = ( + params?: ListShowcaseParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/showcase`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getListShowcaseQueryKey = (params?: ListShowcaseParams,) => { + return [ + `/v1/showcase`, ...(params ? [params]: []) + ] as const; + } + + +export const getListShowcaseQueryOptions = >, TError = void | HTTPValidationError>(params?: ListShowcaseParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListShowcaseQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listShowcase(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListShowcaseQueryResult = NonNullable>> +export type ListShowcaseQueryError = void | HTTPValidationError + + +export function useListShowcase>, TError = void | HTTPValidationError>( + params: undefined | ListShowcaseParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListShowcase>, TError = void | HTTPValidationError>( + params?: ListShowcaseParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListShowcase>, TError = void | HTTPValidationError>( + params?: ListShowcaseParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 발행 사이트 쇼케이스(공개) + */ + +export function useListShowcase>, TError = void | HTTPValidationError>( + params?: ListShowcaseParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListShowcaseQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/solution/frontend/src/features/builder/EditorHeader.tsx b/solution/frontend/src/features/builder/EditorHeader.tsx index a84c1fd..16f9a82 100644 --- a/solution/frontend/src/features/builder/EditorHeader.tsx +++ b/solution/frontend/src/features/builder/EditorHeader.tsx @@ -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 ? ( - + // 편집 중이어도 로고는 홈으로 간다 — 눌리지 않는 로고는 고장으로 읽힌다. + // 입력값은 서버에 저장되므로 나갔다 들어와도 그대로다. + - + ) : ( - + )} diff --git a/solution/frontend/src/features/onboarding/Step1Industry.tsx b/solution/frontend/src/features/onboarding/Step1Industry.tsx index ee866d9..e42eeaf 100644 --- a/solution/frontend/src/features/onboarding/Step1Industry.tsx +++ b/solution/frontend/src/features/onboarding/Step1Industry.tsx @@ -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 (
@@ -22,10 +58,12 @@ export function Step1Industry() {

- 어떤 업종의 홈페이지를 만들까요? + {mustChoose ? '이 가게는 어떤 업종인가요?' : '업종을 바꿀까요?'}

- 업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다. + {mustChoose + ? '지도 분류만으로는 업종을 정하지 못했습니다. 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.' + : '업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'}

@@ -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 (
); diff --git a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx index 00f42d4..16b1947 100644 --- a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx +++ b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx @@ -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 = { - input: '상호명을 검색해 내 가게를 찾아주세요', + input: '가게 이름을 알려주세요', searching: '내 가게를 찾는 중입니다', picking: '이 중에 사장님 가게가 있나요?', unavailable: '지도 검색을 사용할 수 없습니다', @@ -21,22 +25,20 @@ const STAGE_TITLE: Record = { }; const STAGE_DESCRIPTION: Record = { - input: '지도·플레이스에 등록된 가게 중에서 사장님이 직접 고른 한 곳만 기준이 됩니다.', + input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.', searching: '', - picking: '고른 가게의 상호·주소·전화가 이 사이트의 기준 정보가 됩니다.', + picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.', unavailable: '', confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.', }; -/** outcome 코드 → 사장님이 읽을 한 줄. 서버 판정은 문구를 고르는 데만 쓴다. */ -const OUTCOME_TEXT: Record = { - 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(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 (
{stage === 'input' && ( -
-
+
)} @@ -216,72 +244,51 @@ export function Step2PlaceSearch() {

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 && ( - - {search.sourceLabel} - - )} + {search.items.length > 0 + ? '아래 목록에서 사장님 가게를 골라 주세요.' + : '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}

- {search.candidates.length > 0 && - !search.candidates.some((candidate) => candidate.naver_place_url) && ( -

- - 상호 후보는 찾았지만 네이버 플레이스는 찾지 못했습니다. 아래 버튼으로 네이버 - 지도에서 가게를 검색한 뒤 플레이스 URL을 붙여넣어 주세요. -

- )} + {search.pickError && ( +

+ + {search.pickError} +

+ )}
    - {search.candidates.map((candidate, index) => ( -
  • + {search.items.map((item, index) => ( +
  • @@ -289,25 +296,26 @@ export function Step2PlaceSearch() { ))}
- {!search.candidates.some((candidate) => candidate.naver_place_url) ? ( - void pickByUrl()} - isBusy={search.isConfirming} - searchQuery={[storeName, location].filter(Boolean).join(' ')} - /> - ) : ( - void pickByUrl()} - isBusy={search.isConfirming} - compact - searchQuery={[storeName, location].filter(Boolean).join(' ')} - /> - )} + {/* 목록에서 고르면 업종은 그 후보가 정한다 — 이 줄은 아래 '주소로 확정' 경로용이다. */} + + void pickByUrl()} + isBusy={search.isConfirming} + compact={search.items.length > 0 && !search.pickError} + searchQuery={searchQuery} + /> + +
)} @@ -317,12 +325,19 @@ export function Step2PlaceSearch() { {search.unavailableReason}

+ + + void pickByUrl()} isBusy={search.isConfirming} - searchQuery={[storeName, location].filter(Boolean).join(' ')} + searchQuery={searchQuery} />
+ + + + ); +} diff --git a/solution/frontend/src/features/onboarding/Step3DataReview.tsx b/solution/frontend/src/features/onboarding/Step3DataReview.tsx index 0a896c7..cd2c699 100644 --- a/solution/frontend/src/features/onboarding/Step3DataReview.tsx +++ b/solution/frontend/src/features/onboarding/Step3DataReview.tsx @@ -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 = { 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 (
- )} -
@@ -302,7 +303,7 @@ export function Step3DataReview() { )} - @@ -354,8 +355,8 @@ export function Step3DataReview() { '수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.' ) } - onPrev={() => goToStep(2)} - onNext={() => goToStep(4)} + onPrev={() => goToStep('search')} + onNext={() => goToStep('template')} nextLabel="다음: 템플릿 선택" /** * ★ 수집 전에는 잠근다. diff --git a/solution/frontend/src/features/onboarding/Step4Template.tsx b/solution/frontend/src/features/onboarding/Step4Template.tsx index 089e3c6..fa7dfa9 100644 --- a/solution/frontend/src/features/onboarding/Step4Template.tsx +++ b/solution/frontend/src/features/onboarding/Step4Template.tsx @@ -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() {
- +

@@ -166,12 +167,13 @@ export function Step4Template() {

goToStep(3)} + onPrev={() => goToStep('collect')} onNext={() => { // ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 — // 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다. queueSiteTemplateSave(placeId, templateId); startGenerating(); + goToStep('generating'); }} nextLabel="이 템플릿으로 사이트 생성하기" /> diff --git a/solution/frontend/src/features/onboarding/Step5Generating.tsx b/solution/frontend/src/features/onboarding/Step5Generating.tsx index be94552..930c7fa 100644 --- a/solution/frontend/src/features/onboarding/Step5Generating.tsx +++ b/solution/frontend/src/features/onboarding/Step5Generating.tsx @@ -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` 로 잡을 넣고 폴링한다. diff --git a/solution/frontend/src/features/onboarding/WizardSteps.tsx b/solution/frontend/src/features/onboarding/WizardSteps.tsx index a209a0f..6fe4b1c 100644 --- a/solution/frontend/src/features/onboarding/WizardSteps.tsx +++ b/solution/frontend/src/features/onboarding/WizardSteps.tsx @@ -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}) { diff --git a/solution/frontend/src/features/onboarding/index.ts b/solution/frontend/src/features/onboarding/index.ts index af239dd..c71fcd8 100644 --- a/solution/frontend/src/features/onboarding/index.ts +++ b/solution/frontend/src/features/onboarding/index.ts @@ -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'; diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts index 9716eab..805180b 100644 --- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts +++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts @@ -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 = { stay: PlaceCategoryEnum.LODGING, cafe: PlaceCategoryEnum.CAFE, @@ -23,21 +23,15 @@ const INDUSTRY_TO_CATEGORY: Record = { clinic: PlaceCategoryEnum.CLINIC, }; -/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */ -const SOURCE_LABEL: Record = { - [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(INITIAL); const [isConfirming, setIsConfirming] = useState(false); /** @@ -88,8 +88,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | * 되살아난 화면이 같은 가게를 한 번 더 만든다. */ const placeIdRef = useRef(existingPlaceId); + /** 그 사업장을 만들 때 쓴 업종. 업종이 바뀌면 재사용할 수 없다(아래 ensurePlace). */ + const placeCategoryRef = useRef(null); const inflight = useRef(null); - /** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */ + /** 마지막으로 확정 시도한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */ const nameRef = useRef(''); 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 => { - if (placeIdRef.current) { + async (name: string, category: PlaceCategory, signal: AbortSignal): Promise => { + 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 => { - 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 => { + async (url: string, industry: IndustryType): Promise => { 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 => { + 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}; } diff --git a/solution/frontend/src/features/onboarding/wizardUrl.ts b/solution/frontend/src/features/onboarding/wizardUrl.ts new file mode 100644 index 0000000..5190181 --- /dev/null +++ b/solution/frontend/src/features/onboarding/wizardUrl.ts @@ -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 = { + 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; +} + +/** + * 지금 단계와, 단계를 옮기는 함수. + * + * ★ 다른 쿼리(`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]; +} diff --git a/solution/frontend/src/hooks/usePlaceSync.ts b/solution/frontend/src/hooks/usePlaceSync.ts index 78abb68..5f0be43 100644 --- a/solution/frontend/src/hooks/usePlaceSync.ts +++ b/solution/frontend/src/hooks/usePlaceSync.ts @@ -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]); /** * 서버에 저장된 디자인을 얹는다. diff --git a/solution/frontend/src/lib/errorMessages.ts b/solution/frontend/src/lib/errorMessages.ts index 701c703..b5661be 100644 --- a/solution/frontend/src/lib/errorMessages.ts +++ b/solution/frontend/src/lib/errorMessages.ts @@ -26,6 +26,10 @@ export const ERROR_MESSAGE: Record = { 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 diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 621b04b..bedb166 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -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=`. + * ★ 이 화면이 무엇을 그릴지는 **전부 주소창이 정한다.** * - * ★ 라우트(`/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 goToStep(4)} />; + return goToStep('template')} />; } if (step === EDITOR_STEP) { @@ -225,7 +244,9 @@ export function BuilderPage() { return (
- Web4Ai + + Web4Ai + {/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */} {isSignedIn ? (
- {step === 1 && } - {step === 2 && } - {step === 3 && } - {step === 4 && } - {step === 5 && } + {step === 'search' && } + {step === 'industry' && } + {step === 'collect' && } + {step === 'template' && } + {step === 'generating' && }
); diff --git a/solution/frontend/src/stores/builder.ts b/solution/frontend/src/stores/builder.ts index 6d34f1a..5af0ab7 100644 --- a/solution/frontend/src/stores/builder.ts +++ b/solution/frontend/src/stores/builder.ts @@ -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((set, get) => ({ - step: 1, ...seedFor(FALLBACK_INDUSTRY), placeId: null, confirmedIdentity: null, + pendingPick: null, savingFieldIds: [], isGathering: false, @@ -306,8 +309,6 @@ export const useBuilderStore = create((set, get) => ({ isPublishModalOpen: false, publishedUrl: null, - goToStep: (step) => set({step}), - // 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면 // 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다. selectIndustry: (industry) => { @@ -327,7 +328,16 @@ export const useBuilderStore = create((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((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((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((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((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((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((set, get) => ({ factSaver.clear(); clearThemeSaves(); set({ - step: 1, ...seedFor(get().industry), placeId: null, // [처음부터]는 "이 가게가 맞다"까지 물린다 — 상호부터 다시 확인받는다. confirmedIdentity: null, + pendingPick: null, isGathering: false, gatherStage: 1, gatherCompleted: false, diff --git a/solution/frontend/src/stores/builderTypes.ts b/solution/frontend/src/stores/builderTypes.ts index c3dfbb2..225b0d8 100644 --- a/solution/frontend/src/stores/builderTypes.ts +++ b/solution/frontend/src/stores/builderTypes.ts @@ -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; }