fix: bug
This commit is contained in:
@@ -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(<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 { 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(<My />)
|
||||
const { getByTestId } = render(<My />)
|
||||
|
||||
// 模拟下拉刷新
|
||||
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(<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,
|
||||
})
|
||||
|
||||
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(() => {
|
||||
expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 })
|
||||
expect(mockLoadMore).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<View
|
||||
onLayout={(e) => handleTabsLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}
|
||||
style={isSticky ? { opacity: 0, height: tabsHeight } : undefined}
|
||||
@@ -193,8 +197,8 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{showLoading && <LoadingState />}
|
||||
{/* 统一的加载状态 */}
|
||||
{isLoading && <LoadingState />}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{categoriesError && (
|
||||
@@ -210,18 +214,11 @@ export default function HomeScreen() {
|
||||
{showEmptyTemplates && (
|
||||
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
|
||||
)}
|
||||
|
||||
{/* 模板加载中 */}
|
||||
{templatesLoading && !showLoading && (
|
||||
<View style={styles.templatesLoadingContainer}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
), [
|
||||
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 (
|
||||
<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 }]}>
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -68,6 +68,16 @@ export default function My() {
|
||||
hasMore,
|
||||
} = useTemplateGenerations()
|
||||
|
||||
// 调试日志
|
||||
useEffect(() => {
|
||||
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<NativeScrollEvent>) => {
|
||||
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
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 (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
|
||||
@@ -219,11 +245,12 @@ export default function My() {
|
||||
<MySkeleton />
|
||||
) : (
|
||||
<ScrollView
|
||||
testID="my-scroll-view"
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={handleEndReached}
|
||||
scrollEventThrottle={400}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
@@ -356,6 +383,7 @@ const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
backgroundColor: '#090A0B',
|
||||
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
profileSection: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -59,14 +59,27 @@ interface Plan {
|
||||
price: number
|
||||
recommended?: boolean
|
||||
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() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [agreed, setAgreed] = useState(true)
|
||||
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
||||
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||
|
||||
@@ -89,38 +102,26 @@ export default function MembershipScreen() {
|
||||
stripePricingData,
|
||||
} = useMembership()
|
||||
|
||||
|
||||
// 映射 API 数据到 UI 格式
|
||||
const plans: Plan[] = creditPlans.map((plan, index) => ({
|
||||
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() {
|
||||
</Pressable>
|
||||
<View style={styles.headerSpacer} />
|
||||
<View style={styles.pointsContainer}>
|
||||
<Pressable
|
||||
style={styles.pointsPill}
|
||||
onPress={() => setPointsDrawerVisible(true)}
|
||||
>
|
||||
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
||||
<Pressable
|
||||
style={styles.pointsPill}
|
||||
onPress={() => setPointsDrawerVisible(true)}
|
||||
>
|
||||
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
||||
<MembershipPointsIcon />
|
||||
<Text style={styles.pointsValue}>{creditBalance}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.settingsButtonContainer}>
|
||||
<Dropdown
|
||||
options={menuOptions}
|
||||
onSelect={(value) => handleMenuSelect(value)}
|
||||
offsetTop={10}
|
||||
renderTrigger={(selectedOption, isOpen, toggle) => (
|
||||
<Pressable style={styles.settingsButton} onPress={toggle}>
|
||||
<OmitIcon />
|
||||
</Pressable>
|
||||
)}
|
||||
dropdownStyle={styles.menuDropdown}
|
||||
renderOption={(option, isSelected) => (
|
||||
<View style={styles.menuOption}>
|
||||
{option.value === 'terms' ? (
|
||||
<TermsIcon />
|
||||
) : (
|
||||
<PrivacyIcon />
|
||||
)}
|
||||
<Text style={styles.menuOptionText}>{option.label}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.pointsValue}>{creditBalance}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.settingsButtonContainer}>
|
||||
<Dropdown
|
||||
options={menuOptions}
|
||||
onSelect={(value) => handleMenuSelect(value)}
|
||||
offsetTop={10}
|
||||
renderTrigger={(selectedOption, isOpen, toggle) => (
|
||||
<Pressable style={styles.settingsButton} onPress={toggle}>
|
||||
<OmitIcon />
|
||||
</Pressable>
|
||||
)}
|
||||
dropdownStyle={styles.menuDropdown}
|
||||
renderOption={(option, isSelected) => (
|
||||
<View style={styles.menuOption}>
|
||||
{option.value === 'terms' ? (
|
||||
<TermsIcon />
|
||||
) : (
|
||||
<PrivacyIcon />
|
||||
)}
|
||||
<Text style={styles.menuOptionText}>{option.label}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView
|
||||
@@ -241,46 +242,46 @@ export default function MembershipScreen() {
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.imageContainer}>
|
||||
<Carousel
|
||||
width={screenWidth}
|
||||
height={screenWidth / 1.1}
|
||||
data={carouselImages}
|
||||
renderItem={({ item }) => (
|
||||
<Image
|
||||
source={item}
|
||||
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
autoPlay
|
||||
autoPlayInterval={2000}
|
||||
loop
|
||||
onSnapToItem={(index) => setCurrentImageIndex(index)}
|
||||
enabled={false}
|
||||
windowSize={1}
|
||||
mode="parallax"
|
||||
/>
|
||||
<View style={styles.dotsContainer}>
|
||||
{carouselImages.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
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 style={styles.imageContainer}>
|
||||
<Carousel
|
||||
width={screenWidth}
|
||||
height={screenWidth / 1.1}
|
||||
data={carouselImages}
|
||||
renderItem={({ item }) => (
|
||||
<Image
|
||||
source={item}
|
||||
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
autoPlay
|
||||
autoPlayInterval={2000}
|
||||
loop
|
||||
onSnapToItem={(index) => setCurrentImageIndex(index)}
|
||||
enabled={false}
|
||||
windowSize={1}
|
||||
mode="parallax"
|
||||
/>
|
||||
<View style={styles.dotsContainer}>
|
||||
{carouselImages.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
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>
|
||||
|
||||
{/* 订阅计划标题 */}
|
||||
<Text style={styles.sectionTitle}>{t('membership.subscriptionPlan')}</Text>
|
||||
|
||||
@@ -289,121 +290,128 @@ export default function MembershipScreen() {
|
||||
{plans.map((plan, index) => {
|
||||
const isSelected = selectedPlanIndex === index
|
||||
return (
|
||||
<Pressable
|
||||
key={plan.id}
|
||||
style={[
|
||||
<Pressable
|
||||
key={plan.id}
|
||||
style={[
|
||||
styles.planCardWrapper,
|
||||
index > 0 && styles.planCardSpacing,
|
||||
]}
|
||||
onPress={() => setSelectedPlanIndex(index)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.planCardGradient}
|
||||
>
|
||||
<View style={styles.planCard}>
|
||||
{plan.recommended && (
|
||||
<View style={styles.recommendedBadge}>
|
||||
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
|
||||
index > 0 && styles.planCardSpacing,
|
||||
]}
|
||||
onPress={() => setSelectedPlanIndex(index)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.planCardGradient}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
</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}
|
||||
>
|
||||
$
|
||||
</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>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
{/* 卡片所属的信息 */}
|
||||
<View style={styles.pointsMonthlyContainer}>
|
||||
{/* 积分每月显示 */}
|
||||
<View style={styles.pointsMonthlyCard}>
|
||||
<View style={styles.pointsMonthlyHeader}>
|
||||
<MembershipPointsIcon />
|
||||
<Text style={styles.pointsMonthlyValue}>
|
||||
{currentPlan.points.toLocaleString()}
|
||||
</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 style={styles.pointsMonthlyContainer}>
|
||||
{/* 积分每月显示 */}
|
||||
<View style={styles.pointsMonthlyCard}>
|
||||
<View style={styles.pointsMonthlyHeader}>
|
||||
<MembershipPointsIcon />
|
||||
<Text style={styles.pointsMonthlyValue}>
|
||||
{currentPlan.points.toLocaleString()}
|
||||
</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.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>
|
||||
</ScrollView>
|
||||
|
||||
|
||||
{/* 固定在底部的订阅容器 */}
|
||||
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
||||
{/* 立即开通按钮 */}
|
||||
<Pressable
|
||||
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
|
||||
style={styles.subscribeButtonPressable}
|
||||
onPress={handleSubscribe}
|
||||
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
|
||||
style={styles.subscribeButtonPressable}
|
||||
onPress={handleSubscribe}
|
||||
>
|
||||
<LinearGradient
|
||||
<LinearGradient
|
||||
colors={currentPlan.recommended
|
||||
? ['#9966FF', '#FF6699', '#FF9966']
|
||||
: ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF']
|
||||
@@ -419,15 +427,15 @@ export default function MembershipScreen() {
|
||||
(!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled,
|
||||
]}
|
||||
>
|
||||
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? (
|
||||
<ActivityIndicator color="#F5F5F5" />
|
||||
) : (
|
||||
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
||||
)}
|
||||
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? (
|
||||
<ActivityIndicator color="#F5F5F5" />
|
||||
) : (
|
||||
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
{/* 协议复选框 */}
|
||||
<View style={styles.agreementContainer}>
|
||||
{/* 协议复选框 */}
|
||||
<View style={styles.agreementContainer}>
|
||||
<Pressable
|
||||
style={styles.checkbox}
|
||||
onPress={() => setAgreed(!agreed)}
|
||||
@@ -437,14 +445,14 @@ export default function MembershipScreen() {
|
||||
<View style={styles.agreementTextWrapper}>
|
||||
<Text style={styles.agreementText}>
|
||||
{t('membership.agreementText')}{' '}
|
||||
<Text
|
||||
<Text
|
||||
style={styles.agreementLink}
|
||||
onPress={() => router.push('/terms')}
|
||||
>
|
||||
{t('membership.terms')}
|
||||
</Text>
|
||||
<Text style={styles.agreementText}> {t('membership.agreementAnd')} </Text>
|
||||
<Text
|
||||
<Text
|
||||
style={styles.agreementLink}
|
||||
onPress={() => router.push('/privacy')}
|
||||
>
|
||||
@@ -455,7 +463,7 @@ export default function MembershipScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{/* 积分抽屉 */}
|
||||
<PointsDrawer
|
||||
visible={pointsDrawerVisible}
|
||||
@@ -721,15 +729,18 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
agreementTextWrapper: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
checkbox: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 4,
|
||||
},
|
||||
agreementText: {
|
||||
color: '#CCCCCC',
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface PointsDrawerProps {
|
||||
* 额外充值积分
|
||||
*/
|
||||
topUpPoints?: number
|
||||
/**
|
||||
* 充值回调
|
||||
*/
|
||||
onRecharge?: (amount: any) => 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()
|
||||
|
||||
@@ -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<CategoriesWithTagsState>((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,
|
||||
}
|
||||
|
||||
@@ -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<any>(null)
|
||||
const [stripePricingData, setStripePricingData] = useState<StripePricingTableResponse>(null)
|
||||
const [authSubscriptions, setAuthSubscriptions] = useState<any>(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])
|
||||
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
29
lib/auth.ts
29
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 }>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "隐私协议",
|
||||
|
||||
Reference in New Issue
Block a user