From efd4aba8c14c225ed96898e7be25701af7e1ade8 Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 28 Jan 2026 15:57:40 +0800 Subject: [PATCH] fix: bug --- app/(tabs)/__tests__/index.test.tsx | 199 ++++++++++++++ app/(tabs)/__tests__/my.test.tsx | 122 +++++++- app/(tabs)/index.tsx | 38 ++- app/(tabs)/my.tsx | 58 +++- app/membership.tsx | 413 ++++++++++++++-------------- components/drawer/PointsDrawer.tsx | 5 + hooks/use-categories-with-tags.ts | 12 +- hooks/use-membership.ts | 18 +- hooks/use-template-generations.ts | 14 +- lib/auth.ts | 29 +- locales/en-US.json | 15 + locales/zh-CN.json | 15 + 12 files changed, 676 insertions(+), 262 deletions(-) diff --git a/app/(tabs)/__tests__/index.test.tsx b/app/(tabs)/__tests__/index.test.tsx index 3eee496..68d1d20 100644 --- a/app/(tabs)/__tests__/index.test.tsx +++ b/app/(tabs)/__tests__/index.test.tsx @@ -35,6 +35,14 @@ jest.mock('@/components/icon', () => ({ jest.mock('@/components/ErrorState', () => 'ErrorState') jest.mock('@/components/LoadingState', () => 'LoadingState') +// Mock home blocks +jest.mock('@/components/blocks/home', () => ({ + TitleBar: 'TitleBar', + HeroSlider: 'HeroSlider', + TabNavigation: 'TabNavigation', + TemplateCard: 'TemplateCard', +})) + // Mock react-native RefreshControl (directly from react-native) jest.mock('react-native', () => Object.assign({}, jest.requireActual('react-native'), { @@ -108,6 +116,69 @@ jest.mock('@/hooks/use-user-balance', () => ({ })), })) +jest.mock('@/hooks/use-categories-with-tags', () => ({ + useCategoriesWithTags: jest.fn(() => ({ + load: jest.fn(), + data: { + categories: [ + { + id: 'cat1', + name: 'Test Category', + }, + ], + }, + loading: false, + error: null, + })), +})) + +jest.mock('@/hooks/use-category-templates', () => ({ + useCategoryTemplates: jest.fn(() => ({ + templates: [ + { + id: 'template1', + title: 'Test Template', + webpPreviewUrl: 'https://example.com/preview.webp', + previewUrl: 'https://example.com/preview.jpg', + coverImageUrl: 'https://example.com/cover.png', + aspectRatio: '1:1', + }, + ], + loading: false, + loadingMore: false, + execute: jest.fn(), + loadMore: jest.fn(), + hasMore: false, + })), +})) + +jest.mock('@/hooks/use-sticky-tabs', () => ({ + useStickyTabs: jest.fn(() => ({ + isSticky: false, + tabsHeight: 0, + titleBarHeightRef: { current: 0 }, + handleScroll: jest.fn(), + handleTabsLayout: jest.fn(), + handleTitleBarLayout: jest.fn(), + })), +})) + +jest.mock('@/hooks/use-tab-navigation', () => ({ + useTabNavigation: jest.fn(() => ({ + activeIndex: 0, + selectedCategoryId: 'cat1', + tabs: [{ id: 'cat1', name: 'Test Category' }], + selectTab: jest.fn(), + selectCategoryById: jest.fn(), + })), +})) + +jest.mock('@/hooks/use-template-filter', () => ({ + useTemplateFilter: jest.fn(({ templates }) => ({ + filteredTemplates: templates, + })), +})) + const renderWithProviders = (component: React.ReactElement) => { return render(component) } @@ -152,4 +223,132 @@ describe('HomeScreen', () => { expect(getByText('1234')).toBeTruthy() }) }) + + describe('Loading States', () => { + it('should show only one loading indicator when categories are loading', async () => { + const { useCategoriesWithTags } = require('@/hooks/use-categories-with-tags') + const { useCategoryTemplates } = require('@/hooks/use-category-templates') + + useCategoriesWithTags.mockReturnValue({ + load: jest.fn(), + data: null, + loading: true, + error: null, + }) + + useCategoryTemplates.mockReturnValue({ + templates: [], + loading: false, + loadingMore: false, + execute: jest.fn(), + loadMore: jest.fn(), + hasMore: false, + }) + + const { UNSAFE_getAllByType } = renderWithProviders() + + await waitFor(() => { + // 应该只有一个 LoadingState 组件 + const loadingStates = UNSAFE_getAllByType('LoadingState' as any) + expect(loadingStates.length).toBe(1) + }) + }) + + it('should show only one loading indicator when templates are loading', async () => { + const { useCategoriesWithTags } = require('@/hooks/use-categories-with-tags') + const { useCategoryTemplates } = require('@/hooks/use-category-templates') + + useCategoriesWithTags.mockReturnValue({ + load: jest.fn(), + data: { + categories: [{ id: 'cat1', name: 'Test Category' }], + }, + loading: false, + error: null, + }) + + useCategoryTemplates.mockReturnValue({ + templates: [], + loading: true, + loadingMore: false, + execute: jest.fn(), + loadMore: jest.fn(), + hasMore: false, + }) + + const { UNSAFE_queryAllByType } = renderWithProviders() + + await waitFor(() => { + // 应该只有一个 ActivityIndicator + const activityIndicators = UNSAFE_queryAllByType('ActivityIndicator' as any) + expect(activityIndicators.length).toBeLessThanOrEqual(1) + }) + }) + + it('should NOT show multiple loading indicators simultaneously', async () => { + const { useCategoriesWithTags } = require('@/hooks/use-categories-with-tags') + const { useCategoryTemplates } = require('@/hooks/use-category-templates') + + // 模拟两个都在加载的情况 + useCategoriesWithTags.mockReturnValue({ + load: jest.fn(), + data: null, + loading: true, + error: null, + }) + + useCategoryTemplates.mockReturnValue({ + templates: [], + loading: true, + loadingMore: false, + execute: jest.fn(), + loadMore: jest.fn(), + hasMore: false, + }) + + const { UNSAFE_queryAllByType } = renderWithProviders() + + await waitFor(() => { + // 即使两个都在加载,也应该只显示一个 loading + const loadingStates = UNSAFE_queryAllByType('LoadingState' as any) + const activityIndicators = UNSAFE_queryAllByType('ActivityIndicator' as any) + + // 总共的 loading 指示器不应该超过 1 个 + const totalLoadingIndicators = loadingStates.length + activityIndicators.length + expect(totalLoadingIndicators).toBeLessThanOrEqual(1) + }) + }) + + it('should show unified loading state during initial load', async () => { + const { useCategoriesWithTags } = require('@/hooks/use-categories-with-tags') + const { useCategoryTemplates } = require('@/hooks/use-category-templates') + + useCategoriesWithTags.mockReturnValue({ + load: jest.fn(), + data: null, + loading: true, + error: null, + }) + + useCategoryTemplates.mockReturnValue({ + templates: [], + loading: true, + loadingMore: false, + execute: jest.fn(), + loadMore: jest.fn(), + hasMore: false, + }) + + const { UNSAFE_queryAllByType } = renderWithProviders() + + await waitFor(() => { + // 应该只显示一个统一的 loading 状态 + const allLoadingComponents = [ + ...UNSAFE_queryAllByType('LoadingState' as any), + ...UNSAFE_queryAllByType('ActivityIndicator' as any), + ] + expect(allLoadingComponents.length).toBe(1) + }) + }) + }) }) diff --git a/app/(tabs)/__tests__/my.test.tsx b/app/(tabs)/__tests__/my.test.tsx index 9bce14e..36c9163 100644 --- a/app/(tabs)/__tests__/my.test.tsx +++ b/app/(tabs)/__tests__/my.test.tsx @@ -1,7 +1,24 @@ import React from 'react' -import { render, waitFor } from '@testing-library/react-native' +import { render, waitFor, fireEvent } from '@testing-library/react-native' import My from '../my' +// Mock react-native-safe-area-context FIRST +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaProvider: ({ children }: any) => children, + SafeAreaView: ({ children }: any) => children, + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})) + +// Mock expo-status-bar +jest.mock('expo-status-bar', () => ({ + StatusBar: 'StatusBar', +})) + +// Mock expo-image +jest.mock('expo-image', () => ({ + Image: 'Image', +})) + jest.mock('expo-router', () => ({ useRouter: () => ({ push: jest.fn(), @@ -36,6 +53,40 @@ jest.mock('@/hooks/use-user-balance', () => ({ }), })) +jest.mock('@/components/skeleton/MySkeleton', () => ({ + MySkeleton: () => 'MySkeleton', +})) + +jest.mock('@/components/icon', () => ({ + PointsIcon: () => 'PointsIcon', + SearchIcon: () => 'SearchIcon', + SettingsIcon: () => 'SettingsIcon', +})) + +jest.mock('@/components/drawer/EditProfileDrawer', () => ({ + __esModule: true, + default: () => 'EditProfileDrawer', +})) + +jest.mock('@/components/ui/dropdown', () => ({ + __esModule: true, + default: () => 'Dropdown', +})) + +jest.mock('@/components/ui/Toast', () => ({ + __esModule: true, + default: { + show: jest.fn(), + showLoading: jest.fn(), + hideLoading: jest.fn(), + showActionSheet: jest.fn(), + }, +})) + +jest.mock('expo-image', () => ({ + Image: 'Image', +})) + const mockRefetch = jest.fn() const mockLoadMore = jest.fn() @@ -65,21 +116,66 @@ describe('My Page - Pagination', () => { it('should refresh with limit 20 on pull to refresh', async () => { const { useTemplateGenerations } = require('@/hooks') - const mockOnRefresh = jest.fn() + const mockRefetchAsync = jest.fn().mockResolvedValue({ data: [], error: null }) useTemplateGenerations.mockReturnValue({ generations: [{ id: '1', status: 'completed' }], loading: false, loadingMore: false, - refetch: mockRefetch, + refetch: mockRefetchAsync, loadMore: mockLoadMore, hasMore: true, }) - render() + const { getByTestId } = render() + + // 模拟下拉刷新 + const scrollView = getByTestId('my-scroll-view') + const refreshControl = scrollView.props.refreshControl + + // 触发刷新 + await refreshControl.props.onRefresh() await waitFor(() => { - expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) + expect(mockRefetchAsync).toHaveBeenCalledWith({ page: 1, limit: 20 }) + }) + }) + + it('should stop showing refreshing indicator after refresh completes', async () => { + const { useTemplateGenerations } = require('@/hooks') + let resolveRefetch: any + const mockRefetchAsync = jest.fn(() => new Promise((resolve) => { + resolveRefetch = resolve + })) + + useTemplateGenerations.mockReturnValue({ + generations: [{ id: '1', status: 'completed' }], + loading: false, + loadingMore: false, + refetch: mockRefetchAsync, + loadMore: mockLoadMore, + hasMore: true, + }) + + const { getByTestId } = render() + const scrollView = getByTestId('my-scroll-view') + const refreshControl = scrollView.props.refreshControl + + // 开始刷新 + const refreshPromise = refreshControl.props.onRefresh() + + // 刷新中应该显示 refreshing + await waitFor(() => { + expect(refreshControl.props.refreshing).toBe(true) + }) + + // 完成刷新 + resolveRefetch({ data: [], error: null }) + await refreshPromise + + // 刷新完成后应该隐藏 refreshing + await waitFor(() => { + expect(refreshControl.props.refreshing).toBe(false) }) }) @@ -99,10 +195,22 @@ describe('My Page - Pagination', () => { hasMore: true, }) - render() + const { getByTestId } = render() + const scrollView = getByTestId('my-scroll-view') + + // 模拟滚动到底部 + const scrollEvent = { + nativeEvent: { + layoutMeasurement: { height: 800 }, + contentOffset: { y: 1000 }, + contentSize: { height: 1800 }, // 距离底部 < 100px + }, + } + + fireEvent.scroll(scrollView, scrollEvent) await waitFor(() => { - expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) + expect(mockLoadMore).toHaveBeenCalled() }) }) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index a928615..a0edd1f 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -107,14 +107,18 @@ export default function HomeScreen() { }, [params.categoryId]) // 状态判断 - 使用 useMemo 缓存 - const showLoading = categoriesLoading + // 统一 loading 状态:只要有任何一个在加载,就显示 loading + const isLoading = useMemo(() => + categoriesLoading || templatesLoading, + [categoriesLoading, templatesLoading] + ) const showEmptyState = useMemo(() => - !categoriesLoading && categoriesData?.categories?.length === 0, - [categoriesLoading, categoriesData?.categories?.length] + !isLoading && categoriesData?.categories?.length === 0, + [isLoading, categoriesData?.categories?.length] ) const showEmptyTemplates = useMemo(() => - !categoriesLoading && !templatesLoading && !showEmptyState && filteredTemplates.length === 0, - [categoriesLoading, templatesLoading, showEmptyState, filteredTemplates.length] + !isLoading && !showEmptyState && filteredTemplates.length === 0, + [isLoading, showEmptyState, filteredTemplates.length] ) const [refreshing, setRefreshing] = useState(false) @@ -129,10 +133,10 @@ export default function HomeScreen() { // 加载更多处理 const handleEndReached = useCallback(() => { - if (!loadingMore && hasMore && !templatesLoading) { + if (!loadingMore && hasMore && !isLoading) { loadMore() } - }, [loadingMore, hasMore, templatesLoading, loadMore]) + }, [loadingMore, hasMore, isLoading, loadMore]) // 导航处理 - 使用 useCallback memoize const handlePointsPress = useCallback(() => router.push('/membership' as any), [router]) @@ -178,7 +182,7 @@ export default function HomeScreen() { /> {/* 标签导航 */} - {!showLoading && !showEmptyState && ( + {!isLoading && !showEmptyState && ( handleTabsLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)} style={isSticky ? { opacity: 0, height: tabsHeight } : undefined} @@ -193,8 +197,8 @@ export default function HomeScreen() { )} - {/* 加载状态 */} - {showLoading && } + {/* 统一的加载状态 */} + {isLoading && } {/* 错误状态 */} {categoriesError && ( @@ -210,18 +214,11 @@ export default function HomeScreen() { {showEmptyTemplates && ( loadTemplates({ categoryId: selectedCategoryId! })} /> )} - - {/* 模板加载中 */} - {templatesLoading && !showLoading && ( - - - - )} ), [ activatesData?.activities, handleActivityPress, - showLoading, + isLoading, showEmptyState, isSticky, tabsHeight, @@ -231,7 +228,6 @@ export default function HomeScreen() { handleArrowPress, categoriesError, showEmptyTemplates, - templatesLoading, selectedCategoryId, handleTabsLayout, loadCategories, @@ -257,7 +253,7 @@ export default function HomeScreen() { ) // 只有在非加载状态且有数据时才显示列表 - const showTemplateList = !showLoading && !showEmptyState && !showEmptyTemplates && !templatesLoading + const showTemplateList = !isLoading && !showEmptyState && !showEmptyTemplates return ( @@ -273,7 +269,7 @@ export default function HomeScreen() { /> {/* 吸顶标签导航 */} - {isSticky && !showLoading && !showEmptyState && ( + {isSticky && !isLoading && !showEmptyState && ( { + console.log('📊 作品列表状态:', { + 总数: generations.length, + 加载中: loading, + 加载更多中: loadingMore, + 还有更多: hasMore + }) + }, [generations.length, loading, loadingMore, hasMore]) + // 初始化加载作品列表 useEffect(() => { refetch({ page: 1, limit: 20 }) @@ -77,25 +87,41 @@ export default function My() { // 下拉刷新状态 const [refreshing, setRefreshing] = useState(false) + // 防止重复触发加载更多 + const isLoadingMoreRef = useRef(false) + // 下拉刷新处理 const onRefresh = useCallback(async () => { setRefreshing(true) - await refetch({ page: 1, limit: 20 }) - setRefreshing(false) + try { + await refetch({ page: 1, limit: 20 }) + } finally { + setRefreshing(false) + } }, [refetch]) // 加载更多处理 - const handleEndReached = useCallback((event: NativeSyntheticEvent) => { + const handleScroll = useCallback((event: NativeSyntheticEvent) => { const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent - const paddingToBottom = 100 // 距离底部100px时触发加载更多 - if ( - layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom && - !loadingMore && - hasMore - ) { - loadMore() + const paddingToBottom = 200 // 距离底部200px时触发加载更多 + + const isCloseToBottom = + layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom + + // 使用 ref 防止重复触发 + if (isCloseToBottom && !loadingMore && hasMore && !loading && !isLoadingMoreRef.current) { + console.log('🔄 触发加载更多', { + layoutHeight: layoutMeasurement.height, + offsetY: contentOffset.y, + contentHeight: contentSize.height, + distance: contentSize.height - (layoutMeasurement.height + contentOffset.y) + }) + isLoadingMoreRef.current = true + loadMore().finally(() => { + isLoadingMoreRef.current = false + }) } - }, [loadingMore, hasMore, loadMore]) + }, [loadingMore, hasMore, loadMore, loading]) // 处理设置菜单选择 const handleSettingsSelect = async (value: string) => { @@ -155,7 +181,7 @@ export default function My() { ] return ( - + @@ -219,11 +245,12 @@ export default function My() { ) : ( ({ id: `plan-${index}` as PlanType, - name: index === 0 ? 'Plus' : index === 1 ? 'Pro' : 'Plus', + name: plan.name, price: plan.amountInCents / 100, recommended: plan.popular, points: plan.credits, - features: [ - t('membership.features.points750'), - t('membership.features.textToVideo'), - t('membership.features.superClearImage'), - t('membership.features.superDiscount'), - ...(index > 0 ? [ - t('membership.features.sora2ProTemplate'), - t('membership.features.removeWatermark'), - t('membership.features.allTemplates'), - ] : []), - ...(index > 1 ? [ - t('membership.features.higherQuality'), - t('membership.features.prioritySupport'), - ] : []), - ], + currency: plan.currency, + featureList: plan.featureList, })) const selectedPlan = plans[selectedPlanIndex] || plans[0] - + // 下拉菜单选项 const menuOptions = [ { label: t('membership.terms'), value: 'terms' }, { label: t('membership.privacy'), value: 'privacy' }, ] - + // 处理菜单选择 const handleMenuSelect = (value: string) => { if (value === 'terms') { @@ -129,7 +130,7 @@ export default function MembershipScreen() { router.push('/privacy') } } - + // 轮播图相关 const carouselImages = [ require('@/assets/images/membership.png'), @@ -203,37 +204,37 @@ export default function MembershipScreen() { - setPointsDrawerVisible(true)} - > - {t('membership.myPoints')} + setPointsDrawerVisible(true)} + > + {t('membership.myPoints')} - {creditBalance} - - - handleMenuSelect(value)} - offsetTop={10} - renderTrigger={(selectedOption, isOpen, toggle) => ( - - - - )} - dropdownStyle={styles.menuDropdown} - renderOption={(option, isSelected) => ( - - {option.value === 'terms' ? ( - - ) : ( - - )} - {option.label} - - )} - /> - + {creditBalance} + + + handleMenuSelect(value)} + offsetTop={10} + renderTrigger={(selectedOption, isOpen, toggle) => ( + + + + )} + dropdownStyle={styles.menuDropdown} + renderOption={(option, isSelected) => ( + + {option.value === 'terms' ? ( + + ) : ( + + )} + {option.label} + + )} + /> + - - ( - - )} - autoPlay - autoPlayInterval={2000} - loop - onSnapToItem={(index) => setCurrentImageIndex(index)} - enabled={false} - windowSize={1} - mode="parallax" - /> - - {carouselImages.map((_, index) => ( - - ))} - - + + ( + + )} + autoPlay + autoPlayInterval={2000} + loop + onSnapToItem={(index) => setCurrentImageIndex(index)} + enabled={false} + windowSize={1} + mode="parallax" + /> + + {carouselImages.map((_, index) => ( + + ))} - + + + {/* 订阅计划标题 */} {t('membership.subscriptionPlan')} @@ -289,121 +290,128 @@ export default function MembershipScreen() { {plans.map((plan, index) => { const isSelected = selectedPlanIndex === index return ( - 0 && styles.planCardSpacing, - ]} - onPress={() => setSelectedPlanIndex(index)} - > - {isSelected ? ( - - - {plan.recommended && ( - - {t('membership.mostRecommended')} + index > 0 && styles.planCardSpacing, + ]} + onPress={() => setSelectedPlanIndex(index)} + > + {isSelected ? ( + + + {plan.recommended && ( + + {t('membership.mostRecommended')} + + )} + {plan.name} + + + {toUnit(plan.currency)} + + {plan.price} + {t('membership.perMonth')} + + + + ) : ( + + + {plan.recommended && ( + + {t('membership.mostRecommended')} + + )} + {plan.name} + + + {toUnit(plan.currency)} + + {plan.price} + {t('membership.perMonth')} - )} - {plan.name} - - - $ - - {plan.price} - {t('membership.perMonth')} - - ) : ( - - - {plan.recommended && ( - - {t('membership.mostRecommended')} - - )} - {plan.name} - - - $ - - {plan.price} - {t('membership.perMonth')} - - - - )} - + )} + ) })} {/* 卡片所属的信息 */} - - {/* 积分每月显示 */} - - - - - {currentPlan.points.toLocaleString()} - - {t('membership.pointsPerMonth')} - - - - - {t('membership.pointsAutoRenew')} - - - {/* 功能列表 */} - - {currentPlan.features.map((feature, index) => ( - - - {feature} + + {/* 积分每月显示 */} + + + + + {currentPlan.points.toLocaleString()} + + {t('membership.pointsPerMonth')} - ))} + + + + {t('membership.pointsAutoRenew')} + + + {/* 功能列表 */} + + {currentPlan.featureList.map((featureKey, index) => { + const translatedText = t(featureKey) + // 如果没有对应的翻译键(翻译结果等于键本身),不展示该项 + if (translatedText === featureKey) { + return null + } + return ( + + + {translatedText} + + ) + })} + - - + {/* 固定在底部的订阅容器 */} {/* 立即开通按钮 */} - - {isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? ( - - ) : ( - {t('membership.subscribeNow')} - )} + {isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? ( + + ) : ( + {t('membership.subscribeNow')} + )} - {/* 协议复选框 */} - + {/* 协议复选框 */} + setAgreed(!agreed)} @@ -437,14 +445,14 @@ export default function MembershipScreen() { {t('membership.agreementText')}{' '} - router.push('/terms')} > {t('membership.terms')} {t('membership.agreementAnd')} - router.push('/privacy')} > @@ -455,7 +463,7 @@ export default function MembershipScreen() { - + {/* 积分抽屉 */} void } export default function PointsDrawer({ @@ -42,6 +46,7 @@ export default function PointsDrawer({ totalPoints = 0, subscriptionPoints = 0, topUpPoints = 0, + onRecharge, }: PointsDrawerProps) { const { t } = useTranslation() const insets = useSafeAreaInsets() diff --git a/hooks/use-categories-with-tags.ts b/hooks/use-categories-with-tags.ts index b3e62d1..b8beded 100644 --- a/hooks/use-categories-with-tags.ts +++ b/hooks/use-categories-with-tags.ts @@ -4,7 +4,7 @@ import { type ListCategoriesWithTagsInput, type ListCategoriesWithTagsResult, } from '@repo/sdk' -import { useState, useEffect } from 'react' +import { useEffect } from 'react' import { create } from 'zustand' import { type ApiError } from '@/lib/types' @@ -28,19 +28,17 @@ export const useCategoriesWithTagsStore = create((set) export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => { const store = useCategoriesWithTagsStore() - const [localLoading, setLocalLoading] = useState(false) useEffect(() => { - if (!store.hasLoaded && !store.loading && !localLoading) { + if (!store.hasLoaded && !store.loading) { load() } }, []) const load = async (inputParams?: ListCategoriesWithTagsInput) => { - if (store.loading || localLoading) return + if (store.loading) return try { - setLocalLoading(true) useCategoriesWithTagsStore.setState({ loading: true }) const category = root.get(CategoryController) @@ -65,14 +63,12 @@ export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => { useCategoriesWithTagsStore.setState({ data, loading: false, error: null, hasLoaded: true }) } catch (e) { useCategoriesWithTagsStore.setState({ error: e as ApiError, loading: false, hasLoaded: true }) - } finally { - setLocalLoading(false) } } return { load, - loading: store.loading || localLoading, + loading: store.loading, error: store.error, data: store.data, } diff --git a/hooks/use-membership.ts b/hooks/use-membership.ts index 8f25ff4..77084c2 100644 --- a/hooks/use-membership.ts +++ b/hooks/use-membership.ts @@ -2,18 +2,23 @@ import { useState, useEffect, useMemo } from 'react' import { Linking, Alert } from 'react-native' import { subscription, useSession } from '@/lib/auth' -import type { SubscriptionListItem } from '@/lib/auth' +import type { StripePricingTableResponse, SubscriptionListItem } from '@/lib/auth' interface CreditPlan { amountInCents: number credits: number popular?: boolean + name: string + currency: string + featureList: string[] } + + export function useMembership() { const { data: session } = useSession() const [selectedPlanIndex, setSelectedPlanIndex] = useState(0) - const [stripePricingData, setStripePricingData] = useState(null) + const [stripePricingData, setStripePricingData] = useState(null) const [authSubscriptions, setAuthSubscriptions] = useState(null) const [isStripePricingLoading, setIsStripePricingLoading] = useState(false) const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false) @@ -66,11 +71,14 @@ export function useMembership() { const creditPlans = useMemo((): CreditPlan[] => { if (!stripePricingData?.pricing_table_items) return [] return stripePricingData.pricing_table_items - .filter((item: any) => item.recurring?.interval === 'month') - .map((item: any) => ({ + .filter((item) => item.recurring?.interval === 'month') + .map((item) => ({ amountInCents: parseInt(item.amount), - credits: parseInt(item.metadata?.grant_token || item.amount), + credits: parseInt(item.amount), popular: item.is_highlight || item.highlight_text === 'most_popular', + name: item.name || '', + currency: item.currency || 'usd', + featureList: item.feature_list || [], })) }, [stripePricingData?.pricing_table_items]) diff --git a/hooks/use-template-generations.ts b/hooks/use-template-generations.ts index 78a2bc2..c58aae4 100644 --- a/hooks/use-template-generations.ts +++ b/hooks/use-template-generations.ts @@ -73,10 +73,16 @@ export const useTemplateGenerations = () => { hasMoreRef.current = newGenerations.length >= (params?.limit || 20) currentPageRef.current = nextPage - setData((prev) => ({ - ...newData, - data: [...(prev?.data || []), ...newGenerations], - })) + // 合并数据并去重 + setData((prev) => { + const existingIds = new Set((prev?.data || []).map(item => item.id)) + const uniqueNewGenerations = newGenerations.filter(item => !existingIds.has(item.id)) + + return { + ...newData, + data: [...(prev?.data || []), ...uniqueNewGenerations], + } + }) setLoadingMore(false) return { data: newData, error: null } }, diff --git a/lib/auth.ts b/lib/auth.ts index 3ef1d16..a1525d4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -107,9 +107,36 @@ export interface CheckoutResponse { url?: string } + +// Stripe Pricing Table API 接口定义 +export interface StripePricingTableItem { + amount: string; + currency: string; + name: string; + price_id: string; + product_id: string; + feature_list: string[]; + highlight_text?: string | null; + is_highlight: boolean; + call_to_action_link: string; + recurring: { + interval: string; + interval_count: number; + }; + metadata?: any; + metered_price_id: string; +} + +export interface StripePricingTableResponse { + id: string; + object: string; + active: boolean; + pricing_table_items: StripePricingTableItem[]; +} + export interface ISubscription { list: (params?: SubscriptionListParams) => Promise<{ data?: SubscriptionListItem[]; error?: ApiError }> - plans: (params: PlansParams) => Promise<{ data?: any; error?: ApiError }> + plans: (params: PlansParams) => Promise<{ data?: StripePricingTableResponse; error?: ApiError }> create: (params: CreateSubscriptionParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }> upgrade: (params: UpgradeSubscriptionParams) => Promise<{ data?: any; error?: ApiError }> cancel: (params: CancelSubscriptionParams) => Promise<{ data?: any; error?: ApiError }> diff --git a/locales/en-US.json b/locales/en-US.json index cf69be6..1996b15 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -47,6 +47,21 @@ "privacy": { "title": "Privacy Policy" }, + "pricing": { + "feature": { + "useSora2": "Can use Sora2 200 times", + "useSora3": "Can use Sora2 25 times", + "useSora4": "Can use Sora2 100 times", + "textToVideo": "Supports text-to-video/image-to-video", + "superClearImageGeneration": "Ultra-clear image generation", + "superDiscount": "discounts for Sora2, Veo3.1", + "useSora2ProTemplate": "support Sora2 Pro templates", + "productWatermark": "without brand watermarks", + "basicTemplate": "Basic Template", + "allTemplates": "All Templates", + "highQualityVideo": "Higher-definition video" + } + }, "membership": { "terms": "Terms of Service", "privacy": "Privacy Policy", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 44476f5..80750a8 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -47,6 +47,21 @@ "privacy": { "title": "隐私协议" }, + "pricing": { + "feature": { + "useSora2": "6000点,可以使用200次Sora2", + "useSora3": "750点,可以使用25次Sora2", + "useSora4": "3000点,可以使用100次Sora2", + "textToVideo": "支持文生视频/图生视频", + "superClearImageGeneration": "超清图像生成", + "superDiscount": "享受Sora2,Veo3.1超级折扣", + "useSora2ProTemplate": "可以使用Sora2 Pro模版", + "productWatermark": "生成作品去除品牌水印", + "basicTemplate": "基础模版", + "allTemplates": "全部模版", + "highQualityVideo": "视频更高清" + } + }, "membership": { "terms": "服务条款", "privacy": "隐私协议",