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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { motion } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Plus, LogIn, Utensils, Loader2 } from "lucide-react";
|
import { Plus, LogIn, Utensils, Loader2, MapPin, Navigation, X } from "lucide-react";
|
||||||
import { getUserId } from "@/lib/userId";
|
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 };
|
const SHANGHAI_COORDS = { lat: 31.2222, lng: 121.4764 };
|
||||||
|
|
||||||
function getLocation(): Promise<{ lat: number; lng: number }> {
|
function getLocation(): Promise<{ lat: number; lng: number }> {
|
||||||
@@ -35,6 +44,64 @@ export default function LandingPage() {
|
|||||||
const [loadingText, setLoadingText] = useState("");
|
const [loadingText, setLoadingText] = useState("");
|
||||||
const [error, setError] = 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 joinRoom = async (roomId: string) => {
|
||||||
const userId = getUserId();
|
const userId = getUserId();
|
||||||
const res = await fetch(`/api/room/${roomId}/join`, {
|
const res = await fetch(`/api/room/${roomId}/join`, {
|
||||||
@@ -49,12 +116,18 @@ export default function LandingPage() {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
setLoadingText("正在获取位置...");
|
|
||||||
|
|
||||||
try {
|
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", {
|
const res = await fetch("/api/room/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -127,6 +200,75 @@ export default function LandingPage() {
|
|||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.5, delay: 0.15 }}
|
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
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
Reference in New Issue
Block a user