Skip to content

Commit 3947302

Browse files
authored
feat(mobile): align upgrade prompts with desktop (#4914)
1 parent cfb23ad commit 3947302

File tree

11 files changed

+105
-49
lines changed

11 files changed

+105
-49
lines changed

apps/mobile/src/lib/error-parser.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
8080
code = typeof _code === "number" ? _code : Number(_code)
8181
}
8282
message = typeof _message === "string" && _message.trim() ? _message : message
83+
if (code != null) {
84+
const tValue = t(`errors:${code}` as any)
85+
if (tValue !== `${code}`) {
86+
message = tValue
87+
}
88+
}
8389

8490
if (typeof reason === "string" && reason.trim()) {
8591
_reason = reason
@@ -122,8 +128,8 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
122128

123129
if (needUpgradeError) {
124130
showUpgradeRequiredDialog({
125-
title: _title || message,
126-
message: _reason || message,
131+
title: message || t("settings:subscription.actions.upgrade"),
132+
message: t("settings:subscription.summary.free_description"),
127133
})
128134
return
129135
}

apps/mobile/src/modules/feed/FollowFeed.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ import { Text } from "@/src/components/ui/typography/Text"
2828
import { SafeAlertCuteReIcon } from "@/src/icons/safe_alert_cute_re"
2929
import { SafetyCertificateCuteReIcon } from "@/src/icons/safety_certificate_cute_re"
3030
import { User3CuteReIcon } from "@/src/icons/user_3_cute_re"
31+
import { toastFetchError } from "@/src/lib/error-parser"
3132
import { useCanDismiss, useNavigation } from "@/src/lib/navigation/hooks"
3233
import { useSetModalScreenOptions } from "@/src/lib/navigation/ScreenOptionsContext"
33-
import { getBizFetchErrorMessage } from "@/src/lib/parse-api-error"
3434
import { toast } from "@/src/lib/toast"
3535
import { FeedSummary } from "@/src/modules/discover/FeedSummary"
3636
import { FeedViewSelector } from "@/src/modules/feed/view-selector"
@@ -135,7 +135,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) {
135135
navigate.back()
136136
}
137137
} catch (error) {
138-
toast.error(error instanceof Error ? getBizFetchErrorMessage(error) : "Failed to update feed")
138+
toastFetchError(error as Error)
139139
} finally {
140140
setIsLoading(false)
141141
}
@@ -163,9 +163,7 @@ function FollowImpl(props: { feedId: string; defaultView?: FeedViewType }) {
163163
navigate.back()
164164
}
165165
} catch (error) {
166-
toast.error(
167-
error instanceof Error ? getBizFetchErrorMessage(error) : "Failed to update feed",
168-
)
166+
toastFetchError(error as Error)
169167
} finally {
170168
setIsLoading(false)
171169
}

apps/mobile/src/modules/list/FollowList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import { GroupedInsetListCard } from "@/src/components/ui/grouped/GroupedList"
2424
import { IconWithFallback } from "@/src/components/ui/icon/fallback-icon"
2525
import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator"
2626
import { Text } from "@/src/components/ui/typography/Text"
27+
import { toastFetchError } from "@/src/lib/error-parser"
2728
import { useNavigation, useScreenIsInSheetModal } from "@/src/lib/navigation/hooks"
2829
import { useSetModalScreenOptions } from "@/src/lib/navigation/ScreenOptionsContext"
29-
import { getBizFetchErrorMessage } from "@/src/lib/parse-api-error"
3030
import { toast } from "@/src/lib/toast"
3131

3232
import { FeedViewSelector } from "../feed/view-selector"
@@ -108,7 +108,7 @@ const Impl = (props: { id: string }) => {
108108
navigation.back()
109109
}
110110
} catch (error) {
111-
toast.error(error instanceof Error ? getBizFetchErrorMessage(error) : "Failed to update list")
111+
toastFetchError(error as Error)
112112
} finally {
113113
setIsLoading(false)
114114
}

