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:
parent
f39bcb97d8
commit
a86b2c6383
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,5 +87,5 @@
|
|||
"vite-tsconfig-paths": "5.1.4"
|
||||
},
|
||||
"productName": "Folo",
|
||||
"mainHash": "1c627eb0ea5a109a4fc2d7ac5a78777df546d831f6cced620b8120d41dc4e444"
|
||||
"mainHash": "56f9664d0989dd416832d0e94f479340623da3641e1dcef5fb33311f21e9617f"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "最受欢迎",
|
||||
|
|
|
|||
|
|
@ -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": "最受歡迎",
|
||||
|
|
|
|||
Loading…
Reference in New Issue