chore: 恢复上传 components 目录到 GitHub
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,4 @@
|
||||
node_modules/
|
||||
components/
|
||||
.next/
|
||||
.env.local
|
||||
.DS_Store
|
||||
|
||||
225
components/auth-modal.tsx
Normal file
225
components/auth-modal.tsx
Normal 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
111
components/book-cover.tsx
Normal 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
34
components/book-intro.tsx
Normal 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
62
components/bottom-nav.tsx
Normal 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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
56
components/buy-full-book-button.tsx
Normal file
56
components/buy-full-book-button.tsx
Normal 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()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
290
components/chapter-content.tsx
Normal file
290
components/chapter-content.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
135
components/chapters-list.tsx
Normal file
135
components/chapters-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
components/config-loader.tsx
Normal file
14
components/config-loader.tsx
Normal 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
12
components/footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
components/layout-wrapper.tsx
Normal file
27
components/layout-wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
225
components/modules/auth/auth-modal.tsx
Normal file
225
components/modules/auth/auth-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
components/modules/marketing/qr-code-modal.tsx
Normal file
123
components/modules/marketing/qr-code-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
343
components/modules/payment/payment-modal.tsx
Normal file
343
components/modules/payment/payment-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
components/modules/referral/poster-modal.tsx
Normal file
76
components/modules/referral/poster-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
components/modules/referral/referral-share.tsx
Normal file
48
components/modules/referral/referral-share.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
components/modules/referral/withdrawal-modal.tsx
Normal file
172
components/modules/referral/withdrawal-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
components/party-group-section.tsx
Normal file
35
components/party-group-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
311
components/payment-modal.tsx
Normal file
311
components/payment-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
components/purchase-section.tsx
Normal file
96
components/purchase-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
components/qr-code-modal.tsx
Normal file
78
components/qr-code-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
components/table-of-contents.tsx
Normal file
65
components/table-of-contents.tsx
Normal 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">
|
||||
为什么我每天早上6点在Soul开播?
|
||||
</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>
|
||||
)
|
||||
}
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal 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
60
components/ui/button.tsx
Normal 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
76
components/ui/card.tsx
Normal 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
21
components/ui/input.tsx
Normal 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
22
components/ui/label.tsx
Normal 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
160
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
26
components/ui/separator.tsx
Normal file
26
components/ui/separator.tsx
Normal 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 }
|
||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal 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
28
components/ui/switch.tsx
Normal 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
117
components/ui/table.tsx
Normal 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
54
components/ui/tabs.tsx
Normal 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 }
|
||||
20
components/ui/textarea.tsx
Normal file
20
components/ui/textarea.tsx
Normal 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
107
components/user-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user