diff --git a/.github/workflows/branch-check.yml b/.github/workflows/branch-check.yml index dbd39da..bd4ec90 100644 --- a/.github/workflows/branch-check.yml +++ b/.github/workflows/branch-check.yml @@ -33,7 +33,7 @@ jobs: - name: ๐Ÿ— Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: ๐Ÿ“ฆ Install Dependencies @@ -42,55 +42,20 @@ jobs: - name: ๐Ÿงน Dedupe run: pnpm dedupe --check - # Typecheck/lint/format used to run serially in one job (~sum of all - # three). Splitting into a matrix trades that for parallel wall-clock = - # setup + slowest task: each job pays its own ~40s setup (checkout, pnpm, - # node, turbo cache restore), but with turbo cache hits the tasks - # themselves are fast, so 3 parallel setups + max(task) beats 1 setup + - # sum(tasks) once the tasks aren't trivially fast. + # Lint, format and typecheck come from GSTJ/magic โ€” the same job every repo + # on the shared tooling runs, so a change to the stack is made in one place. + # It replaces a three-way matrix that paid its own ~40s setup per task; the + # reusable job installs once and runs all three, which is the better trade + # now that oxlint and oxfmt finish in under a second. checkup: - name: ${{ matrix.emoji }} ${{ matrix.label }} + name: ๐Ÿ‘ฎ Checkup needs: dedupe - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - task: typecheck - label: Typecheck - emoji: ๐Ÿงช - - task: lint - label: Lint - emoji: ๐Ÿงน - - task: format - label: Format - emoji: ๐Ÿ’… - steps: - - name: ๐Ÿ— Setup Repo - uses: actions/checkout@v7 - - - name: ๐Ÿ— Setup PNPM - uses: pnpm/action-setup@v6.0.9 - - - name: ๐Ÿ— Setup Node - uses: actions/setup-node@v7 - with: - node-version: 20.x - cache: pnpm - - - name: ๐Ÿ“ฆ Install Dependencies - run: pnpm install --frozen-lockfile - - - name: ๐Ÿ— Setup Turborepo Cache - uses: rharkor/caching-for-turbo@v2.5.0 - - - name: ${{ matrix.emoji }} ${{ matrix.label }} - run: pnpm run ${{ matrix.task }} + uses: GSTJ/magic/.github/workflows/ci.yml@main - # Kept out of the checkup matrix: only this job needs a database, and - # sharing the matrix would spin up Postgres for typecheck/lint/format too. - # No turbo cache step either โ€” `test` is `cache: false` in turbo.json, so - # there would be nothing to restore. + # Kept out of the shared checkup job: only this one needs a database, and + # folding it in would spin up Postgres for typecheck/lint/format too. No + # turbo cache step either โ€” `test` is `cache: false` in turbo.json, so there + # would be nothing to restore. test: name: ๐Ÿ”ฌ Test needs: dedupe @@ -124,7 +89,7 @@ jobs: - name: ๐Ÿ— Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: ๐Ÿ“ฆ Install Dependencies diff --git a/.github/workflows/deploy-mobile.yml b/.github/workflows/deploy-mobile.yml index ccafc9f..a35e865 100644 --- a/.github/workflows/deploy-mobile.yml +++ b/.github/workflows/deploy-mobile.yml @@ -42,7 +42,7 @@ jobs: - name: ๐Ÿ— Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x - name: "๐Ÿ“ฆ Cache Node Modules" uses: actions/cache@v6 diff --git a/.github/workflows/e2e-mobile.yml b/.github/workflows/e2e-mobile.yml index 3357fe8..3671d07 100644 --- a/.github/workflows/e2e-mobile.yml +++ b/.github/workflows/e2e-mobile.yml @@ -369,7 +369,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: Install dependencies @@ -1053,7 +1053,7 @@ jobs: if: steps.preflight.outputs.skip != 'true' uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: Install dependencies @@ -1395,7 +1395,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: Install dependencies diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml index f0e33dd..71b94a2 100644 --- a/.github/workflows/release-mobile.yml +++ b/.github/workflows/release-mobile.yml @@ -297,7 +297,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: Install dependencies @@ -404,7 +404,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: Install dependencies @@ -552,7 +552,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: Install dependencies @@ -596,7 +596,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x cache: pnpm - name: Install dependencies diff --git a/.npmrc b/.npmrc deleted file mode 100644 index f8915f6..0000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -node-linker=hoisted -dedupe-direct-deps=true \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 9bdb657..836f191 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.16 \ No newline at end of file +22.23 diff --git a/.oxfmtrc.json b/.oxfmtrc.json deleted file mode 100644 index b424fd4..0000000 --- a/.oxfmtrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ignorePatterns": [".github/**"] -} diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index d79cbf4..0000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", - "plugins": [ - "typescript", - "react", - "react-perf", - "unicorn", - "oxc", - "import", - "promise", - "node", - "jsx-a11y" - ], - "categories": { - "correctness": "error", - "suspicious": "warn", - "perf": "warn" - }, - "env": { - "browser": true, - "node": true, - "es2024": true, - "jest": true - }, - "ignorePatterns": [ - "**/dist/**", - "**/build/**", - "**/coverage/**", - "**/.turbo/**", - "**/.next/**", - "**/node_modules/**", - "**/ios/**", - "**/android/**", - "**/.expo/**", - "**/storybook-static/**", - "patches/**", - "pnpm-lock.yaml", - "**/generated/**", - "packages/database/prisma/migrations/**", - "apps/mobile/google-services.json", - "apps/mobile/GoogleService-Info.plist" - ], - "rules": { - "no-console": "error", - "no-void": "off", - "import/no-duplicates": "error", - "react/jsx-key": "error", - "react/jsx-no-useless-fragment": ["error", { "allowExpressions": true }], - "react/jsx-boolean-value": ["error", "never"], - "react/self-closing-comp": "error", - "react/jsx-curly-brace-presence": ["error", { "props": "never", "children": "never" }], - "react/jsx-handler-names": "warn", - "react/no-children-prop": "warn", - "react/react-in-jsx-scope": "off", - "react-hooks/exhaustive-deps": "warn", - "jsx-a11y/no-autofocus": "off", - "typescript/no-unused-vars": [ - "error", - { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } - ], - "typescript/no-explicit-any": "warn", - "typescript/no-require-imports": "off", - "import/no-unassigned-import": "off", - "import/no-named-as-default-member": "off", - "unicorn/no-array-sort": "off", - "unicorn/no-array-reverse": "off", - "no-restricted-imports": [ - "error", - { - "paths": [ - { - "name": "react-native-gesture-handler", - "importNames": ["ScrollView", "FlatList", "SectionList"], - "message": "Import ScrollView/FlatList/SectionList from react-native instead." - } - ] - } - ] - }, - "overrides": [ - { - "files": [ - "**/*.test.ts", - "**/*.test.tsx", - "**/*.spec.ts", - "**/*.spec.tsx", - "**/jest.config.*", - "**/jest.setup.*", - "packages/database/seed.ts", - "packages/database/maestro-seed.ts", - "scripts/**" - ], - "rules": { - "no-console": "off" - } - }, - { - "files": ["apps/mobile/**"], - "rules": { - "react-perf/jsx-no-new-object-as-prop": "off", - "react-perf/jsx-no-new-array-as-prop": "off", - "react-perf/jsx-no-new-function-as-prop": "off", - "react-perf/jsx-no-jsx-as-prop": "off" - } - }, - { - "files": ["packages/api/src/services/**"], - "rules": { - "typescript/no-extraneous-class": "off" - } - } - ] -} diff --git a/apps/mobile/.maestro/20-account-creation-journey.yaml b/apps/mobile/.maestro/20-account-creation-journey.yaml index b26ee2b..43a1b55 100644 --- a/apps/mobile/.maestro/20-account-creation-journey.yaml +++ b/apps/mobile/.maestro/20-account-creation-journey.yaml @@ -17,7 +17,7 @@ tags: # # 2. Sign in via utils/login-fresh.yaml โ€” the API's APPLE_MAGIC_EMAIL_REGEX # bypass (^maestro-fresh.*@pegada\.app$ โ€” see -# packages/api/src/services/AuthenticationService.ts โ†’ isFreshMagicEmail) +# packages/api/src/services/authentication-service.ts โ†’ isFreshMagicEmail) # hard-purges the matching user + cascading Dog/Image/Match/Interest/ # Message rows BEFORE upserting, so the auth router always lands on # CreateProfile regardless of any prior run's state. Two back-to-back diff --git a/apps/mobile/.maestro/README.md b/apps/mobile/.maestro/README.md index f1ea5f5..97e7559 100644 --- a/apps/mobile/.maestro/README.md +++ b/apps/mobile/.maestro/README.md @@ -125,7 +125,7 @@ The iOS simulator can't talk to the real App Store. Without a StoreKit configuration in the active Xcode scheme, `Purchases.getOfferings()` resolves with `current: null` โ€” the upgrade wall then renders empty plan rows and a CTA stuck in loading. To avoid that, the repo ships `apps/mobile/Pegada.storekit` -(checked in โ€” product IDs are not secrets) and `plugins/withStoreKitConfiguration.js` +(checked in โ€” product IDs are not secrets) and `plugins/with-store-kit-configuration.js` wires it into the generated scheme on every `expo prebuild`. Belt-and-suspenders: the mobile payment service also has a JS-side fallback diff --git a/apps/mobile/.maestro/checks/23b-lang-theme.sh b/apps/mobile/.maestro/checks/23b-lang-theme.sh index 7b7aa76..bc1eecc 100755 --- a/apps/mobile/.maestro/checks/23b-lang-theme.sh +++ b/apps/mobile/.maestro/checks/23b-lang-theme.sh @@ -7,7 +7,7 @@ # language = pt-BR (StorageKeys.Language; read by i18n detector # in apps/mobile/src/i18n.ts) # theme = dark (StorageKeys.Theme; read by ThemeProvider in -# apps/mobile/src/contexts/ThemeProvider.tsx) +# apps/mobile/src/contexts/theme-provider.tsx) # # This proves end-to-end persistence: the seed values survive the cold # launch and the app's storage layer reads + applies them on mount. A diff --git a/apps/mobile/.maestro/utils/login-fresh.yaml b/apps/mobile/.maestro/utils/login-fresh.yaml index c7a72af..5150ce0 100644 --- a/apps/mobile/.maestro/utils/login-fresh.yaml +++ b/apps/mobile/.maestro/utils/login-fresh.yaml @@ -4,7 +4,7 @@ tags: - util --- # Reusable subflow: sign-in as a FRESH test user via the API's -# APPLE_MAGIC_EMAIL_REGEX bypass (see packages/api/src/services/AuthenticationService.ts). +# APPLE_MAGIC_EMAIL_REGEX bypass (see packages/api/src/services/authentication-service.ts). # # How it differs from login.yaml: # - login.yaml uses the single APPLE_MAGIC_EMAIL (test@pegada.app). That diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9dbafd7..2be6f93 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,10 +1,9 @@ -import { ExpoConfig } from "expo/config"; +import type { ExpoConfig } from "expo/config"; // The primary/fallback locale's native strings (permission descriptions, // etc.), also used verbatim by the `locales` map below. Reused here to // seed Android's base values/strings.xml via withDefaultLocaleStrings, // see that plugin's file for why this is needed. -// eslint-disable-next-line @typescript-eslint/no-var-requires const defaultLocaleNativeStrings = require("@pegada/shared/i18n/locales/en/native.json"); // The posthog-react-native/expo config plugin wires a sourcemap-upload step @@ -74,7 +73,8 @@ const config: ExpoConfig = { "react-native-maps", { iosGoogleMapsApiKey: process.env.EXPO_PUBLIC_IOS_GOOGLE_MAPS_API_KEY, - androidGoogleMapsApiKey: process.env.EXPO_PUBLIC_ANDROID_GOOGLE_MAPS_API_KEY, + androidGoogleMapsApiKey: + process.env.EXPO_PUBLIC_ANDROID_GOOGLE_MAPS_API_KEY, }, ], [ @@ -86,7 +86,8 @@ const config: ExpoConfig = { }, android: { // https://docs.page/invertase/react-native-google-mobile-ads/european-user-consent#handling-consent - extraProguardRules: "-keep class com.google.android.gms.internal.consent_sdk.** { *; }", + extraProguardRules: + "-keep class com.google.android.gms.internal.consent_sdk.** { *; }", }, }, ], @@ -178,21 +179,24 @@ const config: ExpoConfig = { [ "expo-location", { - locationWhenInUsePermission: "The app uses your location to find doggies near you.", + locationWhenInUsePermission: + "The app uses your location to find doggies near you.", }, ], [ "expo-image-picker", { - photosPermission: "The app allows you to choose photos for your doggie's profile.", - cameraPermission: "The app allows you to take photos for your doggie's profile.", + photosPermission: + "The app allows you to choose photos for your doggie's profile.", + cameraPermission: + "The app allows you to take photos for your doggie's profile.", }, ], // Wires the source-controlled `Pegada.storekit` fixture into the iOS // scheme so simulator runs (local + CI) can resolve real product pricing // without an App Store sandbox session. Plugin is a no-op when the file // is missing or when the platform isn't iOS. - "./plugins/withStoreKitConfiguration", + "./plugins/with-store-kit-configuration", // Seeds Android's base (unqualified) values/strings.xml with the // primary locale's native strings. Without this, Android Lint's // ExtraTranslation check treats every string in locales.en / @@ -200,17 +204,22 @@ const config: ExpoConfig = { // locale-tagged resource file, absent from the default one) and // FAILS gradlew bundleRelease -- this is what killed the 2026-07-05 // overnight EAS cloud build. See withDefaultLocaleStrings.js. - ["./plugins/withDefaultLocaleStrings", { stringsByKey: defaultLocaleNativeStrings }], + [ + "./plugins/with-default-locale-strings", + { stringsByKey: defaultLocaleNativeStrings }, + ], // Applies the user's in-app theme choice (mirrored to NSUserDefaults by // ThemeProvider) to the iOS window before the splash screen renders, so // a forced dark theme boots with a dark splash instead of blinking // white->dark on light-mode devices. See withInitialThemeOverride.js. - "./plugins/withInitialThemeOverride", + "./plugins/with-initial-theme-override", // Sourcemap upload for Release native builds only (see // posthogSourcemapsEnabled above) -- omitted entirely from the plugins // list otherwise, so a plain local build never has the upload step in // its generated Xcode/Gradle project. - ...(posthogSourcemapsEnabled ? (["posthog-react-native/expo"] as const) : []), + ...(posthogSourcemapsEnabled + ? (["posthog-react-native/expo"] as const) + : []), ], androidStatusBar: { barStyle: "dark-content", diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 9d2ae6d..88d7d2d 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -27,8 +27,11 @@ config.watcher = { }; // SVG Support -config.transformer.babelTransformerPath = require.resolve("react-native-svg-transformer"); -config.resolver.assetExts = config.resolver.assetExts.filter((ext) => ext !== "svg"); +config.transformer.babelTransformerPath = + require.resolve("react-native-svg-transformer"); +config.resolver.assetExts = config.resolver.assetExts.filter( + (ext) => ext !== "svg", +); config.resolver.sourceExts.push("svg"); // dotLottie Support diff --git a/apps/mobile/modules/pegada-widget/index.ts b/apps/mobile/modules/pegada-widget/index.ts index f61bdb6..f766e05 100644 --- a/apps/mobile/modules/pegada-widget/index.ts +++ b/apps/mobile/modules/pegada-widget/index.ts @@ -40,20 +40,24 @@ export type WidgetSnapshot = { }; type PegadaWidgetNativeModule = { - setSnapshot(json: string): Promise; + setSnapshot: (json: string) => Promise; }; // Optional so web (and any environment without the native module, e.g. // tests) degrades to a no-op instead of throwing at import time. -const nativeModule = requireOptionalNativeModule("PegadaWidget"); +const nativeModule = + requireOptionalNativeModule("PegadaWidget"); /** * Persists the snapshot where the home-screen widgets can read it * (App Group UserDefaults on iOS, SharedPreferences on Android) and asks * the OS to re-render the widget timelines. */ -export const setWidgetSnapshot = async (snapshot: WidgetSnapshot): Promise => { +export const setWidgetSnapshot = async ( + snapshot: WidgetSnapshot, +): Promise => { await nativeModule?.setSnapshot(JSON.stringify(snapshot)); }; -export const isWidgetModuleAvailable = () => nativeModule != null; +export const isWidgetModuleAvailable = () => + nativeModule !== undefined && nativeModule !== null; diff --git a/apps/mobile/oxlint.config.mts b/apps/mobile/oxlint.config.mts new file mode 100644 index 0000000..4edf8db --- /dev/null +++ b/apps/mobile/oxlint.config.mts @@ -0,0 +1,85 @@ +import { extendConfig } from "magic-oxlint-config"; +import expo from "magic-oxlint-config/expo"; + +/** + * A nested config replaces the root one for everything under `apps/mobile`, so + * this file has to be complete on its own. `extendConfig` flattens the preset + * and the additions below into one config, which is what makes it complete: + * oxlint's own `extends` still drops the extended config's `ignorePatterns` on + * 1.75.0 and needs them re-listed by hand. + */ +export default extendConfig(expo, { + ignorePatterns: ["google-services.json", "GoogleService-Info.plist"], + + rules: { + // See the root config: a nested config replaces the root one outright, so + // repo-wide decisions have to be restated here. + "no-warning-comments": "off", + + // The preset bans namespace imports and allows `react` / `@radix-ui/*`. + // Per-rule config replaces rather than merges, so `react` is restated. + // + // Two additions, both cases where the namespace *is* the documented API: + // every `expo-*` module exports only named functions and the SDK docs are + // written as `import * as Notifications from "expo-notifications"`, and a + // `styles.ts` next to a component is this repo's styled-components + // convention โ€” `S.Container` is how you tell a styled node from a real one. + "import/no-namespace": [ + "error", + { ignore: ["react", "expo-*", "**/styles"] }, + ], + + // The React Compiler rule is `nursery`, and DECISIONS.md in the magic repo + // says to switch it off locally rather than fight it. Everything it reports + // here is a bailout on a library this app is built on and none of it is a + // bug: reanimated shared-value writes (`Immutability: This value cannot be + // modified`), react-native-gesture-handler's `Gesture.Pan()` factories + // (`CapitalizedCalls`), and react-hook-form (`Compilation Skipped: Use of + // incompatible library`). The compiler itself already skips these + // components at build time; the lint rule only restates that as an error. + "react/react-compiler": "off", + + // react-native-gesture-handler ships its own scrollables for use *inside* + // gesture-handler containers. Importing them anywhere else silently loses + // the platform behaviour of the react-native originals. + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "react-native-gesture-handler", + importNames: ["ScrollView", "FlatList", "SectionList"], + message: + "Import ScrollView/FlatList/SectionList from react-native instead.", + }, + ], + }, + ], + }, + + overrides: [ + { + // This is the app's env boundary: the one module that reads + // `process.env.EXPO_PUBLIC_*` and hands back a zod-parsed object. The + // preset exempts `env.ts`; here the file is called `config.ts`. + files: ["src/services/config.ts"], + rules: { "no-restricted-properties": "off" }, + }, + { + // Expo config plugins and native target configs run in the build + // toolchain, not in the app: CommonJS, and they log. + files: ["plugins/**", "targets/**"], + rules: { + "no-console": "off", + "typescript/no-require-imports": "off", + "import/no-default-export": "off", + "func-style": "off", + }, + }, + // Must stay last โ€” see the root config for why. + { + files: ["**/__mocks__/**"], + rules: { "unicorn/filename-case": "off" }, + }, + ], +}); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c89e541..c1e8074 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -111,7 +111,6 @@ "@babel/core": "^7.29.0", "@bacons/apple-targets": "^4.0.7", "@pegada/api": "workspace:*", - "@pegada/tsconfig": "workspace:*", "@posthog/cli": "^0.8.1", "@types/color": "^3.0.6", "@types/jest": "^29.5.12", @@ -122,9 +121,12 @@ "@types/react-test-renderer": "^18.3.0", "babel-jest": "^29.7.0", "babel-plugin-styled-components": "^2.1.4", - "jest": "^29.7.0" + "jest": "^29.7.0", + "magic-oxlint-config": "^1.2.0", + "magic-tsconfig": "^1.2.0" }, "jest": { + "clearMocks": true, "moduleNameMapper": { "^@/(.*)$": "/src/$1" } diff --git a/apps/mobile/plugins/withDefaultLocaleStrings.js b/apps/mobile/plugins/with-default-locale-strings.js similarity index 94% rename from apps/mobile/plugins/withDefaultLocaleStrings.js rename to apps/mobile/plugins/with-default-locale-strings.js index a04efa1..02bcc7f 100644 --- a/apps/mobile/plugins/withDefaultLocaleStrings.js +++ b/apps/mobile/plugins/with-default-locale-strings.js @@ -36,7 +36,10 @@ const withDefaultLocaleStrings = (config, { stringsByKey }) => { return withStringsXml(config, (modConfig) => { let strings = modConfig.modResults; for (const [name, value] of Object.entries(stringsByKey)) { - strings = AndroidConfig.Strings.setStringItem([{ $: { name }, _: value }], strings); + strings = AndroidConfig.Strings.setStringItem( + [{ $: { name }, _: value }], + strings, + ); } modConfig.modResults = strings; return modConfig; diff --git a/apps/mobile/plugins/withInitialThemeOverride.js b/apps/mobile/plugins/with-initial-theme-override.js similarity index 89% rename from apps/mobile/plugins/withInitialThemeOverride.js rename to apps/mobile/plugins/with-initial-theme-override.js index 9980e07..054877d 100644 --- a/apps/mobile/plugins/withInitialThemeOverride.js +++ b/apps/mobile/plugins/with-initial-theme-override.js @@ -14,7 +14,7 @@ * * The JS side mirrors the choice into NSUserDefaults via React Native's * `Settings` API (key: `pegadaThemeOverride`, see - * src/contexts/ThemeProvider.tsx). This plugin injects a snippet into + * src/contexts/theme-provider.tsx). This plugin injects a snippet into * AppDelegate.swift, right after the UIWindow is created and before * startReactNative shows the splash, that reads the key and sets * `overrideUserInterfaceStyle` accordingly. The splash storyboard's @@ -30,7 +30,9 @@ * following the system appearance โ€” exactly today's behavior. */ const { withAppDelegate } = require("expo/config-plugins"); -const { mergeContents } = require("@expo/config-plugins/build/utils/generateCode"); +const { + mergeContents, +} = require("@expo/config-plugins/build/utils/generateCode"); const OVERRIDE_SNIPPET = ` if let themeOverride = UserDefaults.standard.string(forKey: "pegadaThemeOverride"), themeOverride == "dark" || themeOverride == "light" { @@ -41,8 +43,9 @@ const withInitialThemeOverride = (config) => { return withAppDelegate(config, (modConfig) => { if (modConfig.modResults.language !== "swift") { throw new Error( - "withInitialThemeOverride: expected a Swift AppDelegate, found " + - modConfig.modResults.language, + `withInitialThemeOverride: expected a Swift AppDelegate, found ${ + modConfig.modResults.language + }`, ); } diff --git a/apps/mobile/plugins/withStoreKitConfiguration.js b/apps/mobile/plugins/with-store-kit-configuration.js similarity index 96% rename from apps/mobile/plugins/withStoreKitConfiguration.js rename to apps/mobile/plugins/with-store-kit-configuration.js index 47ee236..8682c2e 100644 --- a/apps/mobile/plugins/withStoreKitConfiguration.js +++ b/apps/mobile/plugins/with-store-kit-configuration.js @@ -35,8 +35,8 @@ const fs = require("fs"); const STOREKIT_FILE_NAME = "Pegada.storekit"; const withStoreKitConfiguration = (config) => { - return withXcodeProject(config, async (modConfig) => { - const projectRoot = modConfig.modRequest.projectRoot; + return withXcodeProject(config, (modConfig) => { + const { projectRoot } = modConfig.modRequest; const platformRoot = modConfig.modRequest.platformProjectRoot; const sourcePath = path.join(projectRoot, STOREKIT_FILE_NAME); @@ -44,7 +44,6 @@ const withStoreKitConfiguration = (config) => { // No .storekit file in the mobile app root โ€” silently skip. This // keeps the plugin safe for environments that don't have the file // (e.g. fresh clones before the file has been pulled). - // eslint-disable-next-line no-console console.warn( `[withStoreKitConfiguration] ${STOREKIT_FILE_NAME} not found at ${sourcePath} โ€” skipping StoreKit wiring.`, ); @@ -108,7 +107,6 @@ const withStoreKitConfiguration = (config) => { fs.writeFileSync(schemePath, schemeContent, "utf8"); } } else { - // eslint-disable-next-line no-console console.warn( `[withStoreKitConfiguration] scheme file not found at ${schemePath} โ€” StoreKit file copied but not activated.`, ); diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index e6ab958..b355a8f 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -1,17 +1,32 @@ -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Tabs } from "expo-router"; + +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "styled-components/native"; -import Logo from "@/assets/images/Logo"; -import Messages from "@/assets/images/Messages"; -import Profile from "@/assets/images/Profile"; +import Logo from "@/assets/images/logo"; +import Messages from "@/assets/images/messages"; +import Profile from "@/assets/images/profile"; -interface TabBarIconProps { +type TabBarIconProps = { focused: boolean; color: string; -} +}; -export default () => { +// Hoisted out of the layout: an inline `tabBarIcon` is a fresh component type +// on every render, which remounts the icon whenever the tab bar updates. +const SwipeTabIcon = ({ color }: TabBarIconProps) => ( + +); + +const MessagesTabIcon = ({ color }: TabBarIconProps) => ( + +); + +const ProfileTabIcon = ({ color }: TabBarIconProps) => ( + +); + +const TabsLayout = () => { const theme = useTheme(); const insets = useSafeAreaInsets(); @@ -40,29 +55,25 @@ export default () => { name="swipe" options={{ tabBarButtonTestID: "tab-swipe", - tabBarIcon: ({ color }: TabBarIconProps) => ( - - ), + tabBarIcon: SwipeTabIcon, }} /> ( - - ), + tabBarIcon: MessagesTabIcon, }} /> ( - - ), + tabBarIcon: ProfileTabIcon, }} /> ); }; + +export default TabsLayout; diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 2a2972d..13cd8a7 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -1,12 +1,14 @@ import { Platform } from "react-native"; + import { Stack } from "expo-router"; + import Color from "color"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; -import { useMatchesWidgetSync } from "@/services/matchesWidget/useMatchesWidgetSync"; +import { useMatchesWidgetSync } from "@/services/matchesWidget/use-matches-widget-sync"; -export default () => { +const AppLayout = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -39,7 +41,7 @@ export default () => { // BlurEffect doesn't work on Android, so opacity is not necessary backgroundColor: Platform.OS === "ios" - ? Color(theme.colors.background).alpha(0.5).toString() + ? new Color(theme.colors.background).alpha(0.5).toString() : theme.colors.background, }, @@ -102,3 +104,5 @@ export default () => { ); }; + +export default AppLayout; diff --git a/apps/mobile/src/app/(app)/chat/[matchId].ts b/apps/mobile/src/app/(app)/chat/[matchId].ts index 8fc70c2..2477ca7 100644 --- a/apps/mobile/src/app/(app)/chat/[matchId].ts +++ b/apps/mobile/src/app/(app)/chat/[matchId].ts @@ -1,4 +1,4 @@ -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; export { default } from "@/views/Chat"; diff --git a/apps/mobile/src/app/(app)/profile/[id].ts b/apps/mobile/src/app/(app)/profile/[id].ts index 73d119f..78533ff 100644 --- a/apps/mobile/src/app/(app)/profile/[id].ts +++ b/apps/mobile/src/app/(app)/profile/[id].ts @@ -1,4 +1,4 @@ -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; export { default } from "@/views/DogProfile"; diff --git a/apps/mobile/src/app/(app)/profile/edit.ts b/apps/mobile/src/app/(app)/profile/edit.ts index 7b5ec80..9765e06 100644 --- a/apps/mobile/src/app/(app)/profile/edit.ts +++ b/apps/mobile/src/app/(app)/profile/edit.ts @@ -1,4 +1,4 @@ -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; export { default } from "@/views/EditProfile"; diff --git a/apps/mobile/src/app/(auth)/_layout.tsx b/apps/mobile/src/app/(auth)/_layout.tsx index b76ba9e..da9ecff 100644 --- a/apps/mobile/src/app/(auth)/_layout.tsx +++ b/apps/mobile/src/app/(auth)/_layout.tsx @@ -1,8 +1,9 @@ import { Stack } from "expo-router"; + import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; -export default () => { +const AuthLayout = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -49,3 +50,5 @@ export default () => { ); }; + +export default AuthLayout; diff --git a/apps/mobile/src/app/+html.tsx b/apps/mobile/src/app/+html.tsx index 640da02..c7d54a5 100644 --- a/apps/mobile/src/app/+html.tsx +++ b/apps/mobile/src/app/+html.tsx @@ -1,7 +1,8 @@ import * as React from "react"; + import { ScrollViewStyleReset } from "expo-router/html"; -export default ({ children }: { children: React.ReactNode }) => ( +const HtmlDocument = ({ children }: { children: React.ReactNode }) => ( @@ -16,3 +17,5 @@ export default ({ children }: { children: React.ReactNode }) => ( {children} ); + +export default HtmlDocument; diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 7d9bf00..8d16ca4 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -1,26 +1,27 @@ import "@/config"; - import { useEffect, useState } from "react"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; -import { magicModal, MagicModalPortal } from "react-native-magic-modal"; -import { PostHogProvider } from "posthog-react-native"; + import { router, SplashScreen, Stack } from "expo-router"; + import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; +import { PostHogProvider } from "posthog-react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { magicModal, MagicModalPortal } from "react-native-magic-modal"; import { Provider } from "react-redux"; import styled from "styled-components/native"; import { NetworkBoundary } from "@/components/NetworkBoundary"; +import { storedThemePromise, ThemeProvider } from "@/contexts/theme-provider"; +import { TRPCProvider } from "@/contexts/trpc-provider"; +import { useProtectedRoute } from "@/hooks/use-protected-route"; +import { useTrackScreens } from "@/hooks/use-track-screens"; import { config } from "@/services/config"; -import { posthog } from "@/services/posthog"; -import { storedThemePromise, ThemeProvider } from "@/contexts/ThemeProvider"; -import { TRPCProvider } from "@/contexts/TRPCProvider"; -import { useProtectedRoute } from "@/hooks/useProtectedRoute"; -import { useTrackScreens } from "@/hooks/useTrackScreens"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; import { useGetInitialNotifications } from "@/services/linking"; +import { posthog } from "@/services/posthog"; import { useQuickActions } from "@/services/quickActions"; -import { SceneName } from "@/types/SceneName"; import { store } from "@/store"; +import { SceneName } from "@/types/scene-name"; // Wait for the assets to load before hiding the SplashScreen SplashScreen.preventAutoHideAsync()?.catch(sendError); diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 0e97700..5713e1c 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1 +1 @@ -export { default } from "@/views/Splashscreen"; +export { default } from "@/views/splashscreen"; diff --git a/apps/mobile/src/assets/animations/inverseLoadingDots.json b/apps/mobile/src/assets/animations/inverseLoadingDots.json index 7ef7c5c..ad47877 100644 --- a/apps/mobile/src/assets/animations/inverseLoadingDots.json +++ b/apps/mobile/src/assets/animations/inverseLoadingDots.json @@ -82,7 +82,10 @@ "ty": "fl", "c": { "a": 0, - "k": [0.10196078431372549, 0.10588235294117647, 0.10196078431372549, 1], + "k": [ + 0.10196078431372549, 0.10588235294117647, 0.10196078431372549, + 1 + ], "ix": 4 }, "o": { "a": 0, "k": 100, "ix": 5 }, @@ -189,7 +192,10 @@ "ty": "fl", "c": { "a": 0, - "k": [0.10196078431372549, 0.10588235294117647, 0.10196078431372549, 1], + "k": [ + 0.10196078431372549, 0.10588235294117647, 0.10196078431372549, + 1 + ], "ix": 4 }, "o": { "a": 0, "k": 100, "ix": 5 }, @@ -296,7 +302,10 @@ "ty": "fl", "c": { "a": 0, - "k": [0.10196078431372549, 0.10588235294117647, 0.10196078431372549, 1], + "k": [ + 0.10196078431372549, 0.10588235294117647, 0.10196078431372549, + 1 + ], "ix": 4 }, "o": { "a": 0, "k": 100, "ix": 5 }, diff --git a/apps/mobile/src/assets/animations/swipe.json b/apps/mobile/src/assets/animations/swipe.json index ff8c842..77a726a 100644 --- a/apps/mobile/src/assets/animations/swipe.json +++ b/apps/mobile/src/assets/animations/swipe.json @@ -124,7 +124,9 @@ "ty": "st", "c": { "a": 0, - "k": [0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1], + "k": [ + 0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1 + ], "ix": 3 }, "o": { "a": 0, "k": 100, "ix": 4 }, @@ -191,7 +193,9 @@ "ty": "st", "c": { "a": 0, - "k": [0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1], + "k": [ + 0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1 + ], "ix": 3 }, "o": { "a": 0, "k": 100, "ix": 4 }, @@ -277,7 +281,9 @@ "ty": "st", "c": { "a": 0, - "k": [0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1], + "k": [ + 0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1 + ], "ix": 3 }, "o": { "a": 0, "k": 100, "ix": 4 }, @@ -344,7 +350,9 @@ "ty": "st", "c": { "a": 0, - "k": [0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1], + "k": [ + 0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1 + ], "ix": 3 }, "o": { "a": 0, "k": 100, "ix": 4 }, @@ -559,7 +567,9 @@ "ty": "st", "c": { "a": 0, - "k": [0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1], + "k": [ + 0.36470588235294116, 0.3843137254901961, 0.4588235294117647, 1 + ], "ix": 3 }, "o": { "a": 0, "k": 100, "ix": 4 }, diff --git a/apps/mobile/src/assets/images/Logo.tsx b/apps/mobile/src/assets/images/logo.tsx similarity index 95% rename from apps/mobile/src/assets/images/Logo.tsx rename to apps/mobile/src/assets/images/logo.tsx index 3b1bec3..7ca9288 100644 --- a/apps/mobile/src/assets/images/Logo.tsx +++ b/apps/mobile/src/assets/images/logo.tsx @@ -1,4 +1,6 @@ -import Svg, { Defs, LinearGradient, Path, Stop, SvgProps } from "react-native-svg"; +import type { SvgProps } from "react-native-svg"; + +import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; const SvgComponent = ({ colorStopOne, diff --git a/apps/mobile/src/assets/images/Messages.tsx b/apps/mobile/src/assets/images/messages.tsx similarity index 71% rename from apps/mobile/src/assets/images/Messages.tsx rename to apps/mobile/src/assets/images/messages.tsx index 14df8a8..76c604f 100644 --- a/apps/mobile/src/assets/images/Messages.tsx +++ b/apps/mobile/src/assets/images/messages.tsx @@ -1,4 +1,6 @@ -import Svg, { Defs, LinearGradient, Path, Stop, SvgProps } from "react-native-svg"; +import type { SvgProps } from "react-native-svg"; + +import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; const SvgComponent = ({ colorStopOne, @@ -18,11 +20,25 @@ const SvgComponent = ({ d="m13.443 25.343-5.806 5.985a1.584 1.584 0 0 1-2.72-1.102v-6.412a4.522 4.522 0 0 1-.21-.12c-2.753-1.73-4.54-4.521-4.54-7.67 0-5.247 4.962-9.5 11.083-9.5s11.083 4.253 11.083 9.5c0 4.316-3.358 7.96-7.957 9.117-.309.079-.62.146-.933.202Z" /> - + - + diff --git a/apps/mobile/src/assets/images/Profile.tsx b/apps/mobile/src/assets/images/profile.tsx similarity index 86% rename from apps/mobile/src/assets/images/Profile.tsx rename to apps/mobile/src/assets/images/profile.tsx index 7e3023d..27b3192 100644 --- a/apps/mobile/src/assets/images/Profile.tsx +++ b/apps/mobile/src/assets/images/profile.tsx @@ -1,4 +1,6 @@ -import Svg, { Defs, LinearGradient, Path, Stop, SvgProps } from "react-native-svg"; +import type { SvgProps } from "react-native-svg"; + +import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; const SvgComponent = ({ colorStopOne, diff --git a/apps/mobile/src/components/BottomAction/index.tsx b/apps/mobile/src/components/BottomAction/index.tsx index ce9b71f..a50b57e 100644 --- a/apps/mobile/src/components/BottomAction/index.tsx +++ b/apps/mobile/src/components/BottomAction/index.tsx @@ -1,9 +1,11 @@ +import type { BlurViewProps } from "expo-blur"; + import * as React from "react"; import { Platform } from "react-native"; -import { BlurViewProps } from "expo-blur"; + import { useTheme } from "styled-components/native"; -import { useKeyboardAwareSafeAreaInsets } from "../../hooks/useKeyboardAwareSafeAreaInsets"; +import { useKeyboardAwareSafeAreaInsets } from "../../hooks/use-keyboard-aware-safe-area-insets"; import { BUTTON_HEIGHT } from "../Button/styles"; import * as S from "./styles"; diff --git a/apps/mobile/src/components/BottomAction/styles.ts b/apps/mobile/src/components/BottomAction/styles.ts index 7a433f3..7ec90b6 100644 --- a/apps/mobile/src/components/BottomAction/styles.ts +++ b/apps/mobile/src/components/BottomAction/styles.ts @@ -1,6 +1,6 @@ import styled from "styled-components/native"; -import { BlurView } from "@/components/BlurView"; +import { BlurView } from "@/components/blur-view"; export const Container = styled(BlurView)` padding: ${(props) => props.theme.spacing[4]}px; diff --git a/apps/mobile/src/components/BreedPicker/index.tsx b/apps/mobile/src/components/BreedPicker/index.tsx index 3758bbd..764d6d5 100644 --- a/apps/mobile/src/components/BreedPicker/index.tsx +++ b/apps/mobile/src/components/BreedPicker/index.tsx @@ -1,15 +1,16 @@ -import { useTranslation } from "react-i18next"; -import styled from "styled-components/native"; +import type { BreedSlug } from "@pegada/shared/i18n/i18n"; -import { BreedSlug } from "@pegada/shared/i18n/i18n"; import { Namespace } from "@pegada/shared/i18n/types/types"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components/native"; import { Input } from "@/components/Input"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { api } from "@/contexts/TRPCProvider"; +import { api } from "@/contexts/trpc-provider"; + import { InputPicker } from "../Picker"; -interface BreedPickerProps { +type BreedPickerProps = { breed?: string | null; setBreed: (value: { id: string | null }) => void; error?: string; @@ -17,9 +18,14 @@ interface BreedPickerProps { title?: string; optional?: boolean; testID?: string; -} +}; -const BreedPicker = ({ breed, setBreed, hasAnyOption = false, ...props }: BreedPickerProps) => { +const BreedPicker = ({ + breed, + setBreed, + hasAnyOption = false, + ...props +}: BreedPickerProps) => { const { t } = useTranslation(); const [breedsData] = api.breed.all.useSuspenseQuery(undefined, { refetchOnMount: false, @@ -55,7 +61,13 @@ const DisabledInput = styled(Input)` const BreedPickerLoading = () => { const { t } = useTranslation(); - return ; + return ( + + ); }; const BreedPickerError = () => { @@ -71,8 +83,13 @@ const BreedPickerError = () => { ); }; -export default (props: BreedPickerProps) => ( - } errorFallback={BreedPickerError}> +const BreedPickerBoundary = (props: BreedPickerProps) => ( + } + errorFallback={BreedPickerError} + > ); + +export default BreedPickerBoundary; diff --git a/apps/mobile/src/components/Button/index.tsx b/apps/mobile/src/components/Button/index.tsx index 8f4d809..b126d1c 100644 --- a/apps/mobile/src/components/Button/index.tsx +++ b/apps/mobile/src/components/Button/index.tsx @@ -1,12 +1,17 @@ +import type { ContainerProps } from "./styles"; + +import type { PressableProps } from "react-native"; + import * as React from "react"; -import { PressableProps } from "react-native"; -import Loading from "@/components/Loading"; -import { ButtonText, Container, ContainerProps } from "./styles"; +import Loading from "@/components/loading"; + +import { ButtonText, Container } from "./styles"; -export interface ButtonProps extends ContainerProps, PressableProps { +export type ButtonProps = { children: string; -} +} & ContainerProps & + PressableProps; export const Button: React.FC = ({ children, ...props }) => { const disabled = props.loading || props.disabled; diff --git a/apps/mobile/src/components/Button/styles.ts b/apps/mobile/src/components/Button/styles.ts index 9012362..a0799b2 100644 --- a/apps/mobile/src/components/Button/styles.ts +++ b/apps/mobile/src/components/Button/styles.ts @@ -1,14 +1,14 @@ import styled, { css } from "styled-components/native"; -import { PressableArea } from "@/components/PressableArea"; -import { Text } from "@/components/Text"; +import { PressableArea } from "@/components/pressable-area"; +import { Text } from "@/components/text"; -interface VariantProps { +type VariantProps = { variant?: "outline" | "default"; -} -export interface ContainerProps extends VariantProps { +}; +export type ContainerProps = { loading?: boolean; -} +} & VariantProps; export const BUTTON_HEIGHT = 68; @@ -38,5 +38,6 @@ export const Container = styled(PressableArea)` `; export const ButtonText = styled(Text)` - color: ${(props) => (props.variant === "outline" ? props.theme.colors.primary : "white")}; + color: ${(props) => + props.variant === "outline" ? props.theme.colors.primary : "white"}; `; diff --git a/apps/mobile/src/components/DefaultModal/index.tsx b/apps/mobile/src/components/DefaultModal/index.tsx index 97a07bc..a00ac6d 100644 --- a/apps/mobile/src/components/DefaultModal/index.tsx +++ b/apps/mobile/src/components/DefaultModal/index.tsx @@ -1,15 +1,19 @@ import * as React from "react"; -import { useMagicModal } from "react-native-magic-modal"; + import { useTranslation } from "react-i18next"; +import { useMagicModal } from "react-native-magic-modal"; import { Container, Description, OkButton, Title } from "./styles"; -interface DefaultModalProps { +type DefaultModalProps = { title: string; description: string; -} +}; -export const DefaultModal: React.FC = ({ title, description }) => { +export const DefaultModal: React.FC = ({ + title, + description, +}) => { const { t } = useTranslation(); const { hide } = useMagicModal(); diff --git a/apps/mobile/src/components/DefaultModal/styles.ts b/apps/mobile/src/components/DefaultModal/styles.ts index 2a1a938..e400470 100644 --- a/apps/mobile/src/components/DefaultModal/styles.ts +++ b/apps/mobile/src/components/DefaultModal/styles.ts @@ -1,7 +1,7 @@ import styled from "styled-components/native"; import { Button } from "@/components/Button"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const OkButton = styled(Button)` width: 100%; diff --git a/apps/mobile/src/components/FeedbackCard/index.tsx b/apps/mobile/src/components/FeedbackCard/index.tsx index 8d18f3d..2198dae 100644 --- a/apps/mobile/src/components/FeedbackCard/index.tsx +++ b/apps/mobile/src/components/FeedbackCard/index.tsx @@ -1,41 +1,68 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; +import type { SharedValue } from "react-native-reanimated"; + import * as React from "react"; -import { Extrapolation, interpolate, SharedValue, useAnimatedStyle } from "react-native-reanimated"; + +import { + Extrapolation, + interpolate, + useAnimatedStyle, +} from "react-native-reanimated"; import { ACTION_OFFSET } from "@/constants"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; + import LikeFeedback from "./components/LikeFeedback"; import MaybeFeedback from "./components/MaybeFeedback"; import NopeFeedback from "./components/NopeFeedback"; import { AbsolutePosition, Container, StyledMainCard } from "./styles"; -interface FeedbackCardProps { +type FeedbackCardProps = { dog: SwipeDog; translation: { x: SharedValue; y: SharedValue; }; isFirst: boolean; -} +}; -const FeedbackCard: React.FC = ({ dog, translation, isFirst }) => { +const FeedbackCard: React.FC = ({ + dog, + translation, + isFirst, +}) => { const likeOpacity = useAnimatedStyle(() => { "worklet"; return { - opacity: interpolate(translation.x.value, [10, ACTION_OFFSET], [0, 1], Extrapolation.CLAMP), + opacity: interpolate( + translation.x.value, + [10, ACTION_OFFSET], + [0, 1], + Extrapolation.CLAMP, + ), }; }); const nopeOpacity = useAnimatedStyle(() => { "worklet"; return { - opacity: interpolate(translation.x.value, [-ACTION_OFFSET, -10], [1, 0], Extrapolation.CLAMP), + opacity: interpolate( + translation.x.value, + [-ACTION_OFFSET, -10], + [1, 0], + Extrapolation.CLAMP, + ), }; }); const maybeOpacity = useAnimatedStyle(() => { "worklet"; return { - opacity: interpolate(translation.y.value, [-ACTION_OFFSET, -10], [1, 0], Extrapolation.CLAMP), + opacity: interpolate( + translation.y.value, + [-ACTION_OFFSET, -10], + [1, 0], + Extrapolation.CLAMP, + ), }; }); diff --git a/apps/mobile/src/components/FeedbackCard/styles.ts b/apps/mobile/src/components/FeedbackCard/styles.ts index 0a7a577..bf6be1f 100644 --- a/apps/mobile/src/components/FeedbackCard/styles.ts +++ b/apps/mobile/src/components/FeedbackCard/styles.ts @@ -4,12 +4,13 @@ import styled, { css } from "styled-components/native"; import MainCard from "../MainCard"; import { absoluteFill } from "../MainCard/styles"; -interface IContainer { +type IContainer = { isFirst: boolean; -} +}; export const Container = styled(Animated.View)` - margin: ${(props) => props.theme.spacing[1]}px ${(props) => props.theme.spacing[1.5]}px; + margin: ${(props) => props.theme.spacing[1]}px + ${(props) => props.theme.spacing[1.5]}px; flex: 1; border-radius: ${(props) => props.theme.radii.lg}px; background-color: ${(props) => props.theme.colors.background}; diff --git a/apps/mobile/src/components/Input/index.tsx b/apps/mobile/src/components/Input/index.tsx index df0daaa..e2ae8a3 100644 --- a/apps/mobile/src/components/Input/index.tsx +++ b/apps/mobile/src/components/Input/index.tsx @@ -1,14 +1,17 @@ +import type { TextInput, TextInputProps, ViewProps } from "react-native"; + import * as React from "react"; -import { TextInput, TextInputProps, ViewProps } from "react-native"; + import { useTranslation } from "react-i18next"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; + import * as S from "./styles"; -interface TextFieldContainerProps { +type TextFieldContainerProps = { loading?: boolean; children: React.ReactNode; -} +}; const TextFieldContainer: React.FC = ({ loading, @@ -21,16 +24,26 @@ const TextFieldContainer: React.FC = ({ ); -interface InputProps extends TextInputProps { +type InputProps = { canCancel?: boolean; loading?: boolean; optional?: boolean; title?: string; error?: string; -} +} & TextInputProps; export const Input = React.forwardRef( - ({ title, canCancel = true, error, loading = false, optional = false, ...props }, ref) => { + ( + { + title, + canCancel = true, + error, + loading = false, + optional = false, + ...props + }, + ref, + ) => { const { t } = useTranslation(); return ( @@ -40,11 +53,18 @@ export const Input = React.forwardRef( {title} - {optional ? {t("common.optional")} : null} + {optional ? ( + {t("common.optional")} + ) : null} )} - + {Boolean(props.value) && canCancel ? ( props.onChangeText?.("")}> diff --git a/apps/mobile/src/components/Input/styles.ts b/apps/mobile/src/components/Input/styles.ts index 866f92d..7130b8d 100644 --- a/apps/mobile/src/components/Input/styles.ts +++ b/apps/mobile/src/components/Input/styles.ts @@ -1,8 +1,9 @@ import { ActivityIndicator } from "react-native"; + import styled from "styled-components/native"; import cancelIcon from "@/assets/images/Cancel.svg"; -import { PressableArea } from "@/components/PressableArea"; +import { PressableArea } from "@/components/pressable-area"; export const Container = styled.View` margin-top: ${(props) => props.theme.spacing[4]}px; diff --git a/apps/mobile/src/components/LikeLimitReached/index.tsx b/apps/mobile/src/components/LikeLimitReached/index.tsx index 3d8eaa0..90da15a 100644 --- a/apps/mobile/src/components/LikeLimitReached/index.tsx +++ b/apps/mobile/src/components/LikeLimitReached/index.tsx @@ -1,26 +1,35 @@ +import type { LikeLimitReachedProps } from "@/components/LikeLimitReached/use-countdown"; + import { useEffect } from "react"; import * as React from "react"; -import { magicModal, useMagicModal } from "react-native-magic-modal"; + import { useRouter } from "expo-router"; -import { Trans, useTranslation } from "react-i18next"; import { FREE_DAILY_SWIPE_LIMIT } from "@pegada/shared/constants/constants"; +import { Trans, useTranslation } from "react-i18next"; +import { magicModal, useMagicModal } from "react-native-magic-modal"; import { Description, OkButton, Title } from "@/components/DefaultModal/styles"; -import { Container, CountdownContainer, Header } from "@/components/LikeLimitReached/styles"; import { - LikeLimitReachedProps, + Container, + CountdownContainer, + Header, +} from "@/components/LikeLimitReached/styles"; +import { useCountdown, ZERO_TIME_LEFT, -} from "@/components/LikeLimitReached/useCountdown"; +} from "@/components/LikeLimitReached/use-countdown"; import { CloseIcon } from "@/components/Picker/styles"; -import { Text } from "@/components/Text"; -import { useEligibleForTrial } from "@/hooks/usePayments"; +import { Text } from "@/components/text"; +import { useEligibleForTrial } from "@/hooks/use-payments"; import { analytics } from "@/services/analytics"; -import { SceneName } from "@/types/SceneName"; -import { CloseButton } from "@/views/UpgradeWall/styles"; +import { SceneName } from "@/types/scene-name"; + +import { PinnedCloseButton } from "./styles"; -const LikeLimitReached: React.FC = ({ likeLimitResetAt }) => { +const LikeLimitReached: React.FC = ({ + likeLimitResetAt, +}) => { const timeLeft = useCountdown(likeLimitResetAt); const { t } = useTranslation(); const router = useRouter(); @@ -56,7 +65,7 @@ const LikeLimitReached: React.FC = ({ likeLimitResetAt }) {t("likeLimit.remaining")} { + onPress={() => { hide(); // Need to wait a bit to avoid the modal transition @@ -65,11 +74,13 @@ const LikeLimitReached: React.FC = ({ likeLimitResetAt }) }, 150); }} > - {isEligibleForTrial ? t("likeLimit.winFreeTrial") : t("likeLimit.getPremium")} + {isEligibleForTrial + ? t("likeLimit.winFreeTrial") + : t("likeLimit.getPremium")} - hide()}> + hide()}> - + ); }; diff --git a/apps/mobile/src/components/LikeLimitReached/styles.ts b/apps/mobile/src/components/LikeLimitReached/styles.ts index 9a035bb..deee5b8 100644 --- a/apps/mobile/src/components/LikeLimitReached/styles.ts +++ b/apps/mobile/src/components/LikeLimitReached/styles.ts @@ -1,6 +1,14 @@ import styled from "styled-components/native"; import * as ModalStyles from "@/components/DefaultModal/styles"; +import { CloseButton } from "@/views/UpgradeWall/styles"; + +/** The UpgradeWall close button, pinned to this modal's top-right corner. */ +export const PinnedCloseButton = styled(CloseButton)` + position: absolute; + top: 10px; + right: 10px; +`; export const Container = styled(ModalStyles.Container)` gap: ${(props) => props.theme.spacing[2]}px; diff --git a/apps/mobile/src/components/LikeLimitReached/useCountdown.tsx b/apps/mobile/src/components/LikeLimitReached/use-countdown.tsx similarity index 77% rename from apps/mobile/src/components/LikeLimitReached/useCountdown.tsx rename to apps/mobile/src/components/LikeLimitReached/use-countdown.tsx index 27f8bc6..e98a831 100644 --- a/apps/mobile/src/components/LikeLimitReached/useCountdown.tsx +++ b/apps/mobile/src/components/LikeLimitReached/use-countdown.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from "react"; + import { differenceInSeconds } from "date-fns"; -export interface LikeLimitReachedProps { +export type LikeLimitReachedProps = { likeLimitResetAt: Date; -} +}; const formatTimeLeft = (totalSeconds: number) => { // Time's up if (totalSeconds < 0) { @@ -16,9 +17,8 @@ const formatTimeLeft = (totalSeconds: number) => { return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; }; export const useCountdown = (likeLimitResetAt: Date) => { - const [timeLeft, setTimeLeft] = useState( - formatTimeLeft(differenceInSeconds(likeLimitResetAt, new Date())), - ); + const secondsLeft = differenceInSeconds(likeLimitResetAt, new Date()); + const [timeLeft, setTimeLeft] = useState(formatTimeLeft(secondsLeft)); useEffect(() => { const interval = setInterval(() => { @@ -27,10 +27,7 @@ export const useCountdown = (likeLimitResetAt: Date) => { setTimeLeft(formatTimeLeft(distanceInSeconds)); - if (distanceInSeconds <= 0) { - clearInterval(interval); - return; - } + if (distanceInSeconds <= 0) clearInterval(interval); }, 1000); // Update every second return () => clearInterval(interval); diff --git a/apps/mobile/src/components/MainCard/components/Distance/index.tsx b/apps/mobile/src/components/MainCard/components/Distance/index.tsx index aa7a822..5db10ad 100644 --- a/apps/mobile/src/components/MainCard/components/Distance/index.tsx +++ b/apps/mobile/src/components/MainCard/components/Distance/index.tsx @@ -1,20 +1,25 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; + import * as React from "react"; import { View } from "react-native"; + import { useTranslation } from "react-i18next"; import Location from "@/assets/images/Location.svg"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; + import { Container, Content, DistanceText } from "./styles"; -interface DistanceProps { +type DistanceProps = { dog: SwipeDog; -} +}; // TODO: Use i18n properly const formatDistance = (distance: number, locale: string) => { // Countries that use miles instead of kilometers const countriesUsingMiles = ["US", "GB", "LR", "MM"]; // United States, United Kingdom, Liberia, Myanmar - const usesMiles = countriesUsingMiles.some((countryCode) => locale.includes(countryCode)); + const usesMiles = countriesUsingMiles.some((countryCode) => + locale.includes(countryCode), + ); const unit = usesMiles ? " miles" : "km"; const conversionFactor = 0.621371; // conversion factor from km to miles @@ -23,7 +28,7 @@ const formatDistance = (distance: number, locale: string) => { const convertedDistance = usesMiles ? distance * conversionFactor : distance; return ( - Intl.NumberFormat(locale, { + new Intl.NumberFormat(locale, { style: "decimal", maximumFractionDigits: 1, }).format(convertedDistance) + unit @@ -45,7 +50,9 @@ const Distance: React.FC = ({ dog }) => { - {formatDistance(dog.distance ?? 0, i18n.language)} + + {formatDistance(dog.distance ?? 0, i18n.language)} + ); diff --git a/apps/mobile/src/components/MainCard/components/Distance/styles.ts b/apps/mobile/src/components/MainCard/components/Distance/styles.ts index 0aed437..6adaa8d 100644 --- a/apps/mobile/src/components/MainCard/components/Distance/styles.ts +++ b/apps/mobile/src/components/MainCard/components/Distance/styles.ts @@ -1,7 +1,7 @@ import styled from "styled-components/native"; -import { TransparentGlassOrDarkBlurView } from "@/components/BlurView"; -import { Text } from "@/components/Text"; +import { TransparentGlassOrDarkBlurView } from "@/components/blur-view"; +import { Text } from "@/components/text"; export const Container = styled(TransparentGlassOrDarkBlurView)` margin: ${(props) => props.theme.spacing[6]}px; diff --git a/apps/mobile/src/components/MainCard/components/Pagination/index.tsx b/apps/mobile/src/components/MainCard/components/Pagination/index.tsx index dbf07dd..740a85d 100644 --- a/apps/mobile/src/components/MainCard/components/Pagination/index.tsx +++ b/apps/mobile/src/components/MainCard/components/Pagination/index.tsx @@ -1,4 +1,5 @@ import * as React from "react"; + import { useAnimatedStyle, withTiming } from "react-native-reanimated"; import { Container, Content, Dot } from "./styles"; @@ -18,10 +19,10 @@ const DotComponent: React.FC<{ return ; }; -interface PaginationProps { +type PaginationProps = { pages: number; currentPage: number; -} +}; const Pagination: React.FC = ({ pages, currentPage }) => { if (pages <= 1) return null; @@ -30,7 +31,12 @@ const Pagination: React.FC = ({ pages, currentPage }) => { {Array.from({ length: pages }).map((_, index) => ( - + ))} diff --git a/apps/mobile/src/components/MainCard/components/Pagination/styles.ts b/apps/mobile/src/components/MainCard/components/Pagination/styles.ts index f4b051c..c08cece 100644 --- a/apps/mobile/src/components/MainCard/components/Pagination/styles.ts +++ b/apps/mobile/src/components/MainCard/components/Pagination/styles.ts @@ -1,7 +1,7 @@ import Animated from "react-native-reanimated"; import styled from "styled-components/native"; -import { TransparentGlassOrDarkBlurView } from "@/components/BlurView"; +import { TransparentGlassOrDarkBlurView } from "@/components/blur-view"; export const Content = styled.View` padding: ${(props) => props.theme.spacing[1]}px; @@ -18,9 +18,9 @@ export const Container = styled(TransparentGlassOrDarkBlurView)` overflow: hidden; `; -interface IDot { +type IDot = { active: boolean; -} +}; export const Dot = styled(Animated.View)` background-color: #fff; diff --git a/apps/mobile/src/components/MainCard/components/PersonalInfo/index.tsx b/apps/mobile/src/components/MainCard/components/PersonalInfo/index.tsx index 3807646..5963698 100644 --- a/apps/mobile/src/components/MainCard/components/PersonalInfo/index.tsx +++ b/apps/mobile/src/components/MainCard/components/PersonalInfo/index.tsx @@ -1,24 +1,39 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; + +import type { ViewProps } from "react-native"; + import * as React from "react"; -import { ViewProps } from "react-native"; + import { LinearGradient } from "expo-linear-gradient"; -import { useGetFormattedYears } from "@/services/useGetFormattedYears"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; +import { useGetFormattedYears } from "@/services/use-get-formatted-years"; + import { Age, Container, Description, Name } from "./styles"; export const BIO_NUMBER_OF_LINES = 3; -const PersonalInfo: React.FC<{ dog: SwipeDog } & ViewProps> = ({ dog, ...rest }) => { +const PersonalInfo: React.FC<{ dog: SwipeDog } & ViewProps> = ({ + dog, + ...rest +}) => { const getFormattedYears = useGetFormattedYears(); return ( - + {dog.name} - {dog.birthDate ? , {getFormattedYears(dog.birthDate)} : null} + {dog.birthDate ? ( + , {getFormattedYears(dog.birthDate)} + ) : null} - {dog.bio ? {dog.bio} : null} + {dog.bio ? ( + + {dog.bio} + + ) : null} ); diff --git a/apps/mobile/src/components/MainCard/components/PersonalInfo/styles.ts b/apps/mobile/src/components/MainCard/components/PersonalInfo/styles.ts index 3819134..e607212 100644 --- a/apps/mobile/src/components/MainCard/components/PersonalInfo/styles.ts +++ b/apps/mobile/src/components/MainCard/components/PersonalInfo/styles.ts @@ -1,6 +1,6 @@ import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Container = styled.View` padding: ${(props) => props.theme.spacing[6]}px; diff --git a/apps/mobile/src/components/MainCard/index.tsx b/apps/mobile/src/components/MainCard/index.tsx index a687ba1..61dc394 100644 --- a/apps/mobile/src/components/MainCard/index.tsx +++ b/apps/mobile/src/components/MainCard/index.tsx @@ -1,17 +1,20 @@ import type { SwipeDog } from "@/store/reducers/dogs/swipe"; + import { useState } from "react"; import * as React from "react"; + +import { useRouter } from "expo-router"; + import { useAnimatedStyle, useSharedValue, withSequence, withSpring, } from "react-native-reanimated"; -import { LinearGradient } from "expo-linear-gradient"; -import { useRouter } from "expo-router"; -import { PressableArea } from "@/components/PressableArea"; -import { SceneName } from "@/types/SceneName"; +import { PressableArea } from "@/components/pressable-area"; +import { SceneName } from "@/types/scene-name"; + import Distance from "./components/Distance"; import Pagination from "./components/Pagination"; import PersonalInfo from "./components/PersonalInfo"; @@ -21,6 +24,7 @@ import { NextImage, Picture, PreviousImage, + Scrim, UpperPart, } from "./styles"; @@ -28,7 +32,10 @@ const springConfig = { mass: 0.2 }; const START_IMAGE_INDEX = 0; -export interface VisitingCardProps extends React.ComponentProps { +// oxlint-disable-next-line typescript/consistent-type-definitions -- `Container` is a reanimated Animated.View, whose props carry a string index signature. `interface โ€ฆ extends` keeps the members below at their declared types; the `{โ€ฆ} & Props` intersection the rule wants intersects each of them with the index signature's `any` and silently widens all three to `any`. +export interface VisitingCardProps extends React.ComponentProps< + typeof Container +> { dog: SwipeDog; shouldShowPersonalInfo?: boolean; startImageIndex?: number; @@ -63,8 +70,10 @@ const VisitingCard: React.FC = ({ if (currentImage !== 0) return setCurrentImage((index) => index - 1); - // eslint-disable-next-line react-compiler/react-compiler -- false positive - rotation.value = withSequence(withSpring(-0.5, springConfig), withSpring(0, springConfig)); + rotation.value = withSequence( + withSpring(-0.5, springConfig), + withSpring(0, springConfig), + ); }; const gotoNextImage = () => { @@ -75,7 +84,10 @@ const VisitingCard: React.FC = ({ if (currentImage + 1 < images.length) { return setCurrentImage((index) => index + 1); } - rotation.value = withSequence(withSpring(0.5, springConfig), withSpring(0, springConfig)); + rotation.value = withSequence( + withSpring(0.5, springConfig), + withSpring(0, springConfig), + ); }; const transform = useAnimatedStyle(() => { @@ -94,15 +106,13 @@ const VisitingCard: React.FC = ({ }} key={images[currentImage]?.id} /> - @@ -113,7 +123,10 @@ const VisitingCard: React.FC = ({ {Boolean(shouldShowPersonalInfo) && ( - + )} diff --git a/apps/mobile/src/components/MainCard/styles.ts b/apps/mobile/src/components/MainCard/styles.ts index 575263e..e149c71 100644 --- a/apps/mobile/src/components/MainCard/styles.ts +++ b/apps/mobile/src/components/MainCard/styles.ts @@ -1,7 +1,9 @@ +import { LinearGradient } from "expo-linear-gradient"; + import Animated from "react-native-reanimated"; import styled, { css } from "styled-components/native"; -import { Image } from "@/components/Image"; +import { Image } from "@/components/image"; export const absoluteFill = css` position: absolute; @@ -38,3 +40,12 @@ export const PreviousImage = styled.Pressable` export const NextImage = styled.Pressable` flex: 1; `; + +/** Full-bleed gradient that darkens the top of the card. */ +export const Scrim = styled(LinearGradient)` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +`; diff --git a/apps/mobile/src/components/MatchActionBar/GlassPillBackground.tsx b/apps/mobile/src/components/MatchActionBar/glass-pill-background.tsx similarity index 94% rename from apps/mobile/src/components/MatchActionBar/GlassPillBackground.tsx rename to apps/mobile/src/components/MatchActionBar/glass-pill-background.tsx index b95f4d1..4ad4f0d 100644 --- a/apps/mobile/src/components/MatchActionBar/GlassPillBackground.tsx +++ b/apps/mobile/src/components/MatchActionBar/glass-pill-background.tsx @@ -1,5 +1,7 @@ import * as React from "react"; + import { GlassView } from "expo-glass-effect"; + import styled from "styled-components/native"; const StyledGlassView = styled(GlassView)` @@ -10,10 +12,10 @@ const StyledGlassView = styled(GlassView)` bottom: 0; `; -interface GlassPillBackgroundProps { +type GlassPillBackgroundProps = { tintColor: string; colorScheme: "light" | "dark"; -} +}; /** * Only ever rendered after the caller has checked `isLiquidGlassAvailableSafe()`. diff --git a/apps/mobile/src/components/MatchActionBar/index.tsx b/apps/mobile/src/components/MatchActionBar/index.tsx index 0d62766..32e3049 100644 --- a/apps/mobile/src/components/MatchActionBar/index.tsx +++ b/apps/mobile/src/components/MatchActionBar/index.tsx @@ -1,9 +1,18 @@ import * as React from "react"; + import Animated, { FadeInDown, ZoomOutDown } from "react-native-reanimated"; -import { ActionItem, ConfusedEmoji, Container, HeartEyesEmoji, ThinkingEmoji } from "./styles"; -import { PressableArea } from "@/components/PressableArea"; +import { PressableArea } from "@/components/pressable-area"; + +import { + ActionItem, + ConfusedEmoji, + Container, + HeartEyesEmoji, + ThinkingEmoji, +} from "./styles"; +// oxlint-disable-next-line typescript/consistent-type-definitions -- see MainCard: `Container` is a reanimated Animated.View and its string index signature widens every member of an intersection to `any`. `extends` keeps them. interface MatchActionBarProps extends React.ComponentProps { onNope: () => void; onYep: () => void; @@ -26,7 +35,11 @@ export const MatchActionBar: React.FC = ({ return ( - + diff --git a/apps/mobile/src/components/MatchActionBar/styles.ts b/apps/mobile/src/components/MatchActionBar/styles.ts index 2c06aa8..c4cebe2 100644 --- a/apps/mobile/src/components/MatchActionBar/styles.ts +++ b/apps/mobile/src/components/MatchActionBar/styles.ts @@ -1,13 +1,13 @@ -import Animated from "react-native-reanimated"; import Color from "color"; +import Animated from "react-native-reanimated"; import styled from "styled-components/native"; // Should preload for a better dog experience, so no inline requires import confusedEmoji from "@/assets/images/ConfusedEmoji.webp"; import heartEyesEmoji from "@/assets/images/HeartEyesEmoji.webp"; import thinkingEmoji from "@/assets/images/ThinkingEmoji.webp"; -import { Image } from "@/components/Image"; -import { TransparentGlassOrDarkBlurView } from "@/components/BlurView"; +import { TransparentGlassOrDarkBlurView } from "@/components/blur-view"; +import { Image } from "@/components/image"; // Apple HIG recommends a minimum 44x44pt hit area for tappable targets. const MIN_TOUCH_TARGET = 44; @@ -64,7 +64,8 @@ export const ActionItemFallbackBackground = styled.View` left: 0; right: 0; bottom: 0; - background-color: ${(props) => Color(props.theme.colors.primary).alpha(0.1).rgb().string()}; + background-color: ${(props) => + new Color(props.theme.colors.primary).alpha(0.1).rgb().string()}; `; export const ConfusedEmoji = styled(Image).attrs({ diff --git a/apps/mobile/src/components/NetworkBoundary/index.tsx b/apps/mobile/src/components/NetworkBoundary/index.tsx index 648c718..6c5be36 100644 --- a/apps/mobile/src/components/NetworkBoundary/index.tsx +++ b/apps/mobile/src/components/NetworkBoundary/index.tsx @@ -1,14 +1,21 @@ -import { PropsWithChildren, Suspense, useEffect, useState } from "react"; +import type { PropsWithChildren } from "react"; +import type { ViewProps } from "react-native"; + +import { Suspense, useEffect, useState } from "react"; import * as React from "react"; -import { ActivityIndicator, ViewProps } from "react-native"; +import { ActivityIndicator } from "react-native"; + import NetInfo from "@react-native-community/netinfo"; -import { QueryErrorResetBoundary, useQueryErrorResetBoundary } from "@tanstack/react-query"; +import { + QueryErrorResetBoundary, + useQueryErrorResetBoundary, +} from "@tanstack/react-query"; +import { PostHogErrorBoundary } from "posthog-react-native"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; -import { PostHogErrorBoundary } from "posthog-react-native"; - import { Button } from "@/components/Button"; + import { ContainedText, Container, @@ -27,7 +34,9 @@ export const OfflineComponent = ({ reset }: { reset: () => void }) => { {t("networkBoundary.offline.title")} {t("networkBoundary.offline.message")} - + ); @@ -41,7 +50,9 @@ export const RequestErrorComponent = ({ reset }: { reset: () => void }) => { {t("networkBoundary.requestError.title")} - {t("networkBoundary.requestError.message")} + + {t("networkBoundary.requestError.message")} + @@ -58,7 +69,9 @@ export const UnknownErrorComponent = (props: ViewProps) => { {t("networkBoundary.unknownError.title")} - {t("networkBoundary.unknownError.message")} + + {t("networkBoundary.unknownError.message")} + ); @@ -86,13 +99,15 @@ export const useIsOffline = () => { return !isInternetReachable; }; -interface QueryErrorResetBoundaryValue { +type QueryErrorResetBoundaryValue = { clearReset: () => void; isReset: () => boolean; reset: () => void; -} +}; -export type IErrorBoundary = (props: QueryErrorResetBoundaryValue) => React.ReactNode; +export type IErrorBoundary = ( + props: QueryErrorResetBoundaryValue, +) => React.ReactNode; export const DefaultErrorComponent: IErrorBoundary = ({ reset, isReset }) => { const offline = useIsOffline(); @@ -144,7 +159,9 @@ export const NetworkBoundary = ({ }: NetworkBoundaryProps) => { return ( - }>{children} + }> + {children} + ); }; diff --git a/apps/mobile/src/components/NetworkBoundary/styles.ts b/apps/mobile/src/components/NetworkBoundary/styles.ts index 44e195d..deaa6fc 100644 --- a/apps/mobile/src/components/NetworkBoundary/styles.ts +++ b/apps/mobile/src/components/NetworkBoundary/styles.ts @@ -1,7 +1,7 @@ import LottieView from "lottie-react-native"; import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Container = styled.ScrollView.attrs({ contentContainerStyle: { flex: 1 }, diff --git a/apps/mobile/src/components/Picker/index.tsx b/apps/mobile/src/components/Picker/index.tsx index 8c0841c..6eff413 100644 --- a/apps/mobile/src/components/Picker/index.tsx +++ b/apps/mobile/src/components/Picker/index.tsx @@ -1,15 +1,20 @@ +import type { BottomSheetFlatListProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetScrollable/types"; + +import type { ListRenderItemInfo } from "react-native"; + import { useState } from "react"; import * as React from "react"; -import { ListRenderItemInfo, Pressable, useWindowDimensions } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Pressable, useWindowDimensions } from "react-native"; + import { BottomSheetFlatList, BottomSheetModal } from "@gorhom/bottom-sheet"; -import { BottomSheetFlatListProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetScrollable/types"; import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "styled-components/native"; -import { renderCustomBackdrop } from "@/components/CustomBackdrop"; +import { renderCustomBackdrop } from "@/components/custom-backdrop"; import { Input } from "@/components/Input"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; + import { CloseIcon, Container, @@ -24,9 +29,7 @@ export type Item = { name: string; }; -export interface InputPickerProps extends Partial< - Omit, "ref"> -> { +export type InputPickerProps = { title: string; placeholder?: string; value: T | undefined; @@ -44,7 +47,7 @@ export interface InputPickerProps extends Partial< * reliably tap a known option (e.g. language/theme switches). */ itemTestIDPrefix?: string; -} +} & Partial, "ref">>; const hitSlop = { top: 10, bottom: 10, left: 10, right: 10 }; @@ -117,7 +120,9 @@ const UnForwardedPickerSheet = ( const { height: screenHeight } = useWindowDimensions(); const data = filter - ? props.data.filter((item) => item.name.toLowerCase().includes(filter.toLowerCase())) + ? props.data.filter((item) => + item.name.toLowerCase().includes(filter.toLowerCase()), + ) : props.data; const backgroundStyle = { @@ -144,7 +149,9 @@ const UnForwardedPickerSheet = ( value={value} onChange={onChange} onClose={onClose} - testID={itemTestIDPrefix ? `${itemTestIDPrefix}${item.id ?? "any"}` : undefined} + testID={ + itemTestIDPrefix ? `${itemTestIDPrefix}${item.id ?? "any"}` : undefined + } /> ); @@ -191,7 +198,9 @@ const UnForwardedPickerSheet = ( ); }; -export const PickerSheet = React.forwardRef(UnForwardedPickerSheet) as ( +export const PickerSheet = React.forwardRef(UnForwardedPickerSheet) as < + T extends Item, +>( props: InputPickerProps & { ref?: React.Ref }, ) => React.ReactElement; @@ -220,7 +229,11 @@ export const InputPicker = (props: InputPickerProps) => { {/* Long/searchable lists (breeds, sizes, colors) keep a tall fixed sheet; direct PickerSheet users without snapPoints get content-fit sizing. */} - + ); }; diff --git a/apps/mobile/src/components/Picker/styles.ts b/apps/mobile/src/components/Picker/styles.ts index 067d8fd..635d551 100644 --- a/apps/mobile/src/components/Picker/styles.ts +++ b/apps/mobile/src/components/Picker/styles.ts @@ -2,7 +2,7 @@ import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import styled from "styled-components/native"; import Close from "@/assets/images/Close.svg"; -import { PressableArea } from "@/components/PressableArea"; +import { PressableArea } from "@/components/pressable-area"; export const TitleContainer = styled.View` flex-direction: row; @@ -26,7 +26,8 @@ export const SelectItem = styled(PressableArea)` `; export const SearchContainer = styled.View` - padding: ${(props) => props.theme.spacing[2]}px ${(props) => props.theme.spacing[1.5]}px; + padding: ${(props) => props.theme.spacing[2]}px + ${(props) => props.theme.spacing[1.5]}px; border-bottom-width: ${(props) => props.theme.stroke.md}px; border-color: ${(props) => props.theme.colors.border}; background-color: ${(props) => props.theme.colors.background}; @@ -40,7 +41,8 @@ export const SearchInput = styled(BottomSheetTextInput).attrs((props) => ({ font-family: ${(props) => props.theme.typography.fontFamily.medium}; font-weight: medium; font-size: ${(props) => props.theme.typography.sizes.xs.size}px; - padding: ${(props) => props.theme.spacing[1.5]}px ${(props) => props.theme.spacing[2]}px; + padding: ${(props) => props.theme.spacing[1.5]}px + ${(props) => props.theme.spacing[2]}px; border-radius: ${(props) => props.theme.radii.sm}px; border-width: ${(props) => props.theme.stroke.md}px; border-color: ${(props) => props.theme.colors.border}; diff --git a/apps/mobile/src/components/ProfileImageUploader/components/AddUserPhoto/index.tsx b/apps/mobile/src/components/ProfileImageUploader/components/AddUserPhoto/index.tsx index 8c43099..a897f8f 100644 --- a/apps/mobile/src/components/ProfileImageUploader/components/AddUserPhoto/index.tsx +++ b/apps/mobile/src/components/ProfileImageUploader/components/AddUserPhoto/index.tsx @@ -1,24 +1,33 @@ +import type { + Picture, + ProfileImageUploadStage, +} from "@/components/ProfileImageUploader/utils"; + import { useState } from "react"; import * as React from "react"; import { ActivityIndicator } from "react-native"; -import { magicToast } from "react-native-magic-toast"; -import Animated, { FadeOut, useAnimatedStyle, withSpring } from "react-native-reanimated"; + import { useTranslation } from "react-i18next"; +import { magicToast } from "react-native-magic-toast"; +import Animated, { + FadeOut, + useAnimatedStyle, + withSpring, +} from "react-native-reanimated"; import { useTheme } from "styled-components/native"; import AddRemove from "@/assets/images/AddRemove.svg"; -import { PressableArea } from "@/components/PressableArea"; +import { PressableArea } from "@/components/pressable-area"; import { getMaestroPlaceholderUri, ImagePickerError, - Picture, - ProfileImageUploadStage, shouldOfferMaestroPlaceholder, showImagePickerOptions, uploadProfileImage, } from "@/components/ProfileImageUploader/utils"; -import { Text } from "@/components/Text"; -import { sendError } from "@/services/errorTracking"; +import { Text } from "@/components/text"; +import { sendError } from "@/services/error-tracking"; + import * as S from "./styles"; type AddUserPhotoProps = { @@ -33,6 +42,9 @@ type AddUserPhotoProps = { index?: number; }; +const photoActionTestID = (index: number, hasPicture: boolean) => + hasPicture ? `remove-photo-${index}` : `add-photo-button-${index}`; + const hitSlop = { top: 150, bottom: 150, @@ -40,7 +52,12 @@ const hitSlop = { right: 100, }; -export const AddUserPhoto: React.FC = ({ picture, onDelete, onAdd, index }) => { +export const AddUserPhoto: React.FC = ({ + picture, + onDelete, + onAdd, + index, +}) => { const [localPicture, setLocalPicture] = useState(picture.url); const { t } = useTranslation(); @@ -79,7 +96,9 @@ export const AddUserPhoto: React.FC = ({ picture, onDelete, o sendError(trackedError); if (__DEV__) { - magicToast.alert(t("imagePicker.uploadFailedDev", { reason: `[${stage}] ${reason}` })); + magicToast.alert( + t("imagePicker.uploadFailedDev", { reason: `[${stage}] ${reason}` }), + ); } else { magicToast.alert(t("imagePicker.uploadFailed")); } @@ -99,20 +118,26 @@ export const AddUserPhoto: React.FC = ({ picture, onDelete, o }); onAdd({ url: finalUrl }); - } catch (err) { + } catch (error) { // When the user cancels the image picker, we don't want to show an error - if (err instanceof Error && err.message === ImagePickerError.CANCELED) { + if ( + error instanceof Error && + error.message === ImagePickerError.CANCELED + ) { return; } // Permissions denied โ€” pickImage/takeImage already showed a native alert // explaining what to do. Don't double-toast. - if (err instanceof Error && err.message === ImagePickerError.NO_PERMISSION) { + if ( + error instanceof Error && + error.message === ImagePickerError.NO_PERMISSION + ) { handleDelete(); return; } - reportUploadFailure(err, stage, "ProfileImageUploader.handleAdd"); + reportUploadFailure(error, stage, "ProfileImageUploader.handleAdd"); handleDelete(); } }; @@ -147,8 +172,12 @@ export const AddUserPhoto: React.FC = ({ picture, onDelete, o }); onAdd({ url: finalUrl }); - } catch (err) { - reportUploadFailure(err, stage, "ProfileImageUploader.handleMaestroPlaceholderUpload"); + } catch (error) { + reportUploadFailure( + error, + stage, + "ProfileImageUploader.handleMaestroPlaceholderUpload", + ); handleDelete(); } }; @@ -158,7 +187,8 @@ export const AddUserPhoto: React.FC = ({ picture, onDelete, o // `shouldOfferMaestroPlaceholder()` short-circuits on // `config.ENV === "production"` so App Store builds always evaluate to // `false` here regardless of EXPO_PUBLIC_MAESTRO_E2E misconfiguration. - const showMaestroSkip = !hasPicture && !isLoading && shouldOfferMaestroPlaceholder(); + const showMaestroSkip = + !hasPicture && !isLoading && shouldOfferMaestroPlaceholder(); return ( @@ -176,7 +206,9 @@ export const AddUserPhoto: React.FC = ({ picture, onDelete, o ) : null} {!hasPicture && ( = ({ picture, onDelete, o {showMaestroSkip ? ( @@ -221,9 +255,7 @@ export const AddUserPhoto: React.FC = ({ picture, onDelete, o GenericPictures) => void; value: Picture[]; error?: string; setGesturesEnabled: (value: boolean) => void; -} +}; const AddUserPhotoWrapper = ({ picture, @@ -54,7 +59,14 @@ const AddUserPhotoWrapper = ({ .sort(sortByUrl), ); }; - return ; + return ( + + ); }; export const ProfileImagesUploader: React.FC = ({ @@ -81,7 +93,11 @@ export const ProfileImagesUploader: React.FC = ({ const index = value.findIndex((p) => p.id === item.id); return ( - + ); }; diff --git a/apps/mobile/src/components/ProfileImageUploader/utils/index.ts b/apps/mobile/src/components/ProfileImageUploader/utils/index.ts index 5991ad8..b119f25 100644 --- a/apps/mobile/src/components/ProfileImageUploader/utils/index.ts +++ b/apps/mobile/src/components/ProfileImageUploader/utils/index.ts @@ -1,4 +1,7 @@ +import type { IMAGE_STATUS } from "@pegada/shared/schemas/dog-schema"; + import { Alert } from "react-native"; + import { Asset } from "expo-asset"; import { cacheDirectory, @@ -11,15 +14,13 @@ import { import { manipulateAsync, SaveFormat } from "expo-image-manipulator"; import * as ImagePicker from "expo-image-picker"; -import { IMAGE_STATUS } from "@pegada/shared/schemas/dogSchema"; - +import { getTrcpContext } from "@/contexts/trcp-context"; import i18n from "@/i18n"; import { config } from "@/services/config"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { sendError } from "@/services/errorTracking"; -import { getMimeType } from "@/services/getMimeType"; +import { sendError } from "@/services/error-tracking"; +import { getMimeType } from "@/services/get-mime-type"; -export interface Picture { +export type Picture = { id: string; key: string; disabledDrag: boolean; @@ -28,22 +29,21 @@ export interface Picture { position: number; status?: keyof typeof IMAGE_STATUS; blurhash?: string; -} +}; export type DeletedPicture = Omit; -export const pictures: Picture[] = Array(6) - .fill(null) - .map( - (_data, index): Picture => ({ - id: `image-id-${index}`, - key: `image-key-${index}`, - url: "", - disabledDrag: true, - disabledReSorted: true, - position: index, - }), - ); +export const pictures: Picture[] = Array.from( + { length: 6 }, + (_data, index): Picture => ({ + id: `image-id-${index}`, + key: `image-key-${index}`, + url: "", + disabledDrag: true, + disabledReSorted: true, + position: index, + }), +); export const sortByUrl = ( firstItem: Picture | DeletedPicture, @@ -106,7 +106,9 @@ export const normaliseAssetUri = async (uri: string): Promise => { if (!targetDir) { // No writable directory โ€” extremely unlikely on a real device, but // surface a clear error rather than silently failing the upload. - throw new Error("No writable file-system directory available for image copy"); + throw new Error( + "No writable file-system directory available for image copy", + ); } const extension = (() => { // PH URIs sometimes carry an "?ext=jpg" hint; otherwise default to jpg. @@ -148,7 +150,8 @@ export enum ImagePickerError { } export const pickImage = async () => { - const cameraRollStatus = await ImagePicker.requestMediaLibraryPermissionsAsync(); + const cameraRollStatus = + await ImagePicker.requestMediaLibraryPermissionsAsync(); if (cameraRollStatus.status !== "granted") { Alert.alert( @@ -169,7 +172,7 @@ export const pickImage = async () => { throw new Error(ImagePickerError.CANCELED); } - const image = result.assets[0]; + const [image] = result.assets; if (!image) { throw new Error(ImagePickerError.NO_IMAGE); @@ -200,7 +203,7 @@ export const takeImage = async () => { throw new Error(ImagePickerError.CANCELED); } - const image = result.assets[0]; + const [image] = result.assets; if (!image) { throw new Error(ImagePickerError.NO_IMAGE); @@ -214,11 +217,19 @@ export const takeImage = async () => { * the catch site in `AddUserPhoto` can attach the failing stage to the error * payload sent to PostHog without stringly-typed magic values. */ -export type ProfileImageUploadStage = "presign" | "compress" | "upload" | "finalize"; +export type ProfileImageUploadStage = + | "presign" + | "compress" + | "upload" + | "finalize"; export class ProfileImageUploadError extends Error { readonly stage: ProfileImageUploadStage; - constructor(stage: ProfileImageUploadStage, message: string, options?: { cause?: unknown }) { + constructor( + stage: ProfileImageUploadStage, + message: string, + options?: { cause?: unknown }, + ) { super(message, options); this.name = "ProfileImageUploadError"; this.stage = stage; @@ -254,13 +265,19 @@ export const uploadProfileImage = async ( onProgress?.("presign"); const upload = await getTrcpContext() .image.signedUpload.fetch() - .catch((cause) => { - throw new ProfileImageUploadError("presign", "Failed to fetch upload descriptor", { cause }); + .catch((error) => { + throw new ProfileImageUploadError( + "presign", + "Failed to fetch upload descriptor", + { cause: error }, + ); }); onProgress?.("compress"); - const compressedImage = await compressImage(localUri).catch((cause) => { - throw new ProfileImageUploadError("compress", "compressImage failed", { cause }); + const compressedImage = await compressImage(localUri).catch((error) => { + throw new ProfileImageUploadError("compress", "compressImage failed", { + cause: error, + }); }); onProgress?.("upload"); @@ -269,12 +286,17 @@ export const uploadProfileImage = async ( uploadType: FileSystemUploadType.BINARY_CONTENT, httpMethod: upload.method, headers: upload.headers, - }).catch((cause) => { - throw new ProfileImageUploadError("upload", "uploadAsync threw", { cause }); + }).catch((error) => { + throw new ProfileImageUploadError("upload", "uploadAsync threw", { + cause: error, + }); }); if (response.status !== 200) { - throw new ProfileImageUploadError("upload", `upload PUT returned ${response.status}`); + throw new ProfileImageUploadError( + "upload", + `upload PUT returned ${response.status}`, + ); } onProgress?.("finalize"); @@ -332,12 +354,16 @@ export const shouldOfferMaestroPlaceholder = (): boolean => { * execute it even though the bundler may still ship the PNG. */ export const getMaestroPlaceholderUri = async (): Promise => { - const asset = Asset.fromModule(require("@/assets/images/maestro-placeholder.png")); + const asset = Asset.fromModule( + require("@/assets/images/maestro-placeholder.png"), + ); await asset.downloadAsync(); const localUri = asset.localUri ?? asset.uri; if (!localUri) { - throw new Error("Maestro placeholder asset has no localUri/uri after downloadAsync"); + throw new Error( + "Maestro placeholder asset has no localUri/uri after downloadAsync", + ); } // Re-use the same normaliser the real picker pipeline uses so we end up with @@ -361,7 +387,10 @@ export const showImagePickerOptions = (): Promise<{ takeImage() .then((imageUrl) => resolve(imageUrl)) .catch((error) => { - if (error instanceof Error && error.message !== ImagePickerError.CANCELED) { + if ( + error instanceof Error && + error.message !== ImagePickerError.CANCELED + ) { sendError(error); } @@ -375,7 +404,10 @@ export const showImagePickerOptions = (): Promise<{ pickImage() .then((imageUrl) => resolve(imageUrl)) .catch((error) => { - if (error instanceof Error && error.message !== ImagePickerError.CANCELED) { + if ( + error instanceof Error && + error.message !== ImagePickerError.CANCELED + ) { sendError(error); } diff --git a/apps/mobile/src/components/RadioButtons/index.tsx b/apps/mobile/src/components/RadioButtons/index.tsx index d7f139e..9c2bd09 100644 --- a/apps/mobile/src/components/RadioButtons/index.tsx +++ b/apps/mobile/src/components/RadioButtons/index.tsx @@ -1,23 +1,30 @@ +import type { OptionButtonProps } from "./styles"; + import * as React from "react"; import { Container } from "@/components/Input/styles"; -import { Text } from "@/components/Text"; -import { Content, OptionButtonProps, RadioButtonContainer, TextButton } from "./styles"; +import { Text } from "@/components/text"; + +import { Content, RadioButtonContainer, TextButton } from "./styles"; -interface RadioButtonsProps { +type RadioButtonsProps = { title: string; data: string[]; value: string; onChange: React.Dispatch>; -} +}; -interface RadioButtonProps extends OptionButtonProps { +type RadioButtonProps = { children: string; -} +} & OptionButtonProps; const RadioButton: React.FC = (props) => { return ( - + {props.children} @@ -25,7 +32,12 @@ const RadioButton: React.FC = (props) => { ); }; -export const RadioButtons: React.FC = ({ title, data, onChange, value }) => { +export const RadioButtons: React.FC = ({ + title, + data, + onChange, + value, +}) => { return ( diff --git a/apps/mobile/src/components/RadioButtons/styles.ts b/apps/mobile/src/components/RadioButtons/styles.ts index 8badbc1..79f23ed 100644 --- a/apps/mobile/src/components/RadioButtons/styles.ts +++ b/apps/mobile/src/components/RadioButtons/styles.ts @@ -1,13 +1,14 @@ -import { PressableProps } from "react-native"; +import type { PressableProps } from "react-native"; + import styled, { css } from "styled-components/native"; -import { PressableArea } from "@/components/PressableArea"; -import { Text } from "@/components/Text"; +import { PressableArea } from "@/components/pressable-area"; +import { Text } from "@/components/text"; -export interface OptionButtonProps extends PressableProps { +export type OptionButtonProps = { marked?: boolean; last?: boolean; -} +} & PressableProps; export const Content = styled.View` justify-content: space-between; @@ -18,7 +19,8 @@ export const Content = styled.View` `; export const RadioButtonContainer = styled(PressableArea)` - padding: ${(props) => props.theme.spacing[3]}px ${(props) => props.theme.spacing[4]}px; + padding: ${(props) => props.theme.spacing[3]}px + ${(props) => props.theme.spacing[4]}px; background-color: ${(props) => props.theme.colors.background}; diff --git a/apps/mobile/src/components/Slider/index.tsx b/apps/mobile/src/components/Slider/index.tsx index 31d0f8b..92bff7b 100644 --- a/apps/mobile/src/components/Slider/index.tsx +++ b/apps/mobile/src/components/Slider/index.tsx @@ -1,15 +1,28 @@ +import type { + LabelProps, + MultiSliderProps, +} from "@ptomasroos/react-native-multi-slider"; + import * as React from "react"; import { View } from "react-native"; -import MultiSlider, { LabelProps, MultiSliderProps } from "@ptomasroos/react-native-multi-slider"; + +import MultiSlider from "@ptomasroos/react-native-multi-slider"; import { useTheme } from "styled-components/native"; -import { Text } from "@/components/Text"; -import { LabelContainer, Marker, TitleContainer, Triangle, WIDTH } from "./styles"; +import { Text } from "@/components/text"; -interface TitleProps { +import { + LabelContainer, + Marker, + TitleContainer, + Triangle, + WIDTH, +} from "./styles"; + +type TitleProps = { title: string; subtitle: string; -} +}; const Title: React.FC = ({ title, subtitle }) => ( @@ -22,10 +35,10 @@ const Title: React.FC = ({ title, subtitle }) => ( ); -interface CustomLabelProps { +type CustomLabelProps = { left: number; children: string | number; -} +}; const CustomLabel: React.FC = ({ left, children }) => { // Makes the label text more optically center aligned @@ -55,6 +68,33 @@ const markerHitSlop = { const CustomMarker = () => ; +/** + * Hoisted out of `Root` so it keeps its identity between renders โ€” a component + * declared during render remounts its subtree every time the parent updates. + * `max` comes in as a prop instead of a closure variable. + */ +const CustomLabels = ({ max, ...label }: LabelProps & { max: number }) => { + const oneMarkerValue = + Number(label.oneMarkerValue) >= max ? "โˆž" : label.oneMarkerValue; + const twoMarkerValue = + Number(label.twoMarkerValue) >= max ? "โˆž" : label.twoMarkerValue; + + return ( + <> + {Number(label.oneMarkerValue) >= 0 && ( + + {oneMarkerValue} + + )} + {Number(label.twoMarkerValue) >= 0 && ( + + {twoMarkerValue} + + )} + + ); +}; + export const Root = (props: MultiSliderProps) => { const theme = useTheme(); @@ -62,9 +102,13 @@ export const Root = (props: MultiSliderProps) => { // gets claimed by the OS navigation gesture (iOS interactive pop / Android // system back) instead of the slider, sending the user back a screen. Inset // the track on both platforms so no marker sits in that edge gesture zone. - const safePadding = theme.spacing[7]; + const sliderLength = (props?.sliderLength ?? 0) - theme.spacing[7] * 2; - const sliderLength = (props?.sliderLength ?? 0) - safePadding * 2; + const max = props.max ?? 0; + const renderCustomLabels = React.useCallback( + (label: LabelProps) => , + [max], + ); const hasSecondMarker = (props.values?.length ?? 0) > 1; @@ -72,32 +116,13 @@ export const Root = (props: MultiSliderProps) => { const safeBorderStyle = { height: stroke, - width: safePadding, + width: theme.spacing[7], backgroundColor: theme.colors.border, zIndex: -1, borderTopRightRadius: theme.radii.md, borderBottomRightRadius: theme.radii.md, }; - const CustomLabels = (label: LabelProps) => { - const oneMarkerValue = - Number(label.oneMarkerValue) >= (props.max ?? 0) ? "โˆž" : label.oneMarkerValue; - - const twoMarkerValue = - Number(label.twoMarkerValue) >= (props.max ?? 0) ? "โˆž" : label.twoMarkerValue; - - return ( - <> - {Number(label.oneMarkerValue) >= 0 && ( - {oneMarkerValue} - )} - {Number(label.twoMarkerValue) >= 0 && ( - {twoMarkerValue} - )} - - ); - }; - const style = { flexDirection: "row", alignItems: "center", @@ -116,13 +141,15 @@ export const Root = (props: MultiSliderProps) => { style={[ safeBorderStyle, { - backgroundColor: hasSecondMarker ? theme.colors.border : theme.colors.primary, + backgroundColor: hasSecondMarker + ? theme.colors.border + : theme.colors.primary, }, ]} /> ` +const FallbackBlurView = styled(ContainerComponent).attrs( + getProps, +)` background-color: ${(props) => { if (Platform.OS === "android") return props.theme.colors.background; - return Color(props.theme.colors.background).alpha(0.5).string(); + return new Color(props.theme.colors.background).alpha(0.5).string(); }}; `; @@ -37,7 +49,7 @@ export const TransparentAndroidDarkBlurView = styled(ContainerComponent).attrs({ })` background-color: ${(props) => { if (Platform.OS === "android") return "#00000090"; - return Color(props.theme.colors.black).alpha(0.5).string(); + return new Color(props.theme.colors.black).alpha(0.5).string(); }}; `; @@ -54,7 +66,8 @@ let cachedGlassAvailable: boolean | undefined; export const isLiquidGlassAvailableSafe = (): boolean => { if (cachedGlassAvailable === undefined) { try { - cachedGlassAvailable = isLiquidGlassAvailable() && isGlassEffectAPIAvailable(); + cachedGlassAvailable = + isLiquidGlassAvailable() && isGlassEffectAPIAvailable(); } catch { cachedGlassAvailable = false; } @@ -84,8 +97,8 @@ export const BlurView = React.forwardRef((props, ref) => { if (isLiquidGlassAvailableSafe()) { return ( ( - (props, ref) => { - const theme = useTheme(); +export const TransparentGlassOrDarkBlurView = React.forwardRef< + View, + BlurViewProps +>((props, ref) => { + const theme = useTheme(); - return isLiquidGlassAvailableSafe() ? ( - - ) : ( - - ); - }, -); + return isLiquidGlassAvailableSafe() ? ( + + ) : ( + + ); +}); TransparentGlassOrDarkBlurView.displayName = "TransparentGlassOrDarkBlurView"; diff --git a/apps/mobile/src/components/CustomBackdrop.tsx b/apps/mobile/src/components/custom-backdrop.tsx similarity index 51% rename from apps/mobile/src/components/CustomBackdrop.tsx rename to apps/mobile/src/components/custom-backdrop.tsx index 90724ac..744d737 100644 --- a/apps/mobile/src/components/CustomBackdrop.tsx +++ b/apps/mobile/src/components/custom-backdrop.tsx @@ -1,21 +1,36 @@ +import type { BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; + import React from "react"; import { Pressable, StyleSheet, useWindowDimensions } from "react-native"; -import Animated, { Extrapolation, interpolate, useAnimatedStyle } from "react-native-reanimated"; -import { BottomSheetBackdropProps, useBottomSheetModal } from "@gorhom/bottom-sheet"; -const CustomBackdrop = ({ style, animatedPosition }: BottomSheetBackdropProps) => { +import { useBottomSheetModal } from "@gorhom/bottom-sheet"; +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, +} from "react-native-reanimated"; + +const CustomBackdrop = ({ + style, + animatedPosition, +}: BottomSheetBackdropProps) => { const { height } = useWindowDimensions(); const { dismissAll } = useBottomSheetModal(); const containerAnimatedStyle = useAnimatedStyle(() => { "worklet"; return { - opacity: interpolate(animatedPosition.value, [0, height], [0.9, 0], Extrapolation.CLAMP), + opacity: interpolate( + animatedPosition.value, + [0, height], + [0.9, 0], + Extrapolation.CLAMP, + ), }; }); return ( diff --git a/apps/mobile/src/components/Divider.ts b/apps/mobile/src/components/divider.ts similarity index 100% rename from apps/mobile/src/components/Divider.ts rename to apps/mobile/src/components/divider.ts diff --git a/apps/mobile/src/components/ImageBackground.tsx b/apps/mobile/src/components/image-background.tsx similarity index 57% rename from apps/mobile/src/components/ImageBackground.tsx rename to apps/mobile/src/components/image-background.tsx index 3fb4e4d..f81311c 100644 --- a/apps/mobile/src/components/ImageBackground.tsx +++ b/apps/mobile/src/components/image-background.tsx @@ -1,7 +1,6 @@ -import { - ImageBackground as ExpoImageBackground, - ImageBackgroundProps as ExpoImageBackgroundProps, -} from "expo-image"; +import type { ImageBackgroundProps as ExpoImageBackgroundProps } from "expo-image"; + +import { ImageBackground as ExpoImageBackground } from "expo-image"; export type ImageBackgroundProps = ExpoImageBackgroundProps; diff --git a/apps/mobile/src/components/imageProps.test.ts b/apps/mobile/src/components/image-props.test.ts similarity index 84% rename from apps/mobile/src/components/imageProps.test.ts rename to apps/mobile/src/components/image-props.test.ts index 217c4f5..3c0e0bb 100644 --- a/apps/mobile/src/components/imageProps.test.ts +++ b/apps/mobile/src/components/image-props.test.ts @@ -1,7 +1,8 @@ +import type { Image } from "./image"; + import type { ComponentProps } from "react"; -import type { Image } from "./Image"; -import { resolveImagePresentationProps } from "./imageProps"; +import { resolveImagePresentationProps } from "./image-props"; const blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"; const uri = "https://images.pegada.app/luna.webp"; @@ -19,7 +20,7 @@ const forwardedProps = { } satisfies ComponentProps; test("moves an API blurhash onto the placeholder and keeps the source clean", () => { - expect(resolveImagePresentationProps(forwardedProps)).toEqual({ + expect(resolveImagePresentationProps(forwardedProps)).toStrictEqual({ source: { uri }, placeholder: { blurhash }, contentFit: "contain", @@ -29,7 +30,9 @@ test("moves an API blurhash onto the placeholder and keeps the source clean", () }); test("defaults the blurhash fit to cover when the caller sets no contentFit", () => { - expect(resolveImagePresentationProps({ source: { uri, blurhash } })).toEqual({ + expect( + resolveImagePresentationProps({ source: { uri, blurhash } }), + ).toStrictEqual({ source: { uri }, placeholder: { blurhash }, contentFit: undefined, @@ -48,7 +51,7 @@ test("leaves a caller-provided placeholder and cache policy alone", () => { placeholderContentFit: "scale-down", cachePolicy: "disk", }), - ).toEqual({ + ).toStrictEqual({ source: { uri }, placeholder, contentFit: undefined, @@ -64,7 +67,7 @@ test("treats an explicit null placeholder and cache policy as opting out", () => placeholder: null, cachePolicy: null, }), - ).toEqual({ + ).toStrictEqual({ source: { uri }, placeholder: null, contentFit: undefined, @@ -76,7 +79,7 @@ test("treats an explicit null placeholder and cache policy as opting out", () => test.each([42, "https://images.pegada.app/static.webp", null] as const)( "passes a %p source straight through", (source) => { - expect(resolveImagePresentationProps({ source })).toEqual({ + expect(resolveImagePresentationProps({ source })).toStrictEqual({ source, placeholder: undefined, contentFit: undefined, @@ -92,7 +95,7 @@ test("passes a responsive source array straight through", () => { { uri: "https://images.pegada.app/luna-large.webp", width: 1280 }, ]; - expect(resolveImagePresentationProps({ source })).toEqual({ + expect(resolveImagePresentationProps({ source })).toStrictEqual({ source, placeholder: undefined, contentFit: undefined, diff --git a/apps/mobile/src/components/imageProps.ts b/apps/mobile/src/components/image-props.ts similarity index 81% rename from apps/mobile/src/components/imageProps.ts rename to apps/mobile/src/components/image-props.ts index a233be1..7262388 100644 --- a/apps/mobile/src/components/imageProps.ts +++ b/apps/mobile/src/components/image-props.ts @@ -4,7 +4,11 @@ const DEFAULT_CACHE_POLICY = "memory-disk" as const; type ImagePresentationProps = Pick< ImageProps, - "cachePolicy" | "contentFit" | "placeholder" | "placeholderContentFit" | "source" + | "cachePolicy" + | "contentFit" + | "placeholder" + | "placeholderContentFit" + | "source" >; const isBlurhashSource = ( @@ -34,7 +38,8 @@ export const resolveImagePresentationProps = ({ placeholder, contentFit, placeholderContentFit, - cachePolicy: cachePolicy === undefined ? DEFAULT_CACHE_POLICY : cachePolicy, + cachePolicy: + cachePolicy === undefined ? DEFAULT_CACHE_POLICY : cachePolicy, }; } @@ -46,7 +51,8 @@ export const resolveImagePresentationProps = ({ placeholder: usesSourceBlurhash ? { blurhash } : placeholder, contentFit, placeholderContentFit: - placeholderContentFit ?? (usesSourceBlurhash ? (contentFit ?? "cover") : undefined), + placeholderContentFit ?? + (usesSourceBlurhash ? (contentFit ?? "cover") : undefined), cachePolicy: cachePolicy === undefined ? DEFAULT_CACHE_POLICY : cachePolicy, }; }; diff --git a/apps/mobile/src/components/Image.test.tsx b/apps/mobile/src/components/image.test.tsx similarity index 81% rename from apps/mobile/src/components/Image.test.tsx rename to apps/mobile/src/components/image.test.tsx index e9e33ad..f65104d 100644 --- a/apps/mobile/src/components/Image.test.tsx +++ b/apps/mobile/src/components/image.test.tsx @@ -1,11 +1,15 @@ import { renderToStaticMarkup } from "react-dom/server"; + import { Image as ExpoImage } from "expo-image"; -import { Image } from "./Image"; +import { Image } from "./image"; -jest.mock("expo-image", () => ({ Image: jest.fn(() => null) })); +// `Partial<...>`: the factory replaces the one export this suite renders. +jest.mock>("expo-image", () => ({ + Image: jest.fn(() => null) as unknown as typeof import("expo-image").Image, +})); -const expoImage = ExpoImage as unknown as jest.Mock; +const expoImage = jest.mocked(ExpoImage); const blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"; const uri = "https://images.pegada.app/luna.webp"; diff --git a/apps/mobile/src/components/Image.tsx b/apps/mobile/src/components/image.tsx similarity index 71% rename from apps/mobile/src/components/Image.tsx rename to apps/mobile/src/components/image.tsx index 8d2ce7d..3d038c5 100644 --- a/apps/mobile/src/components/Image.tsx +++ b/apps/mobile/src/components/image.tsx @@ -1,7 +1,8 @@ import { forwardRef, type ComponentRef } from "react"; + import { Image as ExpoImage, type ImageProps } from "expo-image"; -import { resolveImagePresentationProps } from "./imageProps"; +import { resolveImagePresentationProps } from "./image-props"; export type LocalImageProps = ImageProps; @@ -12,8 +13,21 @@ export type LocalImageProps = ImageProps; * would require manually separating every current and future Image prop from * View props, which is how options such as contentFit and onDisplay were lost. */ -export const Image = forwardRef, LocalImageProps>( - ({ source, placeholder, contentFit, placeholderContentFit, cachePolicy, ...props }, ref) => { +export const Image = forwardRef< + ComponentRef, + LocalImageProps +>( + ( + { + source, + placeholder, + contentFit, + placeholderContentFit, + cachePolicy, + ...props + }, + ref, + ) => { const presentationProps = resolveImagePresentationProps({ source, placeholder, diff --git a/apps/mobile/src/components/layout.ts b/apps/mobile/src/components/layout.ts new file mode 100644 index 0000000..b2919d3 --- /dev/null +++ b/apps/mobile/src/components/layout.ts @@ -0,0 +1,25 @@ +import { KeyboardAvoidingView } from "react-native"; + +import styled from "styled-components/native"; + +/** + * The layout primitives that were being written as inline style objects in + * every form screen. `react-native/no-inline-styles` is on, and repeating + * `styled.View\`flex: 1\`` in six `styles.ts` files is worse than naming them + * once. + */ + +/** Takes all the space its parent gives it. */ +export const Fill = styled.View` + flex: 1; +`; + +/** Lays its children out left to right. */ +export const Row = styled.View` + flex-direction: row; +`; + +/** A screen root that grows to the space left over by the keyboard. */ +export const KeyboardScreen = styled(KeyboardAvoidingView)` + flex-grow: 1; +`; diff --git a/apps/mobile/src/components/Loading.ts b/apps/mobile/src/components/loading.ts similarity index 93% rename from apps/mobile/src/components/Loading.ts rename to apps/mobile/src/components/loading.ts index fdc237b..4c5737f 100644 --- a/apps/mobile/src/components/Loading.ts +++ b/apps/mobile/src/components/loading.ts @@ -1,9 +1,9 @@ import LottieView from "lottie-react-native"; import styled from "styled-components/native"; -interface LoadingProps { +type LoadingProps = { inverse?: boolean; -} +}; export default styled(LottieView).attrs((props) => ({ autoPlay: true, diff --git a/apps/mobile/src/components/PressableArea.tsx b/apps/mobile/src/components/pressable-area.tsx similarity index 82% rename from apps/mobile/src/components/PressableArea.tsx rename to apps/mobile/src/components/pressable-area.tsx index 49fd34d..129a972 100644 --- a/apps/mobile/src/components/PressableArea.tsx +++ b/apps/mobile/src/components/pressable-area.tsx @@ -1,5 +1,7 @@ +import type { PressableProps } from "react-native"; + import * as React from "react"; -import { Pressable, PressableProps } from "react-native"; +import { Pressable } from "react-native"; const ACTIVE_OPACITY = 0.9; export const PressableArea: React.FC = ({ style, ...rest }) => { diff --git a/apps/mobile/src/components/Text.ts b/apps/mobile/src/components/text.ts similarity index 81% rename from apps/mobile/src/components/Text.ts rename to apps/mobile/src/components/text.ts index 5c5b253..5a9e3b8 100644 --- a/apps/mobile/src/components/Text.ts +++ b/apps/mobile/src/components/text.ts @@ -1,10 +1,12 @@ -import styled, { css, DefaultTheme as DefaultThemeProps } from "styled-components/native"; +import type { DefaultTheme as DefaultThemeProps } from "styled-components/native"; -export interface TextProps { +import styled, { css } from "styled-components/native"; + +export type TextProps = { fontSize?: keyof DefaultThemeProps["typography"]["sizes"]; color?: keyof DefaultThemeProps["colors"]; fontWeight?: keyof DefaultThemeProps["typography"]["fontFamily"]; -} +}; export const Text = styled.Text` color: ${(props) => { diff --git a/apps/mobile/src/config.ts b/apps/mobile/src/config.ts index 07e2d9e..7326a32 100644 --- a/apps/mobile/src/config.ts +++ b/apps/mobile/src/config.ts @@ -1,12 +1,13 @@ // https://github.com/uuidjs/uuid#getrandomvalues-not-supported import "react-native-get-random-values"; - import { LogBox, Text } from "react-native"; -import mobileAds, { MaxAdContentRating } from "react-native-google-mobile-ads"; + import * as Updates from "expo-updates"; +import mobileAds, { MaxAdContentRating } from "react-native-google-mobile-ads"; + import { config } from "@/services/config"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; import { posthog } from "@/services/posthog"; mobileAds() @@ -21,11 +22,11 @@ mobileAds() mobileAds().initialize().catch(sendError); -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -Text.defaultProps = Text.defaultProps || {}; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore +// oxlint-disable-next-line typescript/ban-ts-comment -- React Native's Text.defaultProps is untyped but is the only way to set app-wide font scaling. +// @ts-expect-error +Text.defaultProps ||= {}; +// oxlint-disable-next-line typescript/ban-ts-comment -- React Native's Text.defaultProps is untyped but is the only way to set app-wide font scaling. +// @ts-expect-error Text.defaultProps.allowFontScaling = false; // Not helpful as there is nothing I can do about them @@ -37,9 +38,10 @@ LogBox.ignoreLogs([ // Attach env + release to every event (analytics and errors), the way // Bugsnag's releaseStage / metadata used to. codeBundleId ties errors to // the exact OTA update group. -const manifest = Updates.manifest; +const { manifest } = Updates; const metadata = "metadata" in manifest ? manifest.metadata : undefined; -const updateGroup = metadata && "updateGroup" in metadata ? metadata.updateGroup : undefined; +const updateGroup = + metadata && "updateGroup" in metadata ? metadata.updateGroup : undefined; posthog.register({ environment: config.ENV, diff --git a/apps/mobile/src/contexts/ThemeProvider.tsx b/apps/mobile/src/contexts/theme-provider.tsx similarity index 86% rename from apps/mobile/src/contexts/ThemeProvider.tsx rename to apps/mobile/src/contexts/theme-provider.tsx index f692fcc..e65f0b3 100644 --- a/apps/mobile/src/contexts/ThemeProvider.tsx +++ b/apps/mobile/src/contexts/theme-provider.tsx @@ -1,21 +1,25 @@ +import type { StorageDataTypes } from "@/services/storage"; + +import type { ColorSchemeName } from "react-native"; + import { useContext, useEffect, useMemo, useState } from "react"; import * as React from "react"; -import { Appearance, ColorSchemeName, Platform, Settings, useColorScheme } from "react-native"; +import { Appearance, Platform, Settings, useColorScheme } from "react-native"; + +import * as SystemUI from "expo-system-ui"; + +import { DarkTheme, LightTheme } from "@pegada/shared/themes/themes"; import { DarkTheme as NavigationDarkTheme, DefaultTheme as NavigationLightTheme, ThemeProvider as NavigationThemeProvider, } from "@react-navigation/native"; -import * as SystemUI from "expo-system-ui"; import { ThemeProvider as StyledThemeProvider } from "styled-components/native"; -import { DarkTheme, LightTheme } from "@pegada/shared/themes/themes"; - -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; import { deleteData, getData, - StorageDataTypes, StorageKeys, storeData, Theme, @@ -33,12 +37,12 @@ export type ActiveTheme = StorageDataTypes[StorageKeys.Theme] | null; // (see app/_layout.tsx), so the first visible frame already uses the right // theme instead of painting the system one and then flipping โ€” the // white/dark "blink" at boot. -export const storedThemePromise: Promise = getData(StorageKeys.Theme).catch( - (error) => { - sendError(error); - return null; - }, -); +export const storedThemePromise: Promise = getData( + StorageKeys.Theme, +).catch((error) => { + sendError(error); + return null; +}); // Mirrors the forced theme into iOS UserDefaults so the native layer can // apply it to the window BEFORE the splash screen renders on the next cold @@ -63,7 +67,9 @@ export const useActiveTheme = () => { return context; }; -export const ThemeProvider: React.FC<{ children: React.ReactElement }> = ({ children }) => { +export const ThemeProvider: React.FC<{ children: React.ReactElement }> = ({ + children, +}) => { const colorScheme = useColorScheme(); const [activeTheme, setActiveTheme] = useState(null); @@ -71,7 +77,8 @@ export const ThemeProvider: React.FC<{ children: React.ReactElement }> = ({ chil useEffect(() => { const applyStoredTheme = async () => { const storedTheme = await storedThemePromise; - if (storedTheme) Appearance.setColorScheme(storedTheme as ColorSchemeName); + if (storedTheme) + Appearance.setColorScheme(storedTheme as ColorSchemeName); persistNativeThemeOverride(storedTheme); setActiveTheme(storedTheme); }; @@ -114,7 +121,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactElement }> = ({ chil }; }, [theme]); - const handleActiveThemeChange = async (theme: ActiveTheme) => { + const handleActiveThemeChange = (theme: ActiveTheme) => { if (theme) Appearance.setColorScheme(theme as ColorSchemeName); persistNativeThemeOverride(theme); setActiveTheme(theme); @@ -123,13 +130,13 @@ export const ThemeProvider: React.FC<{ children: React.ReactElement }> = ({ chil return storeData(StorageKeys.Theme, theme); }; + const themeContextValue = useMemo( + () => ({ activeTheme, setActiveTheme: handleActiveThemeChange }), + [activeTheme], + ); + return ( - + {children} diff --git a/apps/mobile/src/contexts/trcpContext.tsx b/apps/mobile/src/contexts/trcp-context.tsx similarity index 84% rename from apps/mobile/src/contexts/trcpContext.tsx rename to apps/mobile/src/contexts/trcp-context.tsx index 84042d6..2925ada 100644 --- a/apps/mobile/src/contexts/trcpContext.tsx +++ b/apps/mobile/src/contexts/trcp-context.tsx @@ -1,4 +1,4 @@ -import { api } from "./TRPCProvider"; +import type { api } from "./trpc-provider"; let trcpContext = undefined as unknown as ReturnType; diff --git a/apps/mobile/src/contexts/TRPCProvider.tsx b/apps/mobile/src/contexts/trpc-provider.tsx similarity index 91% rename from apps/mobile/src/contexts/TRPCProvider.tsx rename to apps/mobile/src/contexts/trpc-provider.tsx index 846e5d1..054786a 100644 --- a/apps/mobile/src/contexts/TRPCProvider.tsx +++ b/apps/mobile/src/contexts/trpc-provider.tsx @@ -1,20 +1,22 @@ +import type { AppRouter } from "@pegada/api"; + import { useEffect } from "react"; import * as React from "react"; import { Alert } from "react-native"; + import Constants from "expo-constants"; + +import { RequestHeaders } from "@pegada/shared/types/types"; import { QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { createTRPCReact } from "@trpc/react-query"; import superjson from "superjson"; -import type { AppRouter } from "@pegada/api"; -import { RequestHeaders } from "@pegada/shared/types/types"; - -import { setTrcpContext } from "@/contexts/trcpContext"; +import { setTrcpContext } from "@/contexts/trcp-context"; import i18n from "@/i18n"; import { config } from "@/services/config"; import { logout } from "@/services/logout"; -import { queryClient } from "@/services/queryClient"; +import { queryClient } from "@/services/query-client"; import { getData, StorageKeys } from "@/services/storage"; export { type RouterInputs, type RouterOutputs } from "@pegada/api"; @@ -35,7 +37,7 @@ type ResponseJSON = { export const trpcQueryClient = api.createClient({ links: [ httpBatchLink({ - url: config.API_URL + "/trpc", + url: `${config.API_URL}/trpc`, transformer: superjson, headers: async () => { const headers = new Map(); @@ -77,7 +79,10 @@ export const trpcQueryClient = api.createClient({ }); if (unauthorized) { - Alert.alert(i18n.t("session.expired"), i18n.t("session.expiredMessage")); + Alert.alert( + i18n.t("session.expired"), + i18n.t("session.expiredMessage"), + ); throw logout(); } } @@ -85,7 +90,7 @@ export const trpcQueryClient = api.createClient({ return { ...res, // Already decoded here - json: async () => responsesJSON, + json: () => Promise.resolve(responsesJSON), }; }, }), diff --git a/apps/mobile/src/hooks/useCurrentCityText.tsx b/apps/mobile/src/hooks/use-current-city-text.tsx similarity index 80% rename from apps/mobile/src/hooks/useCurrentCityText.tsx rename to apps/mobile/src/hooks/use-current-city-text.tsx index 7c52489..425ef36 100644 --- a/apps/mobile/src/hooks/useCurrentCityText.tsx +++ b/apps/mobile/src/hooks/use-current-city-text.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { api } from "@/contexts/TRPCProvider"; +import { api } from "@/contexts/trpc-provider"; export const useCurrentCityText = () => { const { t } = useTranslation(); @@ -11,7 +11,9 @@ export const useCurrentCityText = () => { const hasLatLng = myDog?.user?.latitude && myDog.user?.longitude; - const currentCityFallback = hasLatLng ? t("common.nearYou") : t("common.unknown"); + const currentCityFallback = hasLatLng + ? t("common.nearYou") + : t("common.unknown"); const currentCityText = myDog?.user?.city ?? currentCityFallback; diff --git a/apps/mobile/src/hooks/useDelayedHeaderHeight.tsx b/apps/mobile/src/hooks/use-delayed-header-height.tsx similarity index 99% rename from apps/mobile/src/hooks/useDelayedHeaderHeight.tsx rename to apps/mobile/src/hooks/use-delayed-header-height.tsx index 45a2c6e..e50d193 100644 --- a/apps/mobile/src/hooks/useDelayedHeaderHeight.tsx +++ b/apps/mobile/src/hooks/use-delayed-header-height.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; + import { useHeaderHeight } from "@react-navigation/elements"; /** diff --git a/apps/mobile/src/hooks/useKeyboardAwareSafeAreaInsets.tsx b/apps/mobile/src/hooks/use-keyboard-aware-safe-area-insets.tsx similarity index 71% rename from apps/mobile/src/hooks/useKeyboardAwareSafeAreaInsets.tsx rename to apps/mobile/src/hooks/use-keyboard-aware-safe-area-insets.tsx index 009fb89..7e4aab5 100644 --- a/apps/mobile/src/hooks/useKeyboardAwareSafeAreaInsets.tsx +++ b/apps/mobile/src/hooks/use-keyboard-aware-safe-area-insets.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { Keyboard } from "react-native"; + import { useSafeAreaInsets } from "react-native-safe-area-context"; export const useKeyboardAwareSafeAreaInsets = () => { @@ -7,11 +8,13 @@ export const useKeyboardAwareSafeAreaInsets = () => { const [keyboardOpen, setKeyboardOpen] = useState(false); useEffect(() => { - const keyboardDidShowListener = Keyboard.addListener("keyboardWillShow", () => - setKeyboardOpen(true), + const keyboardDidShowListener = Keyboard.addListener( + "keyboardWillShow", + () => setKeyboardOpen(true), ); - const keyboardDidHideListener = Keyboard.addListener("keyboardWillHide", () => - setKeyboardOpen(false), + const keyboardDidHideListener = Keyboard.addListener( + "keyboardWillHide", + () => setKeyboardOpen(false), ); return () => { diff --git a/apps/mobile/src/hooks/usePayments.tsx b/apps/mobile/src/hooks/use-payments.tsx similarity index 91% rename from apps/mobile/src/hooks/usePayments.tsx rename to apps/mobile/src/hooks/use-payments.tsx index c0eae1a..601928a 100644 --- a/apps/mobile/src/hooks/usePayments.tsx +++ b/apps/mobile/src/hooks/use-payments.tsx @@ -1,10 +1,12 @@ +import type { CustomerInfo, PurchasesPackage } from "react-native-purchases"; + import { useEffect } from "react"; -import { CustomerInfo, PurchasesPackage } from "react-native-purchases"; + import { useSuspenseQuery } from "@tanstack/react-query"; -import { identifyUser } from "@/services/getInitialRouteName"; +import { identifyUser } from "@/services/get-initial-route-name"; import { PaymentCacheKey, payments, UserPlan } from "@/services/payments"; -import { queryClient } from "@/services/queryClient"; +import { queryClient } from "@/services/query-client"; // This is only called once const usePaymentsLogin = () => { @@ -107,7 +109,7 @@ payments.init(); // Guarded so that a misbehaving / unconfigured RevenueCat (e.g. stub API key) // can't take down the rest of the app at module-import time. try { - payments.addCustomerInfoUpdateListener(async (customerInfo: CustomerInfo) => { + payments.addCustomerInfoUpdateListener((customerInfo: CustomerInfo) => { queryClient.setQueryData([PaymentCacheKey.CustomerInfo], customerInfo); }); } catch { diff --git a/apps/mobile/src/hooks/useProtectedRoute.ts b/apps/mobile/src/hooks/use-protected-route.ts similarity index 92% rename from apps/mobile/src/hooks/useProtectedRoute.ts rename to apps/mobile/src/hooks/use-protected-route.ts index 4b11fb0..19d6645 100644 --- a/apps/mobile/src/hooks/useProtectedRoute.ts +++ b/apps/mobile/src/hooks/use-protected-route.ts @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; + import { useSegments } from "expo-router"; -import { getInitialRouteName } from "@/services/getInitialRouteName"; +import { getInitialRouteName } from "@/services/get-initial-route-name"; type InitialRoute = Awaited>; diff --git a/apps/mobile/src/hooks/useTrackScreens.ts b/apps/mobile/src/hooks/use-track-screens.ts similarity index 99% rename from apps/mobile/src/hooks/useTrackScreens.ts rename to apps/mobile/src/hooks/use-track-screens.ts index 4390851..bb5659f 100644 --- a/apps/mobile/src/hooks/useTrackScreens.ts +++ b/apps/mobile/src/hooks/use-track-screens.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; + import { usePathname } from "expo-router"; import { analytics } from "@/services/analytics"; diff --git a/apps/mobile/src/hooks/useWarmUpBrowser.tsx b/apps/mobile/src/hooks/use-warm-up-browser.tsx similarity index 84% rename from apps/mobile/src/hooks/useWarmUpBrowser.tsx rename to apps/mobile/src/hooks/use-warm-up-browser.tsx index 5d32ac3..25440cb 100644 --- a/apps/mobile/src/hooks/useWarmUpBrowser.tsx +++ b/apps/mobile/src/hooks/use-warm-up-browser.tsx @@ -1,7 +1,7 @@ import { useFocusEffect } from "expo-router"; import * as WebBrowser from "expo-web-browser"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; export const useWarmUpBrowser = () => { useFocusEffect(() => { diff --git a/apps/mobile/src/i18n.ts b/apps/mobile/src/i18n.ts index ec44a57..88f0c4b 100644 --- a/apps/mobile/src/i18n.ts +++ b/apps/mobile/src/i18n.ts @@ -1,10 +1,14 @@ +import type { LanguageDetectorAsyncModule } from "i18next"; + import { getLocales } from "expo-localization"; -import i18n, { LanguageDetectorAsyncModule } from "i18next"; -import { initReactI18next } from "react-i18next"; +import i18n from "i18next"; + +export { default } from "i18next"; import { initI18n } from "@pegada/shared/i18n/i18n"; +import { initReactI18next } from "react-i18next"; -import { sendError } from "./services/errorTracking"; +import { sendError } from "./services/error-tracking"; import { getData, StorageKeys, storeData } from "./services/storage"; export const getSystemLanguage = () => { @@ -39,5 +43,3 @@ const languageDetector: LanguageDetectorAsyncModule = { }; initI18n(i18n.use(languageDetector).use(initReactI18next)).catch(sendError); - -export default i18n; diff --git a/apps/mobile/src/services/advertisement/interstitial.ts b/apps/mobile/src/services/advertisement/interstitial.ts index ca83536..b8ac900 100644 --- a/apps/mobile/src/services/advertisement/interstitial.ts +++ b/apps/mobile/src/services/advertisement/interstitial.ts @@ -1,10 +1,15 @@ import { useEffect } from "react"; import { Platform } from "react-native"; -import { AdEventType, InterstitialAd, TestIds } from "react-native-google-mobile-ads"; -import { useUnsafeIsPremium } from "@/hooks/usePayments"; +import { + AdEventType, + InterstitialAd, + TestIds, +} from "react-native-google-mobile-ads"; + +import { useUnsafeIsPremium } from "@/hooks/use-payments"; import { analytics } from "@/services/analytics"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; const DEFAULT_AD_KEYWORDS = ["dog", "animals", "pets", "puppies"]; @@ -56,7 +61,10 @@ export const createForAdRequestTracked = ( const adLoadedPromise = waitForEvent(AdEventType.LOADED); // Used to catch errors before the AD is loaded - const removeErrorListener = interstitial.addAdEventListener(AdEventType.ERROR, sendError); + const removeErrorListener = interstitial.addAdEventListener( + AdEventType.ERROR, + sendError, + ); const safeLoadAndShow = async () => { try { @@ -72,8 +80,8 @@ export const createForAdRequestTracked = ( await interstitial.show(); await adClosedPromise; - } catch (err) { - sendError(err); + } catch (error) { + sendError(error); } finally { interstitial.removeAllListeners(); } @@ -107,7 +115,10 @@ export const useForAdRequestTracked: typeof createForAdRequestTracked = ( interstitialAdIds, keywords, ) => { - const result = useCreateFreeOnlyForAdRequestTracked(interstitialAdIds, keywords); + const result = useCreateFreeOnlyForAdRequestTracked( + interstitialAdIds, + keywords, + ); useEffect(() => { result.interstitial.load(); diff --git a/apps/mobile/src/services/appReview.tsx b/apps/mobile/src/services/app-review.tsx similarity index 91% rename from apps/mobile/src/services/appReview.tsx rename to apps/mobile/src/services/app-review.tsx index b9f5564..d617870 100644 --- a/apps/mobile/src/services/appReview.tsx +++ b/apps/mobile/src/services/app-review.tsx @@ -1,19 +1,21 @@ import { useEffect } from "react"; import * as React from "react"; import { KeyboardAvoidingView, Platform } from "react-native"; -import { magicModal, useMagicModal } from "react-native-magic-modal"; -import { magicToast } from "react-native-magic-toast"; + import * as StoreReview from "expo-store-review"; + import { useTranslation } from "react-i18next"; +import { magicModal, useMagicModal } from "react-native-magic-modal"; +import { magicToast } from "react-native-magic-toast"; import { useTheme } from "styled-components"; import styled from "styled-components/native"; import { Button } from "@/components/Button"; import { Input } from "@/components/Input"; -import { Text } from "@/components/Text"; -import { getTrcpContext } from "@/contexts/trcpContext"; +import { Text } from "@/components/text"; +import { getTrcpContext } from "@/contexts/trcp-context"; import { analytics } from "@/services/analytics"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; import { getData, StorageKeys, storeData } from "@/services/storage"; const Container = styled.View` @@ -80,7 +82,9 @@ const NotLikingTheAppModal: React.FC = () => { }; return ( - + {t("appReview.notLikingTheAppModal.title")} @@ -96,7 +100,9 @@ const NotLikingTheAppModal: React.FC = () => { placeholder={t("appReview.notLikingTheAppModal.placeholder")} /> - {t("appReview.notLikingTheAppModal.send")} + + {t("appReview.notLikingTheAppModal.send")} + @@ -140,7 +146,9 @@ const AreYouLikingTheAppModal: React.FC = () => { return ( {t("appReview.areYouLikingTheAppModal.title")} - {t("appReview.areYouLikingTheAppModal.description")} + + {t("appReview.areYouLikingTheAppModal.description")} + {t("appReview.areYouLikingTheAppModal.no")} diff --git a/apps/mobile/src/services/config.ts b/apps/mobile/src/services/config.ts index 20c0599..2171d40 100644 --- a/apps/mobile/src/services/config.ts +++ b/apps/mobile/src/services/config.ts @@ -22,15 +22,17 @@ const _config = configSchema.safeParse({ POSTHOG_API_KEY: process.env.EXPO_PUBLIC_POSTHOG_API_KEY, POSTHOG_HOST: process.env.EXPO_PUBLIC_POSTHOG_HOST, IOS_GOOGLE_MAPS_API_KEY: process.env.EXPO_PUBLIC_IOS_GOOGLE_MAPS_API_KEY, - ANDROID_GOOGLE_MAPS_API_KEY: process.env.EXPO_PUBLIC_ANDROID_GOOGLE_MAPS_API_KEY, + ANDROID_GOOGLE_MAPS_API_KEY: + process.env.EXPO_PUBLIC_ANDROID_GOOGLE_MAPS_API_KEY, REVENUE_CAT_IOS_API_KEY: process.env.EXPO_PUBLIC_REVENUE_CAT_IOS_API_KEY, - REVENUE_CAT_ANDROID_API_KEY: process.env.EXPO_PUBLIC_REVENUE_CAT_ANDROID_API_KEY, + REVENUE_CAT_ANDROID_API_KEY: + process.env.EXPO_PUBLIC_REVENUE_CAT_ANDROID_API_KEY, API_URL: process.env.EXPO_PUBLIC_API_URL, MAESTRO_E2E: process.env.EXPO_PUBLIC_MAESTRO_E2E, }); if (!_config.success) { - // eslint-disable-next-line no-console + // oxlint-disable-next-line no-console -- The process is about to throw on invalid env; this is the only way to say why. console.error("โŒ Invalid environment variables", _config.error.format()); throw new Error("Invalid environment variables."); } @@ -43,7 +45,9 @@ if (!_config.success) { * URL always hits the handler directly, regardless of the built env value. */ const normalizeApiUrl = (raw: string): string => - raw.replace(/\/+$/, "").replace(/^(https?:\/\/)pegada\.app(\/|$)/, "$1www.pegada.app$2"); + raw + .replace(/\/+$/, "") + .replace(/^(https?:\/\/)pegada\.app(\/|$)/, "$1www.pegada.app$2"); export const config = { ..._config.data, diff --git a/apps/mobile/src/services/errorTracking.ts b/apps/mobile/src/services/error-tracking.ts similarity index 50% rename from apps/mobile/src/services/errorTracking.ts rename to apps/mobile/src/services/error-tracking.ts index f6948c4..bc02436 100644 --- a/apps/mobile/src/services/errorTracking.ts +++ b/apps/mobile/src/services/error-tracking.ts @@ -1,10 +1,10 @@ import { config } from "./config"; import { posthog } from "./posthog"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// oxlint-disable-next-line typescript/no-explicit-any -- Anything can be thrown, and this is the boundary that has to accept it. export const sendError = (error: any) => { if (config.ENV === "development") { - // eslint-disable-next-line no-console + // oxlint-disable-next-line no-console -- Development-only mirror of what gets reported to PostHog. console.error(error); } else { posthog.captureException(error); diff --git a/apps/mobile/src/services/get-error.ts b/apps/mobile/src/services/get-error.ts new file mode 100644 index 0000000..e07e193 --- /dev/null +++ b/apps/mobile/src/services/get-error.ts @@ -0,0 +1,31 @@ +import { get } from "lodash"; + +type GenericClass = new (...args: never[]) => unknown; + +/** + * Recover a typed error from something the tRPC client threw. + * + * `instanceof` only works locally: over the wire the class is gone and all + * that survives is the `error_code` the server put in the payload, so both + * paths have to be checked. + */ +export const getError = < + T extends GenericClass & { + error_code: string; + }, +>( + error: unknown, + instance: T, +): InstanceType | undefined => { + if (error instanceof instance) { + return error as InstanceType; + } + + const errorCode = get(error, "data.error.error_code"); + + if (errorCode === instance.error_code) { + return get(error, "data.error") as InstanceType; + } + + return undefined; +}; diff --git a/apps/mobile/src/services/getInitialRouteName.ts b/apps/mobile/src/services/get-initial-route-name.ts similarity index 64% rename from apps/mobile/src/services/getInitialRouteName.ts rename to apps/mobile/src/services/get-initial-route-name.ts index 0f04c4b..107e282 100644 --- a/apps/mobile/src/services/getInitialRouteName.ts +++ b/apps/mobile/src/services/get-initial-route-name.ts @@ -1,18 +1,22 @@ import { Platform } from "react-native"; + import Constants from "expo-constants"; -import { getTrcpContext } from "@/contexts/trcpContext"; +import { getTrcpContext } from "@/contexts/trcp-context"; import { analytics } from "@/services/analytics"; -import { getLoggedUserID } from "@/services/getLoggedUserID"; -import { SceneName } from "@/types/SceneName"; -import { sendError } from "./errorTracking"; +import { getLoggedUserID } from "@/services/get-logged-user-id"; +import { SceneName } from "@/types/scene-name"; + +import { sendError } from "./error-tracking"; -export const identifyUser = async (props: Parameters[1]) => { +export const identifyUser = async ( + props: Parameters[1], +) => { try { const userId = await getLoggedUserID(); return analytics.identify(userId, props); - } catch (e) { - sendError(e); + } catch (error) { + sendError(error); } }; @@ -26,7 +30,8 @@ export const trackUser = () => { export const getInitialRouteName = async () => { try { - const { authenticated, forceUpdate } = await getTrcpContext().client.echo.get.query(); + const { authenticated, forceUpdate } = + await getTrcpContext().client.echo.get.query(); if (forceUpdate) { return SceneName.ForceUpdate; @@ -47,8 +52,8 @@ export const getInitialRouteName = async () => { } return SceneName.Swipe; - } catch (e) { - sendError(e); + } catch (error) { + sendError(error); return SceneName.SignIn; } }; diff --git a/apps/mobile/src/services/getLoggedUserID.ts b/apps/mobile/src/services/get-logged-user-id.ts similarity index 99% rename from apps/mobile/src/services/getLoggedUserID.ts rename to apps/mobile/src/services/get-logged-user-id.ts index 1e0305e..6ac7996 100644 --- a/apps/mobile/src/services/getLoggedUserID.ts +++ b/apps/mobile/src/services/get-logged-user-id.ts @@ -1,6 +1,5 @@ // Necessary for jwt-decode to work with react-native import "@react-native-anywhere/polyfill-base64"; - import { jwtDecode } from "jwt-decode"; import { getData, StorageKeys } from "./storage"; diff --git a/apps/mobile/src/services/getMimeType.ts b/apps/mobile/src/services/get-mime-type.ts similarity index 88% rename from apps/mobile/src/services/getMimeType.ts rename to apps/mobile/src/services/get-mime-type.ts index 30aa05c..fd697dc 100644 --- a/apps/mobile/src/services/getMimeType.ts +++ b/apps/mobile/src/services/get-mime-type.ts @@ -1,8 +1,9 @@ +import type { ImagePickerAsset } from "expo-image-picker"; + import mime from "react-native-mime-types"; -import { ImagePickerAsset } from "expo-image-picker"; const getMimeTypeFromUri = (uri: string) => { - const fileName = uri?.substring(uri.lastIndexOf("/") + 1, uri.length); + const fileName = uri?.slice(uri.lastIndexOf("/") + 1); const mimeByFileName = mime.lookup(fileName); if (mimeByFileName) { return mimeByFileName; diff --git a/apps/mobile/src/services/getPushNotificationToken.ts b/apps/mobile/src/services/get-push-notification-token.ts similarity index 66% rename from apps/mobile/src/services/getPushNotificationToken.ts rename to apps/mobile/src/services/get-push-notification-token.ts index f6ac081..32c67cd 100644 --- a/apps/mobile/src/services/getPushNotificationToken.ts +++ b/apps/mobile/src/services/get-push-notification-token.ts @@ -1,8 +1,8 @@ import { Platform } from "react-native"; + import Constants from "expo-constants"; import * as Device from "expo-device"; import * as Notifications from "expo-notifications"; -import Color from "color"; import { NOTIFICATION_ACTION, @@ -10,18 +10,20 @@ import { NOTIFICATION_CHANNEL, } from "@pegada/shared/constants/notifications"; import { LightTheme } from "@pegada/shared/themes/themes"; +import Color from "color"; -import { getTrcpContext } from "@/contexts/trcpContext"; +import { getTrcpContext } from "@/contexts/trcp-context"; import i18n from "@/i18n"; Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldShowBanner: true, - shouldShowList: true, - shouldPlaySound: false, - shouldSetBadge: false, - }), + handleNotification: () => + Promise.resolve({ + shouldShowAlert: true, + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), }); export enum NotificationTokenError { @@ -32,16 +34,19 @@ export enum NotificationTokenError { // platforms, so the user can answer straight from the notification // without opening the app. Handled in `services/linking`. const registerNotificationCategories = async () => { - await Notifications.setNotificationCategoryAsync(NOTIFICATION_CATEGORY.ChatMessage, [ - { - identifier: NOTIFICATION_ACTION.Reply, - buttonTitle: i18n.t("chat.replyAction"), - textInput: { - submitButtonTitle: i18n.t("chat.replyAction"), - placeholder: "", + await Notifications.setNotificationCategoryAsync( + NOTIFICATION_CATEGORY.ChatMessage, + [ + { + identifier: NOTIFICATION_ACTION.Reply, + buttonTitle: i18n.t("chat.replyAction"), + textInput: { + submitButtonTitle: i18n.t("chat.replyAction"), + placeholder: "", + }, }, - }, - ]); + ], + ); }; export const getPushNotificationToken = async () => { @@ -52,17 +57,20 @@ export const getPushNotificationToken = async () => { name: "default", importance: Notifications.AndroidImportance.MAX, vibrationPattern: [0, 250, 250, 250], - lightColor: Color(LightTheme.colors.primary).alpha(0.7).hex(), + lightColor: new Color(LightTheme.colors.primary).alpha(0.7).hex(), }); // Dedicated channel for chat-message pushes, matched server-side by the // same shared id (see MessageService). - await Notifications.setNotificationChannelAsync(NOTIFICATION_CHANNEL.ChatMessage, { - name: i18n.t("chat.notificationChannelName"), - importance: Notifications.AndroidImportance.MAX, - vibrationPattern: [0, 250, 250, 250], - lightColor: Color(LightTheme.colors.primary).alpha(0.7).hex(), - }); + await Notifications.setNotificationChannelAsync( + NOTIFICATION_CHANNEL.ChatMessage, + { + name: i18n.t("chat.notificationChannelName"), + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: new Color(LightTheme.colors.primary).alpha(0.7).hex(), + }, + ); } await registerNotificationCategories(); diff --git a/apps/mobile/src/services/getError.ts b/apps/mobile/src/services/getError.ts deleted file mode 100644 index 7378d06..0000000 --- a/apps/mobile/src/services/getError.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { get } from "lodash"; - -type GenericClass = new (...args: any[]) => any; -export const getError = < - T extends GenericClass & { - error_code: string; - }, ->( - error: any, - instance: T, -): InstanceType | undefined => { - if (error instanceof instance) { - return error; - } - - const errorCode = get(error, "data.error.error_code"); - - if (errorCode === instance.error_code) { - return error.data.error; - } -}; diff --git a/apps/mobile/src/services/haptics.ts b/apps/mobile/src/services/haptics.ts index 5033ce7..1c57170 100644 --- a/apps/mobile/src/services/haptics.ts +++ b/apps/mobile/src/services/haptics.ts @@ -1,7 +1,8 @@ import { Platform } from "react-native"; + import * as Haptics from "expo-haptics"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; // Every call is wrapped so a haptics failure (unsupported device, simulator, // etc.) never throws into calling code โ€” it's purely a "nice to have". diff --git a/apps/mobile/src/services/linking/handlers/initial-notification.ts b/apps/mobile/src/services/linking/handlers/initial-notification.ts new file mode 100644 index 0000000..e449f69 --- /dev/null +++ b/apps/mobile/src/services/linking/handlers/initial-notification.ts @@ -0,0 +1,15 @@ +/** + * The notification URL the app was cold-launched from, if any. + * + * Held in a module-local binding with an accessor rather than exported as a + * `let`: a re-exported mutable binding is a live view of someone else's + * variable, which is exactly the shape `import/no-mutable-exports` exists to + * stop, and callers only ever need "read it" and "clear it". + */ +let initialNotificationUrl: string | undefined; + +export const getInitialNotification = () => initialNotificationUrl; + +export const setInitialNotification = (url?: string) => { + initialNotificationUrl = url; +}; diff --git a/apps/mobile/src/services/linking/handlers/initialNotification.ts b/apps/mobile/src/services/linking/handlers/initialNotification.ts deleted file mode 100644 index 9be0ba0..0000000 --- a/apps/mobile/src/services/linking/handlers/initialNotification.ts +++ /dev/null @@ -1,5 +0,0 @@ -export let initialNotification: string | undefined; - -export const setInitialNotification = (url?: string) => { - initialNotification = url; -}; diff --git a/apps/mobile/src/services/linking/handlers/notification.ts b/apps/mobile/src/services/linking/handlers/notification.ts index d8408ab..b6e8ebc 100644 --- a/apps/mobile/src/services/linking/handlers/notification.ts +++ b/apps/mobile/src/services/linking/handlers/notification.ts @@ -1,8 +1,9 @@ -import * as Notifications from "expo-notifications"; +import type * as Notifications from "expo-notifications"; + import { router } from "expo-router"; -import { sendError } from "@/services/errorTracking"; -import { SceneName } from "@/types/SceneName"; +import { sendError } from "@/services/error-tracking"; +import { SceneName } from "@/types/scene-name"; export enum NotificationUrl { Match = "match/", @@ -19,21 +20,21 @@ const handleUnknownNotification = (url: string) => { sendError(new Error(`Unknown notification: ${url}`)); }; -const handleMatchNotification = async (matchId: string, dogId: string) => { +const handleMatchNotification = (matchId: string, dogId: string) => { return router.push({ pathname: SceneName.NewMatch, - params: { matchDogId: dogId, matchId: matchId }, + params: { matchDogId: dogId, matchId }, }); }; -const handleChatNotification = async (matchId: string, dogId: string) => { +const handleChatNotification = (matchId: string, dogId: string) => { return router.push({ pathname: `${SceneName.Chat}/[matchId]`, params: { dogId, matchId }, }); }; -export const customNotificationHandler = async (url?: string) => { +export const customNotificationHandler = (url?: string) => { if (!url) return; if (url.startsWith(NotificationUrl.Match)) { diff --git a/apps/mobile/src/services/linking/handlers/reply.test.ts b/apps/mobile/src/services/linking/handlers/reply.test.ts index bc354a9..1d07b10 100644 --- a/apps/mobile/src/services/linking/handlers/reply.test.ts +++ b/apps/mobile/src/services/linking/handlers/reply.test.ts @@ -1,23 +1,56 @@ import * as Notifications from "expo-notifications"; -import { NOTIFICATION_ACTION, NOTIFICATION_CATEGORY } from "@pegada/shared/constants/notifications"; +import { + NOTIFICATION_ACTION, + NOTIFICATION_CATEGORY, +} from "@pegada/shared/constants/notifications"; + +import { getTrcpContext } from "@/contexts/trcp-context"; +import { sendError } from "@/services/error-tracking"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { sendError } from "@/services/errorTracking"; import { getMatchIdFromUrl, handleReplyAction, isReplyAction } from "./reply"; -jest.mock("expo-notifications", () => ({ scheduleNotificationAsync: jest.fn() })); +// `Partial<...>` rather than the bare module type: these factories replace the +// one export each test touches, and typing them as the whole module would +// demand a full reimplementation of expo-notifications. +jest.mock>( + "expo-notifications", + () => ({ + scheduleNotificationAsync: jest.fn(), + }), +); // Pulled in transitively by ./notification, and untransformed by jest. -jest.mock("expo-router", () => ({ router: { push: jest.fn() } })); -jest.mock("@/contexts/trcpContext", () => ({ getTrcpContext: jest.fn() })); -jest.mock("@/services/errorTracking", () => ({ sendError: jest.fn() })); -jest.mock("@/i18n", () => ({ __esModule: true, default: { t: (key: string) => key } })); +jest.mock>("expo-router", () => ({ + router: { push: jest.fn() } as unknown as typeof import("expo-router").router, +})); +jest.mock>( + "@/contexts/trcp-context", + () => ({ + getTrcpContext: jest.fn(), + }), +); +jest.mock>( + "@/services/error-tracking", + () => ({ + sendError: jest.fn(), + }), +); +jest.mock>("@/i18n", () => ({ + __esModule: true, + default: { + t: (key: string) => key, + } as unknown as typeof import("@/i18n").default, +})); const mutate = jest.fn(); -const scheduleNotificationAsync = Notifications.scheduleNotificationAsync as jest.Mock; -const sendErrorMock = sendError as jest.Mock; +const scheduleNotificationAsync = jest.mocked( + Notifications.scheduleNotificationAsync, +); +const sendErrorMock = jest.mocked(sendError); -(getTrcpContext as jest.Mock).mockReturnValue({ client: { message: { send: { mutate } } } }); +jest.mocked(getTrcpContext).mockReturnValue({ + client: { message: { send: { mutate } } }, +} as unknown as ReturnType); const response = (overrides: { actionIdentifier?: string; @@ -31,29 +64,32 @@ const response = (overrides: { notification: { request: { content: { - categoryIdentifier: overrides.categoryIdentifier ?? NOTIFICATION_CATEGORY.ChatMessage, + categoryIdentifier: + overrides.categoryIdentifier ?? NOTIFICATION_CATEGORY.ChatMessage, data: { url: overrides.url ?? "chat/match-1/dog-2" }, }, }, }, }) as unknown as Notifications.NotificationResponse; -beforeEach(() => { - jest.clearAllMocks(); -}); - describe("isReplyAction", () => { - test("only claims a reply on the chat-message category", () => { + it("only claims a reply on the chat-message category", () => { expect(isReplyAction(response({}))).toBe(true); expect( - isReplyAction(response({ actionIdentifier: "expo.modules.notifications.actions.DEFAULT" })), + isReplyAction( + response({ + actionIdentifier: "expo.modules.notifications.actions.DEFAULT", + }), + ), ).toBe(false); - expect(isReplyAction(response({ categoryIdentifier: "match" }))).toBe(false); + expect(isReplyAction(response({ categoryIdentifier: "match" }))).toBe( + false, + ); }); }); describe("getMatchIdFromUrl", () => { - test.each([ + it.each([ ["chat/match-1/dog-2", "match-1"], ["match/match-1/dog-2", undefined], [undefined, undefined], @@ -63,14 +99,17 @@ describe("getMatchIdFromUrl", () => { }); describe("handleReplyAction", () => { - test("sends the typed text to the match the push came from", async () => { + it("sends the typed text to the match the push came from", async () => { await handleReplyAction(response({ userText: " on my way " })); - expect(mutate).toHaveBeenCalledWith({ matchId: "match-1", content: "on my way" }); + expect(mutate).toHaveBeenCalledWith({ + matchId: "match-1", + content: "on my way", + }); expect(scheduleNotificationAsync).not.toHaveBeenCalled(); }); - test.each([ + it.each([ ["empty text", { userText: " " }], ["a non-chat url", { userText: "hi", url: "match/match-1/dog-2" }], ])("reports %s instead of sending", async (_label, overrides) => { @@ -80,7 +119,7 @@ describe("handleReplyAction", () => { expect(sendErrorMock).toHaveBeenCalled(); }); - test("tells the user when the send fails, since the notification is already gone", async () => { + it("tells the user when the send fails, since the notification is already gone", async () => { const error = new Error("offline"); mutate.mockRejectedValueOnce(error); diff --git a/apps/mobile/src/services/linking/handlers/reply.ts b/apps/mobile/src/services/linking/handlers/reply.ts index 2e692a6..ecd17b7 100644 --- a/apps/mobile/src/services/linking/handlers/reply.ts +++ b/apps/mobile/src/services/linking/handlers/reply.ts @@ -1,10 +1,14 @@ import * as Notifications from "expo-notifications"; -import { NOTIFICATION_ACTION, NOTIFICATION_CATEGORY } from "@pegada/shared/constants/notifications"; +import { + NOTIFICATION_ACTION, + NOTIFICATION_CATEGORY, +} from "@pegada/shared/constants/notifications"; -import { getTrcpContext } from "@/contexts/trcpContext"; +import { getTrcpContext } from "@/contexts/trcp-context"; import i18n from "@/i18n"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; + import { getNotificationUrl, NotificationUrl } from "./notification"; // Chat-message pushes carry `chat//` in `data.url` (see @@ -46,13 +50,17 @@ const warnReplyFailed = () => * on the action) brings it to the foreground first so this can run; there is no * reliable way with expo-notifications alone to send the reply without that. */ -export const handleReplyAction = async (response: Notifications.NotificationResponse) => { +export const handleReplyAction = async ( + response: Notifications.NotificationResponse, +) => { const content = response.userText?.trim(); const url = getNotificationUrl(response); const matchId = getMatchIdFromUrl(url); if (!content || !matchId) { - sendError(new Error("Invalid reply notification: missing content or matchId")); + sendError( + new Error("Invalid reply notification: missing content or matchId"), + ); return; } diff --git a/apps/mobile/src/services/linking/index.ts b/apps/mobile/src/services/linking/index.ts index d6d4375..67b18ea 100644 --- a/apps/mobile/src/services/linking/index.ts +++ b/apps/mobile/src/services/linking/index.ts @@ -1,30 +1,50 @@ import { useEffect } from "react"; + import * as Notifications from "expo-notifications"; -import { sendError } from "@/services/errorTracking"; -import { initialNotification, setInitialNotification } from "./handlers/initialNotification"; -import { customNotificationHandler, getNotificationUrl } from "./handlers/notification"; +import { sendError } from "@/services/error-tracking"; + +import { + getInitialNotification, + setInitialNotification, +} from "./handlers/initial-notification"; +import { + customNotificationHandler, + getNotificationUrl, +} from "./handlers/notification"; import { handleReplyAction, isReplyAction } from "./handlers/reply"; export const processLinks = () => { + const initialNotification = getInitialNotification(); + if (initialNotification) { - customNotificationHandler(initialNotification).catch(sendError); + // The handler is synchronous โ€” expo-router's push is โ€” so a rejected + // promise was never the failure mode here; a thrown "Invalid notification + // url" was, and `.catch` never saw it. + try { + customNotificationHandler(initialNotification); + } catch (error) { + sendError(error); + } } setInitialNotification(undefined); // When the app is already running, and the user clicks on a notification - const notificationSubscription = Notifications.addNotificationResponseReceivedListener( - (response) => { + const notificationSubscription = + Notifications.addNotificationResponseReceivedListener((response) => { // The "Reply" action is already handled by the root listener in // `useGetInitialNotifications` - skip it here so we don't send the // message twice or navigate into the chat the user didn't tap into. if (isReplyAction(response)) return; const url = getNotificationUrl(response); - customNotificationHandler(url).catch(sendError); - }, - ); + try { + customNotificationHandler(url); + } catch (error) { + sendError(error); + } + }); return { remove: () => { @@ -47,21 +67,21 @@ export const useGetInitialNotifications = () => { // NotificationCenterManager.pendingResponses on iOS and // NotificationManager's listener replay on Android). Sending from the // listener alone silently dropped the message. - if (isReplyAction(response)) { - handleReplyAction(response).catch(sendError); - return; - } + // Returned rather than chained: the outer `.catch` below is the one + // error path, and a nested `.catch` here is a second one nobody reads. + if (isReplyAction(response)) return handleReplyAction(response); const url = getNotificationUrl(response); setInitialNotification(url); + return undefined; }) .catch(sendError); // Registered here (root, mounted for the whole app lifetime) rather // than in `processLinks`, so the "Reply" action on a chat-message push // is handled even if the user never navigates to the Swipe screen. - const notificationSubscription = Notifications.addNotificationResponseReceivedListener( - (response) => { + const notificationSubscription = + Notifications.addNotificationResponseReceivedListener((response) => { if (isReplyAction(response)) { handleReplyAction(response).catch(sendError); return; @@ -69,8 +89,7 @@ export const useGetInitialNotifications = () => { const url = getNotificationUrl(response); setInitialNotification(url); - }, - ); + }); return () => { notificationSubscription.remove(); diff --git a/apps/mobile/src/services/logout.ts b/apps/mobile/src/services/logout.ts index 0ec6442..16e7c17 100644 --- a/apps/mobile/src/services/logout.ts +++ b/apps/mobile/src/services/logout.ts @@ -1,13 +1,14 @@ import { router } from "expo-router"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; import { syncMatchesWidgetLoggedOut } from "@/services/matchesWidget"; import { payments } from "@/services/payments"; -import { queryClient } from "@/services/queryClient"; +import { queryClient } from "@/services/query-client"; import { store } from "@/store"; import { Actions } from "@/store/reducers/dogs"; -import { SceneName } from "@/types/SceneName"; -import { setInitialNotification } from "./linking/handlers/initialNotification"; +import { SceneName } from "@/types/scene-name"; + +import { setInitialNotification } from "./linking/handlers/initial-notification"; import { deleteData, StorageKeys } from "./storage"; export const logout = async () => { diff --git a/apps/mobile/src/services/maskDate.tsx b/apps/mobile/src/services/mask-date.tsx similarity index 84% rename from apps/mobile/src/services/maskDate.tsx rename to apps/mobile/src/services/mask-date.tsx index 5c22893..db3ce18 100644 --- a/apps/mobile/src/services/maskDate.tsx +++ b/apps/mobile/src/services/mask-date.tsx @@ -3,10 +3,10 @@ export const maskDate = (input: string): string => { const dateRegex = new RegExp(/^\d{0,2}\/?\d{0,2}\/?\d{0,4}$/); let maskedInput = input - .replace(/[^\d]/g, "") // Remove any non-digit characters + .replaceAll(/[^\d]/g, "") // Remove any non-digit characters .slice(0, 8); // Limit to a maximum of 8 digits - if (!dateRegex.exec(maskedInput)) { + if (!dateRegex.test(maskedInput)) { return ""; // If the input doesn't match the regex, return an empty string } diff --git a/apps/mobile/src/services/matchesWidget/avatars.ts b/apps/mobile/src/services/matchesWidget/avatars.ts index a37f73d..bd6e806 100644 --- a/apps/mobile/src/services/matchesWidget/avatars.ts +++ b/apps/mobile/src/services/matchesWidget/avatars.ts @@ -1,4 +1,5 @@ import { Platform } from "react-native"; + import { Directory, File, Paths } from "expo-file-system"; import { WIDGET_APP_GROUP } from "../../../modules/pegada-widget"; @@ -37,17 +38,25 @@ const getAvatarsDirectory = (): Directory | null => { }; // Native file APIs (UIImage/BitmapFactory) expect plain paths, not file:// URIs. -const toNativePath = (file: File) => decodeURI(file.uri).replace(/^file:\/\//, ""); +const toNativePath = (file: File) => + decodeURI(file.uri).replace(/^file:\/\//, ""); -// Tiny stable hash (djb2) so a dog changing its main photo produces a new -// cache filename, and the stale one gets swept below. +/** + * Tiny stable hash (djb2) so a dog changing its main photo produces a new + * cache filename, and the stale one gets swept below. + * + * The bitwise operators are the algorithm, not a micro-optimisation: `^` is + * what mixes each character in and `>>> 0` is what makes the result unsigned. + */ +/* oxlint-disable no-bitwise -- djb2 is defined in terms of xor and an unsigned shift */ const hash = (value: string) => { let result = 5381; for (let index = 0; index < value.length; index++) { - result = (result * 33) ^ value.charCodeAt(index); + result = (result * 33) ^ (value.codePointAt(index) ?? 0); } return (result >>> 0).toString(36); }; +/* oxlint-enable no-bitwise */ /** * Downloads (at most 3, enforced by the caller) avatars into the widget diff --git a/apps/mobile/src/services/matchesWidget/index.ts b/apps/mobile/src/services/matchesWidget/index.ts index beec0c5..5ab4837 100644 --- a/apps/mobile/src/services/matchesWidget/index.ts +++ b/apps/mobile/src/services/matchesWidget/index.ts @@ -1,12 +1,13 @@ +import type { WidgetSnapshot } from "../../../modules/pegada-widget"; import type { RouterOutputs } from "@pegada/api"; +import i18n from "@/i18n"; +import { sendError } from "@/services/error-tracking"; + import { isWidgetModuleAvailable, setWidgetSnapshot, - WidgetSnapshot, } from "../../../modules/pegada-widget"; -import i18n from "@/i18n"; -import { sendError } from "@/services/errorTracking"; import { clearWidgetAvatars, downloadWidgetAvatars } from "./avatars"; type Matches = RouterOutputs["match"]["getAll"]; @@ -44,7 +45,9 @@ export const syncMatchesWidget = async (matches: Matches): Promise => { try { const waiting = matches.filter(isWaitingForReply); - const dogsOnWidget = waiting.slice(0, MAX_WIDGET_DOGS).map((match) => match.dog); + const dogsOnWidget = waiting + .slice(0, MAX_WIDGET_DOGS) + .map((match) => match.dog); const avatarPathByDogId = await downloadWidgetAvatars( dogsOnWidget.map((dog) => ({ dogId: dog.id, url: dog.images[0]?.url })), @@ -60,7 +63,8 @@ export const syncMatchesWidget = async (matches: Matches): Promise => { // Only meaningful alongside the "waiting for reply" state: layouts // that render the count as its own numeral (MEDIUM) use this instead // of `message` so the count isn't shown twice. - messageCountless: waiting.length > 0 ? i18n.t("widget.waitingForReplyCountless") : null, + messageCountless: + waiting.length > 0 ? i18n.t("widget.waitingForReplyCountless") : null, dogs: dogsOnWidget.map((dog) => ({ name: dog.name, avatar: avatarPathByDogId.get(dog.id) ?? null, diff --git a/apps/mobile/src/services/matchesWidget/useMatchesWidgetSync.ts b/apps/mobile/src/services/matchesWidget/use-matches-widget-sync.ts similarity index 75% rename from apps/mobile/src/services/matchesWidget/useMatchesWidgetSync.ts rename to apps/mobile/src/services/matchesWidget/use-matches-widget-sync.ts index 6be2d8d..238146f 100644 --- a/apps/mobile/src/services/matchesWidget/useMatchesWidgetSync.ts +++ b/apps/mobile/src/services/matchesWidget/use-matches-widget-sync.ts @@ -1,8 +1,11 @@ +import type { AppStateStatus } from "react-native"; + import { useEffect } from "react"; -import { AppState, AppStateStatus } from "react-native"; +import { AppState } from "react-native"; + +import { api } from "@/contexts/trpc-provider"; +import { sendError } from "@/services/error-tracking"; -import { api } from "@/contexts/TRPCProvider"; -import { sendError } from "@/services/errorTracking"; import { syncMatchesWidget } from "./index"; /** @@ -42,10 +45,13 @@ export const useMatchesWidgetSync = () => { syncFromServer(); - const subscription = AppState.addEventListener("change", (status: AppStateStatus) => { - if (status === "active") syncFromServer(); - if (status === "background") syncFromCache(); - }); + const subscription = AppState.addEventListener( + "change", + (status: AppStateStatus) => { + if (status === "active") syncFromServer(); + if (status === "background") syncFromCache(); + }, + ); return () => { disposed = true; diff --git a/apps/mobile/src/services/openWebBrowser.ts b/apps/mobile/src/services/open-web-browser.ts similarity index 94% rename from apps/mobile/src/services/openWebBrowser.ts rename to apps/mobile/src/services/open-web-browser.ts index fe6643e..f8c3ec4 100644 --- a/apps/mobile/src/services/openWebBrowser.ts +++ b/apps/mobile/src/services/open-web-browser.ts @@ -16,6 +16,6 @@ import * as WebBrowser from "expo-web-browser"; * Apple recommends for Terms / Privacy hand-offs. Users dismiss with * the standard "Done" chrome on iOS or system back on Android. */ -export const openWebBrowser = async (url: string) => { +export const openWebBrowser = (url: string) => { return WebBrowser.openBrowserAsync(url); }; diff --git a/apps/mobile/src/services/payments/index.ts b/apps/mobile/src/services/payments/index.ts index 4c5de9f..852264d 100644 --- a/apps/mobile/src/services/payments/index.ts +++ b/apps/mobile/src/services/payments/index.ts @@ -1,18 +1,21 @@ -import { Alert, Platform } from "react-native"; -import Purchases, { +import type { CustomerInfo, - LOG_LEVEL, PurchasesOffering, PurchasesPackage, } from "react-native-purchases"; + +import { Alert, Platform } from "react-native"; + import * as Device from "expo-device"; + import { get } from "lodash"; +import Purchases, { LOG_LEVEL } from "react-native-purchases"; -import { getTrcpContext } from "@/contexts/trcpContext"; +import { getTrcpContext } from "@/contexts/trcp-context"; import { config } from "@/services/config"; -import { sendError } from "@/services/errorTracking"; -import { getLoggedUserID } from "@/services/getLoggedUserID"; -import { queryClient } from "@/services/queryClient"; +import { sendError } from "@/services/error-tracking"; +import { getLoggedUserID } from "@/services/get-logged-user-id"; +import { queryClient } from "@/services/query-client"; export enum PaymentCacheKey { CustomerInfo = "PAYMENT_CUSTOMER_INFO_KEY", @@ -41,7 +44,8 @@ const isStubRevenueCatKey = revenueCatApiKey === "ci-stub" || revenueCatApiKey.startsWith("ci-stub") || revenueCatApiKey.startsWith("placeholder") || - (!revenueCatApiKey.startsWith("appl_") && !revenueCatApiKey.startsWith("goog_")); + (!revenueCatApiKey.startsWith("appl_") && + !revenueCatApiKey.startsWith("goog_")); const init = () => { if (__DEV__) { @@ -65,7 +69,9 @@ const logIn = async () => { const userID = await getLoggedUserID(); if (!userID) { - throw new Error("Make sure the login is only called when the user is authenticated"); + throw new Error( + "Make sure the login is only called when the user is authenticated", + ); } if (isStubRevenueCatKey) { @@ -75,7 +81,10 @@ const logIn = async () => { try { const userData = await Purchases.logIn(userID); - queryClient.setQueryData([PaymentCacheKey.CustomerInfo], () => userData.customerInfo); + queryClient.setQueryData( + [PaymentCacheKey.CustomerInfo], + () => userData.customerInfo, + ); // Asynchronously set the email and display name getTrcpContext() @@ -354,10 +363,14 @@ const maestroMockPurchase = async (pkg: PurchasesPackage) => { }; }; -const purchasePackage = async (...props: Parameters) => { +const purchasePackage = async ( + ...props: Parameters +) => { if (isMaestroMockMode) { const [pkg] = props; - return maestroMockPurchase(pkg) as unknown as ReturnType; + return maestroMockPurchase(pkg) as unknown as ReturnType< + typeof Purchases.purchasePackage + >; } if (isIosSimulator) { @@ -373,13 +386,15 @@ const purchasePackage = async (...props: Parameters { } for (const entitlement of Object.values(Entitlement)) { - if (typeof customerInfo.entitlements.active[entitlement] !== "undefined") { + if (customerInfo.entitlements.active[entitlement] !== undefined) { return { ...customerInfo.entitlements.active[entitlement], userPlan: getPlanByEntitlement(entitlement), @@ -418,7 +433,9 @@ const restorePurchases = async () => { "Simulator Detected", "Restore is not available in the IOS simulator. Please try on a real device.", ); - throw new Error("Restore is not available in the IOS simulator. Please try on a real device."); + throw new Error( + "Restore is not available in the IOS simulator. Please try on a real device.", + ); } await Purchases.restorePurchases(); @@ -430,7 +447,7 @@ export const payments = { purchasePackage, getPlan, logIn, - restorePurchases: restorePurchases, + restorePurchases, logOut: Purchases.logOut, getCustomerInfo, addCustomerInfoUpdateListener: Purchases.addCustomerInfoUpdateListener, diff --git a/apps/mobile/src/services/queryClient.tsx b/apps/mobile/src/services/query-client.tsx similarity index 63% rename from apps/mobile/src/services/queryClient.tsx rename to apps/mobile/src/services/query-client.tsx index eb821a8..45c643a 100644 --- a/apps/mobile/src/services/queryClient.tsx +++ b/apps/mobile/src/services/query-client.tsx @@ -1,12 +1,19 @@ -import { AppState, AppStateStatus, Platform } from "react-native"; +import type { AppStateStatus } from "react-native"; + +import { AppState, Platform } from "react-native"; + import NetInfo from "@react-native-community/netinfo"; -import { focusManager, onlineManager, QueryClient } from "@tanstack/react-query"; +import { + focusManager, + onlineManager, + QueryClient, +} from "@tanstack/react-query"; export const queryClient = new QueryClient(); onlineManager.setEventListener((setOnline) => { return NetInfo.addEventListener((state) => { - setOnline(!!state.isConnected); + setOnline(Boolean(state.isConnected)); }); }); diff --git a/apps/mobile/src/services/quickActions/handlers/action.ts b/apps/mobile/src/services/quickActions/handlers/action.ts index 7cff311..d1544ae 100644 --- a/apps/mobile/src/services/quickActions/handlers/action.ts +++ b/apps/mobile/src/services/quickActions/handlers/action.ts @@ -1,8 +1,9 @@ -import * as QuickActions from "expo-quick-actions"; +import type * as QuickActions from "expo-quick-actions"; + import { router } from "expo-router"; -import { sendError } from "@/services/errorTracking"; -import { SceneName } from "@/types/SceneName"; +import { sendError } from "@/services/error-tracking"; +import { SceneName } from "@/types/scene-name"; export enum QuickActionId { Matches = "matches", @@ -11,7 +12,7 @@ export enum QuickActionId { // Holds a quick action tapped before we know whether the user is // authenticated and fully onboarded (mirrors `initialNotification` in -// `services/linking/handlers/initialNotification.ts`). Cleared once +// `services/linking/handlers/initial-notification.ts`). Cleared once // consumed so it doesn't replay on a later, unrelated auth resolution. let pendingQuickAction: QuickActions.Action | undefined; @@ -23,7 +24,9 @@ const handleUnknownQuickAction = (id: string) => { sendError(new Error(`Unknown quick action: ${id}`)); }; -export const customQuickActionHandler = (action?: QuickActions.Action | null) => { +export const customQuickActionHandler = ( + action?: QuickActions.Action | null, +) => { if (!action) return; if (action.id === QuickActionId.Matches) { diff --git a/apps/mobile/src/services/quickActions/handlers/icons.ts b/apps/mobile/src/services/quickActions/handlers/icons.ts index fc85542..3748af1 100644 --- a/apps/mobile/src/services/quickActions/handlers/icons.ts +++ b/apps/mobile/src/services/quickActions/handlers/icons.ts @@ -5,6 +5,12 @@ import { Platform } from "react-native"; // only custom drawables via its config plugin -- so we pass `undefined` // there and let the shortcut fall back to the app's own icon, which keeps // this simple without shipping extra icon assets. -export const matchesIcon = Platform.select({ ios: "symbol:message.fill", default: undefined }); +export const matchesIcon = Platform.select({ + ios: "symbol:message.fill", + default: undefined, +}); -export const editProfileIcon = Platform.select({ ios: "symbol:person.fill", default: undefined }); +export const editProfileIcon = Platform.select({ + ios: "symbol:person.fill", + default: undefined, +}); diff --git a/apps/mobile/src/services/quickActions/index.ts b/apps/mobile/src/services/quickActions/index.ts index 3f40d6a..3efb30b 100644 --- a/apps/mobile/src/services/quickActions/index.ts +++ b/apps/mobile/src/services/quickActions/index.ts @@ -1,8 +1,11 @@ import { useEffect } from "react"; + import * as QuickActions from "expo-quick-actions"; + import { useTranslation } from "react-i18next"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; + import { customQuickActionHandler, flushPendingQuickAction, @@ -27,6 +30,7 @@ export const useQuickActions = (enabled: boolean) => { useEffect(() => { // When the app is not already running, and the user taps a quick action + // oxlint-disable-next-line import/namespace -- `initial` is exported by expo-quick-actions; oxlint's resolver misses the optional-chained re-export setPendingQuickAction(QuickActions.initial); }, []); diff --git a/apps/mobile/src/services/storage.ts b/apps/mobile/src/services/storage.ts index 448aa75..a1a5533 100644 --- a/apps/mobile/src/services/storage.ts +++ b/apps/mobile/src/services/storage.ts @@ -15,15 +15,18 @@ export enum Theme { Default = "light", } -export interface StorageDataTypes { +export type StorageDataTypes = { [StorageKeys.Token]: string; [StorageKeys.Theme]: Theme; [StorageKeys.Language]: string; [StorageKeys.AppReviewRequestDate]: string; [StorageKeys.AppReviewStatus]: "completed"; -} +}; -export const storeData = async (key: T, value: StorageDataTypes[T]) => { +export const storeData = async ( + key: T, + value: StorageDataTypes[T], +) => { await AsyncStorage.setItem(key, value); return value; }; diff --git a/apps/mobile/src/services/themeUtils.ts b/apps/mobile/src/services/theme-utils.ts similarity index 53% rename from apps/mobile/src/services/themeUtils.ts rename to apps/mobile/src/services/theme-utils.ts index b93c724..1a2e572 100644 --- a/apps/mobile/src/services/themeUtils.ts +++ b/apps/mobile/src/services/theme-utils.ts @@ -1,7 +1,7 @@ -import { DefaultTheme } from "styled-components/native"; +import type { DefaultTheme } from "styled-components/native"; export const getSpacing = (spacing: keyof DefaultTheme["spacing"]) => ({ theme }: { theme: DefaultTheme }) => { - return theme.spacing[spacing] + "px"; + return `${theme.spacing[spacing]}px`; }; diff --git a/apps/mobile/src/services/use-get-formatted-years.ts b/apps/mobile/src/services/use-get-formatted-years.ts new file mode 100644 index 0000000..5d0a4f4 --- /dev/null +++ b/apps/mobile/src/services/use-get-formatted-years.ts @@ -0,0 +1,9 @@ +import { getFormattedYears } from "@pegada/shared/utils/get-formatted-years"; +import { useTranslation } from "react-i18next"; + +export const useGetFormattedYears = () => { + const { i18n } = useTranslation(); + + return (birthDate: string | Date) => + getFormattedYears({ birthDate, lng: i18n.language }); +}; diff --git a/apps/mobile/src/services/useGetFormattedYears.ts b/apps/mobile/src/services/useGetFormattedYears.ts deleted file mode 100644 index 3ed4bae..0000000 --- a/apps/mobile/src/services/useGetFormattedYears.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useTranslation } from "react-i18next"; - -import { getFormattedYears } from "@pegada/shared/utils/getFormattedYears"; - -export const useGetFormattedYears = () => { - const { i18n } = useTranslation(); - - return (birthDate: string | Date) => getFormattedYears({ birthDate, lng: i18n.language }); -}; diff --git a/apps/mobile/src/services/utils.ts b/apps/mobile/src/services/utils.ts index d14ebde..64db8f5 100644 --- a/apps/mobile/src/services/utils.ts +++ b/apps/mobile/src/services/utils.ts @@ -1,6 +1,11 @@ -import { DependencyList, EffectCallback, useEffect, useRef } from "react"; +import type { DependencyList, EffectCallback } from "react"; -export const useDidMountEffect = (func: EffectCallback, deps: DependencyList) => { +import { useEffect, useRef } from "react"; + +export const useDidMountEffect = ( + func: EffectCallback, + deps: DependencyList, +) => { const didMount = useRef(false); useEffect(() => { diff --git a/apps/mobile/src/store/reducers/dogs/index.ts b/apps/mobile/src/store/reducers/dogs/index.ts index fe41791..02a9ca1 100644 --- a/apps/mobile/src/store/reducers/dogs/index.ts +++ b/apps/mobile/src/store/reducers/dogs/index.ts @@ -1,26 +1,34 @@ +import type { AnyAction } from "redux"; +import type { Reducer } from "typesafe-actions"; + import reduceReducers from "reduce-reducers"; -import { Reducer } from "typesafe-actions"; -import * as list from "./list"; -import * as logout from "./logout"; -import * as swipe from "./swipe"; -import { initialState } from "./swipe"; +import listReducer, { Actions as listActions, ListAction } from "./list"; +import logoutReducer, { + Actions as logoutActions, + LogoutAction, +} from "./logout"; +import swipeReducer, { + Actions as swipeActions, + initialState, + SwipeAction, +} from "./swipe"; export const Types = { - ...list.ListAction, - ...swipe.SwipeAction, - ...logout.LogoutAction, + ...ListAction, + ...SwipeAction, + ...LogoutAction, }; export const Actions = { - swipe: swipe.Actions, - list: list.Actions, - logout: logout.Actions, + swipe: swipeActions, + list: listActions, + logout: logoutActions, }; export default reduceReducers( initialState, - swipe.default as Reducer, - list.default as Reducer, - logout.default as Reducer, + swipeReducer as Reducer, + listReducer as Reducer, + logoutReducer as Reducer, ); diff --git a/apps/mobile/src/store/reducers/dogs/list.ts b/apps/mobile/src/store/reducers/dogs/list.ts index b99dffd..3fc132c 100644 --- a/apps/mobile/src/store/reducers/dogs/list.ts +++ b/apps/mobile/src/store/reducers/dogs/list.ts @@ -1,7 +1,14 @@ +import type { SwipeDog } from "./swipe"; +import type { ActionType } from "typesafe-actions"; + import { produce } from "immer"; -import { ActionType, createAction, createAsyncAction, createReducer } from "typesafe-actions"; +import { + createAction, + createAsyncAction, + createReducer, +} from "typesafe-actions"; -import { initialState, SwipeDog } from "./swipe"; +import { initialState } from "./swipe"; export enum ListAction { FetchDogsRequest = "FETCH_DOGS_REQUEST", @@ -34,7 +41,10 @@ const refetchUsersRequest = (state = initialState) => return draft; }); -const fetchUsersSuccess = (state = initialState, { payload }: ActionType) => +const fetchUsersSuccess = ( + state: typeof initialState, + { payload }: ActionType, +) => produce(state, (draft) => { draft.request.loading = false; draft.request.error = undefined; @@ -50,7 +60,10 @@ const fetchUsersSuccess = (state = initialState, { payload }: ActionType) => +const fetchUsersFailure = ( + state: typeof initialState, + { payload }: ActionType, +) => produce(state, (draft) => { draft.request.loading = false; draft.request.error = payload.message; @@ -58,7 +71,9 @@ const fetchUsersFailure = (state = initialState, { payload }: ActionType>(initialState) +export default createReducer>( + initialState, +) .handleAction(Actions.request, fetchUsersRequest) .handleAction(Actions.success, fetchUsersSuccess) .handleAction(Actions.failure, fetchUsersFailure) diff --git a/apps/mobile/src/store/reducers/dogs/logout.ts b/apps/mobile/src/store/reducers/dogs/logout.ts index 1a89e1e..3f089f4 100644 --- a/apps/mobile/src/store/reducers/dogs/logout.ts +++ b/apps/mobile/src/store/reducers/dogs/logout.ts @@ -1,5 +1,7 @@ +import type { ActionType } from "typesafe-actions"; + import { produce } from "immer"; -import { ActionType, createAction, createReducer } from "typesafe-actions"; +import { createAction, createReducer } from "typesafe-actions"; import { initialState } from "@/store/reducers/dogs/swipe"; diff --git a/apps/mobile/src/store/reducers/dogs/swipe.ts b/apps/mobile/src/store/reducers/dogs/swipe.ts index 6b64190..b291656 100644 --- a/apps/mobile/src/store/reducers/dogs/swipe.ts +++ b/apps/mobile/src/store/reducers/dogs/swipe.ts @@ -1,13 +1,17 @@ -import { produce } from "immer"; -import { ActionType, createAction, createAsyncAction, createReducer } from "typesafe-actions"; - +import type { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/use-swipe-gesture"; import type { RouterOutputs } from "@pegada/api"; +import type { ActionType } from "typesafe-actions"; -import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture"; +import { produce } from "immer"; +import { + createAction, + createAsyncAction, + createReducer, +} from "typesafe-actions"; export type SwipeDog = RouterOutputs["swipe"]["all"][number]; -interface IInitialState { +type IInitialState = { request: { data: SwipeDog[]; loading: boolean; @@ -19,7 +23,7 @@ interface IInitialState { lastCardId?: string; likeLimitResetAt?: Date; }; -} +}; export const initialState: IInitialState = { request: { @@ -55,7 +59,10 @@ const clearLikeLimit = createAction(SwipeAction.ClearLikeLimit)(); export const Actions = { ...asyncActions, swipeBack, clearLikeLimit }; -const swipeUserRequest = (state = initialState, { payload }: ActionType) => +const swipeUserRequest = ( + state: typeof initialState, + { payload }: ActionType, +) => produce(state, (draft) => { const { lastCardId } = draft.config; const { data: dogs } = draft.request; @@ -64,7 +71,7 @@ const swipeUserRequest = (state = initialState, { payload }: ActionType -1) { + if (lastCardIndex !== -1) { draft.request.data.splice(lastCardIndex, 1); } @@ -81,7 +88,7 @@ const swipeUserSuccess = (state = initialState) => }); const swipeUserError = ( - state = initialState, + state: typeof initialState, { payload }: ActionType, ) => produce(state, (draft) => { @@ -107,7 +114,9 @@ const swipeBackHandler = (state = initialState) => return draft; }); -export default createReducer>(initialState) +export default createReducer>( + initialState, +) .handleAction(Actions.request, swipeUserRequest) .handleAction(Actions.swipeBack, swipeBackHandler) .handleAction(asyncActions.failure, swipeUserError) diff --git a/apps/mobile/src/store/reducers/index.ts b/apps/mobile/src/store/reducers/index.ts index 423d9b2..8dd6a98 100644 --- a/apps/mobile/src/store/reducers/index.ts +++ b/apps/mobile/src/store/reducers/index.ts @@ -1,22 +1,26 @@ +import type { initialState as swipeInitialState } from "./dogs/swipe"; + import { combineReducers } from "redux"; -import * as dogs from "./dogs"; -import * as swipe from "./dogs/swipe"; +import dogsReducer, { + Actions as dogsActions, + Types as dogsTypes, +} from "./dogs"; export const Types = { - ...dogs.Types, + ...dogsTypes, }; export const Actions = { - dogs: dogs.Actions, + dogs: dogsActions, }; const rootReducer = combineReducers({ - dogs: dogs.default, + dogs: dogsReducer, }); -export interface RootReducer { - dogs: typeof swipe.initialState; -} +export type RootReducer = { + dogs: typeof swipeInitialState; +}; export default rootReducer; diff --git a/apps/mobile/src/store/sagas/dogs/index.ts b/apps/mobile/src/store/sagas/dogs/index.ts index f4f6e5e..b7ba760 100644 --- a/apps/mobile/src/store/sagas/dogs/index.ts +++ b/apps/mobile/src/store/sagas/dogs/index.ts @@ -1,6 +1,6 @@ import { all } from "redux-saga/effects"; -import fetchUsersRequest from "./list"; -import handleSwipeUserRequest from "./swipe"; +import listSaga from "./list"; +import swipeSaga from "./swipe"; -export default all([fetchUsersRequest, handleSwipeUserRequest]); +export default all([listSaga, swipeSaga]); diff --git a/apps/mobile/src/store/sagas/dogs/list.ts b/apps/mobile/src/store/sagas/dogs/list.ts index 52cf695..bd00db6 100644 --- a/apps/mobile/src/store/sagas/dogs/list.ts +++ b/apps/mobile/src/store/sagas/dogs/list.ts @@ -1,14 +1,18 @@ +import type { RootReducer } from "@/store/reducers"; + import { all, call, put, select, takeLatest } from "redux-saga/effects"; -import { getTrcpContext } from "@/contexts/trcpContext"; +import { getTrcpContext } from "@/contexts/trcp-context"; import i18n from "@/i18n"; -import { sendError } from "@/services/errorTracking"; -import { Actions, RootReducer } from "@/store/reducers"; +import { sendError } from "@/services/error-tracking"; +import { Actions } from "@/store/reducers"; import { ListAction } from "@/store/reducers/dogs/list"; // Without marking as unknown, saga complains about the swipe all type inference export function* fetchUsersRequest(): unknown { - const dogs: RootReducer["dogs"] = yield select((state: RootReducer) => state.dogs); + const dogs: RootReducer["dogs"] = yield select( + (state: RootReducer) => state.dogs, + ); try { const response = yield call(getTrcpContext().client.swipe.all.query, { @@ -29,11 +33,11 @@ export function* fetchUsersRequest(): unknown { hasMore: response.length === dogs.config.limit, }), ); - } catch (err) { - sendError(err); + } catch (error) { + sendError(error); - const error = { message: i18n.t("common.somethingWrong") }; - yield put(Actions.dogs.list.failure(error)); + const failure = { message: i18n.t("common.somethingWrong") }; + yield put(Actions.dogs.list.failure(failure)); } } diff --git a/apps/mobile/src/store/sagas/dogs/swipe.ts b/apps/mobile/src/store/sagas/dogs/swipe.ts index 07d8f59..7520f0b 100644 --- a/apps/mobile/src/store/sagas/dogs/swipe.ts +++ b/apps/mobile/src/store/sagas/dogs/swipe.ts @@ -1,41 +1,47 @@ +import type { RootReducer } from "@/store/reducers"; +import type { ActionType } from "typesafe-actions"; + import { router } from "expo-router"; + +import { LikeLimitReachedError } from "@pegada/shared/errors/errors"; import { isBefore } from "date-fns"; import { all, call, fork, put, select, takeLatest } from "redux-saga/effects"; -import { ActionType } from "typesafe-actions"; - -import { LikeLimitReached } from "@pegada/shared/errors/errors"; import { showLikeLimitReached } from "@/components/LikeLimitReached"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { getUnsafeIsPremium } from "@/hooks/usePayments"; -import { sendError } from "@/services/errorTracking"; -import { getError } from "@/services/getError"; -import { Actions, RootReducer } from "@/store/reducers"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { getUnsafeIsPremium } from "@/hooks/use-payments"; +import { sendError } from "@/services/error-tracking"; +import { getError } from "@/services/get-error"; +import { Actions } from "@/store/reducers"; import { SwipeAction } from "@/store/reducers/dogs/swipe"; -import { SceneName } from "@/types/SceneName"; -import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture"; +import { SceneName } from "@/types/scene-name"; +import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/use-swipe-gesture"; -function* swipeUserRequest({ payload }: ActionType): any { +const swipeUserRequest = function* ({ + payload, +}: ActionType): Generator { const { id, swipeType: _swipeType } = payload; try { - const isPremium = yield call(getUnsafeIsPremium); + // redux-saga's `yield` is `unknown` to TypeScript โ€” the effect knows what it + // resolves to, the generator signature cannot. + const isPremium = (yield call(getUnsafeIsPremium)) as boolean; // If the user is not premium, check if the like limit has been reached if (!isPremium && _swipeType !== Swipe.Dislike) { - const { likeLimitResetAt }: RootReducer["dogs"]["config"] = yield select( + const { likeLimitResetAt } = (yield select( (state: RootReducer) => state.dogs.config, - ); + )) as RootReducer["dogs"]["config"]; if (likeLimitResetAt && isBefore(new Date(), likeLimitResetAt)) { - throw new LikeLimitReached({ likeLimitResetAt }); + throw new LikeLimitReachedError({ likeLimitResetAt }); } } - const response = yield call(getTrcpContext().client.swipe.swipe.mutate, { + const response = (yield call(getTrcpContext().client.swipe.swipe.mutate, { id, swipeType: _swipeType, - }); + })) as { match?: { id: string } } | undefined; if (response?.match) { router.push({ @@ -47,8 +53,8 @@ function* swipeUserRequest({ payload }: ActionType state.dogs); +const handleCardFetching = function* () { + const { request, config }: RootReducer["dogs"] = yield select( + (state: RootReducer) => state.dogs, + ); if ( request.data.length >= FETCH_THRESHOLD || @@ -76,9 +84,11 @@ function* handleCardFetching() { } yield put(Actions.dogs.list.request()); -} +}; -export function* handleSwipeUserRequest(props: ActionType) { +export function* handleSwipeUserRequest( + props: ActionType, +) { yield all([fork(() => swipeUserRequest(props)), fork(handleCardFetching)]); } diff --git a/apps/mobile/src/store/selectors.ts b/apps/mobile/src/store/selectors.ts index 1ac0837..03748d2 100644 --- a/apps/mobile/src/store/selectors.ts +++ b/apps/mobile/src/store/selectors.ts @@ -1,16 +1,22 @@ -import { createSelector } from "reselect"; +import type { RootReducer } from "@/store/reducers/index"; -import { RootReducer } from "@/store/reducers/index"; +import { createSelector } from "reselect"; export const getCards = createSelector( (state: RootReducer) => state.dogs.request, (request) => request.data, ); -export const getLastCardId = (state: RootReducer) => state.dogs.config.lastCardId; +export const getLastCardId = (state: RootReducer) => + state.dogs.config.lastCardId; -export const getActiveCards = createSelector(getCards, getLastCardId, (cards, lastCardId) => - cards.filter((card) => card.id !== lastCardId), +export const getActiveCards = createSelector( + getCards, + getLastCardId, + (cards, lastCardId) => cards.filter((card) => card.id !== lastCardId), ); -export const getCurrentCardId = createSelector(getActiveCards, (activeCards) => activeCards[0]?.id); +export const getCurrentCardId = createSelector( + getActiveCards, + (activeCards) => activeCards[0]?.id, +); diff --git a/apps/mobile/src/types/SceneName.ts b/apps/mobile/src/types/scene-name.ts similarity index 100% rename from apps/mobile/src/types/SceneName.ts rename to apps/mobile/src/types/scene-name.ts diff --git a/apps/mobile/src/types/styled-components.d.ts b/apps/mobile/src/types/styled-components.d.ts index 5ae14a9..ece4e4a 100644 --- a/apps/mobile/src/types/styled-components.d.ts +++ b/apps/mobile/src/types/styled-components.d.ts @@ -1,15 +1,14 @@ import "styled-components"; - import { DefaultTheme as DefaultPegadaTheme } from "@pegada/shared/themes/themes"; type PegadaTheme = typeof DefaultPegadaTheme; declare module "styled-components" { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type + // oxlint-disable-next-line typescript/no-empty-object-type -- styled-components' DefaultTheme is augmented by extension; an empty body is the whole point. export interface DefaultTheme extends PegadaTheme {} } declare module "styled-components/native" { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type + // oxlint-disable-next-line typescript/no-empty-object-type -- styled-components' DefaultTheme is augmented by extension; an empty body is the whole point. export interface DefaultTheme extends PegadaTheme {} } diff --git a/apps/mobile/src/views/(auth)/AskForLocation/index.tsx b/apps/mobile/src/views/(auth)/AskForLocation/index.tsx index 23a00e9..bc5956e 100644 --- a/apps/mobile/src/views/(auth)/AskForLocation/index.tsx +++ b/apps/mobile/src/views/(auth)/AskForLocation/index.tsx @@ -1,20 +1,31 @@ import { useState } from "react"; import * as React from "react"; import { Alert, Linking, ScrollView } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; + import * as Location from "expo-location"; import { useRouter } from "expo-router"; + import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "styled-components/native"; -import Information from "@/assets/images/Information.svg"; -import LocationIcon from "@/assets/images/Location.svg"; import { Button } from "@/components/Button"; -import { Text } from "@/components/Text"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { sendError } from "@/services/errorTracking"; -import { SceneName } from "@/types/SceneName"; -import { BottomView, Container, InformationRow, LocationView } from "./styles"; +import { Text } from "@/components/text"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { sendError } from "@/services/error-tracking"; +import { SceneName } from "@/types/scene-name"; + +import { + BottomView, + Container, + InformationIcon, + InformationRow, + LocationIcon, + LocationView, + Prompt, + scrollContent, + Title, +} from "./styles"; enum UpdateLocationError { PermissionNotGranted = "Location permission not granted", @@ -34,7 +45,10 @@ const getApproximatedPosition = async () => { return currentPostion.coords; }; -export const updateUserLocation = async (newLocation?: { longitude: number; latitude: number }) => { +export const updateUserLocation = async (newLocation?: { + longitude: number; + latitude: number; +}) => { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== "granted") { @@ -56,7 +70,8 @@ export const updateUserLocation = async (newLocation?: { longitude: number; lati country: geocode[0]?.country ?? null, }; - const newUserData = await getTrcpContext().client.user.update.mutate(location); + const newUserData = + await getTrcpContext().client.user.update.mutate(location); getTrcpContext().myDog.get.setData(undefined, (oldDogData) => { if (!oldDogData) return undefined; @@ -82,26 +97,14 @@ const AskForLocation: React.FC = () => { return ( - - + + {t("askForLocation.activateLocation")} - </Text> - <Text fontSize="xs" style={{ textAlign: "center" }}> - {t("askForLocation.permissionPrompt")} - </Text> + + {t("askForLocation.permissionPrompt")} { }} > - + {t("askForLocation.locationUsage")} @@ -152,7 +148,10 @@ const AskForLocation: React.FC = () => { sendError(error); - Alert.alert(t("common.somethingWrong"), t("common.tryAgainLater")); + Alert.alert( + t("common.somethingWrong"), + t("common.tryAgainLater"), + ); router.push(SceneName.Swipe); } finally { diff --git a/apps/mobile/src/views/(auth)/AskForLocation/styles.ts b/apps/mobile/src/views/(auth)/AskForLocation/styles.ts index 16c7106..35142b8 100644 --- a/apps/mobile/src/views/(auth)/AskForLocation/styles.ts +++ b/apps/mobile/src/views/(auth)/AskForLocation/styles.ts @@ -1,5 +1,23 @@ +import { StyleSheet } from "react-native"; + import styled from "styled-components/native"; +import Information from "@/assets/images/Information.svg"; +import Location from "@/assets/images/Location.svg"; +import { Text } from "@/components/text"; + +/** + * `contentContainerStyle` is a plain style prop, not a component, so it cannot + * be a styled component โ€” the only way to keep it out of the JSX is a sheet. + */ +export const { scrollContent } = StyleSheet.create({ + scrollContent: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, +}); + export const Container = styled.View` background-color: ${({ theme }) => theme.colors.background}; flex: 1; @@ -23,3 +41,22 @@ export const InformationRow = styled.View` align-items: center; margin-bottom: 20px; `; + +export const LocationIcon = styled(Location)` + margin-bottom: 20px; +`; + +export const InformationIcon = styled(Information)` + width: 21px; + height: 21px; + margin-right: 10px; +`; + +export const Title = styled(Text)` + text-align: center; + margin-bottom: 4px; +`; + +export const Prompt = styled(Text)` + text-align: center; +`; diff --git a/apps/mobile/src/views/(auth)/CompleteProfile/index.tsx b/apps/mobile/src/views/(auth)/CompleteProfile/index.tsx index 2a95559..24f179c 100644 --- a/apps/mobile/src/views/(auth)/CompleteProfile/index.tsx +++ b/apps/mobile/src/views/(auth)/CompleteProfile/index.tsx @@ -1,28 +1,39 @@ -import { KeyboardAvoidingView, Platform, View } from "react-native"; -import { magicToast } from "react-native-magic-toast"; +import type { DogCompleteClientSchema } from "@pegada/shared/schemas/dog-schema"; + +import { Platform } from "react-native"; + import { useLocalSearchParams, useRouter } from "expo-router"; + import { zodResolver } from "@hookform/resolvers/zod"; +import { dogCompleteClientSchema } from "@pegada/shared/schemas/dog-schema"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { magicToast } from "react-native-magic-toast"; import { useTheme } from "styled-components/native"; -import { DogCompleteClientSchema, dogCompleteClientSchema } from "@pegada/shared/schemas/dogSchema"; - import { BottomAction, useBottomActionStyle } from "@/components/BottomAction"; import BreedPicker from "@/components/BreedPicker"; import { Button } from "@/components/Button"; import { Input } from "@/components/Input"; +import { Fill, KeyboardScreen, Row } from "@/components/layout"; import { InputPicker } from "@/components/Picker"; -import { Text } from "@/components/Text"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { api } from "@/contexts/TRPCProvider"; -import { useDelayedHeaderHeight } from "@/hooks/useDelayedHeaderHeight"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { api } from "@/contexts/trpc-provider"; +import { useDelayedHeaderHeight } from "@/hooks/use-delayed-header-height"; import { analytics } from "@/services/analytics"; import { colors, sizes } from "@/services/consts"; -import { sendError } from "@/services/errorTracking"; -import { maskDate } from "@/services/maskDate"; -import { SceneName } from "@/types/SceneName"; -import { Container, ImageContainer, ProfileImage } from "./styles"; +import { sendError } from "@/services/error-tracking"; +import { maskDate } from "@/services/mask-date"; +import { SceneName } from "@/types/scene-name"; + +import { + Container, + Gap, + ImageContainer, + Note, + ProfileImage, + WideColumn, +} from "./styles"; const CompleteProfile = () => { const router = useRouter(); @@ -30,19 +41,20 @@ const CompleteProfile = () => { const { profileImageUrl } = useLocalSearchParams(); - const { control, handleSubmit, getValues, watch } = useForm({ - defaultValues: { - birthDate: "", - breedId: "", - }, - resolver: zodResolver(dogCompleteClientSchema), - }); + const { control, handleSubmit, getValues, watch } = + useForm({ + defaultValues: { + birthDate: "", + breedId: "", + }, + resolver: zodResolver(dogCompleteClientSchema), + }); const form = watch(); const headerHeight = useDelayedHeaderHeight(); - const hasChanged = Object.values(form).some((value) => Boolean(value)); + const hasChanged = Object.values(form).some(Boolean); const myDogUpdateMutation = api.myDog.update.useMutation({ onSuccess: (data) => { @@ -61,11 +73,12 @@ const CompleteProfile = () => { const saveUser = handleSubmit(async (data) => { if (hasChanged) { + const { birthDate, breedId, color, size } = data; const dogData = { - ...(data.birthDate && { birthDate: data.birthDate }), - ...(data.breedId && { breedId: data.breedId }), - ...(data.color && { color: data.color }), - ...(data.size && { size: data.size }), + ...(birthDate && { birthDate }), + ...(breedId && { breedId }), + ...(color && { color }), + ...(size && { size }), }; await myDogUpdateMutation.mutateAsync(dogData); @@ -80,21 +93,23 @@ const CompleteProfile = () => { const theme = useTheme(); const { scrollViewProps } = useBottomActionStyle(); - const continueText = hasChanged ? t("completeProfile.save") : t("common.skip"); + const continueText = hasChanged + ? t("completeProfile.save") + : t("common.skip"); return ( - - + @@ -102,8 +117,8 @@ const CompleteProfile = () => { - - + + { /> )} /> - + - + - + ( + render={({ + field: { onChange, onBlur, value, name }, + fieldState, + }) => ( { /> )} /> - - + + - + { )} /> - + { /> )} /> - + - - {t("completeProfile.additionalInfo")} - + {t("completeProfile.additionalInfo")} - - + + ); }; diff --git a/apps/mobile/src/views/(auth)/CompleteProfile/styles.ts b/apps/mobile/src/views/(auth)/CompleteProfile/styles.ts index 4c824e8..304b68b 100644 --- a/apps/mobile/src/views/(auth)/CompleteProfile/styles.ts +++ b/apps/mobile/src/views/(auth)/CompleteProfile/styles.ts @@ -1,9 +1,24 @@ import styled from "styled-components/native"; -import { Image } from "@/components/Image"; +import { Image } from "@/components/image"; +import { Text } from "@/components/text"; export const Container = styled.ScrollView` - flex-grow: 1; + flex: 1; +`; + +/** The wider half of the breed / birth-date row. */ +export const WideColumn = styled.View` + flex: 1.5; +`; + +/** The fixed gutter between two fields sharing a row. */ +export const Gap = styled.View` + width: ${({ theme }) => theme.spacing[3]}px; +`; + +export const Note = styled(Text)` + margin-top: ${({ theme }) => theme.spacing[6]}px; `; export const ImageContainer = styled.View` diff --git a/apps/mobile/src/views/(auth)/CreateProfile/index.tsx b/apps/mobile/src/views/(auth)/CreateProfile/index.tsx index 9311646..e618b2c 100644 --- a/apps/mobile/src/views/(auth)/CreateProfile/index.tsx +++ b/apps/mobile/src/views/(auth)/CreateProfile/index.tsx @@ -1,31 +1,35 @@ +import type { ProfileImagesUploaderProps } from "@/components/ProfileImageUploader"; +import type { Picture } from "@/components/ProfileImageUploader/utils"; +import type { DogQuickClientSchema } from "@pegada/shared/schemas/dog-schema"; + import { useState } from "react"; -import { KeyboardAvoidingView, Platform, View } from "react-native"; -import { magicToast } from "react-native-magic-toast"; +import { Platform } from "react-native"; + import { useRouter } from "expo-router"; + import { zodResolver } from "@hookform/resolvers/zod"; +import { dogQuickClientSchema } from "@pegada/shared/schemas/dog-schema"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { magicToast } from "react-native-magic-toast"; import { useTheme } from "styled-components/native"; -import { DogQuickClientSchema, dogQuickClientSchema } from "@pegada/shared/schemas/dogSchema"; - import { BottomAction, useBottomActionStyle } from "@/components/BottomAction"; import { Button } from "@/components/Button"; import { Input } from "@/components/Input"; -import { - ProfileImagesUploader, - ProfileImagesUploaderProps, -} from "@/components/ProfileImageUploader"; -import { Picture, pictures } from "@/components/ProfileImageUploader/utils"; +import { Fill, KeyboardScreen } from "@/components/layout"; +import { ProfileImagesUploader } from "@/components/ProfileImageUploader"; +import { pictures } from "@/components/ProfileImageUploader/utils"; import { RadioButtons } from "@/components/RadioButtons"; -import { Text } from "@/components/Text"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { api } from "@/contexts/TRPCProvider"; -import { useDelayedHeaderHeight } from "@/hooks/useDelayedHeaderHeight"; +import { Text } from "@/components/text"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { api } from "@/contexts/trpc-provider"; +import { useDelayedHeaderHeight } from "@/hooks/use-delayed-header-height"; import { analytics } from "@/services/analytics"; -import { sendError } from "@/services/errorTracking"; -import { SceneName } from "@/types/SceneName"; -import { Container } from "./styles"; +import { sendError } from "@/services/error-tracking"; +import { SceneName } from "@/types/scene-name"; + +import { Container, DragHint, MultilineInput, PhotoHint } from "./styles"; const DEFAULT_VALUES: DogQuickClientSchema = { name: "", @@ -100,18 +104,18 @@ const CreateProfile = () => { const { scrollViewProps } = useBottomActionStyle(); return ( - - + { {t("createProfile.profilePictures")} - + {t("createProfile.minimumOnePhoto")} - + [0]) => { + onChange={( + cb: Parameters[0], + ) => { // This getValues is needed to ensure the update happens // correctly even when adding images fast. onChange(cb(getValues("images") as Picture[])); }} error={fieldState.error?.message} /> - + {t("createProfile.clickAndHold")} - + )} /> @@ -167,7 +173,7 @@ const CreateProfile = () => { control={control} rules={{ required: true }} render={({ field: { onChange, onBlur, value }, fieldState }) => ( - { multiline optional error={fieldState.error?.message} - style={{ minHeight: 75 }} /> )} /> @@ -189,9 +194,15 @@ const CreateProfile = () => { { - onChange(value === t("completeProfile.male") ? "MALE" : "FEMALE"); + onChange( + value === t("completeProfile.male") ? "MALE" : "FEMALE", + ); }} /> )} @@ -206,8 +217,8 @@ const CreateProfile = () => { {t("createProfile.createProfile")} - - + + ); }; diff --git a/apps/mobile/src/views/(auth)/CreateProfile/styles.ts b/apps/mobile/src/views/(auth)/CreateProfile/styles.ts index 60201cf..2eb148f 100644 --- a/apps/mobile/src/views/(auth)/CreateProfile/styles.ts +++ b/apps/mobile/src/views/(auth)/CreateProfile/styles.ts @@ -1,5 +1,20 @@ import styled from "styled-components/native"; +import { Input } from "@/components/Input"; +import { Text } from "@/components/text"; + export const Container = styled.ScrollView` - flex-grow: 1; + flex: 1; +`; + +export const PhotoHint = styled(Text)` + margin-bottom: 10px; +`; + +export const DragHint = styled(Text)` + margin-top: 5px; +`; + +export const MultilineInput = styled(Input)` + min-height: 75px; `; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/components/GoBack/index.tsx b/apps/mobile/src/views/(auth)/OneTimeCode/components/GoBack/index.tsx index c93c2e9..533b6c0 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/components/GoBack/index.tsx +++ b/apps/mobile/src/views/(auth)/OneTimeCode/components/GoBack/index.tsx @@ -1,7 +1,9 @@ import * as React from "react"; + import { useTheme } from "styled-components/native"; import BackArrow from "@/assets/images/BackArrow.svg"; + import { Container } from "./styles"; const GoBack: React.FC> = (props) => { diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/components/GoBack/styles.ts b/apps/mobile/src/views/(auth)/OneTimeCode/components/GoBack/styles.ts index dd5bd1d..50206dd 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/components/GoBack/styles.ts +++ b/apps/mobile/src/views/(auth)/OneTimeCode/components/GoBack/styles.ts @@ -1,6 +1,6 @@ import styled from "styled-components/native"; -import { PressableArea } from "@/components/PressableArea"; +import { PressableArea } from "@/components/pressable-area"; export const Container = styled(PressableArea)` border-radius: ${(props) => props.theme.radii.md}px; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/index.tsx b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/index.tsx index 7ab2e90..efc9dc9 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/index.tsx +++ b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/index.tsx @@ -1,5 +1,7 @@ +import type { TextInput } from "react-native"; + import { forwardRef, useState } from "react"; -import { TextInput } from "react-native"; + import { useTheme } from "styled-components/native"; import * as S from "./styles"; @@ -23,63 +25,82 @@ export const OTP_INPUT_MARGIN = 6; const OtpDigit = forwardRef< TextInput, - Omit, "children"> & OtpDigitProps ->(({ children, index, length, pointerEvents, handleChange, handleErase, testID }, ref) => { - const [selected, setSelected] = useState(false); + Omit, "children"> & + OtpDigitProps +>( + ( + { + children, + index, + length, + pointerEvents, + handleChange, + handleErase, + testID, + }, + ref, + ) => { + const [selected, setSelected] = useState(false); - const { colors } = useTheme(); + const { colors } = useTheme(); - const isFirst = index === 0; - const isLast = index === length - 1; + const isFirst = index === 0; + const isLast = index === length - 1; - const rightMargin = isLast ? 0 : OTP_INPUT_MARGIN; - const selectedBorderColor = colors.border; + const rightMargin = isLast ? 0 : OTP_INPUT_MARGIN; + const selectedBorderColor = colors.border; - const digit = isNaN(Number(children)) ? "" : children; + const digit = Number.isNaN(Number(children)) ? "" : children; - return ( - - setSelected(false)} - onFocus={() => setSelected(true)} - accessibilityLabel="Text input field" - accessibilityHint="Enter the verification code" - value={digit} - keyboardType="number-pad" - onChangeText={(text: string) => handleChange(text, index)} - numberOfLines={1} - maxLength={length} - returnKeyType="next" - pointerEvents={pointerEvents} - selectionColor="transparent" - autoFocus={isFirst} - importantForAutofill={isFirst ? "yes" : "no"} - textContentType={isFirst ? "oneTimeCode" : "none"} - autoComplete={isFirst ? "sms-otp" : "off"} - onKeyPress={({ nativeEvent: { key } }: { nativeEvent: { key: KeyboardKeys } }) => { - if (key === KeyboardKeys.Backspace) return handleErase(digit, index); + return ( + - - - + setSelected(false)} + onFocus={() => setSelected(true)} + accessibilityLabel="Text input field" + accessibilityHint="Enter the verification code" + value={digit} + keyboardType="number-pad" + onChangeText={(text: string) => handleChange(text, index)} + numberOfLines={1} + maxLength={length} + returnKeyType="next" + pointerEvents={pointerEvents} + selectionColor="transparent" + autoFocus={isFirst} + importantForAutofill={isFirst ? "yes" : "no"} + textContentType={isFirst ? "oneTimeCode" : "none"} + autoComplete={isFirst ? "sms-otp" : "off"} + onKeyPress={({ + nativeEvent: { key }, + }: { + nativeEvent: { key: KeyboardKeys }; + }) => { + if (key === KeyboardKeys.Backspace) + return handleErase(digit, index); }} - > - {digit || "0"} - - - - ); -}); + /> + + + + {digit || "0"} + + + + ); + }, +); export default OtpDigit; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/styles.ts b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/styles.ts index fbbcc2b..815122d 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/styles.ts +++ b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/styles.ts @@ -1,4 +1,5 @@ import { Dimensions } from "react-native"; + import styled from "styled-components/native"; const deviceHeight = Dimensions.get("window").height; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpInput/index.tsx b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpInput/index.tsx index 2bb360a..268d0b8 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpInput/index.tsx +++ b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpInput/index.tsx @@ -1,6 +1,7 @@ +import type { TextInput } from "react-native"; + import { forwardRef, useImperativeHandle, useRef } from "react"; import * as React from "react"; -import { TextInput } from "react-native"; import OtpDigit, { OTP_INPUT_HEIGHT, OTP_INPUT_MARGIN } from "../OtpDigit"; import { VerifyRowView } from "./styles"; @@ -11,76 +12,75 @@ export type OtpInputRef = { focus: () => void; }; -interface OtpInputProps { +type OtpInputProps = { value: string; length: number; onChangeText: React.Dispatch>; -} - -const OTPInput = forwardRef(({ length, value, onChangeText }, ref) => { - const inputRefs: { current: (TextInput | null | any)[] } = useRef([]); - - const handleFocus = () => { - inputRefs.current[0]?.focus(); - }; - - useImperativeHandle(ref, () => ({ - focus: handleFocus, - })); - - const changeDigit = (digit: string, index: number) => { - return onChangeText((previousValue) => { - const newValue = previousValue - .slice(0, index) - .concat(digit) - .concat(previousValue.slice(index + 1)); - - return newValue.slice(0, length); - }); - }; - - const handleChange = (digit: string, index: number) => { - if (!digit || digit.match(/[^0-9]/g)) return; - changeDigit(digit, index); - - const nextIndex = Math.min(index + digit.length - 1, length - 1); - - inputRefs.current?.[nextIndex]?.focus(); - }; - - const handleErase = (_digit: string, index: number) => { - changeDigit("", index); - - inputRefs.current?.[index - 1]?.focus(); - }; - - const otp_max_width = (OTP_INPUT_MAX_WIDTH + OTP_INPUT_MARGIN) * length; - - return ( - - {[...Array(length)].map((_, index) => { - const previousValue = value?.[index - 1]; - const isFirst = index === 0; - - return ( - { - inputRefs.current[index] = el; - }} - index={index} - length={length} - pointerEvents={previousValue || isFirst ? "auto" : "none"} - handleChange={handleChange} - handleErase={handleErase} - testID={`otp-digit-${index}`} - > - {value[index]} - - ); - })} - - ); -}); +}; + +const OTPInput = forwardRef( + ({ length, value, onChangeText }, ref) => { + const inputRefs = useRef<(TextInput | null)[]>([]); + + const handleFocus = () => { + inputRefs.current[0]?.focus(); + }; + + useImperativeHandle(ref, () => ({ + focus: handleFocus, + })); + + const changeDigit = (digit: string, index: number) => { + return onChangeText((previousValue) => { + const newValue = `${previousValue.slice(0, index)}${digit}${previousValue.slice(index + 1)}`; + + return newValue.slice(0, length); + }); + }; + + const handleChange = (digit: string, index: number) => { + if (!digit || digit.match(/[^0-9]/g)) return; + changeDigit(digit, index); + + const nextIndex = Math.min(index + digit.length - 1, length - 1); + + inputRefs.current?.[nextIndex]?.focus(); + }; + + const handleErase = (_digit: string, index: number) => { + changeDigit("", index); + + inputRefs.current?.[index - 1]?.focus(); + }; + + const otp_max_width = (OTP_INPUT_MAX_WIDTH + OTP_INPUT_MARGIN) * length; + + return ( + + {Array.from({ length }, (_, index) => { + const previousValue = value?.[index - 1]; + const isFirst = index === 0; + + return ( + { + inputRefs.current[index] = el; + }} + index={index} + length={length} + pointerEvents={previousValue || isFirst ? "auto" : "none"} + handleChange={handleChange} + handleErase={handleErase} + testID={`otp-digit-${index}`} + > + {value[index]} + + ); + })} + + ); + }, +); export default OTPInput; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/hooks/useTimer.ts b/apps/mobile/src/views/(auth)/OneTimeCode/hooks/use-timer.ts similarity index 59% rename from apps/mobile/src/views/(auth)/OneTimeCode/hooks/useTimer.ts rename to apps/mobile/src/views/(auth)/OneTimeCode/hooks/use-timer.ts index ddda1eb..894d789 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/hooks/useTimer.ts +++ b/apps/mobile/src/views/(auth)/OneTimeCode/hooks/use-timer.ts @@ -1,6 +1,10 @@ -import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; -const useTimer = (seconds: number): [number, Dispatch>] => { +import { useEffect, useState } from "react"; + +const useTimer = ( + seconds: number, +): [number, Dispatch>] => { const [timer, setTimer] = useState(seconds); useEffect(() => { diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/index.tsx b/apps/mobile/src/views/(auth)/OneTimeCode/index.tsx index a0d946e..395bc51 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/index.tsx +++ b/apps/mobile/src/views/(auth)/OneTimeCode/index.tsx @@ -1,25 +1,32 @@ +import type { OtpInputRef } from "./components/OtpInput"; + import { useRef, useState } from "react"; import { ActivityIndicator, Platform } from "react-native"; -import { magicToast } from "react-native-magic-toast"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { useLocalSearchParams, useRouter } from "expo-router"; + +import { + InvalidOTPCodeError, + OTPRequiredError, +} from "@pegada/shared/errors/errors"; import { format, set } from "date-fns"; import { useTranslation } from "react-i18next"; +import { magicToast } from "react-native-magic-toast"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { InvalidOTPCodeError, OTPRequiredError } from "@pegada/shared/errors/errors"; - -import { Text } from "@/components/Text"; -import { api } from "@/contexts/TRPCProvider"; +import { Text } from "@/components/text"; +import { api } from "@/contexts/trpc-provider"; import { analytics } from "@/services/analytics"; -import { sendError } from "@/services/errorTracking"; -import { getError } from "@/services/getError"; -import { getInitialRouteName } from "@/services/getInitialRouteName"; +import { sendError } from "@/services/error-tracking"; +import { getError } from "@/services/get-error"; +import { getInitialRouteName } from "@/services/get-initial-route-name"; import { StorageKeys, storeData } from "@/services/storage"; import { useDidMountEffect } from "@/services/utils"; + import { Underline } from "../SignIn/components/HeroText"; import GoBack from "./components/GoBack"; -import OTPInput, { OtpInputRef } from "./components/OtpInput"; -import useTimer from "./hooks/useTimer"; +import OTPInput from "./components/OtpInput"; +import useTimer from "./hooks/use-timer"; import { Container, Content, @@ -55,7 +62,7 @@ const OneTimeCode = () => { const loginMutation = api.authentication.login.useMutation({ onSuccess: async (data) => { try { - const token = data.token; + const { token } = data; await storeData(StorageKeys.Token, token); const initialRouteName = await getInitialRouteName(); @@ -101,7 +108,9 @@ const OneTimeCode = () => { }, [keyboardInput]); return ( - + { {formattedTime} - {t("oneTimeCode.insertCode")} {email} + {t("oneTimeCode.insertCode")}{" "} + {email} { error?: string; } diff --git a/apps/mobile/src/views/(auth)/SignIn/components/EmailInput/styles.ts b/apps/mobile/src/views/(auth)/SignIn/components/EmailInput/styles.ts index df50033..e8ce4b0 100644 --- a/apps/mobile/src/views/(auth)/SignIn/components/EmailInput/styles.ts +++ b/apps/mobile/src/views/(auth)/SignIn/components/EmailInput/styles.ts @@ -1,6 +1,6 @@ import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Container = styled.View` margin-top: ${(props) => props.theme.spacing[5]}px; diff --git a/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.test.jsx b/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.test.jsx deleted file mode 100644 index 025fe73..0000000 --- a/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.test.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { changeLanguage, use } from "i18next"; -import { initReactI18next } from "react-i18next"; - -import { initI18n } from "@pegada/shared/i18n/i18n"; - -import HeroText from "."; - -jest.mock("./styles", () => ({ - Container: "div", - FlexRowView: "div", - Line: "span", - RotatedRectangle: "span", - Title: "span", - UnderlineContainer: "span", - WhiteTitle: "span", -})); - -beforeAll(async () => { - await initI18n(use(initReactI18next)); -}); - -test.each(["en-US", "pt-BR"])("renders %s without React key warnings", async (language) => { - await changeLanguage(language); - - const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); - - try { - renderToStaticMarkup(); - expect(consoleError).not.toHaveBeenCalled(); - } finally { - consoleError.mockRestore(); - } -}); diff --git a/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.test.tsx b/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.test.tsx new file mode 100644 index 0000000..2917654 --- /dev/null +++ b/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.test.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { initI18n } from "@pegada/shared/i18n/i18n"; +import { changeLanguage, use } from "i18next"; +import { initReactI18next } from "react-i18next"; + +import HeroText from "."; + +// Rendered through react-dom/server, so every styled-component is replaced by +// a plain host element. The cast is the mock's whole point: these are strings +// standing in for React Native styled components. +jest.mock>("./styles", () => ({ + Container: "div", + FlexRowView: "div", + Line: "span", + RotatedRectangle: "span", + Title: "span", + UnderlineContainer: "span", + WhiteTitle: "span", +})); + +beforeAll(async () => { + await initI18n(use(initReactI18next)); +}); + +test.each(["en-US", "pt-BR"])( + "renders %s without React key warnings", + async (language) => { + await changeLanguage(language); + + const consoleError = jest + .spyOn(console, "error") + .mockReturnValue(undefined); + + try { + renderToStaticMarkup(); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }, +); diff --git a/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.tsx b/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.tsx index 102cef8..d353c35 100644 --- a/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.tsx +++ b/apps/mobile/src/views/(auth)/SignIn/components/HeroText/index.tsx @@ -1,4 +1,5 @@ import * as React from "react"; + import { Trans } from "react-i18next"; import { diff --git a/apps/mobile/src/views/(auth)/SignIn/components/HeroText/styles.ts b/apps/mobile/src/views/(auth)/SignIn/components/HeroText/styles.ts index ebc5b14..81aaea3 100644 --- a/apps/mobile/src/views/(auth)/SignIn/components/HeroText/styles.ts +++ b/apps/mobile/src/views/(auth)/SignIn/components/HeroText/styles.ts @@ -1,6 +1,6 @@ import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Container = styled.View` align-items: center; diff --git a/apps/mobile/src/views/(auth)/SignIn/index.tsx b/apps/mobile/src/views/(auth)/SignIn/index.tsx index 4bcac63..9f149e5 100644 --- a/apps/mobile/src/views/(auth)/SignIn/index.tsx +++ b/apps/mobile/src/views/(auth)/SignIn/index.tsx @@ -1,19 +1,21 @@ import { useState } from "react"; import { Alert, Keyboard, Platform } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { useRouter } from "expo-router"; import { requestTrackingPermissionsAsync } from "expo-tracking-transparency"; -import { Trans, useTranslation } from "react-i18next"; -import { useTheme } from "styled-components/native"; import { OTPRequiredError } from "@pegada/shared/errors/errors"; +import { Trans, useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTheme } from "styled-components/native"; import { Button } from "@/components/Button"; -import { api } from "@/contexts/TRPCProvider"; -import { useKeyboardAwareSafeAreaInsets } from "@/hooks/useKeyboardAwareSafeAreaInsets"; -import { sendError } from "@/services/errorTracking"; -import { getError } from "@/services/getError"; -import { SceneName } from "@/types/SceneName"; +import { api } from "@/contexts/trpc-provider"; +import { useKeyboardAwareSafeAreaInsets } from "@/hooks/use-keyboard-aware-safe-area-insets"; +import { sendError } from "@/services/error-tracking"; +import { getError } from "@/services/get-error"; +import { SceneName } from "@/types/scene-name"; + import EmailInput from "./components/EmailInput"; import HeroText from "./components/HeroText"; import { @@ -34,6 +36,14 @@ export const useCustomBottomInset = () => { return Math.max(theme.spacing[4], insets.bottom + theme.spacing[1]); }; +const requestTrackingPermissions = async () => { + try { + await requestTrackingPermissionsAsync(); + } catch (error) { + sendError(error); + } +}; + const InsertEmail = () => { const insets = useSafeAreaInsets(); const bottomInset = useCustomBottomInset(); @@ -49,7 +59,10 @@ const InsertEmail = () => { onError: (error) => { // Resend code if (getError(error, OTPRequiredError)) { - requestTrackingPermissionsAsync().catch(sendError); + // Fire-and-forget: the OTP screen must not wait on the ATT prompt. + // `void` rather than a chained catch, which `promise/no-promise-in-callback` + // (rightly) reads as a second error path inside a callback. + void requestTrackingPermissions(); return router.push({ pathname: SceneName.OneTimeCode, @@ -62,7 +75,7 @@ const InsertEmail = () => { }, }); - const handleLogin = async () => { + const handleLogin = () => { const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); if (!isValidEmail) { @@ -74,7 +87,9 @@ const InsertEmail = () => { return ( Keyboard.dismiss()}> - + { onChangeText={setEmail} error={error} /> - diff --git a/apps/mobile/src/views/(auth)/SignIn/styles.ts b/apps/mobile/src/views/(auth)/SignIn/styles.ts index 5573783..4282964 100644 --- a/apps/mobile/src/views/(auth)/SignIn/styles.ts +++ b/apps/mobile/src/views/(auth)/SignIn/styles.ts @@ -1,9 +1,10 @@ import { KeyboardAvoidingView, Pressable } from "react-native"; + import { SafeAreaView } from "react-native-safe-area-context"; import styled from "styled-components/native"; -import Logo from "@/assets/images/Logo"; -import { Text } from "@/components/Text"; +import Logo from "@/assets/images/logo"; +import { Text } from "@/components/text"; export const Container = styled(SafeAreaView).attrs({ edges: ["left", "right"], @@ -18,7 +19,9 @@ export const Container = styled(SafeAreaView).attrs({ * whole SignIn screen (email input, submit button, all testIDs) becomes * one opaque node to XCUITest/VoiceOver. */ -export const PressableContainer = styled(Pressable).attrs({ accessible: false })` +export const PressableContainer = styled(Pressable).attrs({ + accessible: false, +})` flex-grow: 1; `; @@ -41,7 +44,8 @@ export const TopCard = styled.ImageBackground.attrs((props) => ({ background-color: ${(props) => props.theme.colors.background}; justify-content: center; align-items: center; - padding: ${(props) => props.theme.spacing[10]}px ${(props) => props.theme.spacing[4]}px; + padding: ${(props) => props.theme.spacing[10]}px + ${(props) => props.theme.spacing[4]}px; `; export const BottomCard = styled.View` diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Header/index.tsx b/apps/mobile/src/views/(tabs)/Messages/components/Header/index.tsx index 018bdd9..62089cc 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Header/index.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/Header/index.tsx @@ -1,14 +1,16 @@ +import type { Match } from "../.."; + import * as React from "react"; -import { FlatList, View } from "react-native"; +import { FlatList } from "react-native"; + import { useTheme } from "styled-components/native"; -import { Match } from "../.."; import { Preview } from "../Preview"; -import { Container } from "./styles"; +import { Container, PreviewSeparator } from "./styles"; -interface HeaderProps { +type HeaderProps = { matches?: Match[]; -} +}; export const Header: React.FC = ({ matches }) => { const theme = useTheme(); @@ -25,7 +27,7 @@ export const Header: React.FC = ({ matches }) => { contentContainerStyle={{ paddingHorizontal: theme.spacing[4], }} - ItemSeparatorComponent={() => } + ItemSeparatorComponent={PreviewSeparator} showsHorizontalScrollIndicator={false} /> diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Header/styles.ts b/apps/mobile/src/views/(tabs)/Messages/components/Header/styles.ts index 4434c40..e4b6d1f 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Header/styles.ts +++ b/apps/mobile/src/views/(tabs)/Messages/components/Header/styles.ts @@ -3,3 +3,8 @@ import styled from "styled-components/native"; export const Container = styled.View` background-color: ${(props) => props.theme.colors.background}; `; + +/** The gutter between two match previews. */ +export const PreviewSeparator = styled.View` + width: ${({ theme }) => theme.spacing[3]}px; +`; diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Message/index.tsx b/apps/mobile/src/views/(tabs)/Messages/components/Message/index.tsx index 88fea4c..20c722c 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Message/index.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/Message/index.tsx @@ -1,14 +1,19 @@ +import type { Match } from "../.."; + import * as React from "react"; import { View } from "react-native"; + import { useRouter } from "expo-router"; + import { useTranslation } from "react-i18next"; +import { Fill } from "@/components/layout"; import { ThinkingEmoji } from "@/components/MatchActionBar/styles"; -import { Text } from "@/components/Text"; -import { SceneName } from "@/types/SceneName"; -import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture"; -import { Match } from "../.."; -import { Container, EmojiContainer, Picture } from "./styles"; +import { Text } from "@/components/text"; +import { SceneName } from "@/types/scene-name"; +import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/use-swipe-gesture"; + +import { Container, emojiSize, EmojiContainer, Picture } from "./styles"; const getEmojiBySwipeType = (swipeType?: Swipe) => { switch (swipeType) { @@ -19,9 +24,9 @@ const getEmojiBySwipeType = (swipeType?: Swipe) => { } }; -interface MessageProps { +type MessageProps = { item: Match; -} +}; export const Message: React.FC = ({ item }) => { const router = useRouter(); @@ -51,18 +56,18 @@ export const Message: React.FC = ({ item }) => { /> {Emoji ? ( - + ) : null} - + {item.dog.name} {item.lastMessage?.content ?? t("matches.sendFirstMessage")} - + ); }; diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Message/styles.ts b/apps/mobile/src/views/(tabs)/Messages/components/Message/styles.ts index ce0b6dd..b2bb9a5 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Message/styles.ts +++ b/apps/mobile/src/views/(tabs)/Messages/components/Message/styles.ts @@ -1,7 +1,9 @@ +import { StyleSheet } from "react-native"; + import styled from "styled-components/native"; -import { Image } from "@/components/Image"; -import { PressableArea } from "@/components/PressableArea"; +import { Image } from "@/components/image"; +import { PressableArea } from "@/components/pressable-area"; export const Container = styled(PressableArea).attrs({ accessible: true })` padding: ${(props) => props.theme.spacing[4]}px; @@ -28,3 +30,11 @@ export const EmojiContainer = styled.View` border-width: ${(props) => props.theme.stroke.md}px; border-color: ${({ theme }) => theme.colors.border}; `; + +/** + * The emoji component is picked at runtime (or is null), so it cannot be a + * styled component โ€” a sheet entry is the only way to keep the size out of JSX. + */ +export const { emojiSize } = StyleSheet.create({ + emojiSize: { width: 15, height: 15 }, +}); diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Preview/index.tsx b/apps/mobile/src/views/(tabs)/Messages/components/Preview/index.tsx index 7226503..28f6804 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Preview/index.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/Preview/index.tsx @@ -1,17 +1,20 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; + import * as React from "react"; + import { useRouter } from "expo-router"; -import { Text } from "@/components/Text"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; -import { SceneName } from "@/types/SceneName"; +import { Text } from "@/components/text"; +import { SceneName } from "@/types/scene-name"; + import { Container, Content, Picture } from "./styles"; -interface PreviewProps { +type PreviewProps = { item: { id: string; dog: SwipeDog; }; -} +}; export const Preview: React.FC = ({ item }) => { const router = useRouter(); diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Preview/styles.ts b/apps/mobile/src/views/(tabs)/Messages/components/Preview/styles.ts index 3f36012..2d14c89 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Preview/styles.ts +++ b/apps/mobile/src/views/(tabs)/Messages/components/Preview/styles.ts @@ -1,7 +1,7 @@ import styled from "styled-components/native"; -import { Image } from "@/components/Image"; -import { PressableArea } from "@/components/PressableArea"; +import { Image } from "@/components/image"; +import { PressableArea } from "@/components/pressable-area"; export const Content = styled.View` width: 65px; diff --git a/apps/mobile/src/views/(tabs)/Messages/components/SearchBar/index.tsx b/apps/mobile/src/views/(tabs)/Messages/components/SearchBar/index.tsx index 725673f..9445fda 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/SearchBar/index.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/SearchBar/index.tsx @@ -1,9 +1,12 @@ import * as React from "react"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; + import { Container, SearchFieldContainer, TextField } from "./styles"; -export const SearchBar: React.FC> = (props) => { +export const SearchBar: React.FC> = ( + props, +) => { return ( diff --git a/apps/mobile/src/views/(tabs)/Messages/components/EmptyMessages.tsx b/apps/mobile/src/views/(tabs)/Messages/components/empty-messages.tsx similarity index 54% rename from apps/mobile/src/views/(tabs)/Messages/components/EmptyMessages.tsx rename to apps/mobile/src/views/(tabs)/Messages/components/empty-messages.tsx index 66e3d35..409fc34 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/EmptyMessages.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/empty-messages.tsx @@ -1,58 +1,41 @@ import * as React from "react"; import { View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { router } from "expo-router"; + import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "styled-components/native"; import { Button } from "@/components/Button"; -import { Text } from "@/components/Text"; -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; import { EmptyAnimation } from "@/views/(tabs)/Swipe/components/SwipeRequestFeedback/styles"; -interface EmptyMessagesProps { +import { EmptyDescription, EmptyRoot, EmptyTitle } from "../styles"; + +type EmptyMessagesProps = { search: string; setSearch: (value: string) => void; -} +}; -export const EmptyMessages: React.FC = ({ search, setSearch }) => { +export const EmptyMessages: React.FC = ({ + search, + setSearch, +}) => { const insets = useSafeAreaInsets(); const { t } = useTranslation(); const theme = useTheme(); return ( - + - + {t("messages.empty.title")} - - + + {t("messages.empty.description")} - + {search ? ( )} - + ); }; diff --git a/apps/mobile/src/views/(tabs)/Messages/index.tsx b/apps/mobile/src/views/(tabs)/Messages/index.tsx index 66c5a8a..2355f32 100644 --- a/apps/mobile/src/views/(tabs)/Messages/index.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/index.tsx @@ -1,22 +1,27 @@ +import type { RouterOutputs } from "@/contexts/trpc-provider"; + import { useEffect, useRef, useState } from "react"; import { FlatList, Platform, View } from "react-native"; + import { usePathname } from "expo-router"; + import { useScrollToTop } from "@react-navigation/native"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; -import Divider from "@/components/Divider"; +import Divider from "@/components/divider"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { Text } from "@/components/Text"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { api, RouterOutputs } from "@/contexts/TRPCProvider"; -import { handleRequestAppReview } from "@/services/appReview"; -import { sendError } from "@/services/errorTracking"; +import { Text } from "@/components/text"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { api } from "@/contexts/trpc-provider"; +import { handleRequestAppReview } from "@/services/app-review"; +import { sendError } from "@/services/error-tracking"; import { syncMatchesWidget } from "@/services/matchesWidget"; -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; import { Header } from "@/views/(tabs)/Messages/components/Header"; import { Message } from "@/views/(tabs)/Messages/components/Message"; -import { EmptyMessages } from "./components/EmptyMessages"; + +import { EmptyMessages } from "./components/empty-messages"; import { SearchBar } from "./components/SearchBar"; import { Container, DividerContainer, Title } from "./styles"; @@ -28,7 +33,7 @@ const MemoizedDivider = () => ( ); -const getKeyMemoized = (item: Match) => item.id + "_message"; +const getKeyMemoized = (item: Match) => `${item.id}_message`; const Messages = () => { const pathname = usePathname(); @@ -47,10 +52,9 @@ const Messages = () => { handleRequestAppReview().catch(sendError); } - matches.forEach((match) => { - const dog = match.dog; + for (const { dog } of matches) { getTrcpContext().dog.get.setData({ id: dog.id }, dog); - }); + } // Refresh the home-screen widget whenever the matches query updates. void syncMatchesWidget(matches); @@ -63,14 +67,14 @@ const Messages = () => { if (!search) return matches; return matches.filter((match) => { - const dog = match.dog; + const { dog } = match; return dog.name.toLowerCase().includes(search.toLowerCase()); }); }; const filteredMatches = getFiltered(); - if (!filteredMatches.length) return []; + if (filteredMatches.length === 0) return []; // Most recent messages come first. Sort a copy: with no search term // getFiltered hands back the query's own array, and sorting in place @@ -120,11 +124,22 @@ const Messages = () => { ); - const MemoizedEmptyMessages = ; + const MemoizedEmptyMessages = ( + + ); + + // Grow the container only when the list is empty, so the empty state fills + // the screen. Doing it unconditionally breaks the populated list's layout. + const emptyListGrow = data?.length ? undefined : 1; return ( - - {Boolean(matches?.length) && } + + {Boolean(matches?.length) && ( + + )} { contentContainerStyle={{ paddingBottom: theme.spacing[4], paddingTop: theme.spacing[1], - // Increase size only if data is empty - // Otherwise it bugs stuff - flexGrow: data?.length ? undefined : 1, + flexGrow: emptyListGrow, }} /> ); }; -export default () => { +const MessagesScreen = () => { return ( ); }; + +export default MessagesScreen; diff --git a/apps/mobile/src/views/(tabs)/Messages/styles.ts b/apps/mobile/src/views/(tabs)/Messages/styles.ts index ea4942c..16a4dea 100644 --- a/apps/mobile/src/views/(tabs)/Messages/styles.ts +++ b/apps/mobile/src/views/(tabs)/Messages/styles.ts @@ -1,5 +1,7 @@ import styled from "styled-components/native"; +import { Text } from "@/components/text"; + import { PICTURE_SIZE } from "./components/Message/styles"; export const Container = styled.KeyboardAvoidingView` @@ -20,3 +22,23 @@ export const DividerContainer = styled.View` margin-right: ${(props) => props.theme.spacing[4]}px; margin-left: ${(props) => props.theme.spacing[4] * 2 + PICTURE_SIZE}px; `; + +export const EmptyRoot = styled.View` + align-items: center; + justify-content: center; + flex-grow: 1; + padding-horizontal: ${({ theme }) => theme.spacing[12]}px; + padding-bottom: ${({ theme }) => theme.spacing[12]}px; +`; + +export const EmptyTitle = styled(Text)` + margin-top: 12px; + margin-bottom: 10px; + text-align: center; +`; + +export const EmptyDescription = styled(Text)` + letter-spacing: 0.5px; + text-align: center; + margin-bottom: 30px; +`; diff --git a/apps/mobile/src/views/(tabs)/Profile/components/Config/index.tsx b/apps/mobile/src/views/(tabs)/Profile/components/Config/index.tsx index ecc3572..124f35a 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/Config/index.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/Config/index.tsx @@ -1,7 +1,15 @@ import { useTheme } from "styled-components/native"; import ArrowRight from "@/assets/images/ArrowRight.svg"; -import { ArrowContainer, Container, Description, Root, Title } from "./styles"; + +import { + ArrowContainer, + Container, + Description, + IconSlot, + Root, + Title, +} from "./styles"; const Arrow = () => { const theme = useTheme(); @@ -14,6 +22,7 @@ const Arrow = () => { }; export const Config = { Container, + IconSlot, Description, Root, Title, diff --git a/apps/mobile/src/views/(tabs)/Profile/components/Config/styles.ts b/apps/mobile/src/views/(tabs)/Profile/components/Config/styles.ts index b421758..a5652cb 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/Config/styles.ts +++ b/apps/mobile/src/views/(tabs)/Profile/components/Config/styles.ts @@ -1,7 +1,8 @@ import { Pressable } from "react-native"; + import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Root = styled(Pressable)` flex-direction: row; @@ -14,6 +15,12 @@ export const Container = styled.View` flex: 1; `; +/** Fixed-width slot so every row's icon lines up, whatever its own size. */ +export const IconSlot = styled.View` + width: 22px; + align-items: center; +`; + export const ArrowContainer = styled.View` align-items: flex-end; `; diff --git a/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/index.tsx b/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/index.tsx index 4a58d81..b5af986 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/index.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/index.tsx @@ -1,20 +1,25 @@ import { ActivityIndicator, useWindowDimensions, View } from "react-native"; + import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { LinearGradient } from "expo-linear-gradient"; import { useTheme } from "styled-components/native"; import Premium from "@/assets/images/Premium.svg"; import { BIO_NUMBER_OF_LINES } from "@/components/MainCard/components/PersonalInfo"; import * as PersonalInfo from "@/components/MainCard/components/PersonalInfo/styles"; -import { Container, Picture } from "@/components/MainCard/styles"; +import { Picture } from "@/components/MainCard/styles"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { api } from "@/contexts/TRPCProvider"; -import { useCustomerPlan } from "@/hooks/usePayments"; +import { api } from "@/contexts/trpc-provider"; +import { useCustomerPlan } from "@/hooks/use-payments"; import { UserPlan } from "@/services/payments"; -import { useGetFormattedYears } from "@/services/useGetFormattedYears"; +import { useGetFormattedYears } from "@/services/use-get-formatted-years"; import { + HeaderCard, + InfoBlock, + NameRow, ProfileContainer, ProfileUnknownError, + Scrim, + Shade, } from "@/views/(tabs)/Profile/components/UserDogProfileHeader/styles"; export const useDogProfileHeight = () => { @@ -39,32 +44,32 @@ const UserDogProfileHeader = () => { } return ( - + - - - + + {dog.name} {dog.birthDate ? ( - + , {getFormattedYears(dog.birthDate)} ) : null} @@ -77,7 +82,7 @@ const UserDogProfileHeader = () => { height={22} /> ) : null} - + {dog.bio ? ( { {dog.bio} ) : null} - - - + + + ); }; @@ -114,17 +119,13 @@ const WrappedUserDogProfileHeader = () => { return ( - }> + } + > - diff --git a/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/styles.ts b/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/styles.ts index 6411e67..c72ca36 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/styles.ts +++ b/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/styles.ts @@ -1,6 +1,41 @@ +import { LinearGradient } from "expo-linear-gradient"; + import styled from "styled-components/native"; -import { OfflineComponent, UnknownErrorComponent } from "@/components/NetworkBoundary"; +import * as PersonalInfo from "@/components/MainCard/components/PersonalInfo/styles"; +import { Container } from "@/components/MainCard/styles"; +import { + OfflineComponent, + UnknownErrorComponent, +} from "@/components/NetworkBoundary"; + +/** The MainCard shell, squared off because it sits flush under the header. */ +export const HeaderCard = styled(Container)` + border-radius: 0; +`; + +/** Bottom-anchored gradient the name and bio sit on top of. */ +export const Shade = styled(LinearGradient)` + margin-top: auto; +`; + +/** Top-anchored gradient that darkens the status bar area. */ +export const Scrim = styled(LinearGradient)` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +`; + +export const InfoBlock = styled(PersonalInfo.Container)` + padding-bottom: 35px; +`; + +export const NameRow = styled.View` + flex-direction: row; + align-items: center; +`; export const ProfileContainer = styled.View` justify-content: center; diff --git a/apps/mobile/src/views/(tabs)/Profile/components/CurrentPlanConfig.tsx b/apps/mobile/src/views/(tabs)/Profile/components/current-plan-config.tsx similarity index 78% rename from apps/mobile/src/views/(tabs)/Profile/components/CurrentPlanConfig.tsx rename to apps/mobile/src/views/(tabs)/Profile/components/current-plan-config.tsx index 7d08b06..bafaf0e 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/CurrentPlanConfig.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/current-plan-config.tsx @@ -1,15 +1,18 @@ import { Linking, Platform } from "react-native"; + import Constants from "expo-constants"; import { useRouter } from "expo-router"; + import { format } from "date-fns"; import { useTranslation } from "react-i18next"; import styled, { useTheme } from "styled-components/native"; import Premium from "@/assets/images/Badge.svg"; -import Loading from "@/components/Loading"; -import { useCustomerPlan } from "@/hooks/usePayments"; +import Loading from "@/components/loading"; +import { useCustomerPlan } from "@/hooks/use-payments"; import { UserPlan } from "@/services/payments"; -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; + import { Config } from "./Config"; const PlanLoading = styled.View` @@ -56,10 +59,16 @@ export const CurrentPlanConfig = () => { // testID disambiguates the upgrade CTA vs the manage-subscription CTA // so Maestro can assert on the pre/post-purchase state of this row. const testID = - userPlan === UserPlan.Premium ? "profile-current-plan-premium" : "profile-current-plan-upgrade"; + userPlan === UserPlan.Premium + ? "profile-current-plan-premium" + : "profile-current-plan-upgrade"; return ( - + {t("profile.plan.currentPlan")} @@ -68,20 +77,23 @@ export const CurrentPlanConfig = () => { ) : null} - {userPlan ? {t(`plans.${userPlan}`)} : null} + {userPlan ? ( + {t(`plans.${userPlan}`)} + ) : null} {plan.isError ? ( {t("profile.plan.errorLoading")} ) : null} - {!plan.isLoading ? ( + {plan.isLoading ? null : ( {userPlan === UserPlan.Free && t("profile.plan.upgradeToPremium")} - {userPlan === UserPlan.Premium && t("profile.plan.until", { date: expirationDate })} + {userPlan === UserPlan.Premium && + t("profile.plan.until", { date: expirationDate })} {plan.isError ? t("profile.plan.clickToRetry") : null} - ) : null} + )} ); diff --git a/apps/mobile/src/views/(tabs)/Profile/components/LanguageConfig.tsx b/apps/mobile/src/views/(tabs)/Profile/components/language-config.tsx similarity index 77% rename from apps/mobile/src/views/(tabs)/Profile/components/LanguageConfig.tsx rename to apps/mobile/src/views/(tabs)/Profile/components/language-config.tsx index dfef5bf..3d23d8e 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/LanguageConfig.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/language-config.tsx @@ -1,13 +1,15 @@ +import type { BottomSheetModal } from "@gorhom/bottom-sheet"; + import { useRef } from "react"; -import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "styled-components/native"; import { Language } from "@pegada/shared/i18n/types/types"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "styled-components/native"; import Translate from "@/assets/images/Translate.svg"; import { PickerSheet } from "@/components/Picker"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; + import { Config } from "./Config"; // Not translating languages for now, as it's useful to @@ -29,7 +31,9 @@ export const LanguageConfig = () => { const theme = useTheme(); - const value = languagesPickerData.find(({ id }) => id === currentLanguage) ?? { + const value = languagesPickerData.find( + ({ id }) => id === currentLanguage, + ) ?? { id: currentLanguage, name: LANGUAGES[Language.Default], }; @@ -37,7 +41,10 @@ export const LanguageConfig = () => { const pickerSheetRef = useRef(null); return ( - pickerSheetRef?.current?.present()}> + pickerSheetRef?.current?.present()} + > @@ -54,7 +61,9 @@ export const LanguageConfig = () => { data={languagesPickerData} itemTestIDPrefix="language-item-" onChange={(item) => { - i18n.changeLanguage(item.id as keyof typeof LANGUAGES).catch(sendError); + i18n + .changeLanguage(item.id as keyof typeof LANGUAGES) + .catch(sendError); }} ref={pickerSheetRef} /> diff --git a/apps/mobile/src/views/(tabs)/Profile/components/LocationConfig.tsx b/apps/mobile/src/views/(tabs)/Profile/components/location-config.tsx similarity index 74% rename from apps/mobile/src/views/(tabs)/Profile/components/LocationConfig.tsx rename to apps/mobile/src/views/(tabs)/Profile/components/location-config.tsx index 78e85a2..a6c1c82 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/LocationConfig.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/location-config.tsx @@ -1,12 +1,15 @@ -import { ActivityIndicator, View } from "react-native"; +import { ActivityIndicator } from "react-native"; + import { useRouter } from "expo-router"; + import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; import Location from "@/assets/images/Location.svg"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { useCurrentCityText } from "@/hooks/useCurrentCityText"; -import { SceneName } from "@/types/SceneName"; +import { useCurrentCityText } from "@/hooks/use-current-city-text"; +import { SceneName } from "@/types/scene-name"; + import { Config } from "./Config"; const CurrentLocation = () => { @@ -31,7 +34,10 @@ const CurrentLocationLoading = () => { const theme = useTheme(); return ( - + ); }; export const LocationConfig = () => { @@ -41,10 +47,13 @@ export const LocationConfig = () => { const theme = useTheme(); return ( - router.push(SceneName.LocationMap)}> - + router.push(SceneName.LocationMap)} + > + - + {t("profile.updateLocation")} diff --git a/apps/mobile/src/views/(tabs)/Profile/components/ThemeConfig.tsx b/apps/mobile/src/views/(tabs)/Profile/components/theme-config.tsx similarity index 79% rename from apps/mobile/src/views/(tabs)/Profile/components/ThemeConfig.tsx rename to apps/mobile/src/views/(tabs)/Profile/components/theme-config.tsx index c19d3b3..8453ec6 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/ThemeConfig.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/theme-config.tsx @@ -1,12 +1,16 @@ +import type { ActiveTheme } from "@/contexts/theme-provider"; +import type { BottomSheetModal } from "@gorhom/bottom-sheet"; + import { useRef } from "react"; -import { BottomSheetModal } from "@gorhom/bottom-sheet"; + import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; import LightMode from "@/assets/images/LightMode.svg"; import { PickerSheet } from "@/components/Picker"; -import { ActiveTheme, useActiveTheme } from "@/contexts/ThemeProvider"; -import { sendError } from "@/services/errorTracking"; +import { useActiveTheme } from "@/contexts/theme-provider"; +import { sendError } from "@/services/error-tracking"; + import { Config } from "./Config"; export const ThemeConfig = () => { @@ -35,7 +39,10 @@ export const ThemeConfig = () => { const pickerSheetRef = useRef(null); return ( - pickerSheetRef?.current?.present()}> + pickerSheetRef?.current?.present()} + > diff --git a/apps/mobile/src/views/(tabs)/Profile/index.tsx b/apps/mobile/src/views/(tabs)/Profile/index.tsx index 9249466..77f7cc7 100644 --- a/apps/mobile/src/views/(tabs)/Profile/index.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/index.tsx @@ -1,4 +1,11 @@ -import { View } from "react-native"; +import { useFocusEffect, useRouter } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import * as StoreReview from "expo-store-review"; + +import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; +import { useIsFocused, useScrollToTop } from "@react-navigation/native"; +import { t } from "i18next"; +import { useTranslation } from "react-i18next"; import Animated, { Extrapolation, interpolate, @@ -7,13 +14,6 @@ import Animated, { useScrollViewOffset, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useFocusEffect, useRouter } from "expo-router"; -import { StatusBar } from "expo-status-bar"; -import * as StoreReview from "expo-store-review"; -import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; -import { useIsFocused, useScrollToTop } from "@react-navigation/native"; -import { t } from "i18next"; -import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; import Dog from "@/assets/images/Dog.svg"; @@ -21,20 +21,23 @@ import Erase from "@/assets/images/Erase.svg"; import Filters from "@/assets/images/Filters.svg"; import Paperwork from "@/assets/images/Paperwork.svg"; import SignOut from "@/assets/images/SignOut.svg"; -import Divider from "@/components/Divider"; -import { Text } from "@/components/Text"; -import { useWarmUpBrowser } from "@/hooks/useWarmUpBrowser"; +import Divider from "@/components/divider"; +import { Text } from "@/components/text"; +import { useWarmUpBrowser } from "@/hooks/use-warm-up-browser"; import { analytics } from "@/services/analytics"; -import { sendError } from "@/services/errorTracking"; -import { openWebBrowser } from "@/services/openWebBrowser"; +import { sendError } from "@/services/error-tracking"; +import { openWebBrowser } from "@/services/open-web-browser"; import { StorageKeys, storeData } from "@/services/storage"; -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; + import { Config } from "./components/Config"; -import { CurrentPlanConfig } from "./components/CurrentPlanConfig"; -import { LanguageConfig } from "./components/LanguageConfig"; -import { LocationConfig } from "./components/LocationConfig"; -import { ThemeConfig } from "./components/ThemeConfig"; -import UserDogProfileHeader, { useDogProfileHeight } from "./components/UserDogProfileHeader"; +import { CurrentPlanConfig } from "./components/current-plan-config"; +import { LanguageConfig } from "./components/language-config"; +import { LocationConfig } from "./components/location-config"; +import { ThemeConfig } from "./components/theme-config"; +import UserDogProfileHeader, { + useDogProfileHeight, +} from "./components/UserDogProfileHeader"; import { BackgroundOverlay, BackgroundProfileContainer, @@ -42,9 +45,11 @@ import { Content, ScrollContainer, SettingsList, + SettingsBlock, + settingsScroll, } from "./styles"; -import { deleteAccount } from "./utils/deleteAccount"; -import { handleLogout } from "./utils/handleLogout"; +import { deleteAccount } from "./utils/delete-account"; +import { handleLogout } from "./utils/handle-logout"; const openTermsOfUse = () => { analytics.track({ event_type: "Open Terms Of Use" }); @@ -84,7 +89,12 @@ const Profile = () => { const imgStyle = useAnimatedStyle(() => { "worklet"; // should scale down the image a little - const scale = interpolate(scrollY.value, [0, TRANSITION_POINT], [1, 0.97], Extrapolation.CLAMP); + const scale = interpolate( + scrollY.value, + [0, TRANSITION_POINT], + [1, 0.97], + Extrapolation.CLAMP, + ); return { transform: [{ scale }], @@ -129,18 +139,17 @@ const Profile = () => { { {t("profile.settings")} - @@ -171,7 +175,9 @@ const Profile = () => { {t("profile.matchPreferences")} - {t("profile.matchPreferencesDescription")} + + {t("profile.matchPreferencesDescription")} + @@ -184,7 +190,9 @@ const Profile = () => { {t("profile.editProfile")} - {t("profile.editProfileDescription")} + + {t("profile.editProfileDescription")} + @@ -199,17 +207,24 @@ const Profile = () => { {t("profile.termsOfUse")} - {t("profile.termsOfUseDescription")} + + {t("profile.termsOfUseDescription")} + - + {t("profile.privacyPolicy")} - {t("profile.privacyPolicyDescription")} + + {t("profile.privacyPolicyDescription")} + @@ -219,7 +234,9 @@ const Profile = () => { {t("profile.rateTheApp")} - {t("profile.rateTheAppDescription")} + + {t("profile.rateTheAppDescription")} + @@ -234,13 +251,18 @@ const Profile = () => { - + - {t("profile.deleteAccount")} + + {t("profile.deleteAccount")} + - + diff --git a/apps/mobile/src/views/(tabs)/Profile/styles.ts b/apps/mobile/src/views/(tabs)/Profile/styles.ts index d0f6157..d9003a9 100644 --- a/apps/mobile/src/views/(tabs)/Profile/styles.ts +++ b/apps/mobile/src/views/(tabs)/Profile/styles.ts @@ -1,16 +1,20 @@ -import Animated from "react-native-reanimated"; +import { StyleSheet } from "react-native"; + import Color from "color"; +import Animated from "react-native-reanimated"; import styled from "styled-components/native"; -import { PressableArea } from "@/components/PressableArea"; +import { PressableArea } from "@/components/pressable-area"; export const PlanContainer = styled(PressableArea)` position: absolute; top: ${(props) => props.theme.spacing[4]}px; right: ${(props) => props.theme.spacing[4]}px; - padding: ${(props) => props.theme.spacing[2]}px ${(props) => props.theme.spacing[3]}px; + padding: ${(props) => props.theme.spacing[2]}px + ${(props) => props.theme.spacing[3]}px; border-radius: ${(props) => props.theme.radii.sm}px; - background-color: ${({ theme }) => Color(theme.colors.background).alpha(0.6).string()}; + background-color: ${({ theme }) => + new Color(theme.colors.background).alpha(0.6).string()}; z-index: 10; `; @@ -58,3 +62,18 @@ export const Content = styled.View` border-bottom-width: ${(props) => props.theme.stroke.sm}px; background-color: ${({ theme }) => theme.colors.background}; `; + +/** + * The opaque block the settings rows sit on. It owns the background below the + * photo header, so it has to grow to the bottom of the viewport. + */ +export const SettingsBlock = styled.View` + background-color: ${({ theme }) => theme.colors.background}; + padding-top: ${({ theme }) => theme.spacing[1]}px; + flex-grow: 1; +`; + +/** `contentContainerStyle` takes a style object, not a component. */ +export const { settingsScroll } = StyleSheet.create({ + settingsScroll: { flexGrow: 1, zIndex: 10 }, +}); diff --git a/apps/mobile/src/views/(tabs)/Profile/utils/delete-account.tsx b/apps/mobile/src/views/(tabs)/Profile/utils/delete-account.tsx new file mode 100644 index 0000000..4af0c1c --- /dev/null +++ b/apps/mobile/src/views/(tabs)/Profile/utils/delete-account.tsx @@ -0,0 +1,48 @@ +import { Alert } from "react-native"; + +import { t } from "i18next"; + +import { getTrcpContext } from "@/contexts/trcp-context"; +import i18n from "@/i18n"; +import { analytics } from "@/services/analytics"; +import { sendError } from "@/services/error-tracking"; +import { logout } from "@/services/logout"; + +export const deleteAccount = () => { + analytics.track({ + event_type: "Delete Account Pressed", + }); + + Alert.alert( + i18n.t("profile.deleteAccount"), + i18n.t("profile.deleteAccountConfirmation"), + [ + { + text: t("profile.cancel"), + style: "cancel", + onPress: () => { + analytics.track({ event_type: "Delete Account Canceled" }); + }, + }, + { + text: t("profile.delete"), + style: "destructive", + onPress: () => { + analytics.track({ event_type: "Delete Account Confirmed" }); + // App Store Guideline 5.1.1(v) requires actual account deletion, + // not just a dog soft-delete. user.deleteMe wipes the user row plus + // all dependent records (dogs, images, matches, interests, messages) + // in a single transaction server-side, then we clear the local + // session via logout(). + getTrcpContext() + .client.user.deleteMe.mutate() + .then(() => logout()) + .catch((error) => { + Alert.alert(t("common.oops"), t("common.tryAgainLater")); + sendError(error); + }); + }, + }, + ], + ); +}; diff --git a/apps/mobile/src/views/(tabs)/Profile/utils/deleteAccount.tsx b/apps/mobile/src/views/(tabs)/Profile/utils/deleteAccount.tsx deleted file mode 100644 index 818595b..0000000 --- a/apps/mobile/src/views/(tabs)/Profile/utils/deleteAccount.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Alert } from "react-native"; -import { t } from "i18next"; - -import { getTrcpContext } from "@/contexts/trcpContext"; -import i18n from "@/i18n"; -import { analytics } from "@/services/analytics"; -import { sendError } from "@/services/errorTracking"; -import { logout } from "@/services/logout"; - -export const deleteAccount = () => { - analytics.track({ - event_type: "Delete Account Pressed", - }); - - Alert.alert(i18n.t("profile.deleteAccount"), i18n.t("profile.deleteAccountConfirmation"), [ - { - text: t("profile.cancel"), - style: "cancel", - onPress: () => { - analytics.track({ event_type: "Delete Account Canceled" }); - }, - }, - { - text: t("profile.delete"), - style: "destructive", - onPress: () => { - analytics.track({ event_type: "Delete Account Confirmed" }); - // App Store Guideline 5.1.1(v) requires actual account deletion, - // not just a dog soft-delete. user.deleteMe wipes the user row plus - // all dependent records (dogs, images, matches, interests, messages) - // in a single transaction server-side, then we clear the local - // session via logout(). - getTrcpContext() - .client.user.deleteMe.mutate() - .then(() => logout()) - .catch((error) => { - Alert.alert(t("common.oops"), t("common.tryAgainLater")); - sendError(error); - }); - }, - }, - ]); -}; diff --git a/apps/mobile/src/views/(tabs)/Profile/utils/handleLogout.tsx b/apps/mobile/src/views/(tabs)/Profile/utils/handle-logout.tsx similarity index 100% rename from apps/mobile/src/views/(tabs)/Profile/utils/handleLogout.tsx rename to apps/mobile/src/views/(tabs)/Profile/utils/handle-logout.tsx diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/index.tsx b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/index.tsx index 680ef1e..5e973fa 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/index.tsx +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/index.tsx @@ -1,14 +1,16 @@ -import { SlideInRight, SlideOutRight } from "react-native-reanimated"; import { useRouter } from "expo-router"; + +import { SlideInRight, SlideOutRight } from "react-native-reanimated"; import { useDispatch, useSelector } from "react-redux"; import { useTheme } from "styled-components/native"; import SwipeBackArrow from "@/assets/images/SwipeBackArrow.svg"; -import { useUnsafeIsPremium } from "@/hooks/usePayments"; +import { useUnsafeIsPremium } from "@/hooks/use-payments"; import { analytics } from "@/services/analytics"; import { Actions } from "@/store/reducers"; import { getLastCardId } from "@/store/selectors"; -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; + import { Container, GoBack } from "./styles"; const SwipeBackButton = () => { diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/styles.ts b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/styles.ts index 3e3d630..5be8d61 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/styles.ts +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/styles.ts @@ -1,7 +1,7 @@ import Animated from "react-native-reanimated"; import styled from "styled-components/native"; -import { PressableArea } from "@/components/PressableArea"; +import { PressableArea } from "@/components/pressable-area"; export const Container = styled(Animated.View)` z-index: 20; diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture.ts b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/use-swipe-gesture.ts similarity index 83% rename from apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture.ts rename to apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/use-swipe-gesture.ts index 4ed7197..6a2a947 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture.ts +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/use-swipe-gesture.ts @@ -1,12 +1,14 @@ -import { useState } from "react"; -import { - Gesture, +import type { GestureEventPayload, PanGestureHandlerEventPayload, } from "react-native-gesture-handler"; +import type { SharedValue } from "react-native-reanimated"; + +import { useState } from "react"; + +import { Gesture } from "react-native-gesture-handler"; import { runOnJS, - SharedValue, useSharedValue, withSpring, withTiming, @@ -15,15 +17,15 @@ import { import { ACTION_OFFSET, ACTION_VELOCITY, CARD } from "@/constants"; import { haptics } from "@/services/haptics"; -export interface Translation { +export type Translation = { x: SharedValue; y: SharedValue; -} +}; -interface Coordinate { +type Coordinate = { x?: number; y?: number; -} +}; export enum Swipe { Dislike = "NOT_INTERESTED", @@ -54,13 +56,15 @@ const getSwipeType = ( "worklet"; const horizontalTrigger = - Math.abs(event.translationX) > ACTION_OFFSET || Math.abs(event.velocityX) > ACTION_VELOCITY; + Math.abs(event.translationX) > ACTION_OFFSET || + Math.abs(event.velocityX) > ACTION_VELOCITY; if (horizontalTrigger && event.translationX < 0) return Swipe.Dislike; if (horizontalTrigger && event.translationX > 0) return Swipe.Like; const verticalTrigger = - Math.abs(event.translationY) > ACTION_OFFSET || Math.abs(event.velocityY) > ACTION_VELOCITY; + Math.abs(event.translationY) > ACTION_OFFSET || + Math.abs(event.velocityY) > ACTION_VELOCITY; if (verticalTrigger && event.translationY < 0) return Swipe.Maybe; }; @@ -80,17 +84,25 @@ const gotoCoordinate = ( const callbackY = willMoveX ? undefined : callback; if (willMoveX) { - translation.x.value = withTiming(coordinates.x ?? 0, animationConfig, callback); + translation.x.value = withTiming( + coordinates.x ?? 0, + animationConfig, + callback, + ); } if (willMoveY) { - translation.y.value = withTiming(coordinates.y ?? 0, animationConfig, callbackY); + translation.y.value = withTiming( + coordinates.y ?? 0, + animationConfig, + callbackY, + ); } }; -interface UseSwipeGestureProps { +type UseSwipeGestureProps = { onSwipeComplete: (data: Swipe) => void; -} +}; export const useSwipeGesture = ({ onSwipeComplete }: UseSwipeGestureProps) => { const [enabled, setEnabled] = useState(true); @@ -111,7 +123,10 @@ export const useSwipeGesture = ({ onSwipeComplete }: UseSwipeGestureProps) => { setTimeout(() => setEnabled(true), duration); }; - const gotoDirection = (swipeDirection: Swipe, animationConfig = { duration: 250 }) => { + const gotoDirection = ( + swipeDirection: Swipe, + animationConfig = { duration: 250 }, + ) => { "worklet"; // Avoid concurrency, should gotoDirection only once @@ -146,7 +161,7 @@ export const useSwipeGesture = ({ onSwipeComplete }: UseSwipeGestureProps) => { // Fire a tick exactly once per crossing of the decision threshold, // reusing the same rule `onEnd` uses to commit to a direction. - const crossedNow = !!getSwipeType(event); + const crossedNow = Boolean(getSwipeType(event)); if (crossedNow && !hasCrossedThreshold.value) { runOnJS(haptics.selection)(); } diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/index.tsx b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/index.tsx index e786841..07bc051 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/index.tsx +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/index.tsx @@ -1,6 +1,10 @@ +import type { Swipe } from "./hooks/use-swipe-gesture"; +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; + import { useEffect } from "react"; import * as React from "react"; import { StyleSheet } from "react-native"; + import { GestureDetector } from "react-native-gesture-handler"; import Animated, { interpolate, @@ -14,19 +18,19 @@ import FeedbackCard from "@/components/FeedbackCard"; import { ACTION_OFFSET } from "@/constants"; import { useDidMountEffect } from "@/services/utils"; import { Actions } from "@/store/reducers"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; import { getCurrentCardId } from "@/store/selectors"; -import { Swipe, useSwipeGesture } from "./hooks/useSwipeGesture"; + +import { useSwipeGesture } from "./hooks/use-swipe-gesture"; const ROTATION_DEG = 8; -interface SwipeHandlerProps { +type SwipeHandlerProps = { card: SwipeDog; -} +}; -export interface SwipeHandlerRefProps { +export type SwipeHandlerRefProps = { gotoDirection: (swipeType: Swipe) => void; -} +}; export const swipeHandlerRef = React.createRef(); @@ -40,15 +44,22 @@ const SwipeHandler: React.FC = ({ card }) => { dispatch(Actions.dogs.swipe.request({ id: card.id, swipeType })); }; - const [translation, gestureHandler, gotoDirection, enabled] = useSwipeGesture({ - onSwipeComplete, - }); + const [translation, gestureHandler, gotoDirection, enabled] = useSwipeGesture( + { + onSwipeComplete, + }, + ); - const automaticSwipe = (swipeType: Swipe) => { - "worklet"; + // Memoised so the effect below doesn't reinstall the imperative handle on + // every render โ€” a worklet is a new function object each time otherwise. + const automaticSwipe = React.useCallback( + (swipeType: Swipe) => { + "worklet"; - gotoDirection(swipeType, { duration: 500 }); - }; + gotoDirection(swipeType, { duration: 500 }); + }, + [gotoDirection], + ); // useImperativeHandle unloads the ref depending on component rendering order // This is a new behavior that caused bugs, and had to be replaced with useEffect @@ -62,7 +73,6 @@ const SwipeHandler: React.FC = ({ card }) => { useDidMountEffect(() => { if (isFirstCard) { - // eslint-disable-next-line react-compiler/react-compiler -- false positive translation.x.value = withSpring(0, { stiffness: 50 }); translation.y.value = withSpring(0, { stiffness: 50 }); } @@ -89,7 +99,11 @@ const SwipeHandler: React.FC = ({ card }) => { return ( - + ); diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/index.tsx b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/index.tsx index 5bfc044..76814d2 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/index.tsx +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/index.tsx @@ -1,7 +1,11 @@ +import type { RootReducer } from "@/store/reducers"; + import { View } from "react-native"; -import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated"; + import { router } from "expo-router"; + import { useTranslation } from "react-i18next"; +import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated"; import { useDispatch, useSelector } from "react-redux"; import { Button } from "@/components/Button"; @@ -11,8 +15,9 @@ import { useIsOffline, } from "@/components/NetworkBoundary"; import { Container, Content } from "@/components/NetworkBoundary/styles"; -import { Actions, RootReducer } from "@/store/reducers"; -import { SceneName } from "@/types/SceneName"; +import { Actions } from "@/store/reducers"; +import { SceneName } from "@/types/scene-name"; + import { Description, EmptyAnimation, LogoLoading, Title } from "./styles"; export const EmptyComponent = () => { @@ -33,13 +38,16 @@ const EmptyState = () => { - + <Title fontWeight="bold"> {t("swipeRequestFeedback.emptyTitle")} - + {t("swipeRequestFeedback.emptyDescription")} - diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/styles.ts b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/styles.ts index c6b3136..a81cee6 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/styles.ts +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/styles.ts @@ -2,7 +2,7 @@ import LottieView from "lottie-react-native"; import styled from "styled-components/native"; import * as LikeFeedbackStyles from "@/components/FeedbackCard/components/LikeFeedback/styles"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Container = styled(LikeFeedbackStyles.Container)` background-color: transparent; @@ -28,10 +28,12 @@ export const LogoLoading = styled(LottieView).attrs({ export const Title = styled(Text)` margin-bottom: ${(props) => props.theme.spacing[1]}px; + padding-bottom: 2px; text-align: center; `; export const Description = styled(Text)` + padding-bottom: 4px; text-align: center; margin-bottom: ${(props) => props.theme.spacing[4]}px; max-width: 274px; diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/ChangeLocation.tsx b/apps/mobile/src/views/(tabs)/Swipe/components/change-location.tsx similarity index 79% rename from apps/mobile/src/views/(tabs)/Swipe/components/ChangeLocation.tsx rename to apps/mobile/src/views/(tabs)/Swipe/components/change-location.tsx index 8068590..9c0c739 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/ChangeLocation.tsx +++ b/apps/mobile/src/views/(tabs)/Swipe/components/change-location.tsx @@ -1,15 +1,18 @@ import { ActivityIndicator } from "react-native"; + import { useRouter } from "expo-router"; + import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; import ArrowDown from "@/assets/images/ArrowDown.svg"; import Location from "@/assets/images/Location.svg"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { PressableArea } from "@/components/PressableArea"; -import { Text } from "@/components/Text"; -import { SceneName } from "@/types/SceneName"; -import { useCurrentCityText } from "../../../../hooks/useCurrentCityText"; +import { Text } from "@/components/text"; +import { SceneName } from "@/types/scene-name"; + +import { useCurrentCityText } from "../../../../hooks/use-current-city-text"; +import { LocationButton } from "../styles"; const CurrentLocation = () => { const currentCityText = useCurrentCityText(); @@ -49,17 +52,10 @@ export const ChangeLocation = () => { const router = useRouter(); return ( - { router.push(SceneName.LocationMap); }} - style={{ - padding: theme.spacing[2], - flexDirection: "row", - alignItems: "center", - alignSelf: "center", - marginBottom: theme.spacing[2], - }} > { }} fill={theme.colors.primary} /> - + ); }; diff --git a/apps/mobile/src/views/(tabs)/Swipe/index.tsx b/apps/mobile/src/views/(tabs)/Swipe/index.tsx index 9cf21df..1cfd98c 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/index.tsx +++ b/apps/mobile/src/views/(tabs)/Swipe/index.tsx @@ -1,23 +1,25 @@ import { useEffect } from "react"; import * as React from "react"; + import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useDispatch, useSelector } from "react-redux"; import { MatchActionBar } from "@/components/MatchActionBar"; -import { sendError } from "@/services/errorTracking"; -import { trackUser } from "@/services/getInitialRouteName"; +import { sendError } from "@/services/error-tracking"; +import { trackUser } from "@/services/get-initial-route-name"; import { getPushNotificationToken, NotificationTokenError, setPushNotificationToken, -} from "@/services/getPushNotificationToken"; +} from "@/services/get-push-notification-token"; import { processLinks } from "@/services/linking"; import { Actions } from "@/store/reducers/dogs"; import { getCards, getCurrentCardId } from "@/store/selectors"; -import { ChangeLocation } from "./components/ChangeLocation"; + +import { ChangeLocation } from "./components/change-location"; import SwipeBackButton from "./components/SwipeBackButton"; import SwipeHandler, { swipeHandlerRef } from "./components/SwipeHandler"; -import { Swipe } from "./components/SwipeHandler/hooks/useSwipeGesture"; +import { Swipe } from "./components/SwipeHandler/hooks/use-swipe-gesture"; import SwipeRequestFeedback from "./components/SwipeRequestFeedback"; import { Container } from "./styles"; diff --git a/apps/mobile/src/views/(tabs)/Swipe/styles.ts b/apps/mobile/src/views/(tabs)/Swipe/styles.ts index 0d29cf7..0735537 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/styles.ts +++ b/apps/mobile/src/views/(tabs)/Swipe/styles.ts @@ -1,9 +1,20 @@ import { SafeAreaView } from "react-native-safe-area-context"; import styled from "styled-components/native"; +import { PressableArea } from "@/components/pressable-area"; + export const Container = styled(SafeAreaView).attrs({ edges: ["left", "right"], })` flex: 1; background-color: ${(props) => props.theme.colors.background}; `; + +/** The "change location" pill above the card stack. */ +export const LocationButton = styled(PressableArea)` + padding: ${({ theme }) => theme.spacing[2]}px; + flex-direction: row; + align-items: center; + align-self: center; + margin-bottom: ${({ theme }) => theme.spacing[2]}px; +`; diff --git a/apps/mobile/src/views/Chat/components/Header/index.tsx b/apps/mobile/src/views/Chat/components/Header/index.tsx index cfc58c5..501758f 100644 --- a/apps/mobile/src/views/Chat/components/Header/index.tsx +++ b/apps/mobile/src/views/Chat/components/Header/index.tsx @@ -1,20 +1,26 @@ import { ActivityIndicator } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { useLocalSearchParams, useRouter } from "expo-router"; + import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "styled-components/native"; import BackArrow from "@/assets/images/BackArrow.svg"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { Text } from "@/components/Text"; -import { api } from "@/contexts/TRPCProvider"; -import { SceneName } from "@/types/SceneName"; +import { Text } from "@/components/text"; +import { api } from "@/contexts/trpc-provider"; +import { SceneName } from "@/types/scene-name"; + import * as S from "./styles"; export const HEADER_HEIGHT = 65; const DogProfileInfo = ({ dogId }: { dogId: string }) => { - const [dog] = api.dog.get.useSuspenseQuery({ id: dogId }, { refetchOnMount: false }); + const [dog] = api.dog.get.useSuspenseQuery( + { id: dogId }, + { refetchOnMount: false }, + ); return ( diff --git a/apps/mobile/src/views/Chat/components/Header/styles.tsx b/apps/mobile/src/views/Chat/components/Header/styles.tsx index 207057c..9febd4d 100644 --- a/apps/mobile/src/views/Chat/components/Header/styles.tsx +++ b/apps/mobile/src/views/Chat/components/Header/styles.tsx @@ -1,9 +1,9 @@ import Color from "color"; import styled from "styled-components/native"; -import { BlurView } from "@/components/BlurView"; -import { Image } from "@/components/Image"; -import { PressableArea } from "@/components/PressableArea"; +import { BlurView } from "@/components/blur-view"; +import { Image } from "@/components/image"; +import { PressableArea } from "@/components/pressable-area"; export const BackTouchArea = styled(PressableArea)` padding: ${(props) => props.theme.spacing[4]}px; @@ -14,7 +14,8 @@ export const Picture = styled(Image)` height: 38px; border-radius: ${(props) => props.theme.radii.round}px; margin-right: ${(props) => props.theme.spacing[3.5]}px; - background-color: ${(props) => Color(props.theme.colors.text).alpha(0.2).string()}; + background-color: ${(props) => + new Color(props.theme.colors.text).alpha(0.2).string()}; `; export const ProfileInfoContainer = styled.View` diff --git a/apps/mobile/src/views/Chat/components/Message/index.tsx b/apps/mobile/src/views/Chat/components/Message/index.tsx index 9cf00fc..12ca0bf 100644 --- a/apps/mobile/src/views/Chat/components/Message/index.tsx +++ b/apps/mobile/src/views/Chat/components/Message/index.tsx @@ -1,26 +1,33 @@ import * as React from "react"; -import { SlideInLeft, SlideInRight, SlideOutRight } from "react-native-reanimated"; + import { format } from "date-fns"; +import { + SlideInLeft, + SlideInRight, + SlideOutRight, +} from "react-native-reanimated"; + +import { Text } from "@/components/text"; -import { Text } from "@/components/Text"; -import Feedback, { FeedbackStatus } from "../Feedback"; +import Feedback, { FeedbackStatus } from "../feedback"; import { Info, Message, Time } from "./styles"; -interface MessageComponentProps { +type MessageComponentProps = { children: string; self: boolean; createdAt: Date; status?: FeedbackStatus; id: string; newMessage?: boolean; -} +}; export const MessageComponent: React.FC = (props) => { const { children, self, createdAt, status, newMessage, id } = props; const incomingAnimation = newMessage ? SlideInLeft : undefined; - const outgoingAnimation = status === FeedbackStatus.Loading ? SlideInRight : undefined; + const outgoingAnimation = + status === FeedbackStatus.Loading ? SlideInRight : undefined; const enteringAnimation = self ? outgoingAnimation : incomingAnimation; diff --git a/apps/mobile/src/views/Chat/components/Message/styles.ts b/apps/mobile/src/views/Chat/components/Message/styles.ts index ecc5bb0..5c063b8 100644 --- a/apps/mobile/src/views/Chat/components/Message/styles.ts +++ b/apps/mobile/src/views/Chat/components/Message/styles.ts @@ -1,16 +1,19 @@ -import Animated from "react-native-reanimated"; import Color from "color"; +import Animated from "react-native-reanimated"; import styled, { css } from "styled-components/native"; -import { Text } from "@/components/Text"; -import { FeedbackStatus } from "../Feedback"; +import { Text } from "@/components/text"; + +import { FeedbackStatus } from "../feedback"; -interface MessageProps { +type MessageProps = { sending: boolean; status?: FeedbackStatus; -} +}; -export const Message = styled(Animated.View).attrs({ accessible: true })` +export const Message = styled(Animated.View).attrs({ + accessible: true, +})` padding: ${(props) => props.theme.spacing[2.5]}px; padding-top: ${(props) => props.theme.spacing[1.5]}px; padding-bottom: ${(props) => props.theme.spacing[2.5]}px; @@ -62,5 +65,5 @@ export const Info = styled.View` `; export const Time = styled(Text).attrs({ fontSize: "xxs" })` - color: ${(props) => Color(props.theme.colors.text).alpha(0.5).string()}; + color: ${(props) => new Color(props.theme.colors.text).alpha(0.5).string()}; `; diff --git a/apps/mobile/src/views/Chat/components/NextDay/index.tsx b/apps/mobile/src/views/Chat/components/NextDay/index.tsx index 08a2ba1..e431f75 100644 --- a/apps/mobile/src/views/Chat/components/NextDay/index.tsx +++ b/apps/mobile/src/views/Chat/components/NextDay/index.tsx @@ -1,9 +1,16 @@ -import { format, isSameDay, isThisWeek, isThisYear, isToday, isYesterday } from "date-fns"; -import { enUS, pt } from "date-fns/locale"; - import { Language } from "@pegada/shared/i18n/types/types"; +import { + format, + isSameDay, + isThisWeek, + isThisYear, + isToday, + isYesterday, +} from "date-fns"; +import { enUS, pt } from "date-fns/locale"; import i18n from "@/i18n"; + import { Container, DateText } from "./styles"; const formatDate = (date: Date) => { @@ -12,15 +19,17 @@ const formatDate = (date: Date) => { if (isToday(date)) { return i18n.t("chat.today"); - } else if (isYesterday(date)) { + } + if (isYesterday(date)) { return i18n.t("chat.yesterday"); - } else if (isThisWeek(date)) { + } + if (isThisWeek(date)) { return format(date, "eeee", { locale: isPtBr ? pt : enUS }); - } else if (isThisYear(date)) { + } + if (isThisYear(date)) { return format(date, "EEE., d MMM", { locale: isPtBr ? pt : enUS }); - } else { - return format(date, "d MMM, yyyy", { locale: isPtBr ? pt : enUS }); } + return format(date, "d MMM, yyyy", { locale: isPtBr ? pt : enUS }); }; const Component = ({ diff --git a/apps/mobile/src/views/Chat/components/NextDay/styles.ts b/apps/mobile/src/views/Chat/components/NextDay/styles.ts index e3edd41..e76de0e 100644 --- a/apps/mobile/src/views/Chat/components/NextDay/styles.ts +++ b/apps/mobile/src/views/Chat/components/NextDay/styles.ts @@ -1,13 +1,16 @@ import Color from "color"; import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Container = styled.View` - margin: ${(props) => props.theme.spacing[4]}px auto ${(props) => props.theme.spacing[5]}px auto; + margin: ${(props) => props.theme.spacing[4]}px auto + ${(props) => props.theme.spacing[5]}px auto; - padding: ${(props) => props.theme.spacing[0.5]}px ${(props) => props.theme.spacing[2.5]}px - ${(props) => props.theme.spacing[1.5]}px ${(props) => props.theme.spacing[2.5]}px; + padding: ${(props) => props.theme.spacing[0.5]}px + ${(props) => props.theme.spacing[2.5]}px + ${(props) => props.theme.spacing[1.5]}px + ${(props) => props.theme.spacing[2.5]}px; border-radius: ${(props) => props.theme.radii.round}px; background-color: ${(props) => props.theme.colors.card}; @@ -22,5 +25,5 @@ export const Container = styled.View` `; export const DateText = styled(Text)` - color: ${(props) => Color(props.theme.colors.text).alpha(0.5).string()}; + color: ${(props) => new Color(props.theme.colors.text).alpha(0.5).string()}; `; diff --git a/apps/mobile/src/views/Chat/components/Send/index.tsx b/apps/mobile/src/views/Chat/components/Send/index.tsx index 1e09aa3..b1a91e3 100644 --- a/apps/mobile/src/views/Chat/components/Send/index.tsx +++ b/apps/mobile/src/views/Chat/components/Send/index.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; + import { useTranslation } from "react-i18next"; -import { useKeyboardAwareSafeAreaInsets } from "@/hooks/useKeyboardAwareSafeAreaInsets"; -import { useSendMessage } from "../../hooks/useSendMessage"; +import { useKeyboardAwareSafeAreaInsets } from "@/hooks/use-keyboard-aware-safe-area-insets"; + +import { useSendMessage } from "../../hooks/use-send-message"; import { Container, Input } from "./styles"; export const SEND_HEIGHT = 65; diff --git a/apps/mobile/src/views/Chat/components/Send/styles.ts b/apps/mobile/src/views/Chat/components/Send/styles.ts index 820e38b..271c431 100644 --- a/apps/mobile/src/views/Chat/components/Send/styles.ts +++ b/apps/mobile/src/views/Chat/components/Send/styles.ts @@ -1,13 +1,14 @@ import styled from "styled-components/native"; -import { BlurView } from "@/components/BlurView"; +import { BlurView } from "@/components/blur-view"; export const Input = styled.TextInput.attrs((props) => ({ placeholderTextColor: props.theme.colors.placeholder, selectionColor: props.theme.colors.primary, }))` border-radius: ${(props) => props.theme.radii.md}px; - padding: ${(props) => props.theme.spacing[2]}px ${(props) => props.theme.spacing[4]}px; + padding: ${(props) => props.theme.spacing[2]}px + ${(props) => props.theme.spacing[4]}px; background-color: ${(props) => props.theme.colors.card}; diff --git a/apps/mobile/src/views/Chat/components/Feedback.tsx b/apps/mobile/src/views/Chat/components/feedback.tsx similarity index 72% rename from apps/mobile/src/views/Chat/components/Feedback.tsx rename to apps/mobile/src/views/Chat/components/feedback.tsx index f002629..1e8e570 100644 --- a/apps/mobile/src/views/Chat/components/Feedback.tsx +++ b/apps/mobile/src/views/Chat/components/feedback.tsx @@ -10,6 +10,7 @@ export enum FeedbackStatus { Success = "success", } +// oxlint-disable-next-line typescript/consistent-type-definitions -- react-native-svg's SvgProps carry a string index signature, which an intersection would widen `status` to `any` through. `extends` keeps it a FeedbackStatus. interface FeedbackProps extends React.ComponentProps { status?: FeedbackStatus; } diff --git a/apps/mobile/src/views/Chat/components/index.tsx b/apps/mobile/src/views/Chat/components/index.tsx index e1038e5..b492804 100644 --- a/apps/mobile/src/views/Chat/components/index.tsx +++ b/apps/mobile/src/views/Chat/components/index.tsx @@ -1,4 +1,4 @@ -export { default as Feedback } from "./Feedback"; +export { default as Feedback } from "./feedback"; export { default as Header } from "./Header"; export { default as Message } from "./Message"; export { default as NextDay } from "./NextDay"; diff --git a/apps/mobile/src/views/Chat/hooks/useChatPagination.ts b/apps/mobile/src/views/Chat/hooks/use-chat-pagination.ts similarity index 61% rename from apps/mobile/src/views/Chat/hooks/useChatPagination.ts rename to apps/mobile/src/views/Chat/hooks/use-chat-pagination.ts index 94097e2..e37735d 100644 --- a/apps/mobile/src/views/Chat/hooks/useChatPagination.ts +++ b/apps/mobile/src/views/Chat/hooks/use-chat-pagination.ts @@ -1,19 +1,22 @@ +import type { FeedbackStatus } from "../components/feedback"; +import type { RouterOutputs } from "@/contexts/trpc-provider"; + import { useLocalSearchParams } from "expo-router"; + import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { RouterOutputs } from "@/contexts/TRPCProvider"; -import { FeedbackStatus } from "../components/Feedback"; -import { useFetchNewMessages } from "./useFetchNewMessages"; +import { getTrcpContext } from "@/contexts/trcp-context"; + +import { useFetchNewMessages } from "./use-fetch-new-messages"; const PAGE_SIZE = 20; type Message = RouterOutputs["message"]["allByMatch"][number]; -export interface MessageProps extends Message { +export type MessageProps = { status?: FeedbackStatus; newMessage?: boolean; -} +} & Message; export const useChatPagination = () => { const { matchId } = useLocalSearchParams(); @@ -28,15 +31,16 @@ export const useChatPagination = () => { return response; }; - const { data, fetchNextPage, isFetchingNextPage, hasNextPage } = useSuspenseInfiniteQuery({ - queryKey: ["messages", matchId], - queryFn: fetchMessages, - initialPageParam: undefined, - getNextPageParam: (lastPage) => { - const morePagesExist = lastPage.length === PAGE_SIZE; - return morePagesExist ? lastPage[lastPage.length - 1]?.createdAt : undefined; - }, - }); + const { data, fetchNextPage, isFetchingNextPage, hasNextPage } = + useSuspenseInfiniteQuery({ + queryKey: ["messages", matchId], + queryFn: fetchMessages, + initialPageParam: undefined, + getNextPageParam: (lastPage) => { + const morePagesExist = lastPage.length === PAGE_SIZE; + return morePagesExist ? lastPage.at(-1)?.createdAt : undefined; + }, + }); const loadMore = () => { if (!hasNextPage || isFetchingNextPage) return; @@ -50,12 +54,7 @@ export const useChatPagination = () => { // FlashList v2 dropped the `inverted` prop, so the list renders top-down. // Reverse so the oldest message renders at the top and the newest renders // at the bottom โ€” adjacent to the Send composer, matching chat UX. - const messages = data - ? data.pages - .flatMap((page) => page) - .slice() - .reverse() - : []; + const messages = data ? data.pages.flat().reverse() : []; return { messages, diff --git a/apps/mobile/src/views/Chat/hooks/useFetchNewMessages.ts b/apps/mobile/src/views/Chat/hooks/use-fetch-new-messages.ts similarity index 86% rename from apps/mobile/src/views/Chat/hooks/useFetchNewMessages.ts rename to apps/mobile/src/views/Chat/hooks/use-fetch-new-messages.ts index 9f8588e..7935611 100644 --- a/apps/mobile/src/views/Chat/hooks/useFetchNewMessages.ts +++ b/apps/mobile/src/views/Chat/hooks/use-fetch-new-messages.ts @@ -1,10 +1,12 @@ +import type { MessageProps } from "./use-chat-pagination"; + import { useEffect, useRef } from "react"; + import { useLocalSearchParams } from "expo-router"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { sendError } from "@/services/errorTracking"; -import { queryClient } from "@/services/queryClient"; -import { MessageProps } from "./useChatPagination"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { sendError } from "@/services/error-tracking"; +import { queryClient } from "@/services/query-client"; const REFRESH_INTERVAL = 5000; export const useFetchNewMessages = () => { @@ -19,7 +21,7 @@ export const useFetchNewMessages = () => { gt: latestPollTimestampRef.current, }); - if (!newMessages || !newMessages.length) return; + if (!newMessages || newMessages.length === 0) return; latestPollTimestampRef.current = new Date(); @@ -47,7 +49,7 @@ export const useFetchNewMessages = () => { return 0; }); - const lastMessage = updatedPages[0][0]; + const [[lastMessage]] = updatedPages; // Update last message for this match getTrcpContext().match.getAll.setData(undefined, (matches) => { @@ -58,7 +60,7 @@ export const useFetchNewMessages = () => { ...matches.filter((match) => match.id !== matchId), { ...match, - lastMessage: lastMessage, + lastMessage, }, ]; }); @@ -66,9 +68,9 @@ export const useFetchNewMessages = () => { return { ...oldData, pages: updatedPages }; }, ); - } catch (err) { + } catch (error) { // Deals with pooling errors silently - sendError(err); + sendError(error); } }; diff --git a/apps/mobile/src/views/Chat/hooks/useSendMessage.ts b/apps/mobile/src/views/Chat/hooks/use-send-message.ts similarity index 86% rename from apps/mobile/src/views/Chat/hooks/useSendMessage.ts rename to apps/mobile/src/views/Chat/hooks/use-send-message.ts index bf1fe1d..9da2cb4 100644 --- a/apps/mobile/src/views/Chat/hooks/useSendMessage.ts +++ b/apps/mobile/src/views/Chat/hooks/use-send-message.ts @@ -1,11 +1,14 @@ +import type { MessageProps } from "./use-chat-pagination"; + import { useLocalSearchParams } from "expo-router"; -import * as uuid from "uuid"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { sendError } from "@/services/errorTracking"; -import { queryClient } from "@/services/queryClient"; -import { FeedbackStatus } from "../components/Feedback"; -import { MessageProps } from "./useChatPagination"; +import { v4 as uuidV4 } from "uuid"; + +import { getTrcpContext } from "@/contexts/trcp-context"; +import { sendError } from "@/services/error-tracking"; +import { queryClient } from "@/services/query-client"; + +import { FeedbackStatus } from "../components/feedback"; export const useSendMessage = () => { const { matchId } = useLocalSearchParams(); @@ -61,7 +64,8 @@ export const useSendMessage = () => { ["messages", matchId], (oldData: { pages: MessageProps[][] } | undefined) => { const updatedPages = [...(oldData?.pages ?? [[]])]; - updatedPages[0] = updatedPages[0]?.filter((message) => message.id !== id) ?? []; + updatedPages[0] = + updatedPages[0]?.filter((message) => message.id !== id) ?? []; return { ...oldData, pages: updatedPages }; }, ); @@ -99,7 +103,7 @@ export const useSendMessage = () => { }; const sendMessage = async (content: string) => { - const tempId = uuid.v4(); + const tempId = uuidV4(); try { // Add a fake 'sending' message to give an optimistic UI @@ -114,8 +118,8 @@ export const useSendMessage = () => { ...newMessage, newMessage: true, }); - } catch (err) { - sendError(err); + } catch (error) { + sendError(error); errorTemp(tempId); setTimeout(() => removeTemp(tempId), 1500); diff --git a/apps/mobile/src/views/Chat/index.tsx b/apps/mobile/src/views/Chat/index.tsx index 25655cb..bc3c031 100644 --- a/apps/mobile/src/views/Chat/index.tsx +++ b/apps/mobile/src/views/Chat/index.tsx @@ -1,15 +1,20 @@ +import type { MessageProps } from "./hooks/use-chat-pagination"; + import { ActivityIndicator, Platform } from "react-native"; + import { useLocalSearchParams } from "expo-router"; + import { FlashList } from "@shopify/flash-list"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { useKeyboardAwareSafeAreaInsets } from "@/hooks/useKeyboardAwareSafeAreaInsets"; +import { useKeyboardAwareSafeAreaInsets } from "@/hooks/use-keyboard-aware-safe-area-insets"; import { Header, Message, NextDay, Send } from "@/views/Chat/components"; + import { HEADER_HEIGHT } from "./components/Header"; import { SEND_HEIGHT } from "./components/Send"; -import { MessageProps, useChatPagination } from "./hooks/useChatPagination"; +import { useChatPagination } from "./hooks/use-chat-pagination"; import { Background, CenteredText, CenteredView, Container } from "./styles"; const Empty = () => { @@ -25,6 +30,10 @@ const Empty = () => { const keyExtractor = (message: MessageProps) => String(message.id); +// Hoisted: a component declared during render is a new type on every update, +// which remounts the empty state each time a message arrives. +const ListEmptyComponent = () => ; + const ChatMessageList = () => { const { dogId } = useLocalSearchParams(); const theme = useTheme(); @@ -33,7 +42,9 @@ const ChatMessageList = () => { const insets = useKeyboardAwareSafeAreaInsets(); - const MessageLoader = hasNextPage ? : null; + const MessageLoader = hasNextPage ? ( + + ) : null; const FooterComponent = messages ? MessageLoader : null; @@ -50,9 +61,13 @@ const ChatMessageList = () => { paddingBottom: bottomPadding, }; - const ListEmptyComponent = () => ; - - const renderItem = ({ item, index }: { item: MessageProps; index: number }) => { + const renderItem = ({ + item, + index, + }: { + item: MessageProps; + index: number; + }) => { // Show the date separator above this message when the previous (older) // message is on a different day. Hide it for the very first item when // older pages may still load โ€” we don't yet know if it's truly first. @@ -61,7 +76,9 @@ const ChatMessageList = () => { return ( <> - {showNextDay ? : null} + {showNextDay ? ( + + ) : null} {item.content} @@ -84,7 +101,7 @@ const ChatMessageList = () => { }; return ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // oxlint-disable-next-line typescript/no-explicit-any -- FlashList v2's prop types reject the union this list builds; the props are checked where they are assembled. ); }; @@ -92,15 +109,22 @@ const ChatMessageList = () => { const Chat = () => { const theme = useTheme(); + // The tiled background is a texture, not a surface: it has to sit further + // back on dark than on light to read as the same weight. + const patternOpacity = theme.dark ? 0.06 : 0.03; + return ( - + diff --git a/apps/mobile/src/views/Chat/styles.ts b/apps/mobile/src/views/Chat/styles.ts index 4a0ab72..63adca6 100644 --- a/apps/mobile/src/views/Chat/styles.ts +++ b/apps/mobile/src/views/Chat/styles.ts @@ -1,7 +1,7 @@ import Animated, { FadeInUp } from "react-native-reanimated"; import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Container = styled.KeyboardAvoidingView` background-color: ${(props) => props.theme.colors.background}; @@ -19,7 +19,8 @@ export const CenteredView = styled(Animated.View).attrs(() => ({ flex: 1; align-items: center; justify-content: center; - padding: ${(props) => props.theme.spacing[6]}px ${(props) => props.theme.spacing[3]}px; + padding: ${(props) => props.theme.spacing[6]}px + ${(props) => props.theme.spacing[3]}px; `; export const CenteredText = styled(Text)` diff --git a/apps/mobile/src/views/DogProfile/components/GoBack/index.tsx b/apps/mobile/src/views/DogProfile/components/GoBack/index.tsx index 806a788..d611b6d 100644 --- a/apps/mobile/src/views/DogProfile/components/GoBack/index.tsx +++ b/apps/mobile/src/views/DogProfile/components/GoBack/index.tsx @@ -1,7 +1,9 @@ import * as React from "react"; + import { useTheme } from "styled-components/native"; import ArrowDown from "@/assets/images/ArrowDown.svg"; + import { Container, Content } from "./styles"; const GoBack = (props: React.ComponentProps) => { diff --git a/apps/mobile/src/views/DogProfile/components/GoBack/styles.ts b/apps/mobile/src/views/DogProfile/components/GoBack/styles.ts index 6ba2881..0b019fc 100644 --- a/apps/mobile/src/views/DogProfile/components/GoBack/styles.ts +++ b/apps/mobile/src/views/DogProfile/components/GoBack/styles.ts @@ -1,7 +1,7 @@ import styled from "styled-components/native"; -import { TransparentGlassOrDarkBlurView } from "@/components/BlurView"; -import { PressableArea } from "@/components/PressableArea"; +import { TransparentGlassOrDarkBlurView } from "@/components/blur-view"; +import { PressableArea } from "@/components/pressable-area"; const BACK_CONTAINER_SIZE = 60; const HIT_SLOP = { diff --git a/apps/mobile/src/views/DogProfile/components/BreedTag.tsx b/apps/mobile/src/views/DogProfile/components/breed-tag.tsx similarity index 75% rename from apps/mobile/src/views/DogProfile/components/BreedTag.tsx rename to apps/mobile/src/views/DogProfile/components/breed-tag.tsx index 7a9056d..60e3392 100644 --- a/apps/mobile/src/views/DogProfile/components/BreedTag.tsx +++ b/apps/mobile/src/views/DogProfile/components/breed-tag.tsx @@ -1,13 +1,14 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; +import type { BreedSlug } from "@pegada/shared/i18n/i18n"; + import { View } from "react-native"; -import { useTranslation } from "react-i18next"; -import styled from "styled-components/native"; -import { BreedSlug } from "@pegada/shared/i18n/i18n"; import { Namespace } from "@pegada/shared/i18n/types/types"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components/native"; -import { Text } from "@/components/Text"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; -import { TransparentGlassOrDarkBlurView } from "@/components/BlurView"; +import { TransparentGlassOrDarkBlurView } from "@/components/blur-view"; +import { Text } from "@/components/text"; const GlassmorphismStyled = styled(TransparentGlassOrDarkBlurView).attrs({ glassEffectStyle: "regular", @@ -20,7 +21,8 @@ const GlassmorphismStyled = styled(TransparentGlassOrDarkBlurView).attrs({ `; const ViewStyled = styled(View)` - padding: ${(props) => props.theme.spacing[2]}px ${(props) => props.theme.spacing[4]}px; + padding: ${(props) => props.theme.spacing[2]}px + ${(props) => props.theme.spacing[4]}px; padding-bottom: ${(props) => props.theme.spacing[2.5]}px; `; diff --git a/apps/mobile/src/views/DogProfile/index.tsx b/apps/mobile/src/views/DogProfile/index.tsx index 56e47be..d02f199 100644 --- a/apps/mobile/src/views/DogProfile/index.tsx +++ b/apps/mobile/src/views/DogProfile/index.tsx @@ -1,39 +1,45 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; + import { useState } from "react"; import * as React from "react"; import { ActivityIndicator, Alert, Linking, Share, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { router, useLocalSearchParams, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; + import { Header, HeaderBackButton } from "@react-navigation/elements"; import i18n from "i18next"; import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useDispatch, useSelector } from "react-redux"; import { useTheme } from "styled-components/native"; import MainCard from "@/components/MainCard"; import { MatchActionBar } from "@/components/MatchActionBar"; -import { NetworkBoundary, UnknownErrorComponent } from "@/components/NetworkBoundary"; -import { Text } from "@/components/Text"; +import { + NetworkBoundary, + UnknownErrorComponent, +} from "@/components/NetworkBoundary"; import { APP_SHARE_LINK_BASE } from "@/constants"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { api } from "@/contexts/TRPCProvider"; -import { sendError } from "@/services/errorTracking"; -import { useGetFormattedYears } from "@/services/useGetFormattedYears"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { api } from "@/contexts/trpc-provider"; +import { sendError } from "@/services/error-tracking"; +import { useGetFormattedYears } from "@/services/use-get-formatted-years"; import { Actions } from "@/store/reducers"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; import { getCurrentCardId } from "@/store/selectors"; -import { SceneName } from "@/types/SceneName"; +import { SceneName } from "@/types/scene-name"; import { useCustomTopInset } from "@/views/(tabs)/Swipe"; import { swipeHandlerRef } from "@/views/(tabs)/Swipe/components/SwipeHandler"; -import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture"; -import { BreedTag } from "@/views/DogProfile/components/BreedTag"; +import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/use-swipe-gesture"; +import { BreedTag } from "@/views/DogProfile/components/breed-tag"; import GoBack from "@/views/DogProfile/components/GoBack"; + import * as S from "./styles"; export const ShareButton: React.FC<{ dog: SwipeDog }> = ({ dog }) => { const { t } = useTranslation(); - const firstName = dog.name.split(" ")[0]; + const [firstName] = dog.name.split(" "); const handleShare = async () => { try { @@ -54,9 +60,9 @@ export const ShareButton: React.FC<{ dog: SwipeDog }> = ({ dog }) => { return ( - + {t("dogProfile.shareProfile", { name: firstName })} - + ); }; @@ -93,9 +99,9 @@ export const reportUser = (dog: SwipeDog) => { }); router.back(); - } catch (err) { + } catch (error) { // Silently fail - sendError(err); + sendError(error); } }, }, @@ -113,7 +119,7 @@ const useSwipeHandler = (id: string) => { return swipeHandlerRef.current.gotoDirection(swipeType); } - dispatch(Actions.dogs.swipe.request({ id: id, swipeType })); + dispatch(Actions.dogs.swipe.request({ id, swipeType })); }; }; @@ -154,18 +160,24 @@ const DogProfile = () => { }); router.push(SceneName.Messages); - } catch (err) { - sendError(err); + } catch (error) { + sendError(error); - Alert.alert(t("dogProfile.somethingWrong"), t("dogProfile.tryAgainLater")); + Alert.alert( + t("dogProfile.somethingWrong"), + t("dogProfile.tryAgainLater"), + ); } finally { setUnmatchLoading(false); } }; - const [dog] = api.dog.get.useSuspenseQuery({ id: id as string }, { refetchOnMount: false }); + const [dog] = api.dog.get.useSuspenseQuery( + { id: id as string }, + { refetchOnMount: false }, + ); - const firstName = dog.name.split(" ")[0]; + const [firstName] = dog.name.split(" "); const mainCardStyle = { paddingTop: Math.max(insets.top, theme.spacing[6]), @@ -200,7 +212,9 @@ const DogProfile = () => { {dog.name} - {dog.birthDate ? , {getFormattedYears(dog.birthDate)} : undefined} + {dog.birthDate ? ( + , {getFormattedYears(dog.birthDate)} + ) : undefined} {dog.bio} @@ -214,36 +228,34 @@ const DogProfile = () => { {unmatchLoading ? ( ) : ( - + {t("dogProfile.unmatch")} - + )} )} - reportUser(dog)} fontWeight="bold" - style={{ textAlign: "center" }} > {t("dogProfile.reportName", { name: firstName })} - + {__DEV__ && matchId ? ( - { router.push({ pathname: SceneName.NewMatch, - params: { matchDogId: dog.id, matchId: matchId }, + params: { matchDogId: dog.id, matchId }, }); }} fontWeight="bold" - style={{ textAlign: "center" }} > Fake Match Screen - + ) : null} @@ -253,7 +265,9 @@ const DogProfile = () => { {!matchId && ( <> - + swipeHandler(Swipe.Dislike)} @@ -266,41 +280,51 @@ const DogProfile = () => { ); }; +const ErrorHeaderBackButton = () => { + const theme = useTheme(); + + return ( + router.back()} + /> + ); +}; + const DogProfileErrorState = () => { const { t } = useTranslation(); const theme = useTheme(); return ( - +
( - router.back()} - /> - )} - headerRightContainerStyle={{ paddingRight: 16 }} - headerLeftContainerStyle={{ paddingLeft: 16 }} + headerLeft={ErrorHeaderBackButton} + headerRightContainerStyle={S.headerRight} + headerLeftContainerStyle={S.headerLeft} headerTintColor={theme.colors.text} - headerTitleStyle={{ - fontFamily: theme.typography.fontFamily.bold, - fontWeight: "bold", - fontSize: theme.typography.sizes.lg.size, - }} + headerTitleStyle={[ + S.headerTitle, + { + fontFamily: theme.typography.fontFamily.bold, + fontSize: theme.typography.sizes.lg.size, + }, + ]} headerStyle={{ backgroundColor: theme.colors.background, }} /> - + ); }; -export default () => ( +const DogProfileScreen = () => ( ); + +export default DogProfileScreen; diff --git a/apps/mobile/src/views/DogProfile/styles.ts b/apps/mobile/src/views/DogProfile/styles.ts index 4676b24..5294bff 100644 --- a/apps/mobile/src/views/DogProfile/styles.ts +++ b/apps/mobile/src/views/DogProfile/styles.ts @@ -1,10 +1,13 @@ +import { StyleSheet } from "react-native"; + import { LinearGradient } from "expo-linear-gradient"; + import Color from "color"; import { clamp } from "lodash"; import styled from "styled-components/native"; -import { PressableArea } from "@/components/PressableArea"; -import { Text } from "@/components/Text"; +import { PressableArea } from "@/components/pressable-area"; +import { Text } from "@/components/text"; import { height, width } from "@/constants"; const ASPECT_RATIO = 4 / 3; @@ -41,7 +44,7 @@ export const ReportButton = styled(PressableArea).attrs({ `; export const MatchActionBarGradient = styled(LinearGradient).attrs((props) => { - const gradientColor = Color(props.theme.colors.background); + const gradientColor = new Color(props.theme.colors.background); return { colors: [ @@ -82,3 +85,23 @@ export const Description = styled(Text)` margin-top: ${(props) => props.theme.spacing[2]}px; margin-bottom: ${(props) => props.theme.spacing[12]}px; `; + +/** Centred label shared by the share / unmatch / report actions. */ +export const ActionLabel = styled(Text)` + text-align: center; +`; + +export const ErrorScreen = styled.View` + flex-grow: 1; + background-color: ${({ theme }) => theme.colors.background}; +`; + +/** + * `@react-navigation` header slots take style props rather than components, so + * the static half of each one lives in a sheet and the themed half stays inline. + */ +export const { headerLeft, headerRight, headerTitle } = StyleSheet.create({ + headerLeft: { paddingLeft: 16 }, + headerRight: { paddingRight: 16 }, + headerTitle: { fontWeight: "bold" }, +}); diff --git a/apps/mobile/src/views/EditProfile/index.tsx b/apps/mobile/src/views/EditProfile/index.tsx index 815c9eb..2ceab2a 100644 --- a/apps/mobile/src/views/EditProfile/index.tsx +++ b/apps/mobile/src/views/EditProfile/index.tsx @@ -1,37 +1,43 @@ +import type { ProfileImagesUploaderProps } from "@/components/ProfileImageUploader"; +import type { Picture } from "@/components/ProfileImageUploader/utils"; +import type { RouterInputs } from "@/contexts/trpc-provider"; + +import type { ScrollView } from "react-native"; + import { useEffect, useRef, useState } from "react"; -import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; -import { magicToast } from "react-native-magic-toast"; +import { Platform } from "react-native"; + import { useRouter } from "expo-router"; + import { zodResolver } from "@hookform/resolvers/zod"; +import { dogClientSchema } from "@pegada/shared/schemas/dog-schema"; import { useHeaderHeight } from "@react-navigation/elements"; import { format } from "date-fns"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { magicToast } from "react-native-magic-toast"; import { useDispatch } from "react-redux"; import { useTheme } from "styled-components/native"; -import { dogClientSchema } from "@pegada/shared/schemas/dogSchema"; - import { BottomAction, useBottomActionStyle } from "@/components/BottomAction"; import BreedPicker from "@/components/BreedPicker"; import { Button } from "@/components/Button"; import { Input } from "@/components/Input"; +import { Fill, KeyboardScreen, Row } from "@/components/layout"; import { NetworkBoundary } from "@/components/NetworkBoundary"; import { InputPicker } from "@/components/Picker"; -import { - ProfileImagesUploader, - ProfileImagesUploaderProps, -} from "@/components/ProfileImageUploader"; -import { Picture, pictures } from "@/components/ProfileImageUploader/utils"; +import { ProfileImagesUploader } from "@/components/ProfileImageUploader"; +import { pictures } from "@/components/ProfileImageUploader/utils"; import { RadioButtons } from "@/components/RadioButtons"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { api, RouterInputs } from "@/contexts/TRPCProvider"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { api } from "@/contexts/trpc-provider"; import { analytics } from "@/services/analytics"; import { colors, sizes } from "@/services/consts"; -import { sendError } from "@/services/errorTracking"; -import { maskDate } from "@/services/maskDate"; +import { sendError } from "@/services/error-tracking"; +import { maskDate } from "@/services/mask-date"; import { Actions } from "@/store/reducers"; -import { Container } from "./styles"; + +import { Container, Gap, MultilineInput, WideColumn } from "./styles"; type MyDogUpdateMutation = RouterInputs["myDog"]["update"]; type EditProfileForm = Partial; @@ -40,20 +46,21 @@ const EditProfile = () => { const router = useRouter(); const { t } = useTranslation(); - const { control, handleSubmit, setValue, getValues } = useForm({ - defaultValues: { - name: "", - bio: "", - gender: "MALE", - weight: undefined, - birthDate: "", - breedId: "", - color: undefined, - size: undefined, - images: pictures, - }, - resolver: zodResolver(dogClientSchema), - }); + const { control, handleSubmit, setValue, getValues } = + useForm({ + defaultValues: { + name: "", + bio: "", + gender: "MALE", + weight: undefined, + birthDate: "", + breedId: "", + color: undefined, + size: undefined, + images: pictures, + }, + resolver: zodResolver(dogClientSchema), + }); const [dog] = api.myDog.get.useSuspenseQuery(undefined, { refetchOnMount: false, @@ -140,7 +147,7 @@ const EditProfile = () => { birthDate: data.birthDate ? data.birthDate : null, breedId: data.breedId ? data.breedId : null, color: data.color ? data.color : null, - size: data.size ? data.size : null, + size: data.size ?? null, images: data.images?.map((image, index) => ({ id: image.id, url: image.url, @@ -152,18 +159,16 @@ const EditProfile = () => { }); return ( - - + + { return ( [0]) => { + onChange={( + cb: Parameters[0], + ) => { // This getValues is needed to ensure the update happens // correctly even when adding images fast. onChange(cb(getValues("images") as Picture[])); @@ -213,7 +220,7 @@ const EditProfile = () => { control={control} rules={{ required: true }} render={({ field: { onChange, onBlur, value }, fieldState }) => ( - { maxLength={500} multiline error={fieldState.error?.message} - style={{ minHeight: 75 }} /> )} /> - - + + ( + render={({ + field: { onChange, onBlur, value }, + fieldState, + }) => ( { onBlur={onBlur} onChangeText={(value: string) => // Only allow numbers - onChange(value.replace(/[^0-9]/g, "")) + onChange(value.replaceAll(/[^0-9]/g, "")) } maxLength={3} numberOfLines={1} @@ -250,14 +259,17 @@ const EditProfile = () => { /> )} /> - - - + + + ( + render={({ + field: { onChange, onBlur, value, name }, + fieldState, + }) => ( { onBlur={onBlur} onChangeText={(value: string) => { const oldValue = getValues()[name]; - const isErasing = value.length < (oldValue ? oldValue.length : 0); + const isErasing = + value.length < (oldValue ? oldValue.length : 0); if (isErasing) return onChange(value); @@ -278,8 +291,8 @@ const EditProfile = () => { /> )} /> - - + + { )} /> - + { )} /> - + { /> )} /> - + { { onChange(value === t("editProfile.male") ? "MALE" : "FEMALE"); }} @@ -353,13 +370,15 @@ const EditProfile = () => { {t("editProfile.saveProfile")} - - + + ); }; -export default () => ( +const EditProfileScreen = () => ( ); + +export default EditProfileScreen; diff --git a/apps/mobile/src/views/EditProfile/styles.ts b/apps/mobile/src/views/EditProfile/styles.ts index 60201cf..c45d663 100644 --- a/apps/mobile/src/views/EditProfile/styles.ts +++ b/apps/mobile/src/views/EditProfile/styles.ts @@ -1,5 +1,21 @@ import styled from "styled-components/native"; +import { Input } from "@/components/Input"; + export const Container = styled.ScrollView` - flex-grow: 1; + flex: 1; +`; + +/** The wider half of the weight / birth-date row. */ +export const WideColumn = styled.View` + flex: 1.5; +`; + +/** The fixed gutter between two fields sharing a row. */ +export const Gap = styled.View` + width: ${({ theme }) => theme.spacing[3]}px; +`; + +export const MultilineInput = styled(Input)` + min-height: 75px; `; diff --git a/apps/mobile/src/views/ForceUpdate/index.tsx b/apps/mobile/src/views/ForceUpdate/index.tsx index bbc4d99..5a8b832 100644 --- a/apps/mobile/src/views/ForceUpdate/index.tsx +++ b/apps/mobile/src/views/ForceUpdate/index.tsx @@ -1,13 +1,15 @@ import * as React from "react"; import { Linking } from "react-native"; + import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components"; -import Logo from "@/assets/images/Logo"; +import Logo from "@/assets/images/logo"; import { BottomAction } from "@/components/BottomAction"; import { Button } from "@/components/Button"; import { APP_SHARE_LINK_BASE } from "@/constants"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; + import { CenterText, Container } from "./styles"; const ForceUpdate: React.FC = () => { diff --git a/apps/mobile/src/views/ForceUpdate/styles.ts b/apps/mobile/src/views/ForceUpdate/styles.ts index 8c50589..227ec1b 100644 --- a/apps/mobile/src/views/ForceUpdate/styles.ts +++ b/apps/mobile/src/views/ForceUpdate/styles.ts @@ -1,7 +1,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Container = styled(SafeAreaView)` align-items: center; diff --git a/apps/mobile/src/views/LocationMap/components/Bubble/index.tsx b/apps/mobile/src/views/LocationMap/components/Bubble/index.tsx index 87e8599..38ffa02 100644 --- a/apps/mobile/src/views/LocationMap/components/Bubble/index.tsx +++ b/apps/mobile/src/views/LocationMap/components/Bubble/index.tsx @@ -1,10 +1,22 @@ +import type Animated from "react-native-reanimated"; + import * as React from "react"; -import Animated from "react-native-reanimated"; + import { useTranslation } from "react-i18next"; -import { Container, Content, Description, Rect, Row, Swipe, Title } from "./styles"; +import { + Container, + Content, + Description, + Rect, + Row, + Swipe, + Title, +} from "./styles"; -export const Bubble: React.FC> = (props) => { +export const Bubble: React.FC> = ( + props, +) => { const { t } = useTranslation(); return ( diff --git a/apps/mobile/src/views/LocationMap/components/Bubble/styles.ts b/apps/mobile/src/views/LocationMap/components/Bubble/styles.ts index af267cf..2ccab0e 100644 --- a/apps/mobile/src/views/LocationMap/components/Bubble/styles.ts +++ b/apps/mobile/src/views/LocationMap/components/Bubble/styles.ts @@ -1,8 +1,8 @@ -import Animated from "react-native-reanimated"; import LottieView from "lottie-react-native"; +import Animated from "react-native-reanimated"; import styled from "styled-components/native"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; export const Swipe = styled(LottieView).attrs({ resizeMode: "cover", @@ -16,7 +16,8 @@ export const Swipe = styled(LottieView).attrs({ export const Container = styled(Animated.View)` position: absolute; - padding: ${(props) => props.theme.spacing[2.5]}px ${(props) => props.theme.spacing[4]}px; + padding: ${(props) => props.theme.spacing[2.5]}px + ${(props) => props.theme.spacing[4]}px; background: ${(props) => props.theme.colors.background}; border: 1px ${(props) => props.theme.colors.border}; align-items: center; diff --git a/apps/mobile/src/views/LocationMap/components/Marker/index.tsx b/apps/mobile/src/views/LocationMap/components/Marker/index.tsx index 4a783c7..4765d53 100644 --- a/apps/mobile/src/views/LocationMap/components/Marker/index.tsx +++ b/apps/mobile/src/views/LocationMap/components/Marker/index.tsx @@ -1,10 +1,12 @@ +import type { SharedValue } from "react-native-reanimated"; + import * as React from "react"; + import Animated, { Extrapolation, FadeInDown, FadeOutUp, interpolate, - SharedValue, useAnimatedStyle, } from "react-native-reanimated"; import Svg, { Path } from "react-native-svg"; @@ -33,7 +35,12 @@ export const Marker: React.FC<{ const markerViewStyle = useAnimatedStyle(() => { "worklet"; - const markerTop = interpolate(dragging.value, [1, 0], [-15, 0], Extrapolation.CLAMP); + const markerTop = interpolate( + dragging.value, + [1, 0], + [-15, 0], + Extrapolation.CLAMP, + ); return { top: markerTop, diff --git a/apps/mobile/src/views/LocationMap/components/Submit/index.tsx b/apps/mobile/src/views/LocationMap/components/Submit/index.tsx index e5c121c..7a56f4b 100644 --- a/apps/mobile/src/views/LocationMap/components/Submit/index.tsx +++ b/apps/mobile/src/views/LocationMap/components/Submit/index.tsx @@ -1,22 +1,29 @@ +import type { SharedValue } from "react-native-reanimated"; + import * as React from "react"; + +import { useTranslation } from "react-i18next"; import Animated, { Extrapolation, interpolate, - SharedValue, useAnimatedStyle, } from "react-native-reanimated"; -import { useTranslation } from "react-i18next"; import { BottomAction } from "@/components/BottomAction"; + import { StyledButton } from "./styles"; -interface SubmitProps { +type SubmitProps = { loading?: boolean; onPress: () => void; dragging: SharedValue; -} +}; -export const Submit: React.FC = ({ loading, onPress, dragging }) => { +export const Submit: React.FC = ({ + loading, + onPress, + dragging, +}) => { const { t } = useTranslation(); const buttonAnimatedStyle = useAnimatedStyle(() => { @@ -35,7 +42,11 @@ export const Submit: React.FC = ({ loading, onPress, dragging }) => return ( - + {t("locationMap.confirmLocation")} diff --git a/apps/mobile/src/views/LocationMap/index.tsx b/apps/mobile/src/views/LocationMap/index.tsx index c779a53..8874b7c 100644 --- a/apps/mobile/src/views/LocationMap/index.tsx +++ b/apps/mobile/src/views/LocationMap/index.tsx @@ -1,19 +1,22 @@ import { useRef, useState } from "react"; import { Alert, Platform } from "react-native"; -import { magicToast } from "react-native-magic-toast"; -import { Easing, useSharedValue, withTiming } from "react-native-reanimated"; + import { useRouter } from "expo-router"; + import { useHeaderHeight } from "@react-navigation/elements"; import { useMutation } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { magicToast } from "react-native-magic-toast"; +import { Easing, useSharedValue, withTiming } from "react-native-reanimated"; import { useDispatch } from "react-redux"; import { useTheme } from "styled-components/native"; import { useBottomActionStyle } from "@/components/BottomAction"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { api } from "@/contexts/TRPCProvider"; -import { sendError } from "@/services/errorTracking"; +import { api } from "@/contexts/trpc-provider"; +import { sendError } from "@/services/error-tracking"; import { Actions } from "@/store/reducers"; + import { updateUserLocation } from "../(auth)/AskForLocation"; import { Marker } from "./components/Marker"; import { Submit } from "./components/Submit"; @@ -93,7 +96,9 @@ const LocationMap = () => { mapPadding={mapPadding} // Android doesn't switch maps to dark mode like IOS does, // so we need to set the custom style manually - customMapStyle={theme.dark ? require("./assets/map-dark.json") : undefined} + customMapStyle={ + theme.dark ? require("./assets/map-dark.json") : undefined + } key={theme.dark ? "dark" : "light"} initialRegion={{ latitude: location?.latitude, @@ -104,13 +109,15 @@ const LocationMap = () => { onTouchStart={() => setTouchStarted(true)} onRegionChange={() => { if (!touchStarted) return; - // eslint-disable-next-line react-compiler/react-compiler -- false positive dragging.value = withTiming(1, { easing: Easing.in(Easing.ease), duration: 200, }); }} - onRegionChangeComplete={(newLocation: { latitude: number; longitude: number }) => { + onRegionChangeComplete={(newLocation: { + latitude: number; + longitude: number; + }) => { setLocation({ latitude: newLocation.latitude, longitude: newLocation.longitude, @@ -132,8 +139,10 @@ const LocationMap = () => { ); }; -export default () => ( +const LocationMapScreen = () => ( ); + +export default LocationMapScreen; diff --git a/apps/mobile/src/views/NewMatch/AnimatedCards.tsx b/apps/mobile/src/views/NewMatch/animated-cards.tsx similarity index 58% rename from apps/mobile/src/views/NewMatch/AnimatedCards.tsx rename to apps/mobile/src/views/NewMatch/animated-cards.tsx index 7293dde..9e1216e 100644 --- a/apps/mobile/src/views/NewMatch/AnimatedCards.tsx +++ b/apps/mobile/src/views/NewMatch/animated-cards.tsx @@ -1,17 +1,35 @@ +import type { RouterOutputs } from "@/contexts/trpc-provider"; + import { useEffect } from "react"; import * as React from "react"; import { useWindowDimensions, View } from "react-native"; -import { useAnimatedStyle, useSharedValue, withDelay, withTiming } from "react-native-reanimated"; -import { useTheme } from "styled-components"; -import { Image } from "@/components/Image"; -import { DefaultLoadingComponent, NetworkBoundary } from "@/components/NetworkBoundary"; -import { api, RouterOutputs } from "@/contexts/TRPCProvider"; -import { HeartEyesContainer, RotatedImageLeft, RotatedImageRight } from "./styles"; +import { + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from "react-native-reanimated"; + +import { + DefaultLoadingComponent, + NetworkBoundary, +} from "@/components/NetworkBoundary"; +import { api } from "@/contexts/trpc-provider"; -interface AnimatedCardsProps { +import { + CardsColumn, + HeartEyesContainer, + HeartEyesEmoji, + HeartEyesEmojiStandalone, + LoadingBox, + RotatedImageLeft, + RotatedImageRight, +} from "./styles"; + +type AnimatedCardsProps = { matchDog?: RouterOutputs["dog"]["get"]; -} +}; const ROTATE_Z = 18; @@ -26,7 +44,6 @@ const AnimatedCards: React.FC = ({ matchDog }) => { const CARD_HEIGHT = windowDimensions.height < 800 ? 200 : 325; useEffect(() => { - // eslint-disable-next-line react-compiler/react-compiler -- false positive rotateZ.value = withDelay(500, withTiming(ROTATE_Z, { duration: 500 })); }, [rotateZ]); @@ -53,59 +70,51 @@ const AnimatedCards: React.FC = ({ matchDog }) => { }); return ( - + - - + ); }; -const AnimatedCardsErrorFallback = () => { - const theme = useTheme(); - return ( - - ); -}; +const AnimatedCardsErrorFallback = () => ( + +); -const AnimatedCardsLoading = () => { - return ( - - - - ); -}; +const AnimatedCardsLoading = () => ( + + + +); -export default ({ matchDog }: AnimatedCardsProps) => ( +const AnimatedCardsBoundary = ({ matchDog }: AnimatedCardsProps) => ( } errorFallback={AnimatedCardsErrorFallback} @@ -113,3 +122,5 @@ export default ({ matchDog }: AnimatedCardsProps) => ( ); + +export default AnimatedCardsBoundary; diff --git a/apps/mobile/src/views/NewMatch/ConfettiAnimation.tsx b/apps/mobile/src/views/NewMatch/confetti-animation.tsx similarity index 51% rename from apps/mobile/src/views/NewMatch/ConfettiAnimation.tsx rename to apps/mobile/src/views/NewMatch/confetti-animation.tsx index 7783ea1..b34eac0 100644 --- a/apps/mobile/src/views/NewMatch/ConfettiAnimation.tsx +++ b/apps/mobile/src/views/NewMatch/confetti-animation.tsx @@ -1,11 +1,10 @@ -import AnimatedLottieView from "lottie-react-native"; +import { Confetti } from "./styles"; export const ConfettiAnimation = () => ( - ); diff --git a/apps/mobile/src/views/NewMatch/index.tsx b/apps/mobile/src/views/NewMatch/index.tsx index 0d534ab..9ae423e 100644 --- a/apps/mobile/src/views/NewMatch/index.tsx +++ b/apps/mobile/src/views/NewMatch/index.tsx @@ -1,23 +1,31 @@ import { useEffect } from "react"; import * as React from "react"; import { BackHandler, ScrollView, View } from "react-native"; -import { Image } from "expo-image"; + import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; + import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components"; import { Button } from "@/components/Button"; import { NetworkBoundary } from "@/components/NetworkBoundary"; -import { Text } from "@/components/Text"; -import { api } from "@/contexts/TRPCProvider"; +import { Text } from "@/components/text"; +import { api } from "@/contexts/trpc-provider"; import { useForAdRequestTracked } from "@/services/advertisement/interstitial"; import { analytics } from "@/services/analytics"; import { haptics } from "@/services/haptics"; -import { SceneName } from "@/types/SceneName"; -import AnimatedCards from "./AnimatedCards"; -import { ConfettiAnimation } from "./ConfettiAnimation"; -import { Container, Content } from "./styles"; +import { SceneName } from "@/types/scene-name"; + +import AnimatedCards from "./animated-cards"; +import { ConfettiAnimation } from "./confetti-animation"; +import { + Container, + Content, + matchScroll, + MatchCaption, + MatchWordmark, +} from "./styles"; const NewMatch: React.FC = () => { const { matchId, matchDogId } = useLocalSearchParams<{ @@ -73,10 +81,13 @@ const NewMatch: React.FC = () => { useFocusEffect(() => { // Assume 'skip' if the user presses the back button // This is pertinent to Android devices only. - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - void handleSkip(); - return false; - }); + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + () => { + void handleSkip(); + return false; + }, + ); return () => subscription.remove(); }); @@ -91,49 +102,35 @@ const NewMatch: React.FC = () => { - + {t("newMatch.youGotA")} - - + {t("newMatch.youLikedEachOther", { replace: { name: matchDog.name }, })} - + - @@ -142,8 +139,10 @@ const NewMatch: React.FC = () => { ); }; -export default () => ( +const NewMatchScreen = () => ( ); + +export default NewMatchScreen; diff --git a/apps/mobile/src/views/NewMatch/styles.ts b/apps/mobile/src/views/NewMatch/styles.ts index 54feac4..f57fd16 100644 --- a/apps/mobile/src/views/NewMatch/styles.ts +++ b/apps/mobile/src/views/NewMatch/styles.ts @@ -1,9 +1,13 @@ +import { StyleSheet } from "react-native"; + +import Color from "color"; +import AnimatedLottieView from "lottie-react-native"; import Animated from "react-native-reanimated"; import { SafeAreaView } from "react-native-safe-area-context"; -import Color from "color"; import styled from "styled-components/native"; -import { Image } from "@/components/Image"; +import { Image } from "@/components/image"; +import { Text } from "@/components/text"; export const Container = styled.View` background-color: ${(props) => props.theme.colors.background}; @@ -29,8 +33,54 @@ export const RotatedImageRight = styled(RotatedImageLeft)` export const HeartEyesContainer = styled.View` border-radius: ${(props) => props.theme.radii.round}px; - background-color: ${(props) => Color(props.theme.colors.primary).alpha(0.5).rgb().string()}; + background-color: ${(props) => + new Color(props.theme.colors.primary).alpha(0.5).rgb().string()}; padding: ${(props) => props.theme.spacing[1.5]}px; margin-top: -35px; margin-bottom: ${(props) => props.theme.spacing[2]}px; `; + +export const CardsColumn = styled.View` + align-items: center; +`; + +export const HeartEyesEmoji = styled(Image)` + width: 70px; + height: 70px; +`; + +/** Same emoji, standing in for the whole card stack when it fails to load. */ +export const HeartEyesEmojiStandalone = styled(HeartEyesEmoji)` + margin-bottom: ${({ theme }) => theme.spacing[5]}px; +`; + +export const LoadingBox = styled.View` + height: 200px; +`; + +/** `contentContainerStyle` takes a style object, not a component. */ +export const { matchScroll } = StyleSheet.create({ + matchScroll: { + alignItems: "center", + justifyContent: "center", + flexGrow: 1, + }, +}); + +export const MatchWordmark = styled(Image)` + height: 50px; + width: 100%; +`; + +export const MatchCaption = styled(Text)` + text-align: center; + margin-top: 12px; + max-width: 200px; +`; + +export const Confetti = styled(AnimatedLottieView)` + position: absolute; + width: 100%; + height: 100%; + top: 0; +`; diff --git a/apps/mobile/src/views/Preferences/index.tsx b/apps/mobile/src/views/Preferences/index.tsx index 7480acd..34df74c 100644 --- a/apps/mobile/src/views/Preferences/index.tsx +++ b/apps/mobile/src/views/Preferences/index.tsx @@ -1,12 +1,17 @@ +import type { RouterInputs } from "@/contexts/trpc-provider"; +import type { Resolver } from "react-hook-form"; + import { useEffect } from "react"; import * as React from "react"; import { Dimensions } from "react-native"; -import { magicToast } from "react-native-magic-toast"; + import { useRouter } from "expo-router"; + import { zodResolver } from "@hookform/resolvers/zod"; import { useHeaderHeight } from "@react-navigation/elements"; import { t } from "i18next"; import { Controller, useForm } from "react-hook-form"; +import { magicToast } from "react-native-magic-toast"; import { useDispatch } from "react-redux"; import { useTheme } from "styled-components/native"; import { z } from "zod"; @@ -17,27 +22,34 @@ import { Button } from "@/components/Button"; import { NetworkBoundary } from "@/components/NetworkBoundary"; import { InputPicker } from "@/components/Picker"; import { Slider } from "@/components/Slider"; -import { getTrcpContext } from "@/contexts/trcpContext"; -import { api, RouterInputs } from "@/contexts/TRPCProvider"; +import { getTrcpContext } from "@/contexts/trcp-context"; +import { api } from "@/contexts/trpc-provider"; import { analytics } from "@/services/analytics"; import { colors, sizes } from "@/services/consts"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; import { Actions } from "@/store/reducers"; -import { SceneName } from "@/types/SceneName"; -import { Container, DistanceContainer, InputRow, InputSpace, SliderContainer } from "./styles"; +import { SceneName } from "@/types/scene-name"; + +import { + Container, + DistanceContainer, + InputRow, + InputSpace, + SliderContainer, +} from "./styles"; const { width } = Dimensions.get("window"); type MyDogUpdateMutation = RouterInputs["myDog"]["update"]; export const MAX_FILTER_DISTANCE = 300; export const MAX_FILTER_AGE = 10; -interface Preference extends Pick< - MyDogUpdateMutation, - "preferredBreedId" | "preferredColor" | "preferredSize" -> { +type Preference = { preferredMaxDistance: number[]; preferredAgeRange: number[]; -} +} & Pick< + MyDogUpdateMutation, + "preferredBreedId" | "preferredColor" | "preferredSize" +>; const schema = z .object({ @@ -45,7 +57,10 @@ const schema = z preferredSize: z.string().nullable().optional(), preferredMaxDistance: z.array(z.number().nullable()).nullable().optional(), preferredBreedId: z.string().nullable().optional(), - preferredAgeRange: z.array(z.number().nullable().optional()).nullable().optional(), + preferredAgeRange: z + .array(z.number().nullable().optional()) + .nullable() + .optional(), }) .optional(); @@ -68,8 +83,9 @@ const Preferences: React.FC = () => { preferredBreedId: undefined, preferredAgeRange: [0, MAX_FILTER_AGE], }, - // Zod is breaking here, seems to be an issue with the library - resolver: zodResolver(schema as any), + // The zod/@hookform resolver types disagree about the refined schema's + // output; the form's own generic is what actually types the values. + resolver: zodResolver(schema) as Resolver, }); const dispatch = useDispatch(); @@ -101,7 +117,10 @@ const Preferences: React.FC = () => { }; // Unlimited - if (body.preferredMaxDistance && body.preferredMaxDistance >= MAX_FILTER_DISTANCE) { + if ( + body.preferredMaxDistance && + body.preferredMaxDistance >= MAX_FILTER_DISTANCE + ) { body.preferredMaxDistance = undefined; } @@ -131,7 +150,9 @@ const Preferences: React.FC = () => { setValue("preferredBreedId", dog.preferredBreedId); setValue("preferredColor", dog.preferredColor); setValue("preferredSize", dog.preferredSize); - setValue("preferredMaxDistance", [dog.preferredMaxDistance ?? MAX_FILTER_DISTANCE]); + setValue("preferredMaxDistance", [ + dog.preferredMaxDistance ?? MAX_FILTER_DISTANCE, + ]); setValue("preferredAgeRange", [ dog.preferredMinAge ?? 0, dog.preferredMaxAge ?? MAX_FILTER_AGE + 1, @@ -151,7 +172,9 @@ const Preferences: React.FC = () => { contentContainerStyle={{ paddingHorizontal: theme.spacing[4], paddingTop: headerHeight, - paddingBottom: scrollViewProps.contentContainerStyle.paddingBottom + theme.spacing[4], + paddingBottom: + scrollViewProps.contentContainerStyle.paddingBottom + + theme.spacing[4], }} > { itemTestIDPrefix="preferences-color-item-" title={t("preferences.color")} placeholder={t("preferences.anyColor")} - data={[{ id: null, name: t("preferences.anyColor") }, ...colors]} + data={[ + { id: null, name: t("preferences.anyColor") }, + ...colors, + ]} value={ colors.find((color) => color.id === value) ?? { id: null, @@ -276,8 +302,10 @@ const Preferences: React.FC = () => { ); }; -export default () => ( +const PreferencesScreen = () => ( ); + +export default PreferencesScreen; diff --git a/apps/mobile/src/views/UpgradeWall/components/Benefits/index.tsx b/apps/mobile/src/views/UpgradeWall/components/Benefits/index.tsx index 68de1d8..ed9b661 100644 --- a/apps/mobile/src/views/UpgradeWall/components/Benefits/index.tsx +++ b/apps/mobile/src/views/UpgradeWall/components/Benefits/index.tsx @@ -1,8 +1,9 @@ import * as React from "react"; + import { useTranslation } from "react-i18next"; import SwipeBackArrow from "@/assets/images/SwipeBackArrow.svg"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; import AdsOff from "@/views/UpgradeWall/assets/AdsOff.svg"; import HighPriority from "@/views/UpgradeWall/assets/HighPriority.svg"; import Infinite from "@/views/UpgradeWall/assets/Infinite.svg"; diff --git a/apps/mobile/src/views/UpgradeWall/components/Benefits/styles.ts b/apps/mobile/src/views/UpgradeWall/components/Benefits/styles.ts index 692035c..8e2f460 100644 --- a/apps/mobile/src/views/UpgradeWall/components/Benefits/styles.ts +++ b/apps/mobile/src/views/UpgradeWall/components/Benefits/styles.ts @@ -8,15 +8,15 @@ export const BenefitContainer = styled.View` border-radius: ${({ theme }) => theme.radii.md}px; padding: ${({ theme }) => theme.spacing[3.5]}px; background-color: ${({ theme }) => { - return Color(theme.colors.text) + return new Color(theme.colors.text) .alpha(theme.dark ? 0.05 : 0.02) .toString(); }}; `; -interface BenefitIconContainerProps { +type BenefitIconContainerProps = { color: string; -} +}; export const BenefitIconContainer = styled.View` height: ${({ theme }) => theme.spacing[10]}px; @@ -25,9 +25,7 @@ export const BenefitIconContainer = styled.View` justify-content: center; border-radius: ${({ theme }) => theme.radii.sm}px; background-color: ${({ color, theme }) => - Color(color) - .alpha(theme.dark ? 0.2 : 0.1) - .toString()}; + new Color(color).alpha(theme.dark ? 0.2 : 0.1).toString()}; `; export const ContentContainer = styled.View` diff --git a/apps/mobile/src/views/UpgradeWall/components/Checkbox/index.tsx b/apps/mobile/src/views/UpgradeWall/components/Checkbox/index.tsx index 0cc1283..8407177 100644 --- a/apps/mobile/src/views/UpgradeWall/components/Checkbox/index.tsx +++ b/apps/mobile/src/views/UpgradeWall/components/Checkbox/index.tsx @@ -1,6 +1,7 @@ import { useTheme } from "styled-components"; import Check from "@/assets/images/Check.svg"; + import { Container } from "./styles"; export const Checkbox = ({ selected }: { selected?: boolean }) => { diff --git a/apps/mobile/src/views/UpgradeWall/components/Checkbox/styles.ts b/apps/mobile/src/views/UpgradeWall/components/Checkbox/styles.ts index 9e282ad..91c6690 100644 --- a/apps/mobile/src/views/UpgradeWall/components/Checkbox/styles.ts +++ b/apps/mobile/src/views/UpgradeWall/components/Checkbox/styles.ts @@ -1,9 +1,9 @@ import Color from "color"; import styled, { css } from "styled-components/native"; -interface CheckContainerProps { +type CheckContainerProps = { selected?: boolean; -} +}; export const Container = styled.View` height: ${({ theme }) => theme.spacing[6]}px; @@ -20,7 +20,8 @@ export const Container = styled.View` ${(props) => props.selected && css` - background-color: ${({ theme }) => Color(theme.colors.primary).alpha(0.2).toString()}; + background-color: ${({ theme }) => + new Color(theme.colors.primary).alpha(0.2).toString()}; border-color: ${({ theme }) => theme.colors.primary}; `} `; diff --git a/apps/mobile/src/views/UpgradeWall/components/PlanPackages/index.tsx b/apps/mobile/src/views/UpgradeWall/components/PlanPackages/index.tsx index d4e397d..9ec4b40 100644 --- a/apps/mobile/src/views/UpgradeWall/components/PlanPackages/index.tsx +++ b/apps/mobile/src/views/UpgradeWall/components/PlanPackages/index.tsx @@ -1,16 +1,22 @@ +import type { PurchasesPackage } from "react-native-purchases"; + import { useEffect } from "react"; import * as React from "react"; -import { magicToast } from "react-native-magic-toast"; -import { PurchasesPackage } from "react-native-purchases"; + import * as Device from "expo-device"; import { useRouter } from "expo-router"; + import { useTranslation } from "react-i18next"; +import { magicToast } from "react-native-magic-toast"; -import { useOfferings } from "@/hooks/usePayments"; +import { useOfferings } from "@/hooks/use-payments"; import { Container } from "@/views/UpgradeWall/components/PlanPackages/styles"; -import { PlanCard } from "./PlanCard"; -const periodToDays = (period: string) => { +import { PlanCard } from "./plan-card"; + +const periodToDays = (period: string | null | undefined) => { + if (!period) return 0; + const match = period.match(/P(\d+)(D|W|M|Y)/); if (!match) return 0; @@ -18,7 +24,7 @@ const periodToDays = (period: string) => { if (!num || !unit) throw new Error("Invalid period format"); - const numVal = parseInt(num, 10); + const numVal = Math.trunc(Number(num)); switch (unit) { case "D": @@ -34,12 +40,15 @@ const periodToDays = (period: string) => { } }; -interface OfferingsProps { +type OfferingsProps = { selectedPackage: PurchasesPackage | null | undefined; setSelectedPackage: (pkg: PurchasesPackage) => void; -} +}; -const PlanPackages: React.FC = ({ selectedPackage, setSelectedPackage }) => { +const PlanPackages: React.FC = ({ + selectedPackage, + setSelectedPackage, +}) => { const router = useRouter(); const { data: offeringsData, isError } = useOfferings(); const { t } = useTranslation(); @@ -65,19 +74,25 @@ const PlanPackages: React.FC = ({ selectedPackage, setSelectedPa const packageWithLessRelativeValue = offeringsData ? Object.values(offeringsData.availablePackages).sort((a, b) => { - const relativeValueA = a.product.price / periodToDays(a.product.subscriptionPeriod!); - const relativeValueB = b.product.price / periodToDays(b.product.subscriptionPeriod!); + const relativeValueA = + a.product.price / periodToDays(a.product.subscriptionPeriod); + const relativeValueB = + b.product.price / periodToDays(b.product.subscriptionPeriod); return relativeValueB - relativeValueA; })[0] : undefined; + // `packageList` is rebuilt on every render, so depending on it would run + // this effect every render. The first package is the only part that matters. + const [defaultPackage] = packageList; + useEffect(() => { - if (packageList[0] && !selectedPackage) { + if (defaultPackage && !selectedPackage) { // Optionally set a default package, or leave it to user interaction - setSelectedPackage(packageList[0]); + setSelectedPackage(defaultPackage); } - }, [packageList, selectedPackage, setSelectedPackage]); + }, [defaultPackage, selectedPackage, setSelectedPackage]); return ( @@ -85,8 +100,10 @@ const PlanPackages: React.FC = ({ selectedPackage, setSelectedPa // Get old price comparing with the package with less relative value / period * this package period const oldPrice = packageWithLessRelativeValue ? (packageWithLessRelativeValue.product.price / - periodToDays(packageWithLessRelativeValue.product.subscriptionPeriod!)) * - periodToDays(planPackage.product.subscriptionPeriod!) + periodToDays( + packageWithLessRelativeValue.product.subscriptionPeriod, + )) * + periodToDays(planPackage.product.subscriptionPeriod) : undefined; return ( diff --git a/apps/mobile/src/views/UpgradeWall/components/PlanPackages/PlanCard.tsx b/apps/mobile/src/views/UpgradeWall/components/PlanPackages/plan-card.tsx similarity index 72% rename from apps/mobile/src/views/UpgradeWall/components/PlanPackages/PlanCard.tsx rename to apps/mobile/src/views/UpgradeWall/components/PlanPackages/plan-card.tsx index b30fc05..d88a26f 100644 --- a/apps/mobile/src/views/UpgradeWall/components/PlanPackages/PlanCard.tsx +++ b/apps/mobile/src/views/UpgradeWall/components/PlanPackages/plan-card.tsx @@ -1,8 +1,10 @@ +import type { PurchasesPackage } from "react-native-purchases"; + import * as React from "react"; -import { PurchasesPackage } from "react-native-purchases"; + import { useTranslation } from "react-i18next"; -import { Text } from "@/components/Text"; +import { Text } from "@/components/text"; import { Checkbox } from "@/views/UpgradeWall/components/Checkbox"; import { Flex, @@ -12,12 +14,34 @@ import { Price, } from "@/views/UpgradeWall/components/PlanPackages/styles"; -interface PlanCardProps { +const formatPrice = (value: number, currency: string) => + new Intl.NumberFormat("default", { + style: "currency", + currency, + }).format(value); + +const getPeriodDetails = ( + period: string, +): { + periodUnit: "D" | "W" | "M" | "Y"; + periodValue: number; +} => { + const [, num, unit] = period.match(/P(\d+)(D|W|M|Y)/) ?? []; + + if (!num || !unit) throw new Error("Invalid period format"); + + return { + periodUnit: unit as "D" | "W" | "M" | "Y", + periodValue: Math.trunc(Number(num)), + }; +}; + +type PlanCardProps = { selected: boolean; onPress: () => void; planPackage: PurchasesPackage; oldPrice?: number; -} +}; export const PlanCard: React.FC = ({ selected, @@ -28,30 +52,12 @@ export const PlanCard: React.FC = ({ const { t } = useTranslation(); const { product } = pkg; - const { price: currentPrice, currencyCode, subscriptionPeriod: period, identifier } = product; - - const formatPrice = (value: number, currency: string) => - Intl.NumberFormat("default", { - style: "currency", - currency, - }).format(value); - - const getPeriodDetails = ( - period: string, - ): { - periodUnit: "D" | "W" | "M" | "Y"; - periodValue: number; - } => { - const periodMatch = period.match(/P(\d+)(D|W|M|Y)/); - const [, num, unit] = periodMatch!; - - if (!num || !unit) throw new Error("Invalid period format"); - - return { - periodUnit: unit as "D" | "W" | "M" | "Y", - periodValue: parseInt(num, 10), - }; - }; + const { + price: currentPrice, + currencyCode, + subscriptionPeriod: period, + identifier, + } = product; const formattedCurrentPrice = formatPrice(currentPrice, currencyCode); @@ -89,7 +95,11 @@ export const PlanCard: React.FC = ({ })(); return ( - + diff --git a/apps/mobile/src/views/UpgradeWall/components/PlanPackages/styles.ts b/apps/mobile/src/views/UpgradeWall/components/PlanPackages/styles.ts index b6cbe22..98238a3 100644 --- a/apps/mobile/src/views/UpgradeWall/components/PlanPackages/styles.ts +++ b/apps/mobile/src/views/UpgradeWall/components/PlanPackages/styles.ts @@ -1,12 +1,12 @@ import Color from "color"; import styled, { css } from "styled-components/native"; -import { PressableArea } from "@/components/PressableArea"; -import { Text } from "@/components/Text"; +import { PressableArea } from "@/components/pressable-area"; +import { Text } from "@/components/text"; -interface CheckContainerProps { +type CheckContainerProps = { selected?: boolean; -} +}; export const Flex = styled.View` flex: 1; @@ -16,7 +16,9 @@ export const Container = styled.View` gap: ${({ theme }) => theme.spacing[2.5]}px; `; -export const PlanContainer = styled(PressableArea).attrs({ accessible: true })` +export const PlanContainer = styled(PressableArea).attrs({ + accessible: true, +})` border-width: ${({ theme }) => theme.stroke.lg}px; border-color: ${({ theme }) => theme.colors.border}; border-radius: ${({ theme }) => theme.radii.md}px; @@ -29,7 +31,8 @@ export const PlanContainer = styled(PressableArea).attrs({ accessible: true }) props.selected && css` - background-color: ${({ theme }) => Color(theme.colors.primary).alpha(0.1).toString()}; + background-color: ${({ theme }) => + new Color(theme.colors.primary).alpha(0.1).toString()}; border-color: ${({ theme }) => theme.colors.primary}; `} `; @@ -39,7 +42,8 @@ export const PercentContainer = styled.View` top: ${({ theme }) => -theme.spacing[3]}px; right: ${({ theme }) => theme.spacing[3]}px; background-color: ${({ theme }) => theme.colors.primary}; - padding: ${({ theme }) => theme.spacing[1]}px ${({ theme }) => theme.spacing[2]}px; + padding: ${({ theme }) => theme.spacing[1]}px + ${({ theme }) => theme.spacing[2]}px; border-radius: ${({ theme }) => theme.radii.sm}px; `; diff --git a/apps/mobile/src/views/UpgradeWall/components/RestorePurchases/index.tsx b/apps/mobile/src/views/UpgradeWall/components/RestorePurchases/index.tsx index bd7e1e1..4ecbe88 100644 --- a/apps/mobile/src/views/UpgradeWall/components/RestorePurchases/index.tsx +++ b/apps/mobile/src/views/UpgradeWall/components/RestorePurchases/index.tsx @@ -1,15 +1,17 @@ import * as React from "react"; import { ActivityIndicator, Alert } from "react-native"; + import { isDevice } from "expo-device"; import { router } from "expo-router"; + import { useMutation } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components"; -import { PressableArea } from "@/components/PressableArea"; -import { Text } from "@/components/Text"; +import { PressableArea } from "@/components/pressable-area"; +import { Text } from "@/components/text"; import { analytics } from "@/services/analytics"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; import { payments } from "@/services/payments"; const RestorePurchases: React.FC = () => { diff --git a/apps/mobile/src/views/UpgradeWall/index.tsx b/apps/mobile/src/views/UpgradeWall/index.tsx index 87aeadd..6602b5f 100644 --- a/apps/mobile/src/views/UpgradeWall/index.tsx +++ b/apps/mobile/src/views/UpgradeWall/index.tsx @@ -1,24 +1,28 @@ +import type { PurchasesPackage } from "react-native-purchases"; + import { useState } from "react"; import * as React from "react"; import { Alert, Platform, View } from "react-native"; -import { PurchasesPackage } from "react-native-purchases"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; + import { isDevice } from "expo-device"; import { useRouter } from "expo-router"; + import { useMutation } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "styled-components"; import { BottomAction, useBottomActionStyle } from "@/components/BottomAction"; import { Button } from "@/components/Button"; -import { useEligibleForTrial } from "@/hooks/usePayments"; +import { useEligibleForTrial } from "@/hooks/use-payments"; import { analytics } from "@/services/analytics"; -import { sendError } from "@/services/errorTracking"; +import { sendError } from "@/services/error-tracking"; import { haptics } from "@/services/haptics"; import { payments } from "@/services/payments"; import Benefits from "@/views/UpgradeWall/components/Benefits"; import PlanPackages from "@/views/UpgradeWall/components/PlanPackages"; import RestorePurchases from "@/views/UpgradeWall/components/RestorePurchases"; + import { CloseButton, CloseIcon, @@ -31,7 +35,9 @@ import { Title, } from "./styles"; -const useTranslatedTrialDuration = (offering: PurchasesPackage | null | undefined) => { +const useTranslatedTrialDuration = ( + offering: PurchasesPackage | null | undefined, +) => { const { t } = useTranslation(); const introPrice = offering?.product.introPrice; @@ -43,13 +49,21 @@ const useTranslatedTrialDuration = (offering: PurchasesPackage | null | undefine switch (introPrice.periodUnit) { case "DAY": - return quantity === 1 ? t("dateFormatting.day", props) : t("dateFormatting.days", props); + return quantity === 1 + ? t("dateFormatting.day", props) + : t("dateFormatting.days", props); case "WEEK": - return quantity === 1 ? t("dateFormatting.week", props) : t("dateFormatting.weeks", props); + return quantity === 1 + ? t("dateFormatting.week", props) + : t("dateFormatting.weeks", props); case "MONTH": - return quantity === 1 ? t("dateFormatting.month", props) : t("dateFormatting.months", props); + return quantity === 1 + ? t("dateFormatting.month", props) + : t("dateFormatting.months", props); case "YEAR": - return quantity === 1 ? t("dateFormatting.year", props) : t("dateFormatting.years", props); + return quantity === 1 + ? t("dateFormatting.year", props) + : t("dateFormatting.years", props); } }; @@ -59,7 +73,9 @@ const UpgradeWall: React.FC = () => { const router = useRouter(); const { t } = useTranslation(); - const [selectedOffering, setSelectedOffering] = useState(); + const [selectedOffering, setSelectedOffering] = useState< + PurchasesPackage | null | undefined + >(); const freeTrialDuration = useTranslatedTrialDuration(selectedOffering); @@ -127,7 +143,9 @@ const UpgradeWall: React.FC = () => { }); const title = t( - isEligibleForTrial ? "plans.upgradeWall.earnedFreeTrial" : "plans.upgradeWall.getPremium", + isEligibleForTrial + ? "plans.upgradeWall.earnedFreeTrial" + : "plans.upgradeWall.getPremium", ); const trialSubtitle = isEligibleForTrial @@ -135,10 +153,13 @@ const UpgradeWall: React.FC = () => { : t("plans.upgradeWall.enjoyFullAccess"); const buttonText = t( - isEligibleForTrial ? "plans.upgradeWall.startFreeTrial" : "plans.upgradeWall.getPremium", + isEligibleForTrial + ? "plans.upgradeWall.startFreeTrial" + : "plans.upgradeWall.getPremium", ); - const paddingTop = Platform.OS === "ios" ? theme.spacing[2] : insets.top + theme.spacing[2]; + const paddingTop = + Platform.OS === "ios" ? theme.spacing[2] : insets.top + theme.spacing[2]; const headerHeight = 40 + paddingTop; return ( @@ -149,8 +170,8 @@ const UpgradeWall: React.FC = () => { ...bottomActionStyle.scrollViewProps.contentContainerStyle, gap: theme.spacing[5], paddingBottom: - bottomActionStyle.scrollViewProps.contentContainerStyle.paddingBottom + - theme.spacing[8], + bottomActionStyle.scrollViewProps.contentContainerStyle + .paddingBottom + theme.spacing[8], paddingTop: theme.spacing[3] + headerHeight, }} > @@ -209,7 +230,7 @@ const UpgradeWall: React.FC = () => {