apps/mobile/src/modules/settings/routes/ManageList.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,9 @@ import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
2626
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
2727
import { Text } from "@/src/components/ui/typography/Text"
2828
import { CheckLineIcon } from "@/src/icons/check_line"
29+
import { toastFetchError } from "@/src/lib/error-parser"
2930
import { useNavigation } from "@/src/lib/navigation/hooks"
3031
import type { NavigationControllerView } from "@/src/lib/navigation/types"
31-
import { getBizFetchErrorMessage } from "@/src/lib/parse-api-error"
32-
import { toast } from "@/src/lib/toast"
3332
import { accentColor } from "@/src/theme/colors"
3433

3534
const ManageListContext = createContext<{
@@ -82,7 +81,7 @@ export const ManageListScreen: NavigationControllerView<{
8281
navigation.back()
8382
})
8483
.catch((error) => {
85-
toast.error(getBizFetchErrorMessage(error))
84+
toastFetchError(error as Error)
8685
console.error(error)
8786
})
8887
}}

apps/mobile/src/modules/settings/routes/Plan.tsx

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"
66
import dayjs from "dayjs"
77
import type { SubscriptionProduct } from "expo-iap"
88
import { openURL } from "expo-linking"
9+
import type { TFunction } from "i18next"
910
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
1011
import { useTranslation } from "react-i18next"
1112
import type { LayoutChangeEvent } from "react-native"
@@ -39,6 +40,41 @@ type PaymentPlan = NonNullable<StatusConfigs["PAYMENT_PLAN_LIST"]>[number]
3940
type PaymentFeature = PaymentPlan["limit"]
4041
type BillingPeriod = "monthly" | "yearly"
4142

43+
const AI_MODEL_SELECTION_VALUE_LABELS = {
44+
none: {
45+
translationKey: "plan.featureValues.AI_MODEL_SELECTION.none",
46+
fallback: "—",
47+
},
48+
curated: {
49+
translationKey: "plan.featureValues.AI_MODEL_SELECTION.curated",
50+
fallback: "Curated models",
51+
},
52+
high_performance: {
53+
translationKey: "plan.featureValues.AI_MODEL_SELECTION.high_performance",
54+
fallback: "All high-end models",
55+
},
56+
} as const
57+
58+
const PLAN_FEATURE_ORDER: Array<keyof PaymentFeature> = [
59+
"MAX_SUBSCRIPTIONS",
60+
"MAX_LISTS",
61+
"MAX_INBOXES",
62+
"MAX_ACTIONS",
63+
"MAX_AI_ENTRY_SUMMARY_PER_DAY",
64+
"MAX_AI_ENTRY_TRANSLATION_PER_DAY",
65+
"MAX_AI_TEXT_TO_SPEECH_PER_DAY",
66+
"AI_MODEL_SELECTION",
67+
"AI_BRING_YOUR_OWN_KEY",
68+
"BOOSTS",
69+
"PRIORITY_SUPPORT",
70+
"PRIVATE_SUBSCRIPTION",
71+
"MAX_RSSHUB_SUBSCRIPTIONS",
72+
"SECURE_IMAGE_PROXY",
73+
"INTEGRATION_SUPPORTED",
74+
"AI_CREDIT",
75+
"MAX_AI_TASKS",
76+
]
77+
4278
const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"]
4379

