From e9553e40d0c2970080587bdd374dcc3e9df19fcb Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 5 Nov 2025 01:06:36 +0800 Subject: [PATCH] feat(payment): implement upgrade prompt for AI summary and enhance error handling Added functionality to suggest an upgrade when payment-related errors occur in the AI summary component. Introduced a new UpgradeRequiredDialog for user prompts and integrated it with existing error handling. Updated localization for upgrade messages in multiple languages. Refactored related components to improve clarity and maintainability. Signed-off-by: Innei --- apps/mobile/src/atoms/server-configs.ts | 10 + apps/mobile/src/lib/error-parser.ts | 56 +++-- apps/mobile/src/lib/query-client.ts | 22 ++ apps/mobile/src/modules/ai/summary.tsx | 198 ++++++++++++++---- .../modules/dialogs/UpgradeRequiredDialog.tsx | 61 ++++++ .../modules/entry-content/EntryAISummary.tsx | 8 +- .../src/modules/settings/SettingsList.tsx | 6 +- .../routes/{Subscription.tsx => Plan.tsx} | 4 +- .../settings/routes/navigateToPlanScreen.ts | 14 ++ .../src/screens/(headless)/DebugScreen.tsx | 10 + locales/mobile/default/en.json | 4 + locales/mobile/default/ja.json | 4 + locales/mobile/default/zh-CN.json | 4 + locales/mobile/default/zh-TW.json | 4 + 14 files changed, 336 insertions(+), 69 deletions(-) create mode 100644 apps/mobile/src/modules/dialogs/UpgradeRequiredDialog.tsx rename apps/mobile/src/modules/settings/routes/{Subscription.tsx => Plan.tsx} (99%) create mode 100644 apps/mobile/src/modules/settings/routes/navigateToPlanScreen.ts diff --git a/apps/mobile/src/atoms/server-configs.ts b/apps/mobile/src/atoms/server-configs.ts index 622704453..3112b93d5 100644 --- a/apps/mobile/src/atoms/server-configs.ts +++ b/apps/mobile/src/atoms/server-configs.ts @@ -5,3 +5,13 @@ import { atom } from "jotai" export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks( atom>(null), ) + +export const useIsPaymentEnabled = () => { + const serverConfigs = useServerConfigs() + return Boolean(serverConfigs?.PAYMENT_ENABLED) +} + +export const getIsPaymentEnabled = () => { + const serverConfigs = getServerConfigs() + return Boolean(serverConfigs?.PAYMENT_ENABLED) +} diff --git a/apps/mobile/src/lib/error-parser.ts b/apps/mobile/src/lib/error-parser.ts index d4f09a9cf..07b64348c 100644 --- a/apps/mobile/src/lib/error-parser.ts +++ b/apps/mobile/src/lib/error-parser.ts @@ -2,6 +2,9 @@ import { FollowAPIError } from "@follow-app/client-sdk" import { t } from "i18next" import { FetchError } from "ofetch" +import { getIsPaymentEnabled } from "@/src/atoms/server-configs" +import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog" + import { toast } from "./toast" export const getFetchErrorInfo = ( @@ -55,41 +58,52 @@ export const createErrorToaster = (title?: string) => (err: Error) => toastFetchError(err, { title }) export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => { - let message = "" + const { message: fallbackMessage } = error + let message = fallbackMessage let _reason = "" let code: number | undefined + let status: number | undefined if (error instanceof FetchError) { try { + const resolvedStatus = error.statusCode ?? error.response?.status + if (resolvedStatus != null) { + status = Number(resolvedStatus) + } const json = typeof error.response?._data === "string" ? JSON.parse(error.response?._data) : error.response?._data const { reason, code: _code, message: _message } = json - code = _code - message = _message + if (_code != null) { + code = typeof _code === "number" ? _code : Number(_code) + } + message = typeof _message === "string" && _message.trim() ? _message : message - const i18nMessage = message - - message = i18nMessage - - if (reason) { + if (typeof reason === "string" && reason.trim()) { _reason = reason } } catch { - message = error.message + message = fallbackMessage } } - if (error instanceof FollowAPIError && error.code) { - code = Number(error.code) + if (error instanceof FollowAPIError) { + if (error.code) { + code = Number(error.code) + } + status = error.status ? Number(error.status) : status try { - const tValue = t(`errors:${code}` as any) - const i18nMessage = tValue === code?.toString() ? error.message : tValue - message = i18nMessage + if (error.code) { + const tValue = t(`errors:${code}` as any) + const i18nMessage = tValue === code?.toString() ? error.message : tValue + message = i18nMessage + } else { + message = fallbackMessage + } } catch { - message = error.message + message = fallbackMessage } } @@ -98,8 +112,18 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin return } + const needUpgradeError = status === 402 && getIsPaymentEnabled() + + if (needUpgradeError) { + showUpgradeRequiredDialog({ + title: _title || message, + message: _reason || message, + }) + return + } + if (!_reason) { - const title = _title || message + const title = _title || message || "Unknown error" return toast.error(title) } else { return toast.error(message || _title || "Unknown error") diff --git a/apps/mobile/src/lib/query-client.ts b/apps/mobile/src/lib/query-client.ts index ff404d56f..992fc76c2 100644 --- a/apps/mobile/src/lib/query-client.ts +++ b/apps/mobile/src/lib/query-client.ts @@ -1,12 +1,34 @@ +import { FollowAPIError } from "@follow-app/client-sdk" import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister" import { QueryClient } from "@tanstack/react-query" +import { FetchError } from "ofetch" import { kv } from "./kv" +const defaultStaleTime = 600_000 // 10min +const DO_NOT_RETRY_CODES = new Set([400, 401, 403, 404, 422, 402]) export const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24, + refetchOnWindowFocus: false, + retryDelay: 1000, + staleTime: defaultStaleTime, + retry(failureCount, error) { + if ( + error instanceof FetchError && + (error.statusCode === undefined || DO_NOT_RETRY_CODES.has(error.statusCode)) + ) { + return false + } + + if (error instanceof FollowAPIError && DO_NOT_RETRY_CODES.has(error.status)) { + return false + } + + return !!(3 - failureCount) + }, + // throwOnError: import.meta.env.DEV, }, }, }) diff --git a/apps/mobile/src/modules/ai/summary.tsx b/apps/mobile/src/modules/ai/summary.tsx index f8cacaa33..6881414f4 100644 --- a/apps/mobile/src/modules/ai/summary.tsx +++ b/apps/mobile/src/modules/ai/summary.tsx @@ -1,9 +1,11 @@ import { cn } from "@follow/utils" +import { FollowAPIError } from "@follow-app/client-sdk" import MaskedView from "@react-native-masked-view/masked-view" import * as Haptics from "expo-haptics" import { LinearGradient } from "expo-linear-gradient" import type { FC, ReactNode } from "react" import * as React from "react" +import { useTranslation } from "react-i18next" import type { LayoutChangeEvent } from "react-native" import { Clipboard, @@ -25,22 +27,27 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context" import { useColor } from "react-native-uikit-colors" +import { useIsPaymentEnabled } from "@/src/atoms/server-configs" import { BottomModal } from "@/src/components/ui/modal/BottomModal" import { Text } from "@/src/components/ui/typography/Text" import { AiCuteReIcon } from "@/src/icons/ai_cute_re" import { CloseCuteReIcon } from "@/src/icons/close_cute_re" import { CopyCuteReIcon } from "@/src/icons/copy_cute_re" +import { PowerMonoIcon } from "@/src/icons/power_mono" +import { RightCuteReIcon } from "@/src/icons/right_cute_re" import { isAndroid, isIOS } from "@/src/lib/platform" import { toast } from "@/src/lib/toast" +import { navigateToPlanScreen } from "@/src/modules/settings/routes/navigateToPlanScreen" export const AISummary: FC<{ className?: string summary?: string | ReactNode pending?: boolean rawSummaryForCopy?: string - error?: string + error?: unknown onRetry?: () => void }> = ({ className, summary, pending = false, rawSummaryForCopy, error, onRetry }) => { + const { t } = useTranslation() const opacity = useSharedValue(0.3) const height = useSharedValue(0) const [isSheetOpen, setSheetOpen] = React.useState(false) @@ -79,12 +86,66 @@ export const AISummary: FC<{ }) } const purpleColor = useColor("purple") + const isPaymentEnabled = useIsPaymentEnabled() + const followApiError = error instanceof FollowAPIError ? error : null + const shouldSuggestUpgrade = Boolean(isPaymentEnabled && followApiError?.status === 402) + const errorMessage = + typeof error === "string" ? error : error instanceof Error ? error.message : undefined + const showErrorContent = Boolean(errorMessage && !shouldSuggestUpgrade) + const upgradeTitle = t("ai.summary_upgrade_required_title") + const upgradeDescription = t("ai.summary_upgrade_required_description") + const upgradeCTA = t("ai.summary_upgrade_view_plans") + const handleUpgradePress = () => { + void Haptics.selectionAsync() + void navigateToPlanScreen() + } // Check if summary is a React element or string const isReactElement = React.isValidElement(summary) const summaryText = typeof summary === "string" ? summary : "" const summaryTextForSheet = rawSummaryForCopy || summaryText if (pending || (!summary && !error)) return null + const renderSummaryContent = (forMeasurement: boolean) => { + if (shouldSuggestUpgrade) { + return ( + + ) + } + + if (showErrorContent && errorMessage) { + return ( + + ) + } + + if (isReactElement) { + return {summary} + } + + if (forMeasurement) { + return ( + + {summaryText?.trim()} + + ) + } + + return ( + + ) + } const mainContent = ( - {error ? ( - - - {error} - - {onRetry && ( - - Retry - - )} - - ) : isReactElement ? ( - {summary} - ) : ( - - )} + {renderSummaryContent(false)} - - - {error ? ( - - - {error} - - {onRetry && ( - - Retry - - )} - - ) : isReactElement ? ( - {summary} - ) : ( - - {summaryText?.trim()} - - )} - + + {renderSummaryContent(true)} ) @@ -201,6 +220,93 @@ export const AISummary: FC<{ ) } + +const ErrorContent = ({ + forMeasurement, + message, + onRetry, +}: { + forMeasurement: boolean + message: string + onRetry?: () => void +}) => { + return ( + + + {message} + + {onRetry && + (forMeasurement ? ( + + Retry + + ) : ( + + Retry + + ))} + + ) +} + +const UpgradePrompt = ({ + forMeasurement, + iconColor, + title, + description, + ctaLabel, + onPress, +}: { + forMeasurement: boolean + iconColor: string + title: string + description: string + ctaLabel: string + onPress: () => void +}) => { + return ( + + {/* Icon */} + + + + + + + {/* Content */} + + {/* Title */} + {title} + + {/* Description */} + {description} + + {/* CTA Button */} + {forMeasurement ? ( + + + {ctaLabel} + + + ) : ( + + + {ctaLabel} + + + + )} + + + ) +} + const SelectableTextSheet: FC<{ visible: boolean onClose: () => void diff --git a/apps/mobile/src/modules/dialogs/UpgradeRequiredDialog.tsx b/apps/mobile/src/modules/dialogs/UpgradeRequiredDialog.tsx new file mode 100644 index 000000000..df0e20cf2 --- /dev/null +++ b/apps/mobile/src/modules/dialogs/UpgradeRequiredDialog.tsx @@ -0,0 +1,61 @@ +import { t } from "i18next" +import { useEffect } from "react" +import { useTranslation } from "react-i18next" +import { View } from "react-native" + +import { Text } from "@/src/components/ui/typography/Text" +import type { DialogComponent } from "@/src/lib/dialog" +import { Dialog } from "@/src/lib/dialog" + +import { navigateToPlanScreen } from "../settings/routes/navigateToPlanScreen" + +type UpgradeDialogPayload = { + title?: string + message?: string +} + +const defaultPayload: UpgradeDialogPayload = {} +let currentPayload: UpgradeDialogPayload = defaultPayload + +const getPayload = () => currentPayload + +export const showUpgradeRequiredDialog = (payload?: UpgradeDialogPayload) => { + currentPayload = { + title: payload?.title, + message: payload?.message, + } + Dialog.show(UpgradeRequiredDialog) +} + +const UpgradeRequiredDialog: DialogComponent = () => { + const ctx = Dialog.useDialogContext() + const { t: tSettings } = useTranslation("settings") + const payload = getPayload() + + useEffect(() => { + return () => { + currentPayload = defaultPayload + } + }, []) + + const title = payload.title?.trim() || tSettings("subscription.actions.upgrade") + const description = payload.message?.trim() || tSettings("subscription.summary.free_description") + + return ( + + {title} + {description} + { + ctx?.dismiss() + setTimeout(() => { + void navigateToPlanScreen() + }, 16) + }} + /> + + ) +} + +UpgradeRequiredDialog.id = "upgrade-required-dialog" +UpgradeRequiredDialog.confirmText = t("settings:subscription.actions.upgrade") diff --git a/apps/mobile/src/modules/entry-content/EntryAISummary.tsx b/apps/mobile/src/modules/entry-content/EntryAISummary.tsx index 4fc07622c..472d4e1e0 100644 --- a/apps/mobile/src/modules/entry-content/EntryAISummary.tsx +++ b/apps/mobile/src/modules/entry-content/EntryAISummary.tsx @@ -35,7 +35,7 @@ export const EntryAISummary: FC<{ ) const actionLanguage = useActionLanguage() const summary = useSummary(entryId, actionLanguage) - usePrefetchSummary({ + const { error: summaryError, refetch: refetchSummary } = usePrefetchSummary({ entryId, target: entry?.target || "content", actionLanguage, @@ -53,6 +53,9 @@ export const EntryAISummary: FC<{ actionLanguage, target: entry?.target || "content", }) + const handleRetry = useCallback(() => { + refetchSummary() + }, [refetchSummary]) if (!showAISummary) return null return ( ) diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index be46a7812..1fcfddaaa 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}`> @@ -121,7 +121,7 @@ const ReferralGroupNavigationLinks: GroupNavigationLink[] = [ label: "titles.subscription.short", icon: PowerOutlineIcon, onPress: ({ navigation }) => { - navigation.pushControllerView(SubscriptionScreen) + navigation.pushControllerView(PlanScreen) }, iconBackgroundColor: accentColor, anonymous: false, @@ -237,7 +237,7 @@ const NavigationLinkGroup: FC<{ } onPress={() => { if (link.trialNotAllowed && (role === UserRole.Free || role === UserRole.Trial)) { - navigation.presentControllerView(SubscriptionScreen) + navigation.presentControllerView(PlanScreen) } else { link.onPress({ navigation }) } diff --git a/apps/mobile/src/modules/settings/routes/Subscription.tsx b/apps/mobile/src/modules/settings/routes/Plan.tsx similarity index 99% rename from apps/mobile/src/modules/settings/routes/Subscription.tsx rename to apps/mobile/src/modules/settings/routes/Plan.tsx index a0538b7c8..08a391223 100644 --- a/apps/mobile/src/modules/settings/routes/Subscription.tsx +++ b/apps/mobile/src/modules/settings/routes/Plan.tsx @@ -99,7 +99,7 @@ const isFeatureValueVisible = (value: PaymentFeature[keyof PaymentFeature] | nul return true } -export const SubscriptionScreen: NavigationControllerView = () => { +export const PlanScreen: NavigationControllerView = () => { const { t } = useTranslation("settings") const serverConfigs = useServerConfigs() const role = useUserRole() @@ -267,7 +267,7 @@ const BillingToggle = ({ averageSavings: number }) => { const { t } = useTranslation("settings") - const activeBackground = useColor("systemBackground") + const activeBackground = useColor("quaternarySystemFill") const indicatorTranslate = useRef(new Animated.Value(0)).current const indicatorWidth = useRef(new Animated.Value(0)).current const segmentLayouts = useRef>>({}) diff --git a/apps/mobile/src/modules/settings/routes/navigateToPlanScreen.ts b/apps/mobile/src/modules/settings/routes/navigateToPlanScreen.ts new file mode 100644 index 000000000..b46b16015 --- /dev/null +++ b/apps/mobile/src/modules/settings/routes/navigateToPlanScreen.ts @@ -0,0 +1,14 @@ +import { Navigation } from "@/src/lib/navigation/Navigation" + +export const navigateToPlanScreen = () => { + return import("./Plan") + .then(({ PlanScreen }) => { + Navigation.rootNavigation.pushControllerView(PlanScreen) + }) + .catch((error) => { + if (__DEV__) { + console.error("Failed to open plan screen", error) + } + throw error + }) +} diff --git a/apps/mobile/src/screens/(headless)/DebugScreen.tsx b/apps/mobile/src/screens/(headless)/DebugScreen.tsx index db9061d7a..68fd90e74 100644 --- a/apps/mobile/src/screens/(headless)/DebugScreen.tsx +++ b/apps/mobile/src/screens/(headless)/DebugScreen.tsx @@ -31,6 +31,7 @@ import { NavigationSitemapRegistry } from "@/src/lib/navigation/sitemap/registry import type { NavigationControllerView } from "@/src/lib/navigation/types" import { setEnvProfile, useEnvProfile } from "@/src/lib/proxy-env" import { toast } from "@/src/lib/toast" +import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog" import { ProfileScreen } from "../(modal)/ProfileScreen" import { MarkdownScreen } from "./(debug)/markdown" @@ -175,6 +176,15 @@ export const DebugScreen: NavigationControllerView = () => { }) }, }, + { + title: "Upgrade Required Dialog", + onPress: () => { + showUpgradeRequiredDialog({ + title: "Upgrade Required", + message: "Please upgrade to continue", + }) + }, + }, ], }, ] diff --git a/locales/mobile/default/en.json b/locales/mobile/default/en.json index 1a611aba4..5c17ad614 100644 --- a/locales/mobile/default/en.json +++ b/locales/mobile/default/en.json @@ -1,4 +1,8 @@ { + "ai.summary_not_available": "Summary not available", + "ai.summary_upgrade_required_description": "Unlock unlimited AI summaries, translations, and more intelligent features with Pro plan.", + "ai.summary_upgrade_required_title": "Upgrade plan to continue using AI summary", + "ai.summary_upgrade_view_plans": "View Plans", "entry.pull_up_to_next_entry": "Pull up to go to the next entry", "entry.release_to_next_entry": "Release to go to the next entry", "login.back": "Back", diff --git a/locales/mobile/default/ja.json b/locales/mobile/default/ja.json index c79357f71..6b7801b47 100644 --- a/locales/mobile/default/ja.json +++ b/locales/mobile/default/ja.json @@ -1,4 +1,8 @@ { + "ai.summary_not_available": "サマリーは利用できません", + "ai.summary_upgrade_required_description": "Pro プランにアップグレードして、無制限の AI サマリー、翻訳、およびその他のインテリジェント機能をアンロックしましょう。", + "ai.summary_upgrade_required_title": "AI サマリーを引き続き使用するにはプランをアップグレードしてください", + "ai.summary_upgrade_view_plans": "プランを表示", "entry.pull_up_to_next_entry": "引っ張って次のエントリーに移動", "entry.release_to_next_entry": "離して次のエントリーに移動", "login.back": "戻る", diff --git a/locales/mobile/default/zh-CN.json b/locales/mobile/default/zh-CN.json index 4f8117a17..0e1df4476 100644 --- a/locales/mobile/default/zh-CN.json +++ b/locales/mobile/default/zh-CN.json @@ -1,4 +1,8 @@ { + "ai.summary_not_available": "摘要不可用", + "ai.summary_upgrade_required_description": "升级到 Pro 计划,解锁无限 AI 摘要、翻译和更多智能功能。", + "ai.summary_upgrade_required_title": "升级计划以继续使用 AI 摘要", + "ai.summary_upgrade_view_plans": "查看计划", "entry.pull_up_to_next_entry": "继续上拉查看下一条内容", "entry.release_to_next_entry": "松开查看下一条内容", "login.back": "返回", diff --git a/locales/mobile/default/zh-TW.json b/locales/mobile/default/zh-TW.json index 1752d89a0..7ec51aebe 100644 --- a/locales/mobile/default/zh-TW.json +++ b/locales/mobile/default/zh-TW.json @@ -1,4 +1,8 @@ { + "ai.summary_not_available": "暫無摘要", + "ai.summary_upgrade_required_description": "升級到 Pro 方案,解鎖無限次的 AI 摘要、翻譯以及更多智慧功能。", + "ai.summary_upgrade_required_title": "升級方案以繼續使用 AI 摘要", + "ai.summary_upgrade_view_plans": "查看方案", "entry.pull_up_to_next_entry": "繼續上拉查看下一條內容", "entry.release_to_next_entry": "鬆開查看下一條內容", "login.back": "返回",