feat: 支持创建房间时指定位置搜索餐厅
新增位置搜索框,通过高德输入提示 API 提供地点联想, 用户可选择指定位置或使用默认当前定位来查询周边餐厅。
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const keywords = searchParams.get("keywords")?.trim();
|
||||
|
||||
if (!keywords) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const apiKey = process.env.AMAP_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "AMAP_API_KEY not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("keywords", keywords);
|
||||
url.searchParams.set("datatype", "poi");
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status !== "1" || !data.tips) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const suggestions = data.tips
|
||||
.filter((t: { location?: string }) => t.location && t.location !== "")
|
||||
.slice(0, 8)
|
||||
.map((t: { id: string; name: string; district?: string; address?: string; location: string }) => {
|
||||
const [lng, lat] = t.location.split(",").map(Number);
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
district: t.district || "",
|
||||
address: t.address || "",
|
||||
lat,
|
||||
lng,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(suggestions);
|
||||
} catch (e) {
|
||||
console.error("Location suggest error:", e);
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
}
|
||||
+148
-6
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, LogIn, Utensils, Loader2 } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plus, LogIn, Utensils, Loader2, MapPin, Navigation, X } from "lucide-react";
|
||||
import { getUserId } from "@/lib/userId";
|
||||
|
||||
interface LocationSuggestion {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
const SHANGHAI_COORDS = { lat: 31.2222, lng: 121.4764 };
|
||||
|
||||
function getLocation(): Promise<{ lat: number; lng: number }> {
|
||||
@@ -35,6 +44,64 @@ export default function LandingPage() {
|
||||
const [loadingText, setLoadingText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [locationQuery, setLocationQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<LocationSuggestion[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedLocation, setSelectedLocation] = useState<LocationSuggestion | null>(null);
|
||||
const [fetchingSuggestions, setFetchingSuggestions] = useState(false);
|
||||
const suggestRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const fetchSuggestions = useCallback(async (query: string) => {
|
||||
if (query.length < 1) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
setFetchingSuggestions(true);
|
||||
try {
|
||||
const res = await fetch(`/api/location/suggest?keywords=${encodeURIComponent(query)}`);
|
||||
const data: LocationSuggestion[] = await res.json();
|
||||
setSuggestions(data);
|
||||
setShowSuggestions(data.length > 0);
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setFetchingSuggestions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocationInput = (val: string) => {
|
||||
setLocationQuery(val);
|
||||
setSelectedLocation(null);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => fetchSuggestions(val), 300);
|
||||
};
|
||||
|
||||
const handleSelectLocation = (loc: LocationSuggestion) => {
|
||||
setSelectedLocation(loc);
|
||||
setLocationQuery(loc.name);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const clearLocation = () => {
|
||||
setSelectedLocation(null);
|
||||
setLocationQuery("");
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (suggestRef.current && !suggestRef.current.contains(e.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const joinRoom = async (roomId: string) => {
|
||||
const userId = getUserId();
|
||||
const res = await fetch(`/api/room/${roomId}/join`, {
|
||||
@@ -49,12 +116,18 @@ export default function LandingPage() {
|
||||
const handleCreate = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setLoadingText("正在获取位置...");
|
||||
|
||||
try {
|
||||
const coords = await getLocation();
|
||||
let coords: { lat: number; lng: number };
|
||||
|
||||
setLoadingText("正在搜索周边美食...");
|
||||
if (selectedLocation) {
|
||||
coords = { lat: selectedLocation.lat, lng: selectedLocation.lng };
|
||||
setLoadingText("正在搜索周边美食...");
|
||||
} else {
|
||||
setLoadingText("正在获取位置...");
|
||||
coords = await getLocation();
|
||||
setLoadingText("正在搜索周边美食...");
|
||||
}
|
||||
|
||||
const res = await fetch("/api/room/create", {
|
||||
method: "POST",
|
||||
@@ -127,6 +200,75 @@ export default function LandingPage() {
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.15 }}
|
||||
>
|
||||
<div ref={suggestRef} className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<MapPin size={16} className="absolute left-3 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索位置(默认当前位置)"
|
||||
value={locationQuery}
|
||||
onChange={(e) => handleLocationInput(e.target.value)}
|
||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||
disabled={loading}
|
||||
className="h-10 w-full rounded-xl border border-zinc-200 bg-white pl-9 pr-9 text-sm text-zinc-700 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
|
||||
/>
|
||||
{(selectedLocation || locationQuery) && !loading && (
|
||||
<button
|
||||
onClick={clearLocation}
|
||||
className="absolute right-2.5 flex h-5 w-5 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
{fetchingSuggestions && (
|
||||
<Loader2 size={14} className="absolute right-3 animate-spin text-zinc-300" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedLocation && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
||||
<Navigation size={12} className="shrink-0 text-emerald-500" />
|
||||
<span className="truncate text-xs text-emerald-600">
|
||||
{selectedLocation.district} {selectedLocation.address || selectedLocation.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedLocation && !locationQuery && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5 px-1">
|
||||
<Navigation size={12} className="shrink-0 text-zinc-400" />
|
||||
<span className="text-xs text-zinc-400">将使用当前定位</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showSuggestions && (
|
||||
<motion.ul
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-xl border border-zinc-100 bg-white py-1 shadow-lg"
|
||||
>
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
onClick={() => handleSelectLocation(s)}
|
||||
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-emerald-50"
|
||||
>
|
||||
<MapPin size={14} className="mt-0.5 shrink-0 text-zinc-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-zinc-800">{s.name}</p>
|
||||
<p className="truncate text-xs text-zinc-400">{s.district} {s.address}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={loading}
|
||||
|
||||
Reference in New Issue
Block a user