diff --git a/src/pages/Dashboard/UrlInputContent.tsx b/src/pages/Dashboard/UrlInputContent.tsx index 6ee921e..587351c 100644 --- a/src/pages/Dashboard/UrlInputContent.tsx +++ b/src/pages/Dashboard/UrlInputContent.tsx @@ -6,7 +6,7 @@ import SsulCreateForm from '../Ssulbox/SsulCreateForm'; import { Scen } from '../Ssulbox/ssulData'; interface UrlInputContentProps { - /** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋·랜딩 프리셋 때문) */ + /** 현재 선택된 파이프라인. 소유자는 GenerationFlow (분기·리셋 때문) */ pipeline: Pipeline; onPipelineChange: (pipeline: Pipeline) => void; onAnalyze: (value: string, type?: SearchType) => void; @@ -23,7 +23,6 @@ interface UrlInputContentProps { * 대시보드 진입 화면. * * 탭바는 로고와 하위 폼 **사이에 형제로** 얹고 하위 폼만 교체한다. - * SearchInputForm 은 랜딩 HeroSection 과 공유되므로 어떤 경우에도 수정하지 않는다. */ const UrlInputContent: React.FC = ({ pipeline, diff --git a/src/pages/Ssulbox/SsulCreateForm.tsx b/src/pages/Ssulbox/SsulCreateForm.tsx index 10443a2..e522eff 100644 --- a/src/pages/Ssulbox/SsulCreateForm.tsx +++ b/src/pages/Ssulbox/SsulCreateForm.tsx @@ -1,10 +1,11 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { + AccommodationSearchItem, InsufficientCreditError, - SsulPlaceItem, + cleanSearchTitle, createSsulJob, - searchSsulPlace, + searchAccommodation, } from '../../utils/api'; import { SCEN, SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData'; @@ -19,50 +20,105 @@ interface SsulCreateFormProps { const isNaverUrl = (s: string): boolean => /naver\.me|map\.naver|place\.naver|^https?:\/\//i.test(s.trim()); +/** 자동완성 디바운스 — ADO2 SearchInputForm 과 동일 */ +const AUTOCOMPLETE_DEBOUNCE = 300; + /** * 썰박스 생성 폼. 진입 화면의 썰박스 탭에 들어간다. * * 원본 썰박스의 CreateSheet(바텀시트) 를 시트 크롬 없이 인라인으로 재배치했다. * 시나리오 선택과 업장 검색을 한 화면에 둔다(원본은 2단계로 나뉘어 있었으나, * 여기서는 위저드 스텝퍼가 이미 단계를 보여주므로 중복이다). + * + * **업장 검색은 ADO2 와 같은 방식**이다(2026-07-31 전환): + * 타이핑 중에는 네이버 **검색 API**(`/search/accommodation`, ~0.2초)로 자동완성을 + * 띄우고, place URL 해석은 제출 후 서버가 한다(ADO2 `_autocomplete_logic` 과 동일). + * 이전에는 썰박스 전용 지도 크롤링(`/ssul/search/place`)으로 후보를 받았는데 + * **17초**가 걸려 UX 가 크게 나빴다. */ const SsulCreateForm: React.FC = ({ onSubmitted, onNeedCredit }) => { const { t } = useTranslation(); const [scenario, setScenario] = useState(null); const [input, setInput] = useState(''); - const [results, setResults] = useState([]); - const [selected, setSelected] = useState(null); + const [results, setResults] = useState([]); + const [showResults, setShowResults] = useState(false); + const [selected, setSelected] = useState(null); const [searching, setSearching] = useState(false); - const [searched, setSearched] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const debounceRef = useRef | null>(null); + const boxRef = useRef(null); + const isUrl = isNaverUrl(input); - // place_url 이 확정됐거나(검색 결과 선택) 링크를 직접 붙여넣었을 때만 생성 가능 + // 업장을 골랐거나 링크를 직접 붙여넣었을 때만 생성 가능 const canSubmit = !!scenario && (!!selected || isUrl) && !submitting; - const runSearch = async () => { - const query = input.trim(); - if (query.length < 2 || isUrl) return; + // 바깥 클릭 시 자동완성 닫기 + useEffect(() => { + 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); - setSearched(false); - setResults([]); - setSelected(null); 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 { 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 는 제목에 하이라이트 태그를 섞어 준다 — 그대로 쓰면 + // 업장명·SNS 제목에 태그가 노출된다. + const title = cleanSearchTitle(item.title); + setInput(title); + setSelected({ ...item, title }); + setShowResults(false); + setResults([]); + }; + const handleSubmit = async () => { if (!scenario || submitting) return; - // 검색으로 고른 가게가 있으면 그 place_url 로 정확히 크롤링한다 - const payload = selected ? selected.place_url : input.trim(); + // 고른 가게가 있으면 업장명을 보내고, 서버가 place URL 을 해석한다 + // (ADO2 와 동일한 경로 — NvMapPwScraper.get_place_id_url). + // 링크를 직접 붙여넣었으면 그대로 넘긴다. + const payload = selected ? selected.title : input.trim(); setSubmitting(true); setError(null); try { @@ -130,41 +186,37 @@ const SsulCreateForm: React.FC = ({ onSubmitted, onNeedCred {/* 2단계는 약하게 — 시선이 1단계(아직 안 고른 경우)로 먼저 가야 한다 */} {t('ssulbox.create.step2')} -
+ {/* 자동완성 드롭다운을 붙이려면 상대 위치 컨테이너가 필요하다 */} +
{ - setInput(e.target.value); - if (selected) setSelected(null); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - runSearch(); - } - }} + onChange={(e) => handleInputChange(e.target.value)} + onFocus={() => results.length > 0 && setShowResults(true)} + autoComplete="off" /> - + + {showResults && ( +
    + {results.map((place, i) => ( +
  • + +
  • + ))} +
+ )}
- {/* 검색 상태에 따른 안내 — 한 번에 하나만 보인다 */} + {/* 상태 안내 — 한 번에 하나만 보인다 */} {selected ? (
{selected.title} - - {selected.category ? `${selected.category} · ` : ''} - {selected.address || selected.roadAddress} - + {selected.roadAddress || selected.address}
- - ))} - - ) : searched ? ( -
{t('ssulbox.create.noResult')}
) : scenario ? ( // 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라 // 분 단위를 못 박지 않는다("수 분 내외").