From 8b3a1b57bf95e1e6c8f48ea5dadf8012256c8280 Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 24 Dec 2025 18:28:39 +0530 Subject: [PATCH] SDUI modal init --- .gitignore | 1 + App.tsx | 2 + src/components/promotions/CampaignManager.tsx | 355 ++++++++++++++++++ src/components/promotions/PosterModal.tsx | 228 +++++++++++ src/screens/SettingsScreen.tsx | 12 + src/services/campaignService.ts | 217 +++++++++++ 6 files changed, 815 insertions(+) create mode 100644 src/components/promotions/CampaignManager.tsx create mode 100644 src/components/promotions/PosterModal.tsx create mode 100644 src/services/campaignService.ts diff --git a/.gitignore b/.gitignore index 16951d6f4..2f077cab3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/App.tsx b/App.tsx index 39207b57e..ff4b26fd9 100644 --- a/App.tsx +++ b/App.tsx @@ -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" /> + diff --git a/src/components/promotions/CampaignManager.tsx b/src/components/promotions/CampaignManager.tsx new file mode 100644 index 000000000..8caad7fa6 --- /dev/null +++ b/src/components/promotions/CampaignManager.tsx @@ -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 = ({ 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 ( + + + {content.imageUrl && ( + + )} + + {content.title && ( + + {content.title} + + )} + {content.message && ( + + {content.message} + + )} + + {content.primaryAction?.label && ( + + + {content.primaryAction.label} + + + )} + + + + + + ); +}; + +// --- Bottom Sheet Component --- +interface BottomSheetProps { + campaign: Campaign; + onDismiss: () => void; + onAction: (action: CampaignAction) => void; +} + +const BottomSheetCampaign: React.FC = ({ 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 ( + + + + + + + + + + + + + + + {content.imageUrl && ( + + )} + + + {content.title && ( + + {content.title} + + )} + {content.message && ( + + {content.message} + + )} + + + {content.primaryAction && ( + + + {content.primaryAction.label} + + + )} + + + ); +}; + +// --- Campaign Manager --- +export const CampaignManager: React.FC = () => { + const [activeCampaign, setActiveCampaign] = useState(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 ( + + {activeCampaign.type === 'poster_modal' && ( + + )} + {activeCampaign.type === 'banner' && ( + + )} + {activeCampaign.type === 'bottom_sheet' && ( + + )} + + ); +}; + +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', + }, +}); diff --git a/src/components/promotions/PosterModal.tsx b/src/components/promotions/PosterModal.tsx new file mode 100644 index 000000000..61cc4c7ec --- /dev/null +++ b/src/components/promotions/PosterModal.tsx @@ -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 = ({ + 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 ( + + {/* Backdrop */} + + + + + + {/* Modal Container */} + + + {/* Close Button */} + + + + + + + {/* Main Image */} + {content.imageUrl && ( + + + + )} + + {/* Text Content */} + {!isPosterOnly && ( + + {content.title && ( + + {content.title} + + )} + {content.message && ( + + {content.message} + + )} + + )} + + {/* Primary Action Button */} + {content.primaryAction && ( + + + {content.primaryAction.label} + + + )} + + + + ); +}; + +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', + }, +}); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index bfc003faa..d3727982b 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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} /> + { + await campaignService.resetCampaigns(); + openAlert('Success', 'Campaign history reset. Restart app to see posters again.'); + }} + renderControl={ChevronRight} + isTablet={isTablet} + /> ; + 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 { + 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();