From 00dec170b3a92a193b5cfc42a25259193f40e671 Mon Sep 17 00:00:00 2001 From: Barnabas Lapshak Date: Sun, 26 Jul 2026 04:45:49 +0100 Subject: [PATCH] feat(gamification): build subscription gamification system with achievements and rewards (#725) --- contracts/subscription/src/achievements.rs | 61 ++ contracts/subscription/src/lib.rs | 1 + docs/gamification-guide.md | 109 +++ .../gamification/GamificationComponents.tsx | 644 +++++++++++++++++- src/screens/GamificationScreen.tsx | 264 ++++++- .../__tests__/gamificationService.test.ts | 64 +- src/services/gamificationService.ts | 128 +++- src/store/__tests__/gamificationStore.test.ts | 77 ++- src/store/gamificationStore.ts | 150 +++- src/types/gamification.ts | 45 ++ 10 files changed, 1452 insertions(+), 91 deletions(-) create mode 100644 contracts/subscription/src/achievements.rs create mode 100644 docs/gamification-guide.md diff --git a/contracts/subscription/src/achievements.rs b/contracts/subscription/src/achievements.rs new file mode 100644 index 00000000..e7410ef9 --- /dev/null +++ b/contracts/subscription/src/achievements.rs @@ -0,0 +1,61 @@ +/// Gamification & Achievement tracking module for SubTrackr subscriptions. +/// +/// Records user achievement unlocks on-chain and awards loyalty/gamification XP. +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +use crate::{storage_persistent_get, storage_persistent_set}; + +/// Storage key for achievement tracking. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum AchievementKey { + /// List of earned achievement IDs (Symbols) for a subscriber. + UserAchievements(Address), +} + +/// Retrieve all unlocked achievement IDs (Symbols) for a subscriber. +pub fn get_earned_achievements( + env: &Env, + storage: &Address, + subscriber: &Address, +) -> Vec { + storage_persistent_get::>( + env, + storage, + AchievementKey::UserAchievements(subscriber.clone()), + ) + .unwrap_or_else(|| Vec::new(env)) +} + +/// Check if a subscriber has unlocked a specific achievement. +pub fn has_achievement( + env: &Env, + storage: &Address, + subscriber: &Address, + achievement_id: Symbol, +) -> bool { + let achievements = get_earned_achievements(env, storage, subscriber); + achievements.contains(achievement_id) +} + +/// Record an achievement unlock for a subscriber. +/// If the achievement was not already unlocked, records it and returns true. +pub fn record_achievement( + env: &Env, + storage: &Address, + subscriber: &Address, + achievement_id: Symbol, +) -> bool { + let mut achievements = get_earned_achievements(env, storage, subscriber); + if achievements.contains(achievement_id) { + return false; + } + achievements.push_back(achievement_id); + storage_persistent_set( + env, + storage, + AchievementKey::UserAchievements(subscriber.clone()), + &achievements, + ); + true +} diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index a193a56c..4f77ae79 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -5,6 +5,7 @@ mod gas_profiler; mod gas_storage; mod invoice_branding; mod revenue; +pub mod achievements; #[cfg(test)] mod test; diff --git a/docs/gamification-guide.md b/docs/gamification-guide.md new file mode 100644 index 00000000..1a7d8e91 --- /dev/null +++ b/docs/gamification-guide.md @@ -0,0 +1,109 @@ +# SubTrackr Gamification System Guide + +## Overview +The SubTrackr Gamification System is engineered to boost subscriber engagement and retention through interactive rewards, milestone achievements, dynamic leaderboards, and on-chain blockchain verification. + +--- + +## 1. System Architecture + +The gamification architecture consists of three integrated layers: + +```mermaid +graph TD + A[UI Layer: GamificationScreen & Components] --> B[State Layer: Zustand Store / AsyncStorage] + B --> C[Service Layer: GamificationService] + C --> D[On-Chain Layer: Soroban Smart Contract achievements.rs] + B --> E[Notification Layer: Local Alerts] +``` + +### Key Modules: +- **`src/types/gamification.ts`**: Core TypeScript interfaces for `Achievement`, `RewardDefinition`, `RewardItem`, `GamificationConfig`, `GamificationAnalytics`, and `LeaderboardEntry`. +- **`src/services/gamificationService.ts`**: Static definitions of achievements, badges, rewards catalog, multi-category leaderboard generators, and social sharing helpers. +- **`src/store/gamificationStore.ts`**: Zustand persistent store managing user points, levels, unlocked items, reward claims, and analytics calculations. +- **`contracts/subscription/src/achievements.rs`**: On-chain Stellar/Soroban smart contract module for decentralized recording and validation of unlocked milestones. + +--- + +## 2. Achievement Triggers & Definitions + +Achievements are evaluated whenever significant user actions occur, tracked via the `AchievementTrigger` enum: +- `SUBSCRIPTION_ADDED`: Adding new recurring subscriptions to the tracker. +- `CRYPTO_PAYMENT`: Settling subscription invoices using cryptocurrency. +- `SEGMENT_CREATED`: Categorizing subscriptions into custom analytical segments. +- `POINTS_MILESTONE`: Accumulating lifetime loyalty points. +- `STREAK_MILESTONE`: Maintaining consecutive on-time payment cycles without failures. +- `REFERRAL_MADE`: Inviting new merchants or users to SubTrackr. + +### Sample Achievements & Rewards +| Achievement ID | Name | Criteria | XP Awarded | Reward Type | Reward Value | +|---|---|---|---|---|---| +| `first_sub` | Getting Started | Add 1 subscription | 50 XP | Loyalty Credit | 100 Credits | +| `tracker_pro` | Tracker Pro | Add 5 subscriptions | 200 XP | Discount Coupon | 10% Off (`PRO-10OFF`) | +| `crypto_pioneer` | Crypto Pioneer | Pay with crypto | 150 XP | Loyalty Credit | 500 Credits | +| `point_hoarder` | Point Hoarder | Earn 5,000 pts | 300 XP | Loyalty Credit | 1,000 Bonus Credits | +| `loyal_member` | Loyal Member | Earn 15,000 pts | 500 XP | VIP Discount | 20% Lifetime (`VIP-20OFF`) | +| `streak_master` | Streak Master | 30-charge streak | 200 XP | Discount Coupon | 15% Off (`STREAK-15OFF`) | +| `referral_pro` | Networker | Refer 5 friends | 250 XP | Ambassador Credit| 2,500 Credits | + +--- + +## 3. Reward Distribution System + +When `checkAchievements(trigger, metadata)` successfully unlocks an achievement: +1. **XP & Leveling**: Points are added to the user's total. If total points exceed `100 * (level ^ 1.5)`, a Level Up event fires. +2. **Badge Unlocking**: Any linked `badgeId` is unlocked and stored in `earnedBadges`. +3. **Reward Generation**: If the achievement defines a `reward`, a unique `RewardItem` is generated with coupon code prefixes or credit vouchers and placed in `earnedRewards`. +4. **Redemption Flow**: Users can view rewards in the **Rewards Catalog** tab, copy coupon codes directly to their device clipboard, claim loyalty credits, and mark coupons as redeemed after application. + +--- + +## 4. Leaderboard & Social Sharing + +### Multi-Category Leaderboards +The leaderboard system supports three interactive views: +- **All Time**: Ranked by cumulative XP earned across all actions. +- **Weekly**: Ranked by recent weekly XP acceleration. +- **Streaks**: Ranked by consecutive on-time charge days (`streak`). + +### Social Sharing Integration +Using the React Native `Share` API, users can broadcast their progress: +- **Badge Share**: Share specific unlocked badges with custom emoji banners and hashtags (`#SubTrackr #Badges`). +- **Level Share**: Broadcast overall tracker level and XP to invite friends and build network density. + +--- + +## 5. Gamification Analytics + +The `getAnalytics()` method computes real-time engagement telemetry: +- **Completion Rate**: Percentage of available achievements unlocked (`0 - 100%`). +- **Category Breakdown**: Mapping of unlocked milestones across trigger types. +- **Points History**: Timestamped audit trail of up to 100 recent XP earnings and unlock reasons. + +--- + +## 6. Store Configuration + +Users have granular control over their gamification experience via `GamificationConfig`: +- `soundEffectsEnabled`: Toggle audio feedback on unlocks. +- `notificationsEnabled`: Enable/disable local alerts (`presentLocalNotification`). +- `showOnLeaderboard`: Toggle public display of username and rank on leaderboards. +- `dailyReminderEnabled`: Opt-in to daily push notifications to maintain payment streaks. + +--- + +## 7. On-Chain Contract Integration (`achievements.rs`) + +For enterprise and crypto-native users, achievement symbols are persisted to Stellar Soroban contract storage via `StorageKey::UserAchievements(Address)`. + +### Rust Contract API: +```rust +/// Record an achievement unlock on-chain +pub fn record_achievement(env: &Env, storage: &Address, subscriber: &Address, achievement_id: Symbol) -> bool; + +/// Query all earned achievement Symbols for a subscriber +pub fn get_earned_achievements(env: &Env, storage: &Address, subscriber: &Address) -> Vec; + +/// Check whether an achievement is unlocked on-chain +pub fn has_achievement(env: &Env, storage: &Address, subscriber: &Address, achievement_id: Symbol) -> bool; +``` diff --git a/src/components/gamification/GamificationComponents.tsx b/src/components/gamification/GamificationComponents.tsx index 92165ee7..a0c94bf5 100644 --- a/src/components/gamification/GamificationComponents.tsx +++ b/src/components/gamification/GamificationComponents.tsx @@ -1,19 +1,44 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { Badge, LeaderboardEntry } from '../../types/gamification'; +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Modal, + Switch, + Alert, + ScrollView, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { + Badge, + LeaderboardCategory, + LeaderboardEntry, + RewardItem, + GamificationAnalytics, + GamificationConfig, + UserProgress, +} from '../../types/gamification'; import { useTheme } from '../../theme/useTheme'; import { Card } from '../common/Card'; +import { gamificationService } from '../../services/gamificationService'; // ── BadgeCard ─────────────────────────────────────────────────────────────── interface BadgeCardProps { badge: Badge; isUnlocked: boolean; + userProgress: UserProgress; } -export const BadgeCard: React.FC = ({ badge, isUnlocked }) => { +export const BadgeCard: React.FC = ({ badge, isUnlocked, userProgress }) => { const theme = useTheme(); + const handleShare = async () => { + if (!isUnlocked) return; + await gamificationService.shareBadge(badge, userProgress); + }; + return ( = ({ badge, isUnlocked }) => { {badge.name} - {!isUnlocked && ( + {!isUnlocked ? ( Locked + ) : ( + + 📤 Share + )} ); @@ -69,15 +98,204 @@ export const LevelProgressBar: React.FC = ({ points, leve ); }; +// ── RewardCard & RewardsList ──────────────────────────────────────────────── + +interface RewardCardProps { + item: RewardItem; + onClaim: (id: string) => void; + onRedeem: (id: string) => void; +} + +export const RewardCard: React.FC = ({ item, onClaim, onRedeem }) => { + const theme = useTheme(); + + const handleCopyCode = async () => { + if (!item.code) return; + try { + await Clipboard.setStringAsync(item.code); + Alert.alert('Code Copied!', `Coupon code ${item.code} copied to clipboard.`); + } catch { + Alert.alert('Copy failed', 'Could not copy to clipboard.'); + } + }; + + const getStatusBadge = () => { + if (item.isRedeemed) { + return ( + + Redeemed + + ); + } + if (item.isClaimed) { + return ( + + Ready to Use + + ); + } + return null; + }; + + return ( + + + + + {item.type === 'discount' ? '🏷️ Discount' : item.type === 'credit' ? '💰 Credits' : '🎖️ Badge'} + + + {getStatusBadge()} + + {item.title} + {item.description} + + {item.isClaimed && !item.isRedeemed && item.code && ( + + {item.code} + Tap to Copy + + )} + + + {!item.isClaimed && !item.isRedeemed && ( + onClaim(item.id)}> + Claim Reward + + )} + {item.isClaimed && !item.isRedeemed && ( + onRedeem(item.id)}> + Mark Redeemed + + )} + + + ); +}; + +interface RewardsListProps { + rewards: RewardItem[]; + onClaim: (id: string) => void; + onRedeem: (id: string) => void; +} + +export const RewardsList: React.FC = ({ rewards, onClaim, onRedeem }) => { + const theme = useTheme(); + + if (rewards.length === 0) { + return ( + + + No rewards unlocked yet. Complete achievements to earn discounts and loyalty credits! + + + ); + } + + return ( + + {rewards.map((reward) => ( + + ))} + + ); +}; + +// ── GamificationAnalyticsCard ─────────────────────────────────────────────── + +interface GamificationAnalyticsCardProps { + analytics: GamificationAnalytics; +} + +export const GamificationAnalyticsCard: React.FC = ({ analytics }) => { + const theme = useTheme(); + + return ( + + + 📊 Engagement & Progress + + + + + + {analytics.totalPointsEarned} + + Total XP + + + + {analytics.longestStreak} 🔥 + + Max Streak + + + + {analytics.totalAchievementsUnlocked} + + Achievements + + + + {analytics.totalRewardsClaimed} + + Rewards + + + + + + + Completion Rate + + + {analytics.completionRate}% + + + + + + + + ); +}; + // ── LeaderboardList ──────────────────────────────────────────────────────── interface LeaderboardListProps { data: LeaderboardEntry[]; + category: LeaderboardCategory; + onSelectCategory: (cat: LeaderboardCategory) => void; } -export const LeaderboardList: React.FC = ({ data }) => { +export const LeaderboardList: React.FC = ({ + data, + category, + onSelectCategory, +}) => { const theme = useTheme(); + const renderRankBadge = (rank: number) => { + if (rank === 1) return 🥇; + if (rank === 2) return 🥈; + if (rank === 3) return 🥉; + return {rank}; + }; + const renderItem = ({ item }: { item: LeaderboardEntry }) => ( = ({ data }) => { item.isCurrentUser && { backgroundColor: theme.colors.brand.primary + '20', borderRadius: 8, + borderWidth: 1, + borderColor: theme.colors.brand.primary, }, ]}> - {item.rank} + {renderRankBadge(item.rank)} + {item.avatar || '👤'} {item.name} {item.isCurrentUser && '(You)'} - Lvl {item.level} + Lvl {item.level} {item.streak ? `• 🔥 ${item.streak}d` : ''} + + + + + {category === 'streaks' ? `${item.streak || 0} days` : `${item.points} XP`} - - {item.points} XP - ); return ( - - Global Leaderboard - + + + 🏆 Leaderboard + + + + + {(['all_time', 'weekly', 'streaks'] as LeaderboardCategory[]).map((cat) => { + const isActive = category === cat; + const label = cat === 'all_time' ? 'All Time' : cat === 'weekly' ? 'Weekly' : 'Streaks'; + return ( + onSelectCategory(cat)}> + + {label} + + + ); + })} + + {data.map((item) => ( - {renderItem({ item })} + {renderItem({ item })} ))} ); }; +// ── GamificationConfigModal ───────────────────────────────────────────────── + +interface GamificationConfigModalProps { + visible: boolean; + config: GamificationConfig; + onClose: () => void; + onUpdate: (patch: Partial) => void; + onReset: () => void; +} + +export const GamificationConfigModal: React.FC = ({ + visible, + config, + onClose, + onUpdate, + onReset, +}) => { + const theme = useTheme(); + + const confirmReset = () => { + Alert.alert( + 'Reset Gamification Progress?', + 'This will clear all earned XP, levels, badges, and claimed rewards. This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Reset', + style: 'destructive', + onPress: () => { + onReset(); + onClose(); + }, + }, + ] + ); + }; + + return ( + + + + + ⚙️ Gamification Settings + + + + + + + Sound Effects + + + Play audio cues on level ups and badges + + + onUpdate({ soundEffectsEnabled: val })} + trackColor={{ false: '#767577', true: theme.colors.brand.primary }} + /> + + + + + + Achievement Notifications + + + Show local alerts when unlocking milestones + + + onUpdate({ notificationsEnabled: val })} + trackColor={{ false: '#767577', true: theme.colors.brand.primary }} + /> + + + + + + Leaderboard Visibility + + + Display your username and XP on public leaderboards + + + onUpdate({ showOnLeaderboard: val })} + trackColor={{ false: '#767577', true: theme.colors.brand.primary }} + /> + + + + + + Daily Streak Reminders + + + Remind me daily to check subscription status and keep streaks + + + onUpdate({ dailyReminderEnabled: val })} + trackColor={{ false: '#767577', true: theme.colors.brand.primary }} + /> + + + + ⚠️ Reset All Progress + + + + + Done + + + + + ); +}; + const styles = StyleSheet.create({ badgeCard: { - width: 100, + width: 110, padding: 12, alignItems: 'center', marginRight: 12, @@ -142,8 +511,17 @@ const styles = StyleSheet.create({ fontSize: 10, marginTop: 4, }, + shareBtn: { + marginTop: 6, + paddingHorizontal: 8, + paddingVertical: 2, + }, + shareBtnText: { + fontSize: 10, + fontWeight: '600', + }, progressContainer: { - marginVertical: 16, + marginVertical: 8, }, levelHeader: { flexDirection: 'row', @@ -166,13 +544,164 @@ const styles = StyleSheet.create({ barForeground: { height: '100%', }, + rewardCard: { + padding: 16, + marginBottom: 12, + }, + rewardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + rewardTypeBadge: { + backgroundColor: '#3b82f620', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + }, + rewardTypeText: { + fontSize: 12, + fontWeight: '600', + color: '#3b82f6', + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, + }, + statusBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: 'bold', + }, + rewardTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + }, + rewardDesc: { + fontSize: 14, + marginBottom: 12, + }, + codeContainer: { + padding: 12, + borderRadius: 8, + alignItems: 'center', + marginBottom: 12, + borderWidth: 1, + borderColor: '#3b82f640', + borderStyle: 'dashed', + }, + codeText: { + fontSize: 18, + fontWeight: 'bold', + letterSpacing: 1.5, + }, + copyHint: { + fontSize: 10, + color: '#64748b', + marginTop: 2, + }, + rewardActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + }, + actionBtn: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 6, + }, + actionBtnText: { + color: '#fff', + fontWeight: '600', + fontSize: 14, + }, + emptyContainer: { + padding: 24, + alignItems: 'center', + }, + emptyText: { + textAlign: 'center', + fontSize: 14, + }, + rewardsList: { + marginTop: 8, + }, + analyticsCard: { + padding: 16, + marginBottom: 16, + }, + analyticsTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 12, + }, + statsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + marginBottom: 16, + }, + statBox: { + width: '48%', + padding: 12, + borderRadius: 8, + alignItems: 'center', + marginBottom: 8, + }, + statValue: { + fontSize: 20, + fontWeight: 'bold', + }, + statLabel: { + fontSize: 12, + marginTop: 2, + }, + completionContainer: { + marginTop: 4, + }, + completionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 4, + }, + completionLabel: { + fontSize: 14, + fontWeight: '600', + }, + completionValue: { + fontSize: 14, + fontWeight: 'bold', + }, leaderboardContainer: { - marginTop: 24, + marginTop: 16, + }, + leaderboardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, }, sectionTitle: { fontSize: 18, fontWeight: 'bold', - marginBottom: 16, + }, + tabBar: { + flexDirection: 'row', + borderRadius: 8, + padding: 4, + marginBottom: 12, + }, + tabItem: { + flex: 1, + paddingVertical: 6, + alignItems: 'center', + borderRadius: 6, + }, + tabText: { + fontSize: 13, + fontWeight: '600', }, leaderboardItem: { flexDirection: 'row', @@ -181,11 +710,21 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, borderBottomWidth: 1, }, + rankContainer: { + width: 34, + alignItems: 'center', + }, + podiumIcon: { + fontSize: 20, + }, rankText: { - width: 30, fontSize: 16, fontWeight: 'bold', }, + avatarText: { + fontSize: 20, + marginRight: 12, + }, userInfo: { flex: 1, }, @@ -196,8 +735,73 @@ const styles = StyleSheet.create({ userLevel: { fontSize: 12, }, + scoreContainer: { + alignItems: 'flex-end', + }, userPoints: { + fontSize: 15, + fontWeight: 'bold', + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + padding: 20, + }, + modalContent: { + borderRadius: 16, + padding: 20, + maxHeight: '80%', + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 16, + textAlign: 'center', + }, + settingsScroll: { + marginBottom: 16, + }, + settingRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#33415520', + }, + settingInfo: { + flex: 1, + marginRight: 16, + }, + settingLabel: { fontSize: 16, + fontWeight: '600', + }, + settingDesc: { + fontSize: 12, + marginTop: 2, + }, + resetBtn: { + marginTop: 24, + padding: 12, + borderRadius: 8, + backgroundColor: '#ef444420', + alignItems: 'center', + }, + resetBtnText: { + color: '#ef4444', fontWeight: 'bold', + fontSize: 14, + }, + closeBtn: { + padding: 14, + borderRadius: 10, + alignItems: 'center', + }, + closeBtnText: { + color: '#fff', + fontWeight: 'bold', + fontSize: 16, }, }); diff --git a/src/screens/GamificationScreen.tsx b/src/screens/GamificationScreen.tsx index dec90224..055970e1 100644 --- a/src/screens/GamificationScreen.tsx +++ b/src/screens/GamificationScreen.tsx @@ -1,47 +1,187 @@ -import React from 'react'; -import { View, Text, StyleSheet, ScrollView } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; import { useGamificationStore } from '../store/gamificationStore'; import { useUserStore } from '../store/userStore'; import { gamificationService } from '../services/gamificationService'; import { useTheme } from '../theme/useTheme'; +import { LeaderboardCategory } from '../types/gamification'; import { BadgeCard, LevelProgressBar, LeaderboardList, + GamificationAnalyticsCard, + RewardsList, + GamificationConfigModal, } from '../components/gamification/GamificationComponents'; export const GamificationScreen: React.FC = () => { const theme = useTheme(); - const { points, level, earnedBadges } = useGamificationStore(); + const { + points, + level, + earnedBadges, + earnedRewards, + config, + claimReward, + redeemReward, + updateConfig, + resetProgress, + getAnalytics, + } = useGamificationStore(); const { user } = useUserStore(); + const [activeTab, setActiveTab] = useState<'dashboard' | 'rewards' | 'leaderboard'>('dashboard'); + const [leaderboardCategory, setLeaderboardCategory] = useState('all_time'); + const [settingsVisible, setSettingsVisible] = useState(false); + const allBadges = gamificationService.getBadges(); - const leaderboard = gamificationService.getLeaderboard(points, user?.name || 'You'); + const leaderboard = gamificationService.getLeaderboard( + points, + user?.name || 'You', + leaderboardCategory, + useGamificationStore.getState().streak + ); + const analytics = getAnalytics(); + + const handleShareProgress = async () => { + await gamificationService.shareLevel(useGamificationStore.getState()); + }; return ( - - - + + + + + 🎮 Gamification Hub + + + Earn XP, unlock rewards, and compete! + + + + + 🚀 Share + + setSettingsVisible(true)}> + ⚙️ + + - - Your Badges - - {allBadges.map((badge) => ( - - ))} - + + - - + + setActiveTab('dashboard')}> + + 📊 Dashboard + + + setActiveTab('rewards')}> + + 🎁 Rewards {earnedRewards.filter((r) => !r.isClaimed).length > 0 ? `(${earnedRewards.filter((r) => !r.isClaimed).length})` : ''} + + + setActiveTab('leaderboard')}> + + 🏆 Leaderboard + + - - + + {activeTab === 'dashboard' && ( + <> + + + + + + Your Badges ({earnedBadges.length}/{allBadges.length}) + + + + {allBadges.map((badge) => ( + + ))} + + + + + + + + )} + + {activeTab === 'rewards' && ( + + + Unlock achievements to earn discount coupons and loyalty credits. Claim and use them on your subscriptions! + + + + )} + + {activeTab === 'leaderboard' && ( + + + + )} + + + + + setSettingsVisible(false)} + onUpdate={updateConfig} + onReset={resetProgress} + /> + ); }; @@ -49,18 +189,92 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - header: { + topHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 8, + }, + headerTitle: { + fontSize: 22, + fontWeight: 'bold', + }, + headerSubtitle: { + fontSize: 13, + marginTop: 2, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + }, + shareBtn: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + marginRight: 8, + }, + shareBtnText: { + fontSize: 13, + fontWeight: 'bold', + }, + settingsBtn: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + }, + settingsBtnText: { + fontSize: 18, + }, + headerBar: { + paddingHorizontal: 20, + }, + navTabs: { + flexDirection: 'row', + borderBottomWidth: 1, + marginTop: 8, + }, + navTab: { + flex: 1, + paddingVertical: 12, + alignItems: 'center', + borderBottomWidth: 2, + borderBottomColor: 'transparent', + }, + navTabActive: { + borderBottomWidth: 2, + }, + navTabText: { + fontSize: 14, + fontWeight: 'bold', + }, + content: { + flex: 1, + }, + contentContainer: { padding: 20, - paddingTop: 10, }, section: { - padding: 20, - paddingTop: 0, + marginBottom: 24, + }, + sectionHeaderRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, }, sectionTitle: { fontSize: 18, fontWeight: 'bold', + marginBottom: 12, + }, + sectionDesc: { + fontSize: 14, marginBottom: 16, + lineHeight: 20, }, badgeScroll: { paddingRight: 20, diff --git a/src/services/__tests__/gamificationService.test.ts b/src/services/__tests__/gamificationService.test.ts index e7cb1533..46f08329 100644 --- a/src/services/__tests__/gamificationService.test.ts +++ b/src/services/__tests__/gamificationService.test.ts @@ -1,19 +1,71 @@ +import { Share } from 'react-native'; import { gamificationService } from '../gamificationService'; +jest.mock('react-native', () => ({ + Share: { + share: jest.fn(() => Promise.resolve({ action: 'sharedAction' })), + }, +})); + describe('GamificationService', () => { - it('should return all achievements', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return all achievements including those with rewards', () => { const achievements = gamificationService.getAchievements(); expect(achievements.length).toBeGreaterThan(0); + const withReward = achievements.find((a) => a.reward !== undefined); + expect(withReward).toBeDefined(); + expect(withReward?.reward?.type).toMatch(/discount|credit|badge/); }); - it('should return all badges', () => { + it('should return all badges and lookup by id', () => { const badges = gamificationService.getBadges(); expect(badges.length).toBeGreaterThan(0); + const badge = gamificationService.getBadgeById('novice_tracker'); + expect(badge).toBeDefined(); + expect(badge?.name).toBe('Novice Tracker'); + }); + + it('should generate all_time leaderboard with current user', () => { + const leaderboard = gamificationService.getLeaderboard(500, 'Test User', 'all_time'); + expect(leaderboard.some((entry) => entry.name === 'Test User' || entry.isCurrentUser)).toBe(true); + }); + + it('should generate weekly leaderboard scaling points appropriately', () => { + const leaderboard = gamificationService.getLeaderboard(1000, 'Test User', 'weekly'); + const userEntry = leaderboard.find((entry) => entry.isCurrentUser); + expect(userEntry?.points).toBe(300); // Math.floor(1000 * 0.3) + }); + + it('should generate streaks leaderboard sorted by streak count', () => { + const leaderboard = gamificationService.getLeaderboard(500, 'Test User', 'streaks', 25); + const userEntry = leaderboard.find((entry) => entry.isCurrentUser); + expect(userEntry?.streak).toBe(25); + expect(leaderboard[0].streak).toBeGreaterThanOrEqual(leaderboard[1].streak || 0); + }); + + it('should share achievement using native Share API', async () => { + const ach = gamificationService.getAchievements()[0]; + await gamificationService.shareAchievement(ach, { points: 50, level: 1, earnedAchievements: [], earnedBadges: [], streak: 1 }); + expect(Share.share).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining(ach.name) }) + ); + }); + + it('should share badge using native Share API', async () => { + const badge = gamificationService.getBadges()[0]; + await gamificationService.shareBadge(badge, { points: 50, level: 1, earnedAchievements: [], earnedBadges: [], streak: 1 }); + expect(Share.share).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining(badge.name) }) + ); }); - it('should generate leaderboard with current user', () => { - const leaderboard = gamificationService.getLeaderboard(500, 'Test User'); - expect(leaderboard.some((entry) => entry.name === 'Test User')).toBe(true); - expect(leaderboard[0].rank).toBe(1); + it('should share level using native Share API', async () => { + await gamificationService.shareLevel({ points: 500, level: 3, earnedAchievements: [], earnedBadges: [], streak: 5 }); + expect(Share.share).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Level 3') }) + ); }); }); diff --git a/src/services/gamificationService.ts b/src/services/gamificationService.ts index 54f0e604..bb684e62 100644 --- a/src/services/gamificationService.ts +++ b/src/services/gamificationService.ts @@ -1,4 +1,12 @@ -import { Achievement, AchievementTrigger, Badge, LeaderboardEntry } from '../types/gamification'; +import { Share } from 'react-native'; +import { + Achievement, + AchievementTrigger, + Badge, + LeaderboardCategory, + LeaderboardEntry, + UserProgress, +} from '../types/gamification'; export class GamificationService { private achievements: Achievement[] = [ @@ -10,6 +18,12 @@ export class GamificationService { criteria: (metadata) => metadata.totalSubscriptions >= 1, points: 50, badgeId: 'novice_tracker', + reward: { + id: 'rew_first_sub', + type: 'credit', + value: 100, + description: '100 Welcome Credits', + }, }, { id: 'tracker_pro', @@ -19,6 +33,13 @@ export class GamificationService { criteria: (metadata) => metadata.totalSubscriptions >= 5, points: 200, badgeId: 'professional_tracker', + reward: { + id: 'rew_tracker_pro', + type: 'discount', + value: '10%', + description: '10% off your next billing cycle', + code: 'PRO-10OFF', + }, }, { id: 'crypto_pioneer', @@ -28,6 +49,12 @@ export class GamificationService { criteria: () => true, points: 150, badgeId: 'crypto_badge', + reward: { + id: 'rew_crypto_pioneer', + type: 'credit', + value: 500, + description: '500 Crypto Loyalty Credits', + }, }, { id: 'high_roller', @@ -64,6 +91,12 @@ export class GamificationService { criteria: (metadata) => metadata.lifetimePoints >= 5000, points: 300, badgeId: 'hoarder_badge', + reward: { + id: 'rew_point_hoarder', + type: 'credit', + value: 1000, + description: '1,000 Bonus Loyalty Credits', + }, }, { id: 'loyal_member', @@ -73,6 +106,13 @@ export class GamificationService { criteria: (metadata) => metadata.lifetimePoints >= 15000, points: 500, badgeId: 'loyal_badge', + reward: { + id: 'rew_loyal_member', + type: 'discount', + value: '20%', + description: '20% Lifetime VIP Discount', + code: 'VIP-20OFF', + }, }, { id: 'streak_starter', @@ -91,6 +131,13 @@ export class GamificationService { criteria: (metadata) => metadata.streak >= 30, points: 200, badgeId: 'streak_master_badge', + reward: { + id: 'rew_streak_master', + type: 'discount', + value: '15%', + description: '15% Streak Master Discount', + code: 'STREAK-15OFF', + }, }, { id: 'referral_friend', @@ -109,6 +156,12 @@ export class GamificationService { criteria: (metadata) => metadata.totalReferrals >= 5, points: 250, badgeId: 'networker_badge', + reward: { + id: 'rew_referral_pro', + type: 'credit', + value: 2500, + description: '2,500 Ambassador Credits', + }, }, ]; @@ -212,26 +265,40 @@ export class GamificationService { } /** - * Generates a mocked leaderboard. + * Generates a leaderboard based on category and user stats. */ - getLeaderboard(currentUserPoints: number, currentUserName: string): LeaderboardEntry[] { + getLeaderboard( + currentUserPoints: number, + currentUserName: string, + category: LeaderboardCategory = 'all_time', + currentUserStreak: number = 0 + ): LeaderboardEntry[] { const mockUsers = [ - { name: 'Alice', points: 1250, level: 5 }, - { name: 'Bob', points: 980, level: 4 }, - { name: 'Charlie', points: 850, level: 3 }, - { name: 'Diana', points: 600, level: 3 }, - { name: 'Ethan', points: 450, level: 2 }, + { name: 'Alice', points: 1250, level: 5, streak: 14, avatar: '👩‍💻' }, + { name: 'Bob', points: 980, level: 4, streak: 8, avatar: '👨‍🚀' }, + { name: 'Charlie', points: 850, level: 3, streak: 21, avatar: '🦊' }, + { name: 'Diana', points: 600, level: 3, streak: 5, avatar: '🎨' }, + { name: 'Ethan', points: 450, level: 2, streak: 3, avatar: '🎸' }, ]; + const currentLevel = Math.floor(currentUserPoints / 250) + 1; const allEntries = [ ...mockUsers, { name: currentUserName, - points: currentUserPoints, - level: Math.floor(currentUserPoints / 250) + 1, + points: category === 'weekly' ? Math.floor(currentUserPoints * 0.3) : currentUserPoints, + level: currentLevel, + streak: currentUserStreak, + avatar: '⭐', isCurrentUser: true, }, - ].sort((a, b) => b.points - a.points); + ]; + + if (category === 'streaks') { + allEntries.sort((a, b) => (b.streak || 0) - (a.streak || 0)); + } else { + allEntries.sort((a, b) => b.points - a.points); + } return allEntries.map((entry, index) => ({ rank: index + 1, @@ -239,6 +306,45 @@ export class GamificationService { level: entry.level || 1, })); } + + /** + * Share an achievement via native social dialog. + */ + async shareAchievement(achievement: Achievement, progress: UserProgress): Promise { + try { + await Share.share({ + message: `🏆 I just unlocked the "${achievement.name}" achievement on SubTrackr and earned ${achievement.points} XP! Currently Level ${progress.level}. Manage your subscriptions like a pro! #SubTrackr #Gamification`, + }); + } catch { + // Ignore share cancel/error + } + } + + /** + * Share a badge via native social dialog. + */ + async shareBadge(badge: Badge, progress: UserProgress): Promise { + try { + await Share.share({ + message: `${badge.icon} I earned the "${badge.name}" badge on SubTrackr! "${badge.description}" (Level ${progress.level} Tracker). #SubTrackr #Badges`, + }); + } catch { + // Ignore share cancel/error + } + } + + /** + * Share overall level and XP via native social dialog. + */ + async shareLevel(progress: UserProgress): Promise { + try { + await Share.share({ + message: `🚀 I'm Level ${progress.level} with ${progress.points} XP on SubTrackr! Tracking subscriptions and maintaining streaks. Come join the leaderboard! #SubTrackr #Fintech`, + }); + } catch { + // Ignore share cancel/error + } + } } export const gamificationService = new GamificationService(); diff --git a/src/store/__tests__/gamificationStore.test.ts b/src/store/__tests__/gamificationStore.test.ts index 0595808b..9378d2ef 100644 --- a/src/store/__tests__/gamificationStore.test.ts +++ b/src/store/__tests__/gamificationStore.test.ts @@ -1,5 +1,6 @@ import { useGamificationStore } from '../gamificationStore'; import { AchievementTrigger } from '../../types/gamification'; +import { presentLocalNotification } from '../../services/notificationService'; jest.mock('@react-native-async-storage/async-storage', () => ({ setItem: jest.fn(() => Promise.resolve()), @@ -14,38 +15,92 @@ jest.mock('../../services/notificationService', () => ({ describe('GamificationStore', () => { beforeEach(() => { useGamificationStore.getState().resetProgress(); + jest.clearAllMocks(); }); it('should initialize with level 1 and 0 points', () => { const state = useGamificationStore.getState(); expect(state.level).toBe(1); expect(state.points).toBe(0); + expect(state.earnedRewards).toEqual([]); + expect(state.config.notificationsEnabled).toBe(true); }); - it('should add points correctly', () => { - useGamificationStore.getState().addPoints(50); + it('should add points and record transaction history', () => { + useGamificationStore.getState().addPoints(50, 'Test XP'); const state = useGamificationStore.getState(); expect(state.points).toBe(50); + expect(state.pointsHistory.length).toBe(1); + expect(state.pointsHistory[0].amount).toBe(50); + expect(state.pointsHistory[0].reason).toBe('Test XP'); }); - it('should level up when enough points are added', () => { - // Level 2 requires Math.floor(100 * Math.pow(1, 1.5)) = 100 XP + it('should level up when enough points are added and send notification', () => { useGamificationStore.getState().addPoints(100); const state = useGamificationStore.getState(); expect(state.level).toBe(2); + expect(presentLocalNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Level Up! 🎉' }) + ); }); - it('should unlock achievement when criteria are met', () => { - // Trigger first_sub achievement + it('should suppress notification on level up if notificationsEnabled is false', () => { + useGamificationStore.getState().updateConfig({ notificationsEnabled: false }); + useGamificationStore.getState().addPoints(100); + const state = useGamificationStore.getState(); + expect(state.level).toBe(2); + expect(presentLocalNotification).not.toHaveBeenCalled(); + }); + + it('should unlock achievement and generate reward item', () => { useGamificationStore.getState().checkAchievements(AchievementTrigger.SUBSCRIPTION_ADDED, { - totalSubscriptions: 1, + totalSubscriptions: 5, price: 10, }); const state = useGamificationStore.getState(); - expect(state.earnedAchievements).toContain('first_sub'); - expect(state.earnedBadges).toContain('novice_tracker'); - // points should increase by 50 (from achievement) - expect(state.points).toBe(50); + expect(state.earnedAchievements).toContain('tracker_pro'); + expect(state.earnedBadges).toContain('professional_tracker'); + + // Check reward item generation + const reward = state.earnedRewards.find((r) => r.rewardId === 'rew_tracker_pro'); + expect(reward).toBeDefined(); + expect(reward?.title).toBe('Tracker Pro'); + expect(reward?.type).toBe('discount'); + expect(reward?.isClaimed).toBe(false); + }); + + it('should claim and redeem rewards correctly', () => { + useGamificationStore.getState().checkAchievements(AchievementTrigger.SUBSCRIPTION_ADDED, { + totalSubscriptions: 5, + price: 10, + }); + + let state = useGamificationStore.getState(); + const rewardId = state.earnedRewards[0].id; + + useGamificationStore.getState().claimReward(rewardId); + state = useGamificationStore.getState(); + expect(state.earnedRewards[0].isClaimed).toBe(true); + expect(state.earnedRewards[0].isRedeemed).toBe(false); + + useGamificationStore.getState().redeemReward(rewardId); + state = useGamificationStore.getState(); + expect(state.earnedRewards[0].isRedeemed).toBe(true); + expect(state.earnedRewards[0].redeemedAt).toBeDefined(); + }); + + it('should compute gamification analytics accurately', () => { + useGamificationStore.getState().addPoints(150); + useGamificationStore.getState().checkAchievements(AchievementTrigger.SUBSCRIPTION_ADDED, { + totalSubscriptions: 1, + price: 10, + }); + + const analytics = useGamificationStore.getState().getAnalytics(); + expect(analytics.totalPointsEarned).toBeGreaterThanOrEqual(150); + expect(analytics.totalAchievementsUnlocked).toBeGreaterThanOrEqual(1); + expect(analytics.completionRate).toBeGreaterThan(0); + expect(analytics.achievementsByCategory[AchievementTrigger.SUBSCRIPTION_ADDED]).toBeGreaterThanOrEqual(1); }); }); diff --git a/src/store/gamificationStore.ts b/src/store/gamificationStore.ts index 38b1769d..86e8c33a 100644 --- a/src/store/gamificationStore.ts +++ b/src/store/gamificationStore.ts @@ -1,13 +1,34 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { asyncStorageAdapter } from '../utils/storage'; -import { UserProgress, AchievementTrigger } from '../types/gamification'; +import { + UserProgress, + AchievementTrigger, + GamificationConfig, + RewardItem, + GamificationAnalytics, +} from '../types/gamification'; import { gamificationService } from '../services/gamificationService'; import { presentLocalNotification } from '../services/notificationService'; +const DEFAULT_CONFIG: GamificationConfig = { + soundEffectsEnabled: true, + notificationsEnabled: true, + showOnLeaderboard: true, + shareProfilePublicly: false, + dailyReminderEnabled: true, +}; + interface GamificationState extends UserProgress { - addPoints: (amount: number) => void; + config: GamificationConfig; + earnedRewards: RewardItem[]; + pointsHistory: Array<{ timestamp: string; amount: number; reason: string }>; + addPoints: (amount: number, reason?: string) => void; checkAchievements: (trigger: AchievementTrigger, metadata: any) => void; + claimReward: (rewardId: string) => void; + redeemReward: (rewardId: string) => void; + updateConfig: (patch: Partial) => void; + getAnalytics: () => GamificationAnalytics; resetProgress: () => void; } @@ -22,31 +43,43 @@ export const useGamificationStore = create()( earnedBadges: [], streak: 0, lastActionAt: undefined, + config: DEFAULT_CONFIG, + earnedRewards: [], + pointsHistory: [], - addPoints: (amount) => { - const { points, level } = get(); + addPoints: (amount, reason = 'General XP') => { + const { points, level, pointsHistory } = get(); const newPoints = points + amount; + const newEntry = { + timestamp: new Date().toISOString(), + amount, + reason, + }; - // Calculate level up - // Level 1: 0, Level 2: 250, Level 3: 650, etc. const nextLevelPoints = Math.floor(100 * Math.pow(level, 1.5)); if (newPoints >= nextLevelPoints) { set({ points: newPoints, level: level + 1, + pointsHistory: [newEntry, ...pointsHistory].slice(0, 100), }); - void presentLocalNotification({ - title: 'Level Up! 🎉', - body: `You've reached level ${level + 1}! Keep tracking those subscriptions.`, - }); + if (get().config.notificationsEnabled) { + void presentLocalNotification({ + title: 'Level Up! 🎉', + body: `You've reached level ${level + 1}! Keep tracking those subscriptions.`, + }); + } } else { - set({ points: newPoints }); + set({ + points: newPoints, + pointsHistory: [newEntry, ...pointsHistory].slice(0, 100), + }); } }, checkAchievements: (trigger, metadata) => { - const { earnedAchievements, earnedBadges } = get(); + const { earnedAchievements, earnedBadges, earnedRewards, config } = get(); const allAchievements = gamificationService.getAchievements(); const newUnlocks = allAchievements.filter( @@ -63,22 +96,100 @@ export const useGamificationStore = create()( .map((a) => a.badgeId) .filter((b): b is string => !!b && !earnedBadges.includes(b)); + // Generate reward items if defined on achievement + const newRewardItems: RewardItem[] = []; + newUnlocks.forEach((ach) => { + if (ach.reward) { + const item: RewardItem = { + id: `reward_${ach.id}_${Date.now()}`, + rewardId: ach.reward.id, + title: ach.name, + description: ach.reward.description, + type: ach.reward.type, + value: ach.reward.value, + code: ach.reward.code || `SUB-${ach.id.toUpperCase()}-${Math.floor(1000 + Math.random() * 9000)}`, + isClaimed: false, + isRedeemed: false, + earnedAt: new Date().toISOString(), + }; + newRewardItems.push(item); + } + }); + set((state) => ({ earnedAchievements: [...state.earnedAchievements, ...newIds], earnedBadges: [...state.earnedBadges, ...newBadgeIds], + earnedRewards: [...state.earnedRewards, ...newRewardItems], })); - get().addPoints(newPoints); + get().addPoints(newPoints, `Unlocked ${newUnlocks.length} achievement(s)`); - newUnlocks.forEach((ach) => { - void presentLocalNotification({ - title: 'Achievement Unlocked! 🏆', - body: `${ach.name}: ${ach.description}`, + if (config.notificationsEnabled) { + newUnlocks.forEach((ach) => { + void presentLocalNotification({ + title: 'Achievement Unlocked! 🏆', + body: `${ach.name}: ${ach.description}`, + }); }); - }); + } } }, + claimReward: (rewardId: string) => { + set((state) => ({ + earnedRewards: state.earnedRewards.map((item) => + item.id === rewardId ? { ...item, isClaimed: true } : item + ), + })); + }, + + redeemReward: (rewardId: string) => { + set((state) => ({ + earnedRewards: state.earnedRewards.map((item) => + item.id === rewardId + ? { ...item, isRedeemed: true, redeemedAt: new Date().toISOString() } + : item + ), + })); + }, + + updateConfig: (patch) => { + set((state) => ({ + config: { ...state.config, ...patch }, + })); + }, + + getAnalytics: () => { + const { points, level, earnedAchievements, earnedRewards, streak, pointsHistory } = get(); + const allAchievements = gamificationService.getAchievements(); + const completionRate = + allAchievements.length > 0 + ? Math.round((earnedAchievements.length / allAchievements.length) * 100) + : 0; + + const achievementsByCategory: Record = {}; + allAchievements.forEach((ach) => { + const isUnlocked = earnedAchievements.includes(ach.id); + if (isUnlocked) { + const cat = ach.trigger.toString(); + achievementsByCategory[cat] = (achievementsByCategory[cat] || 0) + 1; + } + }); + + const totalRewardsClaimed = earnedRewards.filter((r) => r.isClaimed || r.isRedeemed).length; + + return { + totalPointsEarned: points, + totalAchievementsUnlocked: earnedAchievements.length, + totalRewardsClaimed, + currentLevel: level, + longestStreak: streak, + completionRate, + achievementsByCategory, + pointsHistory, + }; + }, + resetProgress: () => { set({ points: 0, @@ -87,6 +198,9 @@ export const useGamificationStore = create()( earnedBadges: [], streak: 0, lastActionAt: undefined, + config: DEFAULT_CONFIG, + earnedRewards: [], + pointsHistory: [], }); }, }), diff --git a/src/types/gamification.ts b/src/types/gamification.ts index 0b1366db..65062d6a 100644 --- a/src/types/gamification.ts +++ b/src/types/gamification.ts @@ -10,6 +10,14 @@ export enum AchievementTrigger { REFERRAL_MADE = 'REFERRAL_MADE', } +export interface RewardDefinition { + id: string; + type: 'discount' | 'credit' | 'badge'; + value: number | string; // e.g., "10%" or 500 + description: string; + code?: string; // default coupon prefix if discount +} + export interface Achievement { id: string; name: string; @@ -18,6 +26,7 @@ export interface Achievement { criteria: (metadata: any) => boolean; points: number; badgeId?: string; + reward?: RewardDefinition; } export interface Badge { @@ -29,6 +38,20 @@ export interface Badge { unlockedAt?: Date; } +export interface RewardItem { + id: string; + rewardId: string; + title: string; + description: string; + type: 'discount' | 'credit' | 'badge'; + value: number | string; + code?: string; + isClaimed: boolean; + isRedeemed: boolean; + earnedAt: string; + redeemedAt?: string; +} + export interface UserProgress { points: number; level: number; @@ -38,11 +61,33 @@ export interface UserProgress { lastActionAt?: Date; } +export interface GamificationConfig { + soundEffectsEnabled: boolean; + notificationsEnabled: boolean; + showOnLeaderboard: boolean; + shareProfilePublicly: boolean; + dailyReminderEnabled: boolean; +} + +export interface GamificationAnalytics { + totalPointsEarned: number; + totalAchievementsUnlocked: number; + totalRewardsClaimed: number; + currentLevel: number; + longestStreak: number; + completionRate: number; // percentage 0-100 + achievementsByCategory: Record; + pointsHistory: Array<{ timestamp: string; amount: number; reason: string }>; +} + +export type LeaderboardCategory = 'all_time' | 'weekly' | 'streaks'; + export interface LeaderboardEntry { rank: number; name: string; points: number; level: number; avatar?: string; + streak?: number; isCurrentUser?: boolean; }