feat(ssulbox): 업장 검색을 ADO2 자동완성 방식으로 전환
This commit is contained in:
parent
bda4c267dd
commit
3701490728
@ -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,
|
||||||
|
|||||||
@ -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 ? (
|
||||||
// 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라
|
// 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라
|
||||||
// 분 단위를 못 박지 않는다("수 분 내외").
|
// 분 단위를 못 박지 않는다("수 분 내외").
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user