diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bb1fa4..033eae0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,7 +270,7 @@ jobs: uses: actions/checkout@v4 - name: Run k6 Load Test - uses: grafana/k6-action@v0.5.0 + uses: grafana/k6-action@v0.5.1 with: filename: load-tests/run.js flags: --env SCENARIO=subscription diff --git a/app/screens/DisputeManagementScreen.tsx b/app/screens/DisputeManagementScreen.tsx new file mode 100644 index 0000000..733cd5c --- /dev/null +++ b/app/screens/DisputeManagementScreen.tsx @@ -0,0 +1 @@ +export { default } from '../../src/screens/DisputeManagementScreen'; \ No newline at end of file diff --git a/contracts/dispute/Cargo.toml b/contracts/dispute/Cargo.toml new file mode 100644 index 0000000..9768147 --- /dev/null +++ b/contracts/dispute/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "subtrackr-dispute" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = { workspace = true } +subtrackr-types = { path = "../types" } + +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 \ No newline at end of file diff --git a/contracts/dispute/src/lib.rs b/contracts/dispute/src/lib.rs new file mode 100644 index 0000000..8259bae --- /dev/null +++ b/contracts/dispute/src/lib.rs @@ -0,0 +1,808 @@ +// ════════════════════════════════════════════════════════════════ +// DISPUTE RESOLUTION SYSTEM - Handle chargebacks and payment disputes +// ════════════════════════════════════════════════════════════════ + +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, Address, BytesN, Env, IntoVal, String, Symbol, TryFromVal, + Val, Vec, +}; + +// ════════════════════════════════════════════════════════════════ +// DATA STRUCTURES +// ════════════════════════════════════════════════════════════════ + +#[derive(Clone)] +#[contracttype] +enum DataKey { + AdminOwners, + AdminThreshold, + AdminTimelockDelaySeconds, + AdminProposalSeq, + AdminProposal, + ContractVersion, + // Dispute-specific keys + Dispute(DisputeId), + DisputeCount, + DisputeByCharge(ChargeId), + DisputeTimeline(DisputeId), + DisputeEvidence(DisputeId), + DisputeAnalytics, +} + +/// Unique identifier for a dispute +#[derive(Clone)] +#[contracttype] +pub struct DisputeId { + pub inner: u64, +} + +/// Charge identifier from payment system +#[derive(Clone)] +#[contracttype] +pub struct ChargeId { + pub inner: String, +} + +impl DisputeId { + pub fn new(env: &Env, value: u64) -> Self { + DisputeId { inner: value } + } + + pub fn to_val(&self) -> Val { + self.inner.into_val(&Env::new()) + } +} + +impl ChargeId { + pub fn new(env: &Env, value: &str) -> Self { + ChargeId { + inner: String::from_str(env, value), + } + } +} + +/// Dispute status enum +#[derive(Clone, Copy, PartialEq, Eq)] +#[contracttype] +pub enum DisputeStatus { + /// Dispute has been created but not yet submitted + Pending = 0, + /// Evidence is being collected + GatheringEvidence = 1, + /// Evidence submitted, awaiting review + UnderReview = 2, + /// Awaiting manual review decision + AwaitingManualReview = 3, + /// Dispute has been resolved + Resolved = 4, + /// Dispute was rejected + Rejected = 5, + /// Dispute expired due to time limit + Expired = 6, +} + +/// Dispute resolution outcome +#[derive(Clone, Copy, PartialEq, Eq)] +#[contracttype] +pub enum Resolution { + /// Dispute won - customer gets refund + Refund = 0, + /// Original charge upheld - no refund + Upheld = 1, + /// Partial refund granted + PartialRefund = 2, + /// Counter-claim successful + Counter = 3, + /// Settlement reached between parties + Settlement = 4, +} + +/// Evidence type for dispute +#[derive(Clone, Copy, PartialEq, Eq)] +#[contracttype] +pub enum EvidenceType { + /// Proof of delivery + ProofOfDelivery = 0, + /// Communication records + Communication = 1, + /// Contract/terms documentation + Contract = 2, + /// Receipt or invoice + Receipt = 3, + /// Product/service description + ProductDescription = 4, + /// Customer interaction history + InteractionHistory = 5, + /// Other evidence + Other = 6, +} + +/// Evidence submitted for dispute +#[derive(Clone)] +#[contracttype] +pub struct Evidence { + /// Type of evidence + pub evidence_type: EvidenceType, + /// Description of the evidence + pub description: String, + /// URL or reference to evidence file + pub reference: String, + /// Timestamp when evidence was submitted + pub submitted_at: u64, + /// Who submitted the evidence + pub submitted_by: Address, +} + +/// Timeline event for dispute +#[derive(Clone)] +#[contracttype] +pub struct TimelineEvent { + /// Event type + pub event_type: TimelineEventType, + /// Description of the event + pub description: String, + /// Timestamp of the event + pub timestamp: u64, + /// Who triggered the event + pub triggered_by: Address, +} + +/// Timeline event types +#[derive(Clone, Copy, PartialEq, Eq)] +#[contracttype] +pub enum TimelineEventType { + Created = 0, + EvidenceSubmitted = 1, + StatusChanged = 2, + ManualReviewRequested = 3, + Resolved = 4, + Expired = 5, +} + +/// Main dispute struct +#[derive(Clone)] +#[contracttype] +pub struct Dispute { + /// Unique dispute identifier + pub dispute_id: DisputeId, + /// Charge ID this dispute is related to + pub charge_id: ChargeId, + /// Subscription ID (if applicable) + pub subscription_id: Option, + /// User who created the dispute + pub user: Address, + /// Reason for the dispute + pub reason: DisputeReason, + /// Current status of the dispute + pub status: DisputeStatus, + /// Evidence submitted for this dispute + pub evidence: Vec, + /// Resolution (if resolved) + pub resolution: Option, + /// Resolution notes + pub resolution_notes: Option, + /// When the dispute was created + pub created_at: u64, + /// When the dispute was last updated + pub updated_at: u64, + /// Deadline for submitting evidence + pub evidence_deadline: u64, + /// Resolution timestamp (if resolved) + pub resolved_at: Option, +} + +/// Dispute reasons +#[derive(Clone, Copy, PartialEq, Eq)] +#[contracttype] +pub enum DisputeReason { + /// Product/service not as described + NotAsDescribed = 0, + /// Product/service not received + NotReceived = 0, + /// Unauthorized charge + Unauthorized = 1, + /// Duplicate charge + Duplicate = 2, + /// Incorrect amount charged + IncorrectAmount = 3, + /// Subscription cancelled but charged + CancelledSubscription = 4, + /// Refund not processed + RefundNotProcessed = 5, + /// Other reason + Other = 6, +} + +/// Dispute analytics data +#[derive(Clone)] +#[contracttype] +pub struct DisputeAnalytics { + /// Total disputes filed + pub total_disputes: u64, + /// Disputes won (refund) + pub disputes_won: u64, + /// Disputes lost (upheld) + pub disputes_lost: u64, + /// Disputes settled + pub disputes_settled: u64, + /// Pending disputes + pub pending_disputes: u64, + /// Average resolution time in seconds + pub avg_resolution_time: u64, + /// Total amount disputed + pub total_amount_disputed: u64, + /// Total amount refunded + pub total_amount_refunded: u64, +} + +/// Admin action for dispute management +#[derive(Clone)] +#[contracttype] +pub enum AdminAction { + AddOwner(Address), + RemoveOwner(Address), + SetThreshold(u32), + SetTimelockDelaySeconds(u64), +} + +/// Admin proposal for dispute review +#[derive(Clone)] +#[contracttype] +pub struct AdminProposal { + pub id: u64, + pub action: AdminAction, + pub created_at: u64, + pub execute_after: u64, + pub approvals: Vec
, +} + +// ════════════════════════════════════════════════════════════════ +// CONSTANTS +// ════════════════════════════════════════════════════════════════ + +/// Default evidence submission deadline (7 days in seconds) +const DEFAULT_EVIDENCE_DEADLINE_SECS: u64 = 604800; + +/// Maximum evidence items per dispute +const MAX_EVIDENCE_PER_DISPUTE: u32 = 20; + +/// Dispute analytics storage key +struct DisputeAnalyticsKey; + +// ════════════════════════════════════════════════════════════════ +// CONTRACT IMPLEMENTATION +// ════════════════════════════════════════════════════════════════ + +#[contract] +pub struct SubTrackrDispute; + +#[contractimpl] +impl SubTrackrDispute { + /// Initialize the dispute contract with an admin. + pub fn init(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::AdminOwners) { + panic!("already initialized"); + } + + admin.require_auth(); + let mut owners: Vec
= Vec::new(&env); + owners.push_back(admin.clone()); + env.storage().instance().set(&DataKey::AdminOwners, &owners); + env.storage().instance().set(&DataKey::AdminThreshold, &1u32); + env.storage() + .instance() + .set(&DataKey::AdminTimelockDelaySeconds, &0u64); + env.storage().instance().set(&DataKey::AdminProposalSeq, &0u64); + env.storage().instance().set(&DataKey::ContractVersion, &1u32); + + // Initialize dispute analytics + let analytics = DisputeAnalytics { + total_disputes: 0, + disputes_won: 0, + disputes_lost: 0, + disputes_settled: 0, + pending_disputes: 0, + avg_resolution_time: 0, + total_amount_disputed: 0, + total_amount_refunded: 0, + }; + env.storage() + .instance() + .set(&DataKey::DisputeAnalytics, &analytics); + + env.events() + .publish(Symbol::new(&env, "dispute_contract_initialized"), admin); + } + + /// Create a new dispute from a chargeback notification. + /// + /// # Arguments + /// * `env` - Soroban environment + /// * `charge_id` - The charge ID to dispute + /// * `reason` - The reason for the dispute + /// * `subscription_id` - Optional subscription ID + /// * `user` - The user creating the dispute + /// + /// # Returns + /// * `DisputeId` - The created dispute's ID + pub fn create_dispute( + env: Env, + charge_id: String, + reason: DisputeReason, + subscription_id: Option, + user: Address, + ) -> DisputeId { + user.require_auth(); + + // Generate dispute ID + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::DisputeCount) + .unwrap_or(0); + count += 1; + let dispute_id = DisputeId::new(&env, count); + + // Store dispute count + env.storage() + .instance() + .set(&DataKey::DisputeCount, &count); + + // Create charge ID + let charge = ChargeId::new(&env, &charge_id); + + // Calculate evidence deadline (7 days from now) + let now = env.ledger().timestamp(); + let evidence_deadline = now + DEFAULT_EVIDENCE_DEADLINE_SECS; + + // Create the dispute + let dispute = Dispute { + dispute_id: dispute_id.clone(), + charge_id: charge, + subscription_id: subscription_id.clone(), + user: user.clone(), + reason, + status: DisputeStatus::Pending, + evidence: Vec::new(&env), + resolution: None, + resolution_notes: None, + created_at: now, + updated_at: now, + evidence_deadline, + resolved_at: None, + }; + + // Store the dispute + env.storage() + .instance() + .set(&DataKey::Dispute(dispute_id.clone()), &dispute); + + // Index by charge ID + env.storage() + .instance() + .set(&DataKey::DisputeByCharge(charge), &dispute_id); + + // Initialize timeline + let mut timeline: Vec = Vec::new(&env); + timeline.push_back(TimelineEvent { + event_type: TimelineEventType::Created, + description: String::from_str(&env, "Dispute created"), + timestamp: now, + triggered_by: user, + }); + env.storage() + .instance() + .set(&DataKey::DisputeTimeline(dispute_id.clone()), &timeline); + + // Update analytics + Self::update_analytics(&env, true, 0, 0); + + // Emit event + env.events().publish( + Symbol::new(&env, "dispute_created"), + (dispute_id.clone(), charge_id, reason), + ); + + dispute_id + } + + /// Submit evidence for a dispute. + /// + /// # Arguments + /// * `env` - Soroban environment + /// * `dispute_id` - The dispute ID + /// * `evidence_type` - Type of evidence + /// * `description` - Evidence description + /// * `reference` - Evidence reference/URL + /// * `submitter` - Who is submitting the evidence + pub fn submit_evidence( + env: Env, + dispute_id: DisputeId, + evidence_type: EvidenceType, + description: String, + reference: String, + submitter: Address, + ) -> Result<(), String> { + submitter.require_auth(); + + // Get the dispute + let key = DataKey::Dispute(dispute_id.clone()); + let mut dispute: Dispute = env + .storage() + .instance() + .get(&key) + .ok_or("Dispute not found")?; + + // Check if dispute is still active + if dispute.status == DisputeStatus::Resolved + || dispute.status == DisputeStatus::Rejected + || dispute.status == DisputeStatus::Expired + { + return Err(String::from_str(&env, "Cannot submit evidence to resolved dispute")); + } + + // Check evidence deadline + let now = env.ledger().timestamp(); + if now > dispute.evidence_deadline { + // Mark as expired + dispute.status = DisputeStatus::Expired; + dispute.updated_at = now; + env.storage().instance().set(&key, &dispute); + return Err(String::from_str(&env, "Evidence submission deadline passed")); + } + + // Check max evidence limit + if dispute.evidence.len() >= MAX_EVIDENCE_PER_DISPUTE { + return Err(String::from_str(&env, "Maximum evidence limit reached")); + } + + // Create evidence + let evidence = Evidence { + evidence_type, + description, + reference, + submitted_at: now, + submitted_by: submitter.clone(), + }; + + // Add evidence to dispute + dispute.evidence.push_back(evidence); + dispute.status = DisputeStatus::GatheringEvidence; + dispute.updated_at = now; + + // Store updated dispute + env.storage().instance().set(&key, &dispute); + + // Update timeline + let timeline_key = DataKey::DisputeTimeline(dispute_id.clone()); + let mut timeline: Vec = env + .storage() + .instance() + .get(&timeline_key) + .unwrap_or(Vec::new(&env)); + + timeline.push_back(TimelineEvent { + event_type: TimelineEventType::EvidenceSubmitted, + description: String::from_str(&env, "Evidence submitted"), + timestamp: now, + triggered_by: submitter, + }); + env.storage() + .instance() + .set(&timeline_key, &timeline); + + // Emit event + env.events().publish( + Symbol::new(&env, "evidence_submitted"), + (dispute_id, evidence_type), + ); + + Ok(()) + } + + /// Request manual review for a dispute. + /// + /// # Arguments + /// * `env` - Soroban environment + /// * `dispute_id` - The dispute ID + /// * `requester` - Who is requesting the review + pub fn request_manual_review( + env: Env, + dispute_id: DisputeId, + requester: Address, + ) -> Result<(), String> { + requester.require_auth(); + + // Get the dispute + let key = DataKey::Dispute(dispute_id.clone()); + let mut dispute: Dispute = env + .storage() + .instance() + .get(&key) + .ok_or("Dispute not found")?; + + // Check if dispute can be reviewed + if dispute.status == DisputeStatus::Resolved + || dispute.status == DisputeStatus::Rejected + || dispute.status == DisputeStatus::Expired + { + return Err(String::from_str(&env, "Cannot request review for resolved dispute")); + } + + // Update status + dispute.status = DisputeStatus::AwaitingManualReview; + dispute.updated_at = env.ledger().timestamp(); + + // Store updated dispute + env.storage().instance().set(&key, &dispute); + + // Update timeline + let timeline_key = DataKey::DisputeTimeline(dispute_id.clone()); + let mut timeline: Vec = env + .storage() + .instance() + .get(&timeline_key) + .unwrap_or(Vec::new(&env)); + + timeline.push_back(TimelineEvent { + event_type: TimelineEventType::ManualReviewRequested, + description: String::from_str(&env, "Manual review requested"), + timestamp: env.ledger().timestamp(), + triggered_by: requester, + }); + env.storage() + .instance() + .set(&timeline_key, &timeline); + + // Emit event + env.events().publish( + Symbol::new(&env, "manual_review_requested"), + dispute_id, + ); + + Ok(()) + } + + /// Resolve a dispute with the given resolution. + /// + /// # Arguments + /// * `env` - Soroban environment + /// * `dispute_id` - The dispute ID + /// * `resolution` - The resolution outcome + /// * `resolution_notes` - Notes about the resolution + /// * `resolver` - Who is resolving the dispute + pub fn resolve_dispute( + env: Env, + dispute_id: DisputeId, + resolution: Resolution, + resolution_notes: Option, + resolver: Address, + ) -> Result<(), String> { + // Get the dispute + let key = DataKey::Dispute(dispute_id.clone()); + let mut dispute: Dispute = env + .storage() + .instance() + .get(&key) + .ok_or("Dispute not found")?; + + // Check if dispute can be resolved + if dispute.status == DisputeStatus::Resolved + || dispute.status == DisputeStatus::Rejected + || dispute.status == DisputeStatus::Expired + { + return Err(String::from_str(&env, "Dispute already resolved")); + } + + let now = env.ledger().timestamp(); + + // Update dispute + dispute.status = DisputeStatus::Resolved; + dispute.resolution = Some(resolution); + dispute.resolution_notes = resolution_notes; + dispute.updated_at = now; + dispute.resolved_at = Some(now); + + // Store updated dispute + env.storage().instance().set(&key, &dispute); + + // Update timeline + let timeline_key = DataKey::DisputeTimeline(dispute_id.clone()); + let mut timeline: Vec = env + .storage() + .instance() + .get(&timeline_key) + .unwrap_or(Vec::new(&env)); + + timeline.push_back(TimelineEvent { + event_type: TimelineEventType::Resolved, + description: String::from_str(&env, "Dispute resolved"), + timestamp: now, + triggered_by: resolver, + }); + env.storage() + .instance() + .set(&timeline_key, &timeline); + + // Update analytics based on resolution + let (won, lost, settled) = match resolution { + Resolution::Refund => (true, false, false), + Resolution::Upheld => (false, true, false), + Resolution::PartialRefund => (true, false, true), + Resolution::Counter => (true, false, false), + Resolution::Settlement => (false, false, true), + }; + + Self::update_analytics(&env, false, won, settled); + + // Emit event + env.events().publish( + Symbol::new(&env, "dispute_resolved"), + (dispute_id, resolution), + ); + + Ok(()) + } + + /// Get a dispute by ID. + pub fn get_dispute(env: Env, dispute_id: DisputeId) -> Option { + env.storage() + .instance() + .get(&DataKey::Dispute(dispute_id)) + } + + /// Get dispute by charge ID. + pub fn get_dispute_by_charge(env: Env, charge_id: String) -> Option { + let charge = ChargeId::new(&env, &charge_id); + env.storage() + .instance() + .get(&DataKey::DisputeByCharge(charge)) + } + + /// Get dispute timeline. + pub fn get_timeline(env: Env, dispute_id: DisputeId) -> Vec { + env.storage() + .instance() + .get(&DataKey::DisputeTimeline(dispute_id)) + .unwrap_or(Vec::new(&env)) + } + + /// Get dispute evidence. + pub fn get_evidence(env: Env, dispute_id: DisputeId) -> Vec { + let dispute: Option = env + .storage() + .instance() + .get(&DataKey::Dispute(dispute_id)); + dispute.map(|d| d.evidence).unwrap_or(Vec::new(&env)) + } + + /// Get all disputes for a user. + pub fn get_user_disputes(env: Env, user: Address) -> Vec { + // This would require iterating through all disputes + // In production, you'd want an index by user + Vec::new(&env) + } + + /// Get dispute analytics. + pub fn get_analytics(env: Env) -> DisputeAnalytics { + env.storage() + .instance() + .get(&DataKey::DisputeAnalytics) + .unwrap_or(DisputeAnalytics { + total_disputes: 0, + disputes_won: 0, + disputes_lost: 0, + disputes_settled: 0, + pending_disputes: 0, + avg_resolution_time: 0, + total_amount_disputed: 0, + total_amount_refunded: 0, + }) + } + + /// Check and update expired disputes. + pub fn check_expired_disputes(env: Env) -> u32 { + let count: u64 = env + .storage() + .instance() + .get(&DataKey::DisputeCount) + .unwrap_or(0); + + let mut expired_count = 0; + let now = env.ledger().timestamp(); + + for i in 1..=count { + let dispute_id = DisputeId::new(&env, i); + let key = DataKey::Dispute(dispute_id.clone()); + + if let Some(mut dispute) = env.storage().instance().get::<_, Dispute>(&key) { + if dispute.status != DisputeStatus::Resolved + && dispute.status != DisputeStatus::Rejected + && dispute.status != DisputeStatus::Expired + && now > dispute.evidence_deadline + { + dispute.status = DisputeStatus::Expired; + dispute.updated_at = now; + env.storage().instance().set(&key, &dispute); + expired_count += 1; + } + } + } + + // Update analytics + Self::update_analytics(&env, false, false, false); + + expired_count + } + + /// Update dispute analytics. + fn update_analytics(env: &Env, new_dispute: bool, won: bool, settled: bool) { + let mut analytics: DisputeAnalytics = env + .storage() + .instance() + .get(&DataKey::DisputeAnalytics) + .unwrap_or(DisputeAnalytics { + total_disputes: 0, + disputes_won: 0, + disputes_lost: 0, + disputes_settled: 0, + pending_disputes: 0, + avg_resolution_time: 0, + total_amount_disputed: 0, + total_amount_refunded: 0, + }); + + if new_dispute { + analytics.total_disputes += 1; + analytics.pending_disputes += 1; + } else { + // Count pending + let count: u64 = env + .storage() + .instance() + .get(&DataKey::DisputeCount) + .unwrap_or(0); + + let mut pending = 0u64; + for i in 1..=count { + let dispute_id = DisputeId::new(env, i); + let key = DataKey::Dispute(dispute_id); + + if let Some(dispute) = env.storage().instance().get::<_, Dispute>(&key) { + if dispute.status != DisputeStatus::Resolved + && dispute.status != DisputeStatus::Rejected + && dispute.status != DisputeStatus::Expired + { + pending += 1; + } + } + } + analytics.pending_disputes = pending; + + if won { + analytics.disputes_won += 1; + } + if settled { + analytics.disputes_settled += 1; + } + if !won && !settled && analytics.pending_disputes == 0 { + // Must have been lost + analytics.disputes_lost += 1; + } + } + + env.storage() + .instance() + .set(&DataKey::DisputeAnalytics, &analytics); + } + + /// Get dispute count. + pub fn get_dispute_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::DisputeCount) + .unwrap_or(0) + } +} \ No newline at end of file diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index d1850e1..ae9aec9 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -21,6 +21,8 @@ import SettingsScreen from '../screens/SettingsScreen'; import AccountingExportScreen from '../screens/AccountingExportScreen'; import WebhookSettingsScreen from '../screens/WebhookSettingsScreen'; import ErrorDashboardScreen from '../screens/ErrorDashboardScreen'; +import ImportScreen from '../screens/ImportScreen'; +import ExportScreen from '../screens/ExportScreen'; import AdminDashboardScreen from '../screens/AdminDashboardScreen'; import InvoiceListScreen from '../screens/InvoiceListScreen'; import InvoiceDetailScreen from '../screens/InvoiceDetailScreen'; @@ -160,6 +162,61 @@ const SettingsStack = () => ( component={ErrorDashboardScreen} options={{ title: 'Error Dashboard', headerShown: true }} /> + + + + + + + + + + + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 194cc44..a75efed 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -20,6 +20,8 @@ export type RootStackParamList = { LanguageSettings: undefined; SessionManagement: undefined; ErrorDashboard: undefined; + Import: undefined; + Export: undefined; SegmentManagement: undefined; SegmentDetail: { segmentId: string }; Gamification: undefined; diff --git a/src/screens/DisputeManagementScreen.tsx b/src/screens/DisputeManagementScreen.tsx new file mode 100644 index 0000000..f70e48b --- /dev/null +++ b/src/screens/DisputeManagementScreen.tsx @@ -0,0 +1,1240 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + RefreshControl, + TouchableOpacity, + Modal, + TextInput, + Alert, + FlatList, + ActivityIndicator, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { useDisputeStore, Dispute, DisputeStatus, DisputeReason, Resolution, EvidenceType, EvidenceFormData, DisputeAnalytics } from '../store/disputeStore'; +import { RootStackParamList } from '../navigation/types'; + +type HomeNavigationProp = NativeStackNavigationProp; + +// ════════════════════════════════════════════════════════════════ +// COMPONENTS +// ════════════════════════════════════════════════════════════════ + +/** Status badge component */ +const StatusBadge: React.FC<{ status: DisputeStatus }> = ({ status }) => { + const getStatusColor = () => { + switch (status) { + case DisputeStatus.Pending: + return colors.warning; + case DisputeStatus.GatheringEvidence: + return colors.info; + case DisputeStatus.UnderReview: + return colors.primary; + case DisputeStatus.AwaitingManualReview: + return colors.secondary; + case DisputeStatus.Resolved: + return colors.success; + case DisputeStatus.Rejected: + return colors.error; + case DisputeStatus.Expired: + return colors.muted; + default: + return colors.muted; + } + }; + + const getStatusLabel = () => { + return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + }; + + return ( + + + {getStatusLabel()} + + + ); +}; + +/** Reason badge component */ +const ReasonBadge: React.FC<{ reason: DisputeReason }> = ({ reason }) => { + const getReasonLabel = () => { + const labels: Record = { + [DisputeReason.NotAsDescribed]: 'Not as Described', + [DisputeReason.NotReceived]: 'Not Received', + [DisputeReason.Unauthorized]: 'Unauthorized', + [DisputeReason.Duplicate]: 'Duplicate', + [DisputeReason.IncorrectAmount]: 'Incorrect Amount', + [DisputeReason.CancelledSubscription]: 'Cancelled', + [DisputeReason.RefundNotProcessed]: 'Refund Missing', + [DisputeReason.Other]: 'Other', + }; + return labels[reason] || reason; + }; + + return ( + + {getReasonLabel()} + + ); +}; + +/** Analytics card component */ +const AnalyticsCard: React.FC<{ analytics: DisputeAnalytics }> = ({ analytics }) => { + return ( + + Dispute Analytics + + + {analytics.totalDisputes} + Total + + + + {analytics.pendingDisputes} + + Pending + + + + {analytics.disputesWon} + + Won + + + + {analytics.disputesLost} + + Lost + + + + + Total Disputed: ${analytics.totalAmountDisputed.toFixed(2)} + + + Refunded: ${analytics.totalAmountRefunded.toFixed(2)} + + + + ); +}; + +/** Dispute item component */ +const DisputeItem: React.FC<{ + dispute: Dispute; + onPress: (dispute: Dispute) => void; +}> = ({ dispute, onPress }) => { + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; + + const getDaysRemaining = () => { + const now = new Date(); + const deadline = new Date(dispute.evidenceDeadline); + const diff = deadline.getTime() - now.getTime(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); + }; + + const daysRemaining = getDaysRemaining(); + + return ( + onPress(dispute)} + accessibilityRole="button" + accessibilityLabel={`Dispute for charge ${dispute.chargeId}`}> + + + + {dispute.chargeId} + + + Created: {formatDate(dispute.createdAt)} + + + + + ${dispute.amount.toFixed(2)} + + {dispute.currency} + + + + + + + {dispute.status !== DisputeStatus.Resolved && + dispute.status !== DisputeStatus.Rejected && + dispute.status !== DisputeStatus.Expired && ( + + + {daysRemaining > 0 + ? `${daysRemaining} days to submit evidence` + : 'Evidence deadline passed'} + + + )} + + ); +}; + +/** Evidence form component */ +const EvidenceForm: React.FC<{ + visible: boolean; + onClose: () => void; + onSubmit: (evidence: EvidenceFormData) => void; +}> = ({ visible, onClose, onSubmit }) => { + const [evidenceType, setEvidenceType] = useState(EvidenceType.Receipt); + const [description, setDescription] = useState(''); + const [reference, setReference] = useState(''); + + const handleSubmit = () => { + if (!description.trim()) { + Alert.alert('Error', 'Please provide a description'); + return; + } + + onSubmit({ + evidenceType, + description: description.trim(), + reference: reference.trim(), + }); + + // Reset form + setEvidenceType(EvidenceType.Receipt); + setDescription(''); + setReference(''); + onClose(); + }; + + return ( + + + + + Cancel + + Submit Evidence + + Submit + + + + Evidence Type + + {Object.values(EvidenceType).map((type) => ( + setEvidenceType(type)}> + + {type.replace(/_/g, ' ')} + + + ))} + + Description + + Reference/URL + + + + + ); +}; + +/** Create dispute form component */ +const CreateDisputeForm: React.FC<{ + visible: boolean; + onClose: () => void; + onSubmit: (data: { + chargeId: string; + reason: DisputeReason; + amount: number; + currency: string; + subscriptionId?: string; + }) => void; +}> = ({ visible, onClose, onSubmit }) => { + const [chargeId, setChargeId] = useState(''); + const [reason, setReason] = useState(DisputeReason.Other); + const [amount, setAmount] = useState(''); + const [currency, setCurrency] = useState('USD'); + + const handleSubmit = () => { + if (!chargeId.trim()) { + Alert.alert('Error', 'Please enter a charge ID'); + return; + } + if (!amount.trim() || isNaN(Number(amount))) { + Alert.alert('Error', 'Please enter a valid amount'); + return; + } + + onSubmit({ + chargeId: chargeId.trim(), + reason, + amount: Number(amount), + currency, + }); + + // Reset form + setChargeId(''); + setReason(DisputeReason.Other); + setAmount(''); + setCurrency('USD'); + onClose(); + }; + + return ( + + + + + Cancel + + Create Dispute + + Create + + + + Charge ID * + + Reason * + + {Object.values(DisputeReason).map((r) => ( + setReason(r)}> + + {r.replace(/_/g, ' ')} + + + ))} + + Amount * + + Currency + + + + + ); +}; + +/** Resolution form component */ +const ResolutionForm: React.FC<{ + visible: boolean; + onClose: () => void; + onSubmit: (resolution: Resolution, notes?: string) => void; +}> = ({ visible, onClose, onSubmit }) => { + const [resolution, setResolution] = useState(Resolution.Refund); + const [notes, setNotes] = useState(''); + + const handleSubmit = () => { + onSubmit(resolution, notes.trim() || undefined); + setResolution(Resolution.Refund); + setNotes(''); + onClose(); + }; + + return ( + + + + + Cancel + + Resolve Dispute + + Resolve + + + + Resolution * + + {Object.values(Resolution).map((r) => ( + setResolution(r)}> + + {r.charAt(0).toUpperCase() + r.slice(1).replace(/_/g, ' ')} + + + ))} + + Notes (Optional) + + + + + ); +}; + +/** Dispute detail modal */ +const DisputeDetailModal: React.FC<{ + visible: boolean; + dispute: Dispute | null; + onClose: () => void; + onSubmitEvidence: () => void; + onRequestReview: () => void; + onResolve: () => void; +}> = ({ visible, dispute, onClose, onSubmitEvidence, onRequestReview, onResolve }) => { + if (!dispute) return null; + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( + + + + Dispute Details + + Close + + + + + Charge Information + Charge ID: {dispute.chargeId} + + Amount: ${dispute.amount.toFixed(2)} {dispute.currency} + + {dispute.subscriptionId && ( + + Subscription: {dispute.subscriptionName || dispute.subscriptionId} + + )} + + + + Status + + + + + + + + Timeline + {dispute.timeline.map((event, index) => ( + + + + {event.description} + {formatDate(event.timestamp)} + + + ))} + + + {dispute.evidence.length > 0 && ( + + + Evidence ({dispute.evidence.length}) + + {dispute.evidence.map((ev) => ( + + + {ev.evidenceType.replace(/_/g, ' ')} + + {ev.description} + {ev.reference && ( + {ev.reference} + )} + + Submitted: {formatDate(ev.submittedAt)} + + + ))} + + )} + + {dispute.resolution && ( + + Resolution + + Outcome: {dispute.resolution.charAt(0).toUpperCase() + dispute.resolution.slice(1)} + + {dispute.resolutionNotes && ( + Notes: {dispute.resolutionNotes} + )} + {dispute.resolvedAt && ( + + Resolved: {formatDate(dispute.resolvedAt)} + + )} + + )} + + + {dispute.status !== DisputeStatus.Resolved && + dispute.status !== DisputeStatus.Rejected && + dispute.status !== DisputeStatus.Expired && ( + + + Submit Evidence + + + + Request Review + + + + + Resolve + + + + )} + + + ); +}; + +// ════════════════════════════════════════════════════════════════ +// MAIN SCREEN +// ════════════════════════════════════════════════════════════════ + +const DisputeManagementScreen: React.FC = () => { + const navigation = useNavigation(); + const { + disputes, + analytics, + createDispute, + submitEvidence, + requestManualReview, + resolveDispute, + updateAnalytics, + } = useDisputeStore(); + + const [refreshing, setRefreshing] = useState(false); + const [selectedDispute, setSelectedDispute] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + const [showEvidenceForm, setShowEvidenceForm] = useState(false); + const [showResolutionForm, setShowResolutionForm] = useState(false); + const [filterStatus, setFilterStatus] = useState('all'); + + // Filter disputes + const filteredDisputes = useMemo(() => { + if (filterStatus === 'all') return disputes; + return disputes.filter((d) => d.status === filterStatus); + }, [disputes, filterStatus]); + + // Update analytics on mount + useEffect(() => { + updateAnalytics(); + }, [updateAnalytics]); + + const onRefresh = async () => { + setRefreshing(true); + updateAnalytics(); + setRefreshing(false); + }; + + const handleCreateDispute = async (data: { + chargeId: string; + reason: DisputeReason; + amount: number; + currency: string; + subscriptionId?: string; + }) => { + try { + await createDispute(data, 'current-user'); + Alert.alert('Success', 'Dispute created successfully'); + } catch (error) { + Alert.alert('Error', 'Failed to create dispute'); + } + }; + + const handleSubmitEvidence = async (evidence: EvidenceFormData) => { + if (!selectedDispute) return; + try { + await submitEvidence(selectedDispute.id, evidence, 'current-user'); + Alert.alert('Success', 'Evidence submitted successfully'); + setShowEvidenceForm(false); + } catch (error) { + Alert.alert('Error', 'Failed to submit evidence'); + } + }; + + const handleRequestReview = async () => { + if (!selectedDispute) return; + try { + await requestManualReview(selectedDispute.id, 'current-user'); + Alert.alert('Success', 'Manual review requested'); + } catch (error) { + Alert.alert('Error', 'Failed to request review'); + } + }; + + const handleResolve = async (resolution: Resolution, notes?: string) => { + if (!selectedDispute) return; + try { + await resolveDispute(selectedDispute.id, resolution, notes, 'admin'); + Alert.alert('Success', 'Dispute resolved successfully'); + setShowResolutionForm(false); + setSelectedDispute(null); + } catch (error) { + Alert.alert('Error', 'Failed to resolve dispute'); + } + }; + + const handleDisputePress = (dispute: Dispute) => { + setSelectedDispute(dispute); + }; + + const renderDisputeItem = ({ item }: { item: Dispute }) => ( + + ); + + return ( + + + }> + + Dispute Management + setShowCreateForm(true)}> + + New + + + + {/* Analytics Card */} + + + {/* Filter */} + + + setFilterStatus('all')}> + + All + + + {Object.values(DisputeStatus).map((status) => ( + setFilterStatus(status)}> + + {status.replace(/_/g, ' ')} + + + ))} + + + + {/* Dispute List */} + + + Disputes ({filteredDisputes.length}) + + {filteredDisputes.length === 0 ? ( + + No disputes found + + Create a new dispute to get started + + + ) : ( + filteredDisputes.map((dispute) => ( + + )) + )} + + + + {/* Modals */} + setShowCreateForm(false)} + onSubmit={handleCreateDispute} + /> + + setSelectedDispute(null)} + onSubmitEvidence={() => setShowEvidenceForm(true)} + onRequestReview={handleRequestReview} + onResolve={() => setShowResolutionForm(true)} + /> + + setShowEvidenceForm(false)} + onSubmit={handleSubmitEvidence} + /> + + setShowResolutionForm(false)} + onSubmit={handleResolve} + /> + + ); +}; + +// ════════════════════════════════════════════════════════════════ +// STYLES +// ════════════════════════════════════════════════════════════════ + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + scrollView: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + title: { + fontSize: typography.xl, + fontWeight: 'bold', + color: colors.text, + }, + addButton: { + backgroundColor: colors.primary, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + }, + addButtonText: { + color: colors.white, + fontWeight: '600', + }, + analyticsCard: { + backgroundColor: colors.card, + marginHorizontal: spacing.lg, + marginVertical: spacing.md, + padding: spacing.lg, + borderRadius: borderRadius.lg, + shadowColor: colors.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + analyticsTitle: { + fontSize: typography.lg, + fontWeight: '600', + color: colors.text, + marginBottom: spacing.md, + }, + analyticsGrid: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: spacing.md, + }, + analyticsItem: { + alignItems: 'center', + }, + analyticsValue: { + fontSize: typography.xl, + fontWeight: 'bold', + color: colors.text, + }, + analyticsLabel: { + fontSize: typography.sm, + color: colors.muted, + marginTop: spacing.xs, + }, + analyticsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + analyticsText: { + fontSize: typography.sm, + color: colors.muted, + }, + filterContainer: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + filterButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + marginRight: spacing.sm, + borderRadius: borderRadius.md, + backgroundColor: colors.card, + }, + filterButtonActive: { + backgroundColor: colors.primary, + }, + filterText: { + fontSize: typography.sm, + color: colors.muted, + }, + filterTextActive: { + color: colors.white, + fontWeight: '600', + }, + listContainer: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + listTitle: { + fontSize: typography.lg, + fontWeight: '600', + color: colors.text, + marginBottom: spacing.md, + }, + emptyState: { + alignItems: 'center', + paddingVertical: spacing.xl, + }, + emptyText: { + fontSize: typography.lg, + color: colors.muted, + }, + emptySubtext: { + fontSize: typography.sm, + color: colors.muted, + marginTop: spacing.xs, + }, + disputeItem: { + backgroundColor: colors.card, + padding: spacing.md, + borderRadius: borderRadius.lg, + marginBottom: spacing.md, + }, + disputeItemHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: spacing.sm, + }, + disputeItemInfo: { + flex: 1, + }, + disputeItemCharge: { + fontSize: typography.md, + fontWeight: '600', + color: colors.text, + }, + disputeItemDate: { + fontSize: typography.sm, + color: colors.muted, + marginTop: spacing.xs, + }, + disputeItemAmount: { + alignItems: 'flex-end', + }, + disputeItemAmountText: { + fontSize: typography.lg, + fontWeight: 'bold', + color: colors.text, + }, + disputeItemCurrency: { + fontSize: typography.sm, + color: colors.muted, + }, + disputeItemBody: { + flexDirection: 'row', + gap: spacing.sm, + marginBottom: spacing.sm, + }, + disputeItemFooter: { + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + statusBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + }, + statusBadgeText: { + fontSize: typography.xs, + fontWeight: '600', + }, + reasonBadge: { + backgroundColor: colors.muted + '20', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + }, + reasonBadgeText: { + fontSize: typography.xs, + color: colors.text, + }, + daysRemaining: { + fontSize: typography.sm, + color: colors.muted, + }, + daysRemainingUrgent: { + color: colors.error, + }, + formModal: { + flex: 1, + backgroundColor: colors.background, + }, + formHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + formTitle: { + fontSize: typography.lg, + fontWeight: '600', + color: colors.text, + }, + formCancel: { + fontSize: typography.md, + color: colors.muted, + }, + formSubmit: { + fontSize: typography.md, + color: colors.primary, + fontWeight: '600', + }, + formContent: { + flex: 1, + padding: spacing.lg, + }, + formLabel: { + fontSize: typography.md, + fontWeight: '600', + color: colors.text, + marginBottom: spacing.sm, + marginTop: spacing.md, + }, + formInput: { + backgroundColor: colors.card, + padding: spacing.md, + borderRadius: borderRadius.md, + fontSize: typography.md, + color: colors.text, + borderWidth: 1, + borderColor: colors.border, + }, + evidenceTypeGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + evidenceTypeButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + }, + evidenceTypeButtonSelected: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + evidenceTypeText: { + fontSize: typography.sm, + color: colors.text, + }, + evidenceTypeTextSelected: { + color: colors.white, + }, + reasonGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + reasonButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + }, + reasonButtonSelected: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + reasonText: { + fontSize: typography.sm, + color: colors.text, + }, + reasonTextSelected: { + color: colors.white, + }, + resolutionGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + resolutionButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + }, + resolutionButtonSelected: { + backgroundColor: colors.success, + borderColor: colors.success, + }, + resolutionText: { + fontSize: typography.sm, + color: colors.text, + }, + resolutionTextSelected: { + color: colors.white, + }, + detailModal: { + flex: 1, + backgroundColor: colors.background, + }, + detailHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + detailTitle: { + fontSize: typography.lg, + fontWeight: '600', + color: colors.text, + }, + detailClose: { + fontSize: typography.md, + color: colors.primary, + }, + detailContent: { + flex: 1, + padding: spacing.lg, + }, + detailSection: { + marginBottom: spacing.lg, + }, + detailSectionTitle: { + fontSize: typography.md, + fontWeight: '600', + color: colors.text, + marginBottom: spacing.sm, + }, + detailRow: { + flexDirection: 'row', + gap: spacing.sm, + }, + detailText: { + fontSize: typography.sm, + color: colors.text, + marginBottom: spacing.xs, + }, + timelineItem: { + flexDirection: 'row', + marginBottom: spacing.md, + }, + timelineDot: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: colors.primary, + marginTop: 4, + marginRight: spacing.md, + }, + timelineContent: { + flex: 1, + }, + timelineDescription: { + fontSize: typography.sm, + color: colors.text, + }, + timelineDate: { + fontSize: typography.xs, + color: colors.muted, + marginTop: spacing.xs, + }, + evidenceItem: { + backgroundColor: colors.card, + padding: spacing.md, + borderRadius: borderRadius.md, + marginBottom: spacing.sm, + }, + evidenceType: { + fontSize: typography.sm, + fontWeight: '600', + color: colors.primary, + }, + evidenceDescription: { + fontSize: typography.sm, + color: colors.text, + marginTop: spacing.xs, + }, + evidenceReference: { + fontSize: typography.xs, + color: colors.primary, + marginTop: spacing.xs, + }, + evidenceDate: { + fontSize: typography.xs, + color: colors.muted, + marginTop: spacing.xs, + }, + detailActions: { + flexDirection: 'row', + justifyContent: 'space-between', + padding: spacing.lg, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + detailActionButton: { + flex: 1, + backgroundColor: colors.primary, + padding: spacing.md, + borderRadius: borderRadius.md, + marginHorizontal: spacing.xs, + alignItems: 'center', + }, + detailActionText: { + color: colors.white, + fontWeight: '600', + fontSize: typography.sm, + }, + secondaryButton: { + backgroundColor: colors.secondary, + }, + secondaryText: { + color: colors.white, + }, + dangerButton: { + backgroundColor: colors.error, + }, + dangerText: { + color: colors.white, + }, +}); + +export default DisputeManagementScreen; \ No newline at end of file diff --git a/src/screens/ExportScreen.tsx b/src/screens/ExportScreen.tsx new file mode 100644 index 0000000..df35be6 --- /dev/null +++ b/src/screens/ExportScreen.tsx @@ -0,0 +1,511 @@ +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + ScrollView, + TouchableOpacity, + Alert, + Share, + ActivityIndicator, + Clipboard, + Platform, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { Button } from '../components/common/Button'; +import { Card } from '../components/common/Card'; +import { + generateCSV, + exportToJSON, + ExportData, + Subscription, +} from '../utils/importExport'; +import { useSubscriptionStore } from '../store'; + +type ExportScreenNavigationProp = NativeStackNavigationProp; + +type ExportFormat = 'json' | 'csv'; + +const ExportScreen: React.FC = () => { + const navigation = useNavigation(); + const { subscriptions } = useSubscriptionStore(); + + const [exportFormat, setExportFormat] = useState('json'); + const [isExporting, setIsExporting] = useState(false); + const [exportedData, setExportedData] = useState(null); + const [showPreview, setShowPreview] = useState(false); + + const handleExport = useCallback(async () => { + if (subscriptions.length === 0) { + Alert.alert('No Data', 'There are no subscriptions to export.'); + return; + } + + setIsExporting(true); + + try { + let data: string; + let preview: string; + + if (exportFormat === 'json') { + data = exportToJSON(subscriptions); + preview = JSON.stringify(JSON.parse(data), null, 2); + } else { + data = generateCSV(subscriptions); + preview = data; + } + + setExportedData(data); + setShowPreview(true); + + Alert.alert( + 'Export Ready', + `Exported ${subscriptions.length} subscription(s) as ${exportFormat.toUpperCase()}.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Share', + onPress: () => shareData(data), + }, + { + text: 'Copy to Clipboard', + onPress: () => copyToClipboard(data), + }, + ] + ); + } catch (error) { + Alert.alert( + 'Error', + error instanceof Error ? error.message : 'Failed to export data' + ); + } finally { + setIsExporting(false); + } + }, [subscriptions, exportFormat]); + + const shareData = async (data: string) => { + try { + await Share.share({ + message: data, + title: `SubTrackr Export (${exportFormat.toUpperCase()})`, + }); + } catch (error) { + Alert.alert('Error', 'Failed to share data'); + } + }; + + const copyToClipboard = (data: string) => { + Clipboard.setString(data); + Alert.alert('Copied', `${exportFormat.toUpperCase()} data copied to clipboard`); + }; + + const downloadFile = () => { + if (!exportedData) return; + + // In a real implementation, this would use a file system library + // like expo-file-system to save the file + Alert.alert( + 'Download', + 'In a production app, this would save the file to the device storage.', + [ + { text: 'OK' }, + ] + ); + }; + + const renderFormatSelector = () => ( + + Export Format + + setExportFormat('json')} + > + + JSON + + + Full data with metadata + + + setExportFormat('csv')} + > + + CSV + + + Spreadsheet compatible + + + + + ); + + const renderSubscriptionStats = () => ( + + Export Summary + + + {subscriptions.length} + Total Subscriptions + + + + {subscriptions.filter((s) => s.isActive).length} + + Active + + + + {subscriptions.filter((s) => !s.isActive).length} + + Paused + + + + By Category + {getCategoryStats().map((cat) => ( + + {cat.name} + {cat.count} + + ))} + + + ); + + const getCategoryStats = () => { + const categoryMap = new Map(); + subscriptions.forEach((sub) => { + const count = categoryMap.get(sub.category) || 0; + categoryMap.set(sub.category, count + 1); + }); + + return Array.from(categoryMap.entries()) + .map(([name, count]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), count })) + .sort((a, b) => b.count - a.count); + }; + + const renderPreview = () => { + if (!showPreview || !exportedData) return null; + + const previewText = exportedData.length > 500 + ? exportedData.substring(0, 500) + '...' + : exportedData; + + return ( + + + Preview + setShowPreview(false)}> + Hide + + + + {previewText} + + + ); + }; + + const renderActions = () => ( + +