Files
no-whatever/src/app/page.tsx
T
kurihada cc7f6d55a7 feat: 添加品牌 Logo、favicon、Apple Touch Icon 和 OG 分享图
- 新增 icon.png (96x96) 和 apple-icon.png (180x180) 作为浏览器/iOS 图标
- 新增 opengraph-image.png 用于微信等社交媒体分享预览
- 新增 BrandLogo SVG 组件,首页标题上方展示品牌图标
2026-02-25 11:58:05 +08:00

625 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, User } from "lucide-react";
import { getUserId, getCachedProfile, getCachedPreferences } from "@/lib/userId";
import { getAvatarBg } from "@/lib/avatars";
import AuthModal from "@/components/AuthModal";
import BrandLogo from "@/components/BrandLogo";
import { SCENES, getSceneConfig } from "@/lib/sceneConfig";
import type { UserProfile, SceneType } from "@/types";
interface LocationSuggestion {
id: string;
name: string;
district: string;
address: string;
lat: number;
lng: number;
}
type GpsStatus = "idle" | "locating" | "success" | "failed" | "denied";
const DISTANCE_OPTIONS = [
{ label: "1km", value: 1000 },
{ label: "3km", value: 3000 },
{ label: "5km", value: 5000 },
] as const;
type GpsResult =
| { ok: true; lat: number; lng: number }
| { ok: false; reason: "unsupported" | "denied" | "timeout" | "unknown" };
function requestGps(): Promise<GpsResult> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve({ ok: false, reason: "unsupported" });
return;
}
navigator.geolocation.getCurrentPosition(
(pos) =>
resolve({ ok: true, lat: pos.coords.latitude, lng: pos.coords.longitude }),
(err) => {
const reason =
err.code === err.PERMISSION_DENIED
? "denied"
: err.code === err.TIMEOUT
? "timeout"
: "unknown";
resolve({ ok: false, reason });
},
{ timeout: 8000, enableHighAccuracy: false },
);
});
}
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
try {
const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
const data = await res.json();
return data.name || data.formatted || null;
} catch {
return null;
}
}
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 [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 [radius, setRadius] = useState(3000);
const [priceRange, setPriceRange] = useState("any");
const [cuisine, setCuisine] = useState("");
const suggestRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const [gpsStatus, setGpsStatus] = useState<GpsStatus>("idle");
const [gpsCoords, setGpsCoords] = useState<{ lat: number; lng: number } | null>(null);
const [gpsLocationName, setGpsLocationName] = useState<string | null>(null);
const [scene, setScene] = useState<SceneType>("eat");
const sceneConfig = getSceneConfig(scene);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [authModalOpen, setAuthModalOpen] = useState(false);
useEffect(() => {
const cached = getCachedProfile();
if (cached) setProfile(cached);
const prefs = getCachedPreferences();
if (prefs.cuisine) setCuisine(prefs.cuisine);
if (prefs.priceRange) setPriceRange(prefs.priceRange);
if (prefs.radius) setRadius(prefs.radius);
}, []);
const handleSceneChange = useCallback((s: SceneType) => {
setScene(s);
setCuisine("");
setPriceRange("any");
}, []);
const doGpsLocate = useCallback(async () => {
setGpsStatus("locating");
const result = await requestGps();
if (result.ok) {
setGpsCoords({ lat: result.lat, lng: result.lng });
setGpsStatus("success");
const name = await reverseGeocode(result.lat, result.lng);
if (name) setGpsLocationName(name);
} else {
setGpsCoords(null);
setGpsLocationName(null);
setGpsStatus(result.reason === "denied" ? "denied" : "failed");
}
}, []);
useEffect(() => {
doGpsLocate();
}, [doGpsLocate]);
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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) throw new Error("房间不存在");
return roomId;
};
const handleCreate = async () => {
setError("");
let coords: { lat: number; lng: number };
if (selectedLocation) {
coords = { lat: selectedLocation.lat, lng: selectedLocation.lng };
} else if (gpsCoords) {
coords = gpsCoords;
} else if (gpsStatus === "locating") {
setError("正在定位中,请稍候...");
return;
} else {
setError("无法获取位置,请在上方搜索并选择一个地点");
return;
}
setLoading(true);
try {
setLoadingText(sceneConfig.loadingText);
const res = await fetch("/api/room/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...coords, radius, priceRange, cuisine, userId: getUserId(), scene }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "创建房间失败");
}
if (!data.roomId) {
throw new Error("创建房间失败");
}
setLoadingText("正在进入房间...");
await joinRoom(data.roomId);
router.push(`/room/${data.roomId}`);
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败,请重试");
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="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-12">
{/* Profile / Auth button */}
<div className="absolute right-4 top-4">
{profile ? (
<button
onClick={() => router.push("/profile")}
className={`flex h-9 items-center gap-1.5 rounded-full px-3 text-sm font-medium transition-colors active:opacity-80 ${getAvatarBg(profile.avatar)}`}
>
<span className="text-base leading-none">{profile.avatar}</span>
<span className="max-w-[5rem] truncate text-xs font-semibold text-zinc-700">{profile.username}</span>
</button>
) : (
<button
onClick={() => setAuthModalOpen(true)}
className="flex h-9 items-center gap-1.5 rounded-full bg-zinc-100 px-3 text-xs font-medium text-zinc-500 transition-colors active:bg-zinc-200"
>
<User size={14} />
</button>
)}
</div>
<motion.div
className="flex flex-col items-center"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<BrandLogo size={56} />
<h1 className="mt-2.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">
{sceneConfig.subtitle}
</p>
</motion.div>
<motion.div
className="mt-8 flex items-start justify-center gap-3"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<div className="flex flex-col items-center gap-1.5 w-20">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-50">
<Users size={18} className="text-emerald-500" />
</div>
<span className="text-xs font-semibold text-zinc-700"></span>
<span className="text-[10px] leading-tight text-zinc-400 text-center"></span>
</div>
<ChevronRight size={14} className="mt-3 shrink-0 text-zinc-300" />
<div className="flex flex-col items-center gap-1.5 w-20">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-50">
<Heart size={18} className="text-amber-500" />
</div>
<span className="text-xs font-semibold text-zinc-700"></span>
<span className="text-[10px] leading-tight text-zinc-400 text-center"></span>
</div>
<ChevronRight size={14} className="mt-3 shrink-0 text-zinc-300" />
<div className="flex flex-col items-center gap-1.5 w-20">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-50">
<Sparkles size={18} className="text-rose-500" />
</div>
<span className="text-xs font-semibold text-zinc-700"></span>
<span className="text-[10px] leading-tight text-zinc-400 text-center"></span>
</div>
</motion.div>
<motion.div
className="mt-8 flex items-center justify-center gap-2"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.12 }}
>
{SCENES.map((s) => {
const cfg = getSceneConfig(s);
const active = scene === s;
return (
<button
key={s}
onClick={() => handleSceneChange(s)}
disabled={loading}
className={`flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-semibold transition-all disabled:opacity-50 ${
active
? "bg-emerald-500 text-white shadow-md shadow-emerald-200"
: "bg-zinc-100 text-zinc-500 hover:bg-zinc-200"
}`}
>
<span className="text-base leading-none">{cfg.emoji}</span>
{cfg.label}
</button>
);
})}
</motion.div>
<motion.div
className="mt-5 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 }}
>
<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 && gpsStatus === "locating" && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Loader2 size={12} className="shrink-0 animate-spin text-emerald-400" />
<span className="text-xs text-zinc-400">...</span>
</div>
)}
{!selectedLocation && !locationQuery && gpsStatus === "success" && (
<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">
{gpsLocationName || "已定位"}
</span>
</div>
)}
{!selectedLocation && !locationQuery && (gpsStatus === "failed" || gpsStatus === "denied") && (
<div className="mt-1.5 flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
<MapPin size={12} className="shrink-0 text-amber-500" />
<span className="text-xs text-amber-600">
{gpsStatus === "denied" ? "定位权限被拒绝" : "定位失败"}
</span>
</div>
<button
onClick={doGpsLocate}
className="shrink-0 text-xs font-medium text-emerald-500 active:text-emerald-700"
>
</button>
</div>
)}
{!selectedLocation && !locationQuery && gpsStatus === "idle" && (
<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>
<div className="flex flex-col gap-2 rounded-xl border border-zinc-100 bg-zinc-50/50 px-3 py-2.5">
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400">{sceneConfig.tagLabel}</span>
<div className="relative flex flex-1 items-center">
<input
type="text"
placeholder={sceneConfig.tagPlaceholder}
value={cuisine}
onChange={(e) => setCuisine(e.target.value)}
disabled={loading}
className="h-7 w-full rounded-full border-none bg-white pl-3 pr-7 text-xs text-zinc-700 outline-none ring-1 ring-zinc-200 transition-colors placeholder:text-zinc-300 focus:ring-2 focus:ring-emerald-300 disabled:opacity-50"
/>
{cuisine && !loading && (
<button
onClick={() => setCuisine("")}
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
>
<X size={12} />
</button>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400"></span>
<div className="flex flex-wrap items-center gap-1.5">
<Flame size={11} className="shrink-0 text-orange-400" />
{sceneConfig.hotTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setCuisine(tag)}
disabled={loading}
className={`h-6 rounded-full px-2.5 text-xs font-medium transition-colors disabled:opacity-50 ${
cuisine === tag
? "bg-emerald-500 text-white shadow-sm"
: "bg-white text-zinc-500 hover:bg-zinc-200"
}`}
>
{tag}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400"></span>
<div className="flex gap-1.5">
{DISTANCE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setRadius(opt.value)}
disabled={loading}
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
radius === opt.value
? "bg-emerald-500 text-white shadow-sm"
: "bg-white text-zinc-500 hover:bg-zinc-200"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
<span className="w-8 shrink-0 text-xs font-medium text-zinc-400"></span>
<div className="flex flex-wrap gap-1.5">
{sceneConfig.priceOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setPriceRange(opt.value)}
disabled={loading}
className={`h-7 rounded-full px-3 text-xs font-medium transition-colors disabled:opacity-50 ${
priceRange === opt.value
? "bg-emerald-500 text-white shadow-sm"
: "bg-white text-zinc-500 hover:bg-zinc-200"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<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>
<AuthModal
open={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onAuth={(p) => setProfile(p)}
/>
</div>
);
}