4480
type SegmentLayout = {
@@ -96,11 +132,21 @@ const formatCurrency = (value: number) => currencyFormatter.format(value)
96132
const formatFeatureValue = (
97133
key: keyof PaymentFeature,
98134
value: PaymentFeature[keyof PaymentFeature] | undefined | null,
135+
t?: TFunction<"settings">,
99136
): string => {
100137
if (value == null) {
101138
return "—"
102139
}
103140

141+
if (key === "AI_MODEL_SELECTION" && typeof value === "string") {
142+
const selectionValue =
143+
AI_MODEL_SELECTION_VALUE_LABELS[value as keyof typeof AI_MODEL_SELECTION_VALUE_LABELS]
144+
145+
if (selectionValue) {
146+
return t?.(selectionValue.translationKey) ?? selectionValue.fallback
147+
}
148+
}
149+
104150
if (typeof value === "boolean") {
105151
return value ? "✓" : "—"
106152
}
@@ -337,13 +383,13 @@ export const PlanScreen: NavigationControllerView = () => {
337383
: t("subscription.summary.free")
338384

339385
let summarySubtitle = t("subscription.summary.free_description")
340-
if (role === UserRole.Pro || role === UserRole.Plus) {
341-
summarySubtitle = t("subscription.summary.active")
342-
} else if (daysLeft && daysLeft > 0 && role && role !== UserRole.Free) {
386+
if (daysLeft && daysLeft > 0 && role && role !== UserRole.Free) {
343387
summarySubtitle = t("subscription.summary.trial_expiring", {
344388
date: dayjs(roleEndAt).format("MMMM D, YYYY"),
345389
days: daysLeft,
346390
})
391+
} else if (currentPlan && currentPlan.role !== UserRole.Free) {
392+
summarySubtitle = t("subscription.summary.active")
347393
}
348394

349395
return (
@@ -407,6 +453,7 @@ export const PlanScreen: NavigationControllerView = () => {
407453
isProcessing={isProcessing || isPurchasing || isProcessingPurchase}
408454
isManaging={billingPortalMutation.isPending}
409455
activeSubscription={billingSubscriptionQuery.data}
456+
upgradeButtonText={plan.upgradeButtonText}
410457
/>
411458
)
412459
})}
@@ -568,6 +615,7 @@ const PlanCard = ({
568615
isProcessing,
569616
isManaging,
570617
activeSubscription,
618+
upgradeButtonText,
571619
}: {
572620
plan: PaymentPlan
573621
billingPeriod: BillingPeriod
@@ -578,6 +626,7 @@ const PlanCard = ({
578626
isProcessing?: boolean
579627
isManaging?: boolean
580628
activeSubscription?: ActiveSubscription
629+
upgradeButtonText?: string
581630
}) => {
582631
const { t } = useTranslation("settings")
583632

@@ -645,11 +694,14 @@ const PlanCard = ({
645694
const planDescription = t(`plan.descriptions.${plan.role}` as const, { defaultValue: "" })
646695

647696
const features = useMemo(() => {
648-
return (
649-
Object.entries(plan.limit || {}) as Array<
650-
[keyof PaymentFeature, PaymentFeature[keyof PaymentFeature]]
651-
>
652-
)
697+
const fallbackFeatureOrder = Object.keys(plan.limit || {}) as Array<keyof PaymentFeature>
698+
const orderedFeatureKeys = [
699+
...PLAN_FEATURE_ORDER,
700+
...fallbackFeatureOrder.filter((featureKey) => !PLAN_FEATURE_ORDER.includes(featureKey)),
701+
]
702+
703+
return orderedFeatureKeys
704+
.map((featureKey) => [featureKey, plan.limit?.[featureKey]] as const)
653705
.filter(([, value]) => isFeatureValueVisible(value))
654706
.slice(0, 6)
655707
}, [plan.limit])
@@ -719,7 +771,7 @@ const PlanCard = ({
719771

720772
<View className="mt-4 gap-2">
721773
{features.map(([featureKey, value]) => {
722-
const formattedValue = formatFeatureValue(featureKey, value)
774+
const formattedValue = formatFeatureValue(featureKey, value, t)
723775
const showValue = !(typeof value === "boolean" && value)
724776
return (
725777
<View key={featureKey as string} className="flex-row items-center gap-3">
@@ -755,6 +807,7 @@ const PlanCard = ({
755807
isProcessing={isProcessing}
756808
isManaging={isManaging}
757809
activeSubscription={activeSubscription}
810+
upgradeButtonText={upgradeButtonText}
758811
/>
759812
</View>
760813
)
@@ -767,13 +820,15 @@ const PlanAction = ({
767820
isProcessing,
768821
isManaging,
769822
activeSubscription,
823+
upgradeButtonText,
770824
}: {
771825
actionType: "current" | "upgrade" | "coming-soon" | null
772826
onUpgrade?: () => void
773827
onManageSubscription?: () => void
774828
isProcessing?: boolean
775829
isManaging?: boolean
776830
activeSubscription?: ActiveSubscription
831+
upgradeButtonText?: string
777832
}) => {
778833
const { t } = useTranslation("settings")
779834

@@ -865,7 +920,7 @@ const PlanAction = ({
865920
<ActivityIndicator color="white" />
866921
) : (
867922
<Text className="text-base font-semibold text-white">
868-
{t("subscription.actions.upgrade")}
923+
{upgradeButtonText || t("subscription.actions.upgrade")}
869924
</Text>
870925
)}
871926
</Pressable>

apps/mobile/src/modules/settings/utils.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import * as Sharing from "expo-sharing"
55

66
import { getDbPath } from "@/src/database"
77
import { followApi } from "@/src/lib/api-client"
8+
import { toastFetchError } from "@/src/lib/error-parser"
89
import { pickImage } from "@/src/lib/native/picker"
9-
import { getBizFetchErrorMessage } from "@/src/lib/parse-api-error"
1010
import { toast } from "@/src/lib/toast"
1111

1212
export const setAvatar = async () => {
@@ -22,7 +22,7 @@ export const setAvatar = async () => {
2222
file: formData.get("file") as any,
2323
} as any)
2424
.catch((err) => {
25-
toast.error(getBizFetchErrorMessage(err))
25+
toastFetchError(err)
2626
throw err
2727
})
2828

@@ -34,7 +34,7 @@ export const setAvatar = async () => {
3434
toast.success("Avatar updated")
3535
})
3636
.catch((err) => {
37-
toast.error(getBizFetchErrorMessage(err))
37+
toastFetchError(err)
3838
})
3939
}
4040

@@ -74,8 +74,7 @@ export const importOpml = async () => {
7474
`Import successful, ${successfulItems.length} feeds were imported, ${conflictItems.length} feeds were already subscribed, and ${parsedErrorItems.length} feeds failed to import.`,
7575
)
7676
} catch (error) {
77-
const bizError = getBizFetchErrorMessage(error as Error)
78-
toast.error(`Import failed${bizError ? `: ${bizError}` : ""}`)
77+
toastFetchError(error as Error)
7978
console.error(error)
8079
}
8180
}

apps/mobile/src/screens/(modal)/ListScreen.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import {
2323
GroupedInsetButtonCell,
2424
GroupedInsetListCard,
2525
} from "@/src/components/ui/grouped/GroupedList"
26+
import { toastFetchError } from "@/src/lib/error-parser"
2627
import { useNavigation } from "@/src/lib/navigation/hooks"
2728
import { useSetModalScreenOptions } from "@/src/lib/navigation/ScreenOptionsContext"
2829
import type { NavigationControllerView } from "@/src/lib/navigation/types"
29-
import { getBizFetchErrorMessage } from "@/src/lib/parse-api-error"
3030
import { toast } from "@/src/lib/toast"
3131
import { FeedViewSelector } from "@/src/modules/feed/view-selector"
3232

@@ -218,11 +218,7 @@ const ScreenOptions = memo(({ title, listId }: ScreenOptionsProps) => {
218218
toast.success("List created")
219219
navigation.dismiss()
220220
} catch (error) {
221-
toast.error(
222-
error instanceof Error
223-
? getBizFetchErrorMessage(error)
224-
: "Failed to create list",
225-
)
221+
toastFetchError(error as Error)
226222
console.error(error)
227223
} finally {
228224
setIsLoading(false)
@@ -243,9 +239,7 @@ const ScreenOptions = memo(({ title, listId }: ScreenOptionsProps) => {
243239
toast.success("List updated")
244240
navigation.dismiss()
245241
} catch (error) {
246-
toast.error(
247-
error instanceof Error ? getBizFetchErrorMessage(error) : "Failed to update list",
248-
)
242+
toastFetchError(error as Error)
249243
console.error(error)
250244
} finally {
251245
setIsLoading(false)

locales/settings/en.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -579,10 +579,10 @@
579579
"notifications.token": "Client Token",
580580
"plan.canceled_expires": "Canceled - Expires {{date}}",
581581
"plan.current_plan": "Current Plan",
582-
"plan.descriptions.basic": "More feeds without AI features.",
582+
"plan.descriptions.basic": "More feeds plus daily AI summaries and translations.",
583583
"plan.descriptions.free": "Great for beginners.",
584-
"plan.descriptions.plus": "Unlock AI features and more feeds.",
585-
"plan.descriptions.pro": "Full access to the best of Folo.",
584+
"plan.descriptions.plus": "Unlimited AI features with more feeds and automations.",
585+
"plan.descriptions.pro": "Highest limits, full AI access, and top performance.",
586586
"plan.featureValues.AI_MODEL_SELECTION.curated": "Curated models",
587587
"plan.featureValues.AI_MODEL_SELECTION.high_performance": "All high-end models",
588588
"plan.featureValues.AI_MODEL_SELECTION.none": "",
@@ -598,8 +598,8 @@
598598
"plan.features.MAX_AI_REQUESTS_PER_MONTH": "AI Chats Per Month",
599599
"plan.features.MAX_AI_TASKS": "AI Tasks",
600600
"plan.features.MAX_AI_TEXT_TO_SPEECH_PER_DAY": "AI Text-to-Speech Per Day",
601-
"plan.features.MAX_INBOXES": "Inbox",
602-
"plan.features.MAX_LISTS": "List",
601+
"plan.features.MAX_INBOXES": "Inboxes",
602+
"plan.features.MAX_LISTS": "Lists",
603603
"plan.features.MAX_RSSHUB_SUBSCRIPTIONS": "RSSHub Subscriptions",
604604
"plan.features.MAX_SUBSCRIPTIONS": "Feed Subscriptions",
605605
"plan.features.PRIORITY_SUPPORT": "Priority Support",

locales/settings/ja.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,10 +579,10 @@
579579
"notifications.token": "クライアントトークン",
580580
"plan.canceled_expires": "キャンセル済み - {{date}}に終了",
581581
"plan.current_plan": "現在のプラン",
582-
"plan.descriptions.basic": "AI機能なしでより多くのフィードを利用できます",
582+
"plan.descriptions.basic": "より多くのフィードに加えて、毎日のAI要約と翻訳を利用できます",
583583
"plan.descriptions.free": "初心者に最適です。",
584-
"plan.descriptions.plus": "AI機能とより多くのフィードをアンロック",
585-
"plan.descriptions.pro": "Foloの最高の機能すべてにアクセス",
584+
"plan.descriptions.plus": "無制限のAI機能と、より多くのフィードや自動化を利用できます",
585+
"plan.descriptions.pro": "最高の上限、完全なAI機能、そして最上位のパフォーマンス",
586586
"plan.featureValues.AI_MODEL_SELECTION.curated": "厳選モデル",
587587
"plan.featureValues.AI_MODEL_SELECTION.high_performance": "高性能すべて",
588588
"plan.featureValues.AI_MODEL_SELECTION.none": "",
@@ -598,7 +598,7 @@
598598
"plan.features.MAX_AI_REQUESTS_PER_MONTH": "1ヶ月あたりのAIリクエスト数",
599599
"plan.features.MAX_AI_TASKS": "AI タスク",
600600
"plan.features.MAX_AI_TEXT_TO_SPEECH_PER_DAY": "1日あたりのAI音声合成数",
601-
"plan.features.MAX_INBOXES": "受信トレイフィード",
601+
"plan.features.MAX_INBOXES": "受信箱",
602602
"plan.features.MAX_LISTS": "カスタムリスト",
603603
"plan.features.MAX_RSSHUB_SUBSCRIPTIONS": "RSSHub サブスクリプション",
604604
"plan.features.MAX_SUBSCRIPTIONS": "フィードサブスクリプション",

0 commit comments

Comments
 (0)