diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbe18af..3d294e0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,15 +114,17 @@ model BlindBoxIdea { } model WeekendPlan { - id String @id @default(cuid()) + id String @id @default(cuid()) roomId String userId String planData String - status String @default("active") - createdAt DateTime @default(now()) + status String @default("active") + endTime DateTime? + createdAt DateTime @default(now()) room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([roomId]) + @@index([userId]) } diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx new file mode 100644 index 0000000..644c174 --- /dev/null +++ b/src/app/achievements/page.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Trophy, + Zap, + Gift, + ClipboardList, + Target, + TrendingUp, + BarChart3, +} from "lucide-react"; +import { isRegistered, getCachedProfile } from "@/lib/userId"; +import RestaurantImage from "@/components/RestaurantImage"; +import ContractHistoryItem from "@/components/ContractHistoryItem"; +import EmptyState from "@/components/EmptyState"; +import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton"; +import { buildNavUrl } from "@/lib/navigation"; +import type { DecisionRecord, ContractRecord, Restaurant } from "@/types"; + +type Tab = "decisions" | "contracts"; + +interface Stats { + totalDecisions: number; + totalContracts: number; + completedContracts: number; + completionRate: number; +} + +function firstImage(r: Restaurant): string { + if (r.images?.length > 0) return r.images[0]; + const legacy = (r as unknown as Record).image; + return typeof legacy === "string" ? legacy : ""; +} + +export default function AchievementsPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState("decisions"); + const [stats, setStats] = useState({ + totalDecisions: 0, + totalContracts: 0, + completedContracts: 0, + completionRate: 0, + }); + const [decisions, setDecisions] = useState([]); + const [contracts, setContracts] = useState([]); + + useEffect(() => { + if (!isRegistered()) { + router.replace("/"); + return; + } + const p = getCachedProfile(); + if (!p) return; + + (async () => { + try { + const res = await fetch(`/api/user/achievements?userId=${p.id}`); + if (!res.ok) return; + const data = await res.json(); + setStats(data.stats); + setDecisions(data.decisions); + setContracts(data.contracts); + } catch { /* ignore */ } + finally { setLoading(false); } + })(); + }, [router]); + + const statCards = [ + { + label: "决策记录", + value: stats.totalDecisions, + icon: Target, + color: "text-amber-400", + bg: "bg-amber-600/15", + }, + { + label: "契约完成", + value: stats.completedContracts, + icon: Trophy, + color: "text-emerald-400", + bg: "bg-emerald-600/15", + }, + { + label: "完成率", + value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—", + icon: TrendingUp, + color: "text-purple-400", + bg: "bg-purple-600/15", + }, + ]; + + const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [ + { id: "decisions", label: "极速救场", icon: Zap }, + { id: "contracts", label: "周末契约", icon: Gift }, + ]; + + return ( +
+ {/* Ambient glow */} +
+ + {/* Header */} +
+ +
+ +

成就墙

+
+
+ + {/* Stats */} + + {statCards.map((s) => ( +
+
+ +
+ {loading ? ( + + ) : ( +

{s.value}

+ )} +

{s.label}

+
+ ))} +
+ + {/* Tab switcher */} + + {tabs.map((t) => ( + + ))} + + + {/* Content */} +
+ + {tab === "decisions" && ( + + {loading ? ( + <> + + + + + ) : decisions.length === 0 ? ( + + ) : ( + decisions.map((d) => ( + + {firstImage(d.restaurantData) && ( + + )} +
+

+ {d.restaurantName} +

+
+ + {d.matchType === "unanimous" ? "全员一致" : "最佳匹配"} + + {d.participants} 人参与 + + {new Date(d.createdAt).toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + })} + +
+
+
+ )) + )} +
+ )} + + {tab === "contracts" && ( + + {loading ? ( + <> + + + + + ) : contracts.length === 0 ? ( + + ) : ( + contracts.map((c) => ( + + )) + )} + + )} +
+
+ +
+
+ ); +} diff --git a/src/app/api/blindbox/plan/route.ts b/src/app/api/blindbox/plan/route.ts index 5b9bc3b..924b784 100644 --- a/src/app/api/blindbox/plan/route.ts +++ b/src/app/api/blindbox/plan/route.ts @@ -311,46 +311,169 @@ export const POST = apiHandler(async (req) => { }); }); +/** + * Map "周六"/"周日" to the next occurrence of that weekday from a reference date. + * Returns a Date at 00:00 of that day. + */ +function nextWeekday(dayLabel: string, from: Date): Date { + const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6 + const d = new Date(from); + d.setHours(0, 0, 0, 0); + const diff = (targetDow - d.getDay() + 7) % 7; + d.setDate(d.getDate() + (diff === 0 ? 0 : diff)); + return d; +} + +function computeEndTime(planData: string, now: Date): Date | null { + try { + const parsed = JSON.parse(planData); + const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[]; + if (!days?.length) return null; + + const lastDay = days[days.length - 1]; + const lastItem = lastDay.items[lastDay.items.length - 1]; + if (!lastItem) return null; + + const base = nextWeekday(lastDay.date, now); + const [h, m] = lastItem.time.split(":").map(Number); + base.setHours(h, m, 0, 0); + base.setMinutes(base.getMinutes() + (lastItem.duration || 60)); + + // If computed end time is in the past, it's for next week + if (base.getTime() < now.getTime()) { + base.setDate(base.getDate() + 7); + } + + return base; + } catch { + return null; + } +} + export const PATCH = apiHandler(async (req) => { - const { planId, userId } = await req.json(); + const { planId, userId, action } = await req.json(); requireUserId(userId); if (!planId) throw new ApiError("planId 不能为空"); const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } }); if (!plan) throw new ApiError("计划不存在", 404); - if (plan.userId !== userId) throw new ApiError("只能接受自己的计划", 403); + if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403); - await prisma.weekendPlan.update({ - where: { id: planId }, - data: { status: "accepted" }, - }); + const act = action || "accept"; - return NextResponse.json({ ok: true }); + if (act === "accept") { + if (plan.status !== "active") throw new ApiError("该计划无法接受", 400); + const endTime = computeEndTime(plan.planData, new Date()); + await prisma.weekendPlan.update({ + where: { id: planId }, + data: { status: "accepted", endTime }, + }); + return NextResponse.json({ ok: true, endTime }); + } + + if (act === "complete" || act === "expire") { + if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400); + await prisma.weekendPlan.update({ + where: { id: planId }, + data: { status: act === "complete" ? "completed" : "expired" }, + }); + return NextResponse.json({ ok: true }); + } + + throw new ApiError("无效的操作", 400); }); export const GET = apiHandler(async (req) => { const { searchParams } = new URL(req.url); - const roomId = searchParams.get("roomId"); + const mode = searchParams.get("mode") || "latest"; const userId = searchParams.get("userId"); - requireUserId(userId); - if (!roomId) throw new ApiError("roomId 不能为空"); - const plan = await prisma.weekendPlan.findFirst({ - where: { roomId, userId: userId!, status: "accepted" }, - orderBy: { createdAt: "desc" }, - select: { id: true, planData: true, createdAt: true }, - }); + if (mode === "latest") { + const roomId = searchParams.get("roomId"); + if (!roomId) throw new ApiError("roomId 不能为空"); - if (!plan) return NextResponse.json({ plan: null }); + const plan = await prisma.weekendPlan.findFirst({ + where: { roomId, userId: userId!, status: "accepted" }, + orderBy: { createdAt: "desc" }, + select: { id: true, planData: true, endTime: true, createdAt: true }, + }); - const parsed = JSON.parse(plan.planData); + if (!plan) return NextResponse.json({ plan: null }); + const parsed = JSON.parse(plan.planData); + return NextResponse.json({ + plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt }, + }); + } - return NextResponse.json({ - plan: { - id: plan.id, - days: parsed.days, - createdAt: plan.createdAt, - }, - }); + if (mode === "pending") { + const plans = await prisma.weekendPlan.findMany({ + where: { + userId: userId!, + status: "accepted", + endTime: { not: null, lt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + select: { id: true, planData: true, roomId: true, createdAt: true }, + take: 5, + }); + + const result = await Promise.all( + plans.map(async (p) => { + const room = await prisma.blindBoxRoom.findUnique({ + where: { id: p.roomId }, + select: { name: true, code: true }, + }); + const parsed = JSON.parse(p.planData); + const days = parsed.days as { date: string; items: { activity: string }[] }[]; + return { + id: p.id, + roomName: room?.name ?? "未知房间", + roomCode: room?.code ?? "", + date: days.map((d) => d.date).join(" + "), + activities: days.flatMap((d) => d.items.map((i) => i.activity)), + createdAt: p.createdAt, + }; + }), + ); + + return NextResponse.json({ pending: result }); + } + + if (mode === "history") { + const plans = await prisma.weekendPlan.findMany({ + where: { + userId: userId!, + status: { in: ["completed", "expired"] }, + }, + orderBy: { createdAt: "desc" }, + select: { id: true, planData: true, status: true, roomId: true, createdAt: true }, + take: 50, + }); + + const result = await Promise.all( + plans.map(async (p) => { + const room = await prisma.blindBoxRoom.findUnique({ + where: { id: p.roomId }, + select: { name: true, code: true }, + }); + const parsed = JSON.parse(p.planData); + const days = parsed.days as { date: string; items: { activity: string }[] }[]; + return { + id: p.id, + status: p.status, + roomName: room?.name ?? "未知房间", + roomCode: room?.code ?? "", + date: days.map((d) => d.date).join(" + "), + dayCount: days.length, + activities: days.flatMap((d) => d.items.map((i) => i.activity)), + createdAt: p.createdAt, + }; + }), + ); + + return NextResponse.json({ history: result }); + } + + throw new ApiError("无效的 mode 参数", 400); }); diff --git a/src/app/api/user/achievements/route.ts b/src/app/api/user/achievements/route.ts new file mode 100644 index 0000000..4b4a5e3 --- /dev/null +++ b/src/app/api/user/achievements/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { apiHandler, requireUserId } from "@/lib/api"; + +export const GET = apiHandler(async (req) => { + const userId = req.nextUrl.searchParams.get("userId"); + requireUserId(userId); + + const [decisions, contracts] = await Promise.all([ + prisma.decision.findMany({ + where: { userId: userId! }, + orderBy: { createdAt: "desc" }, + take: 50, + }), + prisma.weekendPlan.findMany({ + where: { + userId: userId!, + status: { in: ["completed", "expired"] }, + }, + orderBy: { createdAt: "desc" }, + take: 50, + select: { + id: true, + planData: true, + status: true, + roomId: true, + createdAt: true, + }, + }), + ]); + + const roomIds = [...new Set(contracts.map((c) => c.roomId))]; + const rooms = await prisma.blindBoxRoom.findMany({ + where: { id: { in: roomIds } }, + select: { id: true, name: true, code: true }, + }); + const roomMap = new Map(rooms.map((r) => [r.id, r])); + + const completedCount = contracts.filter((c) => c.status === "completed").length; + + const contractRecords = contracts.map((c) => { + const parsed = JSON.parse(c.planData); + const days = parsed.days as { date: string; items: { activity: string }[] }[]; + const room = roomMap.get(c.roomId); + return { + id: c.id, + status: c.status, + roomName: room?.name ?? "未知房间", + roomCode: room?.code ?? "", + date: days.map((d) => d.date).join(" + "), + dayCount: days.length, + activities: days.flatMap((d) => d.items.map((i) => i.activity)), + createdAt: c.createdAt.toISOString(), + }; + }); + + const decisionRecords = decisions.map((d) => ({ + id: d.id, + roomId: d.roomId, + restaurantName: d.restaurantName, + restaurantData: JSON.parse(d.restaurantData), + matchType: d.matchType, + participants: d.participants, + createdAt: d.createdAt.toISOString(), + })); + + return NextResponse.json({ + stats: { + totalDecisions: decisions.length, + totalContracts: contracts.length, + completedContracts: completedCount, + completionRate: contracts.length > 0 ? Math.round((completedCount / contracts.length) * 100) : 0, + }, + decisions: decisionRecords, + contracts: contractRecords, + }); +}); diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index f8046b0..938f4bf 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -18,6 +18,8 @@ import { MapPin, Calendar, Sparkles, + ClipboardCheck, + ChevronRight, } from "lucide-react"; import confetti from "canvas-confetti"; import { getCachedProfile, isRegistered } from "@/lib/userId"; @@ -27,6 +29,7 @@ import BlindboxMyIdeas, { type MyIdea } from "@/components/BlindboxMyIdeas"; import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory"; import WeekendTimeSelector from "@/components/WeekendTimeSelector"; import BlindboxPlan from "@/components/BlindboxPlan"; +import ContractCompletionModal, { type PendingContract } from "@/components/ContractCompletionModal"; import { useToast } from "@/hooks/useToast"; import { useShare } from "@/hooks/useShare"; import { BlindboxRoomSkeleton } from "@/components/Skeleton"; @@ -76,6 +79,12 @@ export default function BlindboxRoomPage() { const [planAccepted, setPlanAccepted] = useState(false); const [generating, setGenerating] = useState(false); const [showPlanShareCard, setShowPlanShareCard] = useState(false); + const [activeContract, setActiveContract] = useState<{ + id: string; + days: WeekendPlanData[]; + endTime: string | null; + } | null>(null); + const [pendingContracts, setPendingContracts] = useState([]); const boxControls = useAnimation(); const inputRef = useRef(null); @@ -141,14 +150,15 @@ export default function BlindboxRoomPage() { const p = getCachedProfile(); if (!room || !p) return; try { - const res = await fetch(`/api/blindbox/plan?roomId=${room.id}&userId=${p.id}`); + const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${p.id}`); if (!res.ok) return; const data = await res.json(); if (data.plan) { - setPlanId(data.plan.id); - setPlanDays(data.plan.days); - setPlanAccepted(true); - setPhase("plan_reveal"); + setActiveContract({ + id: data.plan.id, + days: data.plan.days, + endTime: data.plan.endTime ?? null, + }); } } catch { /* ignore */ } }, [room]); @@ -160,6 +170,53 @@ export default function BlindboxRoomPage() { } }, [isMember, room, fetchIdeas, fetchAcceptedPlan]); + // Check for expired contracts on load + useEffect(() => { + const p = getCachedProfile(); + if (!isMember || !p) return; + (async () => { + try { + const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`); + if (!res.ok) return; + const data = await res.json(); + if (data.pending?.length) setPendingContracts(data.pending); + } catch { /* ignore */ } + })(); + }, [isMember]); + + // Browser notification timer for active contract + useEffect(() => { + if (!activeContract?.endTime) return; + const end = new Date(activeContract.endTime).getTime(); + const now = Date.now(); + const ms = end - now; + if (ms <= 0) return; + + if (typeof Notification !== "undefined" && Notification.permission === "default") { + Notification.requestPermission(); + } + + const timer = setTimeout(() => { + if (typeof Notification !== "undefined" && Notification.permission === "granted") { + const n = new Notification("周末契约到期", { + body: "你的周末契约已结束,完成了吗?", + icon: "/icon-192x192.png", + }); + n.onclick = () => { window.focus(); n.close(); }; + } + // Refresh pending contracts + const p = getCachedProfile(); + if (p) { + fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`) + .then((r) => r.json()) + .then((d) => { if (d.pending?.length) setPendingContracts(d.pending); }) + .catch(() => {}); + } + }, ms); + + return () => clearTimeout(timer); + }, [activeContract?.endTime]); + useEffect(() => { if (isMember && inputRef.current) { const t = setTimeout(() => inputRef.current?.focus(), 300); @@ -505,6 +562,28 @@ export default function BlindboxRoomPage() {
+ {/* Active contract indicator */} + {activeContract && phase !== "plan_reveal" && ( + { + setPlanId(activeContract.id); + setPlanDays(activeContract.days); + setPlanAccepted(true); + setPhase("plan_reveal"); + }} + > + + 契约进行中 + + {activeContract.days.map((d) => d.date).join(" + ")} + + + + )} + {/* Invite panel */} {showInvite && ( @@ -808,13 +887,26 @@ export default function BlindboxRoomPage() { fireConfetti(); if (planId && profile) { try { - await fetch("/api/blindbox/plan", { + const res = await fetch("/api/blindbox/plan", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ planId, userId: profile.id }), + body: JSON.stringify({ planId, userId: profile.id, action: "accept" }), + }); + const data = await res.json(); + setActiveContract({ + id: planId, + days: planDays, + endTime: data.endTime ?? null, }); } catch { /* best-effort */ } } + toast.show("契约已接受!"); + timersRef.current.push(setTimeout(() => { + setPhase("pool"); + setPlanId(null); + setPlanDays([]); + setPlanAccepted(false); + }, 1500)); }} onRegenerate={() => { setPhase("time_select"); @@ -914,6 +1006,18 @@ export default function BlindboxRoomPage() { )}
+ + {/* Contract expiration check modal */} + {pendingContracts.length > 0 && profile && ( + { + setPendingContracts([]); + setActiveContract(null); + }} + /> + )}
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index df80271..6f27208 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { motion } from "framer-motion"; -import { Zap, Gift, Clock, ChevronRight } from "lucide-react"; +import { Zap, Gift, Clock, ChevronRight, Trophy } from "lucide-react"; import BrandLogo from "@/components/BrandLogo"; export default function LandingPage() { @@ -144,6 +144,25 @@ export default function LandingPage() {
+ {/* Achievements entry */} + router.push("/achievements")} + className="mt-6 flex w-full max-w-sm items-center gap-3 rounded-xl bg-surface px-4 py-3 ring-1 ring-border transition-colors active:bg-elevated" + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.5 }} + whileTap={{ scale: 0.98 }} + > +
+ +
+
+

