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