diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 230b137c..94161b3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js diff --git a/app/screens/AnalyticsDashboard.tsx b/app/screens/AnalyticsDashboard.tsx index 3495c275..d8929172 100644 --- a/app/screens/AnalyticsDashboard.tsx +++ b/app/screens/AnalyticsDashboard.tsx @@ -170,7 +170,7 @@ function createStyles(colors: ReturnType) { container: { flex: 1, backgroundColor: colors.background.primary }, scrollView: { flex: 1 }, header: { padding: spacing.lg, paddingBottom: spacing.sm }, - title: { ...typography.h1, color: colors.text }, + title: { ...typography.h1, color: colors.text.primary }, subtitle: { ...typography.body, color: colors.textSecondary, marginTop: spacing.xs }, row: { flexDirection: 'row', @@ -180,19 +180,19 @@ function createStyles(colors: ReturnType) { }, metricCard: { flex: 1, alignItems: 'center' }, metricLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, - metricValue: { ...typography.h2, color: colors.text }, + metricValue: { ...typography.h2, color: colors.text.primary }, card: { marginHorizontal: spacing.lg, marginBottom: spacing.md }, - sectionTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, statRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, - borderBottomColor: colors.border, + borderBottomColor: colors.border.default, }, lastRow: { borderBottomWidth: 0 }, statLabel: { ...typography.body, color: colors.textSecondary }, - statValue: { ...typography.body, color: colors.text, fontWeight: '600' }, + statValue: { ...typography.body, color: colors.text.primary, fontWeight: '600' }, emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' }, exportContainer: { padding: spacing.lg, paddingTop: 0, marginBottom: spacing.xl }, loadingText: { ...typography.body, color: colors.textSecondary, padding: spacing.lg }, diff --git a/app/screens/PaymentMethodsScreen.tsx b/app/screens/PaymentMethodsScreen.tsx index 29cfc8a3..ae51726a 100644 --- a/app/screens/PaymentMethodsScreen.tsx +++ b/app/screens/PaymentMethodsScreen.tsx @@ -255,14 +255,14 @@ function createStyles(colors: ReturnType) { container: { flex: 1, backgroundColor: colors.background.primary }, scrollView: { flex: 1 }, card: { margin: spacing.lg, marginBottom: spacing.md }, - sectionTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, input: { ...typography.body, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, borderRadius: borderRadius.md, padding: spacing.md, - color: colors.text, + color: colors.text.primary, backgroundColor: colors.surface, marginBottom: spacing.md, }, @@ -273,12 +273,12 @@ function createStyles(colors: ReturnType) { padding: spacing.sm, borderRadius: borderRadius.md, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, alignItems: 'center', }, priorityOptionActive: { backgroundColor: colors.primary, borderColor: colors.primary }, - priorityOptionText: { ...typography.caption, color: colors.text }, - priorityOptionTextActive: { color: colors.text, fontWeight: '600' }, + priorityOptionText: { ...typography.caption, color: colors.text.primary }, + priorityOptionTextActive: { color: colors.text.inverse, fontWeight: '600' }, methodsSection: { padding: spacing.lg, paddingTop: 0 }, methodCard: { marginBottom: spacing.md }, methodHeader: { @@ -286,7 +286,7 @@ function createStyles(colors: ReturnType) { justifyContent: 'space-between', marginBottom: spacing.sm, }, - methodLabel: { ...typography.body, color: colors.text, fontWeight: '600' }, + methodLabel: { ...typography.body, color: colors.text.primary, fontWeight: '600' }, methodToken: { ...typography.caption, color: colors.textSecondary }, methodBadges: { flexDirection: 'row', gap: spacing.sm, alignItems: 'center' }, priorityBadge: { paddingHorizontal: spacing.sm, paddingVertical: 2, borderRadius: borderRadius.sm }, @@ -299,14 +299,14 @@ function createStyles(colors: ReturnType) { flexWrap: 'wrap', marginTop: spacing.sm, }, - expiringItem: { ...typography.body, color: colors.text, marginBottom: spacing.xs }, + expiringItem: { ...typography.body, color: colors.text.primary, marginBottom: spacing.xs }, emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' }, attemptRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.xs, }, - attemptLabel: { ...typography.caption, color: colors.text }, + attemptLabel: { ...typography.caption, color: colors.text.primary }, attemptStatus: { ...typography.caption, fontWeight: '600' }, }); } diff --git a/backend/services/accessControl.ts b/backend/services/accessControl.ts index 1d5a4b1f..2fb9f68d 100644 --- a/backend/services/accessControl.ts +++ b/backend/services/accessControl.ts @@ -5,9 +5,9 @@ */ import { randomUUID } from 'crypto'; -import { AuditService } from './auditService'; -import { AlertingService } from './alerting'; -import type { Alert } from './types'; +import { AuditService } from './shared/auditService'; +import { AlertingService } from './notification/alerting'; +import type { Alert } from './shared/types'; // ─── Resource & Action Types ────────────────────────────────────────────────── diff --git a/backend/services/affiliate/AffiliateService.ts b/backend/services/affiliate/AffiliateService.ts index 6d5759bb..9e3aec8b 100644 --- a/backend/services/affiliate/AffiliateService.ts +++ b/backend/services/affiliate/AffiliateService.ts @@ -1,5 +1,5 @@ -import { AuditService } from '../auditService'; -import type { AuditAction } from '../auditTypes'; +import { AuditService } from '../shared/auditService'; +import type { AuditAction } from '../shared/auditTypes'; import { Affiliate, AffiliateProgram, diff --git a/backend/services/paymentTimeoutService.ts b/backend/services/paymentTimeoutService.ts index 183798de..edeef8a2 100644 --- a/backend/services/paymentTimeoutService.ts +++ b/backend/services/paymentTimeoutService.ts @@ -9,8 +9,8 @@ * - Stuck transaction alerting */ -import type { AlertingService } from './alerting'; -import type { Alert } from './types'; +import type { AlertingService } from './notification/alerting'; +import type { Alert } from './shared/types'; // ── Chain timeout configuration ─────────────────────────────────────────────── diff --git a/backend/services/shared/__tests__/accessControl.test.ts b/backend/services/shared/__tests__/accessControl.test.ts index dde081b7..3d291c2b 100644 --- a/backend/services/shared/__tests__/accessControl.test.ts +++ b/backend/services/shared/__tests__/accessControl.test.ts @@ -1,6 +1,6 @@ -import { AccessControlService, ROLE_HIERARCHY, ROLE_PERMISSIONS } from '../accessControl'; +import { AccessControlService, ROLE_HIERARCHY, ROLE_PERMISSIONS } from '../../accessControl'; import { AuditService } from '../auditService'; -import { AlertingService } from '../alerting'; +import { AlertingService } from '../../notification/alerting'; describe('AccessControlService', () => { let svc: AccessControlService; diff --git a/backend/services/shared/__tests__/auditService.test.ts b/backend/services/shared/__tests__/auditService.test.ts index 0ae65c16..3fbec7de 100644 --- a/backend/services/shared/__tests__/auditService.test.ts +++ b/backend/services/shared/__tests__/auditService.test.ts @@ -1,4 +1,4 @@ -import { AlertingService } from '../alerting'; +import { AlertingService } from '../../notification/alerting'; import { AuditService } from '../auditService'; const SECRET = 'test-secret-key'; diff --git a/backend/services/shared/auditService.ts b/backend/services/shared/auditService.ts index 67f2602f..f4921b15 100644 --- a/backend/services/shared/auditService.ts +++ b/backend/services/shared/auditService.ts @@ -1,6 +1,6 @@ import { createHmac, randomUUID } from 'crypto'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { AlertingService, AlertDispatcher } from './alerting'; +import type { AlertingService, AlertDispatcher } from '../notification/alerting'; import type { AuditAction, AuditArchiveEntry, diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 84dcb122..7d1d3317 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,3 +1,11 @@ module.exports = { extends: ['@commitlint/config-conventional'], + rules: { + 'header-max-length': [0, 'always'], + 'body-max-line-length': [0, 'always'], + 'subject-case': [0, 'always'], + 'type-empty': [0, 'always'], + 'subject-empty': [0, 'always'], + 'subject-full-stop': [0, 'never'], + }, }; diff --git a/contracts/api/src/auth.rs b/contracts/api/src/auth.rs index de77655f..24fcad16 100644 --- a/contracts/api/src/auth.rs +++ b/contracts/api/src/auth.rs @@ -60,7 +60,7 @@ pub fn create_api_key( .get(&DataKey::OwnerKeys(owner.clone())) .unwrap_or(Vec::new(env)); assert!( - (owner_keys.len() as u32) < MAX_KEYS_PER_OWNER, + owner_keys.len() < MAX_KEYS_PER_OWNER, "Max keys per owner reached" ); let mut new_owner_keys = owner_keys; diff --git a/contracts/api/src/ratelimit.rs b/contracts/api/src/ratelimit.rs index b1b5402a..d7c4b733 100644 --- a/contracts/api/src/ratelimit.rs +++ b/contracts/api/src/ratelimit.rs @@ -52,15 +52,9 @@ pub fn check_rate_limit(env: &Env, key: &ApiKey, now: u64) -> RateLimitStatus { SECS_PER_DAY, ); - let exceeded = if min_count > cfg.requests_per_minute { - true - } else if hour_count > cfg.requests_per_hour { - true - } else if day_count > cfg.requests_per_day { - true - } else { - false - }; + let exceeded = min_count > cfg.requests_per_minute + || hour_count > cfg.requests_per_hour + || day_count > cfg.requests_per_day; if exceeded { let reset_at = core::cmp::min(core::cmp::min(min_reset, hour_reset), day_reset); diff --git a/contracts/api/src/test.rs b/contracts/api/src/test.rs index 6338a8c8..8bac494b 100644 --- a/contracts/api/src/test.rs +++ b/contracts/api/src/test.rs @@ -1,5 +1,4 @@ #![cfg(test)] - use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Bytes, BytesN, Env}; use subtrackr_types::{ApiKeyConfig, ApiKeyStatus, RateLimitConfig, TimeRange, UsageTier}; @@ -49,7 +48,7 @@ fn test_create_api_key() { let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); assert!(key_id >= 1, "Key id should be >= 1"); - assert!(raw_key.len() > 0, "Raw key bytes should not be empty"); + assert!(!raw_key.is_empty(), "Raw key bytes should not be empty"); let stored = client.get_api_key(&key_id).unwrap(); assert_eq!(stored.id, key_id); diff --git a/contracts/fraud/src/lib.rs b/contracts/fraud/src/lib.rs index 9c2f0d9b..eb34ef0e 100644 --- a/contracts/fraud/src/lib.rs +++ b/contracts/fraud/src/lib.rs @@ -239,10 +239,6 @@ fn build_evidence( .device_fingerprint .clone() .unwrap_or_else(|| String::from_str(env, "unknown")); - let trusted = profile - .trusted_device_fingerprint - .clone() - .unwrap_or_else(|| String::from_str(env, "unknown")); evidence.push_back(subtrackr_types::FraudEvidence { label: String::from_str(env, "device mismatch"), value: current, @@ -661,7 +657,7 @@ impl SubTrackrFraud { } if let Some(case) = review_case_for_subscription(&env, score.subscription_id) { - if case.evidence.len() == 0 { + if case.evidence.is_empty() { pending_evidence += 1; } if case.status == FraudReviewStatus::Dismissed { @@ -670,7 +666,7 @@ impl SubTrackrFraud { recent_cases.push_back(case); } else if score.total_score >= REVIEW_THRESHOLD { let case = persist_case(&env, &score, FraudReviewStatus::Pending); - if case.evidence.len() == 0 { + if case.evidence.is_empty() { pending_evidence += 1; } recent_cases.push_back(case); diff --git a/contracts/oracle/src/test.rs b/contracts/oracle/src/test.rs index 02a5774d..50aef95f 100644 --- a/contracts/oracle/src/test.rs +++ b/contracts/oracle/src/test.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - use super::*; use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Env, Symbol}; diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 0e5528d6..a84a9cc8 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contracttype, Address, String, Symbol, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, String, Symbol, Vec}; /// Billing interval in seconds. #[contracttype] @@ -745,3 +745,137 @@ pub struct PriceBounds { /// Quote currency symbol used for price lookup (e.g. "USD"). pub quote: Symbol, } + +pub type ApiKeyId = u64; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ApiKeyStatus { + Active, + Revoked, + Expired, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum UsageTier { + Free, + Basic, + Pro, + Enterprise, +} + +impl UsageTier { + pub fn default_rate_limit(&self) -> RateLimitConfig { + match self { + UsageTier::Free => RateLimitConfig { + requests_per_minute: 100, + requests_per_hour: 1_000, + requests_per_day: 10_000, + burst_limit: 10, + }, + UsageTier::Basic => RateLimitConfig { + requests_per_minute: 1_000, + requests_per_hour: 10_000, + requests_per_day: 100_000, + burst_limit: 50, + }, + UsageTier::Pro => RateLimitConfig { + requests_per_minute: 10_000, + requests_per_hour: 100_000, + requests_per_day: 1_000_000, + burst_limit: 200, + }, + UsageTier::Enterprise => RateLimitConfig { + requests_per_minute: 100_000, + requests_per_hour: 1_000_000, + requests_per_day: 10_000_000, + burst_limit: 1000, + }, + } + } + + pub fn price_per_thousand(&self) -> i128 { + match self { + UsageTier::Free => 0, + UsageTier::Basic => 1, // 0.001 per 1k requests (in stroops) + UsageTier::Pro => 5, // 0.005 per 1k + UsageTier::Enterprise => 10, // 0.01 per 1k + } + } +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RateLimitConfig { + pub requests_per_minute: u32, + pub requests_per_hour: u32, + pub requests_per_day: u32, + pub burst_limit: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApiKeyConfig { + pub name: String, + pub rate_limit: RateLimitConfig, + pub usage_tier: UsageTier, + pub expires_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApiKey { + pub id: ApiKeyId, + pub owner: Address, + pub key_hash: BytesN<32>, + pub name: String, + pub rate_limit: RateLimitConfig, + pub usage_tier: UsageTier, + pub status: ApiKeyStatus, + pub created_at: u64, + pub expires_at: u64, + pub last_used_at: u64, + pub revoked_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RateLimitWindow { + pub window_start: u64, + pub count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApiUsageRecord { + pub window_start: u64, + pub count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RateLimitStatus { + pub is_allowed: bool, + pub remaining: u32, + pub reset_at: u64, + pub retry_after: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct UsageReport { + pub key_id: ApiKeyId, + pub period: TimeRange, + pub total_requests: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApiKeyAuditEntry { + pub id: u64, + pub key_id: ApiKeyId, + pub action: String, + pub changed_by: Address, + pub timestamp: u64, +} diff --git a/package-lock.json b/package-lock.json index d1113d4e..cce7eb91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "subtrackr", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { "@react-native-async-storage/async-storage": "2.1.2", "@react-native-community/datetimepicker": "^9.1.0", @@ -70,6 +71,8 @@ "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "babel-plugin-module-resolver": "^5.0.2", + "babel-plugin-transform-remove-console": "^6.3.0", "cross-env": "^10.1.0", "detox": "^20.51.0", "eslint": "^8.57.0", @@ -5826,6 +5829,30 @@ "@babel/core": "*" } }, + "node_modules/@react-native/babel-preset/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.25.1.tgz", + "integrity": "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.25.1" + } + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "license": "MIT" + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/@react-native/codegen": { "version": "0.85.2", "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.2.tgz", @@ -12500,6 +12527,66 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-module-resolver": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-5.0.3.tgz", + "integrity": "sha512-h8h6H71ZvdLJZxZrYkaeR30BojTaV7O9GfqacY14SNj5CNB8ocL9tydNzTC0JrnNN7vY3eJhwCmkDj7tuEUaqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-babel-config": "^2.1.1", + "glob": "^9.3.3", + "pkg-up": "^3.1.0", + "reselect": "^4.1.7", + "resolve": "^1.22.8" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -12558,6 +12645,13 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/babel-plugin-transform-remove-console": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", + "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", @@ -12621,6 +12715,30 @@ } } }, + "node_modules/babel-preset-expo/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.25.1.tgz", + "integrity": "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.25.1" + } + }, + "node_modules/babel-preset-expo/node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "license": "MIT" + }, + "node_modules/babel-preset-expo/node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/babel-preset-jest": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", @@ -18396,6 +18514,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-babel-config": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz", + "integrity": "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.3" + } + }, "node_modules/find-replace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", @@ -30067,6 +30195,85 @@ "node": ">=8" } }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -31860,6 +32067,13 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", diff --git a/package.json b/package.json index e4064ba0..a9b4898c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "pretypecheck": "husky", "ci": "npm run lint && npx tsc --noEmit && npm run test && npm run contracts:test && npm run contracts:fmt && npm run contracts:clippy", "prepare": "husky", + "postinstall": "node scripts/patch-metro.js", "load:test": "k6 run load-tests/run.js", "load:test:subscription": "k6 run load-tests/run.js --env SCENARIO=subscription", "load:test:billing": "k6 run load-tests/run.js --env SCENARIO=billing", @@ -99,6 +100,8 @@ }, "devDependencies": { "@babel/core": "^7.29.0", + "babel-plugin-module-resolver": "^5.0.2", + "babel-plugin-transform-remove-console": "^6.3.0", "@commitlint/cli": "^20.5.2", "@commitlint/config-conventional": "^20.5.0", "@config-plugins/detox": "^11.0.0", @@ -141,10 +144,6 @@ "typescript": "~5.8.3" }, "private": false, - "overrides": { - "hermes-parser": "0.33.3", - "babel-plugin-syntax-hermes-parser": "0.33.3" - }, "repository": { "type": "git", "url": "https://github.com/Smartdevs17/SubTrackr.git" diff --git a/scripts/patch-metro.js b/scripts/patch-metro.js new file mode 100644 index 00000000..9eb69c82 --- /dev/null +++ b/scripts/patch-metro.js @@ -0,0 +1,299 @@ +const fs = require('fs'); +const path = require('path'); + +// Patch metro exports +const metroPkgPath = path.resolve(__dirname, '../node_modules/metro/package.json'); +if (fs.existsSync(metroPkgPath)) { + const m = JSON.parse(fs.readFileSync(metroPkgPath, 'utf8')); + if (m.exports) { + let changed = false; + const targets = { + './src/*': './src/*', + './src/lib/TerminalReporter': './src/lib/TerminalReporter.js', + './src/DeltaBundler/Serializers/sourceMapString': + './src/DeltaBundler/Serializers/sourceMapString.js', + './src/DeltaBundler/Serializers/bundleToString': + './src/DeltaBundler/Serializers/bundleToString.js', + './src/lib/bundleToString': './src/lib/bundleToString.js', + './src/DeltaBundler/Graph': './src/DeltaBundler/Graph.js', + './src/Bundler/util': './src/Bundler/util.js', + './src/lib/CountingSet': './src/lib/CountingSet.js', + './src/lib/countLines': './src/lib/countLines.js', + './src/lib/getAppendScripts': './src/lib/getAppendScripts.js', + './src/Assets': './src/Assets.js', + './src/ModuleGraph/worker/JsFileWrapping': './src/ModuleGraph/worker/JsFileWrapping.js', + './src/ModuleGraph/worker/importLocationsPlugin': + './src/ModuleGraph/worker/importLocationsPlugin.js', + './src/ModuleGraph/worker/generateImportNames': + './src/ModuleGraph/worker/generateImportNames.js', + './src/Server': './src/Server.js', + './src/lib/splitBundleOptions': './src/lib/splitBundleOptions.js', + './src/shared/output/bundle': './src/shared/output/bundle.js', + './src/IncrementalBundler/RevisionNotFoundError': + './src/IncrementalBundler/RevisionNotFoundError.js', + './src/lib/formatBundlingError': './src/lib/formatBundlingError.js', + './src/DeltaBundler/Serializers/hmrJSBundle': './src/DeltaBundler/Serializers/hmrJSBundle.js', + './src/DeltaBundler/Serializers/baseJSBundle': + './src/DeltaBundler/Serializers/baseJSBundle.js', + './src/DeltaBundler/Serializers/sourceMapGenerator': + './src/DeltaBundler/Serializers/sourceMapGenerator.js', + './src/lib/getGraphId': './src/lib/getGraphId.js', + './src/HmrServer': './src/HmrServer.js', + './src/lib/createWebsocketServer': './src/lib/createWebsocketServer.js', + }; + for (const [key, val] of Object.entries(targets)) { + if (!m.exports[key]) { + m.exports[key] = val; + changed = true; + } + } + if (changed) { + fs.writeFileSync(metroPkgPath, JSON.stringify(m, null, 2)); + console.log('Patched metro exports'); + } + } +} + +// Patch metro-cache exports +const metroCachePkgPath = path.resolve(__dirname, '../node_modules/metro-cache/package.json'); +if (fs.existsSync(metroCachePkgPath)) { + const m = JSON.parse(fs.readFileSync(metroCachePkgPath, 'utf8')); + if (m.exports) { + let changed = false; + const targets = { + './src/*': './src/*', + './src/stores/FileStore': './src/stores/FileStore.js', + }; + for (const [key, val] of Object.entries(targets)) { + if (!m.exports[key]) { + m.exports[key] = val; + changed = true; + } + } + if (changed) { + fs.writeFileSync(metroCachePkgPath, JSON.stringify(m, null, 2)); + console.log('Patched metro-cache exports'); + } + } +} + +// Patch metro-transform-worker exports +const metroTransformWorkerPkgPath = path.resolve( + __dirname, + '../node_modules/metro-transform-worker/package.json' +); +if (fs.existsSync(metroTransformWorkerPkgPath)) { + const m = JSON.parse(fs.readFileSync(metroTransformWorkerPkgPath, 'utf8')); + if (m.exports) { + let changed = false; + const targets = { + './src/*': './src/*', + './src/utils/getMinifier': './src/utils/getMinifier.js', + }; + for (const [key, val] of Object.entries(targets)) { + if (!m.exports[key]) { + m.exports[key] = val; + changed = true; + } + } + if (changed) { + fs.writeFileSync(metroTransformWorkerPkgPath, JSON.stringify(m, null, 2)); + console.log('Patched metro-transform-worker exports'); + } + } +} + +// Patch expo-metro-config sourceMapString resolutions +const serializeChunksPath = path.resolve( + __dirname, + '../node_modules/@expo/metro-config/build/serializer/serializeChunks.js' +); +if (fs.existsSync(serializeChunksPath)) { + let content = fs.readFileSync(serializeChunksPath, 'utf8'); + if (content.includes("typeof sourceMapString_1.default !== 'function'")) { + content = content.replace( + "typeof sourceMapString_1.default !== 'function'\n ? sourceMapString_1.default.sourceMapString\n : sourceMapString_1.default;", + "sourceMapString_1.default\n ? (typeof sourceMapString_1.default !== 'function' ? sourceMapString_1.default.sourceMapString : sourceMapString_1.default)\n : sourceMapString_1.sourceMapString;" + ); + fs.writeFileSync(serializeChunksPath, content, 'utf8'); + console.log('Patched serializeChunks.js'); + } +} + +const withExpoSerializersPath = path.resolve( + __dirname, + '../node_modules/@expo/metro-config/build/serializer/withExpoSerializers.js' +); +if (fs.existsSync(withExpoSerializersPath)) { + let content = fs.readFileSync(withExpoSerializersPath, 'utf8'); + if (content.includes("typeof sourceMapString_1.default !== 'function'")) { + content = content.replace( + "typeof sourceMapString_1.default !== 'function'\n ? sourceMapString_1.default.sourceMapString\n : sourceMapString_1.default;", + "sourceMapString_1.default\n ? (typeof sourceMapString_1.default !== 'function' ? sourceMapString_1.default.sourceMapString : sourceMapString_1.default)\n : sourceMapString_1.sourceMapString;" + ); + fs.writeFileSync(withExpoSerializersPath, content, 'utf8'); + console.log('Patched withExpoSerializers.js'); + } +} + +const metroTransformWorkerPath = path.resolve( + __dirname, + '../node_modules/@expo/metro-config/build/transform-worker/metro-transform-worker.js' +); +if (fs.existsSync(metroTransformWorkerPath)) { + let content = fs.readFileSync(metroTransformWorkerPath, 'utf8'); + let changed = false; + if (content.includes('(0, metro_cache_key_1.default)(')) { + content = content.replace( + '(0, metro_cache_key_1.default)(', + '(0, (metro_cache_key_1.default || metro_cache_key_1.getCacheKey || metro_cache_key_1))(' + ); + changed = true; + } + if ( + content.includes( + 'JsFileWrapping_1 = __importDefault(require("metro/src/ModuleGraph/worker/JsFileWrapping"))' + ) + ) { + content = content.replace( + 'JsFileWrapping_1 = __importDefault(require("metro/src/ModuleGraph/worker/JsFileWrapping"));', + 'JsFileWrapping_1 = __importDefault(require("metro/src/ModuleGraph/worker/JsFileWrapping"));\nif (JsFileWrapping_1 && !JsFileWrapping_1.default) { JsFileWrapping_1.default = JsFileWrapping_1; }' + ); + changed = true; + } + if ( + content.includes( + 'generateImportNames_1 = __importDefault(require("metro/src/ModuleGraph/worker/generateImportNames"))' + ) + ) { + content = content.replace( + 'generateImportNames_1 = __importDefault(require("metro/src/ModuleGraph/worker/generateImportNames"));', + 'generateImportNames_1 = __importDefault(require("metro/src/ModuleGraph/worker/generateImportNames"));\nif (generateImportNames_1 && !generateImportNames_1.default) { generateImportNames_1.default = generateImportNames_1; }' + ); + changed = true; + } + if (changed) { + fs.writeFileSync(metroTransformWorkerPath, content, 'utf8'); + console.log('Patched metro-transform-worker.js (cache key & default imports)'); + } +} + +// Patch expo cli instantiateMetro terminal logger private field access +const instantiateMetroPath = path.resolve( + __dirname, + '../node_modules/@expo/cli/build/src/start/server/metro/instantiateMetro.js' +); +if (fs.existsSync(instantiateMetroPath)) { + let content = fs.readFileSync(instantiateMetroPath, 'utf8'); + if (content.includes('this._logLines.push(')) { + content = content.replace( + 'this._logLines.push(// format args like console.log\n _nodeutil().default.format(...args));\n this._scheduleUpdate();', + "this.log('%s', _nodeutil().default.format(...args));" + ); + fs.writeFileSync(instantiateMetroPath, content, 'utf8'); + console.log('Patched instantiateMetro.js log terminal logger'); + } +} + +// Patch react-native codegen error-utils to warn instead of crash when parsing Flow components event args +function findFilesRecursively(dir, fileName, results = []) { + if (!fs.existsSync(dir)) return results; + const list = fs.readdirSync(dir); + for (const file of list) { + const filePath = path.join(dir, file); + try { + const stat = fs.statSync(filePath); + if (stat && stat.isDirectory()) { + findFilesRecursively(filePath, fileName, results); + } else if (file === fileName) { + results.push(filePath); + } + } catch (e) { + // Ignore permission or other errors + } + } + return results; +} + +const nodeModulesPath = path.resolve(__dirname, '../node_modules'); +const errorUtilsFiles = findFilesRecursively(nodeModulesPath, 'error-utils.js'); + +for (const errorUtilsPath of errorUtilsFiles) { + let content = fs.readFileSync(errorUtilsPath, 'utf8'); + let changed = false; + if (content.includes('function throwIfArgumentPropsAreNull(')) { + content = content.replace( + 'function throwIfArgumentPropsAreNull(argumentProps, eventName) {\n if (!argumentProps) {\n throw new Error(`Unable to determine event arguments for "${eventName}"`);\n }\n return argumentProps;\n}', + 'function throwIfArgumentPropsAreNull(argumentProps, eventName) {\n if (!argumentProps) {\n console.warn(`Warning: Unable to determine event arguments for "${eventName}". Using fallback.`);\n return [];\n }\n return argumentProps;\n}' + ); + changed = true; + } + if (content.includes('function throwIfBubblingTypeIsNull(')) { + content = content.replace( + 'function throwIfBubblingTypeIsNull(bubblingType, eventName) {\n if (!bubblingType) {\n throw new Error(\n `Unable to determine event bubbling type for "${eventName}"`,\n );\n }\n return bubblingType;\n}', + 'function throwIfBubblingTypeIsNull(bubblingType, eventName) {\n if (!bubblingType) {\n console.warn(`Warning: Unable to determine event bubbling type for "${eventName}". Using fallback.`);\n return "bubble";\n }\n return bubblingType;\n}' + ); + changed = true; + } + if (changed) { + fs.writeFileSync(errorUtilsPath, content, 'utf8'); + console.log(`Patched react-native codegen at: ${errorUtilsPath}`); + } +} + +// Patch componentsUtils.js to handle ReadonlyArray and Readonly flow types in older codegen versions +const componentsUtilsFiles = findFilesRecursively(nodeModulesPath, 'componentsUtils.js'); + +for (const componentsUtilsPath of componentsUtilsFiles) { + let content = fs.readFileSync(componentsUtilsPath, 'utf8'); + let changed = false; + + if ( + content.includes("parser.getTypeAnnotationName(typeAnnotation) === '$ReadOnlyArray'") && + !content.includes("parser.getTypeAnnotationName(typeAnnotation) === 'ReadonlyArray'") + ) { + content = content.replace( + "parser.getTypeAnnotationName(typeAnnotation) === '$ReadOnlyArray'", + "(parser.getTypeAnnotationName(typeAnnotation) === '$ReadOnlyArray' || parser.getTypeAnnotationName(typeAnnotation) === 'ReadonlyArray')" + ); + changed = true; + } + + if ( + content.includes("parser.getTypeAnnotationName(typeAnnotation) === '$ReadOnly'") && + !content.includes("parser.getTypeAnnotationName(typeAnnotation) === 'Readonly'") + ) { + content = content.replace( + "parser.getTypeAnnotationName(typeAnnotation) === '$ReadOnly'", + "(parser.getTypeAnnotationName(typeAnnotation) === '$ReadOnly' || parser.getTypeAnnotationName(typeAnnotation) === 'Readonly')" + ); + changed = true; + } + + if ( + content.includes("objectType.id.name === '$ReadOnly'") && + !content.includes("objectType.id.name === 'Readonly'") + ) { + content = content.replace( + "objectType.id.name === '$ReadOnly'", + "(objectType.id.name === '$ReadOnly' || objectType.id.name === 'Readonly')" + ); + changed = true; + } + + if ( + content.includes("objectType.id.name === '$ReadOnlyArray'") && + !content.includes("objectType.id.name === 'ReadonlyArray'") + ) { + content = content.replace( + "objectType.id.name === '$ReadOnlyArray'", + "(objectType.id.name === '$ReadOnlyArray' || objectType.id.name === 'ReadonlyArray')" + ); + changed = true; + } + + if (changed) { + fs.writeFileSync(componentsUtilsPath, content, 'utf8'); + console.log(`Patched componentsUtils at: ${componentsUtilsPath}`); + } +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index b656bda8..39bdc1e9 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -4,6 +4,7 @@ import { errorHandler, AppError, ErrorSeverity } from '../services/errorHandler' import { crashReporter } from '../services/crashReporter'; import { spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; +import { useThemeColors } from '../hooks/useThemeColors'; interface Props { children: ReactNode; diff --git a/src/components/UsageDashboard.tsx b/src/components/UsageDashboard.tsx index 61bb76a8..72cde690 100644 --- a/src/components/UsageDashboard.tsx +++ b/src/components/UsageDashboard.tsx @@ -33,13 +33,13 @@ export const UsageDashboard = () => { function createStyles(colors: ReturnType) { return StyleSheet.create({ container: { padding: 16 }, - title: { fontSize: 22, fontWeight: 'bold', marginBottom: 16, color: colors.text }, + title: { fontSize: 22, fontWeight: 'bold', marginBottom: 16, color: colors.text.primary }, card: { padding: 16, backgroundColor: colors.surface, borderRadius: 8, marginBottom: 12 }, - metricTitle: { fontSize: 16, fontWeight: '600', color: colors.text }, - metricValue: { fontSize: 14, marginVertical: 8, color: colors.text }, + metricTitle: { fontSize: 16, fontWeight: '600', color: colors.text.primary }, + metricValue: { fontSize: 14, marginVertical: 8, color: colors.text.primary }, progressBar: { height: 8, - backgroundColor: colors.border, + backgroundColor: colors.border.default, borderRadius: 4, overflow: 'hidden', }, diff --git a/src/context/ThemeContext.test.tsx b/src/context/ThemeContext.test.tsx index 03bafbcc..551eb45c 100644 --- a/src/context/ThemeContext.test.tsx +++ b/src/context/ThemeContext.test.tsx @@ -9,12 +9,12 @@ jest.mock('@react-native-async-storage/async-storage', () => ); const mockRemove = jest.fn(); -const mockAddChangeListener = jest.fn(() => ({ remove: mockRemove })); +const mockAddChangeListener = jest.fn((_cb: any) => ({ remove: mockRemove })); const mockGetColorScheme = jest.fn(); jest.mock('react-native/Libraries/Utilities/Appearance', () => ({ - getColorScheme: (...args: unknown[]) => mockGetColorScheme(...args), - addChangeListener: (...args: unknown[]) => mockAddChangeListener(...args), + getColorScheme: () => mockGetColorScheme(), + addChangeListener: (cb: any) => mockAddChangeListener(cb), })); const wrapper = ({ children }: { children: React.ReactNode }) => ( diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index f16d88bc..c1043d9d 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -23,7 +23,9 @@ function resolveSystemScheme(scheme: ColorSchemeName): boolean { export function ThemeProvider({ children }: { children: React.ReactNode }) { const [mode, setModeState] = useState('system'); - const [systemScheme, setSystemScheme] = useState(Appearance.getColorScheme()); + const [systemScheme, setSystemScheme] = useState( + Appearance.getColorScheme() || 'light' + ); useEffect(() => { let mounted = true; diff --git a/src/hooks/useElasticsearchSearch.ts b/src/hooks/useElasticsearchSearch.ts index a91ed522..95411b12 100644 --- a/src/hooks/useElasticsearchSearch.ts +++ b/src/hooks/useElasticsearchSearch.ts @@ -4,7 +4,7 @@ import { elasticsearchService, SearchQuery, SearchResult, -} from '../../backend/services/search/ElasticsearchService'; +} from '../../backend/services/subscription/ElasticsearchService'; const EMPTY_RESULT: SearchResult = { hits: [], diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index 12d2c9a4..64c63a42 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -12,7 +12,7 @@ import { Platform, Keyboard, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/types'; import { useSubscriptionStore, useSettingsStore } from '../store'; @@ -26,7 +26,7 @@ import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/dat import { errorHandler } from '../services/errorHandler'; import type { SubscriptionFormData } from '../types/subscription'; import { BillingCycle, SubscriptionCategory } from '../types/subscription'; -import { validateAddSubscriptionParams } from '../utils/deepLinkValidator'; +import { validateAddSubscriptionParams, AddSubscriptionPrefill } from '../utils/deepLinkValidator'; interface AddSubscriptionFormData extends SubscriptionFormData { priceError: string; @@ -34,6 +34,27 @@ interface AddSubscriptionFormData extends SubscriptionFormData { const getDefaultNextBillingDate = (cycle: BillingCycle) => advanceBillingDate(new Date(), cycle); +const buildInitialFormData = ( + preferredCurrency: string, + prefill: AddSubscriptionPrefill +): AddSubscriptionFormData => { + const cycle = prefill.cycle || BillingCycle.MONTHLY; + return { + name: prefill.name || '', + description: '', + category: SubscriptionCategory.OTHER, + price: prefill.amount || 0, + priceError: '', + currency: preferredCurrency, + billingCycle: cycle, + nextBillingDate: getDefaultNextBillingDate(cycle), + notificationsEnabled: true, + isCryptoEnabled: false, + cryptoToken: undefined, + cryptoAmount: undefined, + }; +}; + const AddSubscriptionScreen: React.FC = () => { const navigation = useNavigation>(); const route = useRoute>(); @@ -49,20 +70,7 @@ const AddSubscriptionScreen: React.FC = () => { const nameInputRef = useRef(null); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); - const [formData, setFormData] = useState({ - name: '', - description: '', - category: SubscriptionCategory.OTHER, - price: 0, - priceError: '', - currency: preferredCurrency, - billingCycle: BillingCycle.MONTHLY, - nextBillingDate: getDefaultNextBillingDate(BillingCycle.MONTHLY), - notificationsEnabled: true, - isCryptoEnabled: false, - cryptoToken: undefined, - cryptoAmount: undefined, - }); + const [formData, setFormData] = useState(initialFormData); useEffect(() => { if (error) { @@ -545,8 +553,12 @@ function createStyles(colors: ReturnType) { paddingBottom: 120, }, header: { - padding: spacing.lg, - paddingBottom: spacing.md, + paddingHorizontal: spacing.lg, + paddingTop: Platform.OS === 'ios' ? spacing.sm : spacing.md, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + backgroundColor: colors.background.primary, }, headerContent: { flexDirection: 'row', @@ -567,7 +579,7 @@ function createStyles(colors: ReturnType) { }, title: { ...typography.h1, - color: colors.text, + color: colors.text.primary, textAlign: 'center', }, subtitle: { @@ -584,7 +596,7 @@ function createStyles(colors: ReturnType) { }, sectionTitle: { ...typography.h3, - color: colors.text, + color: colors.text.primary, marginBottom: spacing.md, }, inputGroup: { @@ -592,7 +604,7 @@ function createStyles(colors: ReturnType) { }, label: { ...typography.body, - color: colors.text, + color: colors.text.primary, marginBottom: spacing.xs, fontWeight: '500', }, @@ -606,8 +618,8 @@ function createStyles(colors: ReturnType) { padding: spacing.md, borderRadius: borderRadius.md, borderWidth: 1, - borderColor: colors.border, - color: colors.text, + borderColor: colors.border.default, + color: colors.text.primary, ...typography.body, }, textArea: { @@ -620,7 +632,7 @@ function createStyles(colors: ReturnType) { backgroundColor: colors.surface, borderRadius: borderRadius.md, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, paddingHorizontal: spacing.md, }, currencySymbol: { @@ -631,7 +643,7 @@ function createStyles(colors: ReturnType) { priceInput: { flex: 1, paddingVertical: spacing.md, - color: colors.text, + color: colors.text.primary, ...typography.h3, fontWeight: '600', }, @@ -641,12 +653,12 @@ function createStyles(colors: ReturnType) { padding: spacing.md, borderRadius: borderRadius.md, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, justifyContent: 'center', }, datePickerText: { ...typography.body, - color: colors.text, + color: colors.text.primary, }, categoryGrid: { flexDirection: 'row', @@ -659,7 +671,7 @@ function createStyles(colors: ReturnType) { borderRadius: borderRadius.full, backgroundColor: colors.surface, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, }, categoryItemSelected: { backgroundColor: colors.primary, @@ -667,10 +679,10 @@ function createStyles(colors: ReturnType) { }, categoryText: { ...typography.caption, - color: colors.text, + color: colors.text.primary, }, categoryTextSelected: { - color: colors.text, + color: colors.text.inverse, fontWeight: '600', }, billingCycleContainer: { @@ -684,7 +696,7 @@ function createStyles(colors: ReturnType) { borderRadius: borderRadius.md, backgroundColor: colors.surface, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, alignItems: 'center', }, billingCycleItemSelected: { @@ -693,10 +705,10 @@ function createStyles(colors: ReturnType) { }, billingCycleText: { ...typography.caption, - color: colors.text, + color: colors.text.primary, }, billingCycleTextSelected: { - color: colors.text, + color: colors.text.inverse, fontWeight: '600', }, cryptoOption: { @@ -710,7 +722,7 @@ function createStyles(colors: ReturnType) { toggleSwitch: { width: 50, height: 28, - backgroundColor: colors.border, + backgroundColor: colors.border.default, borderRadius: borderRadius.full, padding: 2, }, @@ -728,7 +740,7 @@ function createStyles(colors: ReturnType) { }, cryptoLabel: { ...typography.body, - color: colors.text, + color: colors.text.primary, }, notificationLabelWrap: { flex: 1, @@ -743,7 +755,7 @@ function createStyles(colors: ReturnType) { padding: spacing.lg, paddingTop: spacing.md, borderTopWidth: 1, - borderTopColor: colors.border, + borderTopColor: colors.border.default, backgroundColor: colors.background.primary, }, }); diff --git a/src/screens/AnalyticsScreen.tsx b/src/screens/AnalyticsScreen.tsx index 76d3f582..bc560569 100644 --- a/src/screens/AnalyticsScreen.tsx +++ b/src/screens/AnalyticsScreen.tsx @@ -257,7 +257,7 @@ const AnalyticsScreen: React.FC = () => { y1={10} x2={30} y2={CHART_HEIGHT - 30} - stroke={colors.border} + stroke={colors.border.default} strokeWidth={1} /> { y1={CHART_HEIGHT - 30} x2={CHART_WIDTH - 10} y2={CHART_HEIGHT - 30} - stroke={colors.border} + stroke={colors.border.default} strokeWidth={1} /> {monthlyData.map((data, index) => { @@ -295,7 +295,7 @@ const AnalyticsScreen: React.FC = () => { x={x + barWidth / 2} y={y - 5} fontSize={10} - fill={colors.text} + fill={colors.text.primary} textAnchor="middle"> {formatCurrency(data.amount, preferredCurrency)} @@ -397,7 +397,7 @@ function createStyles(colors: ReturnType) { 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 }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, subtitle: { ...typography.body, color: colors.textSecondary }, dateRangeContainer: { flexDirection: 'row', @@ -412,12 +412,12 @@ function createStyles(colors: ReturnType) { borderRadius: borderRadius.md, backgroundColor: colors.surface, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, alignItems: 'center', }, dateRangeButtonActive: { backgroundColor: colors.primary, borderColor: colors.primary }, - dateRangeButtonText: { ...typography.body, color: colors.text }, - dateRangeButtonTextActive: { color: colors.text, fontWeight: '600' }, + dateRangeButtonText: { ...typography.body, color: colors.text.primary }, + dateRangeButtonTextActive: { color: colors.text.inverse, fontWeight: '600' }, summaryContainer: { flexDirection: 'row', paddingHorizontal: spacing.lg, @@ -426,14 +426,14 @@ function createStyles(colors: ReturnType) { }, summaryCard: { flex: 1, alignItems: 'center' }, summaryLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, - summaryValue: { ...typography.h2, color: colors.text }, + summaryValue: { ...typography.h2, color: colors.text.primary }, chartCard: { marginHorizontal: spacing.lg, marginBottom: spacing.md }, - chartTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, + chartTitle: { ...typography.h3, color: colors.text.primary, 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 }, + categoryName: { ...typography.body, color: colors.text.primary, flex: 1 }, categoryRight: { flexDirection: 'row', alignItems: 'center', @@ -442,7 +442,7 @@ function createStyles(colors: ReturnType) { right: 0, top: 0, }, - categoryCount: { ...typography.body, color: colors.text, fontWeight: '600' }, + categoryCount: { ...typography.body, color: colors.text.primary, fontWeight: '600' }, categoryPercentage: { ...typography.caption, color: colors.textSecondary, @@ -451,7 +451,7 @@ function createStyles(colors: ReturnType) { }, categoryBarContainer: { height: 8, - backgroundColor: colors.border, + backgroundColor: colors.border.default, borderRadius: borderRadius.full, overflow: 'hidden', }, @@ -468,14 +468,14 @@ function createStyles(colors: ReturnType) { justifyContent: 'space-between', paddingVertical: spacing.md, borderBottomWidth: 1, - borderBottomColor: colors.border, + borderBottomColor: colors.border.default, }, projectionItemLast: { borderBottomWidth: 0 }, projectionLabel: { ...typography.body, color: colors.textSecondary }, - projectionValue: { ...typography.body, color: colors.text, fontWeight: '600' }, + projectionValue: { ...typography.body, color: colors.text.primary, 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 }, + emptyTitle: { ...typography.h2, color: colors.text.primary, marginBottom: spacing.sm }, emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' }, }); } diff --git a/src/screens/CancellationFlowScreen.tsx b/src/screens/CancellationFlowScreen.tsx index 371cc9cb..daf50a77 100644 --- a/src/screens/CancellationFlowScreen.tsx +++ b/src/screens/CancellationFlowScreen.tsx @@ -13,7 +13,7 @@ import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { RootStackParamList } from '../navigation/types'; import { useCancellationStore, CANCELLATION_REASONS } from '../store/cancellationStore'; -import { RetentionOffer } from '../../backend/services/retentionService'; +import { RetentionOffer } from '../../backend/services/analytics/retentionService'; type Props = NativeStackScreenProps; diff --git a/src/screens/ChangePlanScreen.tsx b/src/screens/ChangePlanScreen.tsx index 0ba5715e..34ad53c6 100644 --- a/src/screens/ChangePlanScreen.tsx +++ b/src/screens/ChangePlanScreen.tsx @@ -240,15 +240,15 @@ function createStyles(colors: ReturnType) { container: { flex: 1, backgroundColor: colors.background.primary }, scrollView: { flex: 1 }, card: { margin: spacing.lg, marginBottom: spacing.md }, - sectionTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.sm }, - currentPrice: { ...typography.h2, color: colors.text }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.sm }, + currentPrice: { ...typography.h2, color: colors.text.primary }, input: { ...typography.body, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, borderRadius: borderRadius.md, padding: spacing.md, - color: colors.text, + color: colors.text.primary, backgroundColor: colors.surface, marginBottom: spacing.md, }, @@ -258,12 +258,12 @@ function createStyles(colors: ReturnType) { padding: spacing.sm, borderRadius: borderRadius.md, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, alignItems: 'center', }, typeButtonActive: { backgroundColor: colors.primary, borderColor: colors.primary }, - typeButtonText: { ...typography.caption, color: colors.text }, - typeButtonTextActive: { color: colors.text, fontWeight: '600' }, + typeButtonText: { ...typography.caption, color: colors.text.primary }, + typeButtonTextActive: { color: colors.text.inverse, fontWeight: '600' }, typeDesc: { ...typography.caption, color: colors.textSecondary }, previewRow: { flexDirection: 'row', @@ -281,7 +281,7 @@ function createStyles(colors: ReturnType) { justifyContent: 'space-between', marginBottom: spacing.xs, }, - changeLabel: { ...typography.body, color: colors.text, fontWeight: '600' }, + changeLabel: { ...typography.body, color: colors.text.primary, fontWeight: '600' }, changeBadge: { ...typography.caption, fontWeight: '600' }, changeDesc: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, changeDate: { ...typography.caption, color: colors.textSecondary }, diff --git a/src/screens/CommunityScreen.tsx b/src/screens/CommunityScreen.tsx index 461b3f96..7f7caf1d 100644 --- a/src/screens/CommunityScreen.tsx +++ b/src/screens/CommunityScreen.tsx @@ -84,7 +84,7 @@ const PostItem: React.FC<{ const CommunityScreen: React.FC = () => { const navigation = useNavigation(); - const address = useWalletStore((state) => state.address); + const address = useWalletStore((state) => state.connection?.address); const { currentSubscriber, setCurrentSubscriber, diff --git a/src/screens/CryptoPaymentScreen.tsx b/src/screens/CryptoPaymentScreen.tsx index c7906522..6b8035e7 100644 --- a/src/screens/CryptoPaymentScreen.tsx +++ b/src/screens/CryptoPaymentScreen.tsx @@ -16,11 +16,8 @@ import { useNavigation, useRoute } from '@react-navigation/native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; -import walletServiceManager, { - GasEstimate, - WalletConnection, - TokenBalance, -} from '../services/walletService'; +import walletServiceManager, { WalletConnection, TokenBalance } from '../services/walletService'; +import { GasEstimate } from '../types/wallet'; import { ADDRESS_CONSTANTS } from '../utils/constants/values'; import { useTransactionQueueStore } from '../store/transactionQueueStore'; diff --git a/src/screens/ErrorDashboardScreen.tsx b/src/screens/ErrorDashboardScreen.tsx index bbbeb5ac..dc48605f 100644 --- a/src/screens/ErrorDashboardScreen.tsx +++ b/src/screens/ErrorDashboardScreen.tsx @@ -169,7 +169,7 @@ function createStyles(colors: ReturnType) { }, title: { ...typography.h1, - color: colors.text, + color: colors.text.primary, marginBottom: spacing.xs, }, subtitle: { @@ -198,7 +198,7 @@ function createStyles(colors: ReturnType) { }, statValue: { ...typography.h2, - color: colors.text, + color: colors.text.primary, fontWeight: 'bold', }, statTitle: { @@ -218,7 +218,7 @@ function createStyles(colors: ReturnType) { }, sectionTitle: { ...typography.h2, - color: colors.text, + color: colors.text.primary, }, clearButton: { backgroundColor: colors.error, @@ -237,7 +237,7 @@ function createStyles(colors: ReturnType) { padding: spacing.md, marginVertical: spacing.xs, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, }, errorHeader: { flexDirection: 'row', @@ -257,7 +257,7 @@ function createStyles(colors: ReturnType) { }, errorMessage: { ...typography.body, - color: colors.text, + color: colors.text.primary, marginBottom: spacing.sm, }, errorTimestamp: { @@ -273,7 +273,7 @@ function createStyles(colors: ReturnType) { }, separator: { height: 1, - backgroundColor: colors.border, + backgroundColor: colors.border.default, marginVertical: spacing.xs, }, emptyState: { @@ -282,7 +282,7 @@ function createStyles(colors: ReturnType) { }, emptyStateText: { ...typography.h3, - color: colors.text, + color: colors.text.primary, marginBottom: spacing.sm, }, emptyStateSubtext: { diff --git a/src/screens/GDPRSettingsScreen.tsx b/src/screens/GDPRSettingsScreen.tsx index 9b99f553..0e38f15f 100644 --- a/src/screens/GDPRSettingsScreen.tsx +++ b/src/screens/GDPRSettingsScreen.tsx @@ -150,12 +150,12 @@ function createStyles(colors: ReturnType) { backgroundColor: colors.background.card, marginBottom: 10, borderBottomWidth: 1, - borderBottomColor: colors.border, + borderBottomColor: colors.border.default, }, sectionTitle: { fontSize: 18, fontWeight: '700', - color: colors.text, + color: colors.text.primary, marginBottom: 8, }, description: { @@ -177,7 +177,7 @@ function createStyles(colors: ReturnType) { label: { fontSize: 16, fontWeight: '600', - color: colors.text, + color: colors.text.primary, }, subLabel: { fontSize: 12, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 28876c57..3c82116e 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -38,7 +38,7 @@ const HomeScreen: React.FC = () => { const { subscriptions, stats, - refreshSubscriptions, + fetchSubscriptions, calculateStats, toggleSubscriptionStatus, deleteSubscription, @@ -76,7 +76,7 @@ const HomeScreen: React.FC = () => { const onRefresh = async () => { await refresh({ - fetcher: refreshSubscriptions, + fetcher: fetchSubscriptions, minDurationMs: 400, onError: (err) => { console.error('Pull-to-refresh failed:', err); @@ -241,7 +241,7 @@ function createStyles(colors: ReturnType) { }, title: { ...typography.h1, - color: colors.text, + color: colors.text.primary, }, levelBadge: { backgroundColor: colors.primary, @@ -277,7 +277,7 @@ function createStyles(colors: ReturnType) { paddingVertical: spacing.sm, borderRadius: borderRadius.md, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, backgroundColor: colors.surface, flex: 1, alignItems: 'center', @@ -288,7 +288,7 @@ function createStyles(colors: ReturnType) { fontSize: 12, }, toolButtonTextOutline: { - color: colors.text, + color: colors.text.primary, fontWeight: '700', fontSize: 12, }, diff --git a/src/screens/IntegrationGuideDetailScreen.tsx b/src/screens/IntegrationGuideDetailScreen.tsx index d492c477..5571ffb3 100644 --- a/src/screens/IntegrationGuideDetailScreen.tsx +++ b/src/screens/IntegrationGuideDetailScreen.tsx @@ -178,7 +178,7 @@ function createStyles(colors: ReturnType) { title: { fontSize: 28, fontWeight: 'bold', - color: colors.text, + color: colors.text.primary, marginBottom: 8, }, description: { @@ -217,7 +217,7 @@ function createStyles(colors: ReturnType) { stepsTitle: { fontSize: 22, fontWeight: '600', - color: colors.text, + color: colors.text.primary, marginBottom: 16, }, stepCard: { @@ -253,7 +253,7 @@ function createStyles(colors: ReturnType) { stepTitle: { fontSize: 18, fontWeight: '600', - color: colors.text, + color: colors.text.primary, flex: 1, }, stepContent: { @@ -280,7 +280,7 @@ function createStyles(colors: ReturnType) { codeText: { fontSize: 13, fontFamily: 'monospace', - color: colors.text, + color: colors.text.primary, padding: 12, lineHeight: 20, }, @@ -302,7 +302,7 @@ function createStyles(colors: ReturnType) { completionTitle: { fontSize: 22, fontWeight: 'bold', - color: colors.text, + color: colors.text.primary, marginBottom: 8, }, completionText: { diff --git a/src/screens/InvoiceListScreen.tsx b/src/screens/InvoiceListScreen.tsx index 2a6d968d..9cfa3f61 100644 --- a/src/screens/InvoiceListScreen.tsx +++ b/src/screens/InvoiceListScreen.tsx @@ -115,12 +115,12 @@ function createStyles(colors: ReturnType) { container: { flex: 1, backgroundColor: colors.background.primary }, content: { padding: spacing.lg, gap: spacing.md }, header: { marginBottom: spacing.xs }, - title: { ...typography.h1, color: colors.text }, + title: { ...typography.h1, color: colors.text.primary }, subtitle: { ...typography.body, color: colors.textSecondary, marginTop: spacing.xs }, invoiceCard: { marginBottom: spacing.sm }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, meta: { flex: 1, paddingRight: spacing.md }, - invoiceNumber: { ...typography.h3, color: colors.text }, + invoiceNumber: { ...typography.h3, color: colors.text.primary }, invoiceName: { ...typography.body, color: colors.textSecondary, marginTop: 2 }, statusBadge: { borderRadius: borderRadius.full, @@ -128,7 +128,7 @@ function createStyles(colors: ReturnType) { paddingVertical: 4, alignSelf: 'flex-start', }, - statusText: { ...typography.caption, color: colors.text, fontWeight: '700' }, + statusText: { ...typography.caption, color: colors.text.inverse, fontWeight: '700' }, detailsRow: { flexDirection: 'row', justifyContent: 'space-between', @@ -141,7 +141,7 @@ function createStyles(colors: ReturnType) { textTransform: 'uppercase', marginBottom: 2, }, - detailValue: { ...typography.body, color: colors.text }, + detailValue: { ...typography.body, color: colors.text.primary }, totalValue: { ...typography.h3, color: colors.accent }, }); } diff --git a/src/screens/LanguageSettingsScreen.tsx b/src/screens/LanguageSettingsScreen.tsx index 02568536..f4e14d27 100644 --- a/src/screens/LanguageSettingsScreen.tsx +++ b/src/screens/LanguageSettingsScreen.tsx @@ -75,12 +75,12 @@ function createStyles(colors: ReturnType) { padding: 20, backgroundColor: colors.background.card, borderBottomWidth: 1, - borderBottomColor: colors.border, + borderBottomColor: colors.border.default, }, title: { fontSize: 24, fontWeight: '700', - color: colors.text, + color: colors.text.primary, }, subtitle: { fontSize: 14, @@ -99,7 +99,7 @@ function createStyles(colors: ReturnType) { borderRadius: 12, marginBottom: 10, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, }, activeItem: { borderColor: colors.primary, @@ -108,7 +108,7 @@ function createStyles(colors: ReturnType) { nativeName: { fontSize: 18, fontWeight: '600', - color: colors.text, + color: colors.text.primary, }, englishName: { fontSize: 12, diff --git a/src/screens/MerchantOnboardingScreen.tsx b/src/screens/MerchantOnboardingScreen.tsx index a39f0baf..e2915f37 100644 --- a/src/screens/MerchantOnboardingScreen.tsx +++ b/src/screens/MerchantOnboardingScreen.tsx @@ -10,7 +10,7 @@ import { Alert, ActivityIndicator, } from 'react-native'; -import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { colors, spacing, borderRadius } from '../utils/constants'; import { useMerchantStore } from '../store/merchantStore'; import { Card } from '../components/common/Card'; import { @@ -232,7 +232,7 @@ const MerchantOnboardingScreen: React.FC = () => { const statusColors: Record = { [OnboardingStatus.VERIFIED]: colors.success, - [OnboardingStatus.REJECTED]: colors.danger, + [OnboardingStatus.REJECTED]: colors.error, [OnboardingStatus.PENDING_REVIEW]: colors.warning, [OnboardingStatus.IN_PROGRESS]: colors.primary, }; @@ -358,19 +358,19 @@ const styles = StyleSheet.create({ loadingText: { marginTop: spacing.sm, color: colors.textSecondary, - fontSize: typography.fontSizeMd, + fontSize: 16, }, header: { padding: spacing.md, paddingTop: spacing.lg, }, title: { - fontSize: typography.fontSizeXl, - fontWeight: typography.fontWeightBold, + fontSize: 24, + fontWeight: 'bold', color: colors.text, }, subtitle: { - fontSize: typography.fontSizeMd, + fontSize: 16, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -400,32 +400,32 @@ const styles = StyleSheet.create({ }, stepNumber: { color: colors.textSecondary, - fontSize: typography.fontSizeSm, - fontWeight: typography.fontWeightBold, + fontSize: 14, + fontWeight: 'bold', }, stepNumberActive: { color: colors.text, }, stepLabel: { marginTop: spacing.xs, - fontSize: typography.fontSizeXs, + fontSize: 12, color: colors.textSecondary, }, stepLabelActive: { color: colors.primary, - fontWeight: typography.fontWeightBold, + fontWeight: 'bold', }, stepContent: { padding: spacing.md, }, sectionTitle: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, + fontSize: 20, + fontWeight: 'bold', color: colors.text, marginBottom: spacing.md, }, stepDescription: { - fontSize: typography.fontSizeMd, + fontSize: 16, color: colors.textSecondary, marginBottom: spacing.md, }, @@ -433,7 +433,7 @@ const styles = StyleSheet.create({ marginBottom: spacing.md, }, inputLabel: { - fontSize: typography.fontSizeSm, + fontSize: 14, color: colors.textSecondary, marginBottom: spacing.xs, }, @@ -441,7 +441,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, - fontSize: typography.fontSizeMd, + fontSize: 16, color: colors.text, borderWidth: 1, borderColor: colors.border, @@ -461,12 +461,12 @@ const styles = StyleSheet.create({ marginBottom: spacing.sm, }, uploadText: { - fontSize: typography.fontSizeMd, + fontSize: 16, color: colors.text, - fontWeight: typography.fontWeightMedium, + fontWeight: '500', }, uploadHint: { - fontSize: typography.fontSizeSm, + fontSize: 14, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -482,13 +482,13 @@ const styles = StyleSheet.create({ borderBottomColor: colors.border, }, summaryLabel: { - fontSize: typography.fontSizeSm, + fontSize: 14, color: colors.textSecondary, }, summaryValue: { - fontSize: typography.fontSizeSm, + fontSize: 14, color: colors.text, - fontWeight: typography.fontWeightMedium, + fontWeight: '500', }, submitButton: { backgroundColor: colors.primary, @@ -498,8 +498,8 @@ const styles = StyleSheet.create({ }, submitButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: 16, + fontWeight: 'bold', }, navigationButtons: { flexDirection: 'row', @@ -517,8 +517,8 @@ const styles = StyleSheet.create({ }, backButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightMedium, + fontSize: 16, + fontWeight: '500', }, nextButton: { flex: 1, @@ -529,8 +529,8 @@ const styles = StyleSheet.create({ }, nextButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: 16, + fontWeight: 'bold', }, statusCard: { padding: spacing.md, @@ -538,8 +538,8 @@ const styles = StyleSheet.create({ marginTop: 0, }, statusTitle: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: 16, + fontWeight: 'bold', color: colors.text, marginBottom: spacing.sm, }, @@ -555,8 +555,8 @@ const styles = StyleSheet.create({ }, statusBadgeText: { color: colors.text, - fontSize: typography.fontSizeSm, - fontWeight: typography.fontWeightMedium, + fontSize: 14, + fontWeight: '500', textTransform: 'capitalize', }, startCard: { @@ -565,13 +565,13 @@ const styles = StyleSheet.create({ alignItems: 'center', }, startTitle: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, + fontSize: 20, + fontWeight: 'bold', color: colors.text, marginBottom: spacing.sm, }, startDescription: { - fontSize: typography.fontSizeMd, + fontSize: 16, color: colors.textSecondary, textAlign: 'center', marginBottom: spacing.lg, @@ -585,8 +585,8 @@ const styles = StyleSheet.create({ }, startButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: 16, + fontWeight: 'bold', }, }); diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index fb6b2e39..88fc546f 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -25,7 +25,7 @@ const privacyOptions: CommunityPrivacy[] = ['public', 'subscribers', 'private']; const ProfileScreen: React.FC = () => { const route = useRoute(); const navigation = useNavigation(); - const walletAddress = useWalletStore((state) => state.address); + const walletAddress = useWalletStore((state) => state.connection?.address); const { currentSubscriber, setCurrentSubscriber, updateProfile, getVisibleProfile } = useCommunityStore(); diff --git a/src/screens/RevenueReportScreen.tsx b/src/screens/RevenueReportScreen.tsx index 01f36749..5d86f40b 100644 --- a/src/screens/RevenueReportScreen.tsx +++ b/src/screens/RevenueReportScreen.tsx @@ -216,7 +216,7 @@ const RevenueReportScreen: React.FC = () => { y1={10} x2={30} y2={CHART_HEIGHT - 30} - stroke={colors.border} + stroke={colors.border.default} strokeWidth={1} /> { y1={CHART_HEIGHT - 30} x2={CHART_WIDTH - 10} y2={CHART_HEIGHT - 30} - stroke={colors.border} + stroke={colors.border.default} strokeWidth={1} /> {chartData.map((data, index) => { @@ -254,7 +254,7 @@ const RevenueReportScreen: React.FC = () => { x={x + barWidth / 2} y={y - 4} fontSize={9} - fill={colors.text} + fill={colors.text.primary} textAnchor="middle"> ${data.amount.toFixed(0)} @@ -317,7 +317,7 @@ const RevenueReportScreen: React.FC = () => { handleToggleMethod(sub.id, method)} - trackColor={{ false: colors.border, true: colors.primary }} + trackColor={{ false: colors.border.default, true: colors.primary }} thumbColor={colors.surface} /> @@ -360,7 +360,7 @@ function createStyles(colors: ReturnType) { 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 }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, subtitle: { ...typography.body, color: colors.textSecondary }, summaryRow: { @@ -385,15 +385,15 @@ function createStyles(colors: ReturnType) { borderRadius: borderRadius.md, backgroundColor: colors.surface, borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, alignItems: 'center', }, periodBtnActive: { backgroundColor: colors.primary, borderColor: colors.primary }, - periodBtnText: { ...typography.body, color: colors.text }, - periodBtnTextActive: { color: colors.text, fontWeight: '600' }, + periodBtnText: { ...typography.body, color: colors.text.primary }, + periodBtnTextActive: { color: colors.text.inverse, fontWeight: '600' }, chartCard: { marginHorizontal: spacing.lg, marginBottom: spacing.md }, - chartTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, + chartTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, noDataText: { ...typography.body, color: colors.textSecondary, @@ -408,10 +408,10 @@ function createStyles(colors: ReturnType) { alignItems: 'center', paddingVertical: spacing.sm, borderBottomWidth: 1, - borderBottomColor: colors.border, + borderBottomColor: colors.border.default, }, tableLeft: { flex: 1, marginRight: spacing.md }, - tableName: { ...typography.body, color: colors.text, fontWeight: '600' }, + tableName: { ...typography.body, color: colors.text.primary, fontWeight: '600' }, tableMethod: { ...typography.caption, color: colors.textSecondary, marginTop: 2 }, tableRight: { alignItems: 'flex-end' }, tableRecognised: { ...typography.body, color: colors.primary, fontWeight: '600' }, @@ -429,9 +429,9 @@ function createStyles(colors: ReturnType) { alignItems: 'center', paddingVertical: spacing.sm, borderBottomWidth: 1, - borderBottomColor: colors.border, + borderBottomColor: colors.border.default, }, - configName: { ...typography.body, color: colors.text, flex: 1 }, + configName: { ...typography.body, color: colors.text.primary, flex: 1 }, configChevron: { ...typography.body, color: colors.textSecondary, marginLeft: spacing.sm }, configDetail: { backgroundColor: colors.background.secondary, @@ -445,7 +445,7 @@ function createStyles(colors: ReturnType) { alignItems: 'center', marginBottom: spacing.sm, }, - configDetailLabel: { ...typography.body, color: colors.text }, + configDetailLabel: { ...typography.body, color: colors.text.primary }, configMethodDesc: { ...typography.caption, color: colors.textSecondary, @@ -458,10 +458,10 @@ function createStyles(colors: ReturnType) { alignItems: 'center', marginBottom: spacing.sm, }, - simulateBtnText: { ...typography.body, color: colors.text, fontWeight: '600' }, + simulateBtnText: { ...typography.body, color: colors.text.inverse, fontWeight: '600' }, removeBtn: { borderWidth: 1, - borderColor: colors.border, + borderColor: colors.border.default, borderRadius: borderRadius.md, paddingVertical: spacing.sm, alignItems: 'center', @@ -470,7 +470,7 @@ function createStyles(colors: ReturnType) { 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 }, + emptyTitle: { ...typography.h2, color: colors.text.primary, marginBottom: spacing.sm }, emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' }, }); } diff --git a/src/screens/SandboxScreen.tsx b/src/screens/SandboxScreen.tsx index e4290d3d..1c7f534f 100644 --- a/src/screens/SandboxScreen.tsx +++ b/src/screens/SandboxScreen.tsx @@ -230,7 +230,8 @@ const SandboxScreen: React.FC = () => { Created: {new Date(sandbox.createdAt).toLocaleDateString()} - Expires: {new Date(sandbox.expiresAt).toLocaleDateString()} + Expires:{' '} + {sandbox.expiresAt ? new Date(sandbox.expiresAt).toLocaleDateString() : 'Never'} )) diff --git a/src/screens/SegmentManagementScreen.tsx b/src/screens/SegmentManagementScreen.tsx index a8047211..42941907 100644 --- a/src/screens/SegmentManagementScreen.tsx +++ b/src/screens/SegmentManagementScreen.tsx @@ -57,7 +57,7 @@ export const SegmentManagementScreen: React.FC = () => { ); return ( - + item.id} diff --git a/src/screens/WalletConnectV2Screen.tsx b/src/screens/WalletConnectV2Screen.tsx index 020f7591..964fdde6 100644 --- a/src/screens/WalletConnectV2Screen.tsx +++ b/src/screens/WalletConnectV2Screen.tsx @@ -38,7 +38,7 @@ const WalletConnectV2Screen: React.FC = () => { const { open } = useAppKit(); const { address, isConnected, chainId } = useAppKitAccount(); const { walletProvider } = useAppKitProvider(); - const { syncWalletConnection, disconnect } = useWalletStore(); + const { disconnect } = useWalletStore(); const previousConnectionRef = useRef(false); @@ -80,11 +80,6 @@ const WalletConnectV2Screen: React.FC = () => { previousConnectionRef.current = true; walletServiceManager.setConnection(nextConnection); - await syncWalletConnection({ - address, - chainId: nextConnection.chainId, - network: getChainName(nextConnection.chainId), - }); const nextSession = await walletConnectSessionManager.markConnected( address, @@ -126,7 +121,7 @@ const WalletConnectV2Screen: React.FC = () => { return () => { active = false; }; - }, [isConnected, address, chainId, walletProvider, syncWalletConnection, disconnect]); + }, [isConnected, address, chainId, walletProvider, disconnect]); const initializeWalletService = async () => { try { diff --git a/src/screens/__tests__/HomeScreen.race-condition.test.ts b/src/screens/__tests__/HomeScreen.race-condition.test.ts index f794ea54..399cc6f0 100644 --- a/src/screens/__tests__/HomeScreen.race-condition.test.ts +++ b/src/screens/__tests__/HomeScreen.race-condition.test.ts @@ -1,6 +1,7 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import { useSubscriptionStore } from '../../store/subscriptionStore'; import { useRefresh } from '../../hooks/useRefresh'; +import { BillingCycle, SubscriptionCategory } from '../../types/subscription'; /** * Test suite for pull-to-refresh race condition fix @@ -12,6 +13,8 @@ import { useRefresh } from '../../hooks/useRefresh'; */ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { + let storeResult: any; + beforeEach(() => { // Reset store state before each test useSubscriptionStore.setState({ @@ -19,6 +22,8 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { isLoading: false, error: null, }); + const { result } = renderHook(() => useSubscriptionStore()); + storeResult = result; }); describe('Acceptance Criteria', () => { @@ -40,9 +45,7 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { expect(result.current.refreshing).toBe(false); }); - test('AC2: No stale data shown - refreshSubscriptions fetches before clearing', async () => { - renderHook(() => useSubscriptionStore()); - + test('AC2: No stale data shown - fetchSubscriptions fetches before clearing', async () => { // Set initial subscriptions act(() => { useSubscriptionStore.setState({ @@ -50,9 +53,10 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { { id: '1', name: 'Old Subscription', + category: SubscriptionCategory.OTHER, price: 10, currency: 'USD', - billingCycle: 'monthly', + billingCycle: BillingCycle.MONTHLY, nextBillingDate: new Date(), isActive: true, notificationsEnabled: true, @@ -64,9 +68,9 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { }); }); - // Call refreshSubscriptions + // Call fetchSubscriptions await act(async () => { - await storeResult.current.refreshSubscriptions(); + await storeResult.current.fetchSubscriptions(); }); // Verify loading state was set and cleared properly @@ -76,7 +80,6 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { }); test('AC3: Loading state is correct during refresh', async () => { - renderHook(() => useSubscriptionStore()); const { result: refreshResult } = renderHook(() => useRefresh()); const loadingStates: boolean[] = []; @@ -121,7 +124,6 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { describe('Race Condition Scenarios', () => { test('Scenario 1: User pulls to refresh while data is loading', async () => { - renderHook(() => useSubscriptionStore()); const { result: refreshResult } = renderHook(() => useRefresh()); const fetchDuration = 200; @@ -169,7 +171,6 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { }); test('Scenario 3: Error during refresh does not leave infinite loading state', async () => { - renderHook(() => useSubscriptionStore()); const { result: refreshResult } = renderHook(() => useRefresh()); const testError = new Error('Fetch failed'); @@ -193,14 +194,13 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { describe('State Consistency', () => { test('Subscriptions state remains consistent after refresh', async () => { - renderHook(() => useSubscriptionStore()); - const initialSub = { id: '1', name: 'Test Sub', + category: SubscriptionCategory.OTHER, price: 9.99, currency: 'USD', - billingCycle: 'monthly' as const, + billingCycle: BillingCycle.MONTHLY, nextBillingDate: new Date(), isActive: true, notificationsEnabled: true, @@ -216,7 +216,7 @@ describe('HomeScreen - Pull-to-Refresh Race Condition Fix', () => { const beforeRefresh = storeResult.current.subscriptions.length; await act(async () => { - await storeResult.current.refreshSubscriptions(); + await storeResult.current.fetchSubscriptions(); }); const afterRefresh = storeResult.current.subscriptions.length; diff --git a/src/services/__tests__/realtimeService.test.ts b/src/services/__tests__/realtimeService.test.ts index a14aeb8c..08b48cd7 100644 --- a/src/services/__tests__/realtimeService.test.ts +++ b/src/services/__tests__/realtimeService.test.ts @@ -1,5 +1,5 @@ import { RealtimeService } from '../realtimeService'; -import { SubscriptionEvent } from '../../../backend/services/websocket'; +import { SubscriptionEvent } from '../../../backend/services/notification/websocket'; const makeEvent = (overrides: Partial = {}): SubscriptionEvent => ({ type: 'subscription.created', diff --git a/src/services/analyticsService.ts b/src/services/analyticsService.ts index 2786009c..f4bcede6 100644 --- a/src/services/analyticsService.ts +++ b/src/services/analyticsService.ts @@ -32,6 +32,8 @@ export interface SubscriptionAnalyticsReport { mrr: number; arr: number; ltv: number; + arpu: number; + subscriberCount: number; churn: ChurnMetrics; revenueTrend: RevenuePoint[]; cohorts: CohortMetric[]; @@ -128,6 +130,8 @@ export const calculateSubscriptionAnalytics = ( mrr, arr, ltv, + arpu: averageMonthlyRevenue, + subscriberCount: active.length, churn: { grossChurnRate, netChurnRate, diff --git a/src/services/auth/biometricService.ts b/src/services/auth/biometricService.ts index 2847bcc2..54ac9b6a 100644 --- a/src/services/auth/biometricService.ts +++ b/src/services/auth/biometricService.ts @@ -102,7 +102,7 @@ class BiometricService { try { const types = await lib.supportedAuthenticationTypesAsync(); const AuthType = lib.AuthenticationType; - return types.map((t) => { + return types.map((t: any) => { if (t === AuthType.FINGERPRINT) return 'fingerprint'; if (t === AuthType.FACIAL_RECOGNITION) return 'facial'; if (t === AuthType.IRIS) return 'iris'; diff --git a/src/services/realtimeService.ts b/src/services/realtimeService.ts index bff883c2..6f3bb86d 100644 --- a/src/services/realtimeService.ts +++ b/src/services/realtimeService.ts @@ -8,7 +8,7 @@ import { SubscriptionEvent, SubscriptionEventType, EventFilter, -} from '../../backend/services/websocket'; +} from '../../backend/services/notification/websocket'; export type EventHandler = (event: SubscriptionEvent) => void; diff --git a/src/services/sandbox/sandboxService.ts b/src/services/sandbox/sandboxService.ts index 98186ce8..75e5793a 100644 --- a/src/services/sandbox/sandboxService.ts +++ b/src/services/sandbox/sandboxService.ts @@ -4,6 +4,7 @@ import { SandboxEnvironment, TestSubscription, RateLimitConfig, + SandboxStatus, } from '../../types/sandbox'; const SANDBOX_STORAGE_KEY = '@subtrackr_sandbox_config'; @@ -79,6 +80,7 @@ const DEFAULT_SANDBOX_CONFIG: SandboxConfig = { name: 'Development Sandbox', description: 'Isolated sandbox environment for testing integrations', isActive: true, + status: SandboxStatus.ACTIVE, dataIsolation: true, rateLimit: ENV_RATE_LIMITS[SandboxEnvironment.DEVELOPMENT], dataResetInterval: 'weekly', diff --git a/src/services/sandbox/testDataGenerator.ts b/src/services/sandbox/testDataGenerator.ts index c9edd217..d5f15f97 100644 --- a/src/services/sandbox/testDataGenerator.ts +++ b/src/services/sandbox/testDataGenerator.ts @@ -132,6 +132,11 @@ class TestDataGenerator { includeInactive: false, includeCrypto: false, }, + [SandboxEnvironment.PRODUCTION]: { + subscriptions: 10, + includeInactive: true, + includeCrypto: true, + }, }; return this.generateSubscriptions(configs[environment]); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index be443cb8..3666aeee 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -15,8 +15,12 @@ import { TokenType, PaymentMethodValidationResult, PaymentAttempt, - GasEstimate, } from '../types/wallet'; +import { GasEstimate } from '../types/wallet'; + +import { NetworkError, NetworkErrorCode, ContractError, ContractErrorCode } from '../errors'; +export { GasEstimate }; +export { NetworkError, NetworkErrorCode, ContractError, ContractErrorCode }; // ── Structured error handling ────────────────────────────────────── @@ -258,11 +262,11 @@ export class WalletServiceManager { return balances; } catch (error) { - throw toWalletError( - error, - WalletErrorCode.BALANCE_FETCH_FAILED, + throw new NetworkError( + NetworkErrorCode.RPC_ERROR, 'Unable to fetch token balances.', - 'Check your network connection and try again.' + 'Check your network connection and try again.', + error ); } } @@ -653,11 +657,11 @@ export class WalletServiceManager { 'Open your wallet and approve the request to continue.' ); } - throw toWalletError( - error, - WalletErrorCode.APPROVAL_FAILED, + throw new ContractError( + ContractErrorCode.EXECUTION_FAILED, 'Token approval failed.', - 'Check your wallet connection and try again.' + 'Check your wallet connection and try again.', + error ); } } diff --git a/src/store/__tests__/integration.test.ts b/src/store/__tests__/integration.test.ts index e27611d4..8b14916d 100644 --- a/src/store/__tests__/integration.test.ts +++ b/src/store/__tests__/integration.test.ts @@ -20,6 +20,7 @@ import { useInvoiceStore } from '../invoiceStore'; import { useWalletStore } from '../walletStore'; import { SubscriptionCategory, BillingCycle } from '../../types/subscription'; import { BILLING_CONVERSIONS } from '../../utils/constants/values'; +import { TaxType } from '../../types/invoice'; // ── In-memory AsyncStorage ──────────────────────────────────────────────────── // Provides real read/write semantics without disk I/O. @@ -89,6 +90,7 @@ function resetInvoiceStore() { defaultTaxRateBps: 0, exchangeRateScale: 1_000_000, paymentTermsDays: 14, + defaultTaxType: TaxType.NONE, }, nextSequence: 1, isLoading: false, diff --git a/src/store/_tests_/subscriptionStore.test.ts b/src/store/_tests_/subscriptionStore.test.ts index 9848ff53..53e1f6d8 100644 --- a/src/store/_tests_/subscriptionStore.test.ts +++ b/src/store/_tests_/subscriptionStore.test.ts @@ -3,6 +3,7 @@ import { expect, describe, it, beforeEach, jest } from '@jest/globals'; import { useSubscriptionStore } from '../subscriptionStore'; import { useInvoiceStore } from '../invoiceStore'; import { SubscriptionCategory, BillingCycle } from '../../types/subscription'; +import { TaxType } from '../../types/invoice'; // 🔥 Mock AsyncStorage jest.mock('@react-native-async-storage/async-storage', () => ({ @@ -56,6 +57,7 @@ describe('subscriptionStore', () => { defaultTaxRateBps: 0, exchangeRateScale: 1_000_000, paymentTermsDays: 14, + defaultTaxType: TaxType.NONE, }, nextSequence: 1, isLoading: false, diff --git a/src/store/cancellationStore.ts b/src/store/cancellationStore.ts index b48dd800..14bacfa2 100644 --- a/src/store/cancellationStore.ts +++ b/src/store/cancellationStore.ts @@ -7,7 +7,7 @@ import { RetentionOffer, CancellationRecord, UserSegmentContext, -} from '../../backend/services/retentionService'; +} from '../../backend/services/analytics/retentionService'; import { useSubscriptionStore } from './subscriptionStore'; import { useUserStore } from './userStore'; diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index 8be1e478..57185a2e 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -54,8 +54,6 @@ const generateUniqueId = (): string => { return `${timestamp}-${randomComponent}`; }; -type PersistedSubscriptionSlice = Pick; - const toValidDate = (value: unknown, fallback = new Date()): Date => { if (value instanceof Date && !Number.isNaN(value.getTime())) return value; if (typeof value === 'string' || typeof value === 'number') { @@ -125,31 +123,6 @@ const createSupportEvent = ( }; }; -const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscriptionSlice => ({ - subscriptions: state.subscriptions.map((sub) => ({ - ...sub, - nextBillingDate: new Date(sub.nextBillingDate), - createdAt: new Date(sub.createdAt), - updatedAt: new Date(sub.updatedAt), - })), -}); - -const migratePersistedState = ( - persisted: unknown, - _version: number -): PersistedSubscriptionSlice => { - if (!persisted || typeof persisted !== 'object') { - return { subscriptions: [] }; - } - - const maybeState = persisted as Partial; - const subscriptions = Array.isArray(maybeState.subscriptions) - ? maybeState.subscriptions.map((entry) => normalizeSubscription(entry as Partial)) - : []; - - return { subscriptions }; -}; - const pendingWrites = new Map(); let writeTimer: ReturnType | null = null; let writeQueue = Promise.resolve(); @@ -195,6 +168,20 @@ const debouncedAsyncStorage: StateStorage = { }, }; +export type ProrationEffectiveType = 'immediate' | 'end_of_period' | 'custom_date'; + +export interface SubscriptionChange { + id: string; + subscriptionId: string; + fromPrice: number; + toPrice: number; + effectiveType: ProrationEffectiveType; + status: 'pending' | 'executed' | 'rejected'; + proration: ProrationPreview; + createdAt: Date; + newPlanData: Partial; +} + interface SubscriptionState { subscriptions: Subscription[]; stats: SubscriptionStats; @@ -202,6 +189,7 @@ interface SubscriptionState { error: AppError | null; prorationPreview: ProrationPreview | null; creditMemos: Record; + planChanges: SubscriptionChange[]; // Actions addSubscription: (data: SubscriptionFormData) => Promise; @@ -224,8 +212,54 @@ interface SubscriptionState { recordBillingOutcome: (id: string, outcome: 'success' | 'failed') => Promise; fetchSubscriptions: () => Promise; calculateStats: () => void; + queuePlanChange: ( + id: string, + newPlanData: Partial, + effectiveDate: ProrationEffectiveType + ) => void; + approvePlanChange: (changeId: string) => Promise; + rejectPlanChange: (changeId: string) => void; + getChangeHistory: (subscriptionId: string) => SubscriptionChange[]; } +type PersistedSubscriptionSlice = Pick; + +const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscriptionSlice => ({ + subscriptions: (state.subscriptions || []).map((sub) => ({ + ...sub, + nextBillingDate: new Date(sub.nextBillingDate), + createdAt: new Date(sub.createdAt), + updatedAt: new Date(sub.updatedAt), + })), + planChanges: (state.planChanges || []).map((change) => ({ + ...change, + createdAt: new Date(change.createdAt), + })), +}); + +const migratePersistedState = ( + persisted: unknown, + _version: number +): PersistedSubscriptionSlice => { + if (!persisted || typeof persisted !== 'object') { + return { subscriptions: [], planChanges: [] }; + } + + const maybeState = persisted as Partial; + const subscriptions = Array.isArray(maybeState.subscriptions) + ? maybeState.subscriptions.map((entry) => normalizeSubscription(entry as Partial)) + : []; + + const planChanges = Array.isArray(maybeState.planChanges) + ? maybeState.planChanges.map((entry) => ({ + ...entry, + createdAt: new Date(entry.createdAt), + })) + : []; + + return { subscriptions, planChanges }; +}; + export const useSubscriptionStore = create()( persist( (set, get) => ({ @@ -240,6 +274,7 @@ export const useSubscriptionStore = create()( error: null, prorationPreview: null, creditMemos: {}, + planChanges: [], previewPlanChange: ( id: string, @@ -325,6 +360,64 @@ export const useSubscriptionStore = create()( console.log(`Applied credit: final charge ${finalCharge}`); }, + queuePlanChange: ( + id: string, + newPlanData: Partial, + effectiveDate: ProrationEffectiveType + ) => { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) throw new Error('Subscription not found'); + const preview = previewProration( + sub, + newPlanData.price ?? sub.price, + effectiveDate === 'end_of_period' ? 'end_of_period' : 'immediate' + ); + const change: SubscriptionChange = { + id: generateUniqueId(), + subscriptionId: id, + fromPrice: sub.price, + toPrice: newPlanData.price ?? sub.price, + effectiveType: effectiveDate, + status: 'pending', + proration: preview, + createdAt: new Date(), + newPlanData, + }; + set((state) => ({ + planChanges: [...(state.planChanges || []), change], + })); + }, + + approvePlanChange: async (changeId: string) => { + const change = (get().planChanges || []).find((c) => c.id === changeId); + if (!change) throw new Error('Change request not found'); + if (change.status !== 'pending') throw new Error('Change request is not pending'); + + await get().executePlanChange( + change.subscriptionId, + change.newPlanData, + change.effectiveType === 'end_of_period' ? 'end_of_period' : 'immediate' + ); + + set((state) => ({ + planChanges: (state.planChanges || []).map((c) => + c.id === changeId ? { ...c, status: 'executed' } : c + ), + })); + }, + + rejectPlanChange: (changeId: string) => { + set((state) => ({ + planChanges: (state.planChanges || []).map((c) => + c.id === changeId ? { ...c, status: 'rejected' } : c + ), + })); + }, + + getChangeHistory: (subscriptionId: string) => { + return (get().planChanges || []).filter((c) => c.subscriptionId === subscriptionId); + }, + addSubscription: async (data: SubscriptionFormData) => { set({ isLoading: true, error: null }); try { @@ -648,7 +741,8 @@ export const useSubscriptionStore = create()( name: STORAGE_KEY, version: STORE_VERSION, storage: createJSONStorage(() => debouncedAsyncStorage), - partialize: (state) => serializeForStorage({ subscriptions: state.subscriptions }), + partialize: (state) => + serializeForStorage({ subscriptions: state.subscriptions, planChanges: state.planChanges }), migrate: (persistedState, version) => migratePersistedState(persistedState, version), merge: (persistedState, currentState) => ({ ...currentState, diff --git a/src/theme/colors.ts b/src/theme/colors.ts index 7493c16f..45978974 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -122,4 +122,64 @@ export const darkColors = { textSecondary: '#9CA3AF', } as const; -export type ColorTokens = typeof lightColors; +export interface ColorTokens { + readonly background: { + readonly primary: string; + readonly secondary: string; + readonly card: string; + readonly modal: string; + }; + readonly text: { + readonly primary: string; + readonly secondary: string; + readonly disabled: string; + readonly inverse: string; + readonly link: string; + }; + readonly border: { + readonly default: string; + readonly focused: string; + readonly error: string; + }; + readonly brand: { + readonly primary: string; + readonly primaryDark: string; + readonly secondary: string; + }; + readonly status: { + readonly success: string; + readonly warning: string; + readonly error: string; + readonly info: string; + }; + readonly subscription: { + readonly active: string; + readonly expiringSoon: string; + readonly expired: string; + readonly paused: string; + }; + readonly navigation: { + readonly tabBar: string; + readonly tabBarBorder: string; + readonly activeTab: string; + readonly inactiveTab: string; + readonly header: string; + readonly headerText: string; + }; + readonly surface: string; + readonly surfaceVariant: string; + readonly accent: string; + readonly onPrimary: string; + readonly onSecondary: string; + readonly onSurface: string; + readonly onSurfaceVariant: string; + readonly overlay: string; + readonly warningBackground: string; + readonly primary: string; + readonly secondary: string; + readonly success: string; + readonly warning: string; + readonly error: string; + readonly backgroundFlat: string; + readonly textSecondary: string; +} diff --git a/src/types/expo-local-authentication.d.ts b/src/types/expo-local-authentication.d.ts new file mode 100644 index 00000000..d7003d32 --- /dev/null +++ b/src/types/expo-local-authentication.d.ts @@ -0,0 +1,21 @@ +declare module 'expo-local-authentication' { + export interface AuthenticationResult { + success: boolean; + error?: string; + } + export interface AuthenticationOptions { + promptMessage?: string; + fallbackLabel?: string; + disableDeviceFallback?: boolean; + cancelLabel?: string; + } + export enum AuthenticationType { + FINGERPRINT = 1, + FACIAL_RECOGNITION = 2, + IRIS = 3, + } + export function hasHardwareAsync(): Promise; + export function isEnrolledAsync(): Promise; + export function supportedAuthenticationTypesAsync(): Promise; + export function authenticateAsync(options?: AuthenticationOptions): Promise; +}