Files
juwan-frontend/app/(main)/search/page.tsx
T

577 lines
20 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 {
CheckCircle2,
Clock,
Filter,
Gamepad2,
Search,
SlidersHorizontal,
Star,
Store,
User,
XCircle,
} from "lucide-react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useMemo, useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Switch } from "@/components/ui/switch"
import { GameIcon } from "@/lib/game-icons"
import { mockGames, mockPlayers } from "@/lib/mock-data"
import type { Player } from "@/lib/types"
import { cn } from "@/lib/utils"
function StatusBadge({ status }: { status: Player["status"] }) {
switch (status) {
case "available":
return (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 gap-1">
<CheckCircle2 className="w-3 h-3" />
</Badge>
)
case "busy":
return (
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 gap-1">
<Clock className="w-3 h-3" />
</Badge>
)
case "offline":
return (
<Badge variant="outline" className="bg-gray-50 text-gray-500 border-gray-200 gap-1">
<XCircle className="w-3 h-3" />
线
</Badge>
)
default:
return null
}
}
function PlayerCard({ player }: { player: Player }) {
const minPrice = useMemo(() => {
if (!player.services || player.services.length === 0) return 0
return Math.min(...player.services.map((s) => s.price))
}, [player.services])
const unit = useMemo(() => {
if (!player.services || player.services.length === 0) return "局"
const cheapestService = player.services.reduce((prev, curr) =>
prev.price < curr.price ? prev : curr,
)
return cheapestService.unit
}, [player.services])
return (
<Link href={`/player/${player.id}`} className="block h-full">
<Card className="h-full hover:shadow-md transition-shadow duration-200 overflow-hidden flex flex-col">
<CardHeader className="p-4 pb-2 flex flex-row items-start justify-between space-y-0">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 border-2 border-white shadow-sm">
<AvatarImage src={player.user.avatar} alt={player.user.nickname} />
<AvatarFallback>{player.user.nickname.slice(0, 2)}</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-base leading-none mb-1">{player.user.nickname}</h3>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{player.shopName ? (
<span className="flex items-center gap-0.5 text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">
<Store className="w-3 h-3" />
{player.shopName}
</span>
) : (
<span className="flex items-center gap-0.5 text-gray-600 bg-gray-100 px-1.5 py-0.5 rounded">
<User className="w-3 h-3" />
</span>
)}
</div>
</div>
</div>
<StatusBadge status={player.status} />
</CardHeader>
<CardContent className="p-4 pt-2 flex-grow">
<div className="flex items-center gap-4 mb-3 text-sm">
<div className="flex items-center gap-1 text-yellow-500 font-medium">
<Star className="w-4 h-4 fill-current" />
{player.rating.toFixed(1)}
</div>
<div className="text-muted-foreground"> {player.totalOrders}</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{player.games.slice(0, 3).map((game) => (
<Badge key={game} variant="secondary" className="text-xs font-normal">
{game}
</Badge>
))}
{player.games.length > 3 && (
<Badge variant="secondary" className="text-xs font-normal">
+{player.games.length - 3}
</Badge>
)}
</div>
<div className="flex flex-wrap gap-1">
{player.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className="text-[10px] px-1.5 py-0.5 bg-muted rounded text-muted-foreground"
>
{tag}
</span>
))}
</div>
</CardContent>
<Separator />
<CardFooter className="p-3 bg-muted/20 flex items-center justify-between">
<div className="flex items-baseline gap-1">
<span className="text-xs text-muted-foreground"></span>
<span className="text-lg font-bold text-primary">¥{minPrice}</span>
<span className="text-xs text-muted-foreground">/{unit}</span>
</div>
<Button size="sm" variant="ghost" className="h-8 px-3 text-xs">
</Button>
</CardFooter>
</Card>
</Link>
)
}
interface FilterProps {
selectedGames: string[]
onGameChange: (game: string, checked: boolean) => void
priceRange: { min: string; max: string }
onPriceChange: (type: "min" | "max", value: string) => void
onlyOnline: boolean
onOnlineChange: (checked: boolean) => void
minRating: string
onRatingChange: (value: string) => void
className?: string
}
function FilterSection({
selectedGames,
onGameChange,
priceRange,
onPriceChange,
onlyOnline,
onOnlineChange,
minRating,
onRatingChange,
className,
}: FilterProps) {
return (
<div className={cn("space-y-6", className)}>
<div className="space-y-3">
<h3 className="font-medium text-sm flex items-center gap-2">
<Gamepad2 className="w-4 h-4" />
</h3>
<div className="space-y-2">
{mockGames.map((game) => (
<div key={game.id} className="flex items-center space-x-2">
<Checkbox
id={`game-${game.id}`}
checked={selectedGames.includes(game.name)}
onCheckedChange={(checked) => onGameChange(game.name, checked as boolean)}
/>
<Label
htmlFor={`game-${game.id}`}
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<GameIcon name={game.icon} className="h-4 w-4" />
{game.name}
</Label>
</div>
))}
</div>
</div>
<Separator />
<div className="space-y-3">
<h3 className="font-medium text-sm"> ()</h3>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder="最低"
className="h-8 text-sm"
value={priceRange.min}
onChange={(e) => onPriceChange("min", e.target.value)}
min={0}
/>
<span className="text-muted-foreground">-</span>
<Input
type="number"
placeholder="最高"
className="h-8 text-sm"
value={priceRange.max}
onChange={(e) => onPriceChange("max", e.target.value)}
min={0}
/>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<Label htmlFor="online-mode" className="font-medium text-sm cursor-pointer">
线
</Label>
<Switch id="online-mode" checked={onlyOnline} onCheckedChange={onOnlineChange} />
</div>
<Separator />
<div className="space-y-3">
<h3 className="font-medium text-sm flex items-center gap-2">
<Star className="w-4 h-4" />
</h3>
<Select value={minRating} onValueChange={onRatingChange}>
<SelectTrigger className="h-9">
<SelectValue placeholder="选择评分" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="4.0">4.0</SelectItem>
<SelectItem value="4.5">4.5</SelectItem>
<SelectItem value="4.8">4.8</SelectItem>
<SelectItem value="5.0">5.0</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}
function SearchPageContent() {
const searchParams = useSearchParams()
const router = useRouter()
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "")
const [selectedGames, setSelectedGames] = useState<string[]>([])
const [priceRange, setPriceRange] = useState<{ min: string; max: string }>({ min: "", max: "" })
const [onlyOnline, setOnlyOnline] = useState(false)
const [minRating, setMinRating] = useState("0")
const [sortBy, setSortBy] = useState("composite")
const [visibleCount, setVisibleCount] = useState(12)
useEffect(() => {
const q = searchParams.get("q")
if (q !== null && q !== searchQuery) {
setSearchQuery(q)
}
}, [searchParams, searchQuery])
useEffect(() => {
const timer = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString())
if (searchQuery) {
params.set("q", searchQuery)
} else {
params.delete("q")
}
router.replace(`/search?${params.toString()}`, { scroll: false })
}, 500)
return () => clearTimeout(timer)
}, [searchQuery, router, searchParams])
const handleGameChange = (game: string, checked: boolean) => {
setSelectedGames((prev) => (checked ? [...prev, game] : prev.filter((g) => g !== game)))
}
const handlePriceChange = (type: "min" | "max", value: string) => {
setPriceRange((prev) => ({ ...prev, [type]: value }))
}
const filteredPlayers = useMemo(() => {
return mockPlayers.filter((player) => {
if (searchQuery) {
const query = searchQuery.toLowerCase()
const matchName = player.user.nickname.toLowerCase().includes(query)
const matchTags = player.tags.some((tag) => tag.toLowerCase().includes(query))
const matchGames = player.games.some((game) => game.toLowerCase().includes(query))
if (!matchName && !matchTags && !matchGames) return false
}
if (selectedGames.length > 0) {
const hasGame = player.games.some((game) => selectedGames.includes(game))
if (!hasGame) return false
}
const minP = priceRange.min ? Number(priceRange.min) : 0
const maxP = priceRange.max ? Number(priceRange.max) : Infinity
const playerMinPrice = Math.min(...(player.services?.map((s) => s.price) || [0]))
if (playerMinPrice < minP) return false
if (priceRange.max && playerMinPrice > maxP) return false
if (onlyOnline && player.status !== "available") return false
if (player.rating < Number(minRating)) return false
return true
})
}, [searchQuery, selectedGames, priceRange, onlyOnline, minRating])
const sortedPlayers = useMemo(() => {
return [...filteredPlayers].sort((a, b) => {
switch (sortBy) {
case "rating":
return b.rating - a.rating
case "price_asc": {
const priceA = Math.min(...(a.services?.map((s) => s.price) || [0]))
const priceB = Math.min(...(b.services?.map((s) => s.price) || [0]))
return priceA - priceB
}
case "price_desc": {
const priceA = Math.min(...(a.services?.map((s) => s.price) || [0]))
const priceB = Math.min(...(b.services?.map((s) => s.price) || [0]))
return priceB - priceA
}
case "orders":
return b.totalOrders - a.totalOrders
case "composite": {
const scoreA = a.rating * Math.log10(a.totalOrders + 1)
const scoreB = b.rating * Math.log10(b.totalOrders + 1)
return scoreB - scoreA
}
default:
return 0
}
})
}, [filteredPlayers, sortBy])
const displayedPlayers = sortedPlayers.slice(0, visibleCount)
return (
<div className="container mx-auto px-4 py-6 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<div className="relative flex-1 md:w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="搜索陪玩、游戏、标签..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="md:hidden shrink-0">
<Filter className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[300px] sm:w-[400px] overflow-y-auto">
<SheetHeader>
<SheetTitle></SheetTitle>
<SheetDescription></SheetDescription>
</SheetHeader>
<div className="py-6">
<FilterSection
selectedGames={selectedGames}
onGameChange={handleGameChange}
priceRange={priceRange}
onPriceChange={handlePriceChange}
onlyOnline={onlyOnline}
onOnlineChange={setOnlyOnline}
minRating={minRating}
onRatingChange={setMinRating}
/>
</div>
<SheetFooter>
<SheetClose asChild>
<Button className="w-full"> {filteredPlayers.length} </Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
<aside className="hidden md:block w-64 shrink-0 space-y-6">
<Card className="sticky top-24">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold"></h3>
{(selectedGames.length > 0 ||
priceRange.min ||
priceRange.max ||
onlyOnline ||
minRating !== "0") && (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs text-muted-foreground hover:text-primary"
onClick={() => {
setSelectedGames([])
setPriceRange({ min: "", max: "" })
setOnlyOnline(false)
setMinRating("0")
}}
>
</Button>
)}
</div>
</CardHeader>
<CardContent>
<FilterSection
selectedGames={selectedGames}
onGameChange={handleGameChange}
priceRange={priceRange}
onPriceChange={handlePriceChange}
onlyOnline={onlyOnline}
onOnlineChange={setOnlyOnline}
minRating={minRating}
onRatingChange={setMinRating}
/>
</CardContent>
</Card>
</aside>
<main className="flex-1">
<div className="flex items-center justify-between mb-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10 py-2 md:static md:bg-transparent md:p-0">
<div className="flex items-center gap-2 overflow-x-auto pb-2 md:pb-0 no-scrollbar">
<Button
variant={sortBy === "composite" ? "secondary" : "ghost"}
size="sm"
onClick={() => setSortBy("composite")}
className="whitespace-nowrap"
>
</Button>
<Button
variant={sortBy === "rating" ? "secondary" : "ghost"}
size="sm"
onClick={() => setSortBy("rating")}
className="whitespace-nowrap"
>
</Button>
<Button
variant={sortBy === "orders" ? "secondary" : "ghost"}
size="sm"
onClick={() => setSortBy("orders")}
className="whitespace-nowrap"
>
</Button>
<Button
variant={sortBy.startsWith("price") ? "secondary" : "ghost"}
size="sm"
onClick={() => setSortBy(sortBy === "price_asc" ? "price_desc" : "price_asc")}
className="whitespace-nowrap gap-1"
>
<SlidersHorizontal className="w-3 h-3" />
</Button>
</div>
<div className="text-sm text-muted-foreground hidden sm:block">
{filteredPlayers.length}
</div>
</div>
{displayedPlayers.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{displayedPlayers.map((player) => (
<PlayerCard key={player.id} player={player} />
))}
</div>
) : (
<div className="text-center py-20 bg-muted/30 rounded-lg border border-dashed">
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium"></h3>
<p className="text-muted-foreground mt-1 max-w-sm mx-auto">
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => {
setSearchQuery("")
setSelectedGames([])
setPriceRange({ min: "", max: "" })
setOnlyOnline(false)
setMinRating("0")
}}
>
</Button>
</div>
)}
{filteredPlayers.length > visibleCount && (
<div className="mt-8 text-center">
<Button
variant="outline"
size="lg"
onClick={() => setVisibleCount((prev) => prev + 12)}
>
</Button>
</div>
)}
</main>
</div>
</div>
)
}
export default function SearchPage() {
return (
<Suspense
fallback={
<div className="container mx-auto px-4 py-6 flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
}
>
<SearchPageContent />
</Suspense>
)
}