Loyalty Screens
Programme de fidélité complet : cashback, challenges, tiers, récompenses, parrainage et historique des points.
Overview
Le module de fidélité YaniPay récompense les utilisateurs pour chaque transaction. Le système repose sur 5 niveaux (Bronze à Diamond), un mécanisme de cashback progressif, des défis gamifiés et une boutique de récompenses où les points peuvent être échangés contre des avantages exclusifs.
Points YaniPay
Home Screen Integration
Since ORC It.16, the Loyalty module has two prominent entry points on the main Home screen, making it the most accessible feature from the dashboard.
Quick Action
The "Loyalty" quick action (replacing the former Y.A.N.I. shortcut) uses a store-outline icon in #E91E63 pink and navigates directly to /loyalty/store.
CTA Card
A full-width gradient card (#E91E63 → #C2185B) with a decorative background icon, title, description and chevron button. Uses FadeInDown.delay(525) animation.
<TouchableOpacity
style={styles.loyaltyStoreCard}
activeOpacity={0.85}
onPress={() => router.push('/loyalty/store')}
>
<LinearGradient
colors={['#E91E63', '#C2185B']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.loyaltyStoreGradient}
>
<View style={styles.loyaltyStoreContent}>
<View style={styles.loyaltyStoreTextContainer}>
<Text style={styles.loyaltyStoreTitle}>Loyalty Store</Text>
<Text style={styles.loyaltyStoreDescription}>
Discover loyalty cards, earn points & cashback at your
favorite stores
</Text>
</View>
<View style={styles.loyaltyStoreIconContainer}>
<MaterialCommunityIcons name="store" size={28} color="#fff" />
<MaterialCommunityIcons name="chevron-right" size={20}
color="rgba(255,255,255,0.7)" />
</View>
</View>
{/* Decorative background icon */}
<MaterialCommunityIcons
name="card-multiple-outline" size={80}
color="rgba(255,255,255,0.1)"
style={styles.loyaltyStoreDecoration}
/>
</LinearGradient>
</TouchableOpacity>Loyalty Store
The Loyalty Store screen (app/loyalty/store.tsx) is the catalogue of all available loyalty programs and merchant cards. Users can browse, filter and join programs directly from this screen.
Key behaviors
- Catalog loading — Uses
useEffect(() => { fetchCatalog() }, [])(fixed from incorrectuseStateinitializer in ORC It.16) - Pull-to-refresh —
onRefreshcallsrefreshAll()for full catalog + user loyalty cards reload - Filter & search — Category chips and search bar filter the
catalogProgramslist client-side
Screens Overview
Le module loyalty comprend 8 écrans interconnectés permettant une expérience de fidélité complète et engageante.
Cashback Dashboard
Tableau de bord cashback et points
Challenges List
Défis hebdomadaires et mensuels
Challenge Detail
Progression et récompenses
Tiers Overview
Niveaux de fidélité
Rewards Store
Boutique de récompenses
Reward Detail
Détail et échange de points
Referral Page
Parrainage et bonus
Loyalty History
Historique des points
Navigation Flow
Cashback Dashboard
L'écran principal du module fidélité affiche le solde de points, le taux de cashback actuel, les défis en cours et un résumé des gains récents.
12 450
Points disponibles
3%
Taux cashback (Gold)
+840
Points ce mois
export default function CashbackDashboardScreen() {
const { data: loyaltyData } = useLoyaltyDashboard();
const { data: activeChallenges } = useActiveChallenges();
const { data: recentRewards } = useRecentRewards(5);
return (
<SafeAreaView style={styles.container}>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Points Balance Card */}
<LinearGradient
colors={['#ec4899', '#8b5cf6']}
style={styles.balanceCard}
>
<Text style={styles.balanceLabel}>Vos points YaniPay</Text>
<Text style={styles.balanceAmount}>
{formatNumber(loyaltyData?.points || 0)}
</Text>
<View style={styles.tierBadge}>
<Crown size={16} color="#fbbf24" />
<Text style={styles.tierText}>{loyaltyData?.tier}</Text>
</View>
<Text style={styles.cashbackRate}>
{loyaltyData?.cashbackRate}% cashback actif
</Text>
</LinearGradient>
{/* Stats Row */}
<View style={styles.statsRow}>
<StatCard
label="Ce mois"
value={`+${formatNumber(loyaltyData?.monthlyPoints || 0)}`}
icon={<TrendingUp size={20} />}
color="#10b981"
/>
<StatCard
label="Cashback total"
value={formatCurrency(loyaltyData?.totalCashback || 0)}
icon={<Coins size={20} />}
color="#06b6d4"
/>
</View>
{/* Active Challenges */}
<View style={styles.section}>
<SectionHeader
title="Défis en cours"
onSeeAll={() => router.push('/loyalty/challenges')}
/>
{activeChallenges?.slice(0, 3).map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
onPress={() => router.push(`/loyalty/challenges/${challenge.id}`)}
/>
))}
</View>
{/* Quick Actions */}
<View style={styles.actionsGrid}>
<ActionCard
icon={<ShoppingBag size={24} />}
label="Boutique"
onPress={() => router.push('/loyalty/rewards')}
/>
<ActionCard
icon={<Users size={24} />}
label="Parrainer"
onPress={() => router.push('/loyalty/referral')}
/>
<ActionCard
icon={<Crown size={24} />}
label="Mon tier"
onPress={() => router.push('/loyalty/tiers')}
/>
<ActionCard
icon={<History size={24} />}
label="Historique"
onPress={() => router.push('/loyalty/history')}
/>
</View>
{/* Recent Activity */}
<View style={styles.section}>
<SectionHeader
title="Activité récente"
onSeeAll={() => router.push('/loyalty/history')}
/>
{recentRewards?.map((reward) => (
<RewardActivityItem key={reward.id} reward={reward} />
))}
</View>
</ScrollView>
</SafeAreaView>
);
}Challenges
Les défis gamifiés encouragent l'utilisation de l'application avec des récompenses en points. Trois catégories sont disponibles : hebdomadaires, mensuels et spéciaux.
- 5 paiements = 100 pts
- Inviter 1 ami = 200 pts
- 1 achat carte = 150 pts
- 20 transactions = 500 pts
- Staking 30 jours = 800 pts
- KYC complet = 1000 pts
- Anniversaire = 500 pts
- Événement promo = 2x pts
- 1er achat crypto = 300 pts
type ChallengeCategory = 'weekly' | 'monthly' | 'special';
interface Challenge {
id: string;
title: string;
description: string;
category: ChallengeCategory;
reward: number;
progress: number;
target: number;
expiresAt: Date;
completed: boolean;
}
export default function ChallengesListScreen() {
const [activeCategory, setActiveCategory] = useState<ChallengeCategory>('weekly');
const { data: challenges } = useChallenges(activeCategory);
const categories = [
{ key: 'weekly', label: 'Hebdomadaire', icon: Timer },
{ key: 'monthly', label: 'Mensuel', icon: Target },
{ key: 'special', label: 'Spécial', icon: Sparkles },
];
return (
<SafeAreaView style={styles.container}>
{/* Category Tabs */}
<View style={styles.categoryTabs}>
{categories.map((cat) => (
<TouchableOpacity
key={cat.key}
style={[
styles.categoryTab,
activeCategory === cat.key && styles.activeCategoryTab,
]}
onPress={() => setActiveCategory(cat.key as ChallengeCategory)}
>
<cat.icon
size={18}
color={activeCategory === cat.key ? '#ec4899' : '#64748b'}
/>
<Text
style={[
styles.categoryLabel,
activeCategory === cat.key && styles.activeCategoryLabel,
]}
>
{cat.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Challenges List */}
<FlatList
data={challenges}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.challengeCard}
onPress={() => router.push(`/loyalty/challenges/${item.id}`)}
>
<View style={styles.challengeHeader}>
<Text style={styles.challengeTitle}>{item.title}</Text>
<View style={styles.rewardBadge}>
<Star size={14} color="#fbbf24" />
<Text style={styles.rewardText}>{item.reward} pts</Text>
</View>
</View>
<Text style={styles.challengeDesc}>{item.description}</Text>
{/* Progress Bar */}
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${(item.progress / item.target) * 100}%` },
]}
/>
</View>
<Text style={styles.progressText}>
{item.progress}/{item.target}
</Text>
</View>
{/* Expiry */}
<Text style={styles.expiryText}>
Expire {formatRelativeDate(item.expiresAt)}
</Text>
{item.completed && (
<View style={styles.completedBadge}>
<CheckCircle size={16} color="#10b981" />
<Text style={styles.completedText}>Complété</Text>
</View>
)}
</TouchableOpacity>
)}
ListEmptyComponent={
<EmptyState
icon={<Target size={48} />}
title="Aucun défi disponible"
description="Revenez bientôt pour de nouveaux défis !"
/>
}
/>
</SafeAreaView>
);
}Challenge Detail Screen
L'écran de détail d'un challenge affiche la progression, les conditions, la récompense et un historique des actions comptabilisées.
export default function ChallengeDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { data: challenge } = useChallenge(id);
const { data: activities } = useChallengeActivities(id);
const claimMutation = useClaimChallengeReward();
const progressPercent = challenge
? (challenge.progress / challenge.target) * 100
: 0;
return (
<SafeAreaView style={styles.container}>
<ScrollView>
{/* Challenge Header */}
<LinearGradient
colors={['#f97316', '#ec4899']}
style={styles.headerGradient}
>
<Text style={styles.challengeTitle}>{challenge?.title}</Text>
<Text style={styles.challengeDesc}>{challenge?.description}</Text>
<View style={styles.rewardDisplay}>
<Star size={24} color="#fbbf24" />
<Text style={styles.rewardAmount}>{challenge?.reward} pts</Text>
</View>
</LinearGradient>
{/* Circular Progress */}
<View style={styles.progressSection}>
<CircularProgress
percent={progressPercent}
size={160}
strokeWidth={12}
color="#ec4899"
/>
<Text style={styles.progressLabel}>
{challenge?.progress} / {challenge?.target}
</Text>
</View>
{/* Claim Button */}
{challenge?.completed && !challenge?.claimed && (
<Button
title="Réclamer la récompense"
onPress={() => claimMutation.mutate(id)}
loading={claimMutation.isPending}
style={styles.claimButton}
/>
)}
{/* Activity Log */}
<View style={styles.activitySection}>
<Text style={styles.sectionTitle}>Activités comptabilisées</Text>
{activities?.map((activity) => (
<View key={activity.id} style={styles.activityItem}>
<View style={styles.activityDot} />
<View style={styles.activityContent}>
<Text style={styles.activityDesc}>{activity.description}</Text>
<Text style={styles.activityDate}>
{formatDateTime(activity.createdAt)}
</Text>
</View>
<Text style={styles.activityPoints}>+{activity.points}</Text>
</View>
))}
</View>
</ScrollView>
</SafeAreaView>
);
}Loyalty Tiers
Le programme de fidélité YaniPay repose sur 5 niveaux progressifs. Chaque niveau débloque un taux de cashback supérieur et des avantages exclusifs. La progression est basée sur les points accumulés.
Bronze
à partir de 0 points
1%
cashback
Silver
à partir de 5 000 points
2%
cashback
Gold
à partir de 15 000 points
3%
cashback
Platinum
à partir de 50 000 points
5%
cashback
Diamond
à partir de 150 000 points
8%
cashback
Progression de tier
const TIER_CONFIG = [
{
id: 'bronze',
name: 'Bronze',
minPoints: 0,
cashback: 1,
color: ['#b45309', '#92400e'],
perks: ['Cashback 1%', 'Accès boutique rewards'],
},
{
id: 'silver',
name: 'Silver',
minPoints: 5000,
cashback: 2,
color: ['#94a3b8', '#64748b'],
perks: ['Cashback 2%', 'Défis exclusifs', 'Support prioritaire'],
},
{
id: 'gold',
name: 'Gold',
minPoints: 15000,
cashback: 3,
color: ['#fbbf24', '#f59e0b'],
perks: ['Cashback 3%', 'Carte Gold gratuite', 'Assurance voyage'],
},
{
id: 'platinum',
name: 'Platinum',
minPoints: 50000,
cashback: 5,
color: ['#22d3ee', '#3b82f6'],
perks: ['Cashback 5%', 'Conciergerie', 'Taux préférentiels crypto', 'Lounge accès'],
},
{
id: 'diamond',
name: 'Diamond',
minPoints: 150000,
cashback: 8,
color: ['#a78bfa', '#7c3aed'],
perks: ['Cashback 8%', 'Carte Metal offerte', 'Gestionnaire dédié', 'Tous les avantages'],
},
];
export default function TiersOverviewScreen() {
const { data: loyaltyData } = useLoyaltyDashboard();
const currentTierIndex = TIER_CONFIG.findIndex(
(t) => t.id === loyaltyData?.tierId
);
return (
<SafeAreaView style={styles.container}>
<ScrollView>
{/* Current Tier Card */}
<LinearGradient
colors={TIER_CONFIG[currentTierIndex]?.color || ['#64748b', '#475569']}
style={styles.currentTierCard}
>
<Crown size={32} color="#fff" />
<Text style={styles.currentTierName}>
{loyaltyData?.tier}
</Text>
<Text style={styles.currentTierPoints}>
{formatNumber(loyaltyData?.points || 0)} points
</Text>
</LinearGradient>
{/* Progress to Next Tier */}
{currentTierIndex < TIER_CONFIG.length - 1 && (
<View style={styles.nextTierSection}>
<Text style={styles.nextTierLabel}>
Prochain niveau : {TIER_CONFIG[currentTierIndex + 1].name}
</Text>
<ProgressBar
current={loyaltyData?.points || 0}
target={TIER_CONFIG[currentTierIndex + 1].minPoints}
color={TIER_CONFIG[currentTierIndex + 1].color[0]}
/>
<Text style={styles.remainingPoints}>
{formatNumber(
TIER_CONFIG[currentTierIndex + 1].minPoints - (loyaltyData?.points || 0)
)} points restants
</Text>
</View>
)}
{/* All Tiers */}
{TIER_CONFIG.map((tier, index) => (
<View
key={tier.id}
style={[
styles.tierCard,
index <= currentTierIndex && styles.unlockedTier,
]}
>
<LinearGradient
colors={tier.color}
style={styles.tierIcon}
>
<Crown size={20} color="#fff" />
</LinearGradient>
<View style={styles.tierInfo}>
<Text style={styles.tierName}>{tier.name}</Text>
<Text style={styles.tierMinPoints}>
{formatNumber(tier.minPoints)} points
</Text>
<View style={styles.perksList}>
{tier.perks.map((perk) => (
<View key={perk} style={styles.perkItem}>
<CheckCircle size={14} color="#10b981" />
<Text style={styles.perkText}>{perk}</Text>
</View>
))}
</View>
</View>
<Text style={styles.cashbackBadge}>{tier.cashback}%</Text>
</View>
))}
</ScrollView>
</SafeAreaView>
);
}Rewards Store
La boutique de récompenses permet d'échanger les points accumulés contre des avantages concrets : cashback bonus, cartes cadeaux, upgrades de tier temporaires, et offres partenaires exclusives.
Points convertibles
type RewardCategory = 'all' | 'cashback' | 'gift-cards' | 'upgrades' | 'partners';
interface Reward {
id: string;
name: string;
description: string;
pointsCost: number;
category: RewardCategory;
image: string;
available: boolean;
minTier: string;
stock?: number;
}
export default function RewardsStoreScreen() {
const [category, setCategory] = useState<RewardCategory>('all');
const { data: rewards } = useRewards(category);
const { data: loyaltyData } = useLoyaltyDashboard();
return (
<SafeAreaView style={styles.container}>
{/* Points Balance Header */}
<View style={styles.balanceHeader}>
<Coins size={20} color="#ec4899" />
<Text style={styles.balanceText}>
{formatNumber(loyaltyData?.points || 0)} points disponibles
</Text>
</View>
{/* Category Filter */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.categoryScroll}
>
{[
{ key: 'all', label: 'Tout' },
{ key: 'cashback', label: 'Cashback' },
{ key: 'gift-cards', label: 'Cartes cadeaux' },
{ key: 'upgrades', label: 'Upgrades' },
{ key: 'partners', label: 'Partenaires' },
].map((cat) => (
<TouchableOpacity
key={cat.key}
style={[
styles.categoryChip,
category === cat.key && styles.activeCategoryChip,
]}
onPress={() => setCategory(cat.key as RewardCategory)}
>
<Text
style={[
styles.categoryChipText,
category === cat.key && styles.activeCategoryChipText,
]}
>
{cat.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Rewards Grid */}
<FlatList
data={rewards}
numColumns={2}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.rewardCard}
onPress={() => router.push(`/loyalty/rewards/${item.id}`)}
disabled={!item.available}
>
<Image source={{ uri: item.image }} style={styles.rewardImage} />
<View style={styles.rewardInfo}>
<Text style={styles.rewardName}>{item.name}</Text>
<View style={styles.rewardCost}>
<Star size={14} color="#fbbf24" />
<Text style={styles.costText}>
{formatNumber(item.pointsCost)} pts
</Text>
</View>
{!item.available && (
<Text style={styles.unavailableText}>
Min. {item.minTier}
</Text>
)}
</View>
</TouchableOpacity>
)}
/>
</SafeAreaView>
);
}Reward Detail & Redemption
export default function RewardDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { data: reward } = useReward(id);
const { data: loyaltyData } = useLoyaltyDashboard();
const redeemMutation = useRedeemReward();
const canAfford = (loyaltyData?.points || 0) >= (reward?.pointsCost || 0);
const handleRedeem = async () => {
Alert.alert(
'Confirmer l\'échange',
`Échanger ${formatNumber(reward!.pointsCost)} points contre "${reward!.name}" ?`,
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Confirmer',
onPress: async () => {
try {
await redeemMutation.mutateAsync(id);
router.replace('/loyalty/rewards/success');
} catch (error) {
Alert.alert('Erreur', 'Échange impossible. Réessayez.');
}
},
},
]
);
};
return (
<SafeAreaView style={styles.container}>
<ScrollView>
<Image source={{ uri: reward?.image }} style={styles.heroImage} />
<View style={styles.detailContainer}>
<Text style={styles.rewardName}>{reward?.name}</Text>
<Text style={styles.rewardDesc}>{reward?.description}</Text>
<View style={styles.costSection}>
<View style={styles.costDisplay}>
<Star size={24} color="#fbbf24" />
<Text style={styles.costAmount}>
{formatNumber(reward?.pointsCost || 0)} points
</Text>
</View>
<Text style={styles.balanceInfo}>
Solde après échange :{' '}
{formatNumber((loyaltyData?.points || 0) - (reward?.pointsCost || 0))} pts
</Text>
</View>
{reward?.stock !== undefined && (
<Text style={styles.stockInfo}>
{reward.stock} restant(s) en stock
</Text>
)}
</View>
</ScrollView>
<Button
title={canAfford ? 'Échanger mes points' : 'Points insuffisants'}
onPress={handleRedeem}
disabled={!canAfford}
loading={redeemMutation.isPending}
style={styles.redeemButton}
/>
</SafeAreaView>
);
}Referrals
Le programme de parrainage récompense les utilisateurs qui invitent leurs proches. Le parrain et le filleul reçoivent chacun des points bonus après la première transaction du filleul.
Programme de parrainage
Inviter
Partagez votre lien
500 pts
Pour le parrain
250 pts
Pour le filleul
export default function ReferralScreen() {
const { data: referralData } = useReferralData();
const { data: referrals } = useReferralList();
const handleShare = async () => {
try {
await Share.share({
message: `Rejoins YaniPay avec mon code ${referralData?.code} et reçois 250 points offerts ! ${referralData?.link}`,
});
} catch (error) {
console.error('Share error:', error);
}
};
return (
<SafeAreaView style={styles.container}>
<ScrollView>
{/* Referral Card */}
<LinearGradient
colors={['#06b6d4', '#3b82f6']}
style={styles.referralCard}
>
<Users size={32} color="#fff" />
<Text style={styles.referralTitle}>Parrainez vos proches</Text>
<Text style={styles.referralDesc}>
Gagnez 500 points par ami invité
</Text>
{/* Referral Code */}
<View style={styles.codeContainer}>
<Text style={styles.codeLabel}>Votre code</Text>
<View style={styles.codeBox}>
<Text style={styles.codeText}>{referralData?.code}</Text>
<TouchableOpacity
onPress={() => copyToClipboard(referralData?.code)}
>
<Copy size={20} color="#fff" />
</TouchableOpacity>
</View>
</View>
<Button
title="Partager le lien"
onPress={handleShare}
style={styles.shareButton}
icon={<Share2 size={20} />}
/>
</LinearGradient>
{/* Stats */}
<View style={styles.statsRow}>
<StatCard
label="Invitations"
value={referralData?.totalInvites?.toString() || '0'}
/>
<StatCard
label="Inscrits"
value={referralData?.successfulReferrals?.toString() || '0'}
/>
<StatCard
label="Points gagnés"
value={formatNumber(referralData?.totalPointsEarned || 0)}
/>
</View>
{/* Referral List */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Mes filleuls</Text>
{referrals?.map((ref) => (
<View key={ref.id} style={styles.referralItem}>
<Avatar name={ref.name} size={40} />
<View style={styles.referralInfo}>
<Text style={styles.referralName}>{ref.name}</Text>
<Text style={styles.referralDate}>
Inscrit le {formatDate(ref.joinedAt)}
</Text>
</View>
<View style={[
styles.statusBadge,
ref.activated ? styles.activeBadge : styles.pendingBadge,
]}>
<Text style={styles.statusText}>
{ref.activated ? '+500 pts' : 'En attente'}
</Text>
</View>
</View>
))}
</View>
</ScrollView>
</SafeAreaView>
);
}Loyalty History
L'historique complet des points : gains par transactions, cashback reçu, points dépensés en boutique, bonus de challenges et parrainages.
type HistoryFilter = 'all' | 'earned' | 'spent' | 'expired';
interface PointsEvent {
id: string;
type: 'transaction' | 'challenge' | 'referral' | 'redeem' | 'bonus' | 'expired';
description: string;
points: number; // positive = earned, negative = spent
balance: number;
createdAt: Date;
}
export default function LoyaltyHistoryScreen() {
const [filter, setFilter] = useState<HistoryFilter>('all');
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfinitePointsHistory({ filter });
const events = data?.pages.flatMap((page) => page.events) || [];
const groupedEvents = useMemo(() => groupByDate(events), [events]);
return (
<SafeAreaView style={styles.container}>
{/* Filter Tabs */}
<View style={styles.filterTabs}>
{[
{ key: 'all', label: 'Tout' },
{ key: 'earned', label: 'Gagnés' },
{ key: 'spent', label: 'Dépensés' },
{ key: 'expired', label: 'Expirés' },
].map((f) => (
<TouchableOpacity
key={f.key}
style={[styles.filterTab, filter === f.key && styles.activeFilterTab]}
onPress={() => setFilter(f.key as HistoryFilter)}
>
<Text
style={[
styles.filterTabText,
filter === f.key && styles.activeFilterTabText,
]}
>
{f.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* History List */}
<SectionList
sections={groupedEvents}
keyExtractor={(item) => item.id}
renderSectionHeader={({ section }) => (
<Text style={styles.dateHeader}>{section.title}</Text>
)}
renderItem={({ item }) => (
<View style={styles.historyItem}>
<View style={[
styles.typeIcon,
item.points > 0 ? styles.earnedIcon : styles.spentIcon,
]}>
{item.type === 'transaction' && <Coins size={18} />}
{item.type === 'challenge' && <Target size={18} />}
{item.type === 'referral' && <Users size={18} />}
{item.type === 'redeem' && <ShoppingBag size={18} />}
{item.type === 'bonus' && <Sparkles size={18} />}
{item.type === 'expired' && <Clock size={18} />}
</View>
<View style={styles.historyContent}>
<Text style={styles.historyDesc}>{item.description}</Text>
<Text style={styles.historyDate}>
{formatDateTime(item.createdAt)}
</Text>
</View>
<Text
style={[
styles.historyPoints,
item.points > 0 ? styles.earnedText : styles.spentText,
]}
>
{item.points > 0 ? '+' : ''}{formatNumber(item.points)} pts
</Text>
</View>
)}
onEndReached={() => hasNextPage && fetchNextPage()}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? <ActivityIndicator /> : null
}
/>
</SafeAreaView>
);
}Loyalty Routes
app/
├── (tabs)/
│ └── loyalty/
│ └── index.tsx # Cashback Dashboard
├── loyalty/
│ ├── _layout.tsx # Loyalty stack layout
│ ├── challenges/
│ │ ├── index.tsx # Challenges list
│ │ └── [id].tsx # Challenge detail
│ ├── tiers/
│ │ └── index.tsx # Tiers overview
│ ├── rewards/
│ │ ├── index.tsx # Rewards store
│ │ ├── [id].tsx # Reward detail
│ │ └── success.tsx # Redemption success
│ ├── referral/
│ │ └── index.tsx # Referral page
│ └── history/
│ └── index.tsx # Points history