成就墙

+

查看决策记录和契约成就

+
+ +
+ {/* Footer */} + {/* Achievements link */} + + + + setShowHistory((v) => !v)} onEmpty={() => router.push("/blindbox")} - delay={0.15} + delay={0.2} /> void; +} + +export default function ContractCompletionModal({ + contracts, + userId, + onDone, +}: ContractCompletionModalProps) { + const [current, setCurrent] = useState(0); + const [loading, setLoading] = useState(false); + + if (contracts.length === 0) return null; + + const contract = contracts[current]; + + const handleAction = async (action: "complete" | "expire") => { + if (loading) return; + setLoading(true); + try { + await fetch("/api/blindbox/plan", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ planId: contract.id, userId, action }), + }); + } catch { /* best-effort */ } + setLoading(false); + + if (current < contracts.length - 1) { + setCurrent((c) => c + 1); + } else { + onDone(); + } + }; + + const summary = + contract.activities.length <= 3 + ? contract.activities.join(" → ") + : contract.activities.slice(0, 3).join(" → ") + "…"; + + return ( + + + +
+

+ ✦ 契约到期 ✦ +

+

+ {contract.roomName} +

+

+ {contract.date} · {summary} +

+
+ +
+

+ 这份契约已到期,你完成了吗? +

+ +
+ + +
+ + {contracts.length > 1 && ( +

+ {current + 1} / {contracts.length} +

+ )} +
+
+
+
+ ); +} + +export type { PendingContract }; diff --git a/src/components/ContractHistoryItem.tsx b/src/components/ContractHistoryItem.tsx new file mode 100644 index 0000000..fb88a74 --- /dev/null +++ b/src/components/ContractHistoryItem.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { CheckCircle2, XCircle, ChevronDown } from "lucide-react"; +import type { ContractRecord } from "@/types"; + +interface ContractHistoryItemProps { + record: ContractRecord; +} + +export default function ContractHistoryItem({ record }: ContractHistoryItemProps) { + const [expanded, setExpanded] = useState(false); + const isCompleted = record.status === "completed"; + + const summary = + record.activities.length <= 3 + ? record.activities.join(" → ") + : record.activities.slice(0, 3).join(" → ") + "…"; + + return ( + + ); +} diff --git a/src/types/index.ts b/src/types/index.ts index c9df25e..4860caa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -113,3 +113,14 @@ export interface WeekendPlanData { items: PlanItem[]; summary: string; } + +export interface ContractRecord { + id: string; + status: "completed" | "expired"; + roomName: string; + roomCode: string; + date: string; + dayCount: number; + activities: string[]; + createdAt: string; +}