feat: optimize Stripe subscription management UI (#4793)

* feat: optimize Stripe subscription management UI

- Replace static billing portal link with dynamic Stripe Billing Portal Session API
- Allow canceled subscriptions to display status and expiration date
- Show subscription status indicator with color coding (green/blue/yellow)
- Move status info above manage button for better layout
- Update UI for both desktop and mobile platforms
- Add translations for subscription status messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix: use authenticated API client for billing portal on mobile

Replace raw fetch with followClient.request() to properly inject
authentication cookie and required headers on React Native.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
DIYgod 2026-01-01 19:15:34 +08:00 committed by GitHub
parent f39bcb97d8
commit a86b2c6383
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 265 additions and 33 deletions

View File

@ -97,15 +97,47 @@ const useActiveSubscription = () => {
queryKey: ["activeSubscription"],
queryFn: async () => {
const { data } = await subscription.list()
// We used to allow one time purchases, so we need to check for active subscriptions
return data?.find(
(sub) => (sub.status === "active" || sub.status === "trialing") && sub.stripeSubscriptionId,
)
// Find subscription: active, trialing, or canceled with valid period end
return data?.find((sub) => {
if (!sub.stripeSubscriptionId) return false
// Active or trialing subscriptions
if (sub.status === "active" || sub.status === "trialing") {
return true
}
// Canceled subscriptions that haven't expired yet
if (sub.status === "canceled" && sub.periodEnd) {
return new Date(sub.periodEnd) > new Date()
}
return false
})
},
enabled: !!userId,
})
}
const useBillingPortal = () => {
return useMutation({
mutationFn: async () => {
const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href
const res = await fetch(`${env.VITE_API_URL}/billing/portal`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ returnUrl }),
})
const data = await res.json()
if (data.code === 0 && data.data?.url) {
window.open(data.data.url, "_blank")
}
},
})
}
export function SettingPlan() {
const isPaymentEnabled = useIsPaymentEnabled()
const role = useUserRole()
@ -361,10 +393,14 @@ const PlanAction = ({
onSelect?: () => void
isLoading?: boolean
}) => {
const { t } = useTranslation("settings")
const { data: activeSubscription } = useActiveSubscription()
const serverConfig = useServerConfigs()
const stripePortalLink = serverConfig?.STRIPE_PORTAL_LINK
const canManageSubscription = !!activeSubscription && !!stripePortalLink
const billingPortalMutation = useBillingPortal()
const canManageSubscription = !!activeSubscription?.stripeSubscriptionId
// Determine subscription status info
const isCanceled = activeSubscription?.status === "canceled"
const periodEnd = activeSubscription?.periodEnd ? new Date(activeSubscription.periodEnd) : null
const getButtonConfig = () => {
switch (actionType) {
@ -378,7 +414,7 @@ const PlanAction = ({
}
case "current": {
return {
text: canManageSubscription ? "Manage Subscription" : "Current Plan",
text: canManageSubscription ? t("plan.manage_subscription") : t("plan.current_plan"),
icon: undefined,
variant: "outline" as const,
className: !canManageSubscription ? "text-text-secondary" : undefined,
@ -433,25 +469,67 @@ const PlanAction = ({
}
return (
<Button
variant={buttonConfig.variant}
buttonClassName={cn(
"w-full h-9 text-sm font-medium transition-all duration-200",
buttonConfig.className,
<div className="flex flex-col gap-1.5">
{/* Subscription status info for current plan */}
{actionType === "current" && canManageSubscription && (
<div className="flex items-center justify-center gap-1.5 text-xs text-text-secondary">
<span
className={cn(
"inline-block size-1.5 rounded-full",
isCanceled
? "bg-yellow"
: activeSubscription?.status === "trialing"
? "bg-blue"
: "bg-green",
)}
/>
<span>
{isCanceled
? t("plan.canceled_expires", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: activeSubscription?.status === "trialing"
? t("plan.trial_ends", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: t("plan.renews", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</span>
</div>
)}
disabled={buttonConfig.disabled}
onClick={
actionType === "current" && canManageSubscription
? () => window.open(stripePortalLink, "_blank")
: onSelect
}
isLoading={isLoading}
>
<span className="flex items-center justify-center gap-1.5">
{buttonConfig.icon && <i className={cn(buttonConfig.icon, "text-sm")} />}
<span>{buttonConfig.text}</span>
</span>
</Button>
<Button
variant={buttonConfig.variant}
buttonClassName={cn(
"w-full h-9 text-sm font-medium transition-all duration-200",
buttonConfig.className,
)}
disabled={buttonConfig.disabled}
onClick={
actionType === "current" && canManageSubscription
? () => billingPortalMutation.mutate()
: onSelect
}
isLoading={isLoading || billingPortalMutation.isPending}
>
<span className="flex items-center justify-center gap-1.5">
{buttonConfig.icon && <i className={cn(buttonConfig.icon, "text-sm")} />}
<span>{buttonConfig.text}</span>
</span>
</Button>
</div>
)
}

View File

@ -87,5 +87,5 @@
"vite-tsconfig-paths": "5.1.4"
},
"productName": "Folo",
"mainHash": "1c627eb0ea5a109a4fc2d7ac5a78777df546d831f6cced620b8120d41dc4e444"
"mainHash": "56f9664d0989dd416832d0e94f479340623da3641e1dcef5fb33311f21e9617f"
}

View File

@ -1,8 +1,8 @@
import { UserRole, UserRoleName } from "@follow/constants"
import { useRoleEndAt, useUserRole } from "@follow/store/user/hooks"
import { useRoleEndAt, useUserRole, useWhoami } from "@follow/store/user/hooks"
import { cn } from "@follow/utils"
import type { StatusConfigs } from "@follow-app/client-sdk"
import { useMutation } from "@tanstack/react-query"
import { useMutation, useQuery } from "@tanstack/react-query"
import dayjs from "dayjs"
import { openURL } from "expo-linking"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
@ -17,6 +17,7 @@ import {
} from "@/src/components/layouts/views/SafeNavigationScrollView"
import { Text } from "@/src/components/ui/typography/Text"
import { CheckLineIcon } from "@/src/icons/check_line"
import { followClient } from "@/src/lib/api-client"
import { authClient } from "@/src/lib/auth"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { proxyEnv } from "@/src/lib/proxy-env"
@ -183,6 +184,48 @@ export const PlanScreen: NavigationControllerView = () => {
},
})
const userId = useWhoami()?.id
const activeSubscriptionQuery = useQuery({
queryKey: ["activeSubscription"],
queryFn: async () => {
const response = await authClient.subscription.list()
const { data } = response
return data?.find(
(sub: {
status: string
stripeSubscriptionId?: string | null
periodEnd?: Date | null
}) => {
if (!sub.stripeSubscriptionId) return false
if (sub.status === "active" || sub.status === "trialing") return true
if (sub.status === "canceled" && sub.periodEnd) {
return new Date(sub.periodEnd) > new Date()
}
return false
},
)
},
enabled: !!userId,
})
const billingPortalMutation = useMutation({
mutationFn: async () => {
const data = await followClient.request<{ code: number; data?: { url: string } }>(
"/billing/portal",
{
method: "POST",
body: { returnUrl: proxyEnv.WEB_URL },
},
)
if (data.code === 0 && data.data?.url) {
await openURL(data.data.url)
}
},
onError: () => {
toast.error(t("subscription.actions.manage_error"))
},
})
if (!isPaymentEnabled || sortedPlans.length === 0) {
return (
<SafeNavigationScrollView
@ -255,7 +298,10 @@ export const PlanScreen: NavigationControllerView = () => {
})
: undefined
}
onManageSubscription={() => billingPortalMutation.mutate()}
isProcessing={isProcessing}
isManaging={billingPortalMutation.isPending}
activeSubscription={activeSubscriptionQuery.data}
/>
)
})}
@ -390,18 +436,30 @@ const BillingToggle = ({
)
}
type ActiveSubscription = {
status: string
stripeSubscriptionId?: string | null
periodEnd?: Date | null
}
const PlanCard = ({
plan,
billingPeriod,
isCurrentPlan,
onUpgrade,
onManageSubscription,
isProcessing,
isManaging,
activeSubscription,
}: {
plan: PaymentPlan
billingPeriod: BillingPeriod
isCurrentPlan: boolean
onUpgrade?: () => void
onManageSubscription?: () => void
isProcessing?: boolean
isManaging?: boolean
activeSubscription?: ActiveSubscription
}) => {
const { t } = useTranslation("settings")
@ -568,7 +626,14 @@ const PlanCard = ({
})}
</View>
<PlanAction actionType={actionType} onUpgrade={onUpgrade} isProcessing={isProcessing} />
<PlanAction
actionType={actionType}
onUpgrade={onUpgrade}
onManageSubscription={onManageSubscription}
isProcessing={isProcessing}
isManaging={isManaging}
activeSubscription={activeSubscription}
/>
</View>
)
}
@ -576,14 +641,24 @@ const PlanCard = ({
const PlanAction = ({
actionType,
onUpgrade,
onManageSubscription,
isProcessing,
isManaging,
activeSubscription,
}: {
actionType: "current" | "upgrade" | "coming-soon" | null
onUpgrade?: () => void
onManageSubscription?: () => void
isProcessing?: boolean
isManaging?: boolean
activeSubscription?: ActiveSubscription
}) => {
const { t } = useTranslation("settings")
const canManageSubscription = !!activeSubscription?.stripeSubscriptionId
const isCanceled = activeSubscription?.status === "canceled"
const periodEnd = activeSubscription?.periodEnd ? new Date(activeSubscription.periodEnd) : null
if (actionType === "coming-soon") {
return (
<Text className="mt-5 rounded-full border border-opaque-separator/60 px-4 py-2 text-center text-sm text-secondary-label">
@ -594,9 +669,64 @@ const PlanAction = ({
if (actionType === "current") {
return (
<Text className="mt-5 rounded-full border border-opaque-separator/60 px-4 py-2 text-center text-sm text-secondary-label">
{t("subscription.actions.current")}
</Text>
<View className="mt-5 gap-1.5">
{canManageSubscription && (
<View className="flex-row items-center justify-center gap-1.5">
<View
className={cn(
"size-1.5 rounded-full",
isCanceled
? "bg-yellow"
: activeSubscription?.status === "trialing"
? "bg-blue"
: "bg-green",
)}
/>
<Text className="text-xs text-secondary-label">
{isCanceled
? t("plan.canceled_expires", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: activeSubscription?.status === "trialing"
? t("plan.trial_ends", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: t("plan.renews", {
date: periodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})}
</Text>
</View>
)}
<Pressable
accessibilityRole="button"
onPress={onManageSubscription}
disabled={!canManageSubscription || isManaging}
className={cn(
"h-11 items-center justify-center rounded-full border border-opaque-separator/60",
!canManageSubscription && "opacity-50",
)}
>
{isManaging ? (
<ActivityIndicator />
) : (
<Text className="text-base font-medium text-label">
{canManageSubscription ? t("plan.manage_subscription") : t("plan.current_plan")}
</Text>
)}
</Pressable>
</View>
)
}

View File

@ -560,6 +560,8 @@
"notifications.test": "Test Notification",
"notifications.test_success": "Test notification sent successfully.",
"notifications.token": "Client Token",
"plan.canceled_expires": "Canceled - Expires {{date}}",
"plan.current_plan": "Current Plan",
"plan.descriptions.basic": "More feeds without AI features.",
"plan.descriptions.free": "Great for beginners.",
"plan.descriptions.plus": "Unlock AI features and more feeds.",
@ -586,6 +588,9 @@
"plan.features.PRIORITY_SUPPORT": "Priority Support",
"plan.features.PRIVATE_SUBSCRIPTION": "Private Subscriptions",
"plan.features.SECURE_IMAGE_PROXY": "Secure Image Proxy",
"plan.manage_subscription": "Manage Subscription",
"plan.renews": "Renews {{date}}",
"plan.trial_ends": "Trial ends {{date}}",
"privacy.privacy": "Privacy",
"privacy.terms": "Terms",
"profile.avatar.cropInstructions": "Drag the crop area to adjust your avatar",
@ -698,6 +703,7 @@
"rsshub.useModal.useWith": "Use with {{amount}} <Power />",
"subscription.actions.comingSoon": "Coming soon",
"subscription.actions.current": "Current plan",
"subscription.actions.manage_error": "Something went wrong while opening subscription management.",
"subscription.actions.upgrade": "Upgrade",
"subscription.actions.upgrade_error": "Something went wrong while starting checkout.",
"subscription.badge.popular": "Most popular",

View File

@ -556,6 +556,8 @@
"notifications.test": "テスト通知",
"notifications.test_success": "テスト通知が正常に送信されました。",
"notifications.token": "クライアントトークン",
"plan.canceled_expires": "キャンセル済み - {{date}}に終了",
"plan.current_plan": "現在のプラン",
"plan.descriptions.basic": "AI機能なしでより多くのフィードを利用できます。",
"plan.descriptions.free": "初心者に最適です。",
"plan.descriptions.plus": "AI機能とより多くのフィードをアンロック。",
@ -581,6 +583,9 @@
"plan.features.PRIORITY_SUPPORT": "優先サポート",
"plan.features.PRIVATE_SUBSCRIPTION": "プライベートサブスクリプション",
"plan.features.SECURE_IMAGE_PROXY": "セキュア画像プロキシ",
"plan.manage_subscription": "サブスクリプションを管理",
"plan.renews": "更新日 {{date}}",
"plan.trial_ends": "トライアル終了日 {{date}}",
"privacy.privacy": "プライバシー",
"privacy.terms": "利用規約",
"profile.avatar.cropInstructions": "ドラッグしてトリミングエリアを調整します",
@ -693,6 +698,7 @@
"rsshub.useModal.useWith": "使用する {{amount}} <Power />",
"subscription.actions.comingSoon": "近日公開",
"subscription.actions.current": "現在のプラン",
"subscription.actions.manage_error": "サブスクリプション管理を開けませんでした。",
"subscription.actions.upgrade": "アップグレード",
"subscription.actions.upgrade_error": "チェックアウトを開始できませんでした。",
"subscription.badge.popular": "人気No.1",

View File

@ -555,6 +555,8 @@
"notifications.test": "测试通知",
"notifications.test_success": "测试通知发送成功。",
"notifications.token": "客户端令牌",
"plan.canceled_expires": "已取消 - {{date}} 过期",
"plan.current_plan": "当前方案",
"plan.descriptions.basic": "更多订阅源但不含 AI 功能。",
"plan.descriptions.free": "非常适合初学者。",
"plan.descriptions.plus": "解锁 AI 功能和更多订阅源。",
@ -580,6 +582,9 @@
"plan.features.PRIORITY_SUPPORT": "优先支持",
"plan.features.PRIVATE_SUBSCRIPTION": "私有订阅",
"plan.features.SECURE_IMAGE_PROXY": "安全图片代理",
"plan.manage_subscription": "管理订阅",
"plan.renews": "续订日期 {{date}}",
"plan.trial_ends": "试用期至 {{date}}",
"privacy.privacy": "隐私政策",
"privacy.terms": "服务条款",
"profile.avatar.cropInstructions": "拖动裁剪区域以调整头像",
@ -692,6 +697,7 @@
"rsshub.useModal.useWith": "使用 {{amount}} <Power />",
"subscription.actions.comingSoon": "即将推出",
"subscription.actions.current": "当前计划",
"subscription.actions.manage_error": "打开订阅管理时出现问题。",
"subscription.actions.upgrade": "升级",
"subscription.actions.upgrade_error": "启动结账时出现问题。",
"subscription.badge.popular": "最受欢迎",

View File

@ -560,6 +560,8 @@
"notifications.test": "測試通知",
"notifications.test_success": "測試通知發送成功。",
"notifications.token": "客户端令牌",
"plan.canceled_expires": "已取消 - {{date}} 到期",
"plan.current_plan": "目前方案",
"plan.descriptions.basic": "更多訂閱源但不含 AI 功能。",
"plan.descriptions.free": "非常適合初學者。",
"plan.descriptions.plus": "解鎖 AI 功能和更多訂閱源。",
@ -586,6 +588,9 @@
"plan.features.PRIORITY_SUPPORT": "優先支援",
"plan.features.PRIVATE_SUBSCRIPTION": "私有訂閱",
"plan.features.SECURE_IMAGE_PROXY": "安全圖片代理",
"plan.manage_subscription": "管理訂閱",
"plan.renews": "續訂日期 {{date}}",
"plan.trial_ends": "試用期至 {{date}}",
"privacy.privacy": "隱私政策",
"privacy.terms": "服務條款",
"profile.avatar.cropInstructions": "拖曳裁切區域以調整你的頭像",
@ -698,6 +703,7 @@
"rsshub.useModal.useWith": "使用 {{amount}} <Power />",
"subscription.actions.comingSoon": "即將推出",
"subscription.actions.current": "目前方案",
"subscription.actions.manage_error": "開啟訂閱管理時發生問題。",
"subscription.actions.upgrade": "升級",
"subscription.actions.upgrade_error": "開始結帳時發生問題。",
"subscription.badge.popular": "最受歡迎",