fix: bug
This commit is contained in:
@@ -35,6 +35,14 @@ jest.mock('@/components/icon', () => ({
|
|||||||
jest.mock('@/components/ErrorState', () => 'ErrorState')
|
jest.mock('@/components/ErrorState', () => 'ErrorState')
|
||||||
jest.mock('@/components/LoadingState', () => 'LoadingState')
|
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)
|
// Mock react-native RefreshControl (directly from react-native)
|
||||||
jest.mock('react-native', () =>
|
jest.mock('react-native', () =>
|
||||||
Object.assign({}, jest.requireActual('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) => {
|
const renderWithProviders = (component: React.ReactElement) => {
|
||||||
return render(component)
|
return render(component)
|
||||||
}
|
}
|
||||||
@@ -152,4 +223,132 @@ describe('HomeScreen', () => {
|
|||||||
expect(getByText('1234')).toBeTruthy()
|
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(<HomeScreen />)
|
||||||
|
|
||||||
|
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(<HomeScreen />)
|
||||||
|
|
||||||
|
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(<HomeScreen />)
|
||||||
|
|
||||||
|
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(<HomeScreen />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// 应该只显示一个统一的 loading 状态
|
||||||
|
const allLoadingComponents = [
|
||||||
|
...UNSAFE_queryAllByType('LoadingState' as any),
|
||||||
|
...UNSAFE_queryAllByType('ActivityIndicator' as any),
|
||||||
|
]
|
||||||
|
expect(allLoadingComponents.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import React from 'react'
|
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'
|
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', () => ({
|
jest.mock('expo-router', () => ({
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: jest.fn(),
|
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 mockRefetch = jest.fn()
|
||||||
const mockLoadMore = 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 () => {
|
it('should refresh with limit 20 on pull to refresh', async () => {
|
||||||
const { useTemplateGenerations } = require('@/hooks')
|
const { useTemplateGenerations } = require('@/hooks')
|
||||||
const mockOnRefresh = jest.fn()
|
const mockRefetchAsync = jest.fn().mockResolvedValue({ data: [], error: null })
|
||||||
|
|
||||||
useTemplateGenerations.mockReturnValue({
|
useTemplateGenerations.mockReturnValue({
|
||||||
generations: [{ id: '1', status: 'completed' }],
|
generations: [{ id: '1', status: 'completed' }],
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingMore: false,
|
loadingMore: false,
|
||||||
refetch: mockRefetch,
|
refetch: mockRefetchAsync,
|
||||||
loadMore: mockLoadMore,
|
loadMore: mockLoadMore,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<My />)
|
const { getByTestId } = render(<My />)
|
||||||
|
|
||||||
|
// 模拟下拉刷新
|
||||||
|
const scrollView = getByTestId('my-scroll-view')
|
||||||
|
const refreshControl = scrollView.props.refreshControl
|
||||||
|
|
||||||
|
// 触发刷新
|
||||||
|
await refreshControl.props.onRefresh()
|
||||||
|
|
||||||
await waitFor(() => {
|
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(<My />)
|
||||||
|
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,
|
hasMore: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<My />)
|
const { getByTestId } = render(<My />)
|
||||||
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 })
|
expect(mockLoadMore).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -107,14 +107,18 @@ export default function HomeScreen() {
|
|||||||
}, [params.categoryId])
|
}, [params.categoryId])
|
||||||
|
|
||||||
// 状态判断 - 使用 useMemo 缓存
|
// 状态判断 - 使用 useMemo 缓存
|
||||||
const showLoading = categoriesLoading
|
// 统一 loading 状态:只要有任何一个在加载,就显示 loading
|
||||||
|
const isLoading = useMemo(() =>
|
||||||
|
categoriesLoading || templatesLoading,
|
||||||
|
[categoriesLoading, templatesLoading]
|
||||||
|
)
|
||||||
const showEmptyState = useMemo(() =>
|
const showEmptyState = useMemo(() =>
|
||||||
!categoriesLoading && categoriesData?.categories?.length === 0,
|
!isLoading && categoriesData?.categories?.length === 0,
|
||||||
[categoriesLoading, categoriesData?.categories?.length]
|
[isLoading, categoriesData?.categories?.length]
|
||||||
)
|
)
|
||||||
const showEmptyTemplates = useMemo(() =>
|
const showEmptyTemplates = useMemo(() =>
|
||||||
!categoriesLoading && !templatesLoading && !showEmptyState && filteredTemplates.length === 0,
|
!isLoading && !showEmptyState && filteredTemplates.length === 0,
|
||||||
[categoriesLoading, templatesLoading, showEmptyState, filteredTemplates.length]
|
[isLoading, showEmptyState, filteredTemplates.length]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
@@ -129,10 +133,10 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
// 加载更多处理
|
// 加载更多处理
|
||||||
const handleEndReached = useCallback(() => {
|
const handleEndReached = useCallback(() => {
|
||||||
if (!loadingMore && hasMore && !templatesLoading) {
|
if (!loadingMore && hasMore && !isLoading) {
|
||||||
loadMore()
|
loadMore()
|
||||||
}
|
}
|
||||||
}, [loadingMore, hasMore, templatesLoading, loadMore])
|
}, [loadingMore, hasMore, isLoading, loadMore])
|
||||||
|
|
||||||
// 导航处理 - 使用 useCallback memoize
|
// 导航处理 - 使用 useCallback memoize
|
||||||
const handlePointsPress = useCallback(() => router.push('/membership' as any), [router])
|
const handlePointsPress = useCallback(() => router.push('/membership' as any), [router])
|
||||||
@@ -178,7 +182,7 @@ export default function HomeScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 标签导航 */}
|
{/* 标签导航 */}
|
||||||
{!showLoading && !showEmptyState && (
|
{!isLoading && !showEmptyState && (
|
||||||
<View
|
<View
|
||||||
onLayout={(e) => handleTabsLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}
|
onLayout={(e) => handleTabsLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}
|
||||||
style={isSticky ? { opacity: 0, height: tabsHeight } : undefined}
|
style={isSticky ? { opacity: 0, height: tabsHeight } : undefined}
|
||||||
@@ -193,8 +197,8 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 加载状态 */}
|
{/* 统一的加载状态 */}
|
||||||
{showLoading && <LoadingState />}
|
{isLoading && <LoadingState />}
|
||||||
|
|
||||||
{/* 错误状态 */}
|
{/* 错误状态 */}
|
||||||
{categoriesError && (
|
{categoriesError && (
|
||||||
@@ -210,18 +214,11 @@ export default function HomeScreen() {
|
|||||||
{showEmptyTemplates && (
|
{showEmptyTemplates && (
|
||||||
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
|
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 模板加载中 */}
|
|
||||||
{templatesLoading && !showLoading && (
|
|
||||||
<View style={styles.templatesLoadingContainer}>
|
|
||||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
), [
|
), [
|
||||||
activatesData?.activities,
|
activatesData?.activities,
|
||||||
handleActivityPress,
|
handleActivityPress,
|
||||||
showLoading,
|
isLoading,
|
||||||
showEmptyState,
|
showEmptyState,
|
||||||
isSticky,
|
isSticky,
|
||||||
tabsHeight,
|
tabsHeight,
|
||||||
@@ -231,7 +228,6 @@ export default function HomeScreen() {
|
|||||||
handleArrowPress,
|
handleArrowPress,
|
||||||
categoriesError,
|
categoriesError,
|
||||||
showEmptyTemplates,
|
showEmptyTemplates,
|
||||||
templatesLoading,
|
|
||||||
selectedCategoryId,
|
selectedCategoryId,
|
||||||
handleTabsLayout,
|
handleTabsLayout,
|
||||||
loadCategories,
|
loadCategories,
|
||||||
@@ -257,7 +253,7 @@ export default function HomeScreen() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 只有在非加载状态且有数据时才显示列表
|
// 只有在非加载状态且有数据时才显示列表
|
||||||
const showTemplateList = !showLoading && !showEmptyState && !showEmptyTemplates && !templatesLoading
|
const showTemplateList = !isLoading && !showEmptyState && !showEmptyTemplates
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
@@ -273,7 +269,7 @@ export default function HomeScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 吸顶标签导航 */}
|
{/* 吸顶标签导航 */}
|
||||||
{isSticky && !showLoading && !showEmptyState && (
|
{isSticky && !isLoading && !showEmptyState && (
|
||||||
<View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}>
|
<View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}>
|
||||||
<TabNavigation
|
<TabNavigation
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -68,6 +68,16 @@ export default function My() {
|
|||||||
hasMore,
|
hasMore,
|
||||||
} = useTemplateGenerations()
|
} = useTemplateGenerations()
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('📊 作品列表状态:', {
|
||||||
|
总数: generations.length,
|
||||||
|
加载中: loading,
|
||||||
|
加载更多中: loadingMore,
|
||||||
|
还有更多: hasMore
|
||||||
|
})
|
||||||
|
}, [generations.length, loading, loadingMore, hasMore])
|
||||||
|
|
||||||
// 初始化加载作品列表
|
// 初始化加载作品列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refetch({ page: 1, limit: 20 })
|
refetch({ page: 1, limit: 20 })
|
||||||
@@ -77,25 +87,41 @@ export default function My() {
|
|||||||
// 下拉刷新状态
|
// 下拉刷新状态
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
|
// 防止重复触发加载更多
|
||||||
|
const isLoadingMoreRef = useRef(false)
|
||||||
|
|
||||||
// 下拉刷新处理
|
// 下拉刷新处理
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
await refetch({ page: 1, limit: 20 })
|
try {
|
||||||
setRefreshing(false)
|
await refetch({ page: 1, limit: 20 })
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
}, [refetch])
|
}, [refetch])
|
||||||
|
|
||||||
// 加载更多处理
|
// 加载更多处理
|
||||||
const handleEndReached = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
|
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
|
||||||
const paddingToBottom = 100 // 距离底部100px时触发加载更多
|
const paddingToBottom = 200 // 距离底部200px时触发加载更多
|
||||||
if (
|
|
||||||
layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom &&
|
const isCloseToBottom =
|
||||||
!loadingMore &&
|
layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom
|
||||||
hasMore
|
|
||||||
) {
|
// 使用 ref 防止重复触发
|
||||||
loadMore()
|
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) => {
|
const handleSettingsSelect = async (value: string) => {
|
||||||
@@ -155,7 +181,7 @@ export default function My() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<RNStatusBar barStyle="light-content" />
|
<RNStatusBar barStyle="light-content" />
|
||||||
|
|
||||||
@@ -219,11 +245,12 @@ export default function My() {
|
|||||||
<MySkeleton />
|
<MySkeleton />
|
||||||
) : (
|
) : (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
testID="my-scroll-view"
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={handleEndReached}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={400}
|
scrollEventThrottle={16}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@@ -356,6 +383,7 @@ const styles = StyleSheet.create({
|
|||||||
scrollContent: {
|
scrollContent: {
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
|
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: 20,
|
||||||
},
|
},
|
||||||
profileSection: {
|
profileSection: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -59,14 +59,27 @@ interface Plan {
|
|||||||
price: number
|
price: number
|
||||||
recommended?: boolean
|
recommended?: boolean
|
||||||
points: number // 每月积分
|
points: number // 每月积分
|
||||||
features: string[] // 功能列表
|
currency: string // 货币类型
|
||||||
|
featureList: string[] // 功能列表(翻译 key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 货币符号转换函数
|
||||||
|
function toUnit(currency: string): string {
|
||||||
|
switch (currency.toLowerCase()) {
|
||||||
|
case 'hkd':
|
||||||
|
return 'HK$'
|
||||||
|
case 'cny':
|
||||||
|
return '¥'
|
||||||
|
default:
|
||||||
|
return '$'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MembershipScreen() {
|
export default function MembershipScreen() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const { width: screenWidth } = useWindowDimensions()
|
const { width: screenWidth } = useWindowDimensions()
|
||||||
const [agreed, setAgreed] = useState(false)
|
const [agreed, setAgreed] = useState(true)
|
||||||
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
||||||
const [isSubscribing, setIsSubscribing] = useState(false)
|
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||||
|
|
||||||
@@ -89,38 +102,26 @@ export default function MembershipScreen() {
|
|||||||
stripePricingData,
|
stripePricingData,
|
||||||
} = useMembership()
|
} = useMembership()
|
||||||
|
|
||||||
|
|
||||||
// 映射 API 数据到 UI 格式
|
// 映射 API 数据到 UI 格式
|
||||||
const plans: Plan[] = creditPlans.map((plan, index) => ({
|
const plans: Plan[] = creditPlans.map((plan, index) => ({
|
||||||
id: `plan-${index}` as PlanType,
|
id: `plan-${index}` as PlanType,
|
||||||
name: index === 0 ? 'Plus' : index === 1 ? 'Pro' : 'Plus',
|
name: plan.name,
|
||||||
price: plan.amountInCents / 100,
|
price: plan.amountInCents / 100,
|
||||||
recommended: plan.popular,
|
recommended: plan.popular,
|
||||||
points: plan.credits,
|
points: plan.credits,
|
||||||
features: [
|
currency: plan.currency,
|
||||||
t('membership.features.points750'),
|
featureList: plan.featureList,
|
||||||
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'),
|
|
||||||
] : []),
|
|
||||||
],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const selectedPlan = plans[selectedPlanIndex] || plans[0]
|
const selectedPlan = plans[selectedPlanIndex] || plans[0]
|
||||||
|
|
||||||
// 下拉菜单选项
|
// 下拉菜单选项
|
||||||
const menuOptions = [
|
const menuOptions = [
|
||||||
{ label: t('membership.terms'), value: 'terms' },
|
{ label: t('membership.terms'), value: 'terms' },
|
||||||
{ label: t('membership.privacy'), value: 'privacy' },
|
{ label: t('membership.privacy'), value: 'privacy' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 处理菜单选择
|
// 处理菜单选择
|
||||||
const handleMenuSelect = (value: string) => {
|
const handleMenuSelect = (value: string) => {
|
||||||
if (value === 'terms') {
|
if (value === 'terms') {
|
||||||
@@ -129,7 +130,7 @@ export default function MembershipScreen() {
|
|||||||
router.push('/privacy')
|
router.push('/privacy')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 轮播图相关
|
// 轮播图相关
|
||||||
const carouselImages = [
|
const carouselImages = [
|
||||||
require('@/assets/images/membership.png'),
|
require('@/assets/images/membership.png'),
|
||||||
@@ -203,37 +204,37 @@ export default function MembershipScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.headerSpacer} />
|
<View style={styles.headerSpacer} />
|
||||||
<View style={styles.pointsContainer}>
|
<View style={styles.pointsContainer}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.pointsPill}
|
style={styles.pointsPill}
|
||||||
onPress={() => setPointsDrawerVisible(true)}
|
onPress={() => setPointsDrawerVisible(true)}
|
||||||
>
|
>
|
||||||
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
||||||
<MembershipPointsIcon />
|
<MembershipPointsIcon />
|
||||||
<Text style={styles.pointsValue}>{creditBalance}</Text>
|
<Text style={styles.pointsValue}>{creditBalance}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.settingsButtonContainer}>
|
<View style={styles.settingsButtonContainer}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
options={menuOptions}
|
options={menuOptions}
|
||||||
onSelect={(value) => handleMenuSelect(value)}
|
onSelect={(value) => handleMenuSelect(value)}
|
||||||
offsetTop={10}
|
offsetTop={10}
|
||||||
renderTrigger={(selectedOption, isOpen, toggle) => (
|
renderTrigger={(selectedOption, isOpen, toggle) => (
|
||||||
<Pressable style={styles.settingsButton} onPress={toggle}>
|
<Pressable style={styles.settingsButton} onPress={toggle}>
|
||||||
<OmitIcon />
|
<OmitIcon />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
dropdownStyle={styles.menuDropdown}
|
dropdownStyle={styles.menuDropdown}
|
||||||
renderOption={(option, isSelected) => (
|
renderOption={(option, isSelected) => (
|
||||||
<View style={styles.menuOption}>
|
<View style={styles.menuOption}>
|
||||||
{option.value === 'terms' ? (
|
{option.value === 'terms' ? (
|
||||||
<TermsIcon />
|
<TermsIcon />
|
||||||
) : (
|
) : (
|
||||||
<PrivacyIcon />
|
<PrivacyIcon />
|
||||||
)}
|
)}
|
||||||
<Text style={styles.menuOptionText}>{option.label}</Text>
|
<Text style={styles.menuOptionText}>{option.label}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -241,46 +242,46 @@ export default function MembershipScreen() {
|
|||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={styles.imageContainer}>
|
<View style={styles.imageContainer}>
|
||||||
<Carousel
|
<Carousel
|
||||||
width={screenWidth}
|
width={screenWidth}
|
||||||
height={screenWidth / 1.1}
|
height={screenWidth / 1.1}
|
||||||
data={carouselImages}
|
data={carouselImages}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<Image
|
<Image
|
||||||
source={item}
|
source={item}
|
||||||
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
|
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
autoPlay
|
autoPlay
|
||||||
autoPlayInterval={2000}
|
autoPlayInterval={2000}
|
||||||
loop
|
loop
|
||||||
onSnapToItem={(index) => setCurrentImageIndex(index)}
|
onSnapToItem={(index) => setCurrentImageIndex(index)}
|
||||||
enabled={false}
|
enabled={false}
|
||||||
windowSize={1}
|
windowSize={1}
|
||||||
mode="parallax"
|
mode="parallax"
|
||||||
/>
|
/>
|
||||||
<View style={styles.dotsContainer}>
|
<View style={styles.dotsContainer}>
|
||||||
{carouselImages.map((_, index) => (
|
{carouselImages.map((_, index) => (
|
||||||
<View
|
<View
|
||||||
key={index}
|
key={index}
|
||||||
style={[
|
style={[
|
||||||
styles.dot,
|
styles.dot,
|
||||||
index === currentImageIndex && styles.dotActive,
|
index === currentImageIndex && styles.dotActive,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#090A0B00', '#090A0B']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 0, y: 1 }}
|
|
||||||
style={styles.imageGradient}
|
|
||||||
pointerEvents="none"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#090A0B00', '#090A0B']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
style={styles.imageGradient}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 订阅计划标题 */}
|
{/* 订阅计划标题 */}
|
||||||
<Text style={styles.sectionTitle}>{t('membership.subscriptionPlan')}</Text>
|
<Text style={styles.sectionTitle}>{t('membership.subscriptionPlan')}</Text>
|
||||||
|
|
||||||
@@ -289,121 +290,128 @@ export default function MembershipScreen() {
|
|||||||
{plans.map((plan, index) => {
|
{plans.map((plan, index) => {
|
||||||
const isSelected = selectedPlanIndex === index
|
const isSelected = selectedPlanIndex === index
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
style={[
|
style={[
|
||||||
styles.planCardWrapper,
|
styles.planCardWrapper,
|
||||||
index > 0 && styles.planCardSpacing,
|
index > 0 && styles.planCardSpacing,
|
||||||
]}
|
]}
|
||||||
onPress={() => setSelectedPlanIndex(index)}
|
onPress={() => setSelectedPlanIndex(index)}
|
||||||
>
|
>
|
||||||
{isSelected ? (
|
{isSelected ? (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={styles.planCardGradient}
|
style={styles.planCardGradient}
|
||||||
>
|
>
|
||||||
<View style={styles.planCard}>
|
<View style={styles.planCard}>
|
||||||
{plan.recommended && (
|
{plan.recommended && (
|
||||||
<View style={styles.recommendedBadge}>
|
<View style={styles.recommendedBadge}>
|
||||||
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
|
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.planName}>{plan.name}</Text>
|
||||||
|
<View style={styles.priceContainer}>
|
||||||
|
<GradientText
|
||||||
|
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.priceSymbol}
|
||||||
|
>
|
||||||
|
{toUnit(plan.currency)}
|
||||||
|
</GradientText>
|
||||||
|
<GradientText
|
||||||
|
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.priceValue}>{plan.price}</GradientText>
|
||||||
|
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
) : (
|
||||||
|
<View style={styles.planCardWrapperUnselected}>
|
||||||
|
<View style={styles.planCard}>
|
||||||
|
{plan.recommended && (
|
||||||
|
<View style={styles.recommendedBadge}>
|
||||||
|
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.planName}>{plan.name}</Text>
|
||||||
|
<View style={styles.priceContainer}>
|
||||||
|
<GradientText
|
||||||
|
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.priceSymbol}
|
||||||
|
>
|
||||||
|
{toUnit(plan.currency)}
|
||||||
|
</GradientText>
|
||||||
|
<GradientText
|
||||||
|
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.priceValue}>{plan.price}</GradientText>
|
||||||
|
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
<Text style={styles.planName}>{plan.name}</Text>
|
|
||||||
<View style={styles.priceContainer}>
|
|
||||||
<GradientText
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.priceSymbol}
|
|
||||||
>
|
|
||||||
$
|
|
||||||
</GradientText>
|
|
||||||
<GradientText
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.priceValue}>{plan.price}</GradientText>
|
|
||||||
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</LinearGradient>
|
)}
|
||||||
) : (
|
</Pressable>
|
||||||
<View style={styles.planCardWrapperUnselected}>
|
|
||||||
<View style={styles.planCard}>
|
|
||||||
{plan.recommended && (
|
|
||||||
<View style={styles.recommendedBadge}>
|
|
||||||
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Text style={styles.planName}>{plan.name}</Text>
|
|
||||||
<View style={styles.priceContainer}>
|
|
||||||
<GradientText
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.priceSymbol}
|
|
||||||
>
|
|
||||||
$
|
|
||||||
</GradientText>
|
|
||||||
<GradientText
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.priceValue}>{plan.price}</GradientText>
|
|
||||||
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
{/* 卡片所属的信息 */}
|
{/* 卡片所属的信息 */}
|
||||||
<View style={styles.pointsMonthlyContainer}>
|
<View style={styles.pointsMonthlyContainer}>
|
||||||
{/* 积分每月显示 */}
|
{/* 积分每月显示 */}
|
||||||
<View style={styles.pointsMonthlyCard}>
|
<View style={styles.pointsMonthlyCard}>
|
||||||
<View style={styles.pointsMonthlyHeader}>
|
<View style={styles.pointsMonthlyHeader}>
|
||||||
<MembershipPointsIcon />
|
<MembershipPointsIcon />
|
||||||
<Text style={styles.pointsMonthlyValue}>
|
<Text style={styles.pointsMonthlyValue}>
|
||||||
{currentPlan.points.toLocaleString()}
|
{currentPlan.points.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.pointsMonthlyLabel}>{t('membership.pointsPerMonth')}</Text>
|
<Text style={styles.pointsMonthlyLabel}>{t('membership.pointsPerMonth')}</Text>
|
||||||
</View>
|
|
||||||
<View style={styles.progressBar}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.pointsMonthlyNote}>{t('membership.pointsAutoRenew')}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 功能列表 */}
|
|
||||||
<View >
|
|
||||||
{currentPlan.features.map((feature, index) => (
|
|
||||||
<View key={index} style={styles.featureItem}>
|
|
||||||
<CheckMarkIcon />
|
|
||||||
<Text style={styles.featureText}>{feature}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
<View style={styles.progressBar}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.pointsMonthlyNote}>{t('membership.pointsAutoRenew')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 功能列表 */}
|
||||||
|
<View >
|
||||||
|
{currentPlan.featureList.map((featureKey, index) => {
|
||||||
|
const translatedText = t(featureKey)
|
||||||
|
// 如果没有对应的翻译键(翻译结果等于键本身),不展示该项
|
||||||
|
if (translatedText === featureKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View key={index} style={styles.featureItem}>
|
||||||
|
<CheckMarkIcon />
|
||||||
|
<Text style={styles.featureText}>{translatedText}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 固定在底部的订阅容器 */}
|
{/* 固定在底部的订阅容器 */}
|
||||||
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
||||||
{/* 立即开通按钮 */}
|
{/* 立即开通按钮 */}
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
|
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
|
||||||
style={styles.subscribeButtonPressable}
|
style={styles.subscribeButtonPressable}
|
||||||
onPress={handleSubscribe}
|
onPress={handleSubscribe}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={currentPlan.recommended
|
colors={currentPlan.recommended
|
||||||
? ['#9966FF', '#FF6699', '#FF9966']
|
? ['#9966FF', '#FF6699', '#FF9966']
|
||||||
: ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF']
|
: ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF']
|
||||||
@@ -419,15 +427,15 @@ export default function MembershipScreen() {
|
|||||||
(!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled,
|
(!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? (
|
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? (
|
||||||
<ActivityIndicator color="#F5F5F5" />
|
<ActivityIndicator color="#F5F5F5" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
||||||
)}
|
)}
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{/* 协议复选框 */}
|
{/* 协议复选框 */}
|
||||||
<View style={styles.agreementContainer}>
|
<View style={styles.agreementContainer}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.checkbox}
|
style={styles.checkbox}
|
||||||
onPress={() => setAgreed(!agreed)}
|
onPress={() => setAgreed(!agreed)}
|
||||||
@@ -437,14 +445,14 @@ export default function MembershipScreen() {
|
|||||||
<View style={styles.agreementTextWrapper}>
|
<View style={styles.agreementTextWrapper}>
|
||||||
<Text style={styles.agreementText}>
|
<Text style={styles.agreementText}>
|
||||||
{t('membership.agreementText')}{' '}
|
{t('membership.agreementText')}{' '}
|
||||||
<Text
|
<Text
|
||||||
style={styles.agreementLink}
|
style={styles.agreementLink}
|
||||||
onPress={() => router.push('/terms')}
|
onPress={() => router.push('/terms')}
|
||||||
>
|
>
|
||||||
{t('membership.terms')}
|
{t('membership.terms')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.agreementText}> {t('membership.agreementAnd')} </Text>
|
<Text style={styles.agreementText}> {t('membership.agreementAnd')} </Text>
|
||||||
<Text
|
<Text
|
||||||
style={styles.agreementLink}
|
style={styles.agreementLink}
|
||||||
onPress={() => router.push('/privacy')}
|
onPress={() => router.push('/privacy')}
|
||||||
>
|
>
|
||||||
@@ -455,7 +463,7 @@ export default function MembershipScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 积分抽屉 */}
|
{/* 积分抽屉 */}
|
||||||
<PointsDrawer
|
<PointsDrawer
|
||||||
visible={pointsDrawerVisible}
|
visible={pointsDrawerVisible}
|
||||||
@@ -721,15 +729,18 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
},
|
},
|
||||||
agreementTextWrapper: {
|
agreementTextWrapper: {
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
checkbox: {
|
checkbox: {
|
||||||
width: 12,
|
width: 20,
|
||||||
height: 12,
|
height: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
padding: 4,
|
||||||
},
|
},
|
||||||
agreementText: {
|
agreementText: {
|
||||||
color: '#CCCCCC',
|
color: '#CCCCCC',
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface PointsDrawerProps {
|
|||||||
* 额外充值积分
|
* 额外充值积分
|
||||||
*/
|
*/
|
||||||
topUpPoints?: number
|
topUpPoints?: number
|
||||||
|
/**
|
||||||
|
* 充值回调
|
||||||
|
*/
|
||||||
|
onRecharge?: (amount: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PointsDrawer({
|
export default function PointsDrawer({
|
||||||
@@ -42,6 +46,7 @@ export default function PointsDrawer({
|
|||||||
totalPoints = 0,
|
totalPoints = 0,
|
||||||
subscriptionPoints = 0,
|
subscriptionPoints = 0,
|
||||||
topUpPoints = 0,
|
topUpPoints = 0,
|
||||||
|
onRecharge,
|
||||||
}: PointsDrawerProps) {
|
}: PointsDrawerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type ListCategoriesWithTagsInput,
|
type ListCategoriesWithTagsInput,
|
||||||
type ListCategoriesWithTagsResult,
|
type ListCategoriesWithTagsResult,
|
||||||
} from '@repo/sdk'
|
} from '@repo/sdk'
|
||||||
import { useState, useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
import { type ApiError } from '@/lib/types'
|
import { type ApiError } from '@/lib/types'
|
||||||
@@ -28,19 +28,17 @@ export const useCategoriesWithTagsStore = create<CategoriesWithTagsState>((set)
|
|||||||
|
|
||||||
export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => {
|
export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => {
|
||||||
const store = useCategoriesWithTagsStore()
|
const store = useCategoriesWithTagsStore()
|
||||||
const [localLoading, setLocalLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!store.hasLoaded && !store.loading && !localLoading) {
|
if (!store.hasLoaded && !store.loading) {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const load = async (inputParams?: ListCategoriesWithTagsInput) => {
|
const load = async (inputParams?: ListCategoriesWithTagsInput) => {
|
||||||
if (store.loading || localLoading) return
|
if (store.loading) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLocalLoading(true)
|
|
||||||
useCategoriesWithTagsStore.setState({ loading: true })
|
useCategoriesWithTagsStore.setState({ loading: true })
|
||||||
const category = root.get(CategoryController)
|
const category = root.get(CategoryController)
|
||||||
|
|
||||||
@@ -65,14 +63,12 @@ export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => {
|
|||||||
useCategoriesWithTagsStore.setState({ data, loading: false, error: null, hasLoaded: true })
|
useCategoriesWithTagsStore.setState({ data, loading: false, error: null, hasLoaded: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useCategoriesWithTagsStore.setState({ error: e as ApiError, loading: false, hasLoaded: true })
|
useCategoriesWithTagsStore.setState({ error: e as ApiError, loading: false, hasLoaded: true })
|
||||||
} finally {
|
|
||||||
setLocalLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
load,
|
load,
|
||||||
loading: store.loading || localLoading,
|
loading: store.loading,
|
||||||
error: store.error,
|
error: store.error,
|
||||||
data: store.data,
|
data: store.data,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,23 @@ import { useState, useEffect, useMemo } from 'react'
|
|||||||
import { Linking, Alert } from 'react-native'
|
import { Linking, Alert } from 'react-native'
|
||||||
|
|
||||||
import { subscription, useSession } from '@/lib/auth'
|
import { subscription, useSession } from '@/lib/auth'
|
||||||
import type { SubscriptionListItem } from '@/lib/auth'
|
import type { StripePricingTableResponse, SubscriptionListItem } from '@/lib/auth'
|
||||||
|
|
||||||
interface CreditPlan {
|
interface CreditPlan {
|
||||||
amountInCents: number
|
amountInCents: number
|
||||||
credits: number
|
credits: number
|
||||||
popular?: boolean
|
popular?: boolean
|
||||||
|
name: string
|
||||||
|
currency: string
|
||||||
|
featureList: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function useMembership() {
|
export function useMembership() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const [selectedPlanIndex, setSelectedPlanIndex] = useState(0)
|
const [selectedPlanIndex, setSelectedPlanIndex] = useState(0)
|
||||||
const [stripePricingData, setStripePricingData] = useState<any>(null)
|
const [stripePricingData, setStripePricingData] = useState<StripePricingTableResponse>(null)
|
||||||
const [authSubscriptions, setAuthSubscriptions] = useState<any>(null)
|
const [authSubscriptions, setAuthSubscriptions] = useState<any>(null)
|
||||||
const [isStripePricingLoading, setIsStripePricingLoading] = useState(false)
|
const [isStripePricingLoading, setIsStripePricingLoading] = useState(false)
|
||||||
const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false)
|
const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false)
|
||||||
@@ -66,11 +71,14 @@ export function useMembership() {
|
|||||||
const creditPlans = useMemo((): CreditPlan[] => {
|
const creditPlans = useMemo((): CreditPlan[] => {
|
||||||
if (!stripePricingData?.pricing_table_items) return []
|
if (!stripePricingData?.pricing_table_items) return []
|
||||||
return stripePricingData.pricing_table_items
|
return stripePricingData.pricing_table_items
|
||||||
.filter((item: any) => item.recurring?.interval === 'month')
|
.filter((item) => item.recurring?.interval === 'month')
|
||||||
.map((item: any) => ({
|
.map((item) => ({
|
||||||
amountInCents: parseInt(item.amount),
|
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',
|
popular: item.is_highlight || item.highlight_text === 'most_popular',
|
||||||
|
name: item.name || '',
|
||||||
|
currency: item.currency || 'usd',
|
||||||
|
featureList: item.feature_list || [],
|
||||||
}))
|
}))
|
||||||
}, [stripePricingData?.pricing_table_items])
|
}, [stripePricingData?.pricing_table_items])
|
||||||
|
|
||||||
|
|||||||
@@ -73,10 +73,16 @@ export const useTemplateGenerations = () => {
|
|||||||
hasMoreRef.current = newGenerations.length >= (params?.limit || 20)
|
hasMoreRef.current = newGenerations.length >= (params?.limit || 20)
|
||||||
currentPageRef.current = nextPage
|
currentPageRef.current = nextPage
|
||||||
|
|
||||||
setData((prev) => ({
|
// 合并数据并去重
|
||||||
...newData,
|
setData((prev) => {
|
||||||
data: [...(prev?.data || []), ...newGenerations],
|
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)
|
setLoadingMore(false)
|
||||||
return { data: newData, error: null }
|
return { data: newData, error: null }
|
||||||
},
|
},
|
||||||
|
|||||||
29
lib/auth.ts
29
lib/auth.ts
@@ -107,9 +107,36 @@ export interface CheckoutResponse {
|
|||||||
url?: string
|
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 {
|
export interface ISubscription {
|
||||||
list: (params?: SubscriptionListParams) => Promise<{ data?: SubscriptionListItem[]; error?: ApiError }>
|
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 }>
|
create: (params: CreateSubscriptionParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }>
|
||||||
upgrade: (params: UpgradeSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
|
upgrade: (params: UpgradeSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
|
||||||
cancel: (params: CancelSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
|
cancel: (params: CancelSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
|
||||||
|
|||||||
@@ -47,6 +47,21 @@
|
|||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Privacy Policy"
|
"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": {
|
"membership": {
|
||||||
"terms": "Terms of Service",
|
"terms": "Terms of Service",
|
||||||
"privacy": "Privacy Policy",
|
"privacy": "Privacy Policy",
|
||||||
|
|||||||
@@ -47,6 +47,21 @@
|
|||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "隐私协议"
|
"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": {
|
"membership": {
|
||||||
"terms": "服务条款",
|
"terms": "服务条款",
|
||||||
"privacy": "隐私协议",
|
"privacy": "隐私协议",
|
||||||
|
|||||||
Reference in New Issue
Block a user