o2o-castad-frontend/src/pages/Ssulbox/SsulCreateForm.tsx

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;