- 콘서트 정보 섹션: 공연명, 포스터, 참여 멤버 선택 - 공연 일정 섹션: 다회차 지원 (날짜, 시간, 장소) - VenueSearchDialog 컴포넌트 추가 (국내/해외 장소 검색) - 회차 추가/삭제 애니메이션 적용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
/**
|
|
* 장소 검색 다이얼로그 컴포넌트
|
|
* - 국내: 카카오맵 API
|
|
* - 해외: 구글맵 API
|
|
*/
|
|
import { useState } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { X, Search, MapPin, Globe } from "lucide-react";
|
|
|
|
/**
|
|
* @param {Object} props
|
|
* @param {boolean} props.isOpen - 다이얼로그 열림 여부
|
|
* @param {Function} props.onClose - 닫기 핸들러
|
|
* @param {Function} props.onSelect - 장소 선택 핸들러 ({ name, address, country, lat, lng })
|
|
*/
|
|
function VenueSearchDialog({ isOpen, onClose, onSelect }) {
|
|
const [region, setRegion] = useState("domestic"); // domestic | overseas
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [results, setResults] = useState([]);
|
|
const [searching, setSearching] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
|
|
// 다이얼로그 닫기 시 상태 초기화
|
|
const handleClose = () => {
|
|
setSearchQuery("");
|
|
setResults([]);
|
|
setError(null);
|
|
onClose();
|
|
};
|
|
|
|
// 지역 변경 시 결과 초기화
|
|
const handleRegionChange = (newRegion) => {
|
|
setRegion(newRegion);
|
|
setResults([]);
|
|
setError(null);
|
|
};
|
|
|
|
// 검색 실행
|
|
const handleSearch = async () => {
|
|
if (!searchQuery.trim()) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
|
|
setSearching(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const token = localStorage.getItem("adminToken");
|
|
|
|
if (region === "domestic") {
|
|
// 카카오맵 API
|
|
const response = await fetch(
|
|
`/api/admin/kakao/places?query=${encodeURIComponent(searchQuery)}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}
|
|
);
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const places = (data.documents || []).map((place) => ({
|
|
id: place.id,
|
|
name: place.place_name,
|
|
address: place.road_address_name || place.address_name,
|
|
country: "한국",
|
|
lat: parseFloat(place.y),
|
|
lng: parseFloat(place.x),
|
|
category: place.category_name,
|
|
}));
|
|
setResults(places);
|
|
} else {
|
|
setError("검색 중 오류가 발생했습니다.");
|
|
}
|
|
} else {
|
|
// 구글맵 API
|
|
const response = await fetch(
|
|
`/api/admin/google/places?query=${encodeURIComponent(searchQuery)}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}
|
|
);
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const places = (data.results || []).map((place) => ({
|
|
id: place.place_id,
|
|
name: place.name,
|
|
address: place.formatted_address,
|
|
country: extractCountry(place.formatted_address),
|
|
lat: place.geometry?.location?.lat,
|
|
lng: place.geometry?.location?.lng,
|
|
category: place.types?.[0]?.replace(/_/g, " "),
|
|
}));
|
|
setResults(places);
|
|
} else {
|
|
setError("검색 중 오류가 발생했습니다.");
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("장소 검색 오류:", err);
|
|
setError("검색 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
};
|
|
|
|
// 주소에서 국가 추출 (구글맵용)
|
|
const extractCountry = (address) => {
|
|
if (!address) return "";
|
|
const parts = address.split(", ");
|
|
return parts[parts.length - 1] || "";
|
|
};
|
|
|
|
// 장소 선택
|
|
const handleSelectPlace = (place) => {
|
|
onSelect({
|
|
name: place.name,
|
|
address: place.address,
|
|
country: place.country,
|
|
lat: place.lat,
|
|
lng: place.lng,
|
|
});
|
|
handleClose();
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
onClick={handleClose}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.9, opacity: 0 }}
|
|
className="bg-white rounded-2xl p-6 max-w-lg w-full mx-4 shadow-xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-bold text-gray-900">장소 검색</h3>
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 지역 선택 탭 */}
|
|
<div className="flex gap-2 mb-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRegionChange("domestic")}
|
|
className={`flex-1 py-2.5 rounded-lg font-medium text-sm transition-colors flex items-center justify-center gap-2 ${
|
|
region === "domestic"
|
|
? "bg-primary text-white"
|
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
<MapPin size={16} />
|
|
국내
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRegionChange("overseas")}
|
|
className={`flex-1 py-2.5 rounded-lg font-medium text-sm transition-colors flex items-center justify-center gap-2 ${
|
|
region === "overseas"
|
|
? "bg-primary text-white"
|
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
<Globe size={16} />
|
|
해외
|
|
</button>
|
|
</div>
|
|
|
|
{/* 검색 입력 */}
|
|
<div className="flex gap-2 mb-4">
|
|
<div className="flex-1 relative">
|
|
<Search
|
|
size={18}
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleSearch();
|
|
}
|
|
}}
|
|
placeholder={
|
|
region === "domestic"
|
|
? "장소명을 입력하세요 (예: 올림픽홀)"
|
|
: "장소명을 입력하세요 (예: Tokyo Dome)"
|
|
}
|
|
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleSearch}
|
|
disabled={searching}
|
|
className="px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors disabled:opacity-50"
|
|
>
|
|
{searching ? (
|
|
<motion.div
|
|
animate={{ rotate: 360 }}
|
|
transition={{
|
|
duration: 1,
|
|
repeat: Infinity,
|
|
ease: "linear",
|
|
}}
|
|
>
|
|
<Search size={18} />
|
|
</motion.div>
|
|
) : (
|
|
"검색"
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* 에러 메시지 */}
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-50 text-red-600 text-sm rounded-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* 검색 결과 */}
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{results.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{results.map((place) => (
|
|
<button
|
|
key={place.id}
|
|
type="button"
|
|
onClick={() => handleSelectPlace(place)}
|
|
className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100 transition-colors"
|
|
>
|
|
<MapPin
|
|
size={18}
|
|
className="text-gray-400 mt-0.5 flex-shrink-0"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-gray-900">{place.name}</p>
|
|
<p className="text-sm text-gray-500 truncate">
|
|
{place.address}
|
|
</p>
|
|
{place.category && (
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{place.category}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{region === "overseas" && place.country && (
|
|
<span className="text-xs text-gray-400 flex-shrink-0">
|
|
{place.country}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 text-gray-500">
|
|
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
|
|
<p>장소명을 입력하고 검색해주세요</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
export default VenueSearchDialog;
|