o2o-site-AEO/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
Mina Choi 362c76d6c9 [fix] solution/frontend: 뒤로가기 후 재검색이 옛 사업장을 되살림 — placeId URL 우선순위 정정
주소창의 placeId 가 store 보다 우선이라(BuilderPage.tsx), 뒤로가기로 검색 단계에
돌아와도 그 placeId 가 그대로 남아 있으면 usePlaceSync 가 옛 사업장을 다시 확정
정보로 채워 넣었다 — 다른 상호로 재검색해도 이전 확정 화면이 그대로 떴다.

- BuilderPage.tsx: step === 'search' 일 땐 주소창 placeId 를 안 쓴다
- Step2PlaceSearch.tsx: 뒤로가기 가드가 store 뿐 아니라 주소창 placeId·flow 도 지운다

[feat] solution/frontend: "고택" 템플릿 — 숙박·요식업·카페 공용, stay2 목업 색·서체 이식

industryData.ts 에 LOOK.paper(크림 종이·가는 명조·그림자 없음, stay2 실물에서 추출)와
paperTemplate() 을 추가해 stay·restaurant·cafe 세 업종의 네 번째 템플릿으로 등록.
DOM 구조 이식은 solution/site 커밋에서 별도로 한다.
2026-09-23 13:17:39 +09:00

