From 4656e016d747303e851a6430494362f9139cc296 Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:28:21 +0100 Subject: [PATCH 1/3] feat(types): add loyalty types and StorageKey variants - New types: LoyaltyConfig, LoyaltyTierConfig, PointTransaction, PointTxType, RewardsRedemption - 11 new StorageKey variants for on-chain loyalty state: LoyaltyConfig, LoyaltyPoints, LifetimePoints, TotalSpent, MemberSince, Streak, LastChargeAt, PointsExpiration, PointTxCount, PointTx, RedemptionCount, Redemption --- contracts/types/src/lib.rs | 80 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 86daa83..8f8cf7c 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -287,6 +287,60 @@ pub struct FraudReport { pub recent_cases: Vec, } +// ── Loyalty & Rewards types ── + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum PointTxType { + Earned, + Redeemed, + Expired, + ReferralBonus, + StreakBonus, + Achievement, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct LoyaltyTierConfig { + pub name: String, + pub points_threshold: u64, + pub discount_rate_bps: u32, + pub priority_support: bool, + pub reduced_fees_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct LoyaltyConfig { + pub points_per_dollar: u64, + pub expiration_days: u64, + pub tiers: Vec, + pub streak_bonus_threshold: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PointTransaction { + pub id: u64, + pub subscriber: Address, + pub amount: i128, + pub tx_type: PointTxType, + pub timestamp: u64, + pub reference_id: u64, + pub description: String, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RewardsRedemption { + pub id: u64, + pub subscriber: Address, + pub points_cost: u64, + pub discount_amount: i128, + pub timestamp: u64, +} + /// Storage keys for the proxy contract state. /// /// IMPORTANT: Never reorder existing variants. Append new variants only. @@ -360,4 +414,30 @@ pub enum StorageKey { PlanQuotas(u64), /// Usage record for a subscription and metric (sub_id, metric -> UsageRecord) SubscriptionUsage(u64, QuotaMetric), + + // ── Added in storage version 5 (Loyalty & Rewards) ── + /// Global loyalty program config. + LoyaltyConfig, + /// Current points balance for a subscriber. + LoyaltyPoints(Address), + /// Lifetime points earned for a subscriber. + LifetimePoints(Address), + /// Total amount spent by a subscriber. + TotalSpent(Address), + /// When the subscriber enrolled in the loyalty program. + MemberSince(Address), + /// Current consecutive on-time charge streak. + Streak(Address), + /// Timestamp of the last charge processed (for streak calculation). + LastChargeAt(Address), + /// When the subscriber's current points balance expires. + PointsExpiration(Address), + /// Counter for point transaction IDs. + PointTxCount, + /// Individual point transaction record. + PointTx(u64), + /// Counter for redemption IDs. + RedemptionCount, + /// Individual redemption record. + Redemption(u64), } From 8690689eec93b6935e29eb4c8066977fc2181dd7 Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:28:32 +0100 Subject: [PATCH 2/3] feat(contract): add loyalty module with points, tiers, streaks, redemption - New loyalty.rs module: accumulate_points, redeem_points, streak tracking, tier calculation, referral bonuses, points expiry - Integrated accumulate_points into charge_subscription flow - 11 public contract functions for loyalty management - Points capped at 10M to prevent inflation - Streak bonus awarded every 10 consecutive charges - Auto-expiry check on balance reads - Redemption converts points to discount (100 pts = 1%, max 50%) --- contracts/subscription/src/lib.rs | 144 ++++++++++ contracts/subscription/src/loyalty.rs | 386 ++++++++++++++++++++++++++ 2 files changed, 530 insertions(+) create mode 100644 contracts/subscription/src/loyalty.rs diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 75ccad9..dcf8d8f 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -2,6 +2,7 @@ mod gas_profiler; mod gas_storage; mod gas_optimization; +mod loyalty; use soroban_sdk::{token, Address, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ Interval, Invoice, Plan, StorageKey, Subscription, SubscriptionStatus, TimeRange, @@ -686,6 +687,15 @@ impl SubTrackrSubscription { (sub.subscriber.clone(), plan.price, 100_000u64, now), ); + // Accumulate loyalty points after successful charge. + loyalty::accumulate_points( + &env, + &storage, + &sub.subscriber, + plan.price, + now, + ); + if let Some(invoice_addr) = invoice_contract(&env, &storage) { let period = TimeRange { start: sub.last_charged_at, @@ -1045,6 +1055,140 @@ impl SubTrackrSubscription { revenue::get_revenue_schedule(&env, &storage, subscription_id) } + // ── Loyalty & Rewards API ── + + pub fn initialize_loyalty( + env: Env, + proxy: Address, + storage: Address, + config: subtrackr_types::LoyaltyConfig, + ) { + proxy.require_auth(); + get_admin(&env, &storage).require_auth(); + loyalty::set_loyalty_config(&env, &storage, &config); + } + + pub fn update_loyalty_config( + env: Env, + proxy: Address, + storage: Address, + config: subtrackr_types::LoyaltyConfig, + ) { + proxy.require_auth(); + get_admin(&env, &storage).require_auth(); + loyalty::set_loyalty_config(&env, &storage, &config); + } + + pub fn get_loyalty_config( + env: Env, + proxy: Address, + storage: Address, + ) -> Option { + proxy.require_auth(); + loyalty::get_loyalty_config(&env, &storage) + } + + pub fn get_points( + env: Env, + proxy: Address, + storage: Address, + subscriber: Address, + ) -> u64 { + proxy.require_auth(); + loyalty::get_eligible_points(&env, &storage, &subscriber) + } + + pub fn get_lifetime_points( + env: Env, + proxy: Address, + storage: Address, + subscriber: Address, + ) -> u64 { + proxy.require_auth(); + loyalty::get_lifetime_points(&env, &storage, &subscriber) + } + + pub fn get_streak( + env: Env, + proxy: Address, + storage: Address, + subscriber: Address, + ) -> u64 { + proxy.require_auth(); + loyalty::get_streak(&env, &storage, &subscriber) + } + + pub fn get_loyalty_status( + env: Env, + proxy: Address, + storage: Address, + subscriber: Address, + ) -> (u64, u64, u64, i128, Option) { + proxy.require_auth(); + let points = loyalty::get_eligible_points(&env, &storage, &subscriber); + let lifetime = loyalty::get_lifetime_points(&env, &storage, &subscriber); + let streak = loyalty::get_streak(&env, &storage, &subscriber); + let spent = loyalty::get_total_spent(&env, &storage, &subscriber); + let tier = loyalty::get_current_tier(&env, &storage, &subscriber); + (points, lifetime, streak, spent, tier) + } + + pub fn redeem_loyalty_points( + env: Env, + proxy: Address, + storage: Address, + subscriber: Address, + points: u64, + charge_amount: i128, + ) -> i128 { + proxy.require_auth(); + subscriber.require_auth(); + let now = env.ledger().timestamp(); + loyalty::redeem_points(&env, &storage, &subscriber, points, charge_amount, now) + } + + pub fn earn_referral_bonus( + env: Env, + proxy: Address, + storage: Address, + referrer: Address, + ) { + proxy.require_auth(); + let now = env.ledger().timestamp(); + loyalty::earn_referral_bonus(&env, &storage, &referrer, now); + } + + pub fn expire_points( + env: Env, + proxy: Address, + storage: Address, + subscriber: Address, + ) { + proxy.require_auth(); + get_admin(&env, &storage).require_auth(); + loyalty::expire_points(&env, &storage, &subscriber); + } + + pub fn get_point_transactions( + env: Env, + proxy: Address, + storage: Address, + subscriber: Address, + ) -> Vec { + proxy.require_auth(); + loyalty::get_point_transactions(&env, &storage, &subscriber) + } + + pub fn get_redemption( + env: Env, + proxy: Address, + storage: Address, + redemption_id: u64, + ) -> Option { + proxy.require_auth(); + loyalty::get_redemption(&env, &storage, redemption_id) + } + // ── Quota & Usage API ── pub fn set_plan_quotas( diff --git a/contracts/subscription/src/loyalty.rs b/contracts/subscription/src/loyalty.rs new file mode 100644 index 0000000..78cb118 --- /dev/null +++ b/contracts/subscription/src/loyalty.rs @@ -0,0 +1,386 @@ +/// Loyalty & Rewards module for SubTrackr subscriptions. +/// +/// On-chain points, tiered benefits, streaks, referral bonuses, and +/// points redemption — all stored in the shared storage contract. +use soroban_sdk::{Address, Env, String, Vec}; +use subtrackr_types::{ + LoyaltyConfig, LoyaltyTierConfig, PointTransaction, PointTxType, RewardsRedemption, + StorageKey, +}; + +use crate::{storage_persistent_get, storage_persistent_set}; + +// ── Constants ────────────────────────────────────────────────────────────────── + +/// Maximum points a single subscriber can hold (anti-inflation). +const MAX_POINTS: u64 = 10_000_000; +/// Minimum charge amount needed to earn points. +const MIN_CHARGE_FOR_POINTS: i128 = 100_000; + +// ── Config ───────────────────────────────────────────────────────────────────── + +pub fn set_loyalty_config(env: &Env, storage: &Address, config: &LoyaltyConfig) { + storage_persistent_set(env, storage, StorageKey::LoyaltyConfig, config); +} + +pub fn get_loyalty_config(env: &Env, storage: &Address) -> Option { + storage_persistent_get(env, storage, StorageKey::LoyaltyConfig) +} + +// ── Points ───────────────────────────────────────────────────────────────────── + +pub fn get_points(env: &Env, storage: &Address, subscriber: &Address) -> u64 { + storage_persistent_get::(env, storage, StorageKey::LoyaltyPoints(subscriber.clone())) + .unwrap_or(0) +} + +pub fn get_lifetime_points(env: &Env, storage: &Address, subscriber: &Address) -> u64 { + storage_persistent_get::(env, storage, StorageKey::LifetimePoints(subscriber.clone())) + .unwrap_or(0) +} + +pub fn get_total_spent(env: &Env, storage: &Address, subscriber: &Address) -> i128 { + storage_persistent_get::(env, storage, StorageKey::TotalSpent(subscriber.clone())) + .unwrap_or(0) +} + +/// Accumulate points after a successful charge. +/// +/// Called from the `charge_subscription` flow in lib.rs. +pub fn accumulate_points( + env: &Env, + storage: &Address, + subscriber: &Address, + charge_amount: i128, + charge_time: u64, +) { + if charge_amount < MIN_CHARGE_FOR_POINTS { + return; + } + let config = match get_loyalty_config(env, storage) { + Some(c) => c, + None => return, // loyalty not initialised + }; + + let current = get_points(env, storage, subscriber); + let lifetime = get_lifetime_points(env, storage, subscriber); + let total_spent = get_total_spent(env, storage, subscriber); + + let points_earned = (charge_amount as u64).saturating_mul(config.points_per_dollar) / 1_000_000; + if points_earned == 0 { + return; + } + + let new_points = (current + points_earned).min(MAX_POINTS); + let new_lifetime = lifetime + points_earned; + + storage_persistent_set(env, storage, StorageKey::LoyaltyPoints(subscriber.clone()), new_points); + storage_persistent_set(env, storage, StorageKey::LifetimePoints(subscriber.clone()), new_lifetime); + storage_persistent_set(env, storage, StorageKey::TotalSpent(subscriber.clone()), total_spent + charge_amount); + + // Track first participation as "member since" + if storage_persistent_get::(env, storage, StorageKey::MemberSince(subscriber.clone())).is_none() { + storage_persistent_set(env, storage, StorageKey::MemberSince(subscriber.clone()), charge_time); + } + + // Update streak + update_streak(env, storage, subscriber, charge_time); + + // Set points expiration if not set + if storage_persistent_get::(env, storage, StorageKey::PointsExpiration(subscriber.clone())).is_none() { + let expires_at = charge_time + config.expiration_days * 86_400; + storage_persistent_set(env, storage, StorageKey::PointsExpiration(subscriber.clone()), expires_at); + } + + // Record transaction + record_tx( + env, + storage, + subscriber, + points_earned as i128, + PointTxType::Earned, + charge_time, + 0, + String::from_slice(env, b"points earned from charge"), + ); +} + +/// Eligible points balance after checking expiry. +pub fn get_eligible_points(env: &Env, storage: &Address, subscriber: &Address) -> u64 { + let now = env.ledger().timestamp(); + let expires_at: Option = + storage_persistent_get(env, storage, StorageKey::PointsExpiration(subscriber.clone())); + let expired = expires_at.map_or(false, |exp| now >= exp); + + if expired { + let pts = get_points(env, storage, subscriber); + if pts > 0 { + record_tx( + env, + storage, + subscriber, + -(pts as i128), + PointTxType::Expired, + now, + 0, + String::from_slice(env, b"points expired"), + ); + storage_persistent_set(env, storage, StorageKey::LoyaltyPoints(subscriber.clone()), 0u64); + storage_persistent_set::>(env, storage, StorageKey::PointsExpiration(subscriber.clone()), None); + } + 0 + } else { + get_points(env, storage, subscriber) + } +} + +// ── Streaks ──────────────────────────────────────────────────────────────────── + +pub fn get_streak(env: &Env, storage: &Address, subscriber: &Address) -> u64 { + storage_persistent_get::(env, storage, StorageKey::Streak(subscriber.clone())).unwrap_or(0) +} + +fn update_streak(env: &Env, storage: &Address, subscriber: &Address, charge_time: u64) { + let last_charge: Option = + storage_persistent_get(env, storage, StorageKey::LastChargeAt(subscriber.clone())); + let current_streak = get_streak(env, storage, subscriber); + + let new_streak = match last_charge { + Some(last) => { + let config = get_loyalty_config(env, storage); + let threshold = config.map_or(86_400, |c| c.streak_bonus_threshold); + if charge_time >= last && charge_time - last <= threshold { + current_streak + 1 + } else { + 1 + } + } + None => 1, + }; + + storage_persistent_set(env, storage, StorageKey::Streak(subscriber.clone()), new_streak); + storage_persistent_set(env, storage, StorageKey::LastChargeAt(subscriber.clone()), charge_time); + + // Award streak bonus points at milestones + if new_streak > 0 && new_streak % 10 == 0 { + let bonus = (new_streak / 10) * 100; + let current = get_points(env, storage, subscriber); + let new = (current + bonus).min(MAX_POINTS); + storage_persistent_set(env, storage, StorageKey::LoyaltyPoints(subscriber.clone()), new); + record_tx( + env, + storage, + subscriber, + bonus as i128, + PointTxType::StreakBonus, + charge_time, + 0, + &String::from_slice(env, b"streak bonus"), + ); + } +} + +// ── Tiers ────────────────────────────────────────────────────────────────────── + +/// Determine the subscriber's current tier based on lifetime points. +pub fn get_current_tier( + env: &Env, + storage: &Address, + subscriber: &Address, +) -> Option { + let config = get_loyalty_config(env, storage)?; + let lifetime = get_lifetime_points(env, storage, subscriber); + + let mut best: Option = None; + for tier in config.tiers.iter() { + if lifetime >= tier.points_threshold { + best = Some(tier); + } + } + best +} + +// ── Referral bonus ───────────────────────────────────────────────────────────── + +pub fn earn_referral_bonus( + env: &Env, + storage: &Address, + referrer: &Address, + charge_time: u64, +) { + let config = match get_loyalty_config(env, storage) { + Some(c) => c, + None => return, + }; + let bonus = config.points_per_dollar.saturating_mul(100); // flat bonus per referral + let current = get_points(env, storage, referrer); + let lifetime = get_lifetime_points(env, storage, referrer); + let new_points = (current + bonus).min(MAX_POINTS); + + storage_persistent_set(env, storage, StorageKey::LoyaltyPoints(referrer.clone()), new_points); + storage_persistent_set(env, storage, StorageKey::LifetimePoints(referrer.clone()), lifetime + bonus); + + record_tx( + env, + storage, + referrer, + bonus as i128, + PointTxType::ReferralBonus, + charge_time, + 0, + String::from_slice(env, b"referral bonus"), + ); +} + +// ── Redemption ───────────────────────────────────────────────────────────────── + +/// Redeem points for a discount on the next charge. +/// +/// Returns the discount amount in token base units. +pub fn redeem_points( + env: &Env, + storage: &Address, + subscriber: &Address, + points: u64, + charge_amount: i128, + charge_time: u64, +) -> i128 { + if points == 0 { + return 0; + } + let eligible = get_eligible_points(env, storage, subscriber); + let actual = points.min(eligible); + if actual == 0 { + return 0; + } + + // Convert points to discount: 100 points = 1% of charge, capped at 50% + let discount_bps = (actual / 100).min(5000); // 5000 bps = 50% + let discount = (charge_amount * discount_bps as i128) / 10_000; + if discount <= 0 { + return 0; + } + + // Deduct points + let remaining = get_points(env, storage, subscriber) - actual; + storage_persistent_set(env, storage, StorageKey::LoyaltyPoints(subscriber.clone()), remaining); + + // Record redemption + let redemption_id = next_redemption_id(env, storage); + storage_persistent_set( + env, + storage, + StorageKey::Redemption(redemption_id), + RewardsRedemption { + id: redemption_id, + subscriber: subscriber.clone(), + points_cost: actual, + discount_amount: discount, + timestamp: charge_time, + }, + ); + + record_tx( + env, + storage, + subscriber, + -(actual as i128), + PointTxType::Redeemed, + charge_time, + redemption_id, + String::from_slice(env, b"points redeemed for discount"), + ); + + discount +} + +pub fn get_redemption( + env: &Env, + storage: &Address, + redemption_id: u64, +) -> Option { + storage_persistent_get(env, storage, StorageKey::Redemption(redemption_id)) +} + +// ── History ──────────────────────────────────────────────────────────────────── + +pub fn get_point_transactions( + env: &Env, + storage: &Address, + subscriber: &Address, +) -> Vec { + let count: u64 = + storage_persistent_get(env, storage, StorageKey::PointTxCount).unwrap_or(0); + let mut txs: Vec = Vec::new(env); + for i in 1..=count { + if let Some(tx) = + storage_persistent_get::(env, storage, StorageKey::PointTx(i)) + { + if tx.subscriber == *subscriber { + txs.push_back(tx); + } + } + } + txs +} + +// ── Admin utility ────────────────────────────────────────────────────────────── + +/// Manually expire all points for a subscriber. +pub fn expire_points(env: &Env, storage: &Address, subscriber: &Address) { + let pts = get_points(env, storage, subscriber); + if pts > 0 { + let now = env.ledger().timestamp(); + record_tx( + env, + storage, + subscriber, + -(pts as i128), + PointTxType::Expired, + now, + 0, + String::from_slice(env, b"admin-forced expiry"), + ); + storage_persistent_set(env, storage, StorageKey::LoyaltyPoints(subscriber.clone()), 0u64); + storage_persistent_set::>(env, storage, StorageKey::PointsExpiration(subscriber.clone()), None); + } +} + +// ── Internal helpers ─────────────────────────────────────────────────────────── + +fn next_tx_id(env: &Env, storage: &Address) -> u64 { + let count: u64 = storage_persistent_get(env, storage, StorageKey::PointTxCount).unwrap_or(0); + let next = count + 1; + storage_persistent_set(env, storage, StorageKey::PointTxCount, next); + next +} + +fn next_redemption_id(env: &Env, storage: &Address) -> u64 { + let count: u64 = storage_persistent_get(env, storage, StorageKey::RedemptionCount).unwrap_or(0); + let next = count + 1; + storage_persistent_set(env, storage, StorageKey::RedemptionCount, next); + next +} + +fn record_tx( + env: &Env, + storage: &Address, + subscriber: &Address, + amount: i128, + tx_type: PointTxType, + timestamp: u64, + reference_id: u64, + description: String, +) { + let id = next_tx_id(env, storage); + let tx = PointTransaction { + id, + subscriber: subscriber.clone(), + amount, + tx_type, + timestamp, + reference_id, + description, + }; + storage_persistent_set(env, storage, StorageKey::PointTx(id), tx); +} From 7e78c8e5c9e3d2e44a9019f2280636dbe9b4d8a6 Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:28:39 +0100 Subject: [PATCH 3/3] feat(frontend): integrate loyalty store with contract, add streaks/badges/refs to screen - New types: PointTxType, LoyaltyConfig, StreakInfo, ReferralInfo - loyaltyStore: contract integration stubs, streak tracking, referral bonuses, gamification triggers - LoyaltyDashboardScreen: streak card, referral share, badges modal, points expiry UI, tier comparison table - gamificationService: 8 new loyalty achievements and badges (point milestones, streak milestones, referral milestones) - Gamification triggers wired into accumulatePoints and earnReferralBonus flows --- src/screens/LoyaltyDashboardScreen.tsx | 273 ++++++++++++++++++++++++- src/services/gamificationService.ts | 112 ++++++++++ src/store/loyaltyStore.ts | 153 +++++++++++++- src/types/gamification.ts | 3 + src/types/loyalty.ts | 31 ++- 5 files changed, 561 insertions(+), 11 deletions(-) diff --git a/src/screens/LoyaltyDashboardScreen.tsx b/src/screens/LoyaltyDashboardScreen.tsx index 6300ec0..955645d 100644 --- a/src/screens/LoyaltyDashboardScreen.tsx +++ b/src/screens/LoyaltyDashboardScreen.tsx @@ -10,12 +10,14 @@ import { ActivityIndicator, Modal, FlatList, + Share, } from 'react-native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { useLoyaltyStore } from '../store/loyaltyStore'; import { useWalletStore } from '../store/walletStore'; +import { useGamificationStore } from '../store/gamificationStore'; import { Card } from '../components/common/Card'; -import { LoyaltyTier, RewardType, TierBenefits } from '../types/loyalty'; +import { LoyaltyTier, RewardType, TierBenefits, PointTxType, StreakInfo } from '../types/loyalty'; const LoyaltyDashboardScreen: React.FC = () => { const { @@ -23,21 +25,31 @@ const LoyaltyDashboardScreen: React.FC = () => { transactions, rewards, program, + streak, + referral, isLoading, initializeProgram, + fetchLoyaltyStatus, accumulatePoints, redeemPoints, + earnReferralBonus, + generateReferralCode, } = useLoyaltyStore(); const { address } = useWalletStore(); + const { earnedBadges, earnedAchievements } = useGamificationStore(); const [modalVisible, setModalVisible] = useState(false); const [selectedReward, setSelectedReward] = useState(''); + const [badgeModalVisible, setBadgeModalVisible] = useState(false); useEffect(() => { if (!program) { initializeProgram(); } - }, [program, initializeProgram]); + if (address) { + fetchLoyaltyStatus(address); + } + }, [program, initializeProgram, address, fetchLoyaltyStatus]); useEffect(() => { if (address && loyaltyStatus) { @@ -48,6 +60,18 @@ const LoyaltyDashboardScreen: React.FC = () => { } }, [address, loyaltyStatus]); + const handleShareReferral = useCallback(async () => { + const code = generateReferralCode(); + try { + await Share.share({ + message: `Join SubTrackr and use my referral code: ${code}. You'll earn bonus points!`, + title: 'Invite a Friend', + }); + } catch { + // user cancelled + } + }, [generateReferralCode]); + const handleRedeemReward = useCallback(async () => { if (!selectedReward) { Alert.alert('Error', 'Please select a reward'); @@ -84,6 +108,84 @@ const LoyaltyDashboardScreen: React.FC = () => { return program.tiers[currentTierIndex + 1]; }; + const renderStreakCard = () => { + if (!loyaltyStatus) return null; + const currentStreak = loyaltyStatus.streak || streak.current; + return ( + + + 🔥 + + + {currentStreak > 0 ? `${currentStreak}-day streak` : 'Start a streak!'} + + + {currentStreak >= 10 + ? 'Amazing! You earned a streak bonus!' + : currentStreak >= 5 + ? 'Keep going! Almost at bonus milestone.' + : 'Pay on time to build your streak.'} + + + + {currentStreak > 0 && ( + + + + + + {10 - (currentStreak % 10)} charges to next streak bonus + + + )} + + ); + }; + + const renderReferralCard = () => ( + + Refer a Friend + + Earn {referral.bonusPoints} bonus points for each friend who joins! + + + Share Referral Code + + {referral.totalReferrals > 0 && ( + + {referral.totalReferrals} friend{referral.totalReferrals > 1 ? 's' : ''} joined + + )} + + ); + + const renderBadgesCard = () => { + if (earnedBadges.length === 0 && earnedAchievements.length === 0) return null; + return ( + + + Badges & Achievements + setBadgeModalVisible(true)}> + View all → + + + + {earnedBadges.slice(0, 4).map((badge, idx) => ( + + 🏆 + {badge} + + ))} + + + ); + }; + const renderStatusCard = () => { if (!loyaltyStatus) { return ( @@ -204,10 +306,18 @@ const LoyaltyDashboardScreen: React.FC = () => { {transactions.length === 0 ? ( No transactions yet ) : ( - transactions.slice(0, 10).map((tx) => ( + transactions.slice(0, 15).map((tx) => ( {tx.description} + + {tx.type === PointTxType.EARNED && 'Earned'} + {tx.type === PointTxType.REDEEMED && 'Redeemed'} + {tx.type === PointTxType.EXPIRED && 'Expired'} + {tx.type === PointTxType.REFERRAL_BONUS && 'Referral'} + {tx.type === PointTxType.STREAK_BONUS && 'Streak Bonus'} + {tx.type === PointTxType.ACHIEVEMENT && 'Achievement'} + {new Date(tx.createdAt).toLocaleDateString()} @@ -226,7 +336,7 @@ const LoyaltyDashboardScreen: React.FC = () => { ); - const renderMembers = () => { + const renderTierComparison = () => { if (!program) return null; return ( @@ -271,9 +381,12 @@ const LoyaltyDashboardScreen: React.FC = () => { {renderStatusCard()} + {renderStreakCard()} + {renderBadgesCard()} + {renderReferralCard()} {renderRewardsCard()} {renderTransactionsCard()} - {renderMembers()} + {renderTierComparison()} { + + setBadgeModalVisible(false)}> + + + Badges & Achievements + + {earnedBadges.length} badges earned + + `${idx}`} + renderItem={({ item: badge }) => ( + + 🏆 + {badge} + + )} + /> + setBadgeModalVisible(false)}> + Close + + + + ); }; @@ -587,6 +730,126 @@ const styles = StyleSheet.create({ textAlign: 'center', marginTop: spacing.xs, }, + transactionType: { + fontSize: typography.fontSizeXs, + color: colors.primary, + marginTop: spacing.xs, + }, + streakCard: { + padding: spacing.md, + margin: spacing.md, + marginTop: 0, + }, + streakHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + streakIcon: { + fontSize: 32, + marginRight: spacing.md, + }, + streakInfo: { + flex: 1, + }, + streakValue: { + fontSize: typography.fontSizeMd, + fontWeight: typography.fontWeightBold, + color: colors.text, + }, + streakSubtext: { + fontSize: typography.fontSizeSm, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + streakProgress: { + marginTop: spacing.md, + }, + streakBar: { + height: 6, + backgroundColor: colors.border, + borderRadius: 3, + overflow: 'hidden', + }, + streakFill: { + height: '100%', + backgroundColor: '#FF6B35', + }, + streakMilestone: { + fontSize: typography.fontSizeXs, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + referralCard: { + padding: spacing.md, + margin: spacing.md, + marginTop: 0, + }, + referralTitle: { + fontSize: typography.fontSizeMd, + fontWeight: typography.fontWeightBold, + color: colors.text, + marginBottom: spacing.xs, + }, + referralDesc: { + fontSize: typography.fontSizeSm, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + shareButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + padding: spacing.md, + alignItems: 'center', + }, + shareButtonText: { + color: colors.text, + fontSize: typography.fontSizeMd, + fontWeight: typography.fontWeightBold, + }, + referralStats: { + fontSize: typography.fontSizeSm, + color: colors.success, + marginTop: spacing.sm, + textAlign: 'center', + }, + badgesCard: { + padding: spacing.md, + margin: spacing.md, + marginTop: 0, + }, + badgesHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + badgesTitle: { + fontSize: typography.fontSizeMd, + fontWeight: typography.fontWeightBold, + color: colors.text, + }, + badgesViewAll: { + fontSize: typography.fontSizeSm, + color: colors.primary, + }, + badgeRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + badgeItem: { + alignItems: 'center', + width: 60, + }, + badgeIcon: { + fontSize: 28, + }, + badgeName: { + fontSize: typography.fontSizeXs, + color: colors.textSecondary, + marginTop: spacing.xs, + textAlign: 'center', + }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', diff --git a/src/services/gamificationService.ts b/src/services/gamificationService.ts index b91ac22..54f0e60 100644 --- a/src/services/gamificationService.ts +++ b/src/services/gamificationService.ts @@ -47,6 +47,69 @@ export class GamificationService { points: 75, badgeId: 'strategy_badge', }, + { + id: 'point_collector', + name: 'Point Collector', + description: 'Earn 1,000 lifetime loyalty points.', + trigger: AchievementTrigger.POINTS_MILESTONE, + criteria: (metadata) => metadata.lifetimePoints >= 1000, + points: 100, + badgeId: 'collector_badge', + }, + { + id: 'point_hoarder', + name: 'Point Hoarder', + description: 'Earn 5,000 lifetime loyalty points.', + trigger: AchievementTrigger.POINTS_MILESTONE, + criteria: (metadata) => metadata.lifetimePoints >= 5000, + points: 300, + badgeId: 'hoarder_badge', + }, + { + id: 'loyal_member', + name: 'Loyal Member', + description: 'Earn 15,000 lifetime loyalty points.', + trigger: AchievementTrigger.POINTS_MILESTONE, + criteria: (metadata) => metadata.lifetimePoints >= 15000, + points: 500, + badgeId: 'loyal_badge', + }, + { + id: 'streak_starter', + name: 'Streak Starter', + description: 'Maintain a 5-charge streak.', + trigger: AchievementTrigger.STREAK_MILESTONE, + criteria: (metadata) => metadata.streak >= 5, + points: 50, + badgeId: 'streak_starter_badge', + }, + { + id: 'streak_master', + name: 'Streak Master', + description: 'Maintain a 30-charge streak.', + trigger: AchievementTrigger.STREAK_MILESTONE, + criteria: (metadata) => metadata.streak >= 30, + points: 200, + badgeId: 'streak_master_badge', + }, + { + id: 'referral_friend', + name: 'Social Butterfly', + description: 'Refer your first friend.', + trigger: AchievementTrigger.REFERRAL_MADE, + criteria: (metadata) => metadata.totalReferrals >= 1, + points: 75, + badgeId: 'referral_badge', + }, + { + id: 'referral_pro', + name: 'Networker', + description: 'Refer 5 friends.', + trigger: AchievementTrigger.REFERRAL_MADE, + criteria: (metadata) => metadata.totalReferrals >= 5, + points: 250, + badgeId: 'networker_badge', + }, ]; private badges: Badge[] = [ @@ -85,6 +148,55 @@ export class GamificationService { icon: '🎯', color: '#8b5cf6', }, + { + id: 'collector_badge', + name: 'Collector', + description: 'Earned 1,000 loyalty points.', + icon: '⭐', + color: '#f59e0b', + }, + { + id: 'hoarder_badge', + name: 'Hoarder', + description: 'Earned 5,000 loyalty points.', + icon: '💎', + color: '#06b6d4', + }, + { + id: 'loyal_badge', + name: 'Loyal Legend', + description: 'Earned 15,000 loyalty points.', + icon: '👑', + color: '#8b5cf6', + }, + { + id: 'streak_starter_badge', + name: 'On a Roll', + description: '5-charge streak.', + icon: '🔥', + color: '#f97316', + }, + { + id: 'streak_master_badge', + name: 'Unstoppable', + description: '30-charge streak.', + icon: '🔥', + color: '#ef4444', + }, + { + id: 'referral_badge', + name: 'Social Butterfly', + description: 'Referred first friend.', + icon: '🦋', + color: '#10b981', + }, + { + id: 'networker_badge', + name: 'Networker', + description: 'Referred 5 friends.', + icon: '🌐', + color: '#6366f1', + }, ]; getAchievements(): Achievement[] { diff --git a/src/store/loyaltyStore.ts b/src/store/loyaltyStore.ts index a33f714..53d68df 100644 --- a/src/store/loyaltyStore.ts +++ b/src/store/loyaltyStore.ts @@ -5,28 +5,39 @@ import { LoyaltyStatus, LoyaltyTier, PointsTransaction, + PointTxType, Reward, RewardType, TierBenefits, LoyaltyProgram, + StreakInfo, + ReferralInfo, } from '../types/loyalty'; +import { AchievementTrigger } from '../types/gamification'; +import { useGamificationStore } from './gamificationStore'; const STORAGE_KEY = 'subtrackr-loyalty'; -const STORE_VERSION = 1; +const STORE_VERSION = 2; interface LoyaltyState { loyaltyStatus: LoyaltyStatus | null; transactions: PointsTransaction[]; rewards: Reward[]; program: LoyaltyProgram | null; + streak: StreakInfo; + referral: ReferralInfo; isLoading: boolean; error: string | null; initializeProgram: () => Promise; + fetchLoyaltyStatus: (address: string) => Promise; accumulatePoints: (subscriberId: string, subscriptionId: string, amount: number) => Promise; redeemPoints: (rewardId: string) => Promise; + redeemPointsForDiscount: (points: number, chargeAmount: number) => Promise; checkTierUpgrade: () => void; expirePoints: () => void; + earnReferralBonus: (referrerAddress: string) => Promise; + generateReferralCode: () => string; } const generateUniqueId = (): string => { @@ -128,12 +139,21 @@ const getTierFromPoints = (points: number): LoyaltyTier => { return LoyaltyTier.BRONZE; }; +const getTierBenefits = (tier: LoyaltyTier): TierBenefits | undefined => { + return defaultTierBenefits.find((t) => t.tier === tier); +}; + const calculatePointsExpiration = (pointsExpirationDays: number, memberSince: Date): Date => { const expirationDate = new Date(memberSince); expirationDate.setDate(expirationDate.getDate() + pointsExpirationDays); return expirationDate; }; +const generateReferralCode = (address: string): string => { + const suffix = address.slice(-6).toUpperCase(); + return `SUBTRACKR-${suffix}`; +}; + export const useLoyaltyStore = create()( persist( (set, get) => ({ @@ -141,6 +161,8 @@ export const useLoyaltyStore = create()( transactions: [], rewards: defaultRewards, program: null, + streak: { current: 0, lastChargeAt: null, isActive: false }, + referral: { code: '', bonusPoints: 100, totalReferrals: 0 }, isLoading: false, error: null, @@ -156,8 +178,40 @@ export const useLoyaltyStore = create()( set({ program }); }, + fetchLoyaltyStatus: async (address: string) => { + set({ isLoading: true, error: null }); + try { + // TODO: replace with actual contract call: + // const result = await subscriptionContract.get_loyalty_status(address) + // const { points, lifetime, streak, spent, tier } = result; + const existing = get().loyaltyStatus; + const currentTier = existing?.tier || getTierFromPoints(existing?.lifetimePoints || 0); + const refCode = generateReferralCode(address); + set({ + loyaltyStatus: { + subscriberId: address, + tier: currentTier, + points: existing?.points || 0, + lifetimePoints: existing?.lifetimePoints || 0, + totalSpent: existing?.totalSpent || 0, + memberSince: existing?.memberSince || new Date(), + streak: existing?.streak || 0, + }, + referral: { + code: refCode, + bonusPoints: 100, + totalReferrals: 0, + }, + }); + } catch (err: any) { + set({ error: err.message }); + } finally { + set({ isLoading: false }); + } + }, + accumulatePoints: async (subscriberId: string, subscriptionId: string, amount: number) => { - const { program, transactions, loyaltyStatus } = get(); + const { program, transactions, loyaltyStatus, streak } = get(); if (!program) return; const pointsEarned = Math.floor(amount * program.pointsPerDollar); @@ -166,7 +220,7 @@ export const useLoyaltyStore = create()( id: generateUniqueId(), subscriberId, amount: pointsEarned, - type: 'earn', + type: PointTxType.EARNED, subscriptionId, description: `Points earned from subscription`, createdAt: new Date(), @@ -175,7 +229,9 @@ export const useLoyaltyStore = create()( const currentPoints = loyaltyStatus?.points || 0; const lifetimePoints = loyaltyStatus?.lifetimePoints || 0; const totalSpent = loyaltyStatus?.totalSpent || 0; + const currentStreak = streak.current; + const newStreak = currentStreak + 1; const newStatus: LoyaltyStatus = { subscriberId, tier: getTierFromPoints(currentPoints + pointsEarned), @@ -187,12 +243,48 @@ export const useLoyaltyStore = create()( program.pointsExpirationDays, loyaltyStatus?.memberSince || new Date() ), + streak: newStreak, }; + // Streak bonus every 10 consecutive charges + if (newStreak > 0 && newStreak % 10 === 0) { + const bonusPts = Math.floor(newStreak / 10) * 100; + newStatus.points += bonusPts; + newStatus.lifetimePoints += bonusPts; + const bonusTx: PointsTransaction = { + id: generateUniqueId(), + subscriberId, + amount: bonusPts, + type: PointTxType.STREAK_BONUS, + description: `${newStreak}-day streak bonus!`, + createdAt: new Date(), + }; + set({ + transactions: [...transactions, transaction, bonusTx], + loyaltyStatus: newStatus, + streak: { current: newStreak, lastChargeAt: new Date(), isActive: true }, + }); + + // Trigger gamification checks + useGamificationStore.getState().checkAchievements(AchievementTrigger.STREAK_MILESTONE, { streak: newStreak }); + return; + } + set({ transactions: [...transactions, transaction], loyaltyStatus: newStatus, + streak: { current: newStreak, lastChargeAt: new Date(), isActive: true }, }); + + // Trigger gamification checks + useGamificationStore.getState().checkAchievements( + AchievementTrigger.POINTS_MILESTONE, + { lifetimePoints: newStatus.lifetimePoints }, + ); + useGamificationStore.getState().checkAchievements( + AchievementTrigger.STREAK_MILESTONE, + { streak: newStreak }, + ); }, redeemPoints: async (rewardId: string) => { @@ -203,11 +295,12 @@ export const useLoyaltyStore = create()( if (!reward.isActive) return false; if (loyaltyStatus.points < reward.pointsCost) return false; + // TODO: call contract redeem_loyalty_points(reward.pointsCost, chargeAmount) const transaction: PointsTransaction = { id: generateUniqueId(), subscriberId: loyaltyStatus.subscriberId, amount: -reward.pointsCost, - type: 'redeem', + type: PointTxType.REDEEMED, description: `Redeemed: ${reward.name}`, createdAt: new Date(), }; @@ -223,6 +316,14 @@ export const useLoyaltyStore = create()( return true; }, + redeemPointsForDiscount: async (points: number, chargeAmount: number) => { + // TODO: call contract redeem_loyalty_points(points, chargeAmount) + // Returns discount amount + const discountBps = Math.min(Math.floor(points / 100), 5000); + const discount = Math.floor((chargeAmount * discountBps) / 10000); + return discount; + }, + checkTierUpgrade: () => { const { loyaltyStatus } = get(); if (!loyaltyStatus) return; @@ -248,7 +349,7 @@ export const useLoyaltyStore = create()( id: generateUniqueId(), subscriberId: loyaltyStatus.subscriberId, amount: -loyaltyStatus.points, - type: 'expire', + type: PointTxType.EXPIRED, description: 'Points expired', createdAt: new Date(), }; @@ -263,6 +364,46 @@ export const useLoyaltyStore = create()( }); } }, + + earnReferralBonus: async (referrerAddress: string) => { + const { program, loyaltyStatus, referral, transactions } = get(); + if (!program || !loyaltyStatus) return; + + // TODO: call contract earn_referral_bonus(referrerAddress) + const bonusPts = referral.bonusPoints; + + const bonusTx: PointsTransaction = { + id: generateUniqueId(), + subscriberId: referrerAddress, + amount: bonusPts, + type: PointTxType.REFERRAL_BONUS, + description: 'Referral bonus', + createdAt: new Date(), + }; + + const newTotalReferrals = referral.totalReferrals + 1; + set({ + transactions: [...transactions, bonusTx], + referral: { ...referral, totalReferrals: newTotalReferrals }, + loyaltyStatus: { + ...loyaltyStatus, + points: loyaltyStatus.points + bonusPts, + lifetimePoints: loyaltyStatus.lifetimePoints + bonusPts, + }, + }); + + // Trigger gamification checks + useGamificationStore.getState().checkAchievements( + AchievementTrigger.REFERRAL_MADE, + { totalReferrals: newTotalReferrals }, + ); + }, + + generateReferralCode: () => { + const { loyaltyStatus } = get(); + if (!loyaltyStatus) return ''; + return generateReferralCode(loyaltyStatus.subscriberId); + }, }), { name: STORAGE_KEY, @@ -273,6 +414,8 @@ export const useLoyaltyStore = create()( transactions: state.transactions, rewards: state.rewards, program: state.program, + streak: state.streak, + referral: state.referral, }), } ) diff --git a/src/types/gamification.ts b/src/types/gamification.ts index 7893f70..0b1366d 100644 --- a/src/types/gamification.ts +++ b/src/types/gamification.ts @@ -5,6 +5,9 @@ export enum AchievementTrigger { CRYPTO_PAYMENT = 'CRYPTO_PAYMENT', STREAK_MAINTAINED = 'STREAK_MAINTAINED', SEGMENT_CREATED = 'SEGMENT_CREATED', + POINTS_MILESTONE = 'POINTS_MILESTONE', + STREAK_MILESTONE = 'STREAK_MILESTONE', + REFERRAL_MADE = 'REFERRAL_MADE', } export interface Achievement { diff --git a/src/types/loyalty.ts b/src/types/loyalty.ts index bc9235a..cd19bec 100644 --- a/src/types/loyalty.ts +++ b/src/types/loyalty.ts @@ -11,6 +11,15 @@ export enum RewardType { MERCHANDISE = 'merchandise', } +export enum PointTxType { + EARNED = 'earned', + REDEEMED = 'redeemed', + EXPIRED = 'expired', + REFERRAL_BONUS = 'referral_bonus', + STREAK_BONUS = 'streak_bonus', + ACHIEVEMENT = 'achievement', +} + export interface LoyaltyBenefit { type: string; description: string; @@ -30,7 +39,7 @@ export interface PointsTransaction { id: string; subscriberId: string; amount: number; - type: 'earn' | 'redeem' | 'expire' | 'referral_bonus'; + type: PointTxType; subscriptionId?: string; description: string; createdAt: Date; @@ -55,6 +64,14 @@ export interface LoyaltyStatus { totalSpent: number; memberSince: Date; pointsExpirationDate?: Date; + streak: number; +} + +export interface LoyaltyConfig { + pointsPerDollar: number; + expirationDays: number; + tiers: TierBenefits[]; + streakBonusThreshold: number; } export interface LoyaltyProgram { @@ -64,4 +81,16 @@ export interface LoyaltyProgram { pointsPerDollar: number; pointsExpirationDays: number; isActive: boolean; +} + +export interface StreakInfo { + current: number; + lastChargeAt: Date | null; + isActive: boolean; +} + +export interface ReferralInfo { + code: string; + bonusPoints: number; + totalReferrals: number; } \ No newline at end of file