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/src/components/common/SharedElement.tsx b/src/components/common/SharedElement.tsx index f3f4289..0044a03 100644 --- a/src/components/common/SharedElement.tsx +++ b/src/components/common/SharedElement.tsx @@ -9,6 +9,46 @@ export 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, 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 d1850e1..87fe682 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -7,6 +7,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { useTranslation } from 'react-i18next'; 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'; @@ -44,6 +45,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) => ( +