263 lines
9.9 KiB
TypeScript
263 lines
9.9 KiB
TypeScript
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<SsulCreateFormProps> = ({ onSubmitted, onNeedCredit }) => {
|
|
const { t } = useTranslation();
|
|
|
|
const [scenario, setScenario] = useState<Scen | null>(null);
|
|
const [input, setInput] = useState('');
|
|
const [results, setResults] = useState<AccommodationSearchItem[]>([]);
|
|
const [showResults, setShowResults] = useState(false);
|
|
const [selected, setSelected] = useState<AccommodationSearchItem | null>(null);
|
|
const [searching, setSearching] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
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 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 는 제목에 <b> 하이라이트 태그를 섞어 준다 — 그대로 쓰면
|
|
// 업장명·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 (
|
|
<div className="ssul-scope ssul-create">
|
|
{/* ── 1단계: 시나리오 ─────────────────────────────── */}
|
|
<span className="ssul-create__step">{t('ssulbox.create.step1')}</span>
|
|
|
|
<div className="ssul-create__scenarios">
|
|
{SCEN_KEYS.map((key) => {
|
|
const pal = SCEN[key];
|
|
const active = scenario === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
aria-pressed={active}
|
|
className={`ssul-scn${active ? ' active' : ''}`}
|
|
style={
|
|
{
|
|
// 표지 그라디언트 2단 + 대표색. CSS 가 옅게 깔아 쓴다
|
|
'--scn': pal.hex,
|
|
'--scn-a': pal.g[0],
|
|
'--scn-b': pal.g[1],
|
|
} as React.CSSProperties
|
|
}
|
|
onClick={() => setScenario(key)}
|
|
>
|
|
<span className="ssul-scn__emoji" aria-hidden="true">
|
|
{pal.emoji}
|
|
</span>
|
|
<span className="ssul-scn__name">{t(scenName(key))}</span>
|
|
<span className="ssul-scn__desc">{t(scenDesc(key))}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* ── 2단계: 업장 ─────────────────────────────────── */}
|
|
{/* 2단계는 약하게 — 시선이 1단계(아직 안 고른 경우)로 먼저 가야 한다 */}
|
|
<span className="ssul-create__step muted">{t('ssulbox.create.step2')}</span>
|
|
|
|
{/* 자동완성 드롭다운을 붙이려면 상대 위치 컨테이너가 필요하다 */}
|
|
<div className="ssul-create__search" ref={boxRef}>
|
|
<input
|
|
className="ssul-create__input"
|
|
placeholder={t('ssulbox.create.searchPlaceholder')}
|
|
value={input}
|
|
onChange={(e) => handleInputChange(e.target.value)}
|
|
onFocus={() => results.length > 0 && setShowResults(true)}
|
|
autoComplete="off"
|
|
/>
|
|
|
|
{showResults && (
|
|
<ul className="ssul-places">
|
|
{results.map((place, i) => (
|
|
<li key={`${place.title}-${i}`}>
|
|
<button type="button" onClick={() => handleSelect(place)}>
|
|
<b>{cleanSearchTitle(place.title)}</b>
|
|
<span>{place.roadAddress || place.address}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* 상태 안내 — 한 번에 하나만 보인다 */}
|
|
{selected ? (
|
|
<div className="ssul-picked">
|
|
<div className="ssul-picked__body">
|
|
<b>{selected.title}</b>
|
|
<span>{selected.roadAddress || selected.address}</span>
|
|
</div>
|
|
</div>
|
|
) : isUrl ? (
|
|
<div className="ssul-hint ok">{t('ssulbox.create.hintUrlOk')}</div>
|
|
) : searching ? (
|
|
<div className="ssul-hint">{t('ssulbox.create.placeLoading')}</div>
|
|
) : scenario ? (
|
|
// 고른 시나리오를 되짚어 준다. 소요 시간은 기본 옵션 실측 전이라
|
|
// 분 단위를 못 박지 않는다("수 분 내외").
|
|
<div className="ssul-hint dot">
|
|
{t('ssulbox.create.hintScenario', { name: t(scenName(scenario)) })}
|
|
</div>
|
|
) : (
|
|
<div className="ssul-hint">{t('ssulbox.create.hintDefault')}</div>
|
|
)}
|
|
|
|
{error && <div className="ssul-hint warn">{error}</div>}
|
|
|
|
{/* ── 비용 고지 + 제출 ────────────────────────────── */}
|
|
{/* 요청 시점에 즉시 차감되므로 누르기 전에 반드시 알린다 */}
|
|
<div className="ssul-create__cost">
|
|
<span>{t('ssulbox.create.cost')}</span>
|
|
<span className="ssul-create__cost-badge">
|
|
<span className="ssul-create__coin" aria-hidden="true">
|
|
🪙
|
|
</span>
|
|
{t('ssulbox.create.costValue')}
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="ssul-create__submit"
|
|
disabled={!canSubmit}
|
|
onClick={handleSubmit}
|
|
>
|
|
{submitting ? t('ssulbox.create.submitting') : t('ssulbox.create.submit')}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SsulCreateForm;
|