diff --git a/app/__tests__/membership.test.tsx b/app/__tests__/membership.test.tsx
new file mode 100644
index 0000000..f3ef3aa
--- /dev/null
+++ b/app/__tests__/membership.test.tsx
@@ -0,0 +1,259 @@
+import React from 'react'
+import { render, waitFor, fireEvent } from '@testing-library/react-native'
+import MembershipScreen from '../membership'
+
+// 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-linear-gradient
+jest.mock('expo-linear-gradient', () => ({
+ LinearGradient: 'LinearGradient',
+}))
+
+// Mock expo-image
+jest.mock('expo-image', () => ({
+ Image: 'Image',
+}))
+
+// Mock expo-status-bar
+jest.mock('expo-status-bar', () => ({
+ StatusBar: 'StatusBar',
+}))
+
+// Mock react-native-svg
+jest.mock('react-native-svg', () => ({
+ Svg: 'Svg',
+ Path: 'Path',
+ Defs: 'Defs',
+ LinearGradient: 'LinearGradient',
+ Stop: 'Stop',
+}))
+
+// Mock react-native-reanimated-carousel
+jest.mock('react-native-reanimated-carousel', () => {
+ const React = require('react')
+ return {
+ __esModule: true,
+ default: ({ data, renderItem, onSnapToItem }: any) => {
+ return React.createElement(
+ 'View',
+ { testID: 'carousel' },
+ data.map((item: any, index: number) =>
+ React.createElement(
+ 'View',
+ { key: index, testID: `carousel-item-${index}` },
+ renderItem({ item, index })
+ )
+ )
+ )
+ },
+ }
+})
+
+// Mock icons
+jest.mock('@/components/icon', () => ({
+ CheckIcon: () => 'CheckIcon',
+ LeftArrowIcon: () => 'LeftArrowIcon',
+ OmitIcon: () => 'OmitIcon',
+ UncheckedIcon: () => 'UncheckedIcon',
+ TermsIcon: () => 'TermsIcon',
+ PrivacyIcon: () => 'PrivacyIcon',
+}))
+
+jest.mock('@/components/icon/checkMark', () => ({
+ CheckMarkIcon: () => 'CheckMarkIcon',
+}))
+
+// Mock components
+jest.mock('@/components/drawer/PointsDrawer', () => 'PointsDrawer')
+jest.mock('@/components/ui/dropdown', () => {
+ const React = require('react')
+ return {
+ __esModule: true,
+ default: ({ renderTrigger }: any) => {
+ return renderTrigger ? renderTrigger(null, false, jest.fn()) : null
+ },
+ }
+})
+jest.mock('@/components/GradientText', () => 'GradientText')
+
+// Mock expo-router
+jest.mock('expo-router', () => ({
+ useRouter: jest.fn(() => ({
+ push: jest.fn(),
+ back: jest.fn(),
+ })),
+}))
+
+// Mock react-i18next
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock hooks - 默认返回空广告数据
+const mockLoadActivates = jest.fn()
+const mockActivatesData = {
+ activities: [],
+}
+
+jest.mock('@/hooks/use-activates', () => ({
+ useActivates: jest.fn(() => ({
+ load: mockLoadActivates,
+ data: mockActivatesData,
+ })),
+}))
+
+jest.mock('@/hooks/use-membership', () => ({
+ useMembership: jest.fn(() => ({
+ creditPlans: [
+ {
+ name: 'Plus',
+ amountInCents: 999,
+ popular: false,
+ credits: 100,
+ currency: 'USD',
+ featureList: ['feature1', 'feature2'],
+ },
+ ],
+ creditBalance: 50,
+ selectedPlanIndex: 0,
+ setSelectedPlanIndex: jest.fn(),
+ isLoadingSubscriptions: false,
+ isStripePricingLoading: false,
+ createSubscription: jest.fn(),
+ upgradeSubscription: jest.fn(),
+ restoreSubscription: jest.fn(),
+ rechargeToken: jest.fn(),
+ activeAuthSubscription: null,
+ hasActiveSubscription: false,
+ stripePricingData: null,
+ })),
+}))
+
+describe('MembershipScreen - Advertisement Carousel', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('广告轮播显示', () => {
+ it('当广告数据为空时,应该隐藏轮播图', () => {
+ const { queryByTestId } = render()
+
+ // 轮播图不应该显示
+ expect(queryByTestId('carousel')).toBeNull()
+ })
+
+ it('当广告数据存在时,应该显示轮播图', () => {
+ // 设置有广告数据的mock
+ const { useActivates } = require('@/hooks/use-activates')
+ useActivates.mockReturnValue({
+ load: mockLoadActivates,
+ data: {
+ activities: [
+ {
+ id: '1',
+ title: 'Test Ad 1',
+ desc: 'Description 1',
+ coverUrl: 'https://example.com/ad1.jpg',
+ link: '/ad1',
+ },
+ {
+ id: '2',
+ title: 'Test Ad 2',
+ desc: 'Description 2',
+ coverUrl: 'https://example.com/ad2.jpg',
+ link: '/ad2',
+ },
+ ],
+ },
+ })
+
+ const { getByTestId } = render()
+
+ // 轮播图应该显示
+ expect(getByTestId('carousel')).toBeTruthy()
+ })
+
+ it('应该在组件挂载时加载广告数据', () => {
+ render()
+
+ // 应该调用 loadActivates
+ expect(mockLoadActivates).toHaveBeenCalled()
+ })
+ })
+
+ describe('广告点击跳转', () => {
+ it('点击广告时应该跳转到正确的链接', () => {
+ const mockPush = jest.fn()
+ const { useRouter } = require('expo-router')
+ useRouter.mockReturnValue({
+ push: mockPush,
+ back: jest.fn(),
+ })
+
+ const { useActivates } = require('@/hooks/use-activates')
+ useActivates.mockReturnValue({
+ load: mockLoadActivates,
+ data: {
+ activities: [
+ {
+ id: '1',
+ title: 'Test Ad',
+ desc: 'Description',
+ coverUrl: 'https://example.com/ad.jpg',
+ link: '/test-link',
+ },
+ ],
+ },
+ })
+
+ const { getByTestId } = render()
+
+ // 点击第一个广告
+ const carouselItem = getByTestId('carousel-item-0')
+ fireEvent.press(carouselItem)
+
+ // 应该跳转到广告链接
+ expect(mockPush).toHaveBeenCalledWith('/test-link')
+ })
+ })
+
+ describe('广告轮播指示点', () => {
+ it('当有多个广告时,应该显示指示点', () => {
+ const { useActivates } = require('@/hooks/use-activates')
+ useActivates.mockReturnValue({
+ load: mockLoadActivates,
+ data: {
+ activities: [
+ {
+ id: '1',
+ title: 'Ad 1',
+ desc: 'Desc 1',
+ coverUrl: 'https://example.com/ad1.jpg',
+ link: '/ad1',
+ },
+ {
+ id: '2',
+ title: 'Ad 2',
+ desc: 'Desc 2',
+ coverUrl: 'https://example.com/ad2.jpg',
+ link: '/ad2',
+ },
+ ],
+ },
+ })
+
+ const { getAllByTestId } = render()
+
+ // 应该有2个指示点
+ const dots = getAllByTestId(/^dot-/)
+ expect(dots).toHaveLength(2)
+ })
+ })
+})
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 08e49d6..067d781 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -13,6 +13,7 @@ configureReanimatedLogger({
strict: false,
})
import { GestureHandlerRootView } from 'react-native-gesture-handler'
+import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
import { View, ActivityIndicator, AppState, LogBox } from 'react-native'
import * as Updates from 'expo-updates'
@@ -168,18 +169,20 @@ function Providers({ children }: { children: React.ReactNode }) {
return (
-
-
- {children}
- {/* modals */}
+
+
+
+ {children}
+ {/* modals */}
- {/* 挂载全局方法 */}
- ((global as any).actionSheet = ref)} />
- ((global as any).modal = ref)} />
- ((global as any).loading = ref)} />
- ((global as any).toast = ref)} />
-
-
+ {/* 挂载全局方法 */}
+ ((global as any).actionSheet = ref)} />
+ ((global as any).modal = ref)} />
+ ((global as any).loading = ref)} />
+ ((global as any).toast = ref)} />
+
+
+
)
diff --git a/app/membership.tsx b/app/membership.tsx
index 34da696..48d6c87 100644
--- a/app/membership.tsx
+++ b/app/membership.tsx
@@ -24,6 +24,7 @@ import PointsDrawer from '@/components/drawer/PointsDrawer'
import Dropdown from '@/components/ui/dropdown'
import GradientText from '@/components/GradientText'
import { useMembership } from '@/hooks/use-membership'
+import { useActivates } from '@/hooks/use-activates'
// 使用唯一 id 的 PointsIcon,避免与其他页面的图标 id 冲突
const MembershipPointsIcon = () => {
@@ -102,6 +103,9 @@ export default function MembershipScreen() {
stripePricingData,
} = useMembership()
+ // 使用 useActivates hook 获取广告数据
+ const { load: loadActivates, data: activatesData } = useActivates()
+
// 映射 API 数据到 UI 格式
const plans: Plan[] = creditPlans.map((plan, index) => ({
@@ -131,14 +135,15 @@ export default function MembershipScreen() {
}
}
- // 轮播图相关
- const carouselImages = [
- require('@/assets/images/membership.png'),
- require('@/assets/images/icon.png'),
- require('@/assets/images/generate.png'),
- ]
+ // 轮播图相关 - 使用动态广告数据
+ const activities = activatesData?.activities || []
const [currentImageIndex, setCurrentImageIndex] = useState(0)
+ // 加载广告数据
+ useEffect(() => {
+ loadActivates()
+ }, [])
+
// 处理订阅按钮点击
const handleSubscribe = async () => {
if (!agreed || !selectedPlan) return
@@ -243,43 +248,55 @@ export default function MembershipScreen() {
showsVerticalScrollIndicator={false}
>
- (
- 0 && (
+ <>
+ (
+ router.push(item.link as any)}
+ style={{ width: screenWidth, height: screenWidth / 1.1 }}
+ >
+
+
+ )}
+ autoPlay
+ autoPlayInterval={2000}
+ loop
+ onSnapToItem={(index) => setCurrentImageIndex(index)}
+ enabled={false}
+ windowSize={1}
+ mode="parallax"
/>
- )}
- autoPlay
- autoPlayInterval={2000}
- loop
- onSnapToItem={(index) => setCurrentImageIndex(index)}
- enabled={false}
- windowSize={1}
- mode="parallax"
- />
-
- {carouselImages.map((_, index) => (
-
+ {activities.map((_, index) => (
+
+ ))}
+
+
- ))}
-
-
+ >
+ )}
{/* 订阅计划标题 */}
diff --git a/components/drawer/EditProfileDrawer.tsx b/components/drawer/EditProfileDrawer.tsx
index 04fb647..00accc0 100644
--- a/components/drawer/EditProfileDrawer.tsx
+++ b/components/drawer/EditProfileDrawer.tsx
@@ -14,7 +14,7 @@ import {
import { Image } from 'expo-image'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
-import BottomSheet, { BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
+import BottomSheet, { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
import { CloseIcon, AvatarUploadIcon } from '@/components/icon'
import { LinearGradient } from 'expo-linear-gradient'
import { type ImagePickerAsset } from 'expo-image-picker'
@@ -37,7 +37,7 @@ export default function EditProfileDrawer({
onSave,
}: EditProfileDrawerProps) {
const { t } = useTranslation()
- const bottomSheetRef = useRef(null)
+ const bottomSheetRef = useRef(null)
const [name, setName] = useState(initialName)
const [avatar, setAvatar] = useState(initialAvatar)
const [selectedImage, setSelectedImage] = useState(null)
@@ -45,17 +45,18 @@ export default function EditProfileDrawer({
const { loading, pickImage, updateProfile } = useUpdateProfile()
- const snapPoints = useMemo(() => [280], [])
+ // 增加高度以避免被底部 Tab 栏遮挡,考虑底部安全区域
+ const snapPoints = useMemo(() => [280 + insets.bottom + 60], [insets.bottom])
useEffect(() => {
if (visible) {
- bottomSheetRef.current?.expand()
+ bottomSheetRef.current?.present()
// 重置为初始值
setName(initialName)
setAvatar(initialAvatar)
setSelectedImage(null)
} else {
- bottomSheetRef.current?.close()
+ bottomSheetRef.current?.dismiss()
}
}, [visible, initialName, initialAvatar])
@@ -118,9 +119,8 @@ export default function EditProfileDrawer({
}
return (
-
-
+
)
}