feat: 实现 NoWhatever 别说随便餐厅决策 Web App

- Framer Motion 卡片滑动 UI,带物理阻尼动画
- 多人房间系统,4位房间号 + SWR 实时轮询
- 高德地图 POI v5 API 搜索附近餐厅
- Web Share API 一键邀请,剪贴板降级方案
- SQLite/Prisma 持久化存储
- 移动端优先响应式设计 (Tailwind CSS)
This commit is contained in:
2026-02-24 16:49:43 +08:00
parent f5d921d585
commit d87d30ccc0
37 changed files with 8680 additions and 84 deletions
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { Plus, LogIn, Utensils, Loader2 } from "lucide-react";
import { getUserId } from "@/lib/userId";
const SHANGHAI_COORDS = { lat: 31.2222, lng: 121.4764 };
function getLocation(): Promise<{ lat: number; lng: number }> {
if (process.env.NODE_ENV === "development") {
return Promise.resolve(SHANGHAI_COORDS);
}
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve(SHANGHAI_COORDS);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) =>
resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => resolve(SHANGHAI_COORDS),
{ timeout: 5000, enableHighAccuracy: false },
);
});
}
export default function LandingPage() {
const router = useRouter();
const [roomCode, setRoomCode] = useState("");
const [loading, setLoading] = useState(false);
const [loadingText, setLoadingText] = useState("");
const [error, setError] = useState("");
const joinRoom = async (roomId: string) => {
const userId = getUserId();
const res = await fetch(`/api/room/${roomId}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) throw new Error("房间不存在");
return roomId;
};
const handleCreate = async () => {
setLoading(true);
setError("");
setLoadingText("正在获取位置...");
try {
const coords = await getLocation();
setLoadingText("正在搜索周边美食...");
const res = await fetch("/api/room/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(coords),
});
const { roomId } = await res.json();
setLoadingText("正在进入房间...");
await joinRoom(roomId);
router.push(`/room/${roomId}`);
} catch {
setError("创建失败,请重试");
setLoading(false);
setLoadingText("");
}
};
const handleJoin = async (e: React.FormEvent) => {
e.preventDefault();
if (roomCode.length !== 4) {
setError("请输入 4 位房间号");
return;
}
setLoading(true);
setError("");
try {
await joinRoom(roomCode);
router.push(`/room/${roomCode}`);
} catch {
setError("房间不存在,请检查房间号");
setLoading(false);
}
};
return (
<div className="flex h-dvh flex-col items-center justify-center bg-background px-6">
<motion.div
className="flex flex-col items-center"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-200">
<Utensils size={28} className="text-white" />
</div>
<h1 className="mt-5 text-3xl font-black tracking-tight text-zinc-900">
NoWhatever
</h1>
<p className="mt-0.5 text-sm font-medium tracking-widest text-zinc-400">
便
</p>
<p className="mt-3 max-w-xs text-center text-sm leading-relaxed text-zinc-500">
</p>
</motion.div>
<motion.div
className="mt-10 flex w-full max-w-xs flex-col gap-3"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<button
onClick={handleCreate}
disabled={loading}
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
>
{loading && loadingText ? (
<>
<Loader2 size={18} className="animate-spin" />
{loadingText}
</>
) : (
<>
<Plus size={18} strokeWidth={3} />
</>
)}
</button>
<div className="flex items-center gap-3 py-2">
<div className="h-px flex-1 bg-zinc-200" />
<span className="text-xs text-zinc-400"></span>
<div className="h-px flex-1 bg-zinc-200" />
</div>
<form onSubmit={handleJoin} className="flex gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="输入 4 位房间号"
value={roomCode}
onChange={(e) => {
setRoomCode(e.target.value.replace(/\D/g, "").slice(0, 4));
setError("");
}}
disabled={loading}
className="h-12 flex-1 rounded-xl border border-zinc-200 bg-white px-4 text-center text-lg font-semibold tracking-[0.3em] text-zinc-900 outline-none transition-colors placeholder:text-sm placeholder:tracking-normal placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
/>
<button
type="submit"
disabled={loading || roomCode.length !== 4}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-900 text-white transition-colors hover:bg-zinc-700 disabled:opacity-30"
>
<LogIn size={18} />
</button>
</form>
{error && (
<motion.p
className="text-center text-xs font-medium text-rose-500"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
</motion.div>
</div>
);
}