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": "隐私协议",