feat(ssulbox): 업장 검색을 ADO2 자동완성 방식으로 전환

This commit is contained in:
김성경 2026-08-11 14:57:34 +09:00
parent bda4c267dd
commit 3701490728
2 changed files with 94 additions and 59 deletions

View File

@ -6,7 +6,7 @@ import SsulCreateForm from '../Ssulbox/SsulCreateForm';
import { Scen } from '../Ssulbox/ssulData'; import { Scen } from '../Ssulbox/ssulData';
interface UrlInputContentProps { interface UrlInputContentProps {
/** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋·랜딩 프리셋 때문) */ /** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */
pipeline: Pipeline; pipeline: Pipeline;
onPipelineChange: (pipeline: Pipeline) => void; onPipelineChange: (pipeline: Pipeline) => void;
onAnalyze: (value: string, type?: SearchType) => void; onAnalyze: (value: string, type?: SearchType) => void;
@ -23,7 +23,6 @@ interface UrlInputContentProps {
* . * .
* *
* ** ** . * ** ** .
* SearchInputForm HeroSection .
*/ */
const UrlInputContent: React.FC<UrlInputContentProps> = ({ const UrlInputContent: React.FC<UrlInputContentProps> = ({
pipeline, pipeline,

View File

@ -1,10 +1,11 @@
import React, { useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AccommodationSearchItem,
InsufficientCreditError, InsufficientCreditError,
SsulPlaceItem, cleanSearchTitle,
createSsulJob, createSsulJob,
searchSsulPlace, searchAccommodation,
} from '../../utils/api'; } from '../../utils/api';
import { SCEN, SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData'; import { SCEN, SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData';
@ -19,50 +20,105 @@ interface SsulCreateFormProps {
const isNaverUrl = (s: string): boolean => const isNaverUrl = (s: string): boolean =>
/naver\.me|map\.naver|place\.naver|^https?:\/\//i.test(s.trim()); /naver\.me|map\.naver|place\.naver|^https?:\/\//i.test(s.trim());
/** 자동완성 디바운스 — ADO2 SearchInputForm 과 동일 */
const AUTOCOMPLETE_DEBOUNCE = 300;
/** /**
* . . * . .
* *
* CreateSheet() . * CreateSheet() .
* ( 2 , * ( 2 ,
* ). * ).
*
* ** ADO2 **(2026-07-31 ):
* ** API**(`/search/accommodation`, ~0.2)
* , place URL (ADO2 `_autocomplete_logic` ).
* (`/ssul/search/place`)
* **17** UX .
*/ */
const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCredit }) => { const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCredit }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [scenario, setScenario] = useState<Scen | null>(null); const [scenario, setScenario] = useState<Scen | null>(null);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [results, setResults] = useState<SsulPlaceItem[]>([]); const [results, setResults] = useState<AccommodationSearchItem[]>([]);
const [selected, setSelected] = useState<SsulPlaceItem | null>(null); const [showResults, setShowResults] = useState(false);
const [selected, setSelected] = useState<AccommodationSearchItem | null>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
const isUrl = isNaverUrl(input); const isUrl = isNaverUrl(input);
// place_url 이 확정됐거나(검색 결과 선택) 링크를 직접 붙여넣었을 때만 생성 가능 // 업장을 골랐거나 링크를 직접 붙여넣었을 때만 생성 가능
const canSubmit = !!scenario && (!!selected || isUrl) && !submitting; const canSubmit = !!scenario && (!!selected || isUrl) && !submitting;
const runSearch = async () => { // 바깥 클릭 시 자동완성 닫기
const query = input.trim(); useEffect(() => {
if (query.length < 2 || isUrl) return; const onDown = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, []);
// 언마운트 시 대기 중인 디바운스 취소 (setState-after-unmount 방지)
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const runAutocomplete = async (query: string) => {
if (query.trim().length < 2 || isNaverUrl(query)) {
setResults([]);
setShowResults(false);
return;
}
setSearching(true); setSearching(true);
setSearched(false);
setResults([]);
setSelected(null);
try { try {
setResults(await searchSsulPlace(query)); const res = await searchAccommodation(query);
const items = res.items || [];
setResults(items);
setShowResults(items.length > 0);
} catch {
// 검색 실패는 조용히 넘긴다 — 링크 직접 입력 우회 경로가 있다
setResults([]);
setShowResults(false);
} finally { } finally {
setSearching(false); setSearching(false);
setSearched(true);
} }
}; };
const handleInputChange = (value: string) => {
setInput(value);
if (selected) setSelected(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runAutocomplete(value), AUTOCOMPLETE_DEBOUNCE);
};
const handleSelect = (item: AccommodationSearchItem) => {
// 검색 API 는 제목에 <b> 하이라이트 태그를 섞어 준다 — 그대로 쓰면
// 업장명·SNS 제목에 태그가 노출된다.
const title = cleanSearchTitle(item.title);
setInput(title);
setSelected({ ...item, title });
setShowResults(false);
setResults([]);
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!scenario || submitting) return; if (!scenario || submitting) return;
// 검색으로 고른 가게가 있으면 그 place_url 로 정확히 크롤링한다 // 고른 가게가 있으면 업장명을 보내고, 서버가 place URL 을 해석한다
const payload = selected ? selected.place_url : input.trim(); // (ADO2 와 동일한 경로 — NvMapPwScraper.get_place_id_url).
// 링크를 직접 붙여넣었으면 그대로 넘긴다.
const payload = selected ? selected.title : input.trim();
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
@ -130,41 +186,37 @@ const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCred
{/* 2단계는 약하게 — 시선이 1단계(아직 안 고른 경우)로 먼저 가야 한다 */} {/* 2단계는 약하게 — 시선이 1단계(아직 안 고른 경우)로 먼저 가야 한다 */}
<span className="ssul-create__step muted">{t('ssulbox.create.step2')}</span> <span className="ssul-create__step muted">{t('ssulbox.create.step2')}</span>
<div className="ssul-create__search"> {/* 자동완성 드롭다운을 붙이려면 상대 위치 컨테이너가 필요하다 */}
<div className="ssul-create__search" ref={boxRef}>
<input <input
className="ssul-create__input" className="ssul-create__input"
placeholder={t('ssulbox.create.searchPlaceholder')} placeholder={t('ssulbox.create.searchPlaceholder')}
value={input} value={input}
onChange={(e) => { onChange={(e) => handleInputChange(e.target.value)}
setInput(e.target.value); onFocus={() => results.length > 0 && setShowResults(true)}
if (selected) setSelected(null); autoComplete="off"
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
runSearch();
}
}}
/> />
<button
type="button" {showResults && (
className="ssul-create__search-btn" <ul className="ssul-places">
disabled={searching || input.trim().length < 2 || isUrl} {results.map((place, i) => (
onClick={runSearch} <li key={`${place.title}-${i}`}>
> <button type="button" onClick={() => handleSelect(place)}>
{searching ? t('ssulbox.create.searching') : t('ssulbox.create.searchButton')} <b>{cleanSearchTitle(place.title)}</b>
</button> <span>{place.roadAddress || place.address}</span>
</button>
</li>
))}
</ul>
)}
</div> </div>
{/* 검색 상태에 따른 안내 — 한 번에 하나만 보인다 */} {/* 상태 안내 — 한 번에 하나만 보인다 */}
{selected ? ( {selected ? (
<div className="ssul-picked"> <div className="ssul-picked">
<div className="ssul-picked__body"> <div className="ssul-picked__body">
<b>{selected.title}</b> <b>{selected.title}</b>
<span> <span>{selected.roadAddress || selected.address}</span>
{selected.category ? `${selected.category} · ` : ''}
{selected.address || selected.roadAddress}
</span>
</div> </div>
<button type="button" className="ssul-picked__change" onClick={() => setSelected(null)}> <button type="button" className="ssul-picked__change" onClick={() => setSelected(null)}>
{t('ssulbox.create.change')} {t('ssulbox.create.change')}
@ -174,22 +226,6 @@ const SsulCreateForm: React.FC<SsulCreateFormProps> = ({ onSubmitted, onNeedCred
<div className="ssul-hint ok">{t('ssulbox.create.hintUrlOk')}</div> <div className="ssul-hint ok">{t('ssulbox.create.hintUrlOk')}</div>
) : searching ? ( ) : searching ? (
<div className="ssul-hint">{t('ssulbox.create.placeLoading')}</div> <div className="ssul-hint">{t('ssulbox.create.placeLoading')}</div>
) : results.length > 0 ? (
<ul className="ssul-places">
{results.map((place, i) => (
<li key={`${place.place_url}-${i}`}>
<button type="button" onClick={() => setSelected(place)}>
<b>{place.title}</b>
<span>
{place.category ? `${place.category} · ` : ''}
{place.address || place.roadAddress}
</span>
</button>
</li>
))}
</ul>
) : searched ? (
<div className="ssul-hint warn">{t('ssulbox.create.noResult')}</div>
) : scenario ? ( ) : scenario ? (
// 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라 // 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라
// 분 단위를 못 박지 않는다("수 분 내외"). // 분 단위를 못 박지 않는다("수 분 내외").