import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AccommodationSearchItem, InsufficientCreditError, cleanSearchTitle, createSsulJob, searchAccommodation, } from '../../utils/api'; import { SCEN, SCEN_KEYS, Scen, scenDesc, scenName } from './ssulData'; interface SsulCreateFormProps { /** 생성 요청이 접수되면 호출. 폴링 소유는 GenerationFlow */ onSubmitted: (taskId: number, scenario: Scen) => void; /** 크레딧 부족(402) 시 충전 화면으로 유도 */ onNeedCredit: () => void; } /** 네이버 지도 링크로 보이는가 — 링크면 검색을 건너뛰고 그대로 크롤링한다 */ 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 [showResults, setShowResults] = useState(false); const [selected, setSelected] = useState(null); const [searching, setSearching] = 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); // 업장을 골랐거나 링크를 직접 붙여넣었을 때만 생성 가능 const canSubmit = !!scenario && (!!selected || isUrl) && !submitting; // 바깥 클릭 시 자동완성 닫기 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); try { const res = await searchAccommodation(query); const items = res.items || []; setResults(items); setShowResults(items.length > 0); } catch { // 검색 실패는 조용히 넘긴다 — 링크 직접 입력 우회 경로가 있다 setResults([]); setShowResults(false); } finally { setSearching(false); } }; 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 을 해석한다 // (ADO2 와 동일한 경로 — NvMapPwScraper.get_place_id_url). // 링크를 직접 붙여넣었으면 그대로 넘긴다. const payload = selected ? selected.title : input.trim(); setSubmitting(true); setError(null); try { const res = await createSsulJob({ scenario, input: payload, // 고른 가게가 있을 때만 함께 보낸다. 통합 목록의 업장명 표시와 // 업장명·지역 필터가 이 값에 걸린다(링크 직접 입력 시에는 없다). // 도로명·지번을 모두 보낸다. 백엔드가 도로명에서 시/군 추출에 실패하면 // 지번으로 재시도한다(한쪽만 보내면 지역이 비는 경우가 생긴다). ...(selected && { store_name: selected.title, road_address: selected.roadAddress, address: selected.address, }), }); onSubmitted(res.id, scenario); } catch (e) { if (e instanceof InsufficientCreditError) { onNeedCredit(); } else { setError(t('ssulbox.error.createFailed')); } } finally { setSubmitting(false); } }; return (
{/* ── 1단계: 시나리오 ─────────────────────────────── */} {t('ssulbox.create.step1')}
{SCEN_KEYS.map((key) => { const pal = SCEN[key]; const active = scenario === key; return ( ); })}
{/* ── 2단계: 업장 ─────────────────────────────────── */} {/* 2단계는 약하게 — 시선이 1단계(아직 안 고른 경우)로 먼저 가야 한다 */} {t('ssulbox.create.step2')} {/* 자동완성 드롭다운을 붙이려면 상대 위치 컨테이너가 필요하다 */}
handleInputChange(e.target.value)} onFocus={() => results.length > 0 && setShowResults(true)} autoComplete="off" /> {showResults && (
    {results.map((place, i) => (
  • ))}
)}
{/* 상태 안내 — 한 번에 하나만 보인다 */} {selected ? (
{selected.title} {selected.roadAddress || selected.address}
) : isUrl ? (
{t('ssulbox.create.hintUrlOk')}
) : searching ? (
{t('ssulbox.create.placeLoading')}
) : scenario ? ( // 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라 // 분 단위를 못 박지 않는다("수 분 내외").
{t('ssulbox.create.hintScenario', { name: t(scenName(scenario)) })}
) : (
{t('ssulbox.create.hintDefault')}
)} {error &&
{error}
} {/* ── 비용 고지 + 제출 ────────────────────────────── */} {/* 요청 시점에 즉시 차감되므로 누르기 전에 반드시 알린다 */}
{t('ssulbox.create.cost')} {t('ssulbox.create.costValue')}
); }; export default SsulCreateForm;