chore: 恢复上传 components 目录到 GitHub

This commit is contained in:
卡若
2025-12-29 19:06:29 +08:00
parent 5dfc9b2898
commit 0bee46d58d
35 changed files with 3255 additions and 1 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
node_modules/ node_modules/
components/
.next/ .next/
.env.local .env.local
.DS_Store .DS_Store

225
components/auth-modal.tsx Normal file
View File

@@ -0,0 +1,225 @@
"use client"
import { useState } from "react"
import { X, Phone, User, Gift } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useStore } from "@/lib/store"
interface AuthModalProps {
isOpen: boolean
onClose: () => void
defaultTab?: "login" | "register"
}
export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalProps) {
const [tab, setTab] = useState<"login" | "register">(defaultTab)
const [phone, setPhone] = useState("")
const [code, setCode] = useState("")
const [nickname, setNickname] = useState("")
const [referralCode, setReferralCode] = useState("")
const [error, setError] = useState("")
const [codeSent, setCodeSent] = useState(false)
const { login, register } = useStore()
const handleSendCode = () => {
if (phone.length !== 11) {
setError("请输入正确的手机号")
return
}
// Simulate sending verification code
setCodeSent(true)
setError("")
alert("验证码已发送,测试验证码: 123456")
}
const handleLogin = async () => {
setError("")
const success = await login(phone, code)
if (success) {
onClose()
} else {
setError("验证码错误或用户不存在,请先注册")
}
}
const handleRegister = async () => {
setError("")
if (!nickname.trim()) {
setError("请输入昵称")
return
}
const success = await register(phone, nickname, referralCode || undefined)
if (success) {
onClose()
} else {
setError("该手机号已注册")
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Tabs */}
<div className="flex border-b border-gray-700/50">
<button
onClick={() => setTab("login")}
className={`flex-1 py-4 text-center transition-colors ${
tab === "login" ? "text-white border-b-2 border-[#38bdac]" : "text-gray-400 hover:text-white"
}`}
>
</button>
<button
onClick={() => setTab("register")}
className={`flex-1 py-4 text-center transition-colors ${
tab === "register" ? "text-white border-b-2 border-[#38bdac]" : "text-gray-400 hover:text-white"
}`}
>
</button>
</div>
{/* Content */}
<div className="p-6">
{tab === "login" ? (
<div className="space-y-4">
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="请输入手机号"
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
maxLength={11}
/>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="flex gap-3">
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入验证码"
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
maxLength={6}
/>
<Button
type="button"
variant="outline"
onClick={handleSendCode}
disabled={codeSent}
className="whitespace-nowrap border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
{codeSent ? "已发送" : "获取验证码"}
</Button>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<Button onClick={handleLogin} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5">
</Button>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="请输入手机号"
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
maxLength={11}
/>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="请输入昵称"
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
/>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="flex gap-3">
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入验证码"
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
maxLength={6}
/>
<Button
type="button"
variant="outline"
onClick={handleSendCode}
disabled={codeSent}
className="whitespace-nowrap border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
{codeSent ? "已发送" : "获取验证码"}
</Button>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2"> ()</label>
<div className="relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
value={referralCode}
onChange={(e) => setReferralCode(e.target.value)}
placeholder="填写邀请码可获得优惠"
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
/>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<Button onClick={handleRegister} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5">
</Button>
</div>
)}
</div>
</div>
</div>
)
}

111
components/book-cover.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { BookOpen } from "lucide-react"
import Link from "next/link"
import { getFullBookPrice, getAllSections } from "@/lib/book-data"
import { useStore } from "@/lib/store"
import { AuthModal } from "./modules/auth/auth-modal"
import { PaymentModal } from "./modules/payment/payment-modal"
export function BookCover() {
const [fullBookPrice, setFullBookPrice] = useState(9.9)
const [sectionsCount, setSectionsCount] = useState(55)
const [isAuthOpen, setIsAuthOpen] = useState(false)
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
const { isLoggedIn } = useStore()
useEffect(() => {
const sections = getAllSections()
setSectionsCount(sections.length)
setFullBookPrice(getFullBookPrice(sections.length))
}, [])
return (
<section className="relative min-h-[85vh] flex flex-col items-center justify-center px-4 py-8 overflow-hidden bg-app-bg">
{/* Background decorative lines - simplified */}
<div className="absolute inset-0 overflow-hidden opacity-50">
<svg className="absolute w-full h-full" viewBox="0 0 800 600" fill="none">
<path
d="M-100 300 Q 200 100, 400 200 T 900 150"
stroke="rgba(56, 189, 172, 0.2)"
strokeWidth="1"
strokeDasharray="8 8"
fill="none"
/>
</svg>
</div>
{/* Content - more compact for mobile */}
<div className="relative z-10 w-full max-w-sm mx-auto text-center">
{/* Soul badge */}
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-app-card/80 backdrop-blur-md border border-app-brand/30 mb-6">
<span className="text-app-brand text-sm font-medium">Soul · </span>
</div>
{/* Main title - smaller on mobile */}
<h1 className="text-3xl font-bold mb-3 leading-tight text-app-text">
SOUL的
<br />
</h1>
{/* Subtitle */}
<p className="text-app-text-muted text-sm mb-4">Soul派对房的真实商业故事</p>
{/* Quote - smaller */}
<p className="text-app-text-muted/80 italic text-sm mb-6">"社会不是靠努力,是靠洞察与选择"</p>
{/* Price info - compact card */}
<div className="bg-app-card/60 backdrop-blur-md rounded-xl p-4 mb-6 border border-app-border">
<div className="flex items-center justify-center gap-6 text-sm">
<div className="text-center">
<p className="text-xl font-bold text-app-brand">¥{fullBookPrice.toFixed(1)}</p>
<p className="text-app-text-muted text-xs"></p>
</div>
<div className="w-px h-8 bg-app-border" />
<div className="text-center">
<p className="text-xl font-bold text-app-text">{sectionsCount}</p>
<p className="text-app-text-muted text-xs"></p>
</div>
</div>
</div>
{/* Author info - compact */}
<div className="flex justify-between items-center px-2 mb-6 text-sm">
<div className="text-left">
<p className="text-app-text-muted text-xs"></p>
<p className="text-app-brand font-medium"></p>
</div>
<div className="text-right">
<p className="text-app-text-muted text-xs"></p>
<p className="text-app-text font-medium">06:00-09:00</p>
</div>
</div>
{/* CTA Button */}
<Link href="/chapters" className="block">
<Button
size="lg"
className="w-full bg-app-brand hover:bg-app-brand-hover text-white py-5 text-base rounded-xl flex items-center justify-center gap-2"
>
<BookOpen className="w-5 h-5" />
</Button>
</Link>
<p className="text-app-text-muted text-xs mt-4"> · 3</p>
</div>
{/* Modals */}
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
<PaymentModal
isOpen={isPaymentOpen}
onClose={() => setIsPaymentOpen(false)}
type="fullbook"
amount={fullBookPrice}
onSuccess={() => window.location.reload()}
/>
</section>
)
}

34
components/book-intro.tsx Normal file
View File

@@ -0,0 +1,34 @@
export function BookIntro() {
return (
<section className="py-16 px-4">
<div className="max-w-2xl mx-auto">
{/* Glass card */}
<div className="bg-[#0f2137]/80 backdrop-blur-xl rounded-2xl p-8 border border-[#38bdac]/20">
{/* Quote */}
<blockquote className="text-lg md:text-xl text-gray-200 leading-relaxed mb-6">
"这不是一本教你成功的鸡汤书。这是我每天早上6点到9点在Soul派对房和几百个陌生人分享的真实故事。"
</blockquote>
{/* Author */}
<p className="text-[#38bdac] text-lg"> </p>
{/* Stats */}
<div className="mt-8 pt-6 border-t border-gray-700/50 grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-2xl md:text-3xl font-bold text-white">55+</p>
<p className="text-gray-400 text-sm"></p>
</div>
<div>
<p className="text-2xl md:text-3xl font-bold text-white">11</p>
<p className="text-gray-400 text-sm"></p>
</div>
<div>
<p className="text-2xl md:text-3xl font-bold text-white">100+</p>
<p className="text-gray-400 text-sm"></p>
</div>
</div>
</div>
</div>
</section>
)
}

62
components/bottom-nav.tsx Normal file
View File

