feat(subscription): add SubscriptionScreen and update settings navigation

Introduced a new SubscriptionScreen for managing user subscriptions, replacing the previous PlanScreen references. Updated navigation links in SettingsList to reflect this change, ensuring proper routing. Enhanced localization files to include new subscription-related strings for multiple languages.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-11-04 20:45:58 +08:00
parent 2541490129
commit 6a49168181
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
6 changed files with 718 additions and 13 deletions

View File

@ -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<ParseKeys<"settings">, `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 (
<View className="flex-1 bg-system-grouped-background pb-4" style={{ marginTop }}>
{filteredNavigationGroups.map((group, index) => (
<Fragment key={`nav-group-${index}`}>
<NavigationLinkGroup key={`nav-group-${index}`} links={group} />
{index < filteredNavigationGroups.length - 1 && <View style={{ height: groupGap }} />}
</Fragment>
))}
{filteredNavigationGroups.map((group, index) => {
const groupKey = group.map((link) => link.label).join("-")
return (
<Fragment key={groupKey}>
<NavigationLinkGroup links={group} />
{index < filteredNavigationGroups.length - 1 && <View style={{ height: groupGap }} />}
</Fragment>
)
})}
</View>
)
}

View File

@ -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<StatusConfigs["PAYMENT_PLAN_LIST"]>[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<BillingPeriod>(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<void, Error, UpgradeVariables>({
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 (
<SafeNavigationScrollView
className="bg-system-grouped-background"
Header={<NavigationBlurEffectHeaderView title={t("titles.subscription.long")} />}
>
<View className="flex-1 items-center justify-center px-6 py-12">
<Text className="text-center text-base text-secondary-label">
{t("subscription.unavailable")}
</Text>
</View>
</SafeNavigationScrollView>
)
}
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 (
<SafeNavigationScrollView
className="bg-system-grouped-background"
Header={<NavigationBlurEffectHeaderView title={t("titles.subscription.long")} />}
>
<View className="gap-6 px-4 pb-10 pt-6">
<View className="rounded-3xl bg-secondary-system-grouped-background p-5 shadow-sm">
<Text className="text-xs font-semibold uppercase text-secondary-label">
{t("subscription.summary.title")}
</Text>
<Text className="mt-2 text-2xl font-semibold text-label">{summaryTitle}</Text>
<Text className="mt-2 text-sm leading-relaxed text-secondary-label">
{summarySubtitle}
</Text>
</View>
<BillingToggle
value={billingPeriod}
onChange={setBillingPeriod}
averageSavings={averageSavings}
/>
<View className="gap-4">
{sortedPlans.map((plan) => {
const isCurrentPlan = plan.role === role
const isProcessing =
upgradeMutation.isPending && upgradeMutation.variables?.planId === plan.planID
return (
<PlanCard
key={plan.planID ?? plan.name}
plan={plan}
billingPeriod={billingPeriod}
isCurrentPlan={isCurrentPlan}
onUpgrade={
plan.planID
? () =>
upgradeMutation.mutate({
planId: plan.planID as string,
annual: billingPeriod === "yearly",
})
: undefined
}
isProcessing={isProcessing}
/>
)
})}
</View>
</View>
</SafeNavigationScrollView>
)
}
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<Partial<Record<BillingPeriod, SegmentLayout>>>({})
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 (
<View className="relative flex-row rounded-full bg-secondary-system-grouped-background p-1">
{indicatorReady ? (
<Animated.View
pointerEvents="none"
style={[
styles.billingSegmentIndicator,
{
backgroundColor: activeBackground,
transform: [{ translateX: indicatorTranslate }],
width: indicatorWidth,
},
]}
/>
) : 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 (
<Pressable
key={option}
accessibilityRole="button"
onPress={() => {
if (!selected) {
onChange(option)
}
}}
onLayout={handleLayout(option)}
className="flex-1 items-center justify-center rounded-full px-4 py-3"
>
<View className="items-center">
<Text
className={cn(
"text-sm font-medium",
selected ? "text-label" : "text-secondary-label",
)}
>
<Text>
{option === "monthly"
? t("subscription.billing.monthly")
: t("subscription.billing.yearly")}
</Text>
<Text
className={cn(
"text-center text-xs font-semibold text-green",
!showSavings && "opacity-0",
)}
>
{" "}
{showSavings ? savingsLabel : " "}
</Text>
</Text>
</View>
</Pressable>
)
})}
</View>
)
}
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 (
<View
className={cn(
"rounded-3xl bg-secondary-system-grouped-background p-5",
isCurrentPlan && "border border-accent/50 shadow-lg",
plan.isComingSoon && "opacity-70",
)}
>
<View className="flex-row items-start justify-between">
<View className="flex-1">
<Text className="text-xl font-semibold text-label">{planNameFallback}</Text>
{planDescription ? (
<Text className="mt-1 text-sm leading-snug text-secondary-label">
{planDescription}
</Text>
) : null}
</View>
{plan.isPopular ? (
<View className="rounded-full bg-accent px-2 py-1">
<Text className="text-xs font-semibold text-white">
{t("subscription.badge.popular")}
</Text>
</View>
) : null}
</View>
<View className="mt-4">
<View className="flex-row items-baseline gap-2">
<Animated.View
style={{
opacity: priceAnimation,
transform: [{ scale: priceScale }, { translateY: priceTranslateY }],
}}
>
<Text className="text-2xl font-bold text-label">{formattedPrice}</Text>
</Animated.View>
{periodLabel ? <Text className="text-xs text-secondary-label">{periodLabel}</Text> : null}
</View>
{formattedRegularPrice ? (
<Text className="mt-1 text-xs text-secondary-label line-through">
{formattedRegularPrice}
</Text>
) : null}
{discountLabel ? (
<Text className="mt-1 text-xs font-semibold text-green">{discountLabel}</Text>
) : null}
</View>
<View className="mt-4 gap-2">
{features.map(([featureKey, value]) => {
const formattedValue = formatFeatureValue(featureKey, value)
const showValue = !(typeof value === "boolean" && value)
return (
<View key={featureKey as string} className="flex-row items-center gap-3">
<View className="rounded-full bg-green/15 p-1">
<CheckLineIcon width={14} height={14} color="rgb(40, 205, 65)" />
</View>
<Text className="flex-1 text-sm text-label">
{t(`plan.features.${featureKey}` as const, { defaultValue: featureKey })}
</Text>
{showValue ? (
<Text
className={cn(
"text-sm font-medium",
formattedValue === "Unlimited" ? "text-accent" : "text-secondary-label",
)}
>
{formattedValue === "✓" ? t("subscription.feature.included") : formattedValue}
</Text>
) : (
<Text className="text-sm font-medium text-secondary-label">
{t("subscription.feature.included")}
</Text>
)}
</View>
)
})}
</View>
<PlanAction actionType={actionType} onUpgrade={onUpgrade} isProcessing={isProcessing} />
</View>
)
}
const PlanAction = ({
actionType,
onUpgrade,
isProcessing,
}: {
actionType: "current" | "upgrade" | "coming-soon" | null
onUpgrade?: () => void
isProcessing?: boolean
}) => {
const { t } = useTranslation("settings")
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">
{t("subscription.actions.comingSoon")}
</Text>
)
}
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>
)
}
if (actionType === "upgrade" && onUpgrade) {
return (
<Pressable
accessibilityRole="button"
onPress={onUpgrade}
disabled={isProcessing}
className="mt-5 h-11 items-center justify-center rounded-full bg-accent disabled:opacity-70"
>
{isProcessing ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-base font-semibold text-white">
{t("subscription.actions.upgrade")}
</Text>
)}
</Pressable>
)
}
return null
}

View File

@ -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}} <Power />",
"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",

View File

@ -676,6 +676,26 @@
"rsshub.useModal.purchase_expires_at": "このインスタンスを購入しました、利用期限は",
"rsshub.useModal.title": "RSSHub インスタンス",
"rsshub.useModal.useWith": "使用する {{amount}} <Power />",
"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": "残高",

View File

@ -676,6 +676,26 @@
"rsshub.useModal.purchase_expires_at": "你已购买此实例,到期时间为",
"rsshub.useModal.title": "RSSHub 实例",
"rsshub.useModal.useWith": "使用 {{amount}} <Power />",
"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": "余额",

View File

@ -665,6 +665,26 @@
"rsshub.useModal.purchase_expires_at": "你已購買此實例伺服器,到期時間為",
"rsshub.useModal.title": "RSSHub 實例伺服器",
"rsshub.useModal.useWith": "使用 {{amount}} <Power />",
"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": "每日獎勵",