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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-11-05 01:06:36 +08:00
parent cd99ec9179
commit e9553e40d0
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
14 changed files with 336 additions and 69 deletions

View File

@ -5,3 +5,13 @@ import { atom } from "jotai"
export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks(
atom<Nullable<StatusConfigs>>(null),
)
export const useIsPaymentEnabled = () => {
const serverConfigs = useServerConfigs()
return Boolean(serverConfigs?.PAYMENT_ENABLED)
}
export const getIsPaymentEnabled = () => {
const serverConfigs = getServerConfigs()
return Boolean(serverConfigs?.PAYMENT_ENABLED)
}

View File

@ -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")

View File

@ -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,
},
},
})

View File

@ -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 (
<UpgradePrompt
forMeasurement={forMeasurement}
iconColor={purpleColor}
title={upgradeTitle}
description={upgradeDescription}
ctaLabel={upgradeCTA}
onPress={handleUpgradePress}
/>
)
}
if (showErrorContent && errorMessage) {
return (
<ErrorContent forMeasurement={forMeasurement} message={errorMessage} onRetry={onRetry} />
)
}
if (isReactElement) {
return <View className="mt-2">{summary}</View>
}
if (forMeasurement) {
return (
<Text className="mt-2 text-[14px] leading-[22px] text-label" selectable>
{summaryText?.trim()}
</Text>
)
}
return (
<TextInput
readOnly
multiline
className="text-[14px] leading-[22px] text-label"
value={summaryText?.trim()}
/>
)
}
const mainContent = (
<Animated.View
className={cn(
@ -137,54 +198,12 @@ export const AISummary: FC<{
height: contentHeight,
}}
>
{error ? (
<View className="mt-3">
<View className="flex-row items-center gap-2">
<Text className="flex-1 text-[14px] leading-[20px] text-red">{error}</Text>
</View>
{onRetry && (
<Pressable
onPress={onRetry}
className="mt-3 self-start rounded-full bg-quaternary-system-fill px-4 py-2"
>
<Text className="text-[14px] font-medium text-label">Retry</Text>
</Pressable>
)}
</View>
) : isReactElement ? (
<View className="mt-2">{summary}</View>
) : (
<TextInput
readOnly
multiline
className="text-[14px] leading-[22px] text-label"
value={summaryText?.trim()}
/>
)}
{renderSummaryContent(false)}
</View>
</Animated.View>
<View className="absolute w-full opacity-0">
<View onLayout={measureContent}>
{error ? (
<View className="mt-3">
<View className="flex-row items-center gap-2">
<Text className="flex-1 text-[14px] leading-[20px] text-red">{error}</Text>
</View>
{onRetry && (
<View className="mt-3 self-start rounded-full bg-quaternary-system-fill px-4 py-2">
<Text className="text-[14px] font-medium text-label">Retry</Text>
</View>
)}
</View>
) : isReactElement ? (
<View className="mt-2">{summary}</View>
) : (
<Text className="mt-2 text-[14px] leading-[22px] text-label" selectable>
{summaryText?.trim()}
</Text>
)}
</View>
<View className="absolute w-full opacity-0" pointerEvents="none">
<View onLayout={measureContent}>{renderSummaryContent(true)}</View>
</View>
</Animated.View>
)
@ -201,6 +220,93 @@ export const AISummary: FC<{
</>
)
}
const ErrorContent = ({
forMeasurement,
message,
onRetry,
}: {
forMeasurement: boolean
message: string
onRetry?: () => void
}) => {
return (
<View className="mt-3">
<View className="flex-row items-center gap-2">
<Text className="flex-1 text-[14px] leading-[20px] text-red">{message}</Text>
</View>
{onRetry &&
(forMeasurement ? (
<View className="mt-3 self-start rounded-full bg-quaternary-system-fill px-4 py-2">
<Text className="text-[14px] font-medium text-label">Retry</Text>
</View>
) : (
<Pressable
onPress={onRetry}
className="mt-3 self-start rounded-full bg-quaternary-system-fill px-4 py-2"
>
<Text className="text-[14px] font-medium text-label">Retry</Text>
</Pressable>
))}
</View>
)
}
const UpgradePrompt = ({
forMeasurement,
iconColor,
title,
description,
ctaLabel,
onPress,
}: {
forMeasurement: boolean
iconColor: string
title: string
description: string
ctaLabel: string
onPress: () => void
}) => {
return (
<View className="mt-2 flex-row items-start gap-3">
{/* Icon */}
<View className="relative">
<View className="rounded-lg bg-purple p-2.5">
<PowerMonoIcon width={18} height={18} color="white" />
</View>
</View>
{/* Content */}
<View className="flex-1 gap-1">
{/* Title */}
<Text className="text-sm font-medium text-label">{title}</Text>
{/* Description */}
<Text className="text-sm text-secondary-label">{description}</Text>
{/* CTA Button */}
{forMeasurement ? (
<View className="mt-1 flex-row items-center gap-1 self-start">
<Text className="text-[13px] font-medium" style={{ color: iconColor }}>
{ctaLabel}
</Text>
</View>
) : (
<TouchableOpacity
onPress={onPress}
className="mt-1 flex-row items-center gap-1 self-start active:opacity-70"
>
<Text className="text-[13px] font-medium" style={{ color: iconColor }}>
{ctaLabel}
</Text>
<RightCuteReIcon width={14} height={14} color={iconColor} />
</TouchableOpacity>
)}
</View>
</View>
)
}
const SelectableTextSheet: FC<{
visible: boolean
onClose: () => void

View File

@ -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 (
<View className="gap-3">
<Text className="text-base font-semibold text-label">{title}</Text>
<Text className="text-sm leading-relaxed text-secondary-label">{description}</Text>
<Dialog.DialogConfirm
onPress={() => {
ctx?.dismiss()
setTimeout(() => {
void navigateToPlanScreen()
}, 16)
}}
/>
</View>
)
}
UpgradeRequiredDialog.id = "upgrade-required-dialog"
UpgradeRequiredDialog.confirmText = t("settings:subscription.actions.upgrade")

View File

@ -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 (
<ErrorBoundary
@ -67,7 +70,8 @@ export const EntryAISummary: FC<{
rawSummaryForCopy={maybeMarkdown}
summary={summaryToShow}
pending={status === SummaryGeneratingStatus.Pending}
error={status === SummaryGeneratingStatus.Error ? "Failed to generate summary" : undefined}
error={summaryError}
onRetry={status === SummaryGeneratingStatus.Error ? handleRetry : undefined}
/>
</ErrorBoundary>
)

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}`>
@ -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 })
}

View File

@ -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<Partial<Record<BillingPeriod, SegmentLayout>>>({})

View File

@ -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
})
}

View File

@ -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",
})
},
},
],
},
]

View File

@ -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",

View File

@ -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": "戻る",

View File

@ -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": "返回",

View File

@ -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": "返回",