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:
parent
cd99ec9179
commit
e9553e40d0
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>>({})
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "戻る",
|
||||
|
|
|
|||
|
|
@ -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": "返回",
|
||||
|
|
|
|||
|
|
@ -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": "返回",
|
||||
|
|
|
|||
Loading…
Reference in New Issue