주소창의 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 커밋에서 별도로 한다.
544 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|