@@ -0,0 +1,62 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, MessageCircle, User } from "lucide-react"
import { useState } from "react"
import { QRCodeModal } from "./modules/marketing/qr-code-modal"
export function BottomNav() {
const pathname = usePathname()
const [showQRModal, setShowQRModal] = useState(false)
if (pathname.startsWith("/documentation")) {
return null
}
const navItems = [
{ href: "/", icon: Home, label: "首页" },
{ action: () => setShowQRModal(true), icon: MessageCircle, label: "派对群" },
{ href: "/my", icon: User, label: "我的" },
]
return (
<>
<nav className="fixed bottom-0 left-0 right-0 z-40 bg-[#0f2137]/95 backdrop-blur-md border-t border-gray-700/50">
<div className="flex items-center justify-around py-3 max-w-lg mx-auto">
{navItems.map((item, index) => {
const isActive = item.href ? pathname === item.href : false
const Icon = item.icon
if (item.action) {
return (
<button
key={index}
onClick={item.action}
className="flex flex-col items-center py-2 px-6 text-gray-400 hover:text-[#38bdac] transition-colors"
>
<Icon className="w-6 h-6 mb-1" />
<span className="text-xs">{item.label}</span>
</button>
)
}
return (
<Link
key={index}
href={item.href!}
className={`flex flex-col items-center py-2 px-6 transition-colors ${
isActive ? "text-[#38bdac]" : "text-gray-400 hover:text-white"
}`}
>
<Icon className="w-6 h-6 mb-1" />
<span className="text-xs">{item.label}</span>
</Link>
)
})}
</div>
</nav>
<QRCodeModal isOpen={showQRModal} onClose={() => setShowQRModal(false)} />
</>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { useStore } from "@/lib/store"
import { PaymentModal } from "@/components/modules/payment/payment-modal"
import { AuthModal } from "@/components/modules/auth/auth-modal"
interface BuyFullBookButtonProps {
price: number
className?: string
size?: "default" | "sm" | "lg" | "icon"
children?: React.ReactNode
}
export function BuyFullBookButton({ price, className, size = "default", children }: BuyFullBookButtonProps) {
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
const [isAuthOpen, setIsAuthOpen] = useState(false)
const { isLoggedIn } = useStore()
const handleClick = () => {
if (!isLoggedIn) {
setIsAuthOpen(true)
return
}
setIsPaymentOpen(true)
}
return (
<>
<Button
size={size}
className={className}
onClick={handleClick}
>
{children || `购买全书 ¥${price}`}
</Button>
<AuthModal
isOpen={isAuthOpen}
onClose={() => setIsAuthOpen(false)}
/>
<PaymentModal
isOpen={isPaymentOpen}
onClose={() => setIsPaymentOpen(false)}
type="fullbook"
amount={price}
onSuccess={() => {
// Refresh or redirect
window.location.reload()
}}
/>
</>
)
}

View File

@@ -0,0 +1,290 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { ChevronLeft, Lock, Share2, BookOpen, Clock, MessageCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { type Section, getFullBookPrice, isSectionUnlocked } from "@/lib/book-data"
import { useStore } from "@/lib/store"
import { AuthModal } from "./modules/auth/auth-modal"
import { PaymentModal } from "./modules/payment/payment-modal"
import { UserMenu } from "./user-menu"
import { QRCodeModal } from "./modules/marketing/qr-code-modal"
import { ReferralShare } from "./modules/referral/referral-share"
interface ChapterContentProps {
section: Section & { filePath: string }
partTitle: string
chapterTitle: string
}
export function ChapterContent({ section, partTitle, chapterTitle }: ChapterContentProps) {
const [content, setContent] = useState<string>("")
const [isLoading, setIsLoading] = useState(true)
const [isAuthOpen, setIsAuthOpen] = useState(false)
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
const [isQRModalOpen, setIsQRModalOpen] = useState(false)
const [paymentType, setPaymentType] = useState<"section" | "fullbook">("section")
const [fullBookPrice, setFullBookPrice] = useState(9.9)
const { user, isLoggedIn, hasPurchased, settings } = useStore()
const distributorShare = settings?.distributorShare || 90
const isUnlocked = isSectionUnlocked(section)
const canAccess = section.isFree || isUnlocked || (isLoggedIn && hasPurchased(section.id))
useEffect(() => {
setFullBookPrice(getFullBookPrice())
}, [])
useEffect(() => {
async function loadContent() {
try {
if (section.content) {
setContent(section.content)
setIsLoading(false)
return
}
if (typeof window !== "undefined" && section.filePath.startsWith("custom/")) {
const customSections = JSON.parse(localStorage.getItem("custom_sections") || "[]") as Section[]
const customSection = customSections.find((s) => s.id === section.id)
if (customSection?.content) {
setContent(customSection.content)
setIsLoading(false)
return
}
}
const response = await fetch(`/api/content?path=${encodeURIComponent(section.filePath)}`)
if (response.ok) {
const data = await response.json()
if (!data.isCustom) {
setContent(data.content)
}
}
} catch (error) {
console.error("Failed to load content:", error)
} finally {
setIsLoading(false)
}
}
loadContent()
}, [section.filePath, section.id, section.content])
const handlePurchaseClick = (type: "section" | "fullbook") => {
if (!isLoggedIn) {
setIsAuthOpen(true)
return
}
setPaymentType(type)
setIsPaymentOpen(true)
}
const handleShare = async () => {
const url = user?.referralCode ? `${window.location.href}?ref=${user.referralCode}` : window.location.href
const shareData = {
title: section.title,
text: `来自Soul派对房的真实商业故事: ${section.title}`,
url: url,
}
try {
if (navigator.share && navigator.canShare && navigator.canShare(shareData)) {
await navigator.share(shareData)
} else {
navigator.clipboard.writeText(url)
alert(
`链接已复制!分享后他人购买,你可获得${distributorShare}%返利 (¥${((fullBookPrice * distributorShare) / 100).toFixed(1)})`,
)
}
} catch (error) {
if ((error as Error).name !== "AbortError") {
navigator.clipboard.writeText(url)
alert(`链接已复制!分享后他人购买,你可获得${distributorShare}%返利`)
}
}
}
const previewContent = content.slice(0, 500)
return (
<div className="min-h-screen bg-[#0a1628] text-white pb-20">
{/* Header */}
<header className="sticky top-0 z-50 bg-[#0a1628]/90 backdrop-blur-md border-b border-gray-800">
<div className="max-w-3xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/chapters" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors">
<ChevronLeft className="w-5 h-5" />
<span className="hidden sm:inline"></span>
</Link>
<div className="text-center flex-1 px-4">
<p className="text-gray-500 text-xs">{partTitle}</p>
{chapterTitle && <p className="text-gray-400 text-sm truncate">{chapterTitle}</p>}
</div>
<div className="flex items-center gap-2">
<ReferralShare
sectionTitle={section.title}
fullBookPrice={fullBookPrice}
distributorShare={distributorShare}
/>
<UserMenu />
</div>
</div>
</header>
{/* Content */}
<main className="max-w-3xl mx-auto px-4 py-8">
{/* Title */}
<div className="mb-8">
<div className="flex items-center gap-2 text-[#38bdac] text-sm mb-2">
<BookOpen className="w-4 h-4" />
<span>{section.id}</span>
{section.unlockAfterDays && !section.isFree && (
<span className="ml-2 px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded-full flex items-center gap-1">
<Clock className="w-3 h-3" />
{isUnlocked ? "已免费解锁" : `${section.unlockAfterDays}天后免费`}
</span>
)}
</div>
<h1 className="text-2xl md:text-3xl font-bold text-white leading-tight">{section.title}</h1>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-[#38bdac] border-t-transparent rounded-full animate-spin" />
</div>
) : canAccess ? (
<>
<article className="prose prose-invert prose-lg max-w-none">
<div className="text-gray-300 leading-relaxed whitespace-pre-line">
{content.split("\n").map((paragraph, index) => (
<p key={index} className="mb-4">
{paragraph}
</p>
))}
</div>
</article>
{/* Join Party Group CTA */}
<div className="mt-12 bg-gradient-to-r from-[#38bdac]/10 to-[#1a3a4a]/50 rounded-2xl p-6 border border-[#38bdac]/30">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-[#38bdac]/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-6 h-6 text-[#38bdac]" />
</div>
<div className="flex-1">
<h3 className="text-white font-semibold mb-1">?</h3>
<p className="text-gray-400 text-sm">6-9,Soul派对房分享真实案例</p>
</div>
<Button
onClick={() => setIsQRModalOpen(true)}
className="bg-[#38bdac] hover:bg-[#2da396] text-white whitespace-nowrap"
>
</Button>
</div>
</div>
</>
) : (
<div>
<article className="prose prose-invert prose-lg max-w-none relative">
<div className="text-gray-300 leading-relaxed whitespace-pre-line">
{previewContent.split("\n").map((paragraph, index) => (
<p key={index} className="mb-4">
{paragraph}
</p>
))}
</div>
<div className="absolute bottom-0 left-0 right-0 h-40 bg-gradient-to-t from-[#0a1628] to-transparent" />
</article>
{/* Purchase prompt */}
<div className="mt-8 bg-[#0f2137]/80 backdrop-blur-xl rounded-2xl p-8 border border-gray-700/50 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-800/50 flex items-center justify-center">
<Lock className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-xl font-semibold text-white mb-2"></h3>
<p className="text-gray-400 mb-6">
{isLoggedIn ? "购买本节或整本书以阅读完整内容" : "登录后购买即可阅读完整内容"}
</p>
{section.unlockAfterDays && (
<p className="text-orange-400 text-sm mb-4 flex items-center justify-center gap-1">
<Clock className="w-4 h-4" />
{section.unlockAfterDays}
</p>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
onClick={() => handlePurchaseClick("section")}
variant="outline"
className="border-gray-600 text-white hover:bg-gray-700/50 px-6 py-5"
>
¥{section.price}
</Button>
<Button
onClick={() => handlePurchaseClick("fullbook")}
className="bg-[#38bdac] hover:bg-[#2da396] text-white px-6 py-5"
>
¥{fullBookPrice.toFixed(1)}
<span className="ml-2 text-xs opacity-80">82%</span>
</Button>
</div>
<p className="text-gray-500 text-sm mt-4">
, <span className="text-[#38bdac]">{distributorShare}%</span>
</p>
</div>
{/* Join Party Group */}
<div className="mt-8 bg-gradient-to-r from-[#38bdac]/10 to-[#1a3a4a]/50 rounded-2xl p-6 border border-[#38bdac]/30">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-[#38bdac]/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-6 h-6 text-[#38bdac]" />
</div>
<div className="flex-1">
<h3 className="text-white font-semibold mb-1">?!</h3>
<p className="text-gray-400 text-sm">6-9,Soul派对房免费分享</p>
</div>
<Button
onClick={() => setIsQRModalOpen(true)}
className="bg-[#38bdac] hover:bg-[#2da396] text-white whitespace-nowrap"
>
</Button>
</div>
</div>
</div>
)}
{/* Navigation */}
<div className="mt-12 pt-8 border-t border-gray-800 flex justify-between">
<Link href="/chapters" className="text-gray-400 hover:text-white transition-colors">
</Link>
<button
onClick={handleShare}
className="text-[#38bdac] hover:text-[#4fd4c4] transition-colors flex items-center gap-2"
>
<Share2 className="w-4 h-4" />
¥{((section.price * distributorShare) / 100).toFixed(1)}
</button>
</div>
</main>
{/* Modals */}
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
<PaymentModal
isOpen={isPaymentOpen}
onClose={() => setIsPaymentOpen(false)}
type={paymentType}
sectionId={section.id}
sectionTitle={section.title}
amount={paymentType === "section" ? section.price : fullBookPrice}
onSuccess={() => window.location.reload()}
/>
<QRCodeModal isOpen={isQRModalOpen} onClose={() => setIsQRModalOpen(false)} />
</div>
)
}

View File

@@ -0,0 +1,135 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { ChevronRight, Lock, Unlock, BookOpen } from "lucide-react"
import { Part } from "@/lib/book-data"
interface ChaptersListProps {
parts: Part[]
specialSections?: {
preface?: { title: string }
epilogue?: { title: string }
}
}
export function ChaptersList({ parts, specialSections }: ChaptersListProps) {
const [expandedPart, setExpandedPart] = useState<string | null>(parts.length > 0 ? parts[0].id : null)
return (
<div className="space-y-8">
{/* Special sections - Preface */}
{specialSections?.preface && (
<div className="space-y-3">
<Link href={`/read/preface`} className="block group">
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-4 border border-transparent hover:border-[#38bdac]/30 transition-all flex items-center justify-between">
<div className="flex items-center gap-3">
<Unlock className="w-4 h-4 text-[#38bdac]" />
<span className="text-gray-300 group-hover:text-white transition-colors">
{specialSections.preface.title}
</span>
</div>
<span className="text-[#38bdac] text-sm"></span>
</div>
</Link>
</div>
)}
{/* Parts */}
<div className="space-y-4">
{parts.map((part) => (
<div
key={part.id}
className="bg-[#0f2137]/40 backdrop-blur-md rounded-xl border border-gray-800/50 overflow-hidden"
>
{/* Part header */}
<button
onClick={() => setExpandedPart(expandedPart === part.id ? null : part.id)}
className="w-full p-5 flex items-center justify-between hover:bg-[#0f2137]/60 transition-colors"
>
<div className="flex items-center gap-4">
<span className="text-[#38bdac] font-mono text-lg">{part.number}</span>
<div className="text-left">
<h2 className="text-white text-xl font-semibold">{part.title}</h2>
<p className="text-gray-500 text-sm">{part.subtitle}</p>
</div>
</div>
<ChevronRight
className={`w-5 h-5 text-gray-500 transition-transform ${
expandedPart === part.id ? "rotate-90" : ""
}`}
/>
</button>
{/* Chapters and sections */}
{expandedPart === part.id && (
<div className="border-t border-gray-800/50">
{part.chapters.map((chapter) => (
<div key={chapter.id} className="border-b border-gray-800/30 last:border-b-0">
{/* Chapter title */}
<div className="px-5 py-3 bg-[#0a1628]/50">
<h3 className="text-gray-400 text-sm font-medium flex items-center gap-2">
<BookOpen className="w-4 h-4" />
{chapter.title}
</h3>
</div>
{/* Sections */}
<div className="divide-y divide-gray-800/30">
{chapter.sections.map((section) => (
<Link
key={section.id}
href={`/read/${section.id}`}
className="block px-5 py-4 hover:bg-[#0f2137]/40 transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{section.isFree ? (
<Unlock className="w-4 h-4 text-[#38bdac]" />
) : (
<Lock className="w-4 h-4 text-gray-500" />
)}
<span className="text-gray-400 font-mono text-sm">{section.id}</span>
<span className="text-gray-300 group-hover:text-white transition-colors">
{section.title}
</span>
</div>
<div className="flex items-center gap-3">
{section.isFree ? (
<span className="text-[#38bdac] text-sm"></span>
) : (
<span className="text-gray-500 text-sm">¥{section.price}</span>
)}
<ChevronRight className="w-4 h-4 text-gray-600 group-hover:text-gray-400" />
</div>
</div>
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
{/* Special sections - Epilogue */}
{specialSections?.epilogue && (
<div className="mt-8 space-y-3">
<Link href={`/read/epilogue`} className="block group">
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-4 border border-transparent hover:border-[#38bdac]/30 transition-all flex items-center justify-between">
<div className="flex items-center gap-3">
<Unlock className="w-4 h-4 text-[#38bdac]" />
<span className="text-gray-300 group-hover:text-white transition-colors">
{specialSections.epilogue.title}
</span>
</div>
<span className="text-[#38bdac] text-sm"></span>
</div>
</Link>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,14 @@
"use client"
import { useEffect } from "react"
import { useStore } from "@/lib/store"
export function ConfigLoader() {
const { fetchSettings } = useStore()
useEffect(() => {
fetchSettings()
}, [fetchSettings])
return null
}

12
components/footer.tsx Normal file
View File

@@ -0,0 +1,12 @@
"use client"
export function Footer() {
return (
<footer className="py-6 px-4 border-t border-gray-800 pb-24">
<div className="max-w-xs mx-auto text-center">
<p className="text-white text-sm font-medium mb-1">SOUL的创业实验场</p>
<p className="text-gray-500 text-xs">© 2025 · 06:00-09:00</p>
</div>
</footer>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { usePathname } from "next/navigation"
import { BottomNav } from "@/components/bottom-nav"
import { ConfigLoader } from "@/components/config-loader"
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const isAdmin = pathname?.startsWith("/admin")
if (isAdmin) {
return (
<div className="min-h-screen bg-gray-100 text-gray-900 font-sans">
<ConfigLoader />
{children}
</div>
)
}
return (
<div className="mx-auto max-w-[430px] min-h-screen bg-[#0a1628] shadow-2xl relative font-sans antialiased">
<ConfigLoader />
{children}
<BottomNav />
</div>
)
}

View File

@@ -0,0 +1,225 @@
"use client"
import { useState } from "react"
import { X, Phone, User, Gift } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useStore } from "@/lib/store"
interface AuthModalProps {
isOpen: boolean
onClose: () => void
defaultTab?: "login" | "register"
}
export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalProps) {
const [tab, setTab] = useState<"login" | "register">(defaultTab)
const [phone, setPhone] = useState("")
const [code, setCode] = useState("")
const [nickname, setNickname] = useState("")
const [referralCode, setReferralCode] = useState("")
const [error, setError] = useState("")
const [codeSent, setCodeSent] = useState(false)
const { login, register } = useStore()
const handleSendCode = () => {
if (phone.length !== 11) {
setError("请输入正确的手机号")
return
}
// Simulate sending verification code
setCodeSent(true)
setError("")
alert("验证码已发送,测试验证码: 123456")
}
const handleLogin = async () => {
setError("")
const success = await login(phone, code)
if (success) {
onClose()
} else {
setError("验证码错误或用户不存在,请先注册")
}
}
const handleRegister = async () => {
setError("")
if (!nickname.trim()) {
setError("请输入昵称")
return
}
const success = await register(phone, nickname, referralCode || undefined)
if (success) {
onClose()
} else {
setError("该手机号已注册")
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Tabs */}
<div className="flex border-b border-gray-700/50">
<button
onClick={() => setTab("login")}
className={`flex-1 py-4 text-center transition-colors ${
tab === "login" ? "text-white border-b-2 border-[#38bdac]" : "text-gray-400 hover:text-white"
}`}
>
</button>
<button
onClick={() => setTab("register")}
className={`flex-1 py-4 text-center transition-colors ${
tab === "register" ? "text-white border-b-2 border-[#38bdac]" : "text-gray-400 hover:text-white"
}`}
>
</button>
</div>
{/* Content */}
<div className="p-6">
{tab === "login" ? (
<div className="space-y-4">
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="请输入手机号"
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
maxLength={11}
/>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="flex gap-3">
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入验证码"
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
maxLength={6}
/>
<Button
type="button"
variant="outline"
onClick={handleSendCode}
disabled={codeSent}
className="whitespace-nowrap border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
{codeSent ? "已发送" : "获取验证码"}
</Button>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<Button onClick={handleLogin} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5">
</Button>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="请输入手机号"
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
maxLength={11}
/>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="请输入昵称"
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
/>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2"></label>
<div className="flex gap-3">
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入验证码"
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
maxLength={6}
/>
<Button
type="button"
variant="outline"
onClick={handleSendCode}
disabled={codeSent}
className="whitespace-nowrap border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
{codeSent ? "已发送" : "获取验证码"}
</Button>
</div>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2"> ()</label>
<div className="relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
value={referralCode}
onChange={(e) => setReferralCode(e.target.value)}
placeholder="填写邀请码可获得优惠"
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
/>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<Button onClick={handleRegister} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5">
</Button>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { X, MessageCircle, Users, Music } from "lucide-react"
import { useStore } from "@/lib/store"
import { Button } from "@/components/ui/button"
import Image from "next/image"
import { useState, useEffect } from "react"
interface QRCodeModalProps {
isOpen: boolean
onClose: () => void
}
export function QRCodeModal({ isOpen, onClose }: QRCodeModalProps) {
const { settings, getLiveQRCodeUrl } = useStore()
const [isJoining, setIsJoining] = useState(false)
const [qrCodeUrl, setQrCodeUrl] = useState("/images/party-group-qr.png") // Default fallback
// Fetch config on mount
useEffect(() => {
const fetchConfig = async () => {
try {
const res = await fetch('/api/config')
const data = await res.json()
if (data.marketing?.partyGroup?.qrCode) {
setQrCodeUrl(data.marketing.partyGroup.qrCode)
}
} catch (e) {
console.error("Failed to load QR config", e)
}
}
fetchConfig()
}, [])
if (!isOpen) return null
const handleJoin = () => {
setIsJoining(true)
// 获取活码随机URL
const url = getLiveQRCodeUrl("party-group")
if (url) {
window.open(url, "_blank")
}
setTimeout(() => setIsJoining(false), 1000)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-md" onClick={onClose} />
{/* iOS Style Modal */}
<div className="relative w-full max-w-[320px] bg-[#1a1a1a] rounded-3xl border border-white/10 overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
{/* Header Background Effect */}
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-[#7000ff]/20 to-transparent pointer-events-none" />
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-white/50 hover:text-white transition-colors z-10 bg-black/20 rounded-full backdrop-blur-sm"
>
<X className="w-5 h-5" />
</button>
<div className="p-8 flex flex-col items-center text-center relative">
{/* Icon Badge */}
<div className="w-16 h-16 mb-6 rounded-full bg-gradient-to-tr from-[#7000ff] to-[#bd00ff] p-[2px] shadow-lg shadow-purple-500/20">
<div className="w-full h-full rounded-full bg-[#1a1a1a] flex items-center justify-center">
<Music className="w-8 h-8 text-[#bd00ff]" />
</div>
</div>
<h3 className="text-xl font-bold text-white mb-2 tracking-tight">
Soul
</h3>
<div className="flex items-center gap-2 mb-6">
<span className="px-2 py-0.5 rounded-full bg-white/10 text-[10px] text-white/70 border border-white/5">
Live
</span>
<p className="text-white/60 text-xs">
{settings.authorInfo?.liveTime || "06:00-09:00"} · {settings.authorInfo?.name || "卡若"}
</p>
</div>
{/* QR Code Container - Enhanced Visibility */}
<div className="bg-white p-3 rounded-2xl shadow-xl mb-6 transform transition-transform hover:scale-105 duration-300">
<div className="relative w-48 h-48">
<Image
src={qrCodeUrl}
alt="派对群二维码"
fill
className="object-contain"
/>
</div>
</div>
<p className="text-white/40 text-xs mb-6 px-4">
<br/>100
</p>
<Button
onClick={handleJoin}
disabled={isJoining}
className="w-full bg-gradient-to-r from-[#7000ff] to-[#bd00ff] hover:opacity-90 text-white font-medium rounded-xl h-12 shadow-lg shadow-purple-900/20 border-0 transition-all active:scale-95"
>
{isJoining ? (
<span className="flex items-center gap-2">
<Users className="w-4 h-4 animate-pulse" />
...
</span>
) : (
<span className="flex items-center gap-2">
<MessageCircle className="w-4 h-4" />
</span>
)}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,343 @@
"use client"
import type React from "react"
import { useState } from "react"
import { X, CheckCircle, Bitcoin, Globe, Copy, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useStore } from "@/lib/store"
const WechatIcon = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.406-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
</svg>
)
const AlipayIcon = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
<path d="M8.77 20.62l9.92-4.33c-.12-.33-.24-.66-.38-.99-.14-.33-.3-.66-.47-.99H8.08c-2.2 0-3.99-1.79-3.99-3.99V8.08c0-2.2 1.79-3.99 3.99-3.99h7.84c2.2 0 3.99 1.79 3.99 3.99v2.24h-8.66c-.55 0-1 .45-1 1s.45 1 1 1h10.66c-.18 1.73-.71 3.36-1.53 4.83l-2.76 1.2c-.74-1.69-1.74-3.24-2.93-4.6-.52-.59-1.11-1.13-1.76-1.59H4.09v4.24c0 2.2 1.79 3.99 3.99 3.99h.69v.23z" />
</svg>
)
type PaymentMethod = "wechat" | "alipay" | "usdt" | "paypal"
interface PaymentModalProps {
isOpen: boolean
onClose: () => void
type: "section" | "fullbook"
sectionId?: string
sectionTitle?: string
amount: number
onSuccess: () => void
}
export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, amount, onSuccess }: PaymentModalProps) {
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("wechat")
const [isProcessing, setIsProcessing] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [showPaymentDetails, setShowPaymentDetails] = useState(false)
const [copied, setCopied] = useState(false)
const { purchaseSection, purchaseFullBook, user, settings } = useStore()
const paymentConfig = settings?.paymentMethods || {
wechat: { enabled: true, qrCode: "", account: "" },
alipay: { enabled: true, qrCode: "", account: "" },
usdt: { enabled: true, network: "TRC20", address: "", exchangeRate: 7.2 },
paypal: { enabled: false, email: "", exchangeRate: 7.2 },
}
const usdtAmount = (amount / (paymentConfig.usdt.exchangeRate || 7.2)).toFixed(2)
const paypalAmount = (amount / (paymentConfig.paypal.exchangeRate || 7.2)).toFixed(2)
const handleCopyAddress = (address: string) => {
navigator.clipboard.writeText(address)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handlePayment = async () => {
if (paymentMethod === "usdt" || paymentMethod === "paypal" || paymentMethod === "wechat" || paymentMethod === "alipay") {
setShowPaymentDetails(true)
return
}
setIsProcessing(true)
await new Promise((resolve) => setTimeout(resolve, 1500))
let success = false
if (type === "section" && sectionId) {
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
} else if (type === "fullbook") {
success = await purchaseFullBook(paymentMethod)
}
setIsProcessing(false)
if (success) {
setIsSuccess(true)
setTimeout(() => {
onSuccess()
onClose()
setIsSuccess(false)
}, 1500)
}
}
const confirmCryptoPayment = async () => {
setIsProcessing(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
let success = false
if (type === "section" && sectionId) {
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
} else if (type === "fullbook") {
success = await purchaseFullBook(paymentMethod)
}
setIsProcessing(false)
setShowPaymentDetails(false)
if (success) {
setIsSuccess(true)
setTimeout(() => {
onSuccess()
onClose()
setIsSuccess(false)
}, 1500)
}
}
if (!isOpen) return null
const paymentMethods: {
id: PaymentMethod
name: string
icon: React.ReactNode
color: string
enabled: boolean
extra?: string
}[] = [
{
id: "wechat",
name: "微信支付",
icon: <WechatIcon />,
color: "bg-[#07C160]",
enabled: paymentConfig.wechat.enabled,
},
{
id: "alipay",
name: "支付宝",
icon: <AlipayIcon />,
color: "bg-[#1677FF]",
enabled: paymentConfig.alipay.enabled,
},
{
id: "usdt",
name: `USDT (${paymentConfig.usdt.network || "TRC20"})`,
icon: <Bitcoin className="w-5 h-5" />,
color: "bg-[#26A17B]",
enabled: paymentConfig.usdt.enabled,
extra: `$${usdtAmount}`,
},
{
id: "paypal",
name: "PayPal",
icon: <Globe className="w-5 h-5" />,
color: "bg-[#003087]",
enabled: paymentConfig.paypal.enabled,
extra: `$${paypalAmount}`,
},
]
const availableMethods = paymentMethods.filter((m) => m.enabled)
// Payment details view
if (showPaymentDetails) {
const isCrypto = paymentMethod === "usdt"
const isPayPal = paymentMethod === "paypal"
const isWechat = paymentMethod === "wechat"
const isAlipay = paymentMethod === "alipay"
let title = ""
let address = ""
let displayAmount = `¥${amount.toFixed(2)}`
let qrCodeUrl = ""
if (isCrypto) {
title = "USDT支付"
address = paymentConfig.usdt.address
displayAmount = `$${usdtAmount} USDT`
} else if (isPayPal) {
title = "PayPal支付"
address = paymentConfig.paypal.email
displayAmount = `$${paypalAmount} USD`
} else if (isWechat) {
title = "微信支付"
qrCodeUrl = paymentConfig.wechat.qrCode || "/images/wechat-pay.png"
} else if (isAlipay) {
title = "支付宝支付"
qrCodeUrl = paymentConfig.alipay.qrCode || "/images/alipay.png"
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
<button onClick={onClose} className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white z-10">
<X className="w-5 h-5" />
</button>
<div className="p-6">
<h3 className="text-lg font-semibold text-white mb-4 text-center">{title}</h3>
<div className="bg-[#0a1628] rounded-xl p-4 mb-4 text-center">
<p className="text-gray-400 text-sm mb-2"></p>
<p className="text-3xl font-bold text-[#38bdac]">{displayAmount}</p>
{(isCrypto || isPayPal) && <p className="text-gray-500 text-sm"> ¥{amount.toFixed(2)}</p>}
</div>
{(isWechat || isAlipay) ? (
<div className="flex flex-col items-center justify-center mb-6">
<div className="bg-white p-2 rounded-xl mb-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCodeUrl} alt={title} className="w-48 h-48 object-contain" />
</div>
<p className="text-gray-400 text-sm">使{title === "微信支付" ? "微信" : "支付宝"}</p>
</div>
) : (
<div className="bg-[#0a1628] rounded-xl p-4 mb-4">
<p className="text-gray-400 text-sm mb-2">
{isCrypto ? `收款地址 (${paymentConfig.usdt.network})` : "PayPal账户"}
</p>
<div className="flex items-center gap-2">
<p className="text-white text-sm break-all flex-1 font-mono">
{address || "请联系客服获取"}
</p>
{address && (
<button onClick={() => handleCopyAddress(address)} className="text-[#38bdac] hover:text-[#4fd4c4]">
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
</button>
)}
</div>
</div>
)}
<div className="bg-orange-500/10 border border-orange-500/30 rounded-xl p-4 mb-6">
<p className="text-orange-400 text-sm text-center">
"我已支付"<br/>
</p>
</div>
<div className="flex gap-3">
<Button
onClick={() => setShowPaymentDetails(false)}
variant="outline"
className="flex-1 border-gray-600 text-white hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={confirmCryptoPayment}
disabled={isProcessing}
className="flex-1 bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isProcessing ? "处理中..." : "已完成支付"}
</Button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
{isSuccess ? (
<div className="p-8 text-center">
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-[#38bdac]/20 flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-[#38bdac]" />
</div>
<h3 className="text-xl font-semibold text-white mb-2"></h3>
<p className="text-gray-400">{type === "fullbook" ? "您已解锁全部内容" : "您已解锁本节内容"}</p>
</div>
) : (
<>
<div className="p-6 border-b border-gray-700/50">
<h3 className="text-lg font-semibold text-white mb-1"></h3>
<p className="text-gray-400 text-sm">
{type === "fullbook" ? "购买整本书,解锁全部内容" : `购买: ${sectionTitle}`}
</p>
</div>
<div className="p-6 border-b border-gray-700/50 text-center">
<p className="text-gray-400 text-sm mb-1"></p>
<p className="text-4xl font-bold text-white">¥{amount.toFixed(2)}</p>
{(paymentMethod === "usdt" || paymentMethod === "paypal") && (
<p className="text-[#38bdac] text-sm mt-1">
${paymentMethod === "usdt" ? usdtAmount : paypalAmount} USD
</p>
)}
{user?.referredBy && (
<p className="text-[#38bdac] text-sm mt-2">
,{settings?.distributorShare || 90}%
</p>
)}
</div>
<div className="p-6 space-y-3">
<p className="text-gray-400 text-sm mb-3"></p>
{availableMethods.map((method) => (
<button
key={method.id}
onClick={() => setPaymentMethod(method.id)}
className={`w-full p-4 rounded-xl border flex items-center gap-4 transition-all ${
paymentMethod === method.id
? "border-[#38bdac] bg-[#38bdac]/10"
: "border-gray-700 hover:border-gray-600"
}`}
>
<div className={`w-10 h-10 rounded-lg ${method.color} flex items-center justify-center text-white`}>
{method.icon}
</div>
<div className="flex-1 text-left">
<span className="text-white">{method.name}</span>
{method.extra && <span className="text-gray-400 text-sm ml-2">{method.extra}</span>}
</div>
{paymentMethod === method.id && <CheckCircle className="w-5 h-5 text-[#38bdac]" />}
</button>
))}
{availableMethods.length === 0 && <p className="text-gray-500 text-center py-4"></p>}
</div>
<div className="p-6 pt-0">
<Button
onClick={handlePayment}
disabled={isProcessing || availableMethods.length === 0}
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-6 text-lg"
>
{isProcessing ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</div>
) : (
`确认支付 ¥${amount.toFixed(2)}`
)}
</Button>
<p className="text-gray-500 text-xs text-center mt-3"></p>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
interface PosterModalProps {
isOpen: boolean
onClose: () => void
referralLink: string
referralCode: string
nickname: string
}
export function PosterModal({ isOpen, onClose, referralLink, referralCode, nickname }: PosterModalProps) {
if (!isOpen) return null
// Use a public QR code API
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(referralLink)}`
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-sm bg-white rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<button
onClick={onClose}
className="absolute top-2 right-2 p-1.5 bg-black/20 rounded-full text-white hover:bg-black/40 z-10"
>
<X className="w-5 h-5" />
</button>
{/* Poster Content */}
<div className="bg-gradient-to-br from-indigo-900 to-purple-900 text-white p-6 flex flex-col items-center text-center relative overflow-hidden">
{/* Decorative circles */}
<div className="absolute top-0 left-0 w-32 h-32 bg-white/10 rounded-full -translate-x-1/2 -translate-y-1/2 blur-2xl" />
<div className="absolute bottom-0 right-0 w-40 h-40 bg-pink-500/20 rounded-full translate-x-1/3 translate-y-1/3 blur-2xl" />
<div className="relative z-10 w-full flex flex-col items-center">
{/* Book Title */}
<h2 className="text-xl font-bold mb-1 leading-tight text-white">SOUL的<br/></h2>
<p className="text-white/80 text-xs mb-6"> · 55 · </p>
{/* Cover Image Placeholder */}
<div className="w-32 h-44 bg-gray-200 rounded shadow-lg mb-6 overflow-hidden relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/images/image.png" alt="Book Cover" className="w-full h-full object-cover" />
</div>
{/* Recommender Info */}
<div className="flex items-center gap-2 mb-4 bg-white/10 px-3 py-1.5 rounded-full backdrop-blur-sm">
<span className="text-xs text-white">: {nickname}</span>
</div>
{/* QR Code Section */}
<div className="bg-white p-2 rounded-lg shadow-lg mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCodeUrl} alt="QR Code" className="w-32 h-32" />
</div>
<p className="text-[10px] text-white/60 mb-1"></p>
<p className="text-xs font-mono tracking-wider text-white">: {referralCode}</p>
</div>
</div>
{/* Footer Actions */}
<div className="p-4 bg-gray-50 flex flex-col gap-2">
<p className="text-center text-xs text-gray-500 mb-1">
</p>
<Button onClick={onClose} className="w-full" variant="outline">
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
"use client"
import { Share2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useStore } from "@/lib/store"
interface ReferralShareProps {
sectionTitle: string
fullBookPrice: number
distributorShare: number
}
export function ReferralShare({ sectionTitle, fullBookPrice, distributorShare }: ReferralShareProps) {
const { user } = useStore()
const handleShare = async () => {
const url = user?.referralCode ? `${window.location.href}?ref=${user.referralCode}` : window.location.href
const shareData = {
title: sectionTitle,
text: `来自Soul派对房的真实商业故事: ${sectionTitle}`,
url: url,
}
try {
if (navigator.share && navigator.canShare && navigator.canShare(shareData)) {
await navigator.share(shareData)
} else {
navigator.clipboard.writeText(url)
alert(
`链接已复制!分享后他人购买,你可获得${distributorShare}%返利 (¥${((fullBookPrice * distributorShare) / 100).toFixed(1)})`,
)
}
} catch (error) {
console.error("Error sharing:", error)
}
}
return (
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-white"
onClick={handleShare}
>
<Share2 className="w-5 h-5" />
</Button>
)
}

View File

@@ -0,0 +1,172 @@
"use client"
import { useState } from "react"
import { X, Wallet, CheckCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useStore } from "@/lib/store"
interface WithdrawalModalProps {
isOpen: boolean
onClose: () => void
availableAmount: number
}
export function WithdrawalModal({ isOpen, onClose, availableAmount }: WithdrawalModalProps) {
const { requestWithdrawal } = useStore()
const [amount, setAmount] = useState<string>("")
const [method, setMethod] = useState<"wechat" | "alipay">("wechat")
const [account, setAccount] = useState("")
const [name, setName] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const amountNum = parseFloat(amount)
if (isNaN(amountNum) || amountNum <= 0 || amountNum > availableAmount) {
alert("请输入有效的提现金额")
return
}
if (!account || !name) {
alert("请填写完整的提现信息")
return
}
setIsSubmitting(true)
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000))
requestWithdrawal(amountNum, method, account, name)
setIsSubmitting(false)
setIsSuccess(true)
}
const handleClose = () => {
setIsSuccess(false)
setAmount("")
setAccount("")
setName("")
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={handleClose} />
<div className="relative w-full max-w-sm bg-white rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<button
onClick={handleClose}
className="absolute top-2 right-2 p-1.5 bg-black/10 rounded-full text-gray-500 hover:bg-black/20 z-10"
>
<X className="w-5 h-5" />
</button>
{isSuccess ? (
<div className="p-8 flex flex-col items-center text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2"></h3>
<p className="text-sm text-gray-500 mb-6">
1-3
</p>
<Button onClick={handleClose} className="w-full bg-green-600 hover:bg-green-700 text-white">
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6">
<div className="flex items-center gap-2 mb-6">
<Wallet className="w-5 h-5 text-indigo-600" />
<h3 className="text-lg font-bold text-gray-900"></h3>
</div>
<div className="space-y-4 mb-6">
<div className="space-y-2">
<Label htmlFor="amount"> (: ¥{availableAmount.toFixed(2)})</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">¥</span>
<Input
id="amount"
type="number"
min="10"
max={availableAmount}
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="pl-7"
placeholder="最低10元"
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex gap-4">
<button
type="button"
onClick={() => setMethod("wechat")}
className={`flex-1 py-2 px-4 rounded-lg border text-sm font-medium transition-colors ${
method === "wechat"
? "border-green-600 bg-green-50 text-green-700"
: "border-gray-200 hover:bg-gray-50 text-gray-600"
}`}
>
</button>
<button
type="button"
onClick={() => setMethod("alipay")}
className={`flex-1 py-2 px-4 rounded-lg border text-sm font-medium transition-colors ${
method === "alipay"
? "border-blue-600 bg-blue-50 text-blue-700"
: "border-gray-200 hover:bg-gray-50 text-gray-600"
}`}
>
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="account">{method === "wechat" ? "微信号" : "支付宝账号"}</Label>
<Input
id="account"
value={account}
onChange={(e) => setAccount(e.target.value)}
placeholder={method === "wechat" ? "请输入微信号" : "请输入支付宝账号"}
/>
</div>
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入收款人真实姓名"
/>
</div>
</div>
<Button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white"
disabled={isSubmitting || !amount || !account || !name}
>
{isSubmitting ? "提交中..." : "确认提现"}
</Button>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import Image from "next/image"
import { useStore } from "@/lib/store"
export function PartyGroupSection() {
const { settings, getLiveQRCodeUrl } = useStore()
const handleJoin = () => {
const url = getLiveQRCodeUrl("party-group")
if (url) {
window.open(url, "_blank")
}
}
return (
<section className="py-8 px-4 bg-app-bg">
<div className="max-w-sm mx-auto">
<div className="bg-app-card/40 backdrop-blur-md rounded-xl p-5 border border-app-border text-center">
<p className="text-app-text-muted text-xs mb-3">
{settings.authorInfo?.liveTime || "06:00-09:00"} ·
</p>
{/* QR Code - smaller */}
<div className="bg-white rounded-lg p-2 mb-3 inline-block">
<Image src="/images/image.png" alt="派对群二维码" width={120} height={120} className="mx-auto" />
</div>
<p className="text-app-text text-sm font-medium mb-1"></p>
<p className="text-app-text-muted text-xs"></p>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,311 @@
"use client"
import type React from "react"
import { useState } from "react"
import { X, CheckCircle, Bitcoin, Globe, Copy, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useStore } from "@/lib/store"
const WechatIcon = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.406-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
</svg>
)
const AlipayIcon = () => (
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
<path d="M8.77 20.62l9.92-4.33c-.12-.33-.24-.66-.38-.99-.14-.33-.3-.66-.47-.99H8.08c-2.2 0-3.99-1.79-3.99-3.99V8.08c0-2.2 1.79-3.99 3.99-3.99h7.84c2.2 0 3.99 1.79 3.99 3.99v2.24h-8.66c-.55 0-1 .45-1 1s.45 1 1 1h10.66c-.18 1.73-.71 3.36-1.53 4.83l-2.76 1.2c-.74-1.69-1.74-3.24-2.93-4.6-.52-.59-1.11-1.13-1.76-1.59H4.09v4.24c0 2.2 1.79 3.99 3.99 3.99h.69v.23z" />
</svg>
)
type PaymentMethod = "wechat" | "alipay" | "usdt" | "paypal"
interface PaymentModalProps {
isOpen: boolean
onClose: () => void
type: "section" | "fullbook"
sectionId?: string
sectionTitle?: string
amount: number
onSuccess: () => void
}
export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, amount, onSuccess }: PaymentModalProps) {
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("wechat")
const [isProcessing, setIsProcessing] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [showPaymentDetails, setShowPaymentDetails] = useState(false)
const [copied, setCopied] = useState(false)
const { purchaseSection, purchaseFullBook, user, settings } = useStore()
const paymentConfig = settings?.paymentMethods || {
wechat: { enabled: true, qrCode: "", account: "" },
alipay: { enabled: true, qrCode: "", account: "" },
usdt: { enabled: true, network: "TRC20", address: "", exchangeRate: 7.2 },
paypal: { enabled: false, email: "", exchangeRate: 7.2 },
}
const usdtAmount = (amount / (paymentConfig.usdt.exchangeRate || 7.2)).toFixed(2)
const paypalAmount = (amount / (paymentConfig.paypal.exchangeRate || 7.2)).toFixed(2)
const handleCopyAddress = (address: string) => {
navigator.clipboard.writeText(address)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handlePayment = async () => {
if (paymentMethod === "usdt" || paymentMethod === "paypal") {
setShowPaymentDetails(true)
return
}
setIsProcessing(true)
await new Promise((resolve) => setTimeout(resolve, 1500))
let success = false
if (type === "section" && sectionId) {
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
} else if (type === "fullbook") {
success = await purchaseFullBook(paymentMethod)
}
setIsProcessing(false)
if (success) {
setIsSuccess(true)
setTimeout(() => {
onSuccess()
onClose()
setIsSuccess(false)
}, 1500)
}
}
const confirmCryptoPayment = async () => {
setIsProcessing(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
let success = false
if (type === "section" && sectionId) {
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
} else if (type === "fullbook") {
success = await purchaseFullBook(paymentMethod)
}
setIsProcessing(false)
setShowPaymentDetails(false)
if (success) {
setIsSuccess(true)
setTimeout(() => {
onSuccess()
onClose()
setIsSuccess(false)
}, 1500)
}
}
if (!isOpen) return null
const paymentMethods: {
id: PaymentMethod
name: string
icon: React.ReactNode
color: string
enabled: boolean
extra?: string
}[] = [
{
id: "wechat",
name: "微信支付",
icon: <WechatIcon />,
color: "bg-[#07C160]",
enabled: paymentConfig.wechat.enabled,
},
{
id: "alipay",
name: "支付宝",
icon: <AlipayIcon />,
color: "bg-[#1677FF]",
enabled: paymentConfig.alipay.enabled,
},
{
id: "usdt",
name: `USDT (${paymentConfig.usdt.network || "TRC20"})`,
icon: <Bitcoin className="w-5 h-5" />,
color: "bg-[#26A17B]",
enabled: paymentConfig.usdt.enabled,
extra: `$${usdtAmount}`,
},
{
id: "paypal",
name: "PayPal",
icon: <Globe className="w-5 h-5" />,
color: "bg-[#003087]",
enabled: paymentConfig.paypal.enabled,
extra: `$${paypalAmount}`,
},
]
const availableMethods = paymentMethods.filter((m) => m.enabled)
// Crypto payment details view
if (showPaymentDetails) {
const isCrypto = paymentMethod === "usdt"
const address = isCrypto ? paymentConfig.usdt.address : paymentConfig.paypal.email
const displayAmount = isCrypto ? `$${usdtAmount} USDT` : `$${paypalAmount} USD`
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
<button onClick={onClose} className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white">
<X className="w-5 h-5" />
</button>
<div className="p-6">
<h3 className="text-lg font-semibold text-white mb-4">{isCrypto ? "USDT支付" : "PayPal支付"}</h3>
<div className="bg-[#0a1628] rounded-xl p-4 mb-4">
<p className="text-gray-400 text-sm mb-2"></p>
<p className="text-2xl font-bold text-[#38bdac]">{displayAmount}</p>
<p className="text-gray-500 text-sm"> ¥{amount.toFixed(2)}</p>
</div>
<div className="bg-[#0a1628] rounded-xl p-4 mb-4">
<p className="text-gray-400 text-sm mb-2">
{isCrypto ? `收款地址 (${paymentConfig.usdt.network})` : "PayPal账户"}
</p>
<div className="flex items-center gap-2">
<p className="text-white text-sm break-all flex-1 font-mono">
{address || (isCrypto ? "请联系客服获取地址" : "请联系客服获取账户")}
</p>
{address && (
<button onClick={() => handleCopyAddress(address)} className="text-[#38bdac] hover:text-[#4fd4c4]">
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
</button>
)}
</div>
</div>
<div className="bg-orange-500/10 border border-orange-500/30 rounded-xl p-4 mb-6">
<p className="text-orange-400 text-sm">
"已完成支付",1-24
</p>
</div>
<div className="flex gap-3">
<Button
onClick={() => setShowPaymentDetails(false)}
variant="outline"
className="flex-1 border-gray-600 text-white hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={confirmCryptoPayment}
disabled={isProcessing}
className="flex-1 bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isProcessing ? "处理中..." : "已完成支付"}
</Button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
{isSuccess ? (
<div className="p-8 text-center">
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-[#38bdac]/20 flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-[#38bdac]" />
</div>
<h3 className="text-xl font-semibold text-white mb-2"></h3>
<p className="text-gray-400">{type === "fullbook" ? "您已解锁全部内容" : "您已解锁本节内容"}</p>
</div>
) : (
<>
<div className="p-6 border-b border-gray-700/50">
<h3 className="text-lg font-semibold text-white mb-1"></h3>
<p className="text-gray-400 text-sm">
{type === "fullbook" ? "购买整本书,解锁全部内容" : `购买: ${sectionTitle}`}
</p>
</div>
<div className="p-6 border-b border-gray-700/50 text-center">
<p className="text-gray-400 text-sm mb-1"></p>
<p className="text-4xl font-bold text-white">¥{amount.toFixed(2)}</p>
{(paymentMethod === "usdt" || paymentMethod === "paypal") && (
<p className="text-[#38bdac] text-sm mt-1">
${paymentMethod === "usdt" ? usdtAmount : paypalAmount} USD
</p>
)}
{user?.referredBy && (
<p className="text-[#38bdac] text-sm mt-2">
,{settings?.distributorShare || 90}%
</p>
)}
</div>
<div className="p-6 space-y-3">
<p className="text-gray-400 text-sm mb-3"></p>
{availableMethods.map((method) => (
<button
key={method.id}
onClick={() => setPaymentMethod(method.id)}
className={`w-full p-4 rounded-xl border flex items-center gap-4 transition-all ${
paymentMethod === method.id
? "border-[#38bdac] bg-[#38bdac]/10"
: "border-gray-700 hover:border-gray-600"
}`}
>
<div className={`w-10 h-10 rounded-lg ${method.color} flex items-center justify-center text-white`}>
{method.icon}
</div>
<div className="flex-1 text-left">
<span className="text-white">{method.name}</span>
{method.extra && <span className="text-gray-400 text-sm ml-2">{method.extra}</span>}
</div>
{paymentMethod === method.id && <CheckCircle className="w-5 h-5 text-[#38bdac]" />}
</button>
))}
{availableMethods.length === 0 && <p className="text-gray-500 text-center py-4"></p>}
</div>
<div className="p-6 pt-0">
<Button
onClick={handlePayment}
disabled={isProcessing || availableMethods.length === 0}
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-6 text-lg"
>
{isProcessing ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</div>
) : (
`确认支付 ¥${amount.toFixed(2)}`
)}
</Button>
<p className="text-gray-500 text-xs text-center mt-3"></p>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Zap, BookOpen } from "lucide-react"
import { getFullBookPrice, getAllSections } from "@/lib/book-data"
import { useStore } from "@/lib/store"
import { AuthModal } from "./modules/auth/auth-modal"
import { PaymentModal } from "./modules/payment/payment-modal"
export function PurchaseSection() {
const [fullBookPrice, setFullBookPrice] = useState(9.9)
const [sectionsCount, setSectionsCount] = useState(55)
const [isAuthOpen, setIsAuthOpen] = useState(false)
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
const { isLoggedIn } = useStore()
useEffect(() => {
const sections = getAllSections()
setSectionsCount(sections.length)
setFullBookPrice(getFullBookPrice(sections.length))
}, [])
const handlePurchase = () => {
if (!isLoggedIn) {
setIsAuthOpen(true)
return
}
setIsPaymentOpen(true)
}
return (
<section className="py-8 px-4 bg-app-bg">
<div className="max-w-sm mx-auto">
{/* Pricing cards - stacked on mobile */}
<div className="space-y-3">
{/* Single section */}
<div className="bg-app-card/60 backdrop-blur-xl rounded-xl p-4 border border-app-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BookOpen className="w-5 h-5 text-app-text-muted" />
<div>
<h3 className="text-app-text font-medium text-sm"></h3>
<p className="text-app-text-muted text-xs"></p>
</div>
</div>
<div className="text-right">
<span className="text-xl font-bold text-app-text">¥1</span>
<span className="text-app-text-muted text-xs">/</span>
</div>
</div>
</div>
{/* Full book - highlighted */}
<div className="bg-gradient-to-br from-app-brand/20 to-app-card backdrop-blur-xl rounded-xl p-4 border border-app-brand/30 relative">
<span className="absolute -top-2 right-3 bg-app-brand text-white text-xs px-2 py-0.5 rounded-full">
</span>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-app-brand" />
<div>
<h3 className="text-app-text font-medium text-sm"></h3>
<p className="text-app-text-muted text-xs">{sectionsCount} · </p>
</div>
</div>
<div className="text-right">
<span className="text-xl font-bold text-app-text">¥{fullBookPrice.toFixed(1)}</span>
</div>
</div>
<Button
onClick={handlePurchase}
className="w-full bg-app-brand hover:bg-app-brand-hover text-white rounded-lg h-10 text-sm"
>
</Button>
</div>
</div>
{/* Dynamic pricing note */}
<p className="mt-3 text-center text-app-text-muted text-xs">动态定价: 每新增一章节,+¥1</p>
</div>
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
<PaymentModal
isOpen={isPaymentOpen}
onClose={() => setIsPaymentOpen(false)}
type="fullbook"
amount={fullBookPrice}
onSuccess={() => window.location.reload()}
/>
</section>
)
}

View File

@@ -0,0 +1,78 @@
"use client"
import { useState } from "react"
import Image from "next/image"
import { X, MessageCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useStore } from "@/lib/store"
interface QRCodeModalProps {
isOpen: boolean
onClose: () => void
}
export function QRCodeModal({ isOpen, onClose }: QRCodeModalProps) {
const { settings, getLiveQRCodeUrl } = useStore()
const [isJoining, setIsJoining] = useState(false)
if (!isOpen) return null
const qrCodeImage = "/images/image.png"
const handleJoin = () => {
setIsJoining(true)
// 获取活码随机URL
const url = getLiveQRCodeUrl("party-group")
if (url) {
window.open(url, "_blank")
}
setTimeout(() => setIsJoining(false), 1000)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-sm bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors z-10"
>
<X className="w-5 h-5" />
</button>
<div className="p-6 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[#38bdac]/20 flex items-center justify-center">
<MessageCircle className="w-6 h-6 text-[#38bdac]" />
</div>
<h3 className="text-xl font-semibold text-white mb-2"></h3>
<p className="text-gray-400 text-sm mb-6">
{settings.authorInfo?.liveTime || "06:00-09:00"},{settings.authorInfo?.name || "卡若"}
</p>
<div className="bg-white rounded-xl p-4 mb-6">
<Image
src={qrCodeImage || "/placeholder.svg"}
alt="派对群二维码"
width={200}
height={200}
className="mx-auto"
/>
</div>
<p className="text-gray-500 text-sm mb-4">Soul派对群</p>
<Button
onClick={handleJoin}
disabled={isJoining}
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isJoining ? "跳转中..." : "立即加入"}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
"use client"
import Link from "next/link"
import { ChevronRight } from "lucide-react"
import { Part } from "@/lib/book-data"
interface TableOfContentsProps {
parts: Part[]
}
export function TableOfContents({ parts }: TableOfContentsProps) {
return (
<section className="py-16 px-4">
<div className="max-w-2xl mx-auto">
{/* Section title */}
<h2 className="text-gray-400 text-sm mb-8"> {parts.length} </h2>
{/* Parts list */}
<div className="space-y-6">
{parts.map((part) => (
<Link key={part.id} href={`/chapters?part=${part.id}`} className="block group">
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-6 border border-transparent hover:border-[#38bdac]/30 transition-all duration-300">
<div className="flex items-start justify-between">
<div className="flex gap-4">
<span className="text-[#38bdac] font-mono text-lg">{part.number}</span>
<div>
<h3 className="text-white text-xl font-semibold mb-1 group-hover:text-[#38bdac] transition-colors">
{part.title}
</h3>
<p className="text-gray-400">{part.subtitle}</p>
<p className="text-gray-500 text-sm mt-2">
{part.chapters.length} · {part.chapters.reduce((acc, c) => acc + c.sections.length, 0)}
</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-[#38bdac] transition-colors" />
</div>
</div>
</Link>
))}
</div>
{/* Additional content */}
<div className="mt-8 pt-8 border-t border-gray-700/50">
<div className="grid grid-cols-2 gap-4">
<Link href="/chapters?section=preface" className="block group">
<div className="bg-[#0f2137]/40 backdrop-blur-md rounded-xl p-4 border border-transparent hover:border-[#38bdac]/30 transition-all">
<p className="text-gray-400 text-sm"></p>
<p className="text-white group-hover:text-[#38bdac] transition-colors">
6Soul开播?
</p>
</div>
</Link>
<Link href="/chapters?section=epilogue" className="block group">
<div className="bg-[#0f2137]/40 backdrop-blur-md rounded-xl p-4 border border-transparent hover:border-[#38bdac]/30 transition-all">
<p className="text-gray-400 text-sm"></p>
<p className="text-white group-hover:text-[#38bdac] transition-colors">,</p>
</div>
</Link>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

60
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,60 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

76
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }

22
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

160
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

28
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

117
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

54
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

107
components/user-menu.tsx Normal file
View File

@@ -0,0 +1,107 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { User, LogOut, BookOpen, Gift, Settings } from "lucide-react"
import { useStore } from "@/lib/store"
import { AuthModal } from "./modules/auth/auth-modal"
export function UserMenu() {
const [isAuthOpen, setIsAuthOpen] = useState(false)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const { user, isLoggedIn, logout } = useStore()
if (!isLoggedIn || !user) {
return (
<>
<button
onClick={() => setIsAuthOpen(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#38bdac]/10 text-[#38bdac] hover:bg-[#38bdac]/20 transition-colors"
>
<User className="w-4 h-4" />
<span></span>
</button>
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
</>
)
}
return (
<div className="relative">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#0f2137] border border-gray-700 hover:border-[#38bdac]/50 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center">
<User className="w-4 h-4 text-[#38bdac]" />
</div>
<span className="text-white text-sm max-w-[100px] truncate">{user.nickname}</span>
</button>
{isMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsMenuOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-56 bg-[#0f2137] border border-gray-700 rounded-xl shadow-xl z-50 overflow-hidden">
{/* User info */}
<div className="p-4 border-b border-gray-700/50">
<p className="text-white font-medium">{user.nickname}</p>
<p className="text-gray-500 text-sm">{user.phone}</p>
{user.hasFullBook && (
<span className="inline-block mt-2 px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-xs rounded">
</span>
)}
</div>
{/* Menu items */}
<div className="py-2">
<Link
href="/my/purchases"
className="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-gray-800/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<BookOpen className="w-4 h-4" />
<span></span>
</Link>
<Link
href="/my/referral"
className="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-gray-800/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Gift className="w-4 h-4" />
<span></span>
{user.earnings > 0 && (
<span className="ml-auto text-[#38bdac] text-sm">¥{user.earnings.toFixed(2)}</span>
)}
</Link>
{user.isAdmin && (
<Link
href="/admin"
className="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-gray-800/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Settings className="w-4 h-4" />
<span></span>
</Link>
)}
</div>
{/* Logout */}
<div className="border-t border-gray-700/50 py-2">
<button
onClick={() => {
logout()
setIsMenuOpen(false)
}}
className="w-full flex items-center gap-3 px-4 py-3 text-red-400 hover:bg-gray-800/50 transition-colors"
>
<LogOut className="w-4 h-4" />
<span>退</span>
</button>
</div>
</div>
</>
)}
</div>
)
}