diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx index 75a4b9bf57..8a0b14de52 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx @@ -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 ( - + {/* Subscription status info for current plan */} + {actionType === "current" && canManageSubscription && ( + + + + {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", + }), + })} + + )} - disabled={buttonConfig.disabled} - onClick={ - actionType === "current" && canManageSubscription - ? () => window.open(stripePortalLink, "_blank") - : onSelect - } - isLoading={isLoading} - > - - {buttonConfig.icon && } - {buttonConfig.text} - - + billingPortalMutation.mutate() + : onSelect + } + isLoading={isLoading || billingPortalMutation.isPending} + > + + {buttonConfig.icon && } + {buttonConfig.text} + + + ) } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3f951a4705..c3e0d8868b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -87,5 +87,5 @@ "vite-tsconfig-paths": "5.1.4" }, "productName": "Folo", - "mainHash": "1c627eb0ea5a109a4fc2d7ac5a78777df546d831f6cced620b8120d41dc4e444" + "mainHash": "56f9664d0989dd416832d0e94f479340623da3641e1dcef5fb33311f21e9617f" } diff --git a/apps/mobile/src/modules/settings/routes/Plan.tsx b/apps/mobile/src/modules/settings/routes/Plan.tsx index ff44be53c0..268e265fb6 100644 --- a/apps/mobile/src/modules/settings/routes/Plan.tsx +++ b/apps/mobile/src/modules/settings/routes/Plan.tsx @@ -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 ( { }) : 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 = ({ })} - + ) } @@ -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 ( @@ -594,9 +669,64 @@ const PlanAction = ({ if (actionType === "current") { return ( - - {t("subscription.actions.current")} - + + {canManageSubscription && ( + + + + {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", + }), + })} + + + )} + + {isManaging ? ( + + ) : ( + + {canManageSubscription ? t("plan.manage_subscription") : t("plan.current_plan")} + + )} + + ) } diff --git a/locales/settings/en.json b/locales/settings/en.json index 64569dcc69..dc83223305 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -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}} ", "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", diff --git a/locales/settings/ja.json b/locales/settings/ja.json index b321f6bf49..be7b14c6a5 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -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}} ", "subscription.actions.comingSoon": "近日公開", "subscription.actions.current": "現在のプラン", + "subscription.actions.manage_error": "サブスクリプション管理を開けませんでした。", "subscription.actions.upgrade": "アップグレード", "subscription.actions.upgrade_error": "チェックアウトを開始できませんでした。", "subscription.badge.popular": "人気No.1", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 39d4a09c0d..f219045dae 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -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}} ", "subscription.actions.comingSoon": "即将推出", "subscription.actions.current": "当前计划", + "subscription.actions.manage_error": "打开订阅管理时出现问题。", "subscription.actions.upgrade": "升级", "subscription.actions.upgrade_error": "启动结账时出现问题。", "subscription.badge.popular": "最受欢迎", diff --git a/locales/settings/zh-TW.json b/locales/settings/zh-TW.json index 9ea9cc1dc5..309eb8cedc 100644 --- a/locales/settings/zh-TW.json +++ b/locales/settings/zh-TW.json @@ -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}} ", "subscription.actions.comingSoon": "即將推出", "subscription.actions.current": "目前方案", + "subscription.actions.manage_error": "開啟訂閱管理時發生問題。", "subscription.actions.upgrade": "升級", "subscription.actions.upgrade_error": "開始結帳時發生問題。", "subscription.badge.popular": "最受歡迎",