Files
soul/app/match/page.tsx
v0 b487855d44 feat: implement CKB API integration
Add CKB API routes and update match page for joining features

#VERCEL_SKIP

Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
2026-01-14 07:50:53 +00:00

584 lines
22 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, useEffect } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Mic, X, CheckCircle, Loader2 } from "lucide-react"
import { Home, List, User } from "lucide-react"
import { useRouter } from "next/navigation"
interface MatchUser {
id: string
nickname: string
avatar: string
tags: string[]
matchScore: number
concept: string
wechat: string
commonInterests: Array<{ icon: string; text: string }>
}
const matchTypes = [
{ id: "partner", label: "创业合伙", icon: "⭐", color: "#00E5FF", joinable: false },
{ id: "investor", label: "资源对接", icon: "👥", color: "#7B61FF", joinable: true },
{ id: "mentor", label: "导师顾问", icon: "❤️", color: "#E91E63", joinable: true },
{ id: "team", label: "团队招募", icon: "🎮", color: "#4CAF50", joinable: true },
]
// 获取本地存储的手机号
const getStoredPhone = (): string => {
if (typeof window !== "undefined") {
return localStorage.getItem("user_phone") || ""
}
return ""
}
// 保存手机号到本地存储
const savePhone = (phone: string) => {
if (typeof window !== "undefined") {
localStorage.setItem("user_phone", phone)
}
}
export default function MatchPage() {
const [isMatching, setIsMatching] = useState(false)
const [currentMatch, setCurrentMatch] = useState<MatchUser | null>(null)
const [matchAttempts, setMatchAttempts] = useState(0)
const [selectedType, setSelectedType] = useState("partner")
const router = useRouter()
const [showJoinModal, setShowJoinModal] = useState(false)
const [joinType, setJoinType] = useState<string | null>(null)
const [phoneNumber, setPhoneNumber] = useState("")
const [isJoining, setIsJoining] = useState(false)
const [joinSuccess, setJoinSuccess] = useState(false)
const [joinError, setJoinError] = useState("")
// 初始化时读取已存储的手机号
useEffect(() => {
const storedPhone = getStoredPhone()
if (storedPhone) {
setPhoneNumber(storedPhone)
}
}, [])
const handleJoinClick = (typeId: string) => {
const type = matchTypes.find((t) => t.id === typeId)
if (type?.joinable) {
setJoinType(typeId)
setShowJoinModal(true)
setJoinSuccess(false)
setJoinError("")
// 如果有存储的手机号,自动填充
const storedPhone = getStoredPhone()
if (storedPhone) {
setPhoneNumber(storedPhone)
}
} else {
// 不可加入的类型,直接选中并开始匹配
setSelectedType(typeId)
}
}
const handleJoinSubmit = async () => {
if (!phoneNumber || phoneNumber.length !== 11) {
setJoinError("请输入正确的11位手机号")
return
}
setIsJoining(true)
setJoinError("")
try {
const response = await fetch("/api/ckb/join", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
type: joinType,
phone: phoneNumber,
}),
})
const result = await response.json()
if (result.success) {
// 保存手机号以便下次使用
savePhone(phoneNumber)
setJoinSuccess(true)
// 2秒后关闭弹窗
setTimeout(() => {
setShowJoinModal(false)
setJoinSuccess(false)
}, 2000)
} else {
setJoinError(result.message || "加入失败,请稍后重试")
}
} catch (error) {
setJoinError("网络错误,请检查网络后重试")
} finally {
setIsJoining(false)
}
}
const startMatch = () => {
setIsMatching(true)
setMatchAttempts(0)
setCurrentMatch(null)
const interval = setInterval(() => {
setMatchAttempts((prev) => prev + 1)
}, 1000)
setTimeout(
() => {
clearInterval(interval)
setIsMatching(false)
setCurrentMatch(getMockMatch())
},
Math.random() * 3000 + 3000,
)
}
const getMockMatch = (): MatchUser => {
const nicknames = ["创业先锋", "资源整合者", "私域专家", "商业导师", "连续创业者"]
const randomIndex = Math.floor(Math.random() * nicknames.length)
const concepts = [
"专注私域流量运营5年帮助100+品牌实现从0到1的增长。",
"连续创业者,擅长商业模式设计和资源整合。",
"在Soul分享真实创业故事希望找到志同道合的合作伙伴。",
]
const wechats = ["soul_partner_1", "soul_business_2024", "soul_startup_fan"]
return {
id: `user_${Date.now()}`,
nickname: nicknames[randomIndex],
avatar: `https://picsum.photos/200/200?random=${randomIndex}`,
tags: ["创业者", "私域运营", matchTypes.find((t) => t.id === selectedType)?.label || ""],
matchScore: Math.floor(Math.random() * 20) + 80,
concept: concepts[randomIndex % concepts.length],
wechat: wechats[randomIndex % wechats.length],
commonInterests: [
{ icon: "📚", text: "都在读《创业实验》" },
{ icon: "💼", text: "对私域运营感兴趣" },
{ icon: "🎯", text: "相似的创业方向" },
],
}
}
const nextMatch = () => {
setCurrentMatch(null)
setTimeout(() => startMatch(), 500)
}
const handleAddWechat = () => {
if (!currentMatch) return
navigator.clipboard
.writeText(currentMatch.wechat)
.then(() => {
alert(`微信号已复制:${currentMatch.wechat}\n\n请打开微信添加好友备注"创业合作"即可。`)
})
.catch(() => {
alert(`微信号:${currentMatch.wechat}\n\n请手动复制并添加好友。`)
})
}
const currentTypeLabel = matchTypes.find((t) => t.id === selectedType)?.label || "创业合伙"
const joinTypeLabel = matchTypes.find((t) => t.id === joinType)?.label || ""
return (
<div className="min-h-screen bg-black pb-24">
<div className="flex items-center justify-between px-6 pt-6 pb-4">
<h1 className="text-2xl font-bold text-white"></h1>
<button className="w-10 h-10 rounded-full bg-[#1c1c1e] flex items-center justify-center">
<svg className="w-5 h-5 text-white/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
</button>
</div>
<AnimatePresence mode="wait">
{!isMatching && !currentMatch && (
<motion.div
key="idle"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="flex flex-col items-center px-6"
>
{/* 中央匹配圆环 */}
<motion.div
onClick={startMatch}
className="relative w-[280px] h-[280px] mb-8 cursor-pointer"
whileTap={{ scale: 0.95 }}
>
{/* 外层光环 */}
<motion.div
className="absolute inset-[-30px] rounded-full"
style={{
background: "radial-gradient(circle, transparent 50%, rgba(0, 229, 255, 0.1) 70%, transparent 100%)",
}}
animate={{
scale: [1, 1.1, 1],
opacity: [0.5, 0.8, 0.5],
}}
transition={{
duration: 2,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
{/* 中间光环 */}
<motion.div
className="absolute inset-[-15px] rounded-full border-2 border-[#00E5FF]/30"
animate={{
scale: [1, 1.05, 1],
opacity: [0.3, 0.6, 0.3],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
{/* 内层渐变球 */}
<motion.div
className="absolute inset-0 rounded-full flex flex-col items-center justify-center overflow-hidden"
style={{
background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
boxShadow: "0 0 60px rgba(0, 229, 255, 0.3), inset 0 0 60px rgba(123, 97, 255, 0.2)",
}}
animate={{
y: [0, -5, 0],
}}
transition={{
duration: 3,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
>
{/* 内部渐变光效 */}
<div
className="absolute inset-0 rounded-full"
style={{
background:
"radial-gradient(circle at 30% 30%, rgba(123, 97, 255, 0.4) 0%, transparent 50%), radial-gradient(circle at 70% 70%, rgba(233, 30, 99, 0.3) 0%, transparent 50%)",
}}
/>
{/* 中心图标 */}
<Mic className="w-12 h-12 text-white/90 mb-3 relative z-10" />
<div className="text-xl font-bold text-white mb-1 relative z-10"></div>
<div className="text-sm text-white/60 relative z-10">{currentTypeLabel}</div>
</motion.div>
</motion.div>
{/* 当前模式显示 */}
<p className="text-white/50 text-sm mb-8">
: <span className="text-[#00E5FF]">{currentTypeLabel}</span>
</p>
{/* 分隔线 */}
<div className="w-full h-px bg-white/10 mb-6" />
{/* 选择匹配类型 - 修改为点击可加入的类型时弹出加入框 */}
<p className="text-white/40 text-sm mb-4"></p>
<div className="grid grid-cols-4 gap-3 w-full">
{matchTypes.map((type) => (
<button
key={type.id}
onClick={() => {
setSelectedType(type.id)
}}
className={`p-4 rounded-xl flex flex-col items-center gap-2 transition-all ${
selectedType === type.id
? "bg-[#00E5FF]/10 border border-[#00E5FF]/50"
: "bg-[#1c1c1e] border border-transparent"
}`}
>
<span className="text-2xl">{type.icon}</span>
<span className={`text-xs ${selectedType === type.id ? "text-[#00E5FF]" : "text-white/60"}`}>
{type.label}
</span>
{type.joinable && (
<span
onClick={(e) => {
e.stopPropagation()
handleJoinClick(type.id)
}}
className="text-[10px] px-2 py-0.5 rounded-full bg-[#00E5FF]/20 text-[#00E5FF] mt-1 cursor-pointer hover:bg-[#00E5FF]/30"
>
</span>
)}
</button>
))}
</div>
</motion.div>
)}
{isMatching && (
<motion.div
key="matching"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="text-center px-6"
>
{/* 匹配动画 */}
<div className="relative w-[200px] h-[200px] mx-auto mb-8">
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-[#00E5FF] via-[#7B61FF] to-[#E91E63]"
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
/>
<div className="absolute inset-2 rounded-full bg-black flex items-center justify-center">
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY }}
>
<Mic className="w-12 h-12 text-[#00E5FF]" />
</motion.div>
</div>
{/* 扩散波纹 */}
{[1, 2, 3].map((ring) => (
<motion.div
key={ring}
className="absolute inset-0 rounded-full border-2 border-[#00E5FF]/30"
animate={{
scale: [1, 2],
opacity: [0.6, 0],
}}
transition={{
duration: 2,
repeat: Number.POSITIVE_INFINITY,
delay: ring * 0.5,
}}
/>
))}
</div>
<h2 className="text-xl font-semibold mb-2 text-white">...</h2>
<p className="text-white/50 mb-8"> {matchAttempts} </p>
<button
onClick={() => setIsMatching(false)}
className="px-8 py-3 rounded-full bg-[#1c1c1e] text-white border border-white/10"
>
</button>
</motion.div>
)}
{currentMatch && !isMatching && (
<motion.div
key="matched"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="px-6"
>
{/* 成功动画 */}
<motion.div
className="text-center mb-6"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", bounce: 0.5 }}
>
<span className="text-6xl"></span>
</motion.div>
{/* 用户卡片 */}
<div className="bg-[#1c1c1e] rounded-2xl p-5 mb-4 border border-white/5">
<div className="flex items-center gap-4 mb-4">
<img
src={currentMatch.avatar || "/placeholder.svg"}
alt={currentMatch.nickname}
className="w-16 h-16 rounded-full border-2 border-[#00E5FF]"
/>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-2">{currentMatch.nickname}</h3>
<div className="flex flex-wrap gap-1">
{currentMatch.tags.map((tag) => (
<span key={tag} className="px-2 py-0.5 rounded text-xs bg-[#00E5FF]/20 text-[#00E5FF]">
{tag}
</span>
))}
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-[#00E5FF]">{currentMatch.matchScore}%</div>
<div className="text-xs text-white/50"></div>
</div>
</div>
{/* 共同兴趣 */}
<div className="pt-4 border-t border-white/10 mb-4">
<h4 className="text-sm text-white/60 mb-2"></h4>
<div className="space-y-2">
{currentMatch.commonInterests.map((interest, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-white/80">
<span>{interest.icon}</span>
<span>{interest.text}</span>
</div>
))}
</div>
</div>
{/* 核心理念 */}
<div className="pt-4 border-t border-white/10">
<h4 className="text-sm text-white/60 mb-2"></h4>
<p className="text-sm text-white/70">{currentMatch.concept}</p>
</div>
</div>
{/* 操作按钮 */}
<div className="space-y-3">
<button onClick={handleAddWechat} className="w-full py-4 rounded-xl bg-[#00E5FF] text-black font-medium">
</button>
<button
onClick={nextMatch}
className="w-full py-4 rounded-xl bg-[#1c1c1e] text-white border border-white/10"
>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showJoinModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center px-6"
onClick={() => !isJoining && setShowJoinModal(false)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="bg-[#1c1c1e] rounded-2xl w-full max-w-sm overflow-hidden"
>
{/* 弹窗头部 */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h3 className="text-lg font-semibold text-white">{joinTypeLabel}</h3>
<button
onClick={() => !isJoining && setShowJoinModal(false)}
className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center"
>
<X className="w-4 h-4 text-white/60" />
</button>
</div>
{/* 弹窗内容 */}
<div className="p-5">
{joinSuccess ? (
// 成功状态
<motion.div initial={{ scale: 0.8 }} animate={{ scale: 1 }} className="text-center py-8">
<CheckCircle className="w-16 h-16 text-[#00E5FF] mx-auto mb-4" />
<p className="text-white text-lg font-medium mb-2">!</p>
<p className="text-white/60 text-sm"></p>
</motion.div>
) : (
// 表单状态
<>
<p className="text-white/60 text-sm mb-4">
{getStoredPhone() ? "检测到您已注册的手机号,确认后即可加入" : "请输入您的手机号以便我们联系您"}
</p>
{/* 手机号输入 */}
<div className="mb-4">
<label className="block text-white/40 text-xs mb-2"></label>
<input
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/\D/g, "").slice(0, 11))}
placeholder="请输入11位手机号"
className="w-full px-4 py-3 rounded-xl bg-black/30 border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5FF]/50"
disabled={isJoining}
/>
</div>
{/* 错误提示 */}
{joinError && <p className="text-red-400 text-sm mb-4">{joinError}</p>}
{/* 提交按钮 */}
<button
onClick={handleJoinSubmit}
disabled={isJoining || !phoneNumber}
className="w-full py-3 rounded-xl bg-[#00E5FF] text-black font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isJoining ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
...
</>
) : (
"确认加入"
)}
</button>
<p className="text-white/30 text-xs text-center mt-3"></p>
</>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<nav className="fixed bottom-0 left-0 right-0 bg-[#1c1c1e]/95 backdrop-blur-xl border-t border-white/5 pb-safe-bottom">
<div className="px-4 py-2">
<div className="flex items-center justify-around">
<button onClick={() => router.push("/")} className="flex flex-col items-center py-2 px-4">
<Home className="w-5 h-5 text-gray-500 mb-1" />
<span className="text-gray-500 text-xs"></span>
</button>
<button onClick={() => router.push("/chapters")} className="flex flex-col items-center py-2 px-4">
<List className="w-5 h-5 text-gray-500 mb-1" />
<span className="text-gray-500 text-xs"></span>
</button>
{/* 匹配按钮 - 当前页面高亮,小星球图标 */}
<button className="flex flex-col items-center py-2 px-6 -mt-4">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center shadow-lg shadow-[#00CED1]/30">
<svg className="w-7 h-7 text-white" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8" fill="currentColor" opacity="0.9" />
<ellipse
cx="12"
cy="12"
rx="11"
ry="4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
opacity="0.6"
/>
<circle cx="9" cy="10" r="1.5" fill="white" opacity="0.4" />
</svg>
</div>
<span className="text-[#00CED1] text-xs font-medium mt-1"></span>
</button>
<button onClick={() => router.push("/my")} className="flex flex-col items-center py-2 px-4">
<User className="w-5 h-5 text-gray-500 mb-1" />
<span className="text-gray-500 text-xs"></span>
</button>
</div>
</div>
</nav>
</div>
)
}