o2o-castad-frontend/src/components/SearchInputForm.tsx

360 lines
13 KiB
TypeScript

import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
import { useSearchHistory } from './SearchHistory/useSearchHistory';
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from './BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual';
export type { SearchType };
const isValidUrl = (string: string): boolean => {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
interface SearchInputFormProps {
onAnalyze?: (value: string, type: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
onManualButtonClick?: () => void;
onShortform?: () => void;
error?: string | null;
}
const SearchInputForm: React.FC<SearchInputFormProps> = ({
onAnalyze,
onAutocomplete,
onManualInput,
onManualButtonClick,
onShortform,
error: externalError,
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
{ value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
];
const getPlaceholder = () =>
searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName');
const getGuideText = () =>
searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName');
const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim() || searchType !== 'name') {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
}
setIsAutocompleteLoading(true);
try {
const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0);
} catch {
setAutocompleteResults([]);
setShowAutocomplete(false);
} finally {
setIsAutocompleteLoading(false);
}
}, [searchType]);
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, ''));
setSelectedItem(item);
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (showAutocomplete && autocompleteResults.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev => prev < autocompleteResults.length - 1 ? prev + 1 : 0);
return;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev => prev > 0 ? prev - 1 : autocompleteResults.length - 1);
return;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
}
return;
case 'Escape':
e.preventDefault();
setShowAutocomplete(false);
setHighlightedIndex(-1);
return;
}
}
if (e.key === 'Enter') {
e.preventDefault();
}
};
const handleStart = () => {
if (!inputValue.trim()) {
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
return;
}
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
setLocalError(t('landing.hero.errorInvalidUrl'));
return;
}
setLocalError('');
if (searchType === 'name' && selectedItem && onAutocomplete) {
onAutocomplete({
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
});
} else if (onAnalyze) {
onAnalyze(inputValue, searchType);
}
};
const handleManualButtonClick = () => {
if (onManualButtonClick) {
onManualButtonClick();
} else {
setIsManualModalOpen(true);
}
};
const error = externalError || localError;
return (
<div className="hero-form">
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
{/* 드롭다운 */}
<div className="hero-dropdown-container" ref={dropdownRef}>
<button
type="button"
className="hero-dropdown-trigger"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="hero-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
setIsDropdownOpen(false);
if (option.value === 'manual') {
handleManualButtonClick();
} else {
setSearchType(option.value);
}
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
{/* 입력 필드 */}
<div className="hero-input-container" ref={autocompleteRef}>
<input
type="text"
value={inputValue}
onChange={(e) => {
let value = e.target.value;
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
}
setInputValue(value);
setSelectedItem(null);
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
if (searchType === 'name') {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
}
}}
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
if (localError) setLocalError('');
}
}}
onFocus={() => {
setIsFocused(true);
if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true);
openHistory(inputValue);
}}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onKeyDown={handleKeyDown}
placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={(item) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
}
}}
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
className="hero-autocomplete-dropdown"
itemClassName="hero-autocomplete-item"
titleClassName="hero-autocomplete-title"
addressClassName="hero-autocomplete-address"
/>
)}
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
) : (
autocompleteResults.map((item, index) => (
<button
key={index}
type="button"
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
onMouseDown={(e) => { e.preventDefault(); handleSelectAutocomplete(item); }}
onMouseEnter={() => setHighlightedIndex(index)}
>
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
{/* 입력 clear 버튼 */}
{inputValue && (
<button
type="button"
className="hero-input-clear"
onClick={() => {
setInputValue('');
setSelectedItem(null);
setLocalError('');
setAutocompleteResults([]);
setShowAutocomplete(false);
}}
>
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
</button>
)}
</div>
<span className="hero-input-hint">{getGuideText()}</span>
{error && <p className="hero-error">{error}</p>}
<button onClick={handleStart} className="hero-button">
{t('landing.hero.analyzeButton')}
</button>
{onShortform && (
<button
type="button"
className="url-input-shortform-btn"
onClick={onShortform}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
</button>
)}
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
{!onManualButtonClick && isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => {
setIsManualModalOpen(false);
onManualInput?.(businessName, address);
}}
/>
)}
</div>
);
};
export default SearchInputForm;