360 lines
13 KiB
TypeScript
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;
|