From 1dda40d21889527ab557e5789659677dffc2d88c Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Sun, 26 Apr 2026 16:55:01 +0100 Subject: [PATCH 1/2] feat: implement retention-based cancellation flow and UI refactor --- contracts/subscription/src/cancellation.rs | 24 + contracts/subscription/src/retention.rs | 24 + package-lock.json | 20 + src/components/common/SharedElement.tsx | 72 ++- src/components/home/FilterBar.tsx | 102 ++-- src/components/home/StatsCard.tsx | 113 ++-- src/navigation/AppNavigator.tsx | 6 + src/navigation/types.ts | 1 + src/screens/CancellationFlowScreen.tsx | 116 ++++ src/screens/HomeScreen.tsx | 190 +++--- src/screens/SubscriptionDetailScreen.tsx | 673 ++++++--------------- src/store/cancellationStore.ts | 30 + 12 files changed, 671 insertions(+), 700 deletions(-) create mode 100644 contracts/subscription/src/cancellation.rs create mode 100644 contracts/subscription/src/retention.rs create mode 100644 src/screens/CancellationFlowScreen.tsx create mode 100644 src/store/cancellationStore.ts diff --git a/contracts/subscription/src/cancellation.rs b/contracts/subscription/src/cancellation.rs new file mode 100644 index 0000000..b3d3cf8 --- /dev/null +++ b/contracts/subscription/src/cancellation.rs @@ -0,0 +1,24 @@ +use soroban_sdk::{Env, Address, Symbol, log}; +use crate::storage_types::{Subscription, Status}; + +pub fn request_cancellation(e: Env, sub_id: Symbol) { + let mut sub: Subscription = e.storage().instance().get(&sub_id).unwrap(); + + // Instead of immediate deletion, we set an end date + // to allow the user to enjoy the remaining paid period. + let current_ts = e.ledger().timestamp(); + sub.status = Status::ScheduledForCancellation; + sub.end_date = Some(sub.next_billing_date); + sub.updated_at = current_ts; + + e.storage().instance().set(&sub_id, &sub); + log!(&e, "Subscription scheduled for cancellation", sub_id); +} + +pub fn undo_cancellation(e: Env, sub_id: Symbol) { + let mut sub: Subscription = e.storage().instance().get(&sub_id).unwrap(); + sub.status = Status::Active; + sub.end_date = None; + + e.storage().instance().set(&sub_id, &sub); +} \ No newline at end of file diff --git a/contracts/subscription/src/retention.rs b/contracts/subscription/src/retention.rs new file mode 100644 index 0000000..5e6ab46 --- /dev/null +++ b/contracts/subscription/src/retention.rs @@ -0,0 +1,24 @@ +pub enum OfferType { + Discount, + FreeGas, + Extension, +} + +pub fn apply_retention_offer(e: Env, sub_id: Symbol, offer_type: OfferType) { + let mut sub: Subscription = e.storage().instance().get(&sub_id).unwrap(); + + match offer_type { + OfferType::Discount => { + sub.price = sub.price * 80 / 100; // 20% Retention Discount + }, + OfferType::FreeGas => { + sub.gas_budget += 0.50; // Add bonus XLM for gas + }, + OfferType::Extension => { + sub.next_billing_date += 2592000; // 30 days free + } + } + + sub.status = Status::Active; + e.storage().instance().set(&sub_id, &sub); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c001a2b..447c773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "cross-env": "^7.0.3", "detox": "^20.50.1", "eslint": "^8.57.0", "eslint-config-expo": "^7.0.0", @@ -14096,6 +14097,25 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", diff --git a/src/components/common/SharedElement.tsx b/src/components/common/SharedElement.tsx index bc4e219..10c30a3 100644 --- a/src/components/common/SharedElement.tsx +++ b/src/components/common/SharedElement.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { Animated, View, StyleSheet } from 'react-native'; import { SharedElementTransition, animations, useAnimatedValue } from '../../utils/animations'; @@ -9,6 +9,46 @@ interface SharedElementProps { transitionType?: 'fade' | 'scale' | 'slide'; } +interface ScreenTransitionProps { + children: React.ReactNode; + type?: 'slide' | 'fade' | 'none'; + duration?: number; +} + +export const ScreenTransition: React.FC = ({ + children, + type = 'slide', + duration = 400, +}) => { + const anim = useAnimatedValue(0); + + useEffect(() => { + Animated.timing(anim, { + toValue: 1, + duration: duration, + useNativeDriver: true, + }).start(); + }, [anim, duration]); + + const animatedStyle = React.useMemo(() => { + if (type === 'none') return {}; + + return { + opacity: anim, + transform: [ + { + translateX: anim.interpolate({ + inputRange: [0, 1], + outputRange: [type === 'slide' ? 50 : 0, 0], + }), + }, + ], + }; + }, [anim, type]); + + return {children}; +}; + export const SharedElement: React.FC = ({ id, children, @@ -52,12 +92,14 @@ export const SharedElement: React.FC = ({ case 'slide': return { opacity: Animated.multiply(animatedValue, localAnim), - transform: [{ - translateX: Animated.multiply( - animatedValue.interpolate({ inputRange: [0, 1], outputRange: [100, 0] }), - localAnim - ) - }], + transform: [ + { + translateX: Animated.multiply( + animatedValue.interpolate({ inputRange: [0, 1], outputRange: [100, 0] }), + localAnim + ), + }, + ], }; case 'fade': default: @@ -67,11 +109,7 @@ export const SharedElement: React.FC = ({ } }, [animatedValue, localAnim, transitionType]); - return ( - - {children} - - ); + return {children}; }; interface SharedElementTransitionProviderProps { @@ -79,13 +117,9 @@ interface SharedElementTransitionProviderProps { } export const SharedElementTransitionProvider: React.FC = ({ - children + children, }) => { - return ( - - {children} - - ); + return {children}; }; const styles = StyleSheet.create({ @@ -95,4 +129,4 @@ const styles = StyleSheet.create({ provider: { flex: 1, }, -}); \ No newline at end of file +}); diff --git a/src/components/home/FilterBar.tsx b/src/components/home/FilterBar.tsx index e2912e1..170e8e7 100644 --- a/src/components/home/FilterBar.tsx +++ b/src/components/home/FilterBar.tsx @@ -18,58 +18,46 @@ export const FilterBar: React.FC = ({ activeFilterCount, }) => { return ( - - + + {/* Search Input Field */} + + importantForAccessibility="no-hide-descendants"> 🔍 {searchQuery.length > 0 && ( setSearchQuery('')} - accessibilityRole="button" accessibilityLabel="Clear search" - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> - + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + )} + {/* Filter Action Button */} - - 🔧 - + accessibilityLabel={`Filters${hasActiveFilters ? `, ${activeFilterCount} active` : ''}`}> + 🔧 + {hasActiveFilters && ( - - {activeFilterCount} + + {activeFilterCount} )} @@ -78,72 +66,76 @@ export const FilterBar: React.FC = ({ }; const styles = StyleSheet.create({ - searchFilterBar: { + container: { flexDirection: 'row', alignItems: 'center', + paddingHorizontal: spacing.lg, marginTop: spacing.md, gap: spacing.sm, }, - searchContainer: { + searchWrapper: { flex: 1, flexDirection: 'row', alignItems: 'center', backgroundColor: colors.surface, borderRadius: borderRadius.md, paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, + height: 48, // Fixed height for better touch targets borderWidth: 1, borderColor: colors.border, }, - searchIcon: { - fontSize: 16, - marginRight: spacing.sm, - color: colors.textSecondary, - }, - searchInput: { + input: { flex: 1, color: colors.text, ...typography.body, + paddingVertical: 0, // Fixes vertical alignment on some Android versions + marginLeft: spacing.xs, }, - clearSearchIcon: { - fontSize: 16, + iconSm: { + fontSize: 14, + }, + clearIcon: { + fontSize: 14, color: colors.textSecondary, - padding: spacing.xs, + fontWeight: 'bold', }, filterButton: { backgroundColor: colors.surface, borderRadius: borderRadius.md, - padding: spacing.md, + width: 48, + height: 48, borderWidth: 1, borderColor: colors.border, alignItems: 'center', justifyContent: 'center', - position: 'relative', }, filterButtonActive: { - backgroundColor: colors.primary, + backgroundColor: colors.primary + '15', // Soft tint of primary borderColor: colors.primary, }, filterIcon: { fontSize: 18, - color: colors.text, + color: colors.textSecondary, }, - filterBadge: { + filterIconActive: { + color: colors.primary, + }, + badge: { position: 'absolute', - top: -5, - right: -5, - backgroundColor: colors.error, - borderRadius: borderRadius.full, - minWidth: 20, - height: 20, + top: -4, + right: -4, + backgroundColor: colors.accent, // Using accent instead of error red + borderRadius: 10, + minWidth: 18, + height: 18, alignItems: 'center', justifyContent: 'center', - paddingHorizontal: spacing.xs, + borderWidth: 2, + borderColor: colors.background, // Creates a "cutout" effect }, - filterBadgeText: { - ...typography.caption, - color: colors.text, - fontWeight: '600', + badgeText: { + color: '#fff', fontSize: 10, + fontWeight: 'bold', }, }); diff --git a/src/components/home/StatsCard.tsx b/src/components/home/StatsCard.tsx index 1262bb5..df6e1e2 100644 --- a/src/components/home/StatsCard.tsx +++ b/src/components/home/StatsCard.tsx @@ -15,93 +15,102 @@ export const StatsCard: React.FC = ({ onWalletPress, }) => { return ( - + + {/* Monthly Spend Card - Primary Focus */} - - Total Monthly + accessibilityLabel={`Total monthly spend: ${formatCurrencyCompact(totalMonthlySpend)}`}> + + Monthly Spend + accessibilityElementsHidden={true}> {formatCurrencyCompact(totalMonthlySpend)} + + {/* Active Count Card */} - - Active Subs + accessibilityLabel={`Active subscriptions: ${totalActive}`}> + + Active - + {totalActive} - - - Wallet - - 🔗 - - - + + {/* Wallet Action Card */} + + Wallet + + 🔗 + + ); }; const styles = StyleSheet.create({ - statsContainer: { + container: { flexDirection: 'row', paddingHorizontal: spacing.lg, - marginBottom: spacing.lg, - gap: spacing.md, - flexWrap: 'wrap', + marginVertical: spacing.md, + gap: spacing.sm, }, - statCard: { + card: { flex: 1, - minWidth: 100, backgroundColor: colors.surface, - padding: spacing.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.sm, borderRadius: borderRadius.lg, alignItems: 'center', justifyContent: 'center', - minHeight: 80, + minHeight: 90, + borderWidth: 1, + borderColor: colors.border, ...shadows.sm, }, - statLabel: { + primaryCard: { + flex: 1.2, // Give the spending card a bit more visual weight + borderColor: colors.primary + '30', // Subtle primary tint + backgroundColor: colors.surface, + }, + walletCard: { + backgroundColor: colors.accent + '10', // Very light tint of accent + borderColor: colors.accent + '30', + }, + label: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs, - textAlign: 'center', + textTransform: 'uppercase', + letterSpacing: 0.5, + fontSize: 10, + fontWeight: '600', }, - statValue: { - fontSize: 18, - fontWeight: 'bold', + value: { + ...typography.h3, color: colors.text, textAlign: 'center', - lineHeight: 22, - minHeight: 22, + }, + primaryValue: { + color: colors.primary, + fontWeight: '800', + }, + icon: { + fontSize: 20, + marginTop: 2, }, }); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 7bdc119..61ac166 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -6,6 +6,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import HomeScreen from '../screens/HomeScreen'; import AddSubscriptionScreen from '../screens/AddSubscriptionScreen'; +import CancellationFlowScreen from '../screens/CancellationFlowScreen'; import WalletConnectScreen from '../screens/WalletConnectV2Screen'; import CryptoPaymentScreen from '../screens/CryptoPaymentScreen'; import CommunityScreen from '../screens/CommunityScreen'; @@ -39,6 +40,11 @@ const HomeStack = () => ( component={AddSubscriptionScreen} options={{ headerShown: false }} /> + ; + +const CancellationFlowScreen: React.FC = ({ route, navigation }) => { + const { currentStep, setReason, setStep, acceptOffer, reset } = useCancellationStore(); + + const { subscriptionId } = route.params; + + useEffect(() => { + return () => reset(); + }, [reset]); + + const handleFinalSuccess = () => { + navigation.popToTop(); + }; + + const renderStep = () => { + switch (currentStep) { + case 'REASON': + return ( + + Why are you leaving? + {['Too Expensive', 'Switching to Competitor', 'Technical Issues'].map((r) => ( +