pricing-section.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. "use client"
  2. import { useState, useEffect } from "react"
  3. import { Button } from "@/components/ui/button"
  4. import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
  5. import { Badge } from "@/components/ui/badge"
  6. import { CheckIcon, StarIcon, Crown } from "lucide-react"
  7. import { useTranslations } from "next-intl"
  8. import { useSession } from "next-auth/react"
  9. import { useRouter } from "next/navigation"
  10. import { toast } from "@/hooks/use-toast"
  11. import { useAuth } from "./providers"
  12. import { useToast } from "@/components/ui/use-toast"
  13. interface PricingSectionProps {
  14. locale: string
  15. }
  16. interface UserInfo {
  17. id: string;
  18. email: string;
  19. username: string | null;
  20. isEmailVerified: boolean;
  21. credits: number;
  22. subscriptionCredits: number;
  23. subscriptionStatus: string | null;
  24. subscriptionPlan: string | null;
  25. subscriptionStartDate: string | null;
  26. subscriptionEndDate: string | null;
  27. }
  28. export default function PricingSection({ locale }: PricingSectionProps) {
  29. const t = useTranslations("pricing")
  30. const { data: session, status } = useSession()
  31. const router = useRouter()
  32. const [isSubscribing, setIsSubscribing] = useState(false)
  33. const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
  34. const [isLoadingUser, setIsLoadingUser] = useState(false)
  35. const [isLoading, setIsLoading] = useState(false)
  36. const { user, isLoading: isLoadingAuth, refreshUser } = useAuth()
  37. const { toast } = useToast()
  38. // 获取用户信息包括订阅状态
  39. useEffect(() => {
  40. const fetchUserInfo = async () => {
  41. // 等待会话状态确定后再执行
  42. if (status === 'loading') {
  43. return
  44. }
  45. if (!session) {
  46. setUserInfo(null)
  47. return
  48. }
  49. setIsLoadingUser(true)
  50. try {
  51. const response = await fetch('/api/auth/me', {
  52. method: 'GET',
  53. credentials: 'include',
  54. })
  55. if (response.ok) {
  56. const userData = await response.json()
  57. setUserInfo(userData.user)
  58. } else {
  59. setUserInfo(null)
  60. }
  61. } catch (error) {
  62. console.error('获取用户信息失败:', error)
  63. setUserInfo(null)
  64. } finally {
  65. setIsLoadingUser(false)
  66. }
  67. }
  68. fetchUserInfo()
  69. }, [session, status])
  70. // 监听全局认证状态变化事件
  71. useEffect(() => {
  72. const handleAuthUpdate = (event: CustomEvent) => {
  73. console.log('Pricing component: 收到认证状态更新事件', event.detail);
  74. // 当认证状态变化时,重新获取用户信息
  75. if (event.detail.status !== 'loading') {
  76. const fetchUserInfo = async () => {
  77. if (!event.detail.session) {
  78. setUserInfo(null)
  79. return
  80. }
  81. setIsLoadingUser(true)
  82. try {
  83. const response = await fetch('/api/auth/me', {
  84. method: 'GET',
  85. credentials: 'include',
  86. })
  87. if (response.ok) {
  88. const userData = await response.json()
  89. setUserInfo(userData.user)
  90. } else {
  91. setUserInfo(null)
  92. }
  93. } catch (error) {
  94. console.error('获取用户信息失败:', error)
  95. setUserInfo(null)
  96. } finally {
  97. setIsLoadingUser(false)
  98. }
  99. }
  100. fetchUserInfo()
  101. }
  102. }
  103. window.addEventListener('authStatusChanged', handleAuthUpdate as EventListener)
  104. return () => {
  105. window.removeEventListener('authStatusChanged', handleAuthUpdate as EventListener)
  106. }
  107. }, [])
  108. // 强制刷新用户信息的辅助函数
  109. const forceRefreshUserInfo = async () => {
  110. if (status === 'loading') return null;
  111. try {
  112. const response = await fetch('/api/auth/me', {
  113. method: 'GET',
  114. credentials: 'include',
  115. })
  116. if (response.ok) {
  117. const userData = await response.json()
  118. setUserInfo(userData.user)
  119. return userData.user
  120. }
  121. return null
  122. } catch (error) {
  123. console.error('刷新用户信息失败:', error)
  124. return null
  125. }
  126. }
  127. const handleSubscribe = async (priceId: string) => {
  128. if (isLoadingAuth) {
  129. toast({
  130. title: t('checkingAuth'),
  131. description: t('pleaseWait'),
  132. variant: 'default',
  133. })
  134. return
  135. }
  136. if (!user) {
  137. toast({
  138. title: t('loginRequired'),
  139. description: t('loginRequiredDesc'),
  140. variant: 'destructive',
  141. })
  142. router.push(`/${locale}/auth/login`)
  143. return
  144. }
  145. setIsLoading(true)
  146. try {
  147. const response = await fetch('/api/create-checkout-session', {
  148. method: 'POST',
  149. headers: {
  150. 'Content-Type': 'application/json',
  151. },
  152. body: JSON.stringify({
  153. priceId,
  154. locale,
  155. }),
  156. })
  157. const data = await response.json()
  158. if (data.url) {
  159. window.location.href = data.url
  160. } else {
  161. const errorMessage = data.error === 'alreadySubscribed'
  162. ? t('alreadySubscribed')
  163. : data.error || t('createSessionFailed');
  164. toast({
  165. title: t('subscriptionFailed'),
  166. description: errorMessage,
  167. variant: 'destructive',
  168. });
  169. console.log('Checkout session data:', data);
  170. // 如果已经订阅,刷新用户信息
  171. if (data.error === 'alreadySubscribed') {
  172. await refreshUser();
  173. }
  174. }
  175. } catch (error) {
  176. console.error('创建订阅会话失败:', error)
  177. toast({
  178. title: t('subscriptionFailed'),
  179. description: t('networkError'),
  180. variant: 'destructive',
  181. })
  182. } finally {
  183. setIsLoading(false)
  184. }
  185. }
  186. const handleFreeSignUp = () => {
  187. // 跳转到demo页面
  188. router.push(`/${locale}#demo`);
  189. };
  190. const handleContactSales = () => {
  191. // 新页面打开联系方式页面
  192. window.open(`/${locale}/blog/contact-us`, '_blank');
  193. };
  194. const plans = [
  195. {
  196. name: t("free.name"),
  197. price: t("free.price"),
  198. originalPrice: null,
  199. description: t("free.description"),
  200. features: [t("free.feature1"), t("free.feature2"), t("free.feature3")],
  201. cta: t("free.cta"),
  202. popular: false,
  203. type: "free"
  204. },
  205. {
  206. name: t("pro.name"),
  207. price: t("pro.price"),
  208. originalPrice: t("pro.originalPrice"),
  209. description: t("pro.description"),
  210. features: [t("pro.feature1"), t("pro.feature2"), t("pro.feature3"), t("pro.feature4")],
  211. cta: t("subscribe"),
  212. popular: true,
  213. type: "pro"
  214. },
  215. {
  216. name: t("enterprise.name"),
  217. price: t("enterprise.price"),
  218. originalPrice: null,
  219. description: t("enterprise.description"),
  220. features: [t("enterprise.feature1"), t("enterprise.feature3")],
  221. cta: t("enterprise.cta"),
  222. popular: false,
  223. type: "enterprise"
  224. },
  225. ]
  226. const handleButtonClick = (planType: string) => {
  227. switch (planType) {
  228. case "free":
  229. handleFreeSignUp();
  230. break;
  231. case "pro":
  232. handleSubscribe(planType);
  233. break;
  234. case "enterprise":
  235. handleContactSales();
  236. break;
  237. }
  238. };
  239. return (
  240. <section id="pricing" className="py-20 bg-muted/30">
  241. <div className="container mx-auto px-4 sm:px-6 lg:px-8">
  242. <div className="text-center mb-16">
  243. <h2 className="text-3xl md:text-4xl font-bold mb-4">{t("title")}</h2>
  244. <p className="text-xl text-muted-foreground max-w-3xl mx-auto">{t("subtitle")}</p>
  245. </div>
  246. <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
  247. {plans.map((plan, index) => (
  248. <Card
  249. key={index}
  250. className={`relative ${
  251. plan.popular ? "border-primary shadow-lg scale-105 bg-background" : "bg-background/60 backdrop-blur-sm"
  252. }`}
  253. >
  254. {plan.popular && (
  255. <div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
  256. <Badge className="bg-gradient-to-r from-purple-600 to-blue-600 text-white px-4 py-1">
  257. <StarIcon className="w-4 h-4 mr-1" />
  258. {t("mostPopular")}
  259. </Badge>
  260. </div>
  261. )}
  262. <CardHeader className="text-center pb-8">
  263. <CardTitle className="text-2xl font-bold">{plan.name}</CardTitle>
  264. <div className="mt-4">
  265. {plan.originalPrice && (
  266. <div className="flex items-center justify-center gap-2 mb-2">
  267. <span className="text-lg text-muted-foreground line-through">{plan.originalPrice}</span>
  268. <Badge variant="destructive" className="text-xs">{t("saveAmount")}</Badge>
  269. </div>
  270. )}
  271. <span className="text-4xl font-bold">{plan.price}</span>
  272. {plan.price !== t("enterprise.price") && <span className="text-muted-foreground">{t('perMonth')}</span>}
  273. </div>
  274. <p className="text-muted-foreground mt-2">{plan.description}</p>
  275. </CardHeader>
  276. <CardContent className="space-y-6">
  277. <ul className="space-y-3">
  278. {plan.features.map((feature, featureIndex) => (
  279. <li key={featureIndex} className="flex items-start">
  280. <CheckIcon className="w-5 h-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" />
  281. <span className="text-sm">{feature}</span>
  282. </li>
  283. ))}
  284. </ul>
  285. <Button
  286. className={`w-full ${
  287. plan.popular
  288. ? "bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white"
  289. : ""
  290. } ${
  291. plan.type === "pro" && userInfo?.subscriptionStatus === 'active'
  292. ? "bg-gray-400 hover:bg-gray-500 cursor-not-allowed"
  293. : ""
  294. }`}
  295. variant={plan.popular ? "default" : "outline"}
  296. size="lg"
  297. onClick={() => handleButtonClick(plan.type)}
  298. disabled={
  299. (plan.type === "pro" && isSubscribing) ||
  300. (plan.type === "pro" && userInfo?.subscriptionStatus === 'active')
  301. }
  302. >
  303. {plan.type === "pro" && isSubscribing ? (
  304. <>
  305. <Crown className="w-4 h-4 mr-2 animate-spin" />
  306. {t('processing')}
  307. </>
  308. ) : plan.type === "pro" && userInfo?.subscriptionStatus === 'active' ? (
  309. <>
  310. <Crown className="w-4 h-4 mr-2" />
  311. {t("subscribedPro")}
  312. </>
  313. ) : (
  314. <>
  315. {plan.type === "pro" && <Crown className="w-4 h-4 mr-2" />}
  316. {plan.cta}
  317. </>
  318. )}
  319. </Button>
  320. </CardContent>
  321. </Card>
  322. ))}
  323. </div>
  324. </div>
  325. </section>
  326. )
  327. }