`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로 베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
144 lines
6.4 KiB
TypeScript
144 lines
6.4 KiB
TypeScript
import {useState} from 'react';
|
|
import {Link2, Loader2, MapPin, Plus} 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,
|
|
}: {
|
|
onAdd: (url: string, channel: LinkChannelCode, title: string) => Promise<void>;
|
|
isBusy?: boolean;
|
|
}) {
|
|
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>
|
|
|
|
<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" /> : <Plus />}
|
|
<span>추가</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>
|
|
);
|
|
}
|