544 lines
23 KiB
TypeScript

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: '가게 이름을 알려주세요',
searching: '내 가게를 찾는 중입니다',
picking: '이 중에 사장님 가게가 있나요?',
unavailable: '지도 검색을 사용할 수 없습니다',
confirmed: '이 가게로 확인되었습니다',
};
const STAGE_DESCRIPTION: Record<string, string> = {
input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.',
searching: '',
picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.',
unavailable: '',
confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.',
};
/**
* 1단계 — 내 가게 확인. **위저드의 시작점이다.**
*
* ★ 예전에는 업종 선택이 앞에 있었다. 그런데 사장님이 100% 아는 건 자기 가게 **이름**이고,
* 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그래서 순서를 뒤집었다 —
* 상호명을 먼저 받고, 업종은 검색 결과의 분류가 정한다(services/place_category.py).
* 못 정했을 때만 업종 화면(`?step=industry`)으로 넘긴다.
*
* 한 화면에 한 가지만 묻는다: 상호를 묻거나 · 기다리거나 · 후보를 고르게 하거나 · 확정을 보여준다.
*/
export function Step2PlaceSearch() {
const industry = useBuilderStore((s) => s.industry);
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 search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [, goToStep] = useWizardStep();
const [placeUrl, setPlaceUrl] = useState('');
/** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 —
* 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가
* 없을 때만 여는 탈출구다. */
const [urlBoxOpen, setUrlBoxOpen] = useState(false);
/** 고른 후보가 들고 온 네이버 플레이스 주소(검색 단계에서 서버가 찾아 준 값). */
const naverUrlRef = useRef<string | null>(null);
/** 확정 직후 2단계로 넘어가는 한 번은 초기화를 건너뛴다. */
const justConfirmed = useRef(false);
const foundCount = search.items.filter((i) => i.naver_place_url).length;
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
/**
* 확정된 사업장을 주소창에 남기며 다음 단계로.
*
* ★ `?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 가 화면에 남긴다
justConfirmed.current = true;
confirmIdentity(
naverUrlRef.current ? {...identity, naverPlaceUrl: naverUrlRef.current} : 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 ?? '',
};
// ★ 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다.
// 이게 있으면 수집 직전에 다시 찾을 필요가 없고, 사장님에게 지도 주소를 묻지도 않는다.
naverUrlRef.current = item.naver_place_url ?? null;
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
if (!guessed) {
setPendingPick(pick);
goToStep('industry');
return;
}
selectIndustry(guessed);
void finishPick(pick, guessed);
};
const runSearch = () => {
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
void search.searchPublic(storeName, location);
};
const backToInput = () => {
clearIdentity();
// 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다.
goToStep('search', {params: {placeId: null, flow: null}, replace: true});
search.reset();
};
/** 네이버 플레이스 URL 로 확정 — 가장 확실한 경로다(usePlaceSearch.confirmByUrl 주석 참고). */
const pickByUrl = async () => {
const url = placeUrl.trim();
if (!url) return;
const identity = await search.confirmByUrl(url);
if (!identity) return;
justConfirmed.current = true;
confirmIdentity(identity);
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';
/**
* 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.**
*
* ★ 왜 확정 화면을 안 띄우나: 뒤로가기는 "다시 고르겠다" 는 뜻인데, 확정 화면만 뜨면
* 다른 가게로 바꾸러 온 사장님이 목록을 못 찾는다. 후보 목록을 되살리는 방법도 있지만
* 공개 검색이 유료 외부 API 라 뒤로 올 때마다 다시 부르게 된다.
* ★ 이 화면을 **정상적으로 지나간** 경우(확정 직후 2단계로 넘어감)에는 돌지 않는다 —
* `justConfirmed` 가 그 한 번을 막는다. 아니면 확정하자마자 스스로 지워 버린다.
*/
useEffect(() => {
if (!confirmedIdentity) return;
if (justConfirmed.current) {
justConfirmed.current = false;
return;
}
clearIdentity();
search.reset();
goToStep('search', {params: {placeId: null, flow: null}, replace: true});
// eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다
}, []);
const searchQuery = [storeName, location].filter(Boolean).join(' ');
/** 뒤로 와서 목록을 다시 볼 때, 이미 고른 후보를 알아보게 한다. */
const isPicked = (item: PlaceSearchItem) =>
Boolean(confirmedIdentity) &&
item.name === confirmedIdentity?.name &&
(item.road_address ?? '') === (confirmedIdentity?.address ?? '');
return (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage
current={1}
wide={stage === 'picking'}
title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]}
>
{stage === 'input' && (
<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>
</label>
<Input
id="store-name"
value={storeName}
onChange={(e) => setStoreName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSearch) runSearch();
}}
placeholder="가게 이름을 입력하세요"
autoFocus
/>
</div>
<div>
<label htmlFor="store-location" className="mb-1.5 block text-xs font-semibold">
(///)
</label>
<Input
id="store-location"
value={location}
onChange={(e) => setLocation(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSearch) runSearch();
}}
placeholder="예: 제주시 애월읍"
/>
<p className="mt-1.5 text-[11px] text-muted-foreground">
. .
</p>
</div>
<Button
variant="outline"
size="lg"
className="w-full"
onClick={runSearch}
disabled={!canSearch}
>
<Search />
<span> </span>
</Button>
</div>
)}
{stage === 'searching' && (
<WizardWaiting
title="등록된 가게를 찾는 중입니다"
description={`'${storeName}' 와(과) 같은 이름의 가게를 지도에서 확인하고 있어요`}
/>
)}
{stage === 'picking' && (
<div className="space-y-3">
<p
className={cn(
'rounded-xl p-3 text-xs leading-relaxed',
search.items.length > 0
? 'border border-border bg-card text-muted-foreground'
: 'border border-warning/30 bg-warning/10 text-warning',
)}
>
{search.items.length === 0
? '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'
: foundCount > 0
? `${foundCount}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.`
: '아래 목록에서 사장님 가게를 골라 주세요.'}
</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.items.map((item, index) => (
<li key={`${item.name ?? ''}-${item.road_address ?? ''}-${index}`}>
<button
type="button"
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">{item.name}</span>
{item.category_name && (
<Badge variant="outline" className="text-[10px]">
{item.category_name}
</Badge>
)}
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
<IndustryBadge category={item.category} />
{/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면
사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */}
{item.naver_place_url && (
<Badge variant="success" className="gap-1 text-[10px]">
<Check className="size-3" />
</Badge>
)}
</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>{item.road_address ?? '주소 없음'}</span>
</p>
</div>
<span
className={cn(
'shrink-0 rounded-lg px-3 py-1.5 text-[11px] font-bold',
isPicked(item)
? 'bg-success text-success-foreground'
: 'bg-primary text-primary-foreground',
)}
>
{search.isConfirming
? '확정 중...'
: isPicked(item)
? '고른 가게'
: '이 가게예요'}
</span>
</div>
</button>
</li>
))}
</ul>
{/* 목록에서 고르면 업종은 그 후보가 정한다 — 이 줄은 아래 '주소로 확정' 경로용이다. */}
<IndustryLine
label="주소로 확정할 때 쓸 업종"
industry={industry}
onChange={changeIndustry}
/>
{search.items.length > 0 && !urlBoxOpen && !search.pickError ? (
<button
type="button"
onClick={() => setUrlBoxOpen(true)}
className="w-full cursor-pointer rounded-xl border border-dashed border-border px-3 py-2.5 text-[11px] text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
>
?
</button>
) : (
<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>
)}
{stage === 'unavailable' && (
<div className="space-y-3">
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-xs leading-relaxed text-warning">
<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={searchQuery}
/>
<Button variant="outline" className="w-full" onClick={runSearch}>
<Search />
<span> </span>
</Button>
</div>
)}
{stage === 'confirmed' && confirmedIdentity && (
<div className="space-y-3">
<div className="rounded-2xl border border-success/40 bg-success/5 p-6 text-center">
<span className="mx-auto mb-3 flex size-12 items-center justify-center rounded-full bg-success text-white">
<Check className="size-6" strokeWidth={3} />
</span>
<p className="text-base font-bold">{confirmedIdentity.name}</p>
{confirmedIdentity.address && (
<p className="mt-1 text-xs text-muted-foreground">{confirmedIdentity.address}</p>
)}
{confirmedIdentity.phone && (
<p className="mt-0.5 text-xs text-muted-foreground">{confirmedIdentity.phone}</p>
)}
<div className="mt-3 flex items-center justify-center gap-1.5">
{confirmedIdentity.origin === 'external' ? (
<>
<Badge variant="outline" className="text-[10px]">
{confirmedIdentity.sourceLabel}
</Badge>
<Badge variant="success" className="text-[10px]">
</Badge>
</>
) : (
<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={() => advance(confirmedIdentity.placeId)}
>
<span>다음: </span>
<ArrowRight />
</Button>
<Button variant="ghost" size="sm" className="w-full" onClick={backToInput}>
<Search />
<span> </span>
</Button>
</div>
)}
</WizardStage>
<WizardFooter
hint={
confirmedIdentity
? `${confirmedIdentity.name} 기준으로 진행합니다.`
: '내 가게를 확인해야 다음 단계로 갈 수 있습니다.'
}
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>
);
}