o2o-site-AEO/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx
Mina Choi 238d4c25c4 [feat] solution: 네이버 플레이스를 검색 단계에서 찾는다 — 로그인은 수집 직전 한 번
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).

- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
  한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
  실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
  첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
  가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
  없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
  일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
  ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
  빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
  주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
  펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
  없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
  운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
  굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다

검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
2026-09-03 16:37:12 +09:00

163 lines
7.7 KiB
TypeScript

import {useState} from 'react';
import {ExternalLink, Link2, Loader2, MapPin, Search} from 'lucide-react';
import type {LinkChannel as LinkChannelCode} from '@/api';
import {LinkChannel} from '@o2o/shared';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
/**
* 채널 URL 직접 등록 — **수집의 1순위 경로다.** 폴백이 아니다.
*
* ★ 왜 자동 발견보다 이게 먼저인가 (실측 2026-08-27)
* · Perplexity 는 네이버 플레이스를 **한 건도** 못 찾았다(도플로·버터브루·힐튼 가든 인
* 서울 강남 전부 야놀자만 물어옴). 도메인 필터를 넓혔더니 이번엔 네이버 도움말 페이지
* (pages.map.naver.com/useful-tips)를 이 가게 채널로 등록했다. 그래서 기본 경로에서 뺐다.
* · 상호로 place id 를 자동 해석하는 경로도 4곳 중 3곳만 성공한다('롯데호텔 서울' 실패).
* · 반면 **네이버 플레이스 URL 만 있으면 100% 된다** — 어댑터가 fact 5~7건 + 사진 10장을
* 안정적으로 가져온다(힐튼·스테이머뭄·롯데월드에서 검증).
* 그리고 사장님은 자기 가게 주소를 이미 알고 있다. 추측하게 두는 것보다 물어보는 것이
* 빠르고 정확하고 공짜다.
*
* 그래서 이 컴포넌트는 "네이버 지도 주소"를 전제로 말한다 — 다른 채널도 받지만,
* 사장님이 따라 할 동작 하나는 네이버 지도에서 주소를 복사하는 것이다.
*
* 붙여넣은 URL 은 그 자체로 사장님이 확인한 값이므로 등록과 동시에 확정한다.
*/
/** 호스트로 채널을 판정한다. 서버도 같은 규칙을 쓰지만, 화면이 먼저 라벨을 보여줘야 한다. */
function guessChannel(url: string): {channel: LinkChannelCode; label: string} | null {
const u = url.toLowerCase();
if (u.includes('naver.com') || u.includes('naver.me')) {
// 블로그·카페는 채널이 아니라 후기다 — 크롤링 대상이 아니므로 받지 않는다.
if (u.includes('blog.naver') || u.includes('cafe.naver')) return null;
return {channel: LinkChannel.NAVER_PLACE, label: '네이버 플레이스'};
}
if (u.includes('yanolja.com')) return {channel: LinkChannel.YANOLJA, label: '야놀자'};
if (u.includes('goodchoice.kr') || u.includes('yeogi.com')) {
return {channel: LinkChannel.GOODCHOICE, label: '여기어때'};
}
if (u.includes('instagram.com')) return {channel: LinkChannel.INSTAGRAM, label: '인스타그램'};
return {channel: LinkChannel.OFFICIAL_SITE, label: '공식 홈페이지'};
}
/** 주소를 어디서 복사해 오는지 — 사장님이 그대로 따라 할 수 있게 동작 단위로 쪼갠다. */
const HOW_TO = [
'네이버 지도(또는 네이버 검색)에서 내 가게를 찾습니다',
'가게 이름 옆 [공유] 를 누릅니다',
'[URL 복사] 를 눌러 주소를 복사합니다',
'아래 칸에 붙여넣고 [이 주소로 가져오기] 를 누릅니다',
];
export function ChannelUrlInput({
onAdd,
isBusy,
searchQuery,
}: {
onAdd: (url: string, channel: LinkChannelCode, title: string) => Promise<void>;
isBusy?: boolean;
/** 이 상호로 네이버 지도를 바로 연다 — 사장님이 새 탭에서 직접 검색어를 치지 않게. */
searchQuery?: string;
}) {
const [url, setUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const guess = url.trim() ? guessChannel(url.trim()) : null;
const isNaver = guess?.channel === LinkChannel.NAVER_PLACE;
const submit = async () => {
const value = url.trim();
if (!value) return;
if (!/^https?:\/\//i.test(value)) {
setError('https:// 로 시작하는 주소를 붙여넣어 주세요.');
return;
}
if (!guess) {
setError('블로그·카페 글은 채널이 아니라 후기라 수집 대상이 아닙니다.');
return;
}
setError(null);
await onAdd(value, guess.channel, guess.label);
setUrl('');
};
return (
<div className="rounded-2xl border border-primary/40 bg-primary/5 p-5">
<p className="flex items-center gap-1.5 text-sm font-bold">
<MapPin className="size-4 text-primary" />
네이버 지도에서 내 가게 주소를 복사해 붙여넣어 주세요
</p>
<p className="mt-1.5 text-[11px] leading-relaxed text-muted-foreground">
가장 확실한 방법입니다. 주소 하나만 있으면 <strong>영업시간·전화·편의시설과 사진</strong>
까지 그대로 가져옵니다.
</p>
{/* ★ 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다.
"찾아 보세요" 라고만 하고 갈 곳을 안 주면 그 자리에서 멈춘다. */}
{searchQuery?.trim() && (
<a
href={`https://map.naver.com/p/search/${encodeURIComponent(searchQuery.trim())}`}
target="_blank"
rel="noopener noreferrer"
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/40 bg-background px-3 py-2.5 text-xs font-semibold transition-colors hover:bg-accent"
>
<ExternalLink className="size-3.5" />
<span>네이버 지도에서 '{searchQuery.trim()}' 열기</span>
</a>
)}
<ol className="mt-3 space-y-1.5">
{HOW_TO.map((step, index) => (
<li key={step} className="flex items-start gap-2 text-[11px] leading-relaxed">
<span className="mt-px flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
{index + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
<div className="mt-3 flex items-center gap-1.5">
<Input
value={url}
onChange={(e) => {
setUrl(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') void submit();
}}
placeholder="https://map.naver.com/p/entry/place/1273971279"
disabled={isBusy}
/>
{/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.**
'추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */}
<Button size="sm" variant="primary" onClick={() => void submit()} disabled={isBusy || !url.trim()}>
{isBusy ? <Loader2 className="animate-spin" /> : <Search />}
<span>{isBusy ? '가져오는 중' : '이 주소로 가져오기'}</span>
</Button>
</div>
<p
className={`mt-1.5 text-[11px] leading-relaxed ${
error ? 'text-destructive' : isNaver ? 'text-primary' : 'text-muted-foreground'
}`}
>
{error ??
(isNaver
? '네이버 플레이스 주소입니다 — 이 경로가 가장 많이, 가장 정확하게 가져옵니다.'
: guess
? `${guess.label} 주소로 등록됩니다. 네이버 지도 주소가 있으면 그쪽이 더 많이 가져옵니다.`
: 'naver.me 단축 주소도 됩니다. 야놀자·여기어때·공식 홈페이지 주소도 받습니다.')}
</p>
<p className="mt-2 flex items-start gap-1 border-t border-primary/20 pt-2 text-[10px] leading-relaxed text-muted-foreground">
<Link2 className="mt-px size-3 shrink-0" />
<span>
붙여넣은 주소는 사장님이 확인한 것으로 보고 <strong>바로 수집 대상</strong>이 됩니다.
수집된 값은 그래도 전부 확인 대기로 들어갑니다.
</span>
</p>
</div>
);
}