mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-01 05:04:33 +00:00
SDUI modal init
This commit is contained in:
parent
c421e46724
commit
8b3a1b57bf
6 changed files with 815 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -80,6 +80,7 @@ bottomnav.md
|
||||||
mmkv.md
|
mmkv.md
|
||||||
fix-android-scroll-lag-summary.md
|
fix-android-scroll-lag-summary.md
|
||||||
server/cache-server
|
server/cache-server
|
||||||
|
server/campaign-manager
|
||||||
carousal.md
|
carousal.md
|
||||||
node_modules
|
node_modules
|
||||||
expofs.md
|
expofs.md
|
||||||
|
|
|
||||||
2
App.tsx
2
App.tsx
|
|
@ -42,6 +42,7 @@ import { AccountProvider, useAccount } from './src/contexts/AccountContext';
|
||||||
import { ToastProvider } from './src/contexts/ToastContext';
|
import { ToastProvider } from './src/contexts/ToastContext';
|
||||||
import { mmkvStorage } from './src/services/mmkvStorage';
|
import { mmkvStorage } from './src/services/mmkvStorage';
|
||||||
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
|
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
|
||||||
|
import { CampaignManager } from './src/components/promotions/CampaignManager';
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
||||||
|
|
@ -232,6 +233,7 @@ const ThemedApp = () => {
|
||||||
onActionPress={handleNavigateToDebrid}
|
onActionPress={handleNavigateToDebrid}
|
||||||
actionButtonText="Connect Now"
|
actionButtonText="Connect Now"
|
||||||
/>
|
/>
|
||||||
|
<CampaignManager />
|
||||||
</View>
|
</View>
|
||||||
</DownloadsProvider>
|
</DownloadsProvider>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|
|
||||||
355
src/components/promotions/CampaignManager.tsx
Normal file
355
src/components/promotions/CampaignManager.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { View, StyleSheet, Text, TouchableOpacity, Image, Linking, Dimensions } from 'react-native';
|
||||||
|
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown, SlideInUp, SlideOutUp } from 'react-native-reanimated';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { campaignService, Campaign, CampaignAction } from '../../services/campaignService';
|
||||||
|
import { PosterModal } from './PosterModal';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { useAccount } from '../../contexts/AccountContext';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
|
||||||
|
// --- Banner Component ---
|
||||||
|
interface BannerProps {
|
||||||
|
campaign: Campaign;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onAction: (action: CampaignAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BannerCampaign: React.FC<BannerProps> = ({ campaign, onDismiss, onAction }) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { content } = campaign;
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (content.primaryAction) {
|
||||||
|
onAction(content.primaryAction);
|
||||||
|
if (content.primaryAction.type === 'dismiss') {
|
||||||
|
onDismiss();
|
||||||
|
} else if (content.primaryAction.type === 'link' && content.primaryAction.value) {
|
||||||
|
Linking.openURL(content.primaryAction.value);
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={SlideInUp.duration(300)}
|
||||||
|
exiting={SlideOutUp.duration(250)}
|
||||||
|
style={[styles.bannerContainer, { paddingTop: insets.top + 8 }]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.banner, { backgroundColor: content.backgroundColor || '#1a1a1a' }]}
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{content.imageUrl && (
|
||||||
|
<Image source={{ uri: content.imageUrl }} style={styles.bannerImage} />
|
||||||
|
)}
|
||||||
|
<View style={styles.bannerContent}>
|
||||||
|
{content.title && (
|
||||||
|
<Text style={[styles.bannerTitle, { color: content.textColor || '#fff' }]}>
|
||||||
|
{content.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{content.message && (
|
||||||
|
<Text style={[styles.bannerMessage, { color: content.textColor || '#fff' }]} numberOfLines={2}>
|
||||||
|
{content.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{content.primaryAction?.label && (
|
||||||
|
<View style={[styles.bannerCta, { backgroundColor: content.textColor || '#fff' }]}>
|
||||||
|
<Text style={[styles.bannerCtaText, { color: content.backgroundColor || '#1a1a1a' }]}>
|
||||||
|
{content.primaryAction.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={styles.bannerClose} onPress={onDismiss}>
|
||||||
|
<Ionicons name="close" size={18} color={content.closeButtonColor || '#fff'} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Bottom Sheet Component ---
|
||||||
|
interface BottomSheetProps {
|
||||||
|
campaign: Campaign;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onAction: (action: CampaignAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BottomSheetCampaign: React.FC<BottomSheetProps> = ({ campaign, onDismiss, onAction }) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { content } = campaign;
|
||||||
|
|
||||||
|
const handlePrimaryAction = () => {
|
||||||
|
if (content.primaryAction) {
|
||||||
|
onAction(content.primaryAction);
|
||||||
|
if (content.primaryAction.type === 'dismiss') {
|
||||||
|
onDismiss();
|
||||||
|
} else if (content.primaryAction.type === 'link' && content.primaryAction.value) {
|
||||||
|
Linking.openURL(content.primaryAction.value);
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={StyleSheet.absoluteFill}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
exiting={FadeOut.duration(200)}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onDismiss}>
|
||||||
|
<BlurView intensity={20} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={SlideInDown.duration(300)}
|
||||||
|
exiting={SlideOutDown.duration(250)}
|
||||||
|
style={[styles.bottomSheet, { paddingBottom: insets.bottom + 24 }]}
|
||||||
|
>
|
||||||
|
<View style={styles.bottomSheetHandle} />
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.bottomSheetClose} onPress={onDismiss}>
|
||||||
|
<Ionicons name="close" size={22} color={content.closeButtonColor || '#fff'} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{content.imageUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: content.imageUrl }}
|
||||||
|
style={[styles.bottomSheetImage, { aspectRatio: content.aspectRatio || 1.5 }]}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.bottomSheetContent}>
|
||||||
|
{content.title && (
|
||||||
|
<Text style={[styles.bottomSheetTitle, { color: content.textColor || '#fff' }]}>
|
||||||
|
{content.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{content.message && (
|
||||||
|
<Text style={[styles.bottomSheetMessage, { color: content.textColor || '#fff' }]}>
|
||||||
|
{content.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{content.primaryAction && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.bottomSheetButton, { backgroundColor: content.textColor || '#fff' }]}
|
||||||
|
onPress={handlePrimaryAction}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.bottomSheetButtonText, { color: content.backgroundColor || '#1a1a1a' }]}>
|
||||||
|
{content.primaryAction.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Campaign Manager ---
|
||||||
|
export const CampaignManager: React.FC = () => {
|
||||||
|
const [activeCampaign, setActiveCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { user } = useAccount();
|
||||||
|
|
||||||
|
const checkForCampaigns = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[CampaignManager] Checking for campaigns...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const campaign = await campaignService.getActiveCampaign();
|
||||||
|
console.log('[CampaignManager] Got campaign:', campaign?.id, campaign?.type);
|
||||||
|
|
||||||
|
if (campaign) {
|
||||||
|
setActiveCampaign(campaign);
|
||||||
|
setIsVisible(true);
|
||||||
|
campaignService.recordImpression(campaign.id, campaign.rules.showOncePerUser);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[CampaignManager] Failed to check campaigns', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkForCampaigns();
|
||||||
|
}, [checkForCampaigns]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
|
||||||
|
// After animation completes, check for next campaign
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextCampaign = campaignService.getNextCampaign();
|
||||||
|
console.log('[CampaignManager] Next campaign:', nextCampaign?.id, nextCampaign?.type);
|
||||||
|
|
||||||
|
if (nextCampaign) {
|
||||||
|
setActiveCampaign(nextCampaign);
|
||||||
|
setIsVisible(true);
|
||||||
|
campaignService.recordImpression(nextCampaign.id, nextCampaign.rules.showOncePerUser);
|
||||||
|
} else {
|
||||||
|
setActiveCampaign(null);
|
||||||
|
}
|
||||||
|
}, 350); // Wait for exit animation
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAction = (action: CampaignAction) => {
|
||||||
|
console.log('[CampaignManager] Action:', action);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!activeCampaign || !isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
||||||
|
{activeCampaign.type === 'poster_modal' && (
|
||||||
|
<PosterModal
|
||||||
|
campaign={activeCampaign}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeCampaign.type === 'banner' && (
|
||||||
|
<BannerCampaign
|
||||||
|
campaign={activeCampaign}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeCampaign.type === 'bottom_sheet' && (
|
||||||
|
<BottomSheetCampaign
|
||||||
|
campaign={activeCampaign}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
// Banner styles
|
||||||
|
bannerContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
bannerImage: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
bannerContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
bannerTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
bannerMessage: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
bannerCta: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
bannerCtaText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
bannerClose: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bottom sheet styles
|
||||||
|
backdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
},
|
||||||
|
bottomSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
bottomSheetHandle: {
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderRadius: 2,
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
bottomSheetClose: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: 10,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
bottomSheetImage: {
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 10,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
bottomSheetContent: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
bottomSheetTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
bottomSheetMessage: {
|
||||||
|
fontSize: 14,
|
||||||
|
opacity: 0.8,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
bottomSheetButton: {
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
bottomSheetButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
228
src/components/promotions/PosterModal.tsx
Normal file
228
src/components/promotions/PosterModal.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Dimensions,
|
||||||
|
Image,
|
||||||
|
Linking,
|
||||||
|
} from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Campaign } from '../../services/campaignService';
|
||||||
|
|
||||||
|
interface PosterModalProps {
|
||||||
|
campaign: Campaign;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onAction: (action: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export const PosterModal: React.FC<PosterModalProps> = ({
|
||||||
|
campaign,
|
||||||
|
onDismiss,
|
||||||
|
onAction,
|
||||||
|
}) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { content } = campaign;
|
||||||
|
const isPosterOnly = !content.title && !content.message;
|
||||||
|
|
||||||
|
const handleAction = () => {
|
||||||
|
if (content.primaryAction) {
|
||||||
|
if (content.primaryAction.type === 'link' && content.primaryAction.value) {
|
||||||
|
Linking.openURL(content.primaryAction.value);
|
||||||
|
onAction(content.primaryAction);
|
||||||
|
onDismiss();
|
||||||
|
} else if (content.primaryAction.type === 'dismiss') {
|
||||||
|
onDismiss();
|
||||||
|
} else {
|
||||||
|
onAction(content.primaryAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={StyleSheet.absoluteFill}>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
exiting={FadeOut.duration(200)}
|
||||||
|
style={styles.backdrop}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={30}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={onDismiss}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(250)}
|
||||||
|
exiting={FadeOut.duration(200)}
|
||||||
|
style={[
|
||||||
|
styles.modalContainer,
|
||||||
|
{ paddingTop: insets.top + 20, paddingBottom: insets.bottom + 20 }
|
||||||
|
]}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
<View style={styles.contentWrapper}>
|
||||||
|
{/* Close Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={onDismiss}
|
||||||
|
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
|
||||||
|
>
|
||||||
|
<View style={styles.closeButtonBg}>
|
||||||
|
<Ionicons
|
||||||
|
name="close"
|
||||||
|
size={20}
|
||||||
|
color={content.closeButtonColor || '#fff'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Main Image */}
|
||||||
|
{content.imageUrl && (
|
||||||
|
<View style={[
|
||||||
|
styles.imageContainer,
|
||||||
|
{ aspectRatio: content.aspectRatio || 0.7 }
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: content.imageUrl }}
|
||||||
|
style={styles.image}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
{!isPosterOnly && (
|
||||||
|
<View style={[
|
||||||
|
styles.textContainer,
|
||||||
|
{ backgroundColor: content.backgroundColor || '#1a1a1a' }
|
||||||
|
]}>
|
||||||
|
{content.title && (
|
||||||
|
<Text style={[styles.title, { color: content.textColor || '#fff' }]}>
|
||||||
|
{content.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{content.message && (
|
||||||
|
<Text style={[styles.message, { color: content.textColor || '#fff' }]}>
|
||||||
|
{content.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Primary Action Button */}
|
||||||
|
{content.primaryAction && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
{
|
||||||
|
backgroundColor: content.textColor || '#fff',
|
||||||
|
marginTop: isPosterOnly ? 16 : 0,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={handleAction}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.actionButtonText,
|
||||||
|
{ color: content.backgroundColor || '#1a1a1a' }
|
||||||
|
]}>
|
||||||
|
{content.primaryAction.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
zIndex: 998,
|
||||||
|
},
|
||||||
|
modalContainer: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 999,
|
||||||
|
},
|
||||||
|
contentWrapper: {
|
||||||
|
width: Math.min(SCREEN_WIDTH * 0.85, 340),
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
closeButtonBg: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
imageContainer: {
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#222',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
width: '100%',
|
||||||
|
padding: 20,
|
||||||
|
borderBottomLeftRadius: 12,
|
||||||
|
borderBottomRightRadius: 12,
|
||||||
|
marginTop: -2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 6,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
borderRadius: 24,
|
||||||
|
marginTop: 16,
|
||||||
|
minWidth: 180,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -39,6 +39,7 @@ import PluginIcon from '../components/icons/PluginIcon';
|
||||||
import TraktIcon from '../components/icons/TraktIcon';
|
import TraktIcon from '../components/icons/TraktIcon';
|
||||||
import TMDBIcon from '../components/icons/TMDBIcon';
|
import TMDBIcon from '../components/icons/TMDBIcon';
|
||||||
import MDBListIcon from '../components/icons/MDBListIcon';
|
import MDBListIcon from '../components/icons/MDBListIcon';
|
||||||
|
import { campaignService } from '../services/campaignService';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
const isTablet = width >= 768;
|
const isTablet = width >= 768;
|
||||||
|
|
@ -801,6 +802,17 @@ const SettingsScreen: React.FC = () => {
|
||||||
renderControl={ChevronRight}
|
renderControl={ChevronRight}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
|
<SettingItem
|
||||||
|
title="Reset Campaigns"
|
||||||
|
description="Clear campaign impressions"
|
||||||
|
icon="refresh-cw"
|
||||||
|
onPress={async () => {
|
||||||
|
await campaignService.resetCampaigns();
|
||||||
|
openAlert('Success', 'Campaign history reset. Restart app to see posters again.');
|
||||||
|
}}
|
||||||
|
renderControl={ChevronRight}
|
||||||
|
isTablet={isTablet}
|
||||||
|
/>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Clear All Data"
|
title="Clear All Data"
|
||||||
icon="trash-2"
|
icon="trash-2"
|
||||||
|
|
|
||||||
217
src/services/campaignService.ts
Normal file
217
src/services/campaignService.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { mmkvStorage } from './mmkvStorage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
// Dev: Uses Mac's LAN IP for physical device testing (run: ipconfig getifaddr en0)
|
||||||
|
// Prod: Uses EXPO_PUBLIC_CAMPAIGN_API_URL from .env
|
||||||
|
const CAMPAIGN_API_URL = __DEV__
|
||||||
|
? 'http://192.168.1.5:3000'
|
||||||
|
: Constants.expoConfig?.extra?.CAMPAIGN_API_URL || process.env.EXPO_PUBLIC_CAMPAIGN_API_URL || '';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export type CampaignAction = {
|
||||||
|
type: 'link' | 'navigate' | 'dismiss';
|
||||||
|
value?: string; // URL or Route Name
|
||||||
|
label: string;
|
||||||
|
style?: 'primary' | 'secondary' | 'outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignContent = {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
closeButtonColor?: string;
|
||||||
|
primaryAction?: CampaignAction | null;
|
||||||
|
secondaryAction?: CampaignAction | null;
|
||||||
|
aspectRatio?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignRules = {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
maxImpressions?: number | null;
|
||||||
|
minVersion?: string;
|
||||||
|
maxVersion?: string;
|
||||||
|
platforms?: string[];
|
||||||
|
priority: number;
|
||||||
|
showOncePerSession?: boolean;
|
||||||
|
showOncePerUser?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Campaign = {
|
||||||
|
id: string;
|
||||||
|
type: 'poster_modal' | 'banner' | 'bottom_sheet';
|
||||||
|
content: CampaignContent;
|
||||||
|
rules: CampaignRules;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Service ---
|
||||||
|
|
||||||
|
class CampaignService {
|
||||||
|
private sessionImpressions: Set<string>;
|
||||||
|
private campaignQueue: Campaign[] = [];
|
||||||
|
private currentIndex: number = 0;
|
||||||
|
private lastFetch: number = 0;
|
||||||
|
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.sessionImpressions = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all active campaigns and returns the next valid one in the queue.
|
||||||
|
*/
|
||||||
|
async getActiveCampaign(): Promise<Campaign | null> {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If we have campaigns in queue and cache is still valid, get next valid one
|
||||||
|
if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) {
|
||||||
|
return this.getNextValidCampaign();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all campaigns from server
|
||||||
|
const platform = Platform.OS;
|
||||||
|
const response = await fetch(
|
||||||
|
`${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('[CampaignService] Failed to fetch campaigns:', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaigns = await response.json();
|
||||||
|
|
||||||
|
if (!campaigns || !Array.isArray(campaigns) || campaigns.length === 0) {
|
||||||
|
this.campaignQueue = [];
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.lastFetch = now;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative image URLs
|
||||||
|
campaigns.forEach((campaign: Campaign) => {
|
||||||
|
if (campaign.content?.imageUrl && campaign.content.imageUrl.startsWith('/')) {
|
||||||
|
campaign.content.imageUrl = `${CAMPAIGN_API_URL}${campaign.content.imageUrl}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.campaignQueue = campaigns;
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.lastFetch = now;
|
||||||
|
|
||||||
|
return this.getNextValidCampaign();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[CampaignService] Error fetching campaigns:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the next valid campaign from the queue.
|
||||||
|
*/
|
||||||
|
private getNextValidCampaign(): Campaign | null {
|
||||||
|
while (this.currentIndex < this.campaignQueue.length) {
|
||||||
|
const campaign = this.campaignQueue[this.currentIndex];
|
||||||
|
if (this.isLocallyValid(campaign)) {
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
this.currentIndex++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves to the next campaign in the queue and returns it.
|
||||||
|
*/
|
||||||
|
getNextCampaign(): Campaign | null {
|
||||||
|
this.currentIndex++;
|
||||||
|
return this.getNextValidCampaign();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates campaign against local-only rules.
|
||||||
|
*/
|
||||||
|
private isLocallyValid(campaign: Campaign): boolean {
|
||||||
|
const { rules } = campaign;
|
||||||
|
|
||||||
|
// Show once per user (persisted forever)
|
||||||
|
if (rules.showOncePerUser && this.hasSeenCampaign(campaign.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Impression limit check
|
||||||
|
if (rules.maxImpressions) {
|
||||||
|
const impressionCount = this.getImpressionCount(campaign.id);
|
||||||
|
if (impressionCount >= rules.maxImpressions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session check
|
||||||
|
if (rules.showOncePerSession && this.sessionImpressions.has(campaign.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSeenCampaign(campaignId: string): boolean {
|
||||||
|
return mmkvStorage.getBoolean(`campaign_seen_${campaignId}`) || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private markCampaignSeen(campaignId: string) {
|
||||||
|
mmkvStorage.setBoolean(`campaign_seen_${campaignId}`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImpressionCount(campaignId: string): number {
|
||||||
|
return mmkvStorage.getNumber(`campaign_impression_${campaignId}`) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordImpression(campaignId: string, showOncePerUser?: boolean) {
|
||||||
|
const current = this.getImpressionCount(campaignId);
|
||||||
|
mmkvStorage.setNumber(`campaign_impression_${campaignId}`, current + 1);
|
||||||
|
this.sessionImpressions.add(campaignId);
|
||||||
|
|
||||||
|
if (showOncePerUser) {
|
||||||
|
this.markCampaignSeen(campaignId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetCampaigns() {
|
||||||
|
this.sessionImpressions.clear();
|
||||||
|
this.campaignQueue = [];
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.lastFetch = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
this.campaignQueue = [];
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.lastFetch = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns remaining campaigns in queue count.
|
||||||
|
*/
|
||||||
|
getRemainingCount(): number {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = this.currentIndex; i < this.campaignQueue.length; i++) {
|
||||||
|
if (this.isLocallyValid(this.campaignQueue[i])) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const campaignService = new CampaignService();
|
||||||
Loading…
Reference in a new issue