SDUI modal init

This commit is contained in:
tapframe 2025-12-24 18:28:39 +05:30
parent c421e46724
commit 8b3a1b57bf
6 changed files with 815 additions and 0 deletions

1
.gitignore vendored
View file

@ -80,6 +80,7 @@ bottomnav.md
mmkv.md
fix-android-scroll-lag-summary.md
server/cache-server
server/campaign-manager
carousal.md
node_modules
expofs.md

View file

@ -42,6 +42,7 @@ import { AccountProvider, useAccount } from './src/contexts/AccountContext';
import { ToastProvider } from './src/contexts/ToastContext';
import { mmkvStorage } from './src/services/mmkvStorage';
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
import { CampaignManager } from './src/components/promotions/CampaignManager';
Sentry.init({
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
@ -232,6 +233,7 @@ const ThemedApp = () => {
onActionPress={handleNavigateToDebrid}
actionButtonText="Connect Now"
/>
<CampaignManager />
</View>
</DownloadsProvider>
</NavigationContainer>

View 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',
},
});

View 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',
},
});

View file

@ -39,6 +39,7 @@ import PluginIcon from '../components/icons/PluginIcon';
import TraktIcon from '../components/icons/TraktIcon';
import TMDBIcon from '../components/icons/TMDBIcon';
import MDBListIcon from '../components/icons/MDBListIcon';
import { campaignService } from '../services/campaignService';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
@ -801,6 +802,17 @@ const SettingsScreen: React.FC = () => {
renderControl={ChevronRight}
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
title="Clear All Data"
icon="trash-2"

View 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();