This commit is contained in:
imeepos
2026-01-28 15:57:40 +08:00
parent 7d73cfbc3e
commit efd4aba8c1
12 changed files with 676 additions and 262 deletions

View File

@@ -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)
})
})
})
})

View File

@@ -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()
})
})

View File

@@ -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}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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])

View File

@@ -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 }
},

View File

@@ -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 }>

View File

@@ -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",

View File

@@ -47,6 +47,21 @@
"privacy": {
"title": "隐私协议"
},
"pricing": {
"feature": {
"useSora2": "6000点可以使用200次Sora2",
"useSora3": "750点可以使用25次Sora2",
"useSora4": "3000点可以使用100次Sora2",
"textToVideo": "支持文生视频/图生视频",
"superClearImageGeneration": "超清图像生成",
"superDiscount": "享受Sora2Veo3.1超级折扣",
"useSora2ProTemplate": "可以使用Sora2 Pro模版",
"productWatermark": "生成作品去除品牌水印",
"basicTemplate": "基础模版",
"allTemplates": "全部模版",
"highQualityVideo": "视频更高清"
}
},
"membership": {
"terms": "服务条款",
"privacy": "隐私协议",