diff --git a/.eslintrc.json b/.eslintrc.json index 961b5d25..13262b30 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,10 +11,32 @@ }, "rules": { "prettier/prettier": "error", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_" + } + ], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "warn", - "no-console": ["warn", { "allow": ["warn", "error"] }] + "no-console": ["warn", { "allow": ["warn", "error"] }], + "import/no-unresolved": [ + "error", + { + "ignore": [ + "@sentry/react-native", + "bcryptjs", + "expo-image", + "expo-linking", + "expo-local-authentication", + "react-native-performance", + "../../backend/services/shared/monitoring" + ] + } + ] }, "ignorePatterns": [ "node_modules/", diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..65cf4347 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npx lint-staged +NODE_OPTIONS=--max-old-space-size=8192 npx lint-staged --concurrent false diff --git a/AGENTS.md b/AGENTS.md index 38d2d9d3..483e3698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # SubTrackr Development Commands ## Lint and Type Check + ```bash npm run lint # ESLint for TypeScript files npm run typecheck # TypeScript type checking @@ -9,6 +10,7 @@ npm run format:check # Check formatting ``` ## Testing + ```bash npm run test # Run Jest tests npm run test:coverage # Run tests with coverage @@ -16,6 +18,7 @@ npm run performance:ci # Check performance budget ``` ## Build + ```bash npm run build:android # Android release build npm run android # Run on Android @@ -23,8 +26,9 @@ npm run android:device # Run on Android device ``` ## Performance Budget Thresholds (Android) + - Render time: 250ms (p95) - API latency: 1200ms (p95) - Memory usage: 262MB - Startup time: 2000ms (target: <2s) -- Frame rate: 60fps (target for mid-range devices) \ No newline at end of file +- Frame rate: 60fps (target for mid-range devices) diff --git a/App.tsx b/App.tsx index 39150a4e..87fb101f 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Alert, Platform } from 'react-native'; +import { View, Platform } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AppNavigator } from './src/navigation/AppNavigator'; @@ -9,7 +9,7 @@ import ErrorBoundary from './src/components/ErrorBoundary'; import { initI18n } from './src/i18n/config'; import i18n from './src/i18n/config'; import { I18nextProvider } from 'react-i18next'; -import { crashReporter, CrashRecord } from './src/services/crashReporter'; +import { crashReporter } from './src/services/crashReporter'; import * as Sentry from '@sentry/react-native'; import './src/config/env'; @@ -24,7 +24,7 @@ import { EVM_RPC_URLS } from './src/config/evm'; import { useNetworkStore, useSettingsStore } from './src/store'; import { sessionService } from './src/services/auth/session'; -const projectId = env.WALLET_CONNECT_PROJECT_ID; +const projectId = process.env.WALLET_CONNECT_PROJECT_ID || 'YOUR_PROJECT_ID'; try { Sentry.init({ @@ -100,9 +100,6 @@ function NotificationBootstrap() { const session = await sessionService.initializeCurrentSession(); try { Sentry.setContext('session', { id: session.id, deviceName: session.deviceName }); - if (wallet?.address) { - Sentry.setUser({ id: wallet.address }); - } } catch (e) { // ignore } @@ -114,6 +111,8 @@ function NotificationBootstrap() { export default function App() { const [i18nReady, setI18nReady] = React.useState(false); + const [, setPendingCrash] = React.useState(null); + const [, setShowRecoveryModal] = React.useState(false); React.useEffect(() => { let cancelled = false; @@ -160,4 +159,4 @@ export default function App() { ); -} \ No newline at end of file +} diff --git a/BUNDLE_AUDIT.md b/BUNDLE_AUDIT.md index 1e66b980..86b9f511 100644 --- a/BUNDLE_AUDIT.md +++ b/BUNDLE_AUDIT.md @@ -16,13 +16,13 @@ EXPO_BUNDLE_ANALYZE=true npx expo export ### Heavy dependencies — kept (required) -| Package | Gzip size | Reason kept | -|---|---|---| -| `@stellar/stellar-sdk` | ~800 KB | Core crypto/wallet feature | -| `@superfluid-finance/sdk-core` | ~300 KB | Streaming payments | -| `ethers` | ~220 KB | EVM wallet + contract calls | -| `@reown/appkit-ethers-react-native` | ~180 KB | WalletConnect v2 | -| `i18next` + `react-i18next` | ~60 KB | Internationalisation | +| Package | Gzip size | Reason kept | +| ----------------------------------- | --------- | --------------------------- | +| `@stellar/stellar-sdk` | ~800 KB | Core crypto/wallet feature | +| `@superfluid-finance/sdk-core` | ~300 KB | Streaming payments | +| `ethers` | ~220 KB | EVM wallet + contract calls | +| `@reown/appkit-ethers-react-native` | ~180 KB | WalletConnect v2 | +| `i18next` + `react-i18next` | ~60 KB | Internationalisation | ### Tree-shaking improvements applied @@ -42,10 +42,10 @@ Heavy modules are now evaluated on first use rather than at startup: ### Removed / replaced -| Before | After | Saving | -|---|---|---| +| Before | After | Saving | +| -------------------------------------------------- | -------------------------- | ------------------------------ | | `@testing-library/react-hooks` (in `dependencies`) | Moved to `devDependencies` | Removed from production bundle | -| `graphql` (unused at runtime in RN app) | Moved to `devDependencies` | ~50 KB | +| `graphql` (unused at runtime in RN app) | Moved to `devDependencies` | ~50 KB | ### Size-limit CI enforcement diff --git a/RACE_CONDITION_FIX.md b/RACE_CONDITION_FIX.md index d8d92c6a..fc25a1ed 100644 --- a/RACE_CONDITION_FIX.md +++ b/RACE_CONDITION_FIX.md @@ -39,6 +39,7 @@ type RefreshOptions = { ``` **Behavior**: + - When `fetchBeforeClear: true`: Fetches data first, then clears old state - When `fetchBeforeClear: false` (default): Original behavior (clear first, then fetch) - Prevents showing empty state while fetching new data @@ -48,6 +49,7 @@ type RefreshOptions = { **File**: `src/store/subscriptionStore.ts` Added dedicated `refreshSubscriptions()` method that: + - Sets `isLoading: true` before fetching - Fetches fresh data atomically - Updates state only after fetch completes @@ -59,7 +61,7 @@ refreshSubscriptions: async () => { try { // Fetch fresh data first await new Promise((resolve) => setTimeout(resolve, 1000)); - + // Update state atomically after fetch completes set({ isLoading: false }); get().calculateStats(); @@ -73,7 +75,7 @@ refreshSubscriptions: async () => { isLoading: false, }); } -} +}; ``` ### 3. Updated HomeScreen Integration @@ -81,6 +83,7 @@ refreshSubscriptions: async () => { **File**: `src/screens/HomeScreen.tsx` Changes: + - Import `refreshSubscriptions` from store - Import `isLoading` state - Use `refreshSubscriptions` as fetcher (no `clearBefore` needed) @@ -110,20 +113,24 @@ const onRefresh = async () => { ## Acceptance Criteria Met ✅ **AC1: Pull-to-refresh always works** + - Concurrent refreshes prevented via `inFlightRef` - Error handling ensures loading state is cleared ✅ **AC2: No stale data shown** + - `refreshSubscriptions` fetches before updating state - No intermediate empty state displayed - Cache invalidation handled atomically ✅ **AC3: Loading state correct** + - `isLoading` set before fetch, cleared after - RefreshControl reflects both `refreshing` and `isLoading` - Proper state transitions: false → true → false ✅ **AC4: No infinite refresh loops** + - `inFlightRef` prevents concurrent refreshes - Rapid successive refreshes are serialized - Error handling prevents stuck loading state @@ -133,6 +140,7 @@ const onRefresh = async () => { ### Race Condition Prevention **Before**: + ``` T=0ms: clearBefore() → subscriptions: [] T=0ms: fetcher() starts @@ -141,6 +149,7 @@ T=1s: fetchSubscriptions completes → data populates ``` **After**: + ``` T=0ms: fetcher() starts T=0-1s: UI shows previous data (no flash) @@ -165,6 +174,7 @@ try { ### State Consistency All state updates happen atomically within a single `set()` call: + - `isLoading` flag - `error` state - Subscription data (if changed) @@ -176,6 +186,7 @@ All state updates happen atomically within a single `set()` call: **Test File**: `src/screens/__tests__/HomeScreen.race-condition.test.ts` Test coverage includes: + - AC1: Pull-to-refresh always works - AC2: No stale data shown - AC3: Loading state correct @@ -184,6 +195,7 @@ Test coverage includes: - State consistency verification Run tests: + ```bash npm test -- HomeScreen.race-condition.test.ts ``` @@ -195,6 +207,7 @@ npm test -- HomeScreen.race-condition.test.ts If you have custom refresh implementations, update them: **Before**: + ```typescript const onRefresh = async () => { await refresh({ @@ -205,6 +218,7 @@ const onRefresh = async () => { ``` **After**: + ```typescript const onRefresh = async () => { await refresh({ diff --git a/app.config.js b/app.config.js index dec52302..a7861a55 100644 --- a/app.config.js +++ b/app.config.js @@ -31,4 +31,4 @@ module.exports = ({ config }) => ({ bytecodeCache: true, }, }, -}); \ No newline at end of file +}); diff --git a/app.json b/app.json index 5297f3f1..e3dc6b65 100644 --- a/app.json +++ b/app.json @@ -69,4 +69,4 @@ "@config-plugins/detox" ] } -} \ No newline at end of file +} diff --git a/audit-ci.json b/audit-ci.json index 4333052c..6fa3d5e3 100644 --- a/audit-ci.json +++ b/audit-ci.json @@ -18,6 +18,12 @@ "GHSA-r6q2-hw4h-h46w", "GHSA-v9p9-hfj2-hcw8", "GHSA-vjh7-7g9h-fjfh", - "GHSA-vrm6-8vpv-qv8q" + "GHSA-vrm6-8vpv-qv8q", + "GHSA-35jp-ww65-95wh", + "GHSA-5wm8-gmm8-39j9", + "GHSA-ph9p-34f9-6g65", + "GHSA-pjwm-pj3p-43mv", + "GHSA-q3j6-qgpj-74h6", + "GHSA-v39h-62p7-jpjc" ] } diff --git a/babel.config.js b/babel.config.js index d93dafc8..aacb7c76 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,10 +3,13 @@ module.exports = function (api) { const isProduction = api.env('production'); const plugins = [ - ['babel-plugin-module-resolver', { - root: ['./src'], - extensions: ['.js', '.jsx', '.ts', '.tsx'], - }], + [ + 'babel-plugin-module-resolver', + { + root: ['./src'], + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + ], ]; if (isProduction) { @@ -17,4 +20,4 @@ module.exports = function (api) { presets: [['babel-preset-expo', { unstable_transformImportMeta: true }]], plugins, }; -}; \ No newline at end of file +}; diff --git a/backend/services/__tests__/webhook.test.ts b/backend/services/__tests__/webhook.test.ts index fc91bd8a..ee4222e6 100644 --- a/backend/services/__tests__/webhook.test.ts +++ b/backend/services/__tests__/webhook.test.ts @@ -3,7 +3,7 @@ import { buildWebhookPayload, signWebhookPayload, verifyWebhookSignature, -} from '../webhook'; +} from '../notification/webhook'; import type { WebhookEventInput, WebhookPlanSnapshot, diff --git a/backend/services/affiliate/AffiliateService.ts b/backend/services/affiliate/AffiliateService.ts new file mode 100644 index 00000000..6d5759bb --- /dev/null +++ b/backend/services/affiliate/AffiliateService.ts @@ -0,0 +1,473 @@ +import { AuditService } from '../auditService'; +import type { AuditAction } from '../auditTypes'; +import { + Affiliate, + AffiliateProgram, + Commission, + PayoutRecord, + AffiliateStatus, + CommissionType, +} from '../../../src/types/affiliate'; + +const auditService = new AuditService('affiliate-audit-secret-key'); + +export interface ReferralClick { + id: string; + affiliateId: string; + referralCode: string; + ip: string; + userAgent: string; + timestamp: Date; + metadata?: any; +} + +export interface AttributionEvent { + subscriptionId: string; + affiliateId: string; + touchWeight: number; // 0.0 to 1.0 for multi-touch + attributionModel: string; +} + +export class AffiliateService { + private static affiliates: Map = new Map(); + private static programs: Map = new Map(); + private static commissions: Map = new Map(); + private static clicks: ReferralClick[] = []; + private static payouts: Map = new Map(); + + static generateId(): string { + return Math.random().toString(36).substring(2, 15) + Date.now().toString(36); + } + + /** + * Create or update affiliate programs + */ + static registerProgram(program: AffiliateProgram): void { + this.programs.set(program.id, program); + } + + static getProgram(id: string): AffiliateProgram | undefined { + return this.programs.get(id); + } + + static listPrograms(): AffiliateProgram[] { + return Array.from(this.programs.values()); + } + + /** + * Register a new affiliate merchant / referrer + */ + static async registerAffiliate(referrerAddress: string, programId: string): Promise { + const program = this.programs.get(programId); + const referralCode = `REF-${referrerAddress.slice(2, 8).toUpperCase()}-${Math.floor(100 + Math.random() * 900)}`; + const referralLink = `https://subtrackr.com/join?ref=${referralCode}`; + + const newAffiliate: Affiliate = { + id: this.generateId(), + referrerAddress, + programId, + commissionRate: program ? program.commissionConfig.rate : 10, + paymentThreshold: 100, // threshold in USD + status: AffiliateStatus.ACTIVE, + totalReferrals: 0, + totalEarnings: 0, + pendingPayout: 0, + createdAt: new Date(), + referralCode, + referralLink, + clicksCount: 0, + fraudRiskScore: 0, + fraudStatus: 'safe', + payoutHistory: [], + }; + + this.affiliates.set(newAffiliate.id, newAffiliate); + + auditService.capture( + 'admin.action' as AuditAction, + 'system', + newAffiliate.id, + 'affiliate', + { action: 'register', referrerAddress, referralCode } + ); + + return newAffiliate; + } + + /** + * Track Referral Clicks + * Mitigates cookie blocking by saving click metadata (IP, UserAgent) in a server-side list + * to do fallback fingerprint-based match if cookies are blocked on conversions. + */ + static async trackClick(referralCode: string, ip: string, userAgent: string, metadata?: any): Promise { + const affiliate = Array.from(this.affiliates.values()).find(a => a.referralCode === referralCode); + if (!affiliate) { + throw new Error('Affiliate not found with code: ' + referralCode); + } + + if (affiliate.status !== AffiliateStatus.ACTIVE) { + return; // Ignore inactive affiliates + } + + // Fraud prevention check - click flooding + const windowStart = new Date(Date.now() - 60000); // 1 minute window + const recentClicksCount = this.clicks.filter(c => c.ip === ip && c.timestamp > windowStart).length; + if (recentClicksCount > 15) { + affiliate.fraudRiskScore = Math.min(100, (affiliate.fraudRiskScore || 0) + 15); + if (affiliate.fraudRiskScore > 75) { + affiliate.fraudStatus = 'flagged'; + affiliate.status = AffiliateStatus.SUSPENDED; + } else if (affiliate.fraudRiskScore > 40) { + affiliate.fraudStatus = 'suspicious'; + } + this.affiliates.set(affiliate.id, affiliate); + + auditService.capture( + 'admin.action' as AuditAction, + 'system', + affiliate.id, + 'affiliate', + { action: 'fraud_click_flooding_flagged', ip, riskScore: affiliate.fraudRiskScore } + ); + + throw new Error('Rate limit exceeded for clicks from this source.'); + } + + const click: ReferralClick = { + id: this.generateId(), + affiliateId: affiliate.id, + referralCode, + ip, + userAgent, + timestamp: new Date(), + metadata, + }; + + this.clicks.push(click); + affiliate.clicksCount = (affiliate.clicksCount || 0) + 1; + this.affiliates.set(affiliate.id, affiliate); + + auditService.capture( + 'admin.action' as AuditAction, + 'system', + affiliate.id, + 'affiliate', + { action: 'track_click', referralCode, ip } + ); + } + + /** + * Determine the attributed affiliate(s) using a multi-touch attribution model or cookie fallback window + */ + static getAttributedAffiliates( + userIp: string, + userAgent: string, + cookieReferralCode?: string, + customAttributionModel: 'first-touch' | 'last-touch' | 'linear' = 'last-touch' + ): AttributionEvent[] { + const activeAttribution: AttributionEvent[] = []; + + // Find all valid clicks in the attribution window + const now = Date.now(); + const validClicks = this.clicks.filter(click => { + const affiliate = this.affiliates.get(click.affiliateId); + if (!affiliate) return false; + const program = this.programs.get(affiliate.programId); + const attributionWindowDays = program ? program.attributionWindowDays : 30; + const windowMs = attributionWindowDays * 24 * 60 * 60 * 1000; + + // Cookie blocking mitigation: if cookieReferralCode matches OR IP+UserAgent matches + const isAttributionMatch = + (cookieReferralCode && click.referralCode === cookieReferralCode) || + (click.ip === userIp && click.userAgent === userAgent); + + return isAttributionMatch && (now - click.timestamp.getTime()) < windowMs; + }); + + if (validClicks.length === 0) { + return []; + } + + // Sort by timestamp + validClicks.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + + if (customAttributionModel === 'first-touch') { + const firstClick = validClicks[0]; + activeAttribution.push({ + subscriptionId: '', + affiliateId: firstClick.affiliateId, + touchWeight: 1.0, + attributionModel: 'first-touch', + }); + } else if (customAttributionModel === 'linear') { + // Linear: distribute weight equally among all unique touchpoints + const uniqueAffiliates = Array.from(new Set(validClicks.map(c => c.affiliateId))); + const weight = 1.0 / uniqueAffiliates.length; + uniqueAffiliates.forEach(affId => { + activeAttribution.push({ + subscriptionId: '', + affiliateId: affId, + touchWeight: weight, + attributionModel: 'linear', + }); + }); + } else { + // Default: Last-touch + const lastClick = validClicks[validClicks.length - 1]; + activeAttribution.push({ + subscriptionId: '', + affiliateId: lastClick.affiliateId, + touchWeight: 1.0, + attributionModel: 'last-touch', + }); + } + + return activeAttribution; + } + + /** + * Track referral conversion and subscription commission. + * Performs fraud detection: self-referral, same IP/device, conversion speed. + */ + static async convertReferral( + subscriptionId: string, + subscriptionAmount: number, + userIp: string, + userAgent: string, + cookieReferralCode?: string, + customAttributionModel: 'first-touch' | 'last-touch' | 'linear' = 'last-touch' + ): Promise { + const attributions = this.getAttributedAffiliates(userIp, userAgent, cookieReferralCode, customAttributionModel); + const createdCommissions: Commission[] = []; + + for (const attr of attributions) { + const affiliate = this.affiliates.get(attr.affiliateId); + if (!affiliate || affiliate.status !== AffiliateStatus.ACTIVE) continue; + + // ── Fraud Check 1: Self Referral (Same Address, Device or IP) ── + const affiliateWallet = affiliate.referrerAddress.toLowerCase(); + // In mock, we check if IP address matches the clicker or if user metadata indicates self + const affiliateClicks = this.clicks.filter(c => c.affiliateId === affiliate.id); + const matchedClick = affiliateClicks.find(c => c.ip === userIp); + + let fraudRiskInc = 0; + if (matchedClick && matchedClick.ip === userIp) { + fraudRiskInc += 35; // Suspicious IP overlap + } + + // Fraud Check 2: Signup speed - Conversion within 5s of click + if (matchedClick) { + const diffMs = Date.now() - matchedClick.timestamp.getTime(); + if (diffMs < 5000) { + fraudRiskInc += 30; // Unnaturally fast conversion + } + } + + if (fraudRiskInc > 0) { + affiliate.fraudRiskScore = Math.min(100, (affiliate.fraudRiskScore || 0) + fraudRiskInc); + if (affiliate.fraudRiskScore > 70) { + affiliate.fraudStatus = 'flagged'; + affiliate.status = AffiliateStatus.SUSPENDED; + + auditService.capture( + 'admin.action' as AuditAction, + 'system', + affiliate.id, + 'affiliate', + { action: 'fraud_self_referral_detected', riskScore: affiliate.fraudRiskScore, subscriptionId } + ); + + throw new Error('Transaction blocked due to potential self-referral fraud.'); + } else { + affiliate.fraudStatus = 'suspicious'; + } + this.affiliates.set(affiliate.id, affiliate); + } + + // Calculate commission based on program config and touch attribution weight + const program = this.programs.get(affiliate.programId); + let calculatedComm = 0; + + if (program) { + const config = program.commissionConfig; + if (config.type === CommissionType.FLAT) { + calculatedComm = config.rate; + } else if (config.type === CommissionType.TIERED && config.tierThresholds && config.tierRates) { + let selectedRate = config.rate; + for (let i = config.tierThresholds.length - 1; i >= 0; i--) { + if (subscriptionAmount >= config.tierThresholds[i]) { + selectedRate = config.tierRates[i]; + break; + } + } + calculatedComm = subscriptionAmount * (selectedRate / 100); + } else { + calculatedComm = subscriptionAmount * (config.rate / 100); + } + } else { + calculatedComm = subscriptionAmount * 0.1; // fallback 10% + } + + // Apply touch attribution weight + const weightedCommission = Math.round(calculatedComm * attr.touchWeight * 100) / 100; + + const commission: Commission = { + id: this.generateId(), + affiliateId: affiliate.id, + subscriptionId, + amount: weightedCommission, + currency: 'USD', + status: 'pending', + createdAt: new Date(), + }; + + this.commissions.set(commission.id, commission); + + // Update affiliate totals + affiliate.totalReferrals += 1; + affiliate.pendingPayout += weightedCommission; + this.affiliates.set(affiliate.id, affiliate); + + createdCommissions.push(commission); + + auditService.capture( + 'admin.action' as AuditAction, + 'system', + affiliate.id, + 'affiliate', + { action: 'earned_commission', amount: weightedCommission, subscriptionId } + ); + } + + return createdCommissions; + } + + /** + * Commission Clawback: Automatically clawback pending/approved commissions on subscription cancellation/refund. + */ + static async processClawback(subscriptionId: string): Promise { + let totalClawbacked = 0; + this.commissions.forEach((comm, commId) => { + if (comm.subscriptionId === subscriptionId && comm.status !== 'paid' && !comm.isClawbacked) { + comm.isClawbacked = true; + comm.status = 'pending'; // Reset status or lock it + totalClawbacked += comm.amount; + + const affiliate = this.affiliates.get(comm.affiliateId); + if (affiliate) { + affiliate.pendingPayout = Math.max(0, affiliate.pendingPayout - comm.amount); + affiliate.totalEarnings = Math.max(0, affiliate.totalEarnings - comm.amount); + this.affiliates.set(affiliate.id, affiliate); + } + this.commissions.set(commId, comm); + + auditService.capture( + 'admin.action' as AuditAction, + 'system', + comm.affiliateId, + 'affiliate', + { action: 'clawback_commission', commissionId: comm.id, amount: comm.amount } + ); + } + }); + return totalClawbacked; + } + + /** + * Payout Management: request payout of pending/approved commissions + */ + static async requestPayout(affiliateId: string): Promise { + const affiliate = this.affiliates.get(affiliateId); + if (!affiliate) { + throw new Error('Affiliate not found'); + } + + if (affiliate.status !== AffiliateStatus.ACTIVE) { + throw new Error('Affiliate status is not active: ' + affiliate.status); + } + + if (affiliate.pendingPayout < affiliate.paymentThreshold) { + throw new Error(`Minimum payout threshold not met. Required: $${affiliate.paymentThreshold}`); + } + + const payoutAmount = affiliate.pendingPayout; + + // Reset pending payout, add to total earnings + affiliate.pendingPayout = 0; + affiliate.totalEarnings += payoutAmount; + + const payout: PayoutRecord = { + id: this.generateId(), + amount: payoutAmount, + currency: 'USD', + status: 'paid', // Immediately approve for simulation + requestedAt: new Date(), + paidAt: new Date(), + }; + + affiliate.payoutHistory = [...(affiliate.payoutHistory || []), payout]; + this.affiliates.set(affiliate.id, affiliate); + + // Update commissions for this affiliate to 'paid' status + this.commissions.forEach((comm, id) => { + if (comm.affiliateId === affiliateId && comm.status === 'pending') { + comm.status = 'paid'; + comm.paidAt = new Date(); + this.commissions.set(id, comm); + } + }); + + this.payouts.set(payout.id, payout); + + auditService.capture( + 'admin.action' as AuditAction, + 'system', + affiliateId, + 'affiliate', + { action: 'request_payout', amount: payoutAmount } + ); + + return payout; + } + + static getAffiliate(id: string): Affiliate | undefined { + return this.affiliates.get(id); + } + + static getAffiliateByAddress(address: string): Affiliate | undefined { + return Array.from(this.affiliates.values()).find(a => a.referrerAddress.toLowerCase() === address.toLowerCase()); + } + + static listCommissions(): Commission[] { + return Array.from(this.commissions.values()); + } +} + +// Pre-fill a default program +AffiliateService.registerProgram({ + id: 'default-basic', + name: 'Basic Affiliate Program', + description: 'Earn 10% commission on all referrals', + commissionConfig: { + type: CommissionType.PERCENTAGE, + rate: 10, + }, + attributionWindowDays: 30, + isActive: true, + attributionModel: 'last-touch', +}); + +AffiliateService.registerProgram({ + id: 'default-tiered', + name: 'Tiered Affiliate Program', + description: 'Earn up to 15% with tiered rates', + commissionConfig: { + type: CommissionType.TIERED, + rate: 10, + tierThresholds: [100, 500, 1000], + tierRates: [10, 12, 15], + }, + attributionWindowDays: 60, + isActive: true, + attributionModel: 'last-touch', +}); diff --git a/backend/services/index.ts b/backend/services/index.ts index ddaca17b..45595c17 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -1,4 +1,3 @@ -feat/issues-394-405-414-386 // ── Connection pool (#414) ──────────────────────────────────────────────────── export { ConnectionPool, getPool, stellarPool } from './connectionPool'; export type { PoolConfig, PoolMetrics } from './connectionPool'; @@ -28,7 +27,6 @@ export type { PaginationMeta, } from './apiResponse'; -main export { AuditService } from './auditService'; export { CampaignService } from './campaignService'; export { DunningService, dunningService } from './dunningService'; @@ -92,6 +90,7 @@ export type { SupportTicketContext, SupportTicketRecord, } from './supportAutomation'; +export { SubscriptionEventStore, subscriptionEventStore, } from './subscriptionEventStore'; @@ -102,7 +101,9 @@ export type { SubscriptionEventType, } from './subscriptionEventStore'; - +// ── Affiliate Module ────────────────────────────────────────────────────────── +export { AffiliateService } from './affiliate/AffiliateService'; +export type { ReferralClick, AttributionEvent } from './affiliate/AffiliateService'; export { SubscriptionCacheService, diff --git a/backend/tests/integration/api-endpoints.integration.test.ts b/backend/tests/integration/api-endpoints.integration.test.ts index 06a048b2..d128672d 100644 --- a/backend/tests/integration/api-endpoints.integration.test.ts +++ b/backend/tests/integration/api-endpoints.integration.test.ts @@ -7,9 +7,9 @@ */ import { describe, it, expect, beforeEach, jest } from '@jest/globals'; -import { MonitoringService } from '../../../backend/services/monitoring'; -import { AlertingService, createDispatcher } from '../../../backend/services/alerting'; -import type { TransactionEvent, AlertRule, Alert } from '../../../backend/services/types'; +import { MonitoringService } from '../../services/shared/monitoring'; +import { AlertingService, createDispatcher } from '../../services/notification/alerting'; +import type { TransactionEvent, AlertRule, Alert } from '../../services/shared/types'; // ── Factories ───────────────────────────────────────────────────────────────── let _txCounter = 0; diff --git a/developer-portal/pages/OnboardingPage.tsx b/developer-portal/pages/OnboardingPage.tsx index ba355fcd..e9033ba9 100644 --- a/developer-portal/pages/OnboardingPage.tsx +++ b/developer-portal/pages/OnboardingPage.tsx @@ -157,7 +157,7 @@ export const OnboardingPage: React.FC = ({ onComplete }) => case 'create-sandbox': return ( - + Your sandbox environment will be created with default settings. You can customize it later in the environment settings. @@ -170,7 +170,7 @@ export const OnboardingPage: React.FC = ({ onComplete }) => case 'generate-api-key': return ( - + Generate an API key to authenticate your requests. Keep this key secure and never share it publicly. @@ -183,7 +183,7 @@ export const OnboardingPage: React.FC = ({ onComplete }) => case 'explore-docs': return ( - + Review the API documentation to understand available endpoints, authentication, and best practices. @@ -253,7 +253,7 @@ export const OnboardingPage: React.FC = ({ onComplete }) => {index + 1} )} - + {step.title} @@ -376,7 +376,7 @@ const styles = StyleSheet.create({ color: '#FFFFFF', fontWeight: 'bold', }, - stepInfo: { + stepInfoContainer: { flex: 1, }, stepTitle: { @@ -421,7 +421,7 @@ const styles = StyleSheet.create({ padding: 12, fontSize: 16, }, - stepInfo: { + stepHelpText: { fontSize: 14, color: '#6B7280', lineHeight: 20, diff --git a/eas.json b/eas.json index 563d95ef..0085c4dd 100644 --- a/eas.json +++ b/eas.json @@ -42,4 +42,4 @@ "submit": { "production": {} } -} \ No newline at end of file +} diff --git a/metro.config.js b/metro.config.js index ab1d2f0b..c19cc60c 100644 --- a/metro.config.js +++ b/metro.config.js @@ -2,6 +2,16 @@ const { getDefaultConfig } = require('expo/metro-config'); const config = getDefaultConfig(__dirname); +config.transformer = { + ...config.transformer, + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: true, + inlineRequires: true, + }, + }), +}; + config.transformer.hermesEnabled = true; config.transformer.unstable_transformImportMeta = true; diff --git a/package.json b/package.json index be205a48..c80b7844 100644 --- a/package.json +++ b/package.json @@ -161,4 +161,4 @@ "prettier --write" ] } -} \ No newline at end of file +} diff --git a/performance-budget.json b/performance-budget.json index 8d37aca7..08c81aef 100644 --- a/performance-budget.json +++ b/performance-budget.json @@ -10,4 +10,4 @@ "allocationProfile": true, "bytecodeCache": true } -} \ No newline at end of file +} diff --git a/sandbox/services/apiKeyService.ts b/sandbox/services/apiKeyService.ts index 336edf24..813547b3 100644 --- a/sandbox/services/apiKeyService.ts +++ b/sandbox/services/apiKeyService.ts @@ -182,7 +182,7 @@ export class ApiKeyService { async getApiKeyAuditLog(keyId: string): Promise { const apiKey = this.apiKeys.get(keyId); - return apiKey ? apiKey.auditLogs ?? [] : null; + return apiKey ? (apiKey.auditLogs ?? []) : null; } private async hashKey(plainKey: string): Promise { diff --git a/scripts/check-performance-budget.js b/scripts/check-performance-budget.js index 10f73c05..3b6450fb 100644 --- a/scripts/check-performance-budget.js +++ b/scripts/check-performance-budget.js @@ -41,11 +41,15 @@ if (report.memoryMaxBytes > budget.memoryBytes) { } if (report.androidStartupMs && report.androidStartupMs > budget.androidStartupMs) { - failures.push(`Android startup ${report.androidStartupMs}ms exceeds ${budget.androidStartupMs}ms`); + failures.push( + `Android startup ${report.androidStartupMs}ms exceeds ${budget.androidStartupMs}ms` + ); } if (report.androidFps && report.androidFps < budget.androidFrameRateFps) { - failures.push(`Android FPS ${report.androidFps}fps below target ${budget.androidFrameRateFps}fps`); + failures.push( + `Android FPS ${report.androidFps}fps below target ${budget.androidFrameRateFps}fps` + ); } if (failures.length) { @@ -53,4 +57,4 @@ if (failures.length) { process.exit(1); } -console.log('Performance budget passed.'); \ No newline at end of file +console.log('Performance budget passed.'); diff --git a/scripts/i18n-extract.js b/scripts/i18n-extract.js index a393e2e2..70685355 100644 --- a/scripts/i18n-extract.js +++ b/scripts/i18n-extract.js @@ -19,20 +19,20 @@ * node scripts/i18n-extract.js --report-only # never exits with code 1 (CI info only) */ -const fs = require('fs'); +const fs = require('fs'); const path = require('path'); -const SRC_DIR = path.resolve(__dirname, '../src'); -const LOCALES_DIR = path.resolve(__dirname, '../src/i18n/locales'); -const LOCALES = ['en', 'hi', 'ar']; -const FIX_MODE = process.argv.includes('--fix'); -const REPORT_ONLY = process.argv.includes('--report-only'); +const SRC_DIR = path.resolve(__dirname, '../src'); +const LOCALES_DIR = path.resolve(__dirname, '../src/i18n/locales'); +const LOCALES = ['en', 'hi', 'ar']; +const FIX_MODE = process.argv.includes('--fix'); +const REPORT_ONLY = process.argv.includes('--report-only'); // ── 1. Extract keys from source code ───────────────────────────────────────── const KEY_PATTERNS = [ - /\bt\(\s*['"`]([^'"`]+)['"`]/g, // t('key') - /i18n\.t\(\s*['"`]([^'"`]+)['"`]/g, // i18n.t('key') + /\bt\(\s*['"`]([^'"`]+)['"`]/g, // t('key') + /i18n\.t\(\s*['"`]([^'"`]+)['"`]/g, // i18n.t('key') /useTranslation.*?\bt\(\s*['"`]([^'"`]+)['"`]/gs, // useTranslation hook ]; @@ -56,7 +56,7 @@ function walkDir(dir, ext = ['.ts', '.tsx', '.js', '.jsx']) { const full = path.join(dir, entry.name); if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { for (const k of walkDir(full, ext)) results.add(k); - } else if (entry.isFile() && ext.some(e => full.endsWith(e))) { + } else if (entry.isFile() && ext.some((e) => full.endsWith(e))) { for (const k of extractKeysFromFile(full)) results.add(k); } } @@ -101,20 +101,20 @@ function saveLocale(lang, data) { console.log(` ✔ Saved ${filePath}`); } -const codeKeys = walkDir(SRC_DIR); -const enLocale = loadLocale('en'); -const enFlat = flatten(enLocale); -const enKeys = new Set(Object.keys(enFlat)); +const codeKeys = walkDir(SRC_DIR); +const enLocale = loadLocale('en'); +const enFlat = flatten(enLocale); +const enKeys = new Set(Object.keys(enFlat)); -const newKeys = [...codeKeys].filter(k => !enKeys.has(k)); -const unusedKeys = [...enKeys].filter(k => !codeKeys.has(k)); +const newKeys = [...codeKeys].filter((k) => !enKeys.has(k)); +const unusedKeys = [...enKeys].filter((k) => !codeKeys.has(k)); let hasIssues = false; // Report new keys (in code, missing from en.json) if (newKeys.length > 0) { console.warn(`\n⚠ ${newKeys.length} key(s) used in code but missing from en.json:`); - newKeys.forEach(k => console.warn(` - ${k}`)); + newKeys.forEach((k) => console.warn(` - ${k}`)); hasIssues = true; if (FIX_MODE) { for (const key of newKeys) { @@ -130,18 +130,18 @@ if (newKeys.length > 0) { // Report unused keys (in en.json, not used in code) if (unusedKeys.length > 0) { console.warn(`\n⚠ ${unusedKeys.length} key(s) in en.json are not used in the codebase:`); - unusedKeys.forEach(k => console.warn(` - ${k}`)); + unusedKeys.forEach((k) => console.warn(` - ${k}`)); // Unused keys are a warning, not a hard failure } // Report per-locale missing keys -for (const lang of LOCALES.filter(l => l !== 'en')) { - const loc = loadLocale(lang); - const flat = flatten(loc); - const missing = [...enKeys].filter(k => !(k in flat)); +for (const lang of LOCALES.filter((l) => l !== 'en')) { + const loc = loadLocale(lang); + const flat = flatten(loc); + const missing = [...enKeys].filter((k) => !(k in flat)); if (missing.length > 0) { console.warn(`\n⚠ ${missing.length} key(s) missing from ${lang}.json:`); - missing.forEach(k => console.warn(` - ${k}`)); + missing.forEach((k) => console.warn(` - ${k}`)); hasIssues = true; if (FIX_MODE) { const locData = loadLocale(lang); diff --git a/scripts/i18n-lint.js b/scripts/i18n-lint.js index 96a1c766..c05d7287 100644 --- a/scripts/i18n-lint.js +++ b/scripts/i18n-lint.js @@ -11,11 +11,11 @@ * Exit code 1 when any error is found (used in CI). */ -const fs = require('fs'); +const fs = require('fs'); const path = require('path'); const LOCALES_DIR = path.resolve(__dirname, '../src/i18n/locales'); -const LOCALES = ['en', 'hi', 'ar']; +const LOCALES = ['en', 'hi', 'ar']; function flatten(obj, prefix = '') { const out = {}; @@ -36,7 +36,7 @@ function extractPlaceholders(str) { const locales = {}; for (const lang of LOCALES) { - const raw = fs.readFileSync(path.join(LOCALES_DIR, `${lang}.json`), 'utf8'); + const raw = fs.readFileSync(path.join(LOCALES_DIR, `${lang}.json`), 'utf8'); locales[lang] = flatten(JSON.parse(raw)); } @@ -44,9 +44,9 @@ let errors = 0; for (const key of Object.keys(locales.en)) { const enVal = String(locales.en[key]); - const enPH = extractPlaceholders(enVal); + const enPH = extractPlaceholders(enVal); - for (const lang of LOCALES.filter(l => l !== 'en')) { + for (const lang of LOCALES.filter((l) => l !== 'en')) { if (!(key in locales[lang])) continue; // missing keys handled by i18n-extract const val = String(locales[lang][key]); diff --git a/src/components/BiometricGate.tsx b/src/components/BiometricGate.tsx index de405775..1953e6ea 100644 --- a/src/components/BiometricGate.tsx +++ b/src/components/BiometricGate.tsx @@ -86,9 +86,7 @@ const BiometricGate: React.FC = ({ children }) => { {icon} SubTrackr is Locked - - Authenticate to access your subscriptions and wallet. - + Authenticate to access your subscriptions and wallet. {isLoading ? ( = ({ children }) => { { clearError(); void authenticate(); }} + onPress={() => { + clearError(); + void authenticate(); + }} accessibilityRole="button" accessibilityLabel="Unlock with biometrics"> - {cancelled ? 'Try Again' : `Unlock with ${supportedTypes[0] === 'facial' ? 'Face ID' : 'Biometrics'}`} + {cancelled + ? 'Try Again' + : `Unlock with ${supportedTypes[0] === 'facial' ? 'Face ID' : 'Biometrics'}`} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 21feba38..b656bda8 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -2,7 +2,7 @@ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, SafeAreaView } from 'react-native'; import { errorHandler, AppError, ErrorSeverity } from '../services/errorHandler'; import { crashReporter } from '../services/crashReporter'; -import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; interface Props { diff --git a/src/components/common/AsyncStateView.tsx b/src/components/common/AsyncStateView.tsx index 5a0a7e9f..a421fb8e 100644 --- a/src/components/common/AsyncStateView.tsx +++ b/src/components/common/AsyncStateView.tsx @@ -77,13 +77,7 @@ interface ErrorCardProps { inline?: boolean; } -const ErrorCard: React.FC = ({ - title, - message, - suggestions, - onRetry, - inline, -}) => { +const ErrorCard: React.FC = ({ title, message, suggestions, onRetry, inline }) => { const content = ( @@ -146,11 +140,7 @@ export const AsyncStateView: React.FC = ({ if (skeleton) return <>{skeleton}; return ( - + ); diff --git a/src/components/gamification/GamificationComponents.tsx b/src/components/gamification/GamificationComponents.tsx index 5003a160..92165ee7 100644 --- a/src/components/gamification/GamificationComponents.tsx +++ b/src/components/gamification/GamificationComponents.tsx @@ -79,15 +79,15 @@ export const LeaderboardList: React.FC = ({ data }) => { const theme = useTheme(); const renderItem = ({ item }: { item: LeaderboardEntry }) => ( - + {item.rank} @@ -97,13 +97,17 @@ export const LeaderboardList: React.FC = ({ data }) => { Lvl {item.level} - {item.points} XP + + {item.points} XP + ); return ( - Global Leaderboard + + Global Leaderboard + {data.map((item) => ( {renderItem({ item })} ))} diff --git a/src/components/gamification/LoyaltyComponents.tsx b/src/components/gamification/LoyaltyComponents.tsx index 040c3436..e02c26f7 100644 --- a/src/components/gamification/LoyaltyComponents.tsx +++ b/src/components/gamification/LoyaltyComponents.tsx @@ -28,7 +28,10 @@ export const StreakCard: React.FC = ({ streak, onShare }) => { const theme = useTheme(); const handleShare = useCallback(async () => { - if (onShare) { onShare(); return; } + if (onShare) { + onShare(); + return; + } await Share.share({ message: `🔥 I'm on a ${streak.current}-day payment streak on SubTrackr! My longest is ${streak.longest} days. Join me!`, }); @@ -47,9 +50,7 @@ export const StreakCard: React.FC = ({ streak, onShare }) => { - - Best - + Best {streak.longest} @@ -83,7 +84,10 @@ export const AchievementCard: React.FC = ({ achievement, o const isUnlocked = !!achievement.unlockedAt; const handleShare = useCallback(async () => { - if (onShare) { onShare(achievement); return; } + if (onShare) { + onShare(achievement); + return; + } if (!isUnlocked) return; await Share.share({ message: `${achievement.icon} I just unlocked "${achievement.name}" on SubTrackr! ${achievement.description}`, @@ -99,9 +103,7 @@ export const AchievementCard: React.FC = ({ achievement, o ]}> {achievement.icon} - + {achievement.name} = ({ styles.redeemBtnText, { color: canRedeem ? '#fff' : theme.colors.textSecondary }, ]}> - {canRedeem ? 'Redeem' : `Need ${(item.pointsCost - currentPoints).toLocaleString()} more`} + {canRedeem + ? 'Redeem' + : `Need ${(item.pointsCost - currentPoints).toLocaleString()} more`} ); }, - [currentPoints, onRedeem, theme], + [currentPoints, onRedeem, theme] ); return ( @@ -294,7 +298,14 @@ const styles = StyleSheet.create({ shareBtnText: { fontWeight: '600' }, achievementCard: { width: 110, padding: 12, alignItems: 'center', marginRight: 10 }, - achievementIcon: { width: 52, height: 52, borderRadius: 26, justifyContent: 'center', alignItems: 'center', marginBottom: 8 }, + achievementIcon: { + width: 52, + height: 52, + borderRadius: 26, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 8, + }, achievementEmoji: { fontSize: 26 }, achievementName: { fontSize: 12, fontWeight: 'bold', textAlign: 'center' }, achievementDesc: { fontSize: 10, textAlign: 'center', marginTop: 4 }, diff --git a/src/components/segments/SegmentOverlapAnalysis.tsx b/src/components/segments/SegmentOverlapAnalysis.tsx index 00d2eac8..771df9d9 100644 --- a/src/components/segments/SegmentOverlapAnalysis.tsx +++ b/src/components/segments/SegmentOverlapAnalysis.tsx @@ -44,7 +44,10 @@ export const SegmentOverlapAnalysis: React.FC = ({ + style={[ + styles.progressBarContainer, + { backgroundColor: theme.colors.border.default }, + ]}> = ({ { borderColor: theme.colors.border.default }, ]}> + style={[ + styles.chipText, + rule.field === f.value && { color: theme.colors.onPrimary }, + ]}> {f.label} @@ -135,7 +138,10 @@ export const SegmentRuleBuilder: React.FC = ({ { borderColor: theme.colors.border.default }, ]}> + style={[ + styles.chipText, + rule.operator === o.value && { color: theme.colors.onPrimary }, + ]}> {o.label} diff --git a/src/components/subscription/SubscriptionCard.tsx b/src/components/subscription/SubscriptionCard.tsx index 8a3c03b9..4ce41d52 100644 --- a/src/components/subscription/SubscriptionCard.tsx +++ b/src/components/subscription/SubscriptionCard.tsx @@ -173,7 +173,9 @@ export const SubscriptionCard: React.FC = React.memo( testID={`subscription-toggle-${subscription.id}`} accessibilityRole="button" accessibilityLabel={ - subscription.isActive ? `Pause ${subscription.name}` : `Activate ${subscription.name}` + subscription.isActive + ? `Pause ${subscription.name}` + : `Activate ${subscription.name}` }> {subscription.isActive ? 'Pause' : 'Activate'} diff --git a/src/components/subscription/SubscriptionIcon.tsx b/src/components/subscription/SubscriptionIcon.tsx index 2059a70b..ad21d784 100644 --- a/src/components/subscription/SubscriptionIcon.tsx +++ b/src/components/subscription/SubscriptionIcon.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useState } from 'react'; import { Text, View, StyleSheet } from 'react-native'; import { Image } from 'expo-image'; -import { borderRadius, colors } from '../../utils/constants'; +import { colors } from '../../utils/constants'; import { imageCache } from '../../utils/imageCache'; // ── Types ───────────────────────────────────────────────────────────────────── diff --git a/src/components/theme/ThemeBuilder.tsx b/src/components/theme/ThemeBuilder.tsx index c10e26b8..87b1f04b 100644 --- a/src/components/theme/ThemeBuilder.tsx +++ b/src/components/theme/ThemeBuilder.tsx @@ -90,8 +90,8 @@ export const ThemeBuilder: React.FC = () => { accessibilityLabel="Brand theme name" /> {COLOR_FIELDS.map(({ key, label }) => ( - - + + {label} { - if ( - mounted && - (saved === 'light' || saved === 'dark' || saved === 'system') - ) { + if (mounted && (saved === 'light' || saved === 'dark' || saved === 'system')) { setModeState(saved); } }) diff --git a/src/errors/index.ts b/src/errors/index.ts index 7773ac61..b4af009a 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -12,7 +12,8 @@ export class AppError extends Error { cause?: unknown, context?: Record ) { - const fullMessage = cause instanceof Error ? `${userMessage} (Caused by: ${cause.message})` : userMessage; + const fullMessage = + cause instanceof Error ? `${userMessage} (Caused by: ${cause.message})` : userMessage; super(fullMessage); this.name = 'AppError'; this.code = code; diff --git a/src/hooks/useBiometricAuth.ts b/src/hooks/useBiometricAuth.ts index c85a87f8..d8700061 100644 --- a/src/hooks/useBiometricAuth.ts +++ b/src/hooks/useBiometricAuth.ts @@ -13,7 +13,11 @@ */ import { useState, useEffect, useCallback } from 'react'; -import { biometricService, BiometricSettings, BiometricType } from '../services/auth/biometricService'; +import { + biometricService, + BiometricSettings, + BiometricType, +} from '../services/auth/biometricService'; interface BiometricAuthState { /** True when the device has enrolled biometrics. */ @@ -45,7 +49,10 @@ export function useBiometricAuth(): BiometricAuthState { const [error, setError] = useState(null); const [cancelled, setCancelled] = useState(false); const [supportedTypes, setSupportedTypes] = useState(['none']); - const [settings, setSettings] = useState({ enabled: false, fallbackToPIN: true }); + const [settings, setSettings] = useState({ + enabled: false, + fallbackToPIN: true, + }); // Load availability and settings on mount useEffect(() => { @@ -64,31 +71,36 @@ export function useBiometricAuth(): BiometricAuthState { } }; void init(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, []); - const authenticate = useCallback(async (reason?: string): Promise => { - setIsLoading(true); - setError(null); - setCancelled(false); + const authenticate = useCallback( + async (reason?: string): Promise => { + setIsLoading(true); + setError(null); + setCancelled(false); - const result = await biometricService.authenticate( - reason ?? 'Authenticate to access SubTrackr', - settings.fallbackToPIN - ); + const result = await biometricService.authenticate( + reason ?? 'Authenticate to access SubTrackr', + settings.fallbackToPIN + ); - setIsLoading(false); + setIsLoading(false); - if (result.success) { - setIsAuthenticated(true); - return true; - } + if (result.success) { + setIsAuthenticated(true); + return true; + } - setIsAuthenticated(false); - setCancelled(result.cancelled ?? false); - setError(result.error ?? 'Authentication failed.'); - return false; - }, [settings.fallbackToPIN]); + setIsAuthenticated(false); + setCancelled(result.cancelled ?? false); + setError(result.error ?? 'Authentication failed.'); + return false; + }, + [settings.fallbackToPIN] + ); const saveSettings = useCallback(async (patch: Partial) => { const updated = await biometricService.saveSettings(patch); diff --git a/src/hooks/useRefresh.ts b/src/hooks/useRefresh.ts index 6c2560fc..cf661dac 100644 --- a/src/hooks/useRefresh.ts +++ b/src/hooks/useRefresh.ts @@ -25,7 +25,7 @@ export function useRefresh() { const refresh = useCallback(async (opts: RefreshOptions = {}) => { const { fetcher, clearBefore, minDurationMs = 400, onError, fetchBeforeClear = false } = opts; - + // Prevent concurrent refreshes if (inFlightRef.current) return; inFlightRef.current = true; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 324c1df2..0f32b2ee 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1,62 +1,82 @@ import React from 'react'; -import { ActivityIndicator, Text, View } from 'react-native'; +import { Text } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { navigationRef } from './navigationRef'; -import { linkingConfig } from './linking'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { useTranslation } from 'react-i18next'; +import { lazyScreen, prefetchModule } from '../utils/lazyLoading'; +import { RootStackParamList, TabParamList } from './types'; +import { useTheme } from '../theme'; +import { darkNavigationTheme, lightNavigationTheme } from '../theme/navigationTheme'; + +// Eagerly loaded primary entrypoints for instant rendering 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'; -import ProfileScreen from '../screens/ProfileScreen'; -import SubscriptionDetailScreen from '../screens/SubscriptionDetailScreen'; -import EditSubscriptionScreen from '../screens/EditSubscriptionScreen'; -import InvoiceListScreen from '../screens/InvoiceListScreen'; -import InvoiceDetailScreen from '../screens/InvoiceDetailScreen'; -import AnalyticsScreen from '../screens/AnalyticsScreen'; -import SlaDashboard from '../screens/SlaDashboard'; -import GDPRSettingsScreen from '../screens/GDPRSettingsScreen'; -import LanguageSettingsScreen from '../screens/LanguageSettingsScreen'; -import SessionManagementScreen from '../screens/SessionManagementScreen'; import { SettingsScreen } from '../screens/SettingsScreen'; -import CalendarIntegrationScreen from '../screens/CalendarIntegrationScreen'; -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 { BatchOperationsScreen } from '../../app/screens/BatchOperationsScreen'; -import AdminDashboardScreen from '../screens/AdminDashboardScreen'; -import FraudDashboard from '../screens/FraudDashboard'; -import GroupManagementScreen from '../screens/GroupManagementScreen'; -import TaxSettingsScreen from '../screens/TaxSettingsScreen'; -import SupportDashboardScreen from '../screens/SupportDashboardScreen'; -import { SegmentManagementScreen } from '../screens/SegmentManagementScreen'; -import { SegmentDetailScreen } from '../screens/SegmentDetailScreen'; -import { GamificationScreen } from '../screens/GamificationScreen'; -import RevenueReportScreen from '../screens/RevenueReportScreen'; -import UsageDashboardScreen from '../screens/UsageDashboard'; -import MerchantOnboardingScreen from '../screens/MerchantOnboardingScreen'; -import AffiliateDashboardScreen from '../screens/AffiliateDashboardScreen'; -import LoyaltyDashboardScreen from '../screens/LoyaltyDashboardScreen'; -import CampaignManagementScreen from '../screens/CampaignManagementScreen'; -import DeveloperPortalScreen from '../screens/DeveloperPortalScreen'; -import SandboxDashboardScreen from '../screens/SandboxDashboardScreen'; -import ApiKeyManagementScreen from '../screens/ApiKeyManagementScreen'; -import DocumentationPortalScreen from '../screens/DocumentationPortalScreen'; -import IntegrationGuidesScreen from '../screens/IntegrationGuidesScreen'; -import PerformanceDashboardScreen from '../screens/PerformanceDashboardScreen'; -import BillingSettingsScreen from '../screens/BillingSettingsScreen'; -import ChangePlanScreen from '../screens/ChangePlanScreen'; -import { PaymentMethodsScreen } from '../../app/screens/PaymentMethodsScreen'; -import AnalyticsDashboard from '../../app/screens/AnalyticsDashboard'; -import { colors } from '../utils/constants'; -import { RootStackParamList, TabParamList } from './types'; +// Lazy loaded auxiliary and heavy screens with suspense/retry support +const AddSubscriptionScreen = lazyScreen(() => import('../screens/AddSubscriptionScreen')); +const CancellationFlowScreen = lazyScreen(() => import('../screens/CancellationFlowScreen')); +const WalletConnectScreen = lazyScreen(() => import('../screens/WalletConnectV2Screen')); +const CryptoPaymentScreen = lazyScreen(() => import('../screens/CryptoPaymentScreen')); +const CommunityScreen = lazyScreen(() => import('../screens/CommunityScreen')); +const ProfileScreen = lazyScreen(() => import('../screens/ProfileScreen')); +const SubscriptionDetailScreen = lazyScreen(() => import('../screens/SubscriptionDetailScreen')); +const InvoiceListScreen = lazyScreen(() => import('../screens/InvoiceListScreen')); +const InvoiceDetailScreen = lazyScreen(() => import('../screens/InvoiceDetailScreen')); +const AnalyticsScreen = lazyScreen(() => import('../screens/AnalyticsScreen')); +const SlaDashboard = lazyScreen(() => import('../screens/SlaDashboard')); +const GDPRSettingsScreen = lazyScreen(() => import('../screens/GDPRSettingsScreen')); +const LanguageSettingsScreen = lazyScreen(() => import('../screens/LanguageSettingsScreen')); +const SessionManagementScreen = lazyScreen(() => import('../screens/SessionManagementScreen')); +const CalendarIntegrationScreen = lazyScreen(() => import('../screens/CalendarIntegrationScreen')); +const AccountingExportScreen = lazyScreen(() => import('../screens/AccountingExportScreen')); +const WebhookSettingsScreen = lazyScreen(() => import('../screens/WebhookSettingsScreen')); +const ErrorDashboardScreen = lazyScreen(() => import('../screens/ErrorDashboardScreen')); +const ImportScreen = lazyScreen(() => import('../screens/ImportScreen')); +const ExportScreen = lazyScreen(() => import('../screens/ExportScreen')); +const BatchOperationsScreen = lazyScreen(() => + import('../../app/screens/BatchOperationsScreen').then((m) => ({ + default: m.BatchOperationsScreen, + })) +); +const AdminDashboardScreen = lazyScreen(() => import('../screens/AdminDashboardScreen')); +const FraudDashboard = lazyScreen(() => import('../screens/FraudDashboard')); +const GroupManagementScreen = lazyScreen(() => import('../screens/GroupManagementScreen')); +const TaxSettingsScreen = lazyScreen(() => import('../screens/TaxSettingsScreen')); +const SupportDashboardScreen = lazyScreen(() => import('../screens/SupportDashboardScreen')); +const SegmentManagementScreen = lazyScreen(() => + import('../screens/SegmentManagementScreen').then((m) => ({ default: m.SegmentManagementScreen })) +); +const SegmentDetailScreen = lazyScreen(() => + import('../screens/SegmentDetailScreen').then((m) => ({ default: m.SegmentDetailScreen })) +); +const GamificationScreen = lazyScreen(() => + import('../screens/GamificationScreen').then((m) => ({ default: m.GamificationScreen })) +); +const RevenueReportScreen = lazyScreen(() => import('../screens/RevenueReportScreen')); +const UsageDashboardScreen = lazyScreen(() => import('../screens/UsageDashboard')); +const MerchantOnboardingScreen = lazyScreen(() => import('../screens/MerchantOnboardingScreen')); +const AffiliateDashboardScreen = lazyScreen(() => import('../screens/AffiliateDashboardScreen')); +const LoyaltyDashboardScreen = lazyScreen(() => import('../screens/LoyaltyDashboardScreen')); +const CampaignManagementScreen = lazyScreen(() => import('../screens/CampaignManagementScreen')); +const DeveloperPortalScreen = lazyScreen(() => import('../screens/DeveloperPortalScreen')); +const SandboxDashboardScreen = lazyScreen(() => import('../screens/SandboxDashboardScreen')); +const ApiKeyManagementScreen = lazyScreen(() => import('../screens/ApiKeyManagementScreen')); +const DocumentationPortalScreen = lazyScreen(() => import('../screens/DocumentationPortalScreen')); +const IntegrationGuidesScreen = lazyScreen(() => import('../screens/IntegrationGuidesScreen')); +const PerformanceDashboardScreen = lazyScreen( + () => import('../screens/PerformanceDashboardScreen') +); +const EditSubscriptionScreen = lazyScreen(() => import('../screens/EditSubscriptionScreen')); +const ChangePlanScreen = lazyScreen(() => import('../screens/ChangePlanScreen')); +const BillingSettingsScreen = lazyScreen(() => import('../screens/BillingSettingsScreen')); +const PaymentMethodsScreen = lazyScreen(() => + import('../../app/screens/PaymentMethodsScreen').then((m) => ({ + default: m.PaymentMethodsScreen, + })) +); +const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsDashboard')); const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); @@ -419,10 +439,19 @@ const TabNavigator = () => { }; export const AppNavigator = () => { + React.useEffect(() => { + prefetchModule('AddSubscription', () => import('../screens/AddSubscriptionScreen')); + prefetchModule('WalletConnect', () => import('../screens/WalletConnectV2Screen')); + prefetchModule('Analytics', () => import('../screens/AnalyticsScreen')); + prefetchModule('SubscriptionDetail', () => import('../screens/SubscriptionDetailScreen')); + }, []); + const { isDark } = useTheme(); return ( - + ); diff --git a/src/navigation/__tests__/AppNavigator.lazy.test.tsx b/src/navigation/__tests__/AppNavigator.lazy.test.tsx new file mode 100644 index 00000000..294fd2e9 --- /dev/null +++ b/src/navigation/__tests__/AppNavigator.lazy.test.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { lazyWithRetry, LazyErrorBoundary, SuspenseLoadingFallback } from '../../utils/lazyLoading'; + +// Mock react-native completely with pass-through elements so testID is fully discoverable by testing-library +jest.mock('react-native', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') as typeof import('react'); + return { + View: ({ + children, + testID, + style, + }: { + children?: React.ReactNode; + testID?: string; + style?: object; + }) => mockReact.createElement('View', { testID, style }, children), + Text: ({ + children, + testID, + style, + }: { + children?: React.ReactNode; + testID?: string; + style?: object; + }) => mockReact.createElement('Text', { testID, style }, children), + ActivityIndicator: ({ color }: { color?: string }) => + mockReact.createElement('ActivityIndicator', { color }), + TouchableOpacity: ({ + children, + onPress, + style, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + style?: object; + testID?: string; + }) => mockReact.createElement('TouchableOpacity', { onPress, style, testID }, children), + InteractionManager: { + runAfterInteractions: (cb: () => void) => cb(), + }, + StyleSheet: { + create: (styles: object) => styles, + flatten: (styles: object) => styles, + }, + Platform: { + OS: 'ios', + }, + }; +}); + +// Mocking design system constants +jest.mock('../../utils/constants', () => ({ + colors: { + primary: '#6366f1', + secondary: '#8b5cf6', + accent: '#06b6d4', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + background: '#0f172a', + surface: '#1e293b', + text: '#f8fafc', + textSecondary: '#cbd5e1', + onPrimary: '#ffffff', + border: '#334155', + }, + spacing: { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 48, + }, + borderRadius: { + sm: 4, + md: 8, + lg: 12, + xl: 16, + }, + typography: { + h3: { fontSize: 20 }, + body: { fontSize: 16 }, + body2: { fontSize: 14 }, + button: { fontSize: 16 }, + }, + shadows: { + sm: {}, + md: {}, + lg: {}, + }, +})); + +const DummyComponent = () => Loaded Content Successfully; + +describe('Lazy Loading Utilities & Error Boundaries', () => { + it('renders SuspenseLoadingFallback correctly', () => { + const { getByTestId, getByText } = render(); + expect(getByTestId('lazy-loading-fallback')).toBeTruthy(); + expect(getByText('Preparing premium modules...')).toBeTruthy(); + }); + + it('lazyWithRetry resolves successfully on initial attempt', async () => { + const importFn = jest.fn().mockResolvedValue({ default: DummyComponent }); + const lazyComponent = lazyWithRetry(importFn) as unknown as { + _payload: { _result: () => Promise<{ default: typeof DummyComponent }> }; + }; + + const loadFn = lazyComponent._payload._result; + const result = await loadFn(); + + expect(result.default).toBe(DummyComponent); + expect(importFn).toHaveBeenCalledTimes(1); + }); + + it('lazyWithRetry retries on failure and resolves on second attempt', async () => { + let called = 0; + const importFn = jest.fn().mockImplementation(() => { + called++; + if (called === 1) { + return Promise.reject(new Error('Transient connection drop')); + } + return Promise.resolve({ default: DummyComponent }); + }); + + const lazyComponent = lazyWithRetry(importFn, 2, 5) as unknown as { + _payload: { _result: () => Promise<{ default: typeof DummyComponent }> }; + }; + const loadFn = lazyComponent._payload._result; + const result = await loadFn(); + + expect(result.default).toBe(DummyComponent); + expect(importFn).toHaveBeenCalledTimes(2); + }); + + it('LazyErrorBoundary catches loading failures and renders interactive retry screen', async () => { + let shouldThrow = true; + const FailingComponent = () => { + if (shouldThrow) { + throw new Error('All retries failed'); + } + return Recovered!; + }; + + // Suppress console.error to keep the logs clean during expected test failure + const originalConsoleError = console.error; + console.error = jest.fn(); + + try { + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('lazy-error-fallback')).toBeTruthy(); + expect(getByText('Connection Interrupted')).toBeTruthy(); + + // Disable throwing state before retry click + shouldThrow = false; + + const retryButton = getByText('Try Again'); + fireEvent.press(retryButton); + + expect(getByTestId('recovered-component')).toBeTruthy(); + } finally { + console.error = originalConsoleError; + } + }); +}); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 2aaf6ca2..3c3fed91 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,5 +1,4 @@ import { NavigatorScreenParams } from '@react-navigation/native'; -import { BillingCycle } from '../types/subscription'; export type RootStackParamList = { Home: undefined; diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index c30c4ca7..12d2c9a4 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -12,13 +12,14 @@ import { Platform, Keyboard, } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/types'; import { useSubscriptionStore, useSettingsStore } from '../store'; import { Button } from '../components/common/Button'; import { getCurrencySymbol } from '../utils/formatting'; -import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { spacing, typography, borderRadius } from '../utils/constants'; +import { useThemeColors } from '../hooks/useThemeColors'; import { advanceBillingDate } from '../utils/billingDate'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; @@ -35,12 +36,12 @@ const getDefaultNextBillingDate = (cycle: BillingCycle) => advanceBillingDate(ne const AddSubscriptionScreen: React.FC = () => { const navigation = useNavigation>(); + const route = useRoute>(); const colors = useThemeColors(); const styles = React.useMemo(() => createStyles(colors), [colors]); const { addSubscription, isLoading, error } = useSubscriptionStore(); const { preferredCurrency } = useSettingsStore(); const validation = validateAddSubscriptionParams(route.params ?? {}); - const validationErrors = validation.errors; const initialFormData = buildInitialFormData(preferredCurrency, validation.sanitised); // Ref for the name input — used for delayed focus instead of autoFocus, @@ -530,222 +531,222 @@ const AddSubscriptionScreen: React.FC = () => { function createStyles(colors: ReturnType) { return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.primary, - }, - keyboardAvoidingView: { - flex: 1, - }, - scrollView: { - flex: 1, - }, - scrollContentKeyboardOpen: { - paddingBottom: 120, - }, - header: { - padding: spacing.lg, - paddingBottom: spacing.md, - }, - headerContent: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.xs, - }, - cancelButton: { - padding: spacing.sm, - }, - cancelText: { - ...typography.body, - color: colors.primary, - fontWeight: '500', - }, - placeholderButton: { - width: 60, - }, - title: { - ...typography.h1, - color: colors.text, - textAlign: 'center', - }, - subtitle: { - ...typography.body, - color: colors.textSecondary, - textAlign: 'center', - }, - form: { - padding: spacing.lg, - paddingTop: 0, - }, - section: { - marginBottom: spacing.xl, - }, - sectionTitle: { - ...typography.h3, - color: colors.text, - marginBottom: spacing.md, - }, - inputGroup: { - marginBottom: spacing.md, - }, - label: { - ...typography.body, - color: colors.text, - marginBottom: spacing.xs, - fontWeight: '500', - }, - errorText: { - color: colors.error, - fontSize: 12, - marginTop: spacing.xs, - }, - textInput: { - backgroundColor: colors.surface, - padding: spacing.md, - borderRadius: borderRadius.md, - borderWidth: 1, - borderColor: colors.border, - color: colors.text, - ...typography.body, - }, - textArea: { - height: 80, - textAlignVertical: 'top', - }, - priceInputContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.surface, - borderRadius: borderRadius.md, - borderWidth: 1, - borderColor: colors.border, - paddingHorizontal: spacing.md, - }, - currencySymbol: { - ...typography.h3, - color: colors.textSecondary, - marginRight: spacing.sm, - }, - priceInput: { - flex: 1, - paddingVertical: spacing.md, - color: colors.text, - ...typography.h3, - fontWeight: '600', - }, - // Date picker styling - datePickerButton: { - backgroundColor: colors.surface, - padding: spacing.md, - borderRadius: borderRadius.md, - borderWidth: 1, - borderColor: colors.border, - justifyContent: 'center', - }, - datePickerText: { - ...typography.body, - color: colors.text, - }, - categoryGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.sm, - }, - categoryItem: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: borderRadius.full, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.border, - }, - categoryItemSelected: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - categoryText: { - ...typography.caption, - color: colors.text, - }, - categoryTextSelected: { - color: colors.text, - fontWeight: '600', - }, - billingCycleContainer: { - flexDirection: 'row', - gap: spacing.sm, - }, - billingCycleItem: { - flex: 1, - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: borderRadius.md, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center', - }, - billingCycleItemSelected: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - billingCycleText: { - ...typography.caption, - color: colors.text, - }, - billingCycleTextSelected: { - color: colors.text, - fontWeight: '600', - }, - cryptoOption: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - }, - cryptoToggle: { - padding: spacing.xs, - }, - toggleSwitch: { - width: 50, - height: 28, - backgroundColor: colors.border, - borderRadius: borderRadius.full, - padding: 2, - }, - toggleSwitchActive: { - backgroundColor: colors.primary, - }, - toggleKnob: { - width: 24, - height: 24, - backgroundColor: colors.background.card, - borderRadius: borderRadius.full, - }, - toggleKnobActive: { - transform: [{ translateX: 22 }], - }, - cryptoLabel: { - ...typography.body, - color: colors.text, - }, - notificationLabelWrap: { - flex: 1, - marginLeft: spacing.md, - }, - notificationHint: { - ...typography.caption, - color: colors.textSecondary, - marginTop: spacing.xs, - }, - footer: { - padding: spacing.lg, - paddingTop: spacing.md, - borderTopWidth: 1, - borderTopColor: colors.border, - backgroundColor: colors.background.primary, - }, -}); + container: { + flex: 1, + backgroundColor: colors.background.primary, + }, + keyboardAvoidingView: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContentKeyboardOpen: { + paddingBottom: 120, + }, + header: { + padding: spacing.lg, + paddingBottom: spacing.md, + }, + headerContent: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + cancelButton: { + padding: spacing.sm, + }, + cancelText: { + ...typography.body, + color: colors.primary, + fontWeight: '500', + }, + placeholderButton: { + width: 60, + }, + title: { + ...typography.h1, + color: colors.text, + textAlign: 'center', + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + }, + form: { + padding: spacing.lg, + paddingTop: 0, + }, + section: { + marginBottom: spacing.xl, + }, + sectionTitle: { + ...typography.h3, + color: colors.text, + marginBottom: spacing.md, + }, + inputGroup: { + marginBottom: spacing.md, + }, + label: { + ...typography.body, + color: colors.text, + marginBottom: spacing.xs, + fontWeight: '500', + }, + errorText: { + color: colors.error, + fontSize: 12, + marginTop: spacing.xs, + }, + textInput: { + backgroundColor: colors.surface, + padding: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + color: colors.text, + ...typography.body, + }, + textArea: { + height: 80, + textAlignVertical: 'top', + }, + priceInputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: spacing.md, + }, + currencySymbol: { + ...typography.h3, + color: colors.textSecondary, + marginRight: spacing.sm, + }, + priceInput: { + flex: 1, + paddingVertical: spacing.md, + color: colors.text, + ...typography.h3, + fontWeight: '600', + }, + // Date picker styling + datePickerButton: { + backgroundColor: colors.surface, + padding: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + justifyContent: 'center', + }, + datePickerText: { + ...typography.body, + color: colors.text, + }, + categoryGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + categoryItem: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + categoryItemSelected: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + categoryText: { + ...typography.caption, + color: colors.text, + }, + categoryTextSelected: { + color: colors.text, + fontWeight: '600', + }, + billingCycleContainer: { + flexDirection: 'row', + gap: spacing.sm, + }, + billingCycleItem: { + flex: 1, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + }, + billingCycleItemSelected: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + billingCycleText: { + ...typography.caption, + color: colors.text, + }, + billingCycleTextSelected: { + color: colors.text, + fontWeight: '600', + }, + cryptoOption: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + cryptoToggle: { + padding: spacing.xs, + }, + toggleSwitch: { + width: 50, + height: 28, + backgroundColor: colors.border, + borderRadius: borderRadius.full, + padding: 2, + }, + toggleSwitchActive: { + backgroundColor: colors.primary, + }, + toggleKnob: { + width: 24, + height: 24, + backgroundColor: colors.background.card, + borderRadius: borderRadius.full, + }, + toggleKnobActive: { + transform: [{ translateX: 22 }], + }, + cryptoLabel: { + ...typography.body, + color: colors.text, + }, + notificationLabelWrap: { + flex: 1, + marginLeft: spacing.md, + }, + notificationHint: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + footer: { + padding: spacing.lg, + paddingTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.border, + backgroundColor: colors.background.primary, + }, + }); } export default AddSubscriptionScreen; diff --git a/src/screens/AffiliateDashboardScreen.tsx b/src/screens/AffiliateDashboardScreen.tsx index 025a7759..edc1e4b1 100644 --- a/src/screens/AffiliateDashboardScreen.tsx +++ b/src/screens/AffiliateDashboardScreen.tsx @@ -7,8 +7,8 @@ import { SafeAreaView, TouchableOpacity, Alert, - ActivityIndicator, Modal, + TextInput, } from 'react-native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { useAffiliateStore } from '../store/affiliateStore'; @@ -22,15 +22,28 @@ const AffiliateDashboardScreen: React.FC = () => { programs, commissions, metrics, - isLoading, registerAffiliate, + trackClick, + trackReferral, + payoutCommission, updateAffiliateStatus, + triggerClawback, getMetrics, } = useAffiliateStore(); const address = useWalletStore(selectAddress); const [programModalVisible, setProgramModalVisible] = useState(false); const [selectedProgram, setSelectedProgram] = useState(''); + const [selectedAttributionModel, setSelectedAttributionModel] = useState< + 'first-touch' | 'last-touch' | 'linear' + >('last-touch'); + const [customCookieWindow, setCustomCookieWindow] = useState('30'); + + // Simulation Inputs + const [simSubscriptionId, setSimSubscriptionId] = useState('sub_premium_99'); + const [simAmount, setSimAmount] = useState('49.99'); + const [simIp, setSimIp] = useState('192.168.1.105'); + const [simUserAgent, setSimUserAgent] = useState('Mozilla/5.0 Chrome/120.0.0'); useEffect(() => { const currentMetrics = getMetrics(); @@ -38,18 +51,109 @@ const AffiliateDashboardScreen: React.FC = () => { }, [affiliates, commissions, getMetrics]); const handleRegister = useCallback(async () => { - if (!address) { - Alert.alert('Error', 'Please connect your wallet first'); - return; - } + // Fallback/Mock address if no wallet is connected + const activeAddress = address || '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; + if (!selectedProgram) { Alert.alert('Error', 'Please select a program'); return; } - await registerAffiliate(address, selectedProgram); + + await registerAffiliate(activeAddress, selectedProgram); + + // Sync newly chosen program parameters to default programs + const updatedPrograms = programs.map((p) => + p.id === selectedProgram + ? { + ...p, + attributionWindowDays: parseInt(customCookieWindow, 10) || 30, + attributionModel: selectedAttributionModel, + } + : p + ); + useAffiliateStore.setState({ programs: updatedPrograms }); + setProgramModalVisible(false); - Alert.alert('Success', 'You are now an affiliate!'); - }, [address, selectedProgram, registerAffiliate]); + Alert.alert('Success', 'Registered as an affiliate successfully!'); + }, [ + address, + selectedProgram, + registerAffiliate, + customCookieWindow, + selectedAttributionModel, + programs, + ]); + + const handlePayout = useCallback( + async (affiliateId: string) => { + const affiliate = affiliates.find((a) => a.id === affiliateId); + if (!affiliate) return; + + if (affiliate.pendingPayout < affiliate.paymentThreshold) { + Alert.alert( + 'Payout Threshold', + `Required: $${affiliate.paymentThreshold.toFixed(2)}. Current pending balance: $${affiliate.pendingPayout.toFixed(2)}` + ); + return; + } + + try { + await payoutCommission(affiliateId); + Alert.alert('Success', 'Your payout request was processed instantly!'); + } catch (err) { + Alert.alert('Error', err instanceof Error ? err.message : 'Payout failed'); + } + }, + [affiliates, payoutCommission] + ); + + const handleCopyLink = useCallback((link: string) => { + Alert.alert('Copied to Clipboard!', link); + }, []); + + const handleSimulationClick = useCallback( + async (referralCode: string) => { + try { + await trackClick(referralCode, simIp, simUserAgent); + Alert.alert('Success', `Simulated referral link click! Click logged for ${referralCode}`); + } catch (err) { + Alert.alert('Blocked', err instanceof Error ? err.message : 'Click limit reached'); + } + }, + [trackClick, simIp, simUserAgent] + ); + + const handleSimulationConversion = useCallback( + async (affiliateId: string) => { + try { + const amt = parseFloat(simAmount) || 29.99; + await trackReferral( + affiliateId, + simSubscriptionId, + amt, + simIp, + simUserAgent, + undefined, + selectedAttributionModel + ); + Alert.alert('Success', 'Simulated recurring subscription sign up! Commission tracked.'); + } catch (err) { + Alert.alert( + 'Blocked by Fraud Engine', + err instanceof Error ? err.message : 'Conversion rejected' + ); + } + }, + [trackReferral, simSubscriptionId, simAmount, simIp, simUserAgent, selectedAttributionModel] + ); + + const handleSimulationClawback = useCallback(async () => { + await triggerClawback(simSubscriptionId); + Alert.alert( + 'Success', + `Subscription ${simSubscriptionId} cancelled. Commissions within period clawed back successfully.` + ); + }, [triggerClawback, simSubscriptionId]); const handleToggleStatus = useCallback( async (affiliateId: string, newStatus: AffiliateStatus) => { @@ -60,90 +164,204 @@ const AffiliateDashboardScreen: React.FC = () => { const renderMetricsCard = () => ( - Performance Overview + Real-time Performance Metrics - {metrics.totalReferrals} - Total Referrals + {metrics.totalClicks || 0} + Total Clicks - {metrics.activeReferrals} - Active + {metrics.totalReferrals} + Conversions - ${metrics.totalEarnings.toFixed(2)} + + ${metrics.totalEarnings.toFixed(2)} + Total Earnings - ${metrics.pendingPayout.toFixed(2)} - Pending + + ${metrics.pendingPayout.toFixed(2)} + + Pending Payout Conversion Rate - {metrics.conversionRate.toFixed(1)}% + {(metrics.conversionRate || 0).toFixed(1)}% ); const renderAffiliateList = () => ( - Your Affiliates + Referral Configuration & Operations {affiliates.length === 0 ? ( - No affiliates yet + + No registered programs yet. Join a program below to start earning. + ) : ( - affiliates.map((affiliate) => ( - - - - {affiliate.referrerAddress.slice(0, 6)}... - {affiliate.referrerAddress.slice(-4)} - - - {affiliate.totalReferrals} referrals - - ${affiliate.totalEarnings.toFixed(2)} earned - - - - - - {affiliate.status} + affiliates.map((affiliate) => { + const currentProg = programs.find((p) => p.id === affiliate.programId); + return ( + + + + + {currentProg?.name || 'Basic Program'} + + Code: {affiliate.referralCode} + + + + {affiliate.fraudStatus === 'flagged' + ? '⚠️ Suspended' + : affiliate.fraudStatus === 'suspicious' + ? '⚠️ Suspicious' + : '🛡️ Secure'} + + - {affiliate.status === AffiliateStatus.ACTIVE ? ( + + {/* Referral Link & Custom Payout Controls */} + + + {affiliate.referralLink} + handleToggleStatus(affiliate.id, AffiliateStatus.PAUSED)}> - Pause + style={styles.copyButton} + onPress={() => handleCopyLink(affiliate.referralLink || '')}> + Copy Link - ) : ( + + + + Clicks: {affiliate.clicksCount || 0} + Threshold: ${affiliate.paymentThreshold} + Risk: {affiliate.fraudRiskScore || 0}% + + + handleToggleStatus(affiliate.id, AffiliateStatus.ACTIVE)}> - Resume + style={[ + styles.payoutActionButton, + affiliate.pendingPayout < affiliate.paymentThreshold && styles.disabledButton, + ]} + onPress={() => handlePayout(affiliate.id)}> + Request Payout - )} + + {affiliate.status === AffiliateStatus.ACTIVE ? ( + handleToggleStatus(affiliate.id, AffiliateStatus.PAUSED)}> + Pause + + ) : ( + handleToggleStatus(affiliate.id, AffiliateStatus.ACTIVE)}> + Resume + + )} + + + {/* SIMULATION BENCH */} + + Sandbox Simulator Bench + + Simulate visitor flow to test multi-touch attribution, fraud mitigation, and churn + clawbacks. + + + + + Sub ID + + + + Amount ($) + + + + + + + Visitor IP + + + + User Agent + + + + + + handleSimulationClick(affiliate.referralCode || '')}> + 1. Sim Click + + + handleSimulationConversion(affiliate.id)}> + 2. Sim Convert + + + + 3. Sim Churn + + + - - )) + ); + }) )} ); const renderProgramsCard = () => ( - Available Programs + Available Affiliate Programs {programs.map((program) => ( { {program.name} {program.description} + + Attribution: {program.attributionModel || 'last-touch'} •{' '} + {program.attributionWindowDays} days cookie + @@ -164,7 +386,7 @@ const AffiliateDashboardScreen: React.FC = () => { ? `$${program.commissionConfig.rate}` : 'Tiered'} - commission + Commission ))} @@ -173,65 +395,79 @@ const AffiliateDashboardScreen: React.FC = () => { const renderCommissionsList = () => ( - Recent Commissions + Recent Commission Ledger & Clawbacks {commissions.length === 0 ? ( - No commissions yet + No commissions tracked yet. ) : ( - commissions.slice(0, 5).map((commission) => ( - - - - Sub: {commission.subscriptionId.slice(0, 8)}... - - - {new Date(commission.createdAt).toLocaleDateString()} - - - - ${commission.amount.toFixed(2)} - - {commission.status} + commissions + .slice() + .reverse() + .slice(0, 5) + .map((commission) => ( + + + Sub: {commission.subscriptionId} + + {new Date(commission.createdAt).toLocaleDateString()} + + + + + ${commission.amount.toFixed(2)} + + + + {commission.isClawbacked ? 'Clawed back' : commission.status} + + - - )) + )) )} ); - if (isLoading) { - return ( - - - - Loading... - - - ); - } - return ( - Affiliate Dashboard - Track referrals and earn commissions + Affiliate & Referral Engine + + Create marketing campaigns, track recurring conversions, mitigate fraud, and handle + payouts. + {renderMetricsCard()} - {renderProgramsCard()} {renderAffiliateList()} + {renderProgramsCard()} {renderCommissionsList()} { onPress={() => setProgramModalVisible(true)} accessibilityRole="button" accessibilityLabel="Register as affiliate"> - Become an Affiliate + Launch New Affiliate Campaign + {/* Program Config Modal */} setProgramModalVisible(false)}> - - Select Program - Choose an affiliate program to join - - {programs.map((program) => ( - setSelectedProgram(program.id)}> - - {program.name} - {program.description} - - + + Configure Affiliate Program + + Join a program and configure tracking constraints. + + + {programs.map((program) => ( + - {selectedProgram === program.id && } - - - ))} - - - setProgramModalVisible(false)}> - Cancel - - - Join Program - + styles.programOption, + selectedProgram === program.id && styles.programOptionSelected, + ]} + onPress={() => setSelectedProgram(program.id)}> + + {program.name} + {program.description} + + + {selectedProgram === program.id && } + + + ))} + + Attribution & Window Parameters + + Cookie Validity Window (Days) + + + Attribution Model + + {(['first-touch', 'last-touch', 'linear'] as const).map((model) => ( + setSelectedAttributionModel(model)}> + + {model} + + + ))} + + + + setProgramModalVisible(false)}> + Cancel + + + Join Program + + - + ); }; +// Extracted styles from theme safely +const programOptionInfo = { + flex: 1, +}; + const styles = StyleSheet.create({ container: { flex: 1, @@ -300,16 +578,6 @@ const styles = StyleSheet.create({ scrollView: { flex: 1, }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loadingText: { - marginTop: spacing.sm, - color: colors.textSecondary, - fontSize: typography.body2.fontSize, - }, header: { padding: spacing.md, paddingTop: spacing.lg, @@ -320,14 +588,16 @@ const styles = StyleSheet.create({ color: colors.text, }, subtitle: { - fontSize: typography.body2.fontSize, + fontSize: typography.caption.fontSize, color: colors.textSecondary, marginTop: spacing.xs, + lineHeight: 20, }, metricsCard: { padding: spacing.md, margin: spacing.md, marginTop: 0, + backgroundColor: colors.surface, }, metricsTitle: { fontSize: typography.body.fontSize, @@ -345,7 +615,7 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: spacing.md, marginBottom: spacing.sm, - backgroundColor: colors.surface, + backgroundColor: 'rgba(255, 255, 255, 0.03)', borderRadius: borderRadius.md, }, metricValue: { @@ -365,13 +635,14 @@ const styles = StyleSheet.create({ paddingTop: spacing.md, borderTopWidth: 1, borderTopColor: colors.border, + marginTop: spacing.xs, }, conversionLabel: { fontSize: typography.body2.fontSize, color: colors.textSecondary, }, conversionValue: { - fontSize: typography.body2.fontSize, + fontSize: typography.body.fontSize, fontWeight: '700', color: colors.primary, }, @@ -379,6 +650,7 @@ const styles = StyleSheet.create({ padding: spacing.md, margin: spacing.md, marginTop: 0, + backgroundColor: colors.surface, }, listTitle: { fontSize: typography.body.fontSize, @@ -391,66 +663,177 @@ const styles = StyleSheet.create({ color: colors.textSecondary, textAlign: 'center', paddingVertical: spacing.lg, + lineHeight: 20, }, - affiliateItem: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingVertical: spacing.sm, + affiliateContainer: { + paddingVertical: spacing.md, borderBottomWidth: 1, borderBottomColor: colors.border, }, - affiliateInfo: { - flex: 1, + affiliateHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', }, - affiliateAddress: { - fontSize: typography.body2.fontSize, + affiliateProgramName: { + fontSize: typography.body.fontSize, + fontWeight: '700', color: colors.text, }, - affiliateStats: { + affiliateCode: { + fontSize: typography.body2.fontSize, + color: colors.textSecondary, + marginTop: 2, + }, + fraudBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: borderRadius.sm, + }, + fraudBadgeText: { + fontSize: typography.small.fontSize, + fontWeight: '700', + }, + referralLinkSection: { flexDirection: 'row', - marginTop: spacing.xs, - gap: spacing.md, + alignItems: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.02)', + borderRadius: borderRadius.md, + padding: spacing.sm, + marginTop: spacing.md, + borderWidth: 1, + borderColor: colors.border, }, - affiliateStat: { + referralLinkLabel: { + flex: 1, fontSize: typography.small.fontSize, color: colors.textSecondary, }, - affiliateActions: { - alignItems: 'flex-end', - }, - statusBadge: { + copyButton: { + backgroundColor: colors.primary, paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, + paddingVertical: 6, borderRadius: borderRadius.sm, - marginBottom: spacing.xs, + marginLeft: spacing.sm, }, - statusBadgeText: { - color: colors.text, + copyButtonText: { + color: '#ffffff', fontSize: typography.small.fontSize, - fontWeight: '600', - textTransform: 'capitalize', + fontWeight: '700', + }, + affiliateStatsRow: { + flexDirection: 'row', + marginTop: spacing.md, + gap: spacing.lg, + }, + statMini: { + fontSize: typography.small.fontSize, + color: colors.textSecondary, + }, + actionButtonsRow: { + flexDirection: 'row', + marginTop: spacing.md, + gap: spacing.md, + }, + payoutActionButton: { + backgroundColor: colors.success, + borderRadius: borderRadius.sm, + paddingVertical: 8, + paddingHorizontal: spacing.md, + alignItems: 'center', + }, + disabledButton: { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + payoutActionText: { + color: colors.text, + fontSize: typography.body2.fontSize, + fontWeight: '700', }, pauseButton: { - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: 8, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.warning, + justifyContent: 'center', }, pauseButtonText: { color: colors.warning, - fontSize: typography.small.fontSize, + fontSize: typography.body2.fontSize, + fontWeight: '700', }, resumeButton: { - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: 8, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.success, + justifyContent: 'center', }, resumeButtonText: { color: colors.success, + fontSize: typography.body2.fontSize, + fontWeight: '700', + }, + simulatorBench: { + marginTop: spacing.lg, + padding: spacing.md, + backgroundColor: 'rgba(255, 255, 255, 0.02)', + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.05)', + }, + simulatorTitle: { + fontSize: typography.body2.fontSize, + fontWeight: '700', + color: colors.text, + }, + simulatorSubtitle: { fontSize: typography.small.fontSize, + color: colors.textSecondary, + marginTop: 2, + lineHeight: 16, + }, + simInputsRow: { + flexDirection: 'row', + gap: spacing.md, + marginTop: spacing.sm, + }, + simInputBox: { + flex: 1, + }, + inputLabel: { + fontSize: typography.small.fontSize, + color: colors.textSecondary, + marginBottom: 4, + }, + simInput: { + backgroundColor: colors.background, + borderColor: colors.border, + borderWidth: 1, + borderRadius: borderRadius.sm, + padding: 6, + color: colors.text, + fontSize: typography.body2.fontSize, + }, + simButtonsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: spacing.md, + gap: spacing.xs, + }, + simBtn: { + flex: 1, + backgroundColor: 'rgba(255,255,255,0.08)', + paddingVertical: 8, + borderRadius: borderRadius.sm, + alignItems: 'center', + }, + simBtnText: { + fontSize: 11, + fontWeight: '700', + color: colors.text, }, programItem: { flexDirection: 'row', @@ -465,12 +848,17 @@ const styles = StyleSheet.create({ programName: { fontSize: typography.body.fontSize, color: colors.text, - fontWeight: '600', + fontWeight: '700', }, programDescription: { fontSize: typography.body2.fontSize, color: colors.textSecondary, - marginTop: spacing.xs, + marginTop: 4, + }, + programSubMeta: { + fontSize: typography.small.fontSize, + color: colors.primary, + marginTop: 6, }, programRate: { alignItems: 'flex-end', @@ -497,11 +885,12 @@ const styles = StyleSheet.create({ commissionId: { fontSize: typography.body2.fontSize, color: colors.text, + fontWeight: '700', }, commissionDate: { fontSize: typography.small.fontSize, color: colors.textSecondary, - marginTop: spacing.xs, + marginTop: 4, }, commissionAmount: { alignItems: 'flex-end', @@ -515,12 +904,12 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.sm, paddingVertical: 2, borderRadius: borderRadius.sm, - marginTop: spacing.xs, + marginTop: 4, }, commissionStatusText: { color: colors.text, fontSize: typography.small.fontSize, - fontWeight: '600', + fontWeight: '700', textTransform: 'capitalize', }, registerButton: { @@ -531,13 +920,17 @@ const styles = StyleSheet.create({ alignItems: 'center', }, registerButtonText: { - color: colors.text, + color: '#ffffff', fontSize: typography.body.fontSize, fontWeight: '700', }, modalOverlay: { flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', + backgroundColor: 'rgba(0, 0, 0, 0.7)', + justifyContent: 'flex-end', + }, + modalScroll: { + flexGrow: 1, justifyContent: 'flex-end', }, modalContent: { @@ -545,7 +938,6 @@ const styles = StyleSheet.create({ borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, padding: spacing.lg, - maxHeight: '70%', }, modalTitle: { fontSize: typography.h3.fontSize, @@ -565,23 +957,22 @@ const styles = StyleSheet.create({ borderRadius: borderRadius.md, marginBottom: spacing.sm, backgroundColor: colors.background, + borderWidth: 1, + borderColor: colors.border, }, programOptionSelected: { borderWidth: 2, borderColor: colors.primary, }, - programOptionInfo: { - flex: 1, - }, programOptionName: { fontSize: typography.body.fontSize, - fontWeight: '600', + fontWeight: '700', color: colors.text, }, programOptionDesc: { fontSize: typography.body2.fontSize, color: colors.textSecondary, - marginTop: spacing.xs, + marginTop: 4, }, radioCircle: { width: 24, @@ -601,6 +992,55 @@ const styles = StyleSheet.create({ borderRadius: 6, backgroundColor: colors.primary, }, + sectionLabelHeader: { + fontSize: typography.body.fontSize, + fontWeight: '700', + color: colors.text, + marginTop: spacing.md, + marginBottom: spacing.xs, + }, + inputTitleLabel: { + fontSize: typography.small.fontSize, + color: colors.textSecondary, + marginTop: spacing.sm, + marginBottom: 4, + }, + modalTextInput: { + backgroundColor: colors.background, + borderColor: colors.border, + borderWidth: 1, + borderRadius: borderRadius.sm, + padding: 10, + color: colors.text, + fontSize: typography.body2.fontSize, + marginBottom: spacing.sm, + }, + optionsSelectorRow: { + flexDirection: 'row', + gap: spacing.sm, + marginBottom: spacing.md, + }, + optionSelectorItem: { + flex: 1, + backgroundColor: colors.background, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.sm, + paddingVertical: 10, + alignItems: 'center', + }, + optionSelectorItemSelected: { + borderColor: colors.primary, + backgroundColor: 'rgba(255,255,255,0.03)', + }, + optionSelectorText: { + fontSize: 12, + color: colors.textSecondary, + }, + optionSelectorTextActive: { + color: colors.primary, + fontWeight: '700', + }, modalButtons: { flexDirection: 'row', justifyContent: 'space-between', @@ -613,11 +1053,13 @@ const styles = StyleSheet.create({ borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, }, cancelButtonText: { color: colors.text, fontSize: typography.body.fontSize, - fontWeight: '600', + fontWeight: '700', }, confirmButton: { flex: 1, @@ -627,7 +1069,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, confirmButtonText: { - color: colors.text, + color: colors.background, fontSize: typography.body.fontSize, fontWeight: '700', }, diff --git a/src/screens/AnalyticsScreen.tsx b/src/screens/AnalyticsScreen.tsx index bbaa3013..76d3f582 100644 --- a/src/screens/AnalyticsScreen.tsx +++ b/src/screens/AnalyticsScreen.tsx @@ -394,89 +394,89 @@ const AnalyticsScreen: React.FC = () => { function createStyles(colors: ReturnType) { return StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.background.primary }, - scrollView: { flex: 1 }, - header: { padding: spacing.lg, paddingBottom: spacing.md }, - title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs }, - subtitle: { ...typography.body, color: colors.textSecondary }, - dateRangeContainer: { - flexDirection: 'row', - paddingHorizontal: spacing.lg, - marginBottom: spacing.md, - gap: spacing.sm, - }, - dateRangeButton: { - flex: 1, - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: borderRadius.md, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center', - }, - dateRangeButtonActive: { backgroundColor: colors.primary, borderColor: colors.primary }, - dateRangeButtonText: { ...typography.body, color: colors.text }, - dateRangeButtonTextActive: { color: colors.text, fontWeight: '600' }, - summaryContainer: { - flexDirection: 'row', - paddingHorizontal: spacing.lg, - marginBottom: spacing.md, - gap: spacing.md, - }, - summaryCard: { flex: 1, alignItems: 'center' }, - summaryLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, - summaryValue: { ...typography.h2, color: colors.text }, - chartCard: { marginHorizontal: spacing.lg, marginBottom: spacing.md }, - chartTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, - categoryList: { gap: spacing.md }, - categoryItem: { marginBottom: spacing.sm }, - categoryLeft: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs }, - categoryIcon: { fontSize: 20, marginRight: spacing.sm }, - categoryName: { ...typography.body, color: colors.text, flex: 1 }, - categoryRight: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - position: 'absolute', - right: 0, - top: 0, - }, - categoryCount: { ...typography.body, color: colors.text, fontWeight: '600' }, - categoryPercentage: { - ...typography.caption, - color: colors.textSecondary, - width: 50, - textAlign: 'right', - }, - categoryBarContainer: { - height: 8, - backgroundColor: colors.border, - borderRadius: borderRadius.full, - overflow: 'hidden', - }, - categoryBar: { height: '100%', borderRadius: borderRadius.full }, - noDataText: { - ...typography.body, - color: colors.textSecondary, - textAlign: 'center', - paddingVertical: spacing.lg, - }, - projectionCard: { marginHorizontal: spacing.lg, marginBottom: spacing.lg }, - projectionItem: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingVertical: spacing.md, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - projectionItemLast: { borderBottomWidth: 0 }, - projectionLabel: { ...typography.body, color: colors.textSecondary }, - projectionValue: { ...typography.body, color: colors.text, fontWeight: '600' }, - emptyState: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: spacing.xl }, - emptyIcon: { fontSize: 64, marginBottom: spacing.md }, - emptyTitle: { ...typography.h2, color: colors.text, marginBottom: spacing.sm }, - emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' }, + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + dateRangeContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.sm, + }, + dateRangeButton: { + flex: 1, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + }, + dateRangeButtonActive: { backgroundColor: colors.primary, borderColor: colors.primary }, + dateRangeButtonText: { ...typography.body, color: colors.text }, + dateRangeButtonTextActive: { color: colors.text, fontWeight: '600' }, + summaryContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.md, + }, + summaryCard: { flex: 1, alignItems: 'center' }, + summaryLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + summaryValue: { ...typography.h2, color: colors.text }, + chartCard: { marginHorizontal: spacing.lg, marginBottom: spacing.md }, + chartTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, + categoryList: { gap: spacing.md }, + categoryItem: { marginBottom: spacing.sm }, + categoryLeft: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs }, + categoryIcon: { fontSize: 20, marginRight: spacing.sm }, + categoryName: { ...typography.body, color: colors.text, flex: 1 }, + categoryRight: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + position: 'absolute', + right: 0, + top: 0, + }, + categoryCount: { ...typography.body, color: colors.text, fontWeight: '600' }, + categoryPercentage: { + ...typography.caption, + color: colors.textSecondary, + width: 50, + textAlign: 'right', + }, + categoryBarContainer: { + height: 8, + backgroundColor: colors.border, + borderRadius: borderRadius.full, + overflow: 'hidden', + }, + categoryBar: { height: '100%', borderRadius: borderRadius.full }, + noDataText: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + paddingVertical: spacing.lg, + }, + projectionCard: { marginHorizontal: spacing.lg, marginBottom: spacing.lg }, + projectionItem: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + projectionItemLast: { borderBottomWidth: 0 }, + projectionLabel: { ...typography.body, color: colors.textSecondary }, + projectionValue: { ...typography.body, color: colors.text, fontWeight: '600' }, + emptyState: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: spacing.xl }, + emptyIcon: { fontSize: 64, marginBottom: spacing.md }, + emptyTitle: { ...typography.h2, color: colors.text, marginBottom: spacing.sm }, + emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' }, }); } diff --git a/src/screens/ApiKeyManagementScreen.tsx b/src/screens/ApiKeyManagementScreen.tsx index b418c804..f38afbc2 100644 --- a/src/screens/ApiKeyManagementScreen.tsx +++ b/src/screens/ApiKeyManagementScreen.tsx @@ -44,7 +44,10 @@ const ApiKeyManagementScreen: React.FC = () => { const handleCopyKey = (key?: string) => { if (!key) { - Alert.alert('Key unavailable', 'This API key is only visible once and cannot be copied again.'); + Alert.alert( + 'Key unavailable', + 'This API key is only visible once and cannot be copied again.' + ); return; } Clipboard.setString(key); diff --git a/src/screens/ApiKeysScreen.tsx b/src/screens/ApiKeysScreen.tsx index 34b75317..c4c30879 100644 --- a/src/screens/ApiKeysScreen.tsx +++ b/src/screens/ApiKeysScreen.tsx @@ -23,15 +23,8 @@ const USAGE_TIERS = [ ] as const; const ApiKeysScreen: React.FC = () => { - const { - apiKeys, - createApiKey, - revokeApiKey, - rotateApiKey, - deleteApiKey, - getKeyStats, - maskKey, - } = useApiStore(); + const { apiKeys, createApiKey, revokeApiKey, rotateApiKey, deleteApiKey, getKeyStats, maskKey } = + useApiStore(); const [newKeyName, setNewKeyName] = useState(''); const [selectedTier, setSelectedTier] = useState('free'); @@ -45,7 +38,10 @@ const ApiKeysScreen: React.FC = () => { return; } - const key = createApiKey(newKeyName.trim(), selectedTier as 'free' | 'basic' | 'pro' | 'enterprise'); + const key = createApiKey( + newKeyName.trim(), + selectedTier as 'free' | 'basic' | 'pro' | 'enterprise' + ); setShowNewKey(key.key); setNewKeyName(''); Alert.alert( @@ -60,10 +56,14 @@ const ApiKeysScreen: React.FC = () => { }; const handleRevokeKey = (keyId: string, keyName: string) => { - Alert.alert('Revoke API Key', `Revoke "${keyName}"? This will immediately invalidate the key.`, [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Revoke', style: 'destructive', onPress: () => revokeApiKey(keyId) }, - ]); + Alert.alert( + 'Revoke API Key', + `Revoke "${keyName}"? This will immediately invalidate the key.`, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Revoke', style: 'destructive', onPress: () => revokeApiKey(keyId) }, + ] + ); }; const handleRotateKey = (keyId: string, keyName: string) => { @@ -94,9 +94,7 @@ const ApiKeysScreen: React.FC = () => { API Keys - - Manage API keys with rate limiting and usage metering - + Manage API keys with rate limiting and usage metering @@ -135,18 +133,10 @@ const ApiKeysScreen: React.FC = () => { {USAGE_TIERS.map((tier) => ( setSelectedTier(tier.key)} - > + style={[styles.tierCard, selectedTier === tier.key && styles.tierCardSelected]} + onPress={() => setSelectedTier(tier.key)}> + style={[styles.tierLabel, selectedTier === tier.key && styles.tierLabelSelected]}> {tier.label} {tier.desc} @@ -171,16 +161,10 @@ const ApiKeysScreen: React.FC = () => { - handleCopyKey(showNewKey)} - > + handleCopyKey(showNewKey)}> Copy Key - setShowNewKey(null)} - > + setShowNewKey(null)}> Dismiss @@ -192,9 +176,7 @@ const ApiKeysScreen: React.FC = () => { {apiKeys.length === 0 ? ( No API keys yet - - Generate an API key above to get started - + Generate an API key above to get started ) : ( apiKeys.map((key) => ( @@ -210,11 +192,10 @@ const ApiKeysScreen: React.FC = () => { key.status === ApiKeyStatus.ACTIVE ? colors.success : key.status === ApiKeyStatus.REVOKED - ? colors.error - : colors.warning, + ? colors.error + : colors.warning, }, - ]} - > + ]}> {key.status} @@ -242,20 +223,17 @@ const ApiKeysScreen: React.FC = () => { <> handleCopyKey(key.key)} - > + onPress={() => handleCopyKey(key.key)}> Copy handleRotateKey(key.id, key.name)} - > + onPress={() => handleRotateKey(key.id, key.name)}> Rotate handleRevokeKey(key.id, key.name)} - > + onPress={() => handleRevokeKey(key.id, key.name)}> Revoke @@ -264,8 +242,7 @@ const ApiKeysScreen: React.FC = () => { )} handleDeleteKey(key.id, key.name)} - > + onPress={() => handleDeleteKey(key.id, key.name)}> Delete diff --git a/src/screens/BillingSettingsScreen.tsx b/src/screens/BillingSettingsScreen.tsx index 0dc80d39..940c58fe 100644 --- a/src/screens/BillingSettingsScreen.tsx +++ b/src/screens/BillingSettingsScreen.tsx @@ -92,7 +92,6 @@ const BillingSettingsScreen: React.FC = () => { const { schedules, - invoices, setMerchantCalendarBilling, removeMerchantCalendarBilling, advanceSchedule, @@ -109,11 +108,9 @@ const BillingSettingsScreen: React.FC = () => { // ── Local form state ─────────────────────────────────────────────────── - const [config, setConfig] = useState( - existingSchedule?.config ?? DEFAULT_CONFIG - ); + const [config, setConfig] = useState(existingSchedule?.config ?? DEFAULT_CONFIG); const [showDayPicker, setShowDayPicker] = useState(false); - const [showProRataDemo, setShowProRataDemo] = useState(false); + const [_showProRataDemo, _setShowProRataDemo] = useState(false); const [proRataJoinDate, setProRataJoinDate] = useState(new Date()); const [showDatePicker, setShowDatePicker] = useState(false); @@ -223,18 +220,26 @@ const BillingSettingsScreen: React.FC = () => { Active schedule Billing day - {ordinal(existingSchedule.config.day_of_month)} + + {ordinal(existingSchedule.config.day_of_month)} + Interval - {INTERVAL_OPTIONS.find((o) => o.value === existingSchedule.config.billing_months_interval)?.label ?? `Every ${existingSchedule.config.billing_months_interval} months`} + {INTERVAL_OPTIONS.find( + (o) => o.value === existingSchedule.config.billing_months_interval + )?.label ?? `Every ${existingSchedule.config.billing_months_interval} months`} Short-month policy - {ADJUSTMENT_POLICY_OPTIONS.find((o) => o.value === existingSchedule.config.adjustment_policy)?.label} + { + ADJUSTMENT_POLICY_OPTIONS.find( + (o) => o.value === existingSchedule.config.adjustment_policy + )?.label + } @@ -262,10 +267,7 @@ const BillingSettingsScreen: React.FC = () => { {showDayPicker ? ( - + {DAY_OPTIONS.map((day) => ( { Subscription join date - setShowDatePicker(true)}> + setShowDatePicker(true)}> {proRataJoinDate.toLocaleDateString('en-US', { year: 'numeric', diff --git a/src/screens/CancellationFlowScreen.tsx b/src/screens/CancellationFlowScreen.tsx index 728845ce..371cc9cb 100644 --- a/src/screens/CancellationFlowScreen.tsx +++ b/src/screens/CancellationFlowScreen.tsx @@ -12,14 +12,6 @@ import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { RootStackParamList } from '../navigation/types'; -import { useCancellationStore } from '../store/cancellationStore'; -import { useSubscriptionStore } from '../store'; - -type Props = NativeStackScreenProps; - -const CancellationFlowScreen: React.FC = ({ route, navigation }) => { - const { currentStep, setReason, setStep, acceptOffer, reset } = useCancellationStore(); - const { deleteSubscription } = useSubscriptionStore(); import { useCancellationStore, CANCELLATION_REASONS } from '../store/cancellationStore'; import { RetentionOffer } from '../../backend/services/retentionService'; @@ -211,22 +203,6 @@ const CancellationFlowScreen: React.FC = ({ route, navigation }) => { case 'OFFERS': return renderOffersStep(); case 'CONFIRM': - return ( - - Are you sure? - - Your access will continue until the end of the billing period. - -