diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index 2fd217d1f..be46a7812 100644 --- a/apps/mobile/src/modules/settings/SettingsList.tsx +++ b/apps/mobile/src/modules/settings/SettingsList.tsx @@ -41,9 +41,9 @@ import { GeneralScreen } from "./routes/General" import { InvitationsScreen } from "./routes/Invitations" import { ListsScreen } from "./routes/Lists" import { NotificationsScreen } from "./routes/Notifications" -import { PlanScreen } from "./routes/Plan" import { PrivacyScreen } from "./routes/Privacy" import { ReferralScreen } from "./routes/Referral" +import { SubscriptionScreen } from "./routes/Subscription" interface GroupNavigationLink { label: Extract, `titles.${string}`> @@ -118,15 +118,14 @@ const BetaGroupNavigationLinks: GroupNavigationLink[] = [ const ReferralGroupNavigationLinks: GroupNavigationLink[] = [ { - label: "titles.plan.short", + label: "titles.subscription.short", icon: PowerOutlineIcon, onPress: ({ navigation }) => { - navigation.pushControllerView(PlanScreen) + navigation.pushControllerView(SubscriptionScreen) }, iconBackgroundColor: accentColor, anonymous: false, - // TODO: support pay on mobile - hideIf: () => true, + hideIf: (serverConfigs) => !serverConfigs?.PAYMENT_ENABLED, }, { label: "titles.referral.short", @@ -238,7 +237,7 @@ const NavigationLinkGroup: FC<{ } onPress={() => { if (link.trialNotAllowed && (role === UserRole.Free || role === UserRole.Trial)) { - navigation.presentControllerView(PlanScreen) + navigation.presentControllerView(SubscriptionScreen) } else { link.onPress({ navigation }) } @@ -272,7 +271,7 @@ export const SettingsList: FC = () => { if (filteredGroup.length === 0) return false return filteredGroup }) - .filter((group) => group !== false) + .filter((group): group is GroupNavigationLink[] => group !== false) }, [whoami, serverConfigs]) const pixelRatio = PixelRatio.get() @@ -281,12 +280,15 @@ export const SettingsList: FC = () => { return ( - {filteredNavigationGroups.map((group, index) => ( - - - {index < filteredNavigationGroups.length - 1 && } - - ))} + {filteredNavigationGroups.map((group, index) => { + const groupKey = group.map((link) => link.label).join("-") + return ( + + + {index < filteredNavigationGroups.length - 1 && } + + ) + })} ) } diff --git a/apps/mobile/src/modules/settings/routes/Subscription.tsx b/apps/mobile/src/modules/settings/routes/Subscription.tsx new file mode 100644 index 000000000..a0538b7c8 --- /dev/null +++ b/apps/mobile/src/modules/settings/routes/Subscription.tsx @@ -0,0 +1,615 @@ +import { UserRole, UserRoleName } from "@follow/constants" +import { useRoleEndAt, useUserRole } 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 dayjs from "dayjs" +import { openURL } from "expo-linking" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import type { LayoutChangeEvent } from "react-native" +import { ActivityIndicator, Animated, Easing, Pressable, StyleSheet, View } from "react-native" + +import { useServerConfigs } from "@/src/atoms/server-configs" +import { + NavigationBlurEffectHeaderView, + SafeNavigationScrollView, +} from "@/src/components/layouts/views/SafeNavigationScrollView" +import { Text } from "@/src/components/ui/typography/Text" +import { CheckLineIcon } from "@/src/icons/check_line" +import { authClient } from "@/src/lib/auth" +import type { NavigationControllerView } from "@/src/lib/navigation/types" +import { proxyEnv } from "@/src/lib/proxy-env" +import { toast } from "@/src/lib/toast" +import { useColor } from "@/src/theme/colors" + +type PaymentPlan = NonNullable[number] +type PaymentFeature = PaymentPlan["limit"] +type BillingPeriod = "monthly" | "yearly" + +const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"] + +type SegmentLayout = { + width: number + x: number +} + +const styles = StyleSheet.create({ + billingSegmentIndicator: { + position: "absolute", + top: 2, + bottom: 2, + borderRadius: 9999, + }, +}) + +type UpgradeVariables = { + planId: string + annual: boolean +} + +const currencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + trailingZeroDisplay: "stripIfInteger", +}) + +const formatCurrency = (value: number) => currencyFormatter.format(value) + +const formatFeatureValue = ( + key: keyof PaymentFeature, + value: PaymentFeature[keyof PaymentFeature] | undefined | null, +): string => { + if (value == null) { + return "—" + } + + if (typeof value === "boolean") { + return value ? "✓" : "—" + } + + if (key === "PRIORITY_SUPPORT" && typeof value === "number") { + return "⭐️".repeat(value) + } + + if (value === Number.MAX_SAFE_INTEGER) { + return "Unlimited" + } + + return new Intl.NumberFormat("en", { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 1, + }).format(value) +} + +const isFeatureValueVisible = (value: PaymentFeature[keyof PaymentFeature] | null | undefined) => { + if (value == null) { + return false + } + + if (typeof value === "boolean") { + return value + } + + if (typeof value === "number") { + return value > 0 + } + + return true +} + +export const SubscriptionScreen: NavigationControllerView = () => { + const { t } = useTranslation("settings") + const serverConfigs = useServerConfigs() + const role = useUserRole() + const roleEndAt = useRoleEndAt() + + const plans = useMemo(() => serverConfigs?.PAYMENT_PLAN_LIST ?? [], [serverConfigs]) + const isPaymentEnabled = serverConfigs?.PAYMENT_ENABLED + + const defaultBillingPeriod: BillingPeriod = "yearly" + const [billingPeriod, setBillingPeriod] = useState(defaultBillingPeriod) + + const sortedPlans = useMemo(() => { + return [...plans].sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0)) + }, [plans]) + + const currentPlan = useMemo(() => { + return sortedPlans.find((plan) => plan.role === role) ?? null + }, [sortedPlans, role]) + + const daysLeft = useMemo(() => { + if (!roleEndAt) { + return null + } + + const difference = dayjs(roleEndAt).diff(dayjs(), "day") + return Math.max(difference, 0) + }, [roleEndAt]) + + const averageSavings = useMemo(() => { + const paidPlans = sortedPlans.filter( + (plan) => (plan.priceInDollars ?? 0) > 0 && (plan.priceInDollarsAnnual ?? 0) > 0, + ) + + if (paidPlans.length === 0) { + return 0 + } + + const total = paidPlans.reduce((acc, plan) => { + const monthlyTotal = (plan.priceInDollars ?? 0) * 12 + const yearlyTotal = plan.priceInDollarsAnnual ?? 0 + if (monthlyTotal === 0) { + return acc + } + const savings = ((monthlyTotal - yearlyTotal) / monthlyTotal) * 100 + return acc + savings + }, 0) + + return Math.round(total / paidPlans.length) + }, [sortedPlans]) + + const upgradeMutation = useMutation({ + mutationFn: async ({ planId, annual }) => { + const response = await authClient.subscription.upgrade({ + plan: planId, + annual, + successUrl: "folo://refresh", + cancelUrl: proxyEnv.WEB_URL, + disableRedirect: true, + }) + + const redirectUrl = + typeof response === "object" && response && "data" in response && response.data + ? (response.data as { url?: string }).url + : undefined + + if (redirectUrl) { + await openURL(redirectUrl) + } + }, + onError: (error) => { + const message = error.message?.trim() || t("subscription.actions.upgrade_error") + toast.error(message) + }, + }) + + if (!isPaymentEnabled || sortedPlans.length === 0) { + return ( + } + > + + + {t("subscription.unavailable")} + + + + ) + } + + const summaryTitle = currentPlan + ? t("subscription.summary.current", { plan: currentPlan.name }) + : t("subscription.summary.free") + + let summarySubtitle = t("subscription.summary.free_description") + if (role === UserRole.Pro || role === UserRole.Plus) { + summarySubtitle = t("subscription.summary.active") + } else if (daysLeft && daysLeft > 0 && role && role !== UserRole.Free) { + summarySubtitle = t("subscription.summary.trial_expiring", { + date: dayjs(roleEndAt).format("MMMM D, YYYY"), + days: daysLeft, + }) + } + + return ( + } + > + + + + {t("subscription.summary.title")} + + {summaryTitle} + + {summarySubtitle} + + + + + + + {sortedPlans.map((plan) => { + const isCurrentPlan = plan.role === role + const isProcessing = + upgradeMutation.isPending && upgradeMutation.variables?.planId === plan.planID + + return ( + + upgradeMutation.mutate({ + planId: plan.planID as string, + annual: billingPeriod === "yearly", + }) + : undefined + } + isProcessing={isProcessing} + /> + ) + })} + + + + ) +} + +const BillingToggle = ({ + value, + onChange, + averageSavings, +}: { + value: BillingPeriod + onChange: (value: BillingPeriod) => void + averageSavings: number +}) => { + const { t } = useTranslation("settings") + const activeBackground = useColor("systemBackground") + const indicatorTranslate = useRef(new Animated.Value(0)).current + const indicatorWidth = useRef(new Animated.Value(0)).current + const segmentLayouts = useRef>>({}) + const [indicatorReady, setIndicatorReady] = useState(false) + + const animateIndicator = useCallback( + (period: BillingPeriod, animated: boolean) => { + const layout = segmentLayouts.current[period] + if (!layout) return + + if (!animated) { + indicatorTranslate.setValue(layout.x) + indicatorWidth.setValue(layout.width) + return + } + + Animated.spring(indicatorTranslate, { + toValue: layout.x, + useNativeDriver: false, + damping: 18, + stiffness: 180, + }).start() + + Animated.timing(indicatorWidth, { + toValue: layout.width, + duration: 180, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start() + }, + [indicatorTranslate, indicatorWidth], + ) + + const handleLayout = useCallback( + (period: BillingPeriod) => (event: LayoutChangeEvent) => { + const { width, x } = event.nativeEvent.layout + segmentLayouts.current[period] = { width, x } + const measuredAll = BILLING_SEGMENTS.every((option) => segmentLayouts.current[option]) + if (measuredAll && !indicatorReady) { + setIndicatorReady(true) + animateIndicator(value, false) + } + }, + [animateIndicator, indicatorReady, value], + ) + + useEffect(() => { + if (indicatorReady) { + animateIndicator(value, true) + } + }, [animateIndicator, indicatorReady, value]) + + return ( + + {indicatorReady ? ( + + ) : null} + {BILLING_SEGMENTS.map((option) => { + const selected = value === option + const showSavings = option === "yearly" && averageSavings > 0 + const savingsLabel = t("subscription.billing.yearly_savings", { value: averageSavings }) + + return ( + { + if (!selected) { + onChange(option) + } + }} + onLayout={handleLayout(option)} + className="flex-1 items-center justify-center rounded-full px-4 py-3" + > + + + + {option === "monthly" + ? t("subscription.billing.monthly") + : t("subscription.billing.yearly")} + + + {" "} + {showSavings ? savingsLabel : " "} + + + + + ) + })} + + ) +} + +const PlanCard = ({ + plan, + billingPeriod, + isCurrentPlan, + onUpgrade, + isProcessing, +}: { + plan: PaymentPlan + billingPeriod: BillingPeriod + isCurrentPlan: boolean + onUpgrade?: () => void + isProcessing?: boolean +}) => { + const { t } = useTranslation("settings") + + const isPaidPlan = plan.role !== UserRole.Free + + const regularPrice = isPaidPlan + ? billingPeriod === "yearly" + ? (plan.priceInDollarsAnnual ?? 0) / 12 + : (plan.priceInDollars ?? 0) + : 0 + + const discountPrice = isPaidPlan + ? billingPeriod === "yearly" + ? (plan.priceInDollarsInDiscountAnnual ?? 0) / 12 + : (plan.priceInDollarsInDiscount ?? 0) + : 0 + + const hasDiscount = isPaidPlan && discountPrice > 0 && discountPrice < regularPrice + + const displayPrice = !isPaidPlan ? 0 : hasDiscount ? discountPrice : regularPrice + const formattedPrice = isPaidPlan ? formatCurrency(displayPrice) : t("subscription.price.free") + + const formattedRegularPrice = + hasDiscount && regularPrice > 0 ? formatCurrency(regularPrice) : undefined + const discountPercentage = hasDiscount + ? Math.round(((regularPrice - discountPrice) / regularPrice) * 100) + : 0 + const discountLabel = + discountPercentage > 0 + ? t("subscription.discount.tag", { value: discountPercentage }) + : undefined + + const priceAnimation = useRef(new Animated.Value(1)).current + + useEffect(() => { + priceAnimation.setValue(0) + Animated.spring(priceAnimation, { + toValue: 1, + useNativeDriver: false, + damping: 14, + stiffness: 180, + }).start() + }, [priceAnimation, displayPrice]) + + const priceScale = priceAnimation.interpolate({ + inputRange: [0, 1], + outputRange: [0.95, 1], + }) + + const priceTranslateY = priceAnimation.interpolate({ + inputRange: [0, 1], + outputRange: [6, 0], + }) + + const periodLabel = !isPaidPlan + ? "" + : billingPeriod === "yearly" + ? t("subscription.price.per_month_billed_yearly") + : t("subscription.price.per_month") + + const planDescription = t(`plan.descriptions.${plan.role}` as const, { defaultValue: "" }) + + const features = useMemo(() => { + return ( + Object.entries(plan.limit || {}) as Array< + [keyof PaymentFeature, PaymentFeature[keyof PaymentFeature]] + > + ) + .filter(([, value]) => isFeatureValueVisible(value)) + .slice(0, 6) + }, [plan.limit]) + + const actionType = useMemo(() => { + if (plan.isComingSoon) { + return "coming-soon" as const + } + if (isCurrentPlan) { + return "current" as const + } + if (plan.planID) { + return "upgrade" as const + } + return null + }, [plan.isComingSoon, plan.planID, isCurrentPlan]) + + const planNameFallback = plan.name || (plan.role ? UserRoleName[plan.role as UserRole] : "") + + return ( + + + + {planNameFallback} + {planDescription ? ( + + {planDescription} + + ) : null} + + {plan.isPopular ? ( + + + {t("subscription.badge.popular")} + + + ) : null} + + + + + + {formattedPrice} + + {periodLabel ? {periodLabel} : null} + + {formattedRegularPrice ? ( + + {formattedRegularPrice} + + ) : null} + {discountLabel ? ( + {discountLabel} + ) : null} + + + + {features.map(([featureKey, value]) => { + const formattedValue = formatFeatureValue(featureKey, value) + const showValue = !(typeof value === "boolean" && value) + return ( + + + + + + {t(`plan.features.${featureKey}` as const, { defaultValue: featureKey })} + + {showValue ? ( + + {formattedValue === "✓" ? t("subscription.feature.included") : formattedValue} + + ) : ( + + {t("subscription.feature.included")} + + )} + + ) + })} + + + + + ) +} + +const PlanAction = ({ + actionType, + onUpgrade, + isProcessing, +}: { + actionType: "current" | "upgrade" | "coming-soon" | null + onUpgrade?: () => void + isProcessing?: boolean +}) => { + const { t } = useTranslation("settings") + + if (actionType === "coming-soon") { + return ( + + {t("subscription.actions.comingSoon")} + + ) + } + + if (actionType === "current") { + return ( + + {t("subscription.actions.current")} + + ) + } + + if (actionType === "upgrade" && onUpgrade) { + return ( + + {isProcessing ? ( + + ) : ( + + {t("subscription.actions.upgrade")} + + )} + + ) + } + + return null +} diff --git a/locales/settings/en.json b/locales/settings/en.json index 6ee034371..ff6cc2b9b 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -683,6 +683,26 @@ "rsshub.useModal.purchase_expires_at": "You have purchased this Instance, and your purchase expires at", "rsshub.useModal.title": "RSSHub Instance", "rsshub.useModal.useWith": "Use with {{amount}} ", + "subscription.actions.comingSoon": "Coming soon", + "subscription.actions.current": "Current plan", + "subscription.actions.upgrade": "Upgrade", + "subscription.actions.upgrade_error": "Something went wrong while starting checkout.", + "subscription.badge.popular": "Most popular", + "subscription.billing.monthly": "Monthly", + "subscription.billing.yearly": "Yearly", + "subscription.billing.yearly_savings": "Save {{value}}%", + "subscription.discount.tag": "Save {{value}}%", + "subscription.feature.included": "Included", + "subscription.price.free": "Free", + "subscription.price.per_month": "per month", + "subscription.price.per_month_billed_yearly": "per month, billed yearly", + "subscription.summary.active": "You have an active subscription.", + "subscription.summary.current": "{{plan}} plan", + "subscription.summary.free": "Free plan", + "subscription.summary.free_description": "Upgrade to unlock more feeds, actions, and AI features.", + "subscription.summary.title": "Your subscription", + "subscription.summary.trial_expiring": "Trial ends {{date}} ({{days}} days left)", + "subscription.unavailable": "Subscriptions are not available right now.", "titles.about": "About", "titles.account": "Account", "titles.actions": "Actions", @@ -703,6 +723,8 @@ "titles.referral.short": "Invite & Earn", "titles.shortcuts": "Shortcuts", "titles.sign_out": "Sign Out", + "titles.subscription.long": "Manage your subscription", + "titles.subscription.short": "Subscription", "titles.token_usage": "AI Credits Usage", "wallet.balance.activePoints": "Active Points", "wallet.balance.dailyReward": "Your Daily Reward", diff --git a/locales/settings/ja.json b/locales/settings/ja.json index 274235eca..8fa3396a7 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -676,6 +676,26 @@ "rsshub.useModal.purchase_expires_at": "このインスタンスを購入しました、利用期限は", "rsshub.useModal.title": "RSSHub インスタンス", "rsshub.useModal.useWith": "使用する {{amount}} ", + "subscription.actions.comingSoon": "近日公開", + "subscription.actions.current": "現在のプラン", + "subscription.actions.upgrade": "アップグレード", + "subscription.actions.upgrade_error": "チェックアウトを開始できませんでした。", + "subscription.badge.popular": "人気No.1", + "subscription.billing.monthly": "月払い", + "subscription.billing.yearly": "年払い", + "subscription.billing.yearly_savings": "{{value}}%お得", + "subscription.discount.tag": "{{value}}%お得", + "subscription.feature.included": "含まれています", + "subscription.price.free": "無料", + "subscription.price.per_month": "/月", + "subscription.price.per_month_billed_yearly": "年払い(月額換算)", + "subscription.summary.active": "現在アクティブなサブスクリプションです。", + "subscription.summary.current": "{{plan}} プラン", + "subscription.summary.free": "無料プラン", + "subscription.summary.free_description": "アップグレードして、より多くのフィードやAI機能を利用しましょう。", + "subscription.summary.title": "現在のサブスクリプション", + "subscription.summary.trial_expiring": "体験版は {{date}} に終了(残り {{days}} 日)", + "subscription.unavailable": "モバイルでは現在サブスクリプションをご利用いただけません。", "titles.about": "About", "titles.account": "アカウント", "titles.actions": "アクション", @@ -696,6 +716,8 @@ "titles.referral.short": "招待 & 収益", "titles.shortcuts": "ショートカット", "titles.sign_out": "サインアウト", + "titles.subscription.long": "サブスクリプション管理", + "titles.subscription.short": "サブスクリプション", "wallet.balance.activePoints": "アクティブなポイント", "wallet.balance.dailyReward": "デイリー報酬", "wallet.balance.title": "残高", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 576c56666..1f19b5e50 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -676,6 +676,26 @@ "rsshub.useModal.purchase_expires_at": "你已购买此实例,到期时间为", "rsshub.useModal.title": "RSSHub 实例", "rsshub.useModal.useWith": "使用 {{amount}} ", + "subscription.actions.comingSoon": "即将推出", + "subscription.actions.current": "当前计划", + "subscription.actions.upgrade": "升级", + "subscription.actions.upgrade_error": "启动结账时出现问题。", + "subscription.badge.popular": "最受欢迎", + "subscription.billing.monthly": "按月", + "subscription.billing.yearly": "按年", + "subscription.billing.yearly_savings": "节省 {{value}}%", + "subscription.discount.tag": "节省 {{value}}%", + "subscription.feature.included": "已包含", + "subscription.price.free": "免费", + "subscription.price.per_month": "每月", + "subscription.price.per_month_billed_yearly": "按年计费,每月", + "subscription.summary.active": "你当前拥有一个有效的订阅。", + "subscription.summary.current": "{{plan}} 计划", + "subscription.summary.free": "免费计划", + "subscription.summary.free_description": "升级即可解锁更多订阅、列表与 AI 功能。", + "subscription.summary.title": "我的订阅", + "subscription.summary.trial_expiring": "试用将于 {{date}} 到期(剩余 {{days}} 天)", + "subscription.unavailable": "当前暂不支持订阅服务。", "titles.about": "关于", "titles.account": "账户", "titles.actions": "自动化", @@ -696,6 +716,8 @@ "titles.referral.short": "邀请并赚取", "titles.shortcuts": "快捷键", "titles.sign_out": "登出", + "titles.subscription.long": "管理订阅", + "titles.subscription.short": "订阅", "wallet.balance.activePoints": "活跃度", "wallet.balance.dailyReward": "每日奖励", "wallet.balance.title": "余额", diff --git a/locales/settings/zh-TW.json b/locales/settings/zh-TW.json index 8379295ae..e94d2722c 100644 --- a/locales/settings/zh-TW.json +++ b/locales/settings/zh-TW.json @@ -665,6 +665,26 @@ "rsshub.useModal.purchase_expires_at": "你已購買此實例伺服器,到期時間為", "rsshub.useModal.title": "RSSHub 實例伺服器", "rsshub.useModal.useWith": "使用 {{amount}} ", + "subscription.actions.comingSoon": "即將推出", + "subscription.actions.current": "目前方案", + "subscription.actions.upgrade": "升級", + "subscription.actions.upgrade_error": "開始結帳時發生問題。", + "subscription.badge.popular": "最受歡迎", + "subscription.billing.monthly": "按月", + "subscription.billing.yearly": "按年", + "subscription.billing.yearly_savings": "節省 {{value}}%", + "subscription.discount.tag": "節省 {{value}}%", + "subscription.feature.included": "已包含", + "subscription.price.free": "免費", + "subscription.price.per_month": "每月", + "subscription.price.per_month_billed_yearly": "按年計費,每月", + "subscription.summary.active": "你目前擁有有效的訂閱。", + "subscription.summary.current": "{{plan}} 方案", + "subscription.summary.free": "免費方案", + "subscription.summary.free_description": "升級即可解鎖更多訂閱、列表與 AI 功能。", + "subscription.summary.title": "我的訂閱", + "subscription.summary.trial_expiring": "試用將於 {{date}} 到期(剩餘 {{days}} 天)", + "subscription.unavailable": "目前暫不支援訂閱功能。", "titles.about": "關於", "titles.account": "帳戶", "titles.actions": "自動化操作", @@ -685,6 +705,8 @@ "titles.referral.short": "邀請並賺取", "titles.shortcuts": "快捷鍵", "titles.sign_out": "登出", + "titles.subscription.long": "管理訂閱", + "titles.subscription.short": "訂閱", "titles.token_usage": "令牌使用情況", "wallet.balance.activePoints": "活躍度", "wallet.balance.dailyReward": "每日獎勵",