From 46e77565d6d9717a8fc09e0e81d8157b65748412 Mon Sep 17 00:00:00 2001 From: Jefferson Calderon Mesen Date: Thu, 20 Aug 2026 17:08:42 -0600 Subject: [PATCH] ( feat: enable typed ESLint rules (@typescript-eslint/recommended + stylistic) --- .eslintrc.js | 43 ++ App.tsx | 6 +- package-lock.json | 568 ++++++++++++++++-- package.json | 7 + src/__tests__/__mocks__/rn-modules.ts | 2 +- src/__tests__/__mocks__/setup.ts | 20 +- src/__tests__/authRefresh.test.ts | 44 +- src/__tests__/notifications.test.ts | 12 +- src/__tests__/proofQueue.test.ts | 2 +- src/__tests__/stellarPayment.test.ts | 5 +- src/__tests__/useProofSubmit.test.tsx | 116 ++-- src/__tests__/useTaskFeed.test.ts | 14 +- src/components/ErrorBoundary.tsx | 2 +- src/components/LoadingSkeleton.tsx | 12 +- src/components/TransactionHistory.tsx | 4 +- src/hooks/useAuth.ts | 8 +- src/hooks/useLocation.ts | 6 +- src/hooks/useProofSubmit.ts | 86 ++- src/hooks/useStellarWallet.ts | 22 +- src/hooks/useTaskFeed.ts | 34 +- src/navigation/MainTabNavigator.tsx | 3 + src/navigation/RootNavigator.tsx | 3 + src/navigation/TaskStackNavigator.tsx | 3 + src/screens/EditProfileScreen.tsx | 9 +- src/screens/HomeScreen.tsx | 18 +- src/screens/NotificationPreferencesScreen.tsx | 7 +- src/screens/OnboardingScreen.tsx | 8 +- src/screens/ProfileScreen.tsx | 11 +- src/screens/SendTokensScreen.tsx | 10 +- src/screens/SubmitProofScreen.tsx | 15 +- src/screens/TaskDetailScreen.tsx | 61 +- src/screens/TaskListScreen.tsx | 12 +- src/screens/WalletScreen.tsx | 18 +- .../__tests__/api.integration.test.ts | 27 +- src/services/api.ts | 25 +- src/services/ipfs.ts | 8 +- src/services/notifications.ts | 31 +- src/services/stellar.ts | 2 +- src/store/prefsStore.ts | 42 +- src/types/index.ts | 7 + src/utils/achievements.ts | 9 +- src/utils/geoUtils.ts | 4 +- 42 files changed, 1052 insertions(+), 294 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 810c0ab..6f118f3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,4 +6,47 @@ module.exports = { 'react-native/sort-styles': 0, 'react-native/no-inline-styles': 1, }, + overrides: [ + { + files: ['*.ts', '*.tsx'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/stylistic', + ], + rules: { + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-misused-promises': 'warn', + '@typescript-eslint/await-thenable': 'warn', + '@typescript-eslint/strict-boolean-expressions': [ + 'warn', + { + allowNullableBoolean: true, + allowNullableString: true, + allowNullableNumber: true, + allowNullableObject: true, + }, + ], + // `void expr` (including as a concise arrow body, e.g. + // `onPress={() => void save()}`) is the idiomatic way to satisfy + // no-floating-promises for intentionally unawaited promises, which + // conflicts with the base config's blanket ban on `void`. + 'no-void': 'off', + }, + }, + { + // react-navigation's ParamList generics require an object-literal + // `type` alias (not `interface`) to satisfy their implicit index + // signature constraint — https://reactnavigation.org/docs/typescript + files: ['src/navigation/*.tsx'], + rules: { + '@typescript-eslint/consistent-type-definitions': 'off', + }, + }, + ], }; diff --git a/App.tsx b/App.tsx index b051aab..4c08e3d 100644 --- a/App.tsx +++ b/App.tsx @@ -15,16 +15,16 @@ function AppSync() { useEffect(() => { const sub = AppState.addEventListener('change', (state: AppStateStatus) => { if (state === 'active') { - syncPendingProofs(); + void syncPendingProofs(); } }); return () => sub.remove(); }, [syncPendingProofs]); useEffect(() => { - registerForPushNotifications().then(token => { + void registerForPushNotifications().then(token => { if (token) { - sendTokenToServer(token); + void sendTokenToServer(token); } }); }, []); diff --git a/package-lock.json b/package-lock.json index 1e1a612..bc42dd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,11 +40,14 @@ "@types/react": "^18.2.0", "@types/react-native": "^0.73.0", "@types/react-test-renderer": "^19.1.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "babel-plugin-module-resolver": "^5.0.0", "eslint": "^8.19.0", "eslint-plugin-prettier": "^5.5.6", "jest": "^29.7.0", "metro-react-native-babel-preset": "^0.77.0", + "prettier": "^3.9.6", "react-test-renderer": "^18.2.0", "typescript": "5.3.3" } @@ -382,6 +385,7 @@ }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -396,6 +400,7 @@ }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -409,6 +414,7 @@ }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -422,6 +428,7 @@ }, "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -436,6 +443,7 @@ }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -451,6 +459,7 @@ }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -582,6 +591,7 @@ }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -674,6 +684,7 @@ }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -687,6 +698,7 @@ }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -837,6 +849,7 @@ }, "node_modules/@babel/plugin-syntax-unicode-sets-regex": { "version": "7.18.6", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -864,6 +877,7 @@ }, "node_modules/@babel/plugin-transform-async-generator-functions": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -894,6 +908,7 @@ }, "node_modules/@babel/plugin-transform-block-scoped-functions": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -920,6 +935,7 @@ }, "node_modules/@babel/plugin-transform-class-properties": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", @@ -934,6 +950,7 @@ }, "node_modules/@babel/plugin-transform-class-static-block": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", @@ -994,6 +1011,7 @@ }, "node_modules/@babel/plugin-transform-dotall-regex": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1008,6 +1026,7 @@ }, "node_modules/@babel/plugin-transform-duplicate-keys": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1021,6 +1040,7 @@ }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1035,6 +1055,7 @@ }, "node_modules/@babel/plugin-transform-dynamic-import": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1048,6 +1069,7 @@ }, "node_modules/@babel/plugin-transform-explicit-resource-management": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1062,6 +1084,7 @@ }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1075,6 +1098,7 @@ }, "node_modules/@babel/plugin-transform-export-namespace-from": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1102,6 +1126,7 @@ }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1131,6 +1156,7 @@ }, "node_modules/@babel/plugin-transform-json-strings": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1157,6 +1183,7 @@ }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1170,6 +1197,7 @@ }, "node_modules/@babel/plugin-transform-member-expression-literals": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1183,6 +1211,7 @@ }, "node_modules/@babel/plugin-transform-modules-amd": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", @@ -1211,6 +1240,7 @@ }, "node_modules/@babel/plugin-transform-modules-systemjs": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", @@ -1227,6 +1257,7 @@ }, "node_modules/@babel/plugin-transform-modules-umd": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", @@ -1255,6 +1286,7 @@ }, "node_modules/@babel/plugin-transform-new-target": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1268,6 +1300,7 @@ }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1281,6 +1314,7 @@ }, "node_modules/@babel/plugin-transform-numeric-separator": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1294,6 +1328,7 @@ }, "node_modules/@babel/plugin-transform-object-rest-spread": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", @@ -1311,6 +1346,7 @@ }, "node_modules/@babel/plugin-transform-object-super": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1325,6 +1361,7 @@ }, "node_modules/@babel/plugin-transform-optional-catch-binding": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1338,6 +1375,7 @@ }, "node_modules/@babel/plugin-transform-optional-chaining": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1394,6 +1432,7 @@ }, "node_modules/@babel/plugin-transform-property-literals": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1463,6 +1502,7 @@ }, "node_modules/@babel/plugin-transform-regenerator": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1476,6 +1516,7 @@ }, "node_modules/@babel/plugin-transform-regexp-modifiers": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1490,6 +1531,7 @@ }, "node_modules/@babel/plugin-transform-reserved-words": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1572,6 +1614,7 @@ }, "node_modules/@babel/plugin-transform-template-literals": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1585,6 +1628,7 @@ }, "node_modules/@babel/plugin-transform-typeof-symbol": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1615,6 +1659,7 @@ }, "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1628,6 +1673,7 @@ }, "node_modules/@babel/plugin-transform-unicode-property-regex": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1656,6 +1702,7 @@ }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1670,6 +1717,7 @@ }, "node_modules/@babel/preset-env": { "version": "7.29.7", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -1768,6 +1816,7 @@ }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -3765,12 +3814,12 @@ }, "node_modules/@types/prop-types": { "version": "15.7.15", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.30", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3817,31 +3866,95 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependenciesMeta": { "typescript": { @@ -3849,6 +3962,89 @@ } } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "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/@typescript-eslint/eslint-plugin/node_modules/semver": { "version": "7.8.1", "dev": true, @@ -3861,24 +4057,27 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.62.0", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -3886,6 +4085,137 @@ } } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "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/@typescript-eslint/parser/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "5.62.0", "dev": true, @@ -3903,24 +4233,26 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", "debug": "^4.3.4", - "tsutils": "^3.21.0" + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -3928,6 +4260,163 @@ } } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "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/@typescript-eslint/type-utils/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/types": { "version": "5.62.0", "dev": true, @@ -4973,6 +5462,7 @@ }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.14.2", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", @@ -5786,7 +6276,7 @@ }, "node_modules/csstype": { "version": "3.2.3", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -6669,6 +7159,7 @@ }, "node_modules/esutils": { "version": "2.0.3", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -9822,11 +10313,6 @@ "dev": true, "license": "MIT" }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "license": "MIT", @@ -10747,10 +11233,11 @@ } }, "node_modules/prettier": { - "version": "3.8.3", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12773,6 +13260,19 @@ "version": "0.0.3", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/package.json b/package.json index 164c2dd..6f651f8 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,14 @@ "@types/react": "^18.2.0", "@types/react-native": "^0.73.0", "@types/react-test-renderer": "^19.1.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "babel-plugin-module-resolver": "^5.0.0", "eslint": "^8.19.0", "eslint-plugin-prettier": "^5.5.6", "jest": "^29.7.0", "metro-react-native-babel-preset": "^0.77.0", + "prettier": "^3.9.6", "react-test-renderer": "^18.2.0", "typescript": "5.3.3" }, @@ -57,5 +60,9 @@ "integration", "__mocks__" ] + }, + "overrides": { + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0" } } diff --git a/src/__tests__/__mocks__/rn-modules.ts b/src/__tests__/__mocks__/rn-modules.ts index e855921..2d7afdb 100644 --- a/src/__tests__/__mocks__/rn-modules.ts +++ b/src/__tests__/__mocks__/rn-modules.ts @@ -55,7 +55,7 @@ jest.mock( ), getGenericPassword: jest.fn(async (opts?: { service?: string }) => { const key = opts?.service || 'default'; - return store[key] || false; + return key in store ? store[key] : false; }), resetGenericPassword: jest.fn(async (opts?: { service?: string }) => { const key = opts?.service || 'default'; diff --git a/src/__tests__/__mocks__/setup.ts b/src/__tests__/__mocks__/setup.ts index 5296274..018ee55 100644 --- a/src/__tests__/__mocks__/setup.ts +++ b/src/__tests__/__mocks__/setup.ts @@ -14,24 +14,36 @@ jest.mock('react-native-mmkv', () => { }; }); +type ZustandSet = (...args: unknown[]) => void; +type ZustandGet = (...args: unknown[]) => unknown; +type ZustandStateCreator = ( + set: ZustandSet, + get: ZustandGet, + api: unknown, +) => unknown; +interface ZustandPersistOptions { + name?: string; + storage?: { getItem: (name: string) => string | null }; +} + jest.mock('zustand/middleware', () => { // simple in-memory JSON storage adapter for tests return { - persist: (config: any, options: any) => { + persist: (config: ZustandStateCreator, options?: ZustandPersistOptions) => { // options may include name and storage const storage = options?.storage; const name = options?.name || 'zustand-test'; // create a wrapped config that uses the provided set/get - return (set: any, get: any, api: any) => { + return (set: ZustandSet, get: ZustandGet, api: unknown) => { // persist storage helpers are intentionally unused in tests // (storage is accessed directly via createJSONStorage mock below) // if storage has existing data, try to hydrate by calling set with parsed JSON - const existing = storage.getItem(name); + const existing = storage?.getItem(name); if (existing) { try { - const parsed = JSON.parse(existing); + const parsed: unknown = JSON.parse(existing); // apply initial state by calling set set(() => parsed); } catch {} diff --git a/src/__tests__/authRefresh.test.ts b/src/__tests__/authRefresh.test.ts index cc7d783..3b75ece 100644 --- a/src/__tests__/authRefresh.test.ts +++ b/src/__tests__/authRefresh.test.ts @@ -43,7 +43,23 @@ function makeJwt(exp: number): string { return `${header}.${payload}.signature`; } -function successResponse(data: unknown, config: unknown) { +interface MockAxiosConfig { + url?: string; + headers?: Record; + [key: string]: unknown; +} + +interface MockAxiosError extends Error { + config?: MockAxiosConfig; + response?: { + status: number; + statusText: string; + data: unknown; + headers: Record; + }; +} + +function successResponse(data: unknown, config: MockAxiosConfig) { return { data, status: 200, @@ -53,8 +69,10 @@ function successResponse(data: unknown, config: unknown) { }; } -function unauthorizedError(config: unknown) { - const error: any = new Error('Request failed with status code 401'); +function unauthorizedError(config: MockAxiosConfig) { + const error: MockAxiosError = new Error( + 'Request failed with status code 401', + ); error.config = config; error.response = { status: 401, @@ -66,7 +84,7 @@ function unauthorizedError(config: unknown) { } function mockAuthEndpoints(adapter: jest.Mock): void { - adapter.mockImplementation(async (config: any) => { + adapter.mockImplementation(async (config: MockAxiosConfig) => { if (config.url === '/auth/challenge') { return successResponse({ challenge: 'challenge-xdr' }, config); } @@ -78,7 +96,7 @@ function mockAuthEndpoints(adapter: jest.Mock): void { } const adapter = jest.fn(); -api.defaults.adapter = adapter as any; +api.defaults.adapter = adapter as unknown as typeof api.defaults.adapter; beforeEach(() => { useUserStore.setState({ profile: null, token: null, tokenExpiresAt: null }); @@ -160,9 +178,9 @@ describe('request interceptor', () => { .getState() .setToken(makeJwt(Math.floor(Date.now() / 1000) - 60)); - adapter.mockImplementationOnce(async (config: any) => { + adapter.mockImplementationOnce(async (config: MockAxiosConfig) => { if (config.url === '/tasks') { - expect(config.headers.Authorization).toBe(`Bearer ${NEW_TOKEN}`); + expect(config.headers?.Authorization).toBe(`Bearer ${NEW_TOKEN}`); } return successResponse({ tasks: [] }, config); }); @@ -181,9 +199,11 @@ describe('response interceptor', () => { useWalletStore.getState().connect('GCKEY'); useUserStore.getState().setToken(makeJwt(EXP)); mockedGetInAppSecret.mockReturnValue(null); // no signing key => refresh fails - const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const alertSpy = jest + .spyOn(Alert, 'alert') + .mockImplementation(() => undefined); - adapter.mockImplementation(async (config: any) => { + adapter.mockImplementation(async (config: MockAxiosConfig) => { if (config.url === '/auth/challenge') { return successResponse({ challenge: 'challenge-xdr' }, config); } @@ -208,7 +228,7 @@ describe('response interceptor', () => { mockedSignChallengeXDR.mockReturnValue('signed-xdr'); let taskRequests = 0; - adapter.mockImplementation(async (config: any) => { + adapter.mockImplementation(async (config: MockAxiosConfig) => { if (config.url === '/auth/challenge') { return successResponse({ challenge: 'challenge-xdr' }, config); } @@ -223,13 +243,13 @@ describe('response interceptor', () => { if (taskRequests === 1) { return unauthorizedError(config); } - expect(config.headers.Authorization).toBe(`Bearer ${NEW_TOKEN}`); + expect(config.headers?.Authorization).toBe(`Bearer ${NEW_TOKEN}`); return successResponse({ tasks: [{ id: 't1' }] }, config); } return successResponse({}, config); }); - const result: any = await api.get('/tasks'); + const result = await api.get<{ tasks: { id: string }[] }>('/tasks'); expect(result.data.tasks).toEqual([{ id: 't1' }]); expect(taskRequests).toBe(2); diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts index 826500f..c41bac4 100644 --- a/src/__tests__/notifications.test.ts +++ b/src/__tests__/notifications.test.ts @@ -1,6 +1,7 @@ import { onNotificationReceived, scheduleLocalNotification, + NotificationPayload, NOTIFICATION_TYPES, } from '../services/notifications'; import usePrefsStore from '../store/prefsStore'; @@ -10,15 +11,14 @@ describe('notification suppression', () => { // reset prefs const s = usePrefsStore.getState(); s.setAllEnabled(true); - const defaults = Object.values(NOTIFICATION_TYPES).reduce( - (acc: any, t) => ({ ...acc, [t]: true }), - {}, - ); - usePrefsStore.setState({ notificationPrefs: defaults } as any); + const defaults = Object.values(NOTIFICATION_TYPES).reduce< + Record + >((acc, t) => ({ ...acc, [t]: true }), {}); + usePrefsStore.setState({ notificationPrefs: defaults }); }); test('does not fire when type is disabled', async () => { - const called: any[] = []; + const called: NotificationPayload[] = []; const unsub = onNotificationReceived(p => called.push(p)); const type = Object.values(NOTIFICATION_TYPES)[0]; diff --git a/src/__tests__/proofQueue.test.ts b/src/__tests__/proofQueue.test.ts index dc5a409..9021515 100644 --- a/src/__tests__/proofQueue.test.ts +++ b/src/__tests__/proofQueue.test.ts @@ -1,4 +1,5 @@ import './__mocks__/setup'; +import { MMKV } from 'react-native-mmkv'; import { loadQueue, saveQueue, @@ -88,7 +89,6 @@ describe('proofQueue', () => { }); it('returns an empty array when storage is corrupted', () => { - const { MMKV } = require('react-native-mmkv'); const instance = new MMKV(); instance.set('pending_proofs', 'not-json{'); expect(loadQueue()).toEqual([]); diff --git a/src/__tests__/stellarPayment.test.ts b/src/__tests__/stellarPayment.test.ts index 8260ea7..f7ef80f 100644 --- a/src/__tests__/stellarPayment.test.ts +++ b/src/__tests__/stellarPayment.test.ts @@ -4,6 +4,7 @@ import { Networks, Account, TransactionBuilder, + Operation, } from '@stellar/stellar-sdk'; import { buildPaymentXDR, @@ -29,7 +30,7 @@ describe('buildPaymentXDR', () => { const parsed = TransactionBuilder.fromXDR(xdr, Networks.TESTNET); expect(parsed.source).toBe(kp.publicKey()); expect(parsed.operations).toHaveLength(1); - const op = parsed.operations[0] as any; + const op = parsed.operations[0] as Operation.Payment; expect(op.type).toBe('payment'); expect(op.destination).toBe(dest); expect(parseFloat(op.amount)).toBe(10.5); @@ -50,7 +51,7 @@ describe('buildPaymentXDR', () => { ); const parsed = TransactionBuilder.fromXDR(xdr, Networks.TESTNET); - const op = parsed.operations[0] as any; + const op = parsed.operations[0] as Operation.Payment; expect(op.asset.code).toBe('ECO'); expect(op.asset.issuer).toBe(kp.publicKey()); }); diff --git a/src/__tests__/useProofSubmit.test.tsx b/src/__tests__/useProofSubmit.test.tsx index 9e64214..233a60a 100644 --- a/src/__tests__/useProofSubmit.test.tsx +++ b/src/__tests__/useProofSubmit.test.tsx @@ -4,9 +4,19 @@ import { useProofSubmit } from '../hooks/useProofSubmit'; import * as ipfs from '../services/ipfs'; import * as api from '../services/api'; import { clearQueue, loadQueue } from '../services/proofQueue'; +import * as proofQueueService from '../services/proofQueue'; import { useProofSyncStore } from '../store/proofSyncStore'; +import { SubmitProofResult } from '../types'; -function HookHarness({ onRef }: any) { +type UseProofSubmitResult = ReturnType; + +const CAPTURED_AT = '2026-01-01T00:00:00.000Z'; + +function HookHarness({ + onRef, +}: { + onRef: (hook: UseProofSubmitResult) => void; +}) { const hook = useProofSubmit(); React.useEffect(() => { onRef(hook); @@ -21,39 +31,62 @@ describe('useProofSubmit integration', () => { }); test('online happy path', async () => { - const ipfsFile = { cid: 'Qm123', url: 'https://ipfs.io/ipfs/Qm123' }; - jest.spyOn(ipfs, 'pinFile').mockResolvedValue(ipfsFile as any); - jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ cid: 'QmMeta' } as any); - jest.spyOn(api, 'submitProof').mockResolvedValue({ success: true } as any); + const ipfsFile = { + cid: 'Qm123', + url: 'https://ipfs.io/ipfs/Qm123', + size: 100, + }; + jest.spyOn(ipfs, 'pinFile').mockResolvedValue(ipfsFile); + jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ + cid: 'QmMeta', + url: 'https://ipfs.io/ipfs/QmMeta', + size: 50, + }); + const submitResult: SubmitProofResult = { taskTitle: 'success' }; + jest.spyOn(api, 'submitProof').mockResolvedValue(submitResult); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); await act(async () => { - const res = await ref.submit('task-1', '/path/photo.jpg', 1, 2); - expect(res).toEqual({ success: true }); + const res = await ref.submit( + 'task-1', + '/path/photo.jpg', + CAPTURED_AT, + 1, + 2, + ); + expect(res).toEqual(submitResult); expect(ref.progress).toBe('confirmed'); expect(ref.pendingCount).toBe(0); }); }); test('offline enqueue then sync', async () => { - jest.spyOn(ipfs, 'pinFile').mockResolvedValue({ cid: 'QmX' } as any); - jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ cid: 'QmMeta' } as any); + jest.spyOn(ipfs, 'pinFile').mockResolvedValue({ + cid: 'QmX', + url: 'https://ipfs.io/ipfs/QmX', + size: 10, + }); + jest.spyOn(ipfs, 'pinJSON').mockResolvedValue({ + cid: 'QmMeta', + url: 'https://ipfs.io/ipfs/QmMeta', + size: 50, + }); jest .spyOn(api, 'submitProof') .mockRejectedValueOnce(new Error('network')) - .mockResolvedValueOnce({ ok: true } as any); + .mockResolvedValueOnce({ taskTitle: 'ok' }); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); await act(async () => { - const res = await ref.submit('task-2', '/path/p.jpg'); + const res = await ref.submit('task-2', '/path/p.jpg', CAPTURED_AT); expect(res).toBeUndefined(); expect(ref.progress).toBe('failed'); expect(ref.pendingCount).toBeGreaterThan(0); @@ -69,16 +102,17 @@ describe('useProofSubmit integration', () => { test('ipfs failure handled (still attempts submit)', async () => { jest.spyOn(ipfs, 'pinFile').mockRejectedValue(new Error('ipfs down')); jest.spyOn(ipfs, 'pinJSON').mockRejectedValue(new Error('ipfs down')); - jest.spyOn(api, 'submitProof').mockResolvedValue({ ok: true } as any); + const submitResult: SubmitProofResult = { taskTitle: 'ok' }; + jest.spyOn(api, 'submitProof').mockResolvedValue(submitResult); - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); await act(async () => { - const res = await ref.submit('task-3', '/p.jpg'); - expect(res).toEqual({ ok: true }); + const res = await ref.submit('task-3', '/p.jpg', CAPTURED_AT); + expect(res).toEqual(submitResult); expect(ref.progress).toBe('confirmed'); }); }); @@ -88,9 +122,9 @@ describe('useProofSubmit integration', () => { jest .spyOn(api, 'submitProof') .mockRejectedValueOnce(new Error('fail1')) - .mockResolvedValueOnce({ ok: true } as any); + .mockResolvedValueOnce({ taskTitle: 'ok' }); // enqueue two proofs manually via the submit failure path - let ref: any; + let ref!: UseProofSubmitResult; await act(async () => { renderer.create( (ref = r)} />); }); @@ -98,19 +132,19 @@ describe('useProofSubmit integration', () => { await act(async () => { // first submit fails and enqueues jest.spyOn(ipfs, 'pinFile').mockRejectedValue(new Error('ipfs')); - await ref.submit('task-A', '/a.jpg'); + await ref.submit('task-A', '/a.jpg', CAPTURED_AT); // second submit fails and enqueues (simulate network) jest .spyOn(api, 'submitProof') .mockRejectedValueOnce(new Error('network')); - await ref.submit('task-B', '/b.jpg'); + await ref.submit('task-B', '/b.jpg', CAPTURED_AT); }); // now make next sync attempt: first will fail, second will succeed jest .spyOn(api, 'submitProof') .mockRejectedValueOnce(new Error('still-fail')) - .mockResolvedValueOnce({ ok: true } as any); + .mockResolvedValueOnce({ taskTitle: 'ok' }); await act(async () => { await ref.syncPendingProofs(); @@ -120,6 +154,12 @@ describe('useProofSubmit integration', () => { }); }); +interface FormDataLike { + get?: (key: string) => unknown; + _parts?: [string, unknown][]; + getParts?: () => { fieldName: string; string?: unknown }[]; +} + describe('useProofSubmit retry logic', () => { beforeEach(() => { jest.clearAllMocks(); @@ -131,10 +171,7 @@ describe('useProofSubmit retry logic', () => { const mockPinFile = jest.spyOn(ipfs, 'pinFile'); const mockPinJSON = jest.spyOn(ipfs, 'pinJSON'); const mockSubmitProof = jest.spyOn(api, 'submitProof'); - const mockLoadQueue = jest.spyOn( - require('../services/proofQueue'), - 'loadQueue', - ); + const mockLoadQueue = jest.spyOn(proofQueueService, 'loadQueue'); mockLoadQueue.mockReturnValue([ { @@ -146,16 +183,16 @@ describe('useProofSubmit retry logic', () => { photoCid: 'QmPhoto', metadataCid: 'QmMeta', }, - ] as any); - mockSubmitProof.mockResolvedValue({} as any); + ]); + mockSubmitProof.mockResolvedValue({}); - let hookResult: any; + let hookResult!: UseProofSubmitResult; function TestComponent() { hookResult = useProofSubmit(); return null; } - act(() => { + void act(() => { renderer.create(); }); @@ -165,7 +202,8 @@ describe('useProofSubmit retry logic', () => { expect(mockPinFile).not.toHaveBeenCalled(); expect(mockPinJSON).not.toHaveBeenCalled(); - const formDataArg = (mockSubmitProof as jest.Mock).mock.calls[0][0] as any; + const formDataArg = mockSubmitProof.mock + .calls[0][0] as unknown as FormDataLike; // Extract ipfs CIDs from FormData variations let photoCid; @@ -175,18 +213,14 @@ describe('useProofSubmit retry logic', () => { photoCid = formDataArg.get('ipfsPhotoCid'); metadataCid = formDataArg.get('ipfsMetadataCid'); } else if (formDataArg._parts) { - photoCid = formDataArg._parts.find( - (p: any) => p[0] === 'ipfsPhotoCid', - )?.[1]; + photoCid = formDataArg._parts.find(p => p[0] === 'ipfsPhotoCid')?.[1]; metadataCid = formDataArg._parts.find( - (p: any) => p[0] === 'ipfsMetadataCid', + p => p[0] === 'ipfsMetadataCid', )?.[1]; } else if (typeof formDataArg.getParts === 'function') { const parts = formDataArg.getParts(); - photoCid = parts.find((p: any) => p.fieldName === 'ipfsPhotoCid')?.string; - metadataCid = parts.find( - (p: any) => p.fieldName === 'ipfsMetadataCid', - )?.string; + photoCid = parts.find(p => p.fieldName === 'ipfsPhotoCid')?.string; + metadataCid = parts.find(p => p.fieldName === 'ipfsMetadataCid')?.string; } expect(photoCid).toBe('QmPhoto'); @@ -223,13 +257,13 @@ describe('useProofSubmit retry logic', () => { mockLoadQueue.mockReturnValue(pendingProofs); mockSubmitProof.mockResolvedValue({}); - let hookResult: any; + let hookResult!: UseProofSubmitResult; function TestComponent() { hookResult = useProofSubmit(); return null; } - act(() => { + void act(() => { renderer.create(); }); diff --git a/src/__tests__/useTaskFeed.test.ts b/src/__tests__/useTaskFeed.test.ts index 8419acb..15bff13 100644 --- a/src/__tests__/useTaskFeed.test.ts +++ b/src/__tests__/useTaskFeed.test.ts @@ -14,13 +14,13 @@ import { fetchTasks } from '../services/api'; const mockFetchTasks = fetchTasks as jest.MockedFunction; -type FeedOptions = { +interface FeedOptions { type?: string; lat?: number; lng?: number; radius?: number; sort?: TaskSortMode; -}; +} function makeTask(id: string): Task { return { @@ -48,26 +48,26 @@ function createHarness(options: FeedOptions) { } async function flush() { - await renderer.act(async () => {}); + await renderer.act(async () => undefined); } describe('useTaskFeed', () => { let tree: renderer.ReactTestRenderer | null = null; function renderFeed(options: FeedOptions) { - renderer.act(() => { + void renderer.act(() => { tree = renderer.create(createHarness(options)); }); } function updateFeed(options: FeedOptions) { - renderer.act(() => { + void renderer.act(() => { tree?.update(createHarness(options)); }); } function unmountFeed() { - renderer.act(() => { + void renderer.act(() => { tree?.unmount(); tree = null; }); @@ -186,7 +186,7 @@ describe('useTaskFeed', () => { await flush(); expect(useTaskStore.getState().tasks).toHaveLength(1); - renderer.act(() => { + void renderer.act(() => { feedRef.loadMore(); }); await flush(); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 6724b67..2f0acef 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -25,7 +25,7 @@ export default class ErrorBoundary extends Component { render() { if (this.state.hasError) { - if (this.props.fallback) { + if (this.props.fallback != null) { return this.props.fallback; } return ( diff --git a/src/components/LoadingSkeleton.tsx b/src/components/LoadingSkeleton.tsx index 9ac6ba4..08a2875 100644 --- a/src/components/LoadingSkeleton.tsx +++ b/src/components/LoadingSkeleton.tsx @@ -1,12 +1,18 @@ import React, { useEffect, useRef } from 'react'; -import { View, Animated } from 'react-native'; +import { + View, + Animated, + StyleProp, + ViewStyle, + DimensionValue, +} from 'react-native'; import { colors, spacing } from '../utils/theme'; interface SkeletonProps { - width?: number | string; + width?: DimensionValue; height?: number; borderRadius?: number; - style?: any; + style?: StyleProp; } export default function Skeleton({ diff --git a/src/components/TransactionHistory.tsx b/src/components/TransactionHistory.tsx index 54fd588..92155c9 100644 --- a/src/components/TransactionHistory.tsx +++ b/src/components/TransactionHistory.tsx @@ -55,7 +55,7 @@ export default function TransactionHistory({ }, [publicKey]); useEffect(() => { - loadPayments(); + void loadPayments(); }, [loadPayments]); if (loading) { @@ -82,7 +82,7 @@ export default function TransactionHistory({ return ( {error} - + void loadPayments()}> (null); useEffect(() => { - requestPermission(); + void requestPermission(); return () => { // Acceptance: clear watcher on unmount @@ -64,8 +64,8 @@ export function useLocation() { } setPermissionGranted(true); startWatch(); - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Location error'); } } diff --git a/src/hooks/useProofSubmit.ts b/src/hooks/useProofSubmit.ts index 79817fc..bfebfe0 100644 --- a/src/hooks/useProofSubmit.ts +++ b/src/hooks/useProofSubmit.ts @@ -11,6 +11,21 @@ import { } from '../services/proofQueue'; import { useProofSyncStore } from '../store/proofSyncStore'; +// Thrown by submitProofAttempt so callers can recover the CIDs that were +// already pinned to IPFS before the network submission failed, and avoid +// re-pinning them when the proof is retried from the offline queue. +class ProofUploadError extends Error { + photoCid?: string; + metadataCid?: string; + + constructor(message: string, photoCid?: string, metadataCid?: string) { + super(message); + this.name = 'ProofUploadError'; + this.photoCid = photoCid; + this.metadataCid = metadataCid; + } +} + export function useProofSubmit() { const [isSubmitting, setIsSubmitting] = useState(false); const [progress, setProgress] = useState< @@ -23,6 +38,7 @@ export function useProofSubmit() { async ( taskId: string, photoUri: string, + capturedAt: string, opts?: { lat?: number; lng?: number; @@ -39,11 +55,16 @@ export function useProofSubmit() { formData.append('lng', String(opts.lng)); } + interface ReactNativeFilePart { + uri: string; + type: string; + name: string; + } formData.append('photos', { uri: photoUri, type: 'image/jpeg', name: 'proof.jpg', - } as any); + } as ReactNativeFilePart as unknown as Blob); // reuse provided CIDs when available let photoCid = opts?.photoCid; @@ -52,7 +73,10 @@ export function useProofSubmit() { if (!photoCid || !metadataCid) { try { if (!photoCid) { - const photoRes = await pinFile(photoUri, proofFileName(taskId)); + const photoRes = await pinFile( + photoUri, + proofFileName(taskId, capturedAt), + ); photoCid = photoRes.cid; } if (photoCid && !metadataCid) { @@ -63,7 +87,7 @@ export function useProofSubmit() { lat: opts?.lat, lng: opts?.lng, }), - proofFileName(taskId, 'json'), + proofFileName(taskId, capturedAt, 'json'), ); metadataCid = metadataRes.cid; } @@ -83,28 +107,40 @@ export function useProofSubmit() { return await submitProof(formData); } catch (err) { // attach the generated cids so callers can persist them - (err as any).photoCid = photoCid; - (err as any).metadataCid = metadataCid; - throw err; + throw new ProofUploadError( + err instanceof Error ? err.message : 'Upload failed', + photoCid, + metadataCid, + ); } }, [], ); const submit = useCallback( - async (taskId: string, photoUri: string, lat?: number, lng?: number) => { + async ( + taskId: string, + photoUri: string, + capturedAt: string, + lat?: number, + lng?: number, + ) => { setIsSubmitting(true); setProgress('uploading'); setError(null); try { setProgress('verifying'); - const result = await submitProofAttempt(taskId, photoUri, { lat, lng }); + const result = await submitProofAttempt(taskId, photoUri, capturedAt, { + lat, + lng, + }); removeProofsForTask(taskId); setPendingCount(loadQueue().length); setProgress('confirmed'); return result; } catch (err) { + const uploadError = err instanceof ProofUploadError ? err : undefined; enqueueProof({ id: `${Date.now()}`, taskId, @@ -112,11 +148,14 @@ export function useProofSubmit() { lat, lng, createdAt: new Date().toISOString(), - photoCid: (err as any)?.photoCid, - metadataCid: (err as any)?.metadataCid, + capturedAt, + photoCid: uploadError?.photoCid, + metadataCid: uploadError?.metadataCid, }); setPendingCount(loadQueue().length); - setError((err as any).message || 'Upload failed, saved for later'); + setError( + err instanceof Error ? err.message : 'Upload failed, saved for later', + ); setProgress('failed'); return undefined; } finally { @@ -143,19 +182,24 @@ export function useProofSubmit() { const remaining: PendingProof[] = []; for (const proof of pending) { try { - await submitProofAttempt(proof.taskId, proof.photoPath, { - lat: (proof as any).lat, - lng: (proof as any).lng, - photoCid: (proof as any).photoCid, - metadataCid: (proof as any).metadataCid, - }); + await submitProofAttempt( + proof.taskId, + proof.photoPath, + proof.capturedAt, + { + lat: proof.lat, + lng: proof.lng, + photoCid: proof.photoCid, + metadataCid: proof.metadataCid, + }, + ); } catch (err) { + const uploadError = err instanceof ProofUploadError ? err : undefined; remaining.push({ ...proof, - photoCid: (err as any)?.photoCid || (proof as any).photoCid, - metadataCid: - (err as any)?.metadataCid || (proof as any).metadataCid, - } as PendingProof); + photoCid: uploadError?.photoCid || proof.photoCid, + metadataCid: uploadError?.metadataCid || proof.metadataCid, + }); } } saveQueue(remaining); diff --git a/src/hooks/useStellarWallet.ts b/src/hooks/useStellarWallet.ts index 033948c..3aeb673 100644 --- a/src/hooks/useStellarWallet.ts +++ b/src/hooks/useStellarWallet.ts @@ -13,6 +13,10 @@ interface FreighterWindow { }; } +// React Native has no DOM `window`; Freighter (browser extension) only +// exists when this code happens to run in a web context. +declare const window: FreighterWindow; + export function useStellarWallet() { const { connect, @@ -71,8 +75,8 @@ export function useStellarWallet() { } const key = await freighter.getPublicKey(); await connectAccount(key); - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not connect'); } finally { setIsConnecting(false); } @@ -90,8 +94,8 @@ export function useStellarWallet() { await stellar.createTestnetAccount(); await connectAccount(key, secretKey); return { publicKey: key, secretKey }; - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not create wallet'); } finally { setIsConnecting(false); } @@ -109,8 +113,10 @@ export function useStellarWallet() { const key = stellar.getPublicKeyFromSecret(trimmed); await connectAccount(key, trimmed); return { publicKey: key }; - } catch (err: any) { - setError(err.message || 'Could not import wallet'); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Could not import wallet', + ); return undefined; } finally { setIsConnecting(false); @@ -135,8 +141,8 @@ export function useStellarWallet() { useEffect(() => { if (isConnected && publicKey) { - refreshBalance(); - refreshEcoBalance(); + void refreshBalance(); + void refreshEcoBalance(); } }, [isConnected, publicKey, refreshBalance, refreshEcoBalance]); diff --git a/src/hooks/useTaskFeed.ts b/src/hooks/useTaskFeed.ts index 52a1f01..8c309a0 100644 --- a/src/hooks/useTaskFeed.ts +++ b/src/hooks/useTaskFeed.ts @@ -42,24 +42,30 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { ); const loadTasks = useCallback( - async (pageNum: number = 1) => { + async (pageNum = 1) => { setLoading(true); setError(null); try { - const params: Record = { page: pageNum, limit: 20 }; + const params: Record = { + page: pageNum, + limit: 20, + }; if (serverParams.type) { params.type = serverParams.type; } const loc = locationRef.current; - const withLocation = loc.lat !== undefined && loc.lng !== undefined; - if (withLocation) { - params.lat = loc.lat; - params.lng = loc.lng; + const location = + loc.lat !== undefined && loc.lng !== undefined + ? { lat: loc.lat, lng: loc.lng } + : null; + if (location) { + params.lat = location.lat; + params.lng = location.lng; if (serverParams.radius !== undefined) { params.radius = serverParams.radius; } - lastFetchLocationRef.current = { lat: loc.lat, lng: loc.lng }; + lastFetchLocationRef.current = location; } else { lastFetchLocationRef.current = null; } @@ -67,7 +73,9 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { const result = await fetchTasks(params); const normalize = (list: Task[]) => - withLocation ? enrichTasksWithDistance(list, loc.lat, loc.lng) : list; + location + ? enrichTasksWithDistance(list, location.lat, location.lng) + : list; if (pageNum === 1) { setTasks(normalize(result.tasks)); @@ -76,8 +84,8 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { } setPage(pageNum); setHasMore(pageNum < result.totalPages); - } catch (err: any) { - setError(err.message || 'Failed to load tasks'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load tasks'); } finally { setLoading(false); } @@ -96,12 +104,12 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { const refresh = useCallback(() => loadTasks(1), [loadTasks]); const loadMore = useCallback(() => { if (!isLoading && hasMore) { - loadTasks(page + 1); + void loadTasks(page + 1); } }, [isLoading, hasMore, page, loadTasks]); useEffect(() => { - loadTasks(1); + void loadTasks(1); }, [loadTasks]); const currentLocation = useMemo( @@ -122,7 +130,7 @@ export function useTaskFeed(options: UseTaskFeedOptions = {}) { ) { return; } - loadTasks(1); + void loadTasks(1); }, LOCATION_DEBOUNCE_MS); return () => clearTimeout(timer); }, [currentLocation, loadTasks]); diff --git a/src/navigation/MainTabNavigator.tsx b/src/navigation/MainTabNavigator.tsx index 4ed9ec3..d20b73d 100644 --- a/src/navigation/MainTabNavigator.tsx +++ b/src/navigation/MainTabNavigator.tsx @@ -6,6 +6,9 @@ import WalletScreen from '../screens/WalletScreen'; import SubmitPlaceholderScreen from '../screens/SubmitPlaceholderScreen'; import TabBarIcon from '../components/TabBarIcon'; +// react-navigation's typed hooks require ParamList to satisfy an implicit +// index signature, which only object-literal `type` aliases provide (not +// `interface`) — https://reactnavigation.org/docs/typescript export type MainTabParamList = { Home: undefined; Tasks: undefined; diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 8d144ee..28cc7b2 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -12,6 +12,9 @@ import SubmitProofScreen from '../screens/SubmitProofScreen'; import SendTokensScreen from '../screens/SendTokensScreen'; import { SubmitProofParams } from '../types'; +// react-navigation's typed hooks require ParamList to satisfy an implicit +// index signature, which only object-literal `type` aliases provide (not +// `interface`) — https://reactnavigation.org/docs/typescript export type RootStackParamList = { Onboarding: undefined; Main: undefined; diff --git a/src/navigation/TaskStackNavigator.tsx b/src/navigation/TaskStackNavigator.tsx index a74d5d6..5f26089 100644 --- a/src/navigation/TaskStackNavigator.tsx +++ b/src/navigation/TaskStackNavigator.tsx @@ -5,6 +5,9 @@ import TaskDetailScreen from '../screens/TaskDetailScreen'; import SubmitProofScreen from '../screens/SubmitProofScreen'; import { SubmitProofParams } from '../types'; +// react-navigation's typed hooks require ParamList to satisfy an implicit +// index signature, which only object-literal `type` aliases provide (not +// `interface`) — https://reactnavigation.org/docs/typescript export type TaskStackParamList = { TaskList: undefined; TaskDetail: { taskId: string }; diff --git a/src/screens/EditProfileScreen.tsx b/src/screens/EditProfileScreen.tsx index cd0a431..8886957 100644 --- a/src/screens/EditProfileScreen.tsx +++ b/src/screens/EditProfileScreen.tsx @@ -44,8 +44,11 @@ export default function EditProfileScreen() { } navigation.goBack(); - } catch (err: any) { - Alert.alert('Save Failed', err.message || 'Could not update profile'); + } catch (err) { + Alert.alert( + 'Save Failed', + err instanceof Error ? err.message : 'Could not update profile', + ); } finally { setIsSaving(false); } @@ -142,7 +145,7 @@ export default function EditProfileScreen() { /> void handleSave()} disabled={isSaving} style={{ padding: spacing.md, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 7100cde..f6ed8b2 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,6 +1,11 @@ import React, { useEffect } from 'react'; import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { + useNavigation, + CompositeNavigationProp, +} from '@react-navigation/native'; +import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { colors, spacing } from '../utils/theme'; import ImpactStats from '../components/ImpactStats'; import StreakCard from '../components/StreakCard'; @@ -12,6 +17,13 @@ import { useWalletStore } from '../store/walletStore'; import { useProofSubmit } from '../hooks/useProofSubmit'; import { TASK_TYPE_CONFIG, TaskType } from '../types'; import { truncatePublicKey } from '../utils/validation'; +import { MainTabParamList } from '../navigation/MainTabNavigator'; +import { RootStackParamList } from '../navigation/RootNavigator'; + +type HomeScreenNavigationProp = CompositeNavigationProp< + BottomTabNavigationProp, + NativeStackNavigationProp +>; function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); @@ -42,7 +54,7 @@ function getGreeting(): string { } export default function HomeScreen() { - const navigation = useNavigation(); + const navigation = useNavigation(); const { profile } = useUserStore(); const activities = useActivityStore(s => s.activities); const streak = useActivityStore(s => s.streak); @@ -163,7 +175,7 @@ export default function HomeScreen() { void syncPendingProofs()} /> diff --git a/src/screens/NotificationPreferencesScreen.tsx b/src/screens/NotificationPreferencesScreen.tsx index c5d5e8b..0ea3d59 100644 --- a/src/screens/NotificationPreferencesScreen.tsx +++ b/src/screens/NotificationPreferencesScreen.tsx @@ -1,12 +1,17 @@ import React from 'react'; import { View, Text, Switch, TouchableOpacity, ScrollView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { colors, spacing } from '../utils/theme'; import { NOTIFICATION_TYPES } from '../services/notifications'; import usePrefsStore from '../store/prefsStore'; +import { RootStackParamList } from '../navigation/RootNavigator'; export default function NotificationPreferencesScreen() { - const navigation = useNavigation(); + const navigation = + useNavigation< + NativeStackNavigationProp + >(); const { notificationPrefs, allEnabled, diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index a5178aa..5258792 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -29,7 +29,7 @@ export default function OnboardingScreen() { useEffect(() => { if (isConnected && publicKey) { - authenticate(publicKey).catch(() => {}); + authenticate(publicKey).catch(() => undefined); } }, [isConnected, publicKey, authenticate]); @@ -103,7 +103,7 @@ export default function OnboardingScreen() { )} void connectFreighter()} disabled={busy} style={{ padding: spacing.md, @@ -124,7 +124,7 @@ export default function OnboardingScreen() { void handleCreateWallet()} disabled={busy} style={{ padding: spacing.md, @@ -180,7 +180,7 @@ export default function OnboardingScreen() { }} /> void handleImport()} disabled={busy || !secretKey.trim()} style={{ padding: spacing.md, diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index a7495f7..e743054 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, Text, ScrollView, TouchableOpacity } from 'react-native'; import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useUserStore } from '../store/userStore'; import { useWalletStore } from '../store/walletStore'; import { useStellarWallet } from '../hooks/useStellarWallet'; @@ -9,9 +10,11 @@ import ImpactStats from '../components/ImpactStats'; import AchievementGrid from '../components/AchievementGrid'; import EmptyState from '../components/EmptyState'; import { truncatePublicKey } from '../utils/validation'; +import { RootStackParamList } from '../navigation/RootNavigator'; export default function ProfileScreen() { - const navigation = useNavigation(); + const navigation = + useNavigation>(); const { profile, logout } = useUserStore(); const { isConnected, publicKey } = useWalletStore(); const { disconnectWallet } = useStellarWallet(); @@ -147,8 +150,8 @@ export default function ProfileScreen() { label="Notification Preferences" onPress={() => navigation.navigate('NotificationPreferences')} /> - {}} /> - {}} /> + + { @@ -179,7 +182,7 @@ function SettingsRow({ }: { label: string; value?: string; - onPress: () => void; + onPress?: () => void; }) { return ( void handleSend()} disabled={isSending} style={{ padding: spacing.md, diff --git a/src/screens/SubmitProofScreen.tsx b/src/screens/SubmitProofScreen.tsx index 4980429..9860a44 100644 --- a/src/screens/SubmitProofScreen.tsx +++ b/src/screens/SubmitProofScreen.tsx @@ -37,7 +37,7 @@ export default function SubmitProofScreen() { useEffect(() => { if (!hasPermission) { - requestPermission(); + void requestPermission(); } }, [hasPermission, requestPermission]); @@ -51,8 +51,11 @@ export default function SubmitProofScreen() { }); setPhotoUri(`file://${photo.path}`); setCapturedAt(new Date().toISOString()); - } catch (err: any) { - Alert.alert('Camera Error', err.message || 'Failed to capture photo'); + } catch (err) { + Alert.alert( + 'Camera Error', + err instanceof Error ? err.message : 'Failed to capture photo', + ); } }, []); @@ -180,7 +183,7 @@ export default function SubmitProofScreen() { {hasPermission === false && ( void requestPermission()} style={{ marginTop: spacing.md, padding: spacing.sm }} > Grant Permission @@ -248,7 +251,7 @@ export default function SubmitProofScreen() { > {!photoUri ? ( void handleCapture()} disabled={isSubmitting} style={{ flex: 1, @@ -283,7 +286,7 @@ export default function SubmitProofScreen() { Retake void handleSubmit()} disabled={isSubmitting} style={{ flex: 1, diff --git a/src/screens/TaskDetailScreen.tsx b/src/screens/TaskDetailScreen.tsx index f33ae5b..cf1d8a5 100644 --- a/src/screens/TaskDetailScreen.tsx +++ b/src/screens/TaskDetailScreen.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { colors, spacing } from '../utils/theme'; import { fetchTaskById } from '../services/api'; import { TaskDetailSkeleton } from '../components/LoadingSkeleton'; @@ -9,7 +10,9 @@ import { TaskType, DIFFICULTY_CONFIG, TaskDifficulty, + Task, } from '../types'; +import { TaskStackParamList } from '../navigation/TaskStackNavigator'; type TaskDetailRoute = RouteProp< { TaskDetail: { taskId: string } }, @@ -18,15 +21,18 @@ type TaskDetailRoute = RouteProp< export default function TaskDetailScreen() { const route = useRoute(); - const navigation = useNavigation(); + const navigation = + useNavigation< + NativeStackNavigationProp + >(); const { taskId } = route.params; - const [task, setTask] = useState(null); + const [task, setTask] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - loadTask(); + void loadTask(); }, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps async function loadTask() { @@ -34,8 +40,8 @@ export default function TaskDetailScreen() { try { const data = await fetchTaskById(taskId); setTask(data); - } catch (err: any) { - setError(err.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load task'); } finally { setLoading(false); } @@ -60,7 +66,10 @@ export default function TaskDetailScreen() { }} > {error || 'Task not found'} - + void loadTask()} + style={{ marginTop: spacing.md }} + > Try Again @@ -103,30 +112,28 @@ export default function TaskDetailScreen() { {task.rewardAmount} {task.rewardToken || 'ECO'} reward - {task.difficulty && - DIFFICULTY_CONFIG[task.difficulty as TaskDifficulty] && ( - + + {DIFFICULTY_CONFIG[task.difficulty as TaskDifficulty].icon} + + - - {DIFFICULTY_CONFIG[task.difficulty as TaskDifficulty].icon} - - - {DIFFICULTY_CONFIG[task.difficulty as TaskDifficulty].label} - - - )} + {DIFFICULTY_CONFIG[task.difficulty as TaskDifficulty].label} + + + )} {task.estimatedMinutes && ( ~{task.estimatedMinutes}min diff --git a/src/screens/TaskListScreen.tsx b/src/screens/TaskListScreen.tsx index c0684b2..9b03241 100644 --- a/src/screens/TaskListScreen.tsx +++ b/src/screens/TaskListScreen.tsx @@ -9,6 +9,7 @@ import { TextInput, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { colors, spacing } from '../utils/theme'; import { useTaskFeed } from '../hooks/useTaskFeed'; import { useLocation } from '../hooks/useLocation'; @@ -21,6 +22,7 @@ import { filterTasksByQuery, sortTasks, } from '../utils/sortTasks'; +import { TaskStackParamList } from '../navigation/TaskStackNavigator'; const TASK_TYPES = [ { key: '', label: 'All', icon: '🌍' }, @@ -40,7 +42,8 @@ const SORT_OPTIONS: { key: TaskSortMode; label: string }[] = [ const RADIUS_OPTIONS = [10, 25, 50, 100]; export default function TaskListScreen() { - const navigation = useNavigation(); + const navigation = + useNavigation>(); const [activeType, setActiveType] = useState(''); const [query, setQuery] = useState(''); const [sortMode, setSortMode] = useState('distance'); @@ -103,7 +106,10 @@ export default function TaskListScreen() { }} > {error} - + void refresh()} + style={{ marginTop: spacing.md }} + > Retry @@ -277,7 +283,7 @@ export default function TaskListScreen() { onEndReached={loadMore} onEndReachedThreshold={0.5} refreshing={isLoading && tasks.length > 0} - onRefresh={refresh} + onRefresh={() => void refresh()} ListEmptyComponent={ !isLoading ? ( , + NativeStackNavigationProp +>; export default function WalletScreen() { - const navigation = useNavigation(); + const navigation = useNavigation(); const { balance, ecoBalance, publicKey, isConnected } = useWalletStore(); const { disconnectWallet, refreshBalance } = useStellarWallet(); const [loading, setLoading] = useState(true); useEffect(() => { - refreshBalance().finally(() => setLoading(false)); + void refreshBalance().finally(() => setLoading(false)); }, [refreshBalance]); if (!isConnected) { diff --git a/src/services/__tests__/api.integration.test.ts b/src/services/__tests__/api.integration.test.ts index ee019d3..29c1f0c 100644 --- a/src/services/__tests__/api.integration.test.ts +++ b/src/services/__tests__/api.integration.test.ts @@ -5,31 +5,44 @@ import { useUserStore } from '../../store/userStore'; describe('api integration flow (mocked)', () => { beforeEach(() => { jest.restoreAllMocks(); - useUserStore.setState({ token: null, profile: null } as any); + useUserStore.setState({ token: null, profile: null }); }); test('auth challenge -> login -> fetchTasks -> submitProof', async () => { const wallet = 'GABC'; const spyGetAuth = jest.spyOn(api, 'getAuthChallenge'); - spyGetAuth.mockResolvedValue({ challenge: 'nonce-123' } as any); + spyGetAuth.mockResolvedValue({ challenge: 'nonce-123' }); const spyLogin = jest.spyOn(api, 'loginWithWallet'); - spyLogin.mockResolvedValue({ token: 'tok', user: { id: 'u1' } } as any); + spyLogin.mockResolvedValue({ token: 'tok', user: { id: 'u1' } }); const spyFetch = jest.spyOn(api, 'fetchTasks'); - spyFetch.mockResolvedValue([{ id: 't1' }] as any); + spyFetch.mockResolvedValue([{ id: 't1' }]); const spySubmit = jest.spyOn(api, 'submitProof'); - spySubmit.mockResolvedValue({ id: 'proof1', status: 'confirmed' } as any); + spySubmit.mockResolvedValue({ + id: 'proof1', + status: 'confirmed', + } as unknown as Awaited>); const c = await api.getAuthChallenge(wallet); expect(c.challenge).toBe('nonce-123'); const login = await api.loginWithWallet(wallet, 'sig', c.challenge); expect(login.token).toBe('tok'); - useUserStore.setState({ token: login.token, profile: login.user } as any); + useUserStore.setState({ + token: login.token, + profile: { + id: login.user.id, + wallet, + stats: { treesPlanted: 0, plasticCollected: 0, co2Reduced: 0 }, + }, + }); const tasks = await api.fetchTasks(); expect(Array.isArray(tasks)).toBe(true); - const proofRes = await api.submitProof(new FormData() as any); + const proofRes = (await api.submitProof(new FormData())) as unknown as { + id: string; + status: string; + }; expect(proofRes.id).toBe('proof1'); }); }); diff --git a/src/services/api.ts b/src/services/api.ts index 92a0909..ac26111 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,4 +1,5 @@ -import axios from 'axios'; +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { Task, SubmitProofResult } from '../types'; import { Alert } from 'react-native'; import Config from 'react-native-config'; import { useUserStore } from '../store/userStore'; @@ -114,13 +115,13 @@ api.interceptors.request.use(async config => { api.interceptors.response.use( response => response, - async error => { - const original = error.config; + async (error: AxiosError) => { + const original = error.config as InternalAxiosRequestConfig | undefined; if ( error.response?.status === 401 && original && - !original._retry && - !original.skipAuthRefresh + original._retry !== true && + original.skipAuthRefresh !== true ) { original._retry = true; try { @@ -171,26 +172,28 @@ export async function updateProfile(data: { name?: string; bio?: string; avatarUrl?: string; -}) { +}): Promise<{ name?: string; bio?: string; avatarUrl?: string }> { const res = await api.put('/auth/me', data); return res.data; } -export async function fetchTasks(params?: Record) { +export async function fetchTasks(params?: Record) { const res = await api.get('/tasks', { params }); return res.data; } -export async function fetchTaskById(id: string) { +export async function fetchTaskById(id: string): Promise { const res = await api.get(`/tasks/${id}`); - return res.data; + return res.data as Task; } -export async function submitProof(formData: FormData) { +export async function submitProof( + formData: FormData, +): Promise { const res = await api.post('/proofs', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); - return res.data; + return res.data as SubmitProofResult; } export default api; diff --git a/src/services/ipfs.ts b/src/services/ipfs.ts index 94cb400..3a6afbf 100644 --- a/src/services/ipfs.ts +++ b/src/services/ipfs.ts @@ -38,12 +38,18 @@ export async function pinFile( throw new Error('IPFS API key not configured. Set IPFS_API_KEY in .env'); } + interface ReactNativeFilePart { + uri: string; + type: string; + name: string; + } + const formData = new FormData(); formData.append('file', { uri: filePath.startsWith('file://') ? filePath : `file://${filePath}`, type: 'image/jpeg', name: fileName, - } as any); + } as ReactNativeFilePart as unknown as Blob); formData.append( 'pinataMetadata', diff --git a/src/services/notifications.ts b/src/services/notifications.ts index a79b2f8..1e7a85e 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -73,42 +73,29 @@ export async function scheduleLocalNotification( const type = payload.type || payload.data?.type || payload.data?.notificationType; - if ( - type && - prefs.notificationPrefs && - prefs.notificationPrefs[type] === false - ) { + if (type && prefs.notificationPrefs[type] === false) { return; } - const { from, to } = prefs.quietHours || { from: '00:00', to: '00:00' }; + const { from, to } = prefs.quietHours; if (isNowInQuietHours(from, to)) { return; } // Best-effort: if notifee is available and payload requests scheduling, use it try { - const notifee = await import('@notifee/react-native'); + const { default: notifee } = await import('@notifee/react-native'); // If payload contains a timestamp/data.trigger we could schedule; for now display immediately - const notification = await notifee.displayNotification({ + const notificationId = await notifee.displayNotification({ title: payload.title, body: payload.body, - data: payload.data as any, - } as any); - // notifee.displayNotification may return an id in some setups; store it if present - if (notification && (notification as any).id) { - usePrefsStore - .getState() - .addScheduledId(type || 'unknown', (notification as any).id); - } - if (onNotificationCallback) { - onNotificationCallback(payload); - } + data: payload.data, + }); + usePrefsStore.getState().addScheduledId(type || 'unknown', notificationId); + onNotificationCallback?.(payload); return; } catch { // fallback to local callback - if (onNotificationCallback) { - onNotificationCallback(payload); - } + onNotificationCallback?.(payload); } } diff --git a/src/services/stellar.ts b/src/services/stellar.ts index 322d229..2d0258d 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -63,7 +63,7 @@ export async function getTokenBalance( export async function getPayments( publicKey: string, - limit: number = 10, + limit = 10, ): Promise { try { const page = await server diff --git a/src/store/prefsStore.ts b/src/store/prefsStore.ts index 3451e77..007a5c4 100644 --- a/src/store/prefsStore.ts +++ b/src/store/prefsStore.ts @@ -10,7 +10,10 @@ const zustandMMKVStorage = { removeItem: (name: string) => storage.delete(name), }; -type QuietHours = { from: string; to: string }; +interface QuietHours { + from: string; + to: string; +} // Create default notification preferences with all types enabled function makeDefaultNotificationPrefs() { @@ -35,7 +38,7 @@ interface PrefsState { allEnabled: boolean; notificationPrefs: Record; quietHours: QuietHours; - scheduledNotificationIds: Record; // type -> ids + scheduledNotificationIds: Record; // type -> ids toggleType: (type: string, enabled: boolean) => void; setAllEnabled: (enabled: boolean) => void; setQuietHours: (from: string, to: string) => void; @@ -59,12 +62,13 @@ export const usePrefsStore = create()( // if disabling, cancel any scheduled notifications asynchronously if (!enabled) { - (async () => { + void (async () => { const ids = usePrefsStore.getState().scheduledNotificationIds[type] || []; if (ids.length > 0) { try { - const notifee = await import('@notifee/react-native'); + const { default: notifee } = + await import('@notifee/react-native'); await Promise.all( ids.map(id => notifee.cancelNotification(id).catch(() => null), @@ -74,15 +78,12 @@ export const usePrefsStore = create()( // notifee not available or cancel failed; best-effort } } - usePrefsStore.setState( - s => - ({ - scheduledNotificationIds: { - ...s.scheduledNotificationIds, - [type]: [], - }, - }) as any, - ); + usePrefsStore.setState(s => ({ + scheduledNotificationIds: { + ...s.scheduledNotificationIds, + [type]: [], + }, + })); })(); } }, @@ -108,15 +109,12 @@ export const usePrefsStore = create()( { name: 'prefs-storage', storage: createJSONStorage(() => zustandMMKVStorage), - onRehydrateStorage: () => state => { - return persisted => { - if (persisted && persisted.notificationPrefs) { - const merged = mergeNotificationDefaults( - persisted.notificationPrefs as Record, - ); - state?.setState({ notificationPrefs: merged }); - } - }; + onRehydrateStorage: () => (persisted, error) => { + if (error != null || !persisted) { + return; + } + const merged = mergeNotificationDefaults(persisted.notificationPrefs); + usePrefsStore.setState({ notificationPrefs: merged }); }, }, ), diff --git a/src/types/index.ts b/src/types/index.ts index 206a593..0f45b03 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -79,6 +79,13 @@ export interface SubmitProofParams { rewardToken?: string; } +export interface SubmitProofResult { + taskTitle?: string; + taskType?: TaskType; + rewardAmount?: number; + rewardToken?: string; +} + export const TASK_TYPE_CONFIG: Record< TaskType, { icon: string; label: string } diff --git a/src/utils/achievements.ts b/src/utils/achievements.ts index c00ae20..06b86a3 100644 --- a/src/utils/achievements.ts +++ b/src/utils/achievements.ts @@ -132,9 +132,8 @@ export function getEarnedCount(stats: UserStats): number { } export function getNextAchievement(stats: UserStats): Achievement | null { - const unlocked = getAchievements(stats); - return ( - unlocked.filter(a => !a.earned).sort((a, b) => a.target - b.target)[0] || - null - ); + const remaining = getAchievements(stats) + .filter(a => !a.earned) + .sort((a, b) => a.target - b.target); + return remaining.length > 0 ? remaining[0] : null; } diff --git a/src/utils/geoUtils.ts b/src/utils/geoUtils.ts index a890386..b96eb13 100644 --- a/src/utils/geoUtils.ts +++ b/src/utils/geoUtils.ts @@ -37,12 +37,12 @@ export function isWithinRadius( } export function enrichTasksWithDistance( - tasks: Array<{ + tasks: { lat?: number; lng?: number; distance?: number; [key: string]: unknown; - }>, + }[], userLat: number, userLng: number, ) {