diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000..b2318480b3 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,9 @@ +# CodeRabbit review scope. Generated native scaffolding (Xcode/Gradle +# projects, plists, resources) isn't worth AI review and pushes large +# mobile PRs over the 150-file review limit. +reviews: + path_filters: + - '!ios/**' + - '!android/**' + - '!patches/**' + - '!**/*.svg' diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000000..35529de5a8 --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,159 @@ +name: Android Release (Play) + +# Builds a SIGNED Android App Bundle and uploads it to Google Play. +# Removes the "release only builds on one laptop" gap: the upload keystore lives +# as CI secrets, the build is reproducible, and the AAB lands on a Play track. +# +# Trigger: push a tag `vX.Y.Z`, or run manually (workflow_dispatch) and pick a track. +# Prereq: fix/native-build-reliability must be merged (card-comparison static-export +# fix) or `native:release` will fail to build. +# +# Required repo secrets (see docs/NATIVE-RELEASE.md §Ops): +# ANDROID_KEYSTORE_BASE64 base64 of the upload keystore +# ANDROID_KEYSTORE_PASSWORD store password +# ANDROID_KEY_ALIAS key alias (e.g. peanut) +# ANDROID_KEY_PASSWORD key password +# PLAY_SERVICE_ACCOUNT_JSON Google Play Developer API service-account JSON +# SUBMODULE_TOKEN read access to the src/content submodule +# Plus the production NEXT_PUBLIC_* the static export bakes in (see the env step). + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + track: + description: 'Play track' + required: true + default: 'internal' + type: choice + options: [internal, alpha, beta, production] + versionName: + description: 'versionName override (optional; defaults to package.json)' + required: false + type: string + +permissions: + contents: read + +concurrency: + group: android-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + # Protect with required reviewers in repo Settings → Environments → Production. + environment: Production + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '22' # @sentry/profiling-node has no Node 25 binary; project targets 22 + cache: 'pnpm' + + - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 + with: + distribution: 'temurin' + java-version: '21' # Capacitor 8's capacitor-android compiles at source 21 + + - name: Install dependencies + run: pnpm install + + - name: Decode upload keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/peanut-release.keystore + cat > android/keystore.properties < .env.production.local < android/app/google-services.json + echo "✅ google-services.json written" + else + echo "⚠️ ANDROID_GOOGLE_SERVICES_JSON unset — native push disabled for this build" + fi + + - name: Build signed AAB + run: | + # versionName: manual dispatch input wins; else the tag name minus + # its leading 'v' (v1.0.10 -> 1.0.10); else build.gradle's + # package.json fallback. Keeps the Play versionName in lockstep + # with the release tag without a manual package.json bump. + VERSION_NAME="${{ github.event.inputs.versionName }}" + if [ -z "$VERSION_NAME" ] && [ "$GITHUB_REF_TYPE" = "tag" ]; then + VERSION_NAME="${GITHUB_REF_NAME#v}" + fi + export ANDROID_VERSION_NAME="$VERSION_NAME" + # Monotonic (run_number always increases). +10000 clears legacy + # codes already on Play from earlier manual/local uploads (small + # console codes plus git-commit-count builds up to ~8600). + export ANDROID_VERSION_CODE=$((10000 + GITHUB_RUN_NUMBER)) + pnpm native:release + + - name: Upload to Google Play + uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: me.peanut.wallet + releaseFiles: android/app/build/outputs/bundle/release/app-release.aab + tracks: ${{ github.event.inputs.track || 'internal' }} + status: completed + # First releases have no reviewed base, so Play can't auto-submit + # for review; commit the edit and review from the Console instead. + changesNotSentForReview: true + # Staged production rollout: set status: inProgress + userFraction: 0.1, + # then promote in Play Console once crash/error rates look clean. + + - name: Upload AAB as build artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: app-release-aab + path: android/app/build/outputs/bundle/release/app-release.aab + if-no-files-found: warn diff --git a/.github/workflows/capgo-deploy-ios.yml b/.github/workflows/capgo-deploy-ios.yml new file mode 100644 index 0000000000..684399f0c1 --- /dev/null +++ b/.github/workflows/capgo-deploy-ios.yml @@ -0,0 +1,92 @@ +name: Deploy OTA Update — iOS only (Capgo) + +# iOS-only OTA for the mobile-release branch. Uploads the JS bundle to a +# dedicated iOS channel so Android (which lives on the `production`/`staging` +# channels) is never affected. Devices must be subscribed to this channel to +# receive it (self-assign enabled below), so this is a testing/preview lane — +# not a push to all production iOS users. + +on: + push: + branches: [feat/mobile-release] + workflow_dispatch: + inputs: + channel: + description: 'iOS Capgo channel to deploy to' + required: true + default: 'ios-mobile-release' + type: string + +permissions: + contents: read + +concurrency: + group: capgo-deploy-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + submodules: true + token: ${{ secrets.SUBMODULE_TOKEN }} + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Build native static export + run: node scripts/native-build.js + + - name: Verify build output + run: | + test -d out && test -f out/index.html || (echo "ERROR: out/ directory missing or incomplete" && exit 1) + echo "Bundle ready. File count: $(find out -type f | wc -l)" + + - name: Resolve channel + id: channel + run: echo "name=${{ github.event.inputs.channel || 'ios-mobile-release' }}" >> "$GITHUB_OUTPUT" + + # Best-effort: create the channel and lock it to iOS. Idempotent and + # non-fatal — if the channel already exists / flags differ, the upload + # below still runs and the channel's platform can be set once in the + # Capgo dashboard (iOS on, Android off, self-assign on). + - name: Ensure iOS-only channel exists + continue-on-error: true + run: | + npx @capgo/cli@latest channel add ${{ steps.channel.outputs.name }} \ + --apikey ${{ secrets.CAPGO_API_KEY }} || true + npx @capgo/cli@latest channel set ${{ steps.channel.outputs.name }} \ + --apikey ${{ secrets.CAPGO_API_KEY }} \ + --ios --no-android --self-assign + + - name: Upload iOS bundle to Capgo + # Pass the commit message via env, never inline — a multi-line message + # (or one containing quotes) injected into the run script breaks the + # --comment quoting and spills into positional args. Use its first line. + env: + CAPGO_API_KEY: ${{ secrets.CAPGO_API_KEY }} + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: | + COMMENT="iOS OTA ${GITHUB_SHA:0:7} — $(printf '%s' "${COMMIT_MSG:-manual deploy}" | head -n1)" + npx @capgo/cli@latest bundle upload \ + --channel "${{ steps.channel.outputs.name }}" \ + --apikey "$CAPGO_API_KEY" \ + --path ./out \ + --auto-min-update-version \ + --comment "$COMMENT" + + - name: Deployment summary + run: | + echo "## iOS-only OTA Deployment" >> $GITHUB_STEP_SUMMARY + echo "- **Channel:** ${{ steps.channel.outputs.name }} (iOS only)" >> $GITHUB_STEP_SUMMARY + echo "- **Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "- **Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/capgo-deploy.yml b/.github/workflows/capgo-deploy.yml index 724d0bcc27..ffafe6d01e 100644 --- a/.github/workflows/capgo-deploy.yml +++ b/.github/workflows/capgo-deploy.yml @@ -1,8 +1,11 @@ name: Deploy OTA Update (Capgo) on: + # Only `dev` auto-ships (→ staging channel). Production is workflow_dispatch-only + # and gated by the Production environment's required reviewers, so a merge to + # `main` can never push OTA JS to devices without an explicit approved run. push: - branches: [main, dev] + branches: [dev] workflow_dispatch: inputs: channel: @@ -15,6 +18,9 @@ on: - staging - production +permissions: + contents: read + concurrency: group: capgo-deploy-${{ github.ref }} cancel-in-progress: true @@ -22,19 +28,22 @@ concurrency: jobs: deploy: runs-on: ubuntu-latest + # Gate ONLY the production deploy behind the Production environment (required + # reviewers, set in repo Settings → Environments → Production). Resolving to an + # empty string means no environment, so the dev→staging auto-push is never + # paused for approval. Production is reachable only via workflow_dispatch. + environment: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.channel == 'production') && 'Production' || '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: submodules: true token: ${{ secrets.SUBMODULE_TOKEN }} - - uses: pnpm/action-setup@v4 - with: - version: 10 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: '21.1.0' + node-version: '20' cache: 'pnpm' - name: Install dependencies @@ -60,13 +69,24 @@ jobs: fi - name: Upload bundle to Capgo + # Commit message via env, never inline — a multi-line message (or one + # with quotes) injected into the run script breaks --comment quoting. + env: + CAPGO_API_KEY: ${{ secrets.CAPGO_API_KEY }} + # Private E2E signing key (PEM contents of .capgo_key_v2). Its + # public half is baked into capacitor.config.ts, so the app only + # accepts bundles signed here. + CAPGO_PRIVATE_KEY: ${{ secrets.CAPGO_PRIVATE_KEY }} + COMMIT_MSG: ${{ github.event.head_commit.message }} run: | + COMMENT="${GITHUB_SHA:0:7} — $(printf '%s' "${COMMIT_MSG:-Manual deploy}" | head -n1)" npx @capgo/cli@latest bundle upload \ - --channel ${{ steps.channel.outputs.name }} \ - --apikey ${{ secrets.CAPGO_API_KEY }} \ + --channel "${{ steps.channel.outputs.name }}" \ + --apikey "$CAPGO_API_KEY" \ + --key-data-v2 "$CAPGO_PRIVATE_KEY" \ --path ./out \ --auto-min-update-version \ - --comment "${{ github.event.head_commit.message || 'Manual deploy' }}" + --comment "$COMMENT" - name: Deployment summary run: | diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml index 513f16e7ab..c37511a2f4 100644 --- a/.github/workflows/code-analysis.yml +++ b/.github/workflows/code-analysis.yml @@ -46,8 +46,6 @@ jobs: submodules: false - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml new file mode 100644 index 0000000000..1f66c72df5 --- /dev/null +++ b/.github/workflows/ios-release.yml @@ -0,0 +1,193 @@ +name: iOS Release (TestFlight) + +# Builds a SIGNED iOS app and uploads it to TestFlight. +# Mirrors android-release.yml's no-fastlane style: signing material lives as CI +# secrets (base64 .p12 cert + base64 provisioning profile, the iOS analogue of the +# Android keystore-as-secret), the build is reproducible, and the IPA lands on +# TestFlight for manual App Store promotion. No Ruby/fastlane toolchain. +# +# Trigger: push a tag `vX.Y.Z`, or run manually (workflow_dispatch). +# Prereq: the `ios/` Capacitor platform must be committed on the branch CI builds +# (currently only present in the peanut-ui-ios2 worktree). +# +# Required repo secrets (see docs/NATIVE-RELEASE.md §11): +# ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_CONTENT App Store Connect API key (.p8 PEM contents) +# APPLE_TEAM_ID Apple Developer team id +# IOS_DIST_CERT_P12_BASE64 / IOS_DIST_CERT_PASSWORD Apple Distribution cert (.p12, base64) + its password +# IOS_PROVISIONING_PROFILE_BASE64 App Store provisioning profile (.mobileprovision, base64) +# SUBMODULE_TOKEN read access to the src/content submodule +# Plus the production NEXT_PUBLIC_* the static export bakes in (see the env step). + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + versionName: + description: 'versionName override (optional; defaults to project MARKETING_VERSION)' + required: false + type: string + +permissions: + contents: read + +concurrency: + group: ios-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: macos-15 + # Protect with required reviewers in repo Settings → Environments → Production. + environment: Production + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} + + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: latest-stable + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' # @sentry/profiling-node has no Node 25 binary; project targets 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Production web env + run: | + # NEXT_PUBLIC_* values are baked into the static export at build time. + # rpId MUST be peanut.me (passkeys + assetlinks/AASA). + # Keep this block identical to android-release.yml. CAPACITOR_BUILD / + # IS_NATIVE_BUILD / GIT_COMMIT_HASH are auto-baked by next.config.native.js. + cat > .env.production.local < /tmp/profile.mobileprovision + # Decode the profile to read its UUID + Name (no extra secret needed). + security cms -D -i /tmp/profile.mobileprovision > /tmp/profile.plist + PROFILE_UUID=$(/usr/libexec/PlistBuddy -c 'Print :UUID' /tmp/profile.plist) + PROFILE_NAME=$(/usr/libexec/PlistBuddy -c 'Print :Name' /tmp/profile.plist) + cp /tmp/profile.mobileprovision "$PROFILE_DIR/$PROFILE_UUID.mobileprovision" + echo "name=$PROFILE_NAME" >> "$GITHUB_OUTPUT" + echo "Installed provisioning profile: $PROFILE_NAME ($PROFILE_UUID)" + + - name: Archive & export IPA + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + PROFILE_NAME: ${{ steps.provision.outputs.name }} + # Monotonic, always-increments-even-on-rerun. TestFlight requires each + # upload's build number to exceed the last. + IOS_BUILD_NUMBER: ${{ github.run_number }} + IOS_VERSION_NAME: ${{ github.event.inputs.versionName || '' }} + run: | + # Only override MARKETING_VERSION when an explicit versionName is supplied; + # otherwise keep the project's MARKETING_VERSION. CURRENT_PROJECT_VERSION is + # always the CI run number. Info.plist reads both via $(...) build settings. + MARKETING_ARG=() + if [ -n "$IOS_VERSION_NAME" ]; then MARKETING_ARG=("MARKETING_VERSION=$IOS_VERSION_NAME"); fi + + cat > /tmp/ExportOptions.plist < + + + + method + app-store + teamID + ${APPLE_TEAM_ID} + signingStyle + manual + provisioningProfiles + + me.peanut.wallet + ${PROFILE_NAME} + + uploadSymbols + + + + EOF + + # Manual signing lives in the App target's Release build config + # (CODE_SIGN_STYLE=Manual, DEVELOPMENT_TEAM, CODE_SIGN_IDENTITY, + # PROVISIONING_PROFILE_SPECIFIER="Peanut Wallet App Store"). It is NOT + # passed on the command line: global build settings leak onto the SwiftPM + # dependency targets (Alamofire, ZIPFoundation, …), which reject a + # provisioning profile and fail the archive. Only version numbers, which + # are harmless to inherit, are overridden here. + xcodebuild \ + -project ios/App/App.xcodeproj \ + -scheme App \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath build/ios/App.xcarchive \ + CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" \ + "${MARKETING_ARG[@]}" \ + archive + + xcodebuild \ + -exportArchive \ + -archivePath build/ios/App.xcarchive \ + -exportPath build/ios \ + -exportOptionsPlist /tmp/ExportOptions.plist + + - name: Upload to TestFlight + uses: apple-actions/upload-testflight-build@1ad58030672057aa084b4e96beb6f7a8c627f9e6 # v5 + with: + app-path: build/ios/App.ipa + issuer-id: ${{ secrets.ASC_ISSUER_ID }} + api-key-id: ${{ secrets.ASC_KEY_ID }} + api-private-key: ${{ secrets.ASC_KEY_CONTENT }} + # The upload itself is what matters; the post-upload processing + # poll re-queries ASC for many minutes and fails when the API + # token expires mid-wait (401 NOT_AUTHORIZED). The build is + # already in App Store Connect by then, so skip the wait. + wait-for-processing: 'false' + + - name: Upload IPA as build artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: app-store-ipa + path: build/ios/*.ipa + if-no-files-found: warn diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index 73f5dd5d43..e076922e0c 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -22,8 +22,6 @@ jobs: submodules: true token: ${{ secrets.SUBMODULE_TOKEN }} - uses: pnpm/action-setup@v4 - with: - version: 9 - name: Install Vercel CLI run: pnpm add --global vercel@latest - name: Link to Project diff --git a/.github/workflows/supply-chain-check.yml b/.github/workflows/supply-chain-check.yml index 319f731abc..ce4fbc3930 100644 --- a/.github/workflows/supply-chain-check.yml +++ b/.github/workflows/supply-chain-check.yml @@ -32,8 +32,6 @@ jobs: node-version: '21.1.0' - uses: pnpm/action-setup@v4 - with: - version: 10 - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/sync-openapi.yml b/.github/workflows/sync-openapi.yml index be4480e16a..c2e22ce482 100644 --- a/.github/workflows/sync-openapi.yml +++ b/.github/workflows/sync-openapi.yml @@ -19,8 +19,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e6b55f38a2..f61504efda 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,8 +32,6 @@ jobs: token: ${{ secrets.SUBMODULE_TOKEN }} - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: @@ -71,8 +69,6 @@ jobs: token: ${{ secrets.SUBMODULE_TOKEN }} - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: @@ -95,8 +91,6 @@ jobs: token: ${{ secrets.SUBMODULE_TOKEN }} - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: @@ -124,8 +118,6 @@ jobs: token: ${{ secrets.SUBMODULE_TOKEN }} - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: @@ -192,8 +184,6 @@ jobs: token: ${{ secrets.SUBMODULE_TOKEN }} - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: diff --git a/.gitignore b/.gitignore index d9587c4290..70d2ea2ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ docs/PR.md **.patch +# pnpm patchedDependencies live in patches/ and must be committed +!patches/**.patch PR.md @@ -13,6 +15,8 @@ PR.md .pnp.js .vscode **.patch +# pnpm patchedDependencies live in patches/ and must be committed +!patches/**.patch # testing /coverage @@ -96,8 +100,9 @@ e2e/.auth/ keystore.properties .env.production.local -# capgo ota signing key +# capgo ota signing keys — private key is a CI secret; public key lives in capacitor.config.ts .capgo_key_v2 +.capgo_key_v2.pub test-results/ coverage/ diff --git a/.npmrc b/.npmrc index f615610113..0b36ba60ce 100644 --- a/.npmrc +++ b/.npmrc @@ -1,23 +1,3 @@ -# this file is used to configure the behavior of npm -# adding these lines as a workaround for the issue with warnings when using trubopack, source: https://github.com/vercel/next.js/issues/68805 -public-hoist-pattern[]=*import-in-the-middle* -public-hoist-pattern[]=*require-in-the-middle* - -# Supply-chain freshness floor: every dep (incl. transitive) must be ≥14 days -# old before pnpm will install it. Defends against compromised packages that -# get yanked within hours of publish. Emergency override: -# PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 pnpm install -# Per-package allowlist via minimum-release-age-exclude (comma-separated). -minimum-release-age=20160 -# protobufjs 7.5.5 (2026-04-15) is 9.7d old at time of allowlisting but -# closes a critical RCE (GHSA-xx7c-cv9c-4p4r). Remove this entry once it -# crosses the 14-day floor (~2026-04-29). -# @capgo/capacitor-passkey 8.2.2 (2026-04-16) ships with feat/card-ui's Rain -# card flow; allowlist until 2026-04-30 then drop. FOLLOW-UP: bump to 8.2.3. -minimum-release-age-exclude[]=protobufjs -# @capgo/* packages ship rolling Capacitor 8.x releases; the floor would block -# every native-app build. Card-ui's Rain card flow + native passkey path need -# them. FOLLOW-UP 2026-05: revisit and pin specific versions ≥14d old. -minimum-release-age-exclude[]=@capgo/capacitor-passkey -minimum-release-age-exclude[]=@capgo/capacitor-crisp -minimum-release-age-exclude[]=@capgo/capacitor-updater \ No newline at end of file +# npm config. pnpm-only settings (minimumReleaseAge, minimumReleaseAgeExclude, +# publicHoistPattern) live in pnpm-workspace.yaml so npm doesn't warn about +# unknown config keys. diff --git a/android/app/build.gradle b/android/app/build.gradle index 535d058748..cf4bb6fbc3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -6,15 +6,51 @@ if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } +// Version is sourced from the release pipeline so it always increments without a +// manual edit. Play requires each upload's versionCode to exceed the last; the +// rejected first submission was code 1, so we never emit below 2. +// - versionName: ANDROID_VERSION_NAME env, else package.json "version" +// - versionCode: ANDROID_VERSION_CODE env (set by scripts/native-release.sh), +// else the git commit count — monotonic and zero-bookkeeping, so even a raw +// `./gradlew bundleRelease` produces a valid, increasing code. +def pkgVersion = '1.0.0' +try { + def pkgJson = new groovy.json.JsonSlurper().parse(rootProject.file('../package.json')) + if (pkgJson.version) pkgVersion = pkgJson.version +} catch (Exception e) { + logger.warn("Could not read version from package.json: ${e.message}") +} + +def gitCommitCount = { + try { + def proc = ['git', 'rev-list', '--count', 'HEAD'].execute(null, rootProject.projectDir) + proc.waitFor() + def out = proc.in.text.trim() + if (out.isInteger() && out.toInteger() >= 2) return out.toInteger() + } catch (Exception e) { + logger.warn("Could not derive versionCode from git: ${e.message}") + } + return 2 // floor — rejected first upload was code 1 +} + +def resolvedVersionName = System.getenv('ANDROID_VERSION_NAME') ?: pkgVersion +def resolvedVersionCode = (System.getenv('ANDROID_VERSION_CODE')?.isInteger() + ? System.getenv('ANDROID_VERSION_CODE').toInteger() + : gitCommitCount()) + android { namespace = "me.peanut.wallet" compileSdk = rootProject.ext.compileSdkVersion + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } defaultConfig { applicationId "me.peanut.wallet" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0.0" + versionCode resolvedVersionCode + versionName resolvedVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 1afe8332f3..d023f8ac12 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -11,11 +11,15 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') implementation project(':capacitor-browser') + implementation project(':capacitor-clipboard') + implementation project(':capacitor-haptics') + implementation project(':capacitor-keyboard') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') implementation project(':capgo-capacitor-crisp') implementation project(':capgo-capacitor-passkey') implementation project(':capgo-capacitor-updater') + implementation project(':onesignal-capacitor-plugin') } apply from: "../../node_modules/.pnpm/@sumsub+cordova-idensic-mobile-sdk-plugin@1.42.0/node_modules/@sumsub/cordova-idensic-mobile-sdk-plugin/src/android/build-extras.gradle" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e0dbb1f461..b9932357b4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme"> + android:theme="@style/AppTheme" + android:networkSecurityConfig="@xml/network_security_config"> @@ -23,6 +26,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + diff --git a/android/app/src/main/res/values/capacitor-passkey.xml b/android/app/src/main/res/values/capacitor-passkey.xml index ee14ceda6e..ff1916f1a0 100644 --- a/android/app/src/main/res/values/capacitor-passkey.xml +++ b/android/app/src/main/res/values/capacitor-passkey.xml @@ -1,4 +1,4 @@ - [{"include":"https://staging.peanut.me/.well-known/assetlinks.json"}] + [{"include":"https://peanut.me/.well-known/assetlinks.json"}] diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..c7755e76fa --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + 10.0.2.2 + localhost + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index af686b5bc9..549e498190 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -29,6 +29,12 @@ allprojects { exclude group: 'com.sumsub.sns', module: 'idensic-mobile-sdk-eid' exclude group: 'de.authada.library', module: 'aal' } + + // Force Java 17 for all modules (capacitor-android uses 21) + tasks.withType(JavaCompile) { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } } task clean(type: Delete) { diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index db105730f1..9d8d764e58 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -8,6 +8,15 @@ project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacito include ':capacitor-browser' project(':capacitor-browser').projectDir = new File('../node_modules/.pnpm/@capacitor+browser@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/browser/android') +include ':capacitor-clipboard' +project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard/android') + +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/haptics/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/keyboard/android') + include ':capacitor-splash-screen' project(':capacitor-splash-screen').projectDir = new File('../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen/android') @@ -22,3 +31,6 @@ project(':capgo-capacitor-passkey').projectDir = new File('../node_modules/@capg include ':capgo-capacitor-updater' project(':capgo-capacitor-updater').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-updater@8.45.9_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-updater/android') + +include ':onesignal-capacitor-plugin' +project(':onesignal-capacitor-plugin').projectDir = new File('../node_modules/@onesignal/capacitor-plugin/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index 01fb171846..91a84133bf 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -25,11 +25,19 @@ const config: CapacitorConfig = { }, plugins: { CapacitorUpdater: { - autoUpdate: true, + // autoUpdate:false → the plugin no longer polls getLatest on every + // foreground, which was hammering Capgo's cloud rate limit (429s in + // Sentry). initCapgoUpdater() does one guarded check per launch instead. + autoUpdate: false, appReadyTimeout: 15000, responseTimeout: 30, autoDeleteFailed: true, autoDeletePrevious: true, + // E2E signing: bundles are encrypted/signed with the private key + // (.capgo_key_v2, CI secret only) and verified on-device with this + // public key, so only we can ship an OTA the app will accept. + publicKey: + '-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAr0HzEca/1vuvWcJ8/xYB6tx0j4uJMzw/kT34GnjyMlRmLLUIO9sj\nroXaUGaNoqlOCx73b7Qgp10TLPOAVxmoHV9ZJ4BS9cMCl5mvzB4qIdl6FZLcl3g5\nk5Nkj4w22nskqbBqL7eqMXpk4DD9oWRclnaZC/lCpok1n2AWy4EMZrshemBQ6iXr\ncppo+WByPbqmh/GbHvJyRvkx4Rgt2LSBJBI3laP3eEDkujCq1ZH9qgcIE4MXO5xq\n7c6LsLjN5wkQiNPSPI81zAbqBThhqodKzwav0FwIE1pyiJeGk1nV5Ji5kUgpFNwY\nY78iDVq4OP2jPfWO4jXnnJtnGN7aeKDMEQIDAQAB\n-----END RSA PUBLIC KEY-----\n', }, CapacitorHttp: { enabled: true, @@ -42,6 +50,10 @@ const config: CapacitorConfig = { origin: `https://${process.env.NEXT_PUBLIC_NATIVE_RP_ID || 'peanut.me'}`, domains: [process.env.NEXT_PUBLIC_NATIVE_RP_ID || 'peanut.me'], }, + Keyboard: { + // resize the webview when the soft keyboard shows so inputs aren't hidden. + resize: 'native', + }, }, } diff --git a/docs/DEMO-MODE.md b/docs/DEMO-MODE.md new file mode 100644 index 0000000000..c130250c4c --- /dev/null +++ b/docs/DEMO-MODE.md @@ -0,0 +1,105 @@ +# Demo mode + +App-Store reviewer demo: a fully navigable, **native-only**, client-side walkthrough with +believable data and **no backend**. There is no demo account, no `/demo-login`, no real +reads or writes — each reviewer gets an isolated, deterministic session. + +## How it works + +1. **Entry.** The invite code `demo` (case-insensitive) flips demo mode on. See + [`src/utils/demo.ts`](../src/utils/demo.ts): `enableDemoMode()` sets an in-memory flag + plus a `localStorage` key. `isDemoMode()` returns `true` only when **both** + `isCapacitor()` is true **and** the flag is set — so it is always `false` on web. + +2. **Network interception.** Every API request funnels through `callApi` in + [`src/utils/api-fetch.ts`](../src/utils/api-fetch.ts) (both `apiFetch` and `serverFetch` + are aliases of it). The first line short-circuits in demo mode: + + ```ts + if (isDemoMode()) return demoRespond(path, options) + ``` + + so no request ever reaches the network and no `authorization` header is required. + +3. **The router.** [`src/utils/demo-api.ts`](../src/utils/demo-api.ts) — `demoRespond(path, options)` + strips the query string, matches `{method, path}` against an ordered table (literal paths + before `:param` paths), and returns `new Response(JSON.stringify(data), { status: 200 })`. + Unmatched routes hit a **shape-aware fallback** (`[]` for collection-ish paths, `{}` + otherwise) so a consumer never throws on `undefined.map`, and log + `[demo-api] unmocked ` in dev so QA can spot gaps. + +4. **Fixtures.** All demo data lives in + [`src/constants/demo-data.ts`](../src/constants/demo-data.ts): `DEMO_USER`, + `DEMO_CONTACTS`, `DEMO_HISTORY_ENTRIES`, `DEMO_LIMITS`, `DEMO_BALANCE_UNITS`, + `DEMO_ADDRESS`. `demo-api.ts` composes its responses from these plus inline canned objects. + +5. **WebSocket.** `getWebSocketInstance()` in + [`src/services/websocket.ts`](../src/services/websocket.ts) returns `null` in demo mode + (callers already handle `null`), avoiding the mixed-content `wss://` failure. + +6. **No real money.** UserOps / passkey signing are hard-stopped elsewhere + (`kernelClient.context`, `useZeroDev`, `useSpendBundle`), so even "enabled" rails and + completed flows are purely simulated. + +### Relationship to the per-hook demo branches + +A few hooks short-circuit demo mode *before* `callApi` and source their values from +`demo-data.ts`: `useUserQuery` (`/users/me` + a synchronous Redux seed that fixes the +no-bounce race), `useTransactionHistory` (history), `useWallet` (balance), `useCapabilities`. +These remain the primary path; the interceptor is the **backstop** that also covers +`/users/me`, `/users/history`, etc., so any code path that reaches the network still gets +synthetic data instead of a 401. + +> Exception: `sendLinksApi.create` posts multipart `FormData` via `fetchWithSentry` +> directly (bypassing `callApi`), so it calls `demoRespond` explicitly in demo mode. + +## Adding a mock + +1. Find the endpoint (path + method + expected response shape) — grep the `serverFetch(` / + `apiFetch(` call site, or check `src/types/api.openapi.json`. +2. Add a row to the `ROUTES` table in `demo-api.ts`. Use `:param` segments for path params; + keep literal paths above `:param` paths that could also match. +3. If the response is real *data* (not a one-off canned success), add a fixture to + `demo-data.ts` and reference it, so data has one home. +4. Match the consumer's expected envelope exactly (e.g. `{ items, nextCursor }` for + notifications, a bare `[]` array for `/users/:id/rewards`). When unsure, the consumer's + `.json()` usage is the source of truth. + +## QA screen-walk checklist + +Enter invite code `demo` on a native build, then visit each screen and confirm: **no auth +errors, populated-or-empty states only, headline flows reach a simulated success screen.** +Watch the console for `[demo-api] unmocked` and close any gap. + +- [ ] Home (balance, latest activity) +- [ ] Send → Contacts (alice/bob/carol/dave populated) and Send link +- [ ] Request payment +- [ ] Add money — US, BR, AR (quote → review → simulated success) +- [ ] Withdraw / cash out — US, BR, AR (incl. Bridge ToS step) +- [ ] QR pay (scan → complete) +- [ ] Activity / history (infinite scroll) + a receipt detail +- [ ] Profile + sub-pages, Settings +- [ ] Rewards / Points +- [ ] Card +- [ ] Notifications +- [ ] Support + +## Safety / tests + +Hard boundaries the demo session cannot cross: + +- **Web-inert.** `isDemoMode()` is `false` outside the Capacitor shell, no matter what + flags are set — the demo API layer is unreachable from the web app. +- **No real funds.** Sends are simulated; UserOps are hard-stopped before signing, so + nothing can reach a chain. +- **No real backend session.** Every API call short-circuits to synthetic responses; + no JWT exists and KYC is skipped by construction. +- **No push / tracking identity.** `useNotifications` skips OneSignal init entirely in + demo mode — no subscription is created and no `external_id` login is attempted. +- **No websocket.** Demo sessions never open a live connection; consumers must handle + the absent socket. + +`src/utils/__tests__/demo-api.test.ts` covers routing, param extraction, and the +shape-aware fallback; `src/utils/__tests__/demo.test.ts` locks the web-inert guarantee +(session flag, direct localStorage flag, and disable/cleanup paths). Run `pnpm test` +and `pnpm typecheck`. diff --git a/docs/NATIVE-RELEASE-IOS.md b/docs/NATIVE-RELEASE-IOS.md new file mode 100644 index 0000000000..c2b25fe8d4 --- /dev/null +++ b/docs/NATIVE-RELEASE-IOS.md @@ -0,0 +1,166 @@ +# Native (iOS) — CI Release to TestFlight + +How the iOS app is built, signed, and shipped to TestFlight from CI. This mirrors +the Android release pipeline (`docs/NATIVE-RELEASE.md`) but for iOS, and uses the +same **no-fastlane** style: signing material lives as CI secrets (the iOS analogue +of the Android keystore-as-secret), the build is reproducible, and the IPA lands on +TestFlight for manual App Store promotion. + +> **Architecture:** Capacitor 8 wrapping a static export of the Next.js app. The iOS +> project is standard Capacitor (scheme `App`, workspace `ios/App/App.xcworkspace`, +> bundle `me.peanut.wallet`, SPM, deploy target 15.0, entitlements +> `App/App.entitlements`). `Info.plist` reads `$(CURRENT_PROJECT_VERSION)` / +> `$(MARKETING_VERSION)`. + +--- + +## 1. The pipeline (`.github/workflows/ios-release.yml`) + +- **Trigger:** push tag `vX.Y.Z`, or manual dispatch (optional `versionName` input). +- **Runner:** `macos-15`, gated by the `production` GitHub Environment (required reviewers). +- **Flow:** checkout (submodules) → Xcode `latest-stable` + Node 22 + pnpm 10 → + `pnpm install` → write prod `NEXT_PUBLIC_*` (same block as `android-release.yml`) → + `node scripts/native-build.js && npx cap sync ios` (+ optional + `scripts/native-ios-postsync.js` if present) → **import cert** + (`apple-actions/import-codesign-certs`) → **install profile** (decode the base64 + secret, read its UUID/Name, drop into `~/Library/MobileDevice/Provisioning Profiles/`) + → **archive + export** (`xcodebuild archive` then `-exportArchive` with a generated + `ExportOptions.plist`) → **upload** (`apple-actions/upload-testflight-build`) → IPA + artifact (`build/ios/*.ipa`). +- **Signing:** the project default is **Automatic**; the archive step overrides it for + that build via command-line build settings (`CODE_SIGN_STYLE=Manual`, + `DEVELOPMENT_TEAM`, `CODE_SIGN_IDENTITY="Apple Distribution"`, + `PROVISIONING_PROFILE_SPECIFIER`). `CURRENT_PROJECT_VERSION` is always the CI run + number; `MARKETING_VERSION` is overridden only when `versionName` is supplied + (`Info.plist` reads both via `$(...)`). + +--- + +## 2. One-time setup (signing material) + +CI consumes pre-made signing material — create it once from a machine with Apple +Developer access and store it as repo secrets: + +1. **Distribution certificate** — in Xcode (or the Developer portal) create/obtain an + **Apple Distribution** certificate, then export it from Keychain Access as a `.p12` + (with a password). Base64 it: + ```bash + base64 -i AppleDistribution.p12 | pbcopy # → IOS_DIST_CERT_P12_BASE64 + ``` + Store the export password as `IOS_DIST_CERT_PASSWORD`. +2. **Provisioning profile** — in the Developer portal create an **App Store** profile + for `me.peanut.wallet` that includes the **Associated Domains** capability (it backs + `webcredentials:peanut.me` in `App/App.entitlements` — passkeys break without it). + Download the `.mobileprovision` and base64 it: + ```bash + base64 -i me_peanut_wallet_appstore.mobileprovision | pbcopy # → IOS_PROVISIONING_PROFILE_BASE64 + ``` + The workflow reads the profile's name from the file itself — no separate name secret. +3. **App Store Connect API key** — create an API key (Admin / App Manager) in App Store + Connect; store the issuer id, key id, and the **raw `.p8` contents** as the secrets + below. + +> **Rotation:** the distribution certificate expires ~yearly and the profile +> expires / needs re-issuing when the cert or capabilities change. When that happens, +> regenerate the cert/profile, re-export, re-base64, and update `IOS_DIST_CERT_*` / +> `IOS_PROVISIONING_PROFILE_BASE64`. (This manual step is the trade-off for not using +> fastlane `match`.) + +--- + +## 3. Manual App Store promotion + +The pipeline stops at **TestFlight** (upload is automatic). After the build finishes +processing, promote it to the App Store **manually** in App Store Connect (submit for +review) once TestFlight validation looks clean — the iOS analogue of the Play track +promotion on the Android side. + +--- + +## 4. Required repo secrets + +| Secret | What | +|--------|------| +| `ASC_KEY_ID` | App Store Connect API key id | +| `ASC_ISSUER_ID` | App Store Connect API issuer id | +| `ASC_KEY_CONTENT` | the **raw `.p8` contents** of the ASC API private key (paste the PEM, incl. `-----BEGIN PRIVATE KEY-----`) | +| `APPLE_TEAM_ID` | Apple Developer team id | +| `IOS_DIST_CERT_P12_BASE64` | Apple Distribution cert exported as `.p12`, base64-encoded | +| `IOS_DIST_CERT_PASSWORD` | password set when exporting the `.p12` | +| `IOS_PROVISIONING_PROFILE_BASE64` | App Store `.mobileprovision` for `me.peanut.wallet`, base64-encoded | +| `SUBMODULE_TOKEN` | read access to the `src/content` submodule (shared with Android) | + +Plus the production `NEXT_PUBLIC_*` Variables/Secrets the static export bakes in — the +`Production web env` step is identical to `android-release.yml`, so both platforms +produce the same `.env.production.local`. + +--- + +## 5. Prerequisite — the `ios/` platform must be committed + +The `ios/` Capacitor platform must exist on the branch CI builds. It currently lives +**only in the `peanut-ui-ios2` / `peanut-ui-ios` worktrees** — until `ios/` is committed +to the branch this workflow runs on, `npx cap sync ios` (and the whole job) will fail. + +--- + +## 6. Push notifications (OneSignal / APNs) + +Push is delivered through the same OneSignal app as web — the device links to the user +via `OneSignal.login(userId)`, so the existing `external_id`-targeted sequences reach +native with no backend or sequence changes. The web/native split lives behind +`src/services/onesignal/` (selected by `isCapacitor()`); the native side is +`@onesignal/capacitor-plugin`, wired into the app target's SPM by `npx cap sync ios`. + +### 6a. App target — already committed +- `App.entitlements`: `aps-environment` + App Group `group.me.peanut.wallet.onesignal`. +- `Info.plist`: `UIBackgroundModes` → `remote-notification`. +- `aps-environment` is committed as `development` (local on-device debug builds use the + APNs **sandbox**). **Release/TestFlight builds need `production`** — the App Store + provisioning profile carries production APNs. Either flip the value before an archive + or keep a build-config-specific entitlements file. Watch for a signing mismatch if the + profile and entitlement environments disagree. + +### 6b. Notification Service Extension (NSE) — needs a one-time Xcode step +Rich media, badge sync, and confirmed-delivery analytics require an NSE target. The +**source files are committed** under `ios/App/OneSignalNotificationServiceExtension/` +(`NotificationService.swift`, `Info.plist`, `*.entitlements`). The Xcode **target** +itself is not hand-forged into `project.pbxproj` (too error-prone to script blind). Add +it once in Xcode: + +1. **File → New → Target → Notification Service Extension.** Name it + `OneSignalNotificationServiceExtension`. Set its deployment target to **iOS 15** + (match the app). Do **not** activate the scheme when prompted. +2. Delete Xcode's generated `NotificationService.swift` / `Info.plist` for the target and + **add the committed files** in `ios/App/OneSignalNotificationServiceExtension/` to the + target instead (or point the target's `INFOPLIST_FILE` / sources at them). +3. **Signing & Capabilities** for the extension target: add the **App Groups** capability + and tick `group.me.peanut.wallet.onesignal` (same group as the app). Set + `CODE_SIGN_ENTITLEMENTS` to the committed `*.entitlements`. +4. **Add the OneSignal extension SPM product to the *extension* target:** File → Add + Package Dependencies → `https://github.com/OneSignal/OneSignal-iOS-SDK` → add the + **`OneSignalExtension`** product **to the extension target only** (the app target gets + OneSignal transitively via the Capacitor plugin — don't double-add). +5. The extension bundle id is `me.peanut.wallet.OneSignalNotificationServiceExtension`; + create a matching **App Store provisioning profile** for it (with the App Group) and + add it to CI signing alongside the app profile. + +> After adding the target, commit the resulting `project.pbxproj` (and `Package.resolved`) +> so CI builds it. `npx cap sync ios` regenerates `CapApp-SPM/Package.swift` for the app +> target only and will **not** touch the extension target — the NSE's SPM dependency is +> managed directly on the target in Xcode. + +### 6c. Provider setup (OneSignal dashboard — do once, no code) +- Create an **APNs `.p8` auth key** in the Apple Developer portal (Keys → enable Apple + Push Notifications service). Note the **Key ID** and your **Team ID**. +- In the OneSignal dashboard → the existing app → **Apple iOS (APNs)** platform → upload + the `.p8`, Key ID, Team ID, and bundle id `me.peanut.wallet`. +- Enable **Push Notifications** on the `me.peanut.wallet` App ID, and make sure the App + Store provisioning profile(s) include the push entitlement. + +### 6d. Verify (real device — APNs doesn't work on the simulator) +1. `node scripts/native-build.js && npx cap sync ios`, open `ios/App/App.xcworkspace`, + run on a physical device. +2. Accept the permission prompt (surfaced via the existing `SetupNotificationsModal`), + confirm a subscription appears under the user's `external_id` in OneSignal. +3. Send a test push with an image and confirm the NSE renders the rich notification. diff --git a/docs/NATIVE-RELEASE.md b/docs/NATIVE-RELEASE.md new file mode 100644 index 0000000000..da469da3d5 --- /dev/null +++ b/docs/NATIVE-RELEASE.md @@ -0,0 +1,307 @@ +# Native (Android) — Local Dev, Release & Play Review + +How to run the app locally, build/sign/ship it, and get it through Play review. + +> **Architecture:** Capacitor 8 wrapping a static export of the Next.js app — one +> codebase, web + Android (iOS in progress, see §11). Key files: +> `scripts/native-build.js`, `scripts/native-release.sh`, `capacitor.config.ts`, +> `next.config.native.js`. + +--- + +## 1. Toolchain (must match, or builds fail) + +| Tool | Version | Why | +|------|---------|-----| +| **Node** | **22.x** | `@sentry/profiling-node` ships no binary for Node 25; the API crashes on boot under 25. Use `node@22` (nvm `v22.x` or `brew install node@22`). | +| **JDK** | **17** | Capacitor-android compiles at 21; the app/AGP baseline is 17. `android/build.gradle` forces Java 17 across all modules. | +| **pnpm** | 10 | `corepack enable` | +| **PostgreSQL** | 16 (14 works locally) | backend | +| Xcode / CocoaPods | 26+ / latest | iOS only (§11) | + +Clone with submodules — the build needs `src/content`: +```bash +git clone --recurse-submodules https://github.com/peanutprotocol/peanut-ui +``` + +--- + +## 2. Branches & merge order + +The native work is split into focused branches. **Merge order matters** — the +build-reliability branch is a hard prerequisite: + +1. **`fix/native-build-reliability`** — Java 17 force + `card-comparison.ts` + `'use server'` removal (a Server Action breaks `output: 'export'`) + cleartext + network config. **Without this the static export and Gradle build fail**, so it + must land first. +2. **`fix/native-passkey-reliability`** — silent "Set it up" fix + multi-account + signing (PR #2189 re-applied). +3. **`feat/native-review-readiness`** — reviewer/demo mode, build guard, versionCode + wiring, plugins (haptics/keyboard), `native:release`, CI release, this doc. +4. **`peanut-api-ts` `feat/demo-reviewer-invite`** — the `demo` code + reviewer seed; + deploy alongside #3 so reviewer access works. +5. **`feat/native-ios`** — iOS platform (in progress). + +--- + +## 3. Run it all locally (sandbox / testnet) + +### Backend → `peanut-api-ts` +```bash +# Postgres role + db (one-time). On macOS Homebrew the superuser is your user, +# not `postgres`, so: createuser/createdb directly (DATABASE_URL → peanut_dev). +npx prisma generate --sql # needs the DB up (typed SQL client) +npx prisma migrate deploy +npx tsx scripts/seed-dev-system-users.ts +npx tsx scripts/seed-reviewer-user.ts # seeds the `demo` → `reviewer` inviter +npx tsx scripts/seed-rails.ts # rails — flows 400 without this +PORT=5001 pnpm dev # see port note below +curl localhost:5001/healthz # {"status":"healthy","dbConnected":true} +``` +**Local gotchas (this machine):** +- **Port 5000 is taken by macOS AirPlay Receiver** (`ControlCenter`). Either turn it + off (System Settings → General → AirDrop & Handoff → AirPlay Receiver) to use 5000, + or run on another port (`PORT=5001`) and point the app at it. +- **Run with Node 22** (`PATH="$(brew --prefix node@22)/bin:$PATH" pnpm dev`). +- **`engineering/qa/` dev-cheat imports**: `src/routes/dev/cheats.ts` dynamically + imports a QA harness that only exists in the full monorepo. In a standalone + checkout, drop stub `.mjs` files at `../engineering/qa/lib/{factories/*,zerodev}.mjs` + so esbuild resolves them (the `/dev/cheats` endpoints are unused by the demo flow). +- **`PERK_WALLET_PRIVATE_KEY`** must be set in `.env` (startup inits a perk-wallet + cache). A dummy `0x`+64-hex key is fine for local. + +### App → Android emulator against the local backend +The native shell talks to `NEXT_PUBLIC_BASE_URL` (defaults to prod `peanut.me`). To +hit the **local** backend from the emulator, build with the host alias `10.0.2.2`: +```bash +# peanut-ui/.env.production.local +NEXT_PUBLIC_BASE_URL=http://10.0.2.2:5001 +NEXT_PUBLIC_PEANUT_API_URL=http://10.0.2.2:5001 +NEXT_PUBLIC_NATIVE_RP_ID=peanut.me +NEXT_PUBLIC_CAPACITOR_BUILD=true +``` +Cleartext to `10.0.2.2`/`localhost` is already permitted (`network_security_config.xml` ++ manifest, on `fix/native-build-reliability`; scoped so production https is untouched). +```bash +node scripts/native-build.js +npx cap sync android +npx cap run android --target # or: cd android && ./gradlew assembleDebug && adb install -r +``` +> **Debug-build passkey caveat:** passkey registration verifies the app's signing +> cert against `peanut.me/.well-known/assetlinks.json`. A local **debug** keystore is +> usually not among the listed fingerprints, so passkey creation may fail on the +> emulator. The landing/ribbon and the `demo` invite validation work regardless; for +> full passkey flow use a build signed with a registered key. + +--- + +## 4. Reviewer access (the May-18 rejection fix) + +Invite-only + passkey-only is why the reviewer's access "didn't work". There's now a +**reviewer/demo mode** entered with a single code. + +- Reviewer enters invite code **`demo`** → backend maps it to the `reviewer` inviter + (`PROD/STAGING_SPECIAL_INVITE_CODES_MAP` in `peanut-api-ts/src/utils/invite.ts`). +- The native client recognizes `demo` (`src/utils/reviewer.ts`) and: overlays + pre-filled balance + history (no empty states), **skips KYC**, and **simulates** + send/pay/withdraw (no real funds / on-chain tx — safe on mainnet). +- The reviewer still creates a **real passkey** — the core mechanic review needs to see. + +**Seed the inviter** per environment (idempotent; localhost self-heals): +```bash +npx tsx scripts/seed-reviewer-user.ts # in peanut-api-ts, DATABASE_URL → target env +``` +**Play Console → App content → App access:** declare restricted, instructions: "Enter +invite code `demo`, tap Continue, create a passkey when prompted; you'll land on a +populated demo wallet. No username/password." (Passwordless — the code is the access.) + +--- + +## 5. Passkey reliability fixes (`fix/native-passkey-reliability`) + +- **Silent "Set it up":** `passkeyPreflight.ts` now queries the native plugin's + `isSupported()`; `SetupPasskey.tsx` re-checks on tap and always surfaces an + actionable message — the button can never silently no-op. +- **Multi-account signing:** native signing is pinned to the kernel's own credential + (`native-webauthn.ts` + `kernelClient.context.tsx`). **Smoke-test on a 2-account + device** before relying on it. + +--- + +## 6. Build the release + +```bash +pnpm native:release # derive version → native-build → cap sync → bundleRelease +# → android/app/build/outputs/bundle/release/app-release.aab +``` +- **Anti-rot guard:** `native-build.js` fails loudly if a new server-only route + (route handler / `force-dynamic`) isn't in `ITEMS_TO_DISABLE`. Fix = add it there + (web-only) or give the page `generateStaticParams`. +- **Versioning** (`android/app/build.gradle`, zero manual edits): + - `versionName` ← `ANDROID_VERSION_NAME` env, else `package.json` `version`. + - `versionCode` ← `ANDROID_VERSION_CODE` env, else git commit count; floored at 2 + (rejected first upload was code 1). CI passes `10000 + github.run_number`. + - **CI is the only authoritative versionCode source.** Local builds derive the code + from git commit count, which can collide with or fall behind codes already on Play + (Play rejects duplicates and non-increasing codes). Never upload a locally built + AAB; use the workflow. +- Overrides: `ANDROID_VERSION_NAME=1.0.0 ANDROID_VERSION_CODE=9000 pnpm native:release`. +- **Headers note:** `vercel.json` headers (CSP, HSTS, …) apply to the Vercel web + deployment only — the native static export is served from the app bundle and ships + no HTTP headers, so nothing there affects (or protects) the WebView. + +--- + +## 7. Signing, keystore & secret management + +**Play App Signing is enabled** → Google holds the real signing key; the local +keystore is only the **upload** key. A loss is recoverable (upload-key reset, ~days); +a leak is bounded by Play review + account 2FA. Still treat it as a secret. + +- **Store it in a team secret manager** (1Password/Vault/cloud Secret Manager): + the keystore **base64-encoded** + `storePassword` / `keyPassword` / `keyAlias`. + Never in git, Slack, or a single laptop. +- **Recovery:** Play Console → App integrity → request upload-key reset, then upload a + new upload certificate. Document who can do this. +- **Passkey coupling:** users get the binary re-signed with the **Play App Signing** + cert, so that SHA-256 must be in `public/.well-known/assetlinks.json` (3 fingerprints + listed — confirm the Play App Signing one is present). **Rotating keys requires + updating `assetlinks.json` or passkey sign-in breaks.** +- **rpId sync set:** the passkey rpId (`peanut.me`) is hardcoded in several places that + must change together — a miss breaks passkey creation silently: + 1. `capacitor.config.ts` (`CapacitorPasskey.origin` / `domains`) + 2. `android/app/src/main/res/values/capacitor-passkey.xml` (asset-statements URL) + 3. `.github/workflows/android-release.yml` (`NEXT_PUBLIC_NATIVE_RP_ID`) + 4. `public/.well-known/assetlinks.json` (served from the rpId domain) +- Local signing reads `android/keystore.properties` (gitignored): + ```properties + storeFile=../peanut-release.keystore + storePassword=… + keyAlias=peanut + keyPassword=… + ``` + +--- + +## 8. CI release pipeline (`.github/workflows/android-release.yml`) + +Removes the "release only builds on one laptop" gap — keystore lives as CI secrets, +the build is reproducible, the AAB lands on a Play track. + +- **Trigger:** push tag `vX.Y.Z`, or manual dispatch (pick a track). +- **Flow:** checkout (submodules) → JDK 17 + Node 22 → install → decode keystore + + write `keystore.properties` → write prod `NEXT_PUBLIC_*` → `pnpm native:release` + (`versionCode = github.run_number`) → upload AAB to Play (`internal` by default). +- **Gate** with a `production` GitHub Environment + required reviewers. +- **Track promotion:** internal → closed/beta → production with **staged rollout** + (`status: inProgress` + `userFraction: 0.1`, promote after metrics look clean). + +**Required repo secrets:** + +| Secret | What | +|--------|------| +| `ANDROID_KEYSTORE_BASE64` | `base64 -w0 peanut-release.keystore` | +| `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | signing creds | +| `PLAY_SERVICE_ACCOUNT_JSON` | Google Play Developer API service account (least-priv "Release manager") | +| `SUBMODULE_TOKEN` | read access to the `src/content` submodule | +| `CAPGO_API_KEY` | OTA (already used by `capgo-deploy.yml`) | +| prod `NEXT_PUBLIC_*` | the values the static export bakes in (OneSignal, Sentry, chain, …) | + +> Housekeeping: the secret is named `NEXT_PUBLIC_SENTRY_DSN` but a Sentry DSN is public +> by design (it ships in every web bundle) — the `secrets.*` storage is convention, not +> confidentiality. If renaming to `SENTRY_DSN` for clarity, update the reference in +> `android-release.yml` in the same change or builds bake an empty DSN. + +> Prereq: `fix/native-build-reliability` must be merged or `native:release` won't build. + +--- + +## 9. OTA updates (Capgo) + +`capgo-deploy.yml` builds the static export and uploads on push: `main` → `production`, +`dev` → `staging`; manual dispatch picks the channel. + +- **Configure the `production` channel** in the Capgo dashboard and bind it to the prod + app (the workflow pushes to it; the channel must exist). +- **Native-version gating:** `--auto-min-update-version` (already set) keeps a JS bundle + built against new plugins off older native shells. **Bump the native version whenever + you change plugins/native code**, then ship that via Play — OTA can't. +- **Staged rollout:** roll production OTA to ~10% → watch Sentry/crash + error rates → + 100%. Don't 100% every merge. +- **Rollback** is configured in `capacitor.config.ts` (`appReadyTimeout: 15000` + + `autoDeleteFailed` + `autoDeletePrevious`): a bundle that never calls + `notifyAppReady()` auto-reverts. **Verify once** with a deliberately-broken bundle. +- **Boundary:** OTA ships web assets only. New plugins, Gradle, permissions, versionCode + → Play release. + +--- + +## 10. Pre-submission verification + +1. Local stack up (backend + seeds), `demo` validates. +2. `pnpm native:release` produces a signed AAB with `versionCode ≥ 2`. +3. Reviewer-mode E2E on a device: invite `demo` → passkey → Home/History show demo data + → KYC skipped → a send reaches a simulated success (no on-chain tx). +4. Passkey silent-failure: no Google account / outdated Play Services → actionable error, + never a no-op. +5. Multi-account smoke test: two accounts on one device → both sign valid signatures. +6. `pnpm test:unit:ci` green; `pnpm typecheck` clean. + +--- + +## 11. Push notifications (OneSignal / FCM) + +Push is delivered through the same OneSignal app as web — the device links to the user +via `OneSignal.login(userId)`, so existing `external_id`-targeted sequences reach native +with **no backend or sequence changes**. The web/native split lives behind +`src/services/onesignal/` (selected by `isCapacitor()`); native uses +`@onesignal/capacitor-plugin`, autolinked into Gradle by `npx cap sync android`. + +**Already wired (committed):** +- `@onesignal/capacitor-plugin` in `package.json`; the plugin's FCM/OneSignal Gradle + deps autolink on `cap sync`. +- `AndroidManifest.xml`: `POST_NOTIFICATIONS` (the Android 13+ runtime prompt, driven by + the plugin's `requestPermission()`). +- `android/app/build.gradle` already conditionally applies the `google-services` plugin + **only when `google-services.json` is present** — its absence disables push but never + fails the build. +- `scripts/native-build.js` warns when `NEXT_PUBLIC_ONESIGNAL_APP_ID` is unset (the app id + is inlined into the static bundle; without it the native SDK can't initialize). + +**Provider setup (do once, no code):** +1. **Firebase:** create/locate the Firebase project for `me.peanut.wallet`, download + `google-services.json`, place it at `android/app/google-services.json` (gitignored). +2. **OneSignal dashboard** → the existing app → **Google Android (FCM)** platform → + upload the **FCM v1 service account JSON** (Firebase → Project settings → Service + accounts → Generate private key). +3. **CI:** set the `ANDROID_GOOGLE_SERVICES_JSON` secret to `base64 -w0 google-services.json`. + The `Decode google-services.json` step in `android-release.yml` writes it before the + build (and skips gracefully when unset). + +**Verify (real device/emulator with Play Services):** +`node scripts/native-build.js && npx cap sync android && ./gradlew assembleDebug`, install, +accept the prompt (surfaced via the existing `SetupNotificationsModal`), confirm a +subscription appears under the user's `external_id` in OneSignal, then send a test push. + +--- + +## 12. iOS release + +The iOS release pipeline (`.github/workflows/ios-release.yml`) is maintained on its own +branch (`feat/ci-ios`), separate from this Android runbook. It mirrors the Android lane's +no-fastlane style: an Apple Distribution cert + App Store provisioning profile stored as +CI secrets, `xcodebuild` archive/export, and upload to TestFlight via +`apple-actions/upload-testflight-build`. + +See **`docs/NATIVE-RELEASE-IOS.md`** (on `feat/ci-ios`) for the full iOS pipeline, +one-time signing-material setup, secrets table, and manual App Store promotion. + +--- + +## 13. Play submission + +- Upload to a **closed/internal** track first; dogfood the reviewer flow end-to-end. +- Re-check Data safety, permissions (camera for QR/KYC), content rating, and the App + access instructions in §4. +- Promote to **production** review only after the internal track passes. diff --git a/instrumentation-client.ts b/instrumentation-client.ts index b43d638bed..53dec5d251 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -1,19 +1,61 @@ import posthog from 'posthog-js' +import * as Sentry from '@sentry/nextjs' +import { beforeSendHandler } from './sentry.utils' +import { inferSentryEnvironment } from '@/utils/sentry-env' +import { getIOSMajorVersion } from '@/utils/webkit.utils' + +// rrweb session replay serializes the DOM on every mutation — too heavy for low-end +// WebViews. iOS WebKit has no deviceMemory, so gate on OS version there: iOS 17 +// requires A12+, which handles rrweb fine; anything stuck below is A11 or older. +// On Android, deviceMemory (GB) is Chromium-only and often absent on cheap devices; +// treat "unknown" as low-end and skip, to protect the weakest ones. +function nativeReplayEnabled(): boolean { + if (typeof navigator === 'undefined') return false + const iosVersion = getIOSMajorVersion() + if (iosVersion !== null) return iosVersion >= 17 + const nav = navigator as Navigator & { deviceMemory?: number } + return (nav.deviceMemory ?? 0) >= 4 && (nav.hardwareConcurrency ?? 0) >= 6 +} if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') { + const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com' + const isNativeBuild = process.env.NEXT_PUBLIC_CAPACITOR_BUILD === 'true' + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { - // Proxied through Next.js rewrites (see next.config.js). Path is - // intentionally innocuous — `/ingest/` was on uBlock Origin's default - // blocklist as a known PostHog signature, blocked-by-client retries - // were flooding the console. - api_host: '/relay', - ui_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + // Web posts through the `/relay` Next.js rewrite — the path is intentionally + // innocuous (`/ingest/` was on uBlock Origin's blocklist as a known PostHog + // signature, and blocked-client retries flooded the console). The Capacitor + // static export has no rewrite layer, so it posts to the absolute host. + api_host: isNativeBuild ? posthogHost : '/relay', + ui_host: posthogHost, person_profiles: 'identified_only', capture_pageview: true, capture_pageleave: true, autocapture: true, + // Leave replay sampling to the PostHog project settings (unchanged). Only extra + // gate: on native, skip low-end WebViews where rrweb serialization causes jank. + disable_session_recording: isNativeBuild && !nativeReplayEnabled(), }) + // The web build inits Sentry via sentry.client.config.ts (injected by + // withSentryConfig) with tunnelRoute '/monitoring'. The Capacitor static + // export runs neither withSentryConfig nor a server for that tunnel, so + // without this it reports nothing — init here and post straight to the DSN. + if (isNativeBuild && process.env.NEXT_PUBLIC_SENTRY_DSN) { + Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + environment: inferSentryEnvironment(), + release: process.env.NEXT_PUBLIC_GIT_COMMIT_HASH, + // Errors captured at 100% (sampleRate stated explicitly for intent); + // traces sampled at 10% to match the web and keep transaction volume + // down. Mirror web by also capturing console.warn. + sampleRate: 1.0, + tracesSampleRate: 0.1, + beforeSend: beforeSendHandler, + integrations: [Sentry.captureConsoleIntegration({ levels: ['error', 'warn'] })], + }) + } + // Brave identifies as Chrome in User-Agent — detect it and set a person property // so we can accurately measure our crypto-native Brave audience in PostHog if (navigator.brave) { diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000000..f47029973b --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..b1b57f23eb --- /dev/null +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,381 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = me.peanut.wallet; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Distribution"; + DEVELOPMENT_TEAM = PW388G893L; + PROVISIONING_PROFILE_SPECIFIER = "Peanut Wallet App Store"; + CURRENT_PROJECT_VERSION = 1; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.peanut.wallet; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..feb3b6f1dc --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,60 @@ +{ + "originHash" : "0d32099c6de980d81aa148fe0b949860aac22ff9aea4b310ebfffe762e5d802e", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" + } + }, + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt.git", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "0e862e6ff13852a710c8a484180ca4d6a2cc9761", + "version" : "8.2.0" + } + }, + { + "identity" : "crisp-sdk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/crisp-im/crisp-sdk-ios.git", + "state" : { + "revision" : "61e11393f22812e3b3fc6ab98a2d22a4eaef0102", + "version" : "2.13.0" + } + }, + { + "identity" : "version", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mrackwitz/Version.git", + "state" : { + "revision" : "fd4b0eb5756aa7f1c33977fb626cf37d2140a3a0", + "version" : "0.8.0" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 3 +} diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements new file mode 100644 index 0000000000..fd2bcc8741 --- /dev/null +++ b/ios/App/App/App.entitlements @@ -0,0 +1,17 @@ + + + + + com.apple.developer.associated-domains + + applinks:peanut.me + webcredentials:peanut.me + + aps-environment + development + com.apple.security.application-groups + + group.me.peanut.wallet.onesignal + + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000000..c3cd83b5c0 --- /dev/null +++ b/ios/App/App/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@1x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@1x.png new file mode 100644 index 0000000000..6ed622b7f5 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@1x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png new file mode 100644 index 0000000000..5084688690 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png new file mode 100644 index 0000000000..634cf61bbc Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@1x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@1x.png new file mode 100644 index 0000000000..448fbffa7f Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@1x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png new file mode 100644 index 0000000000..7196a8e9ea Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png new file mode 100644 index 0000000000..96bed22ef9 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@1x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@1x.png new file mode 100644 index 0000000000..5084688690 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@1x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png new file mode 100644 index 0000000000..d0b49bf5bf Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png new file mode 100644 index 0000000000..508554e30d Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000000..12ac01d0a5 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png new file mode 100644 index 0000000000..508554e30d Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png new file mode 100644 index 0000000000..d6c0fda18e Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76@1x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76@1x.png new file mode 100644 index 0000000000..2b7caa5d36 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76@1x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png new file mode 100644 index 0000000000..5adbd57483 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png new file mode 100644 index 0000000000..e301bed6bd Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..485ee682d9 --- /dev/null +++ b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images": [ + { + "filename": "AppIcon-20@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "AppIcon-20@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "filename": "AppIcon-29@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-29@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "AppIcon-40@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "AppIcon-40@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "filename": "AppIcon-60@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "AppIcon-60@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "AppIcon-20@1x.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" + }, + { + "filename": "AppIcon-20@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "AppIcon-29@1x.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "AppIcon-29@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-40@1x.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" + }, + { + "filename": "AppIcon-40@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "AppIcon-76@1x.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" + }, + { + "filename": "AppIcon-76@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "AppIcon-83.5@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" + }, + { + "filename": "AppIcon-512@2x.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/App/App/Assets.xcassets/Contents.json b/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..9e0da7c5a4 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 0000000000..84c681318e --- /dev/null +++ b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "splash-2732x2732-2.png", + "scale": "1x" + }, + { + "idiom": "universal", + "filename": "splash-2732x2732-1.png", + "scale": "2x" + }, + { + "idiom": "universal", + "filename": "splash-2732x2732.png", + "scale": "3x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000000..1d104bf48d Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000000..1d104bf48d Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000000..1d104bf48d Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/ios/App/App/Base.lproj/LaunchScreen.storyboard b/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..e7ae5d7802 --- /dev/null +++ b/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..b44df7be8f --- /dev/null +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist new file mode 100644 index 0000000000..43a3e781d2 --- /dev/null +++ b/ios/App/App/Info.plist @@ -0,0 +1,75 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Peanut + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + NSCameraUsageDescription + Peanut uses the camera to scan QR codes and verify your identity. + NSFaceIDUsageDescription + Peanut uses Face ID to securely sign in with your passkey. + NSPhotoLibraryUsageDescription + Peanut needs photo access to upload identity documents during verification. + NSLocationWhenInUseUsageDescription + Peanut may use your location to help verify your identity and prevent fraud during account setup and support. + ITSAppUsesNonExemptEncryption + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UIBackgroundModes + + remote-notification + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/App/CapApp-SPM/.gitignore b/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 0000000000..3b29812086 --- /dev/null +++ b/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 0000000000..24539268d7 --- /dev/null +++ b/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"), + .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.2.0/node_modules/@capacitor/app"), + .package(name: "CapacitorBrowser", path: "../../../node_modules/.pnpm/@capacitor+browser@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/browser"), + .package(name: "CapacitorHaptics", path: "../../../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/haptics"), + .package(name: "CapacitorKeyboard", path: "../../../node_modules/.pnpm/@capacitor+keyboard@8.0.3_@capacitor+core@8.2.0/node_modules/@capacitor/keyboard"), + .package(name: "CapacitorSplashScreen", path: "../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/.pnpm/@capacitor+status-bar@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/status-bar"), + .package(name: "CapgoCapacitorCrisp", path: "../../../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp"), + .package(name: "CapgoCapacitorPasskey", path: "../../../node_modules/@capgo/capacitor-passkey"), + .package(name: "CapgoCapacitorUpdater", path: "../../../node_modules/.pnpm/@capgo+capacitor-updater@8.45.9_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-updater"), + .package(name: "OnesignalCapacitorPlugin", path: "../../../node_modules/@onesignal/capacitor-plugin"), + .package(name: "SumsubCordovaIdensicMobileSdkPlugin", path: "../../capacitor-cordova-ios-plugins/sources/SumsubCordovaIdensicMobileSdkPlugin") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), + .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), + .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), + .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"), + .product(name: "CapgoCapacitorCrisp", package: "CapgoCapacitorCrisp"), + .product(name: "CapgoCapacitorPasskey", package: "CapgoCapacitorPasskey"), + .product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater"), + .product(name: "OnesignalCapacitorPlugin", package: "OnesignalCapacitorPlugin"), + .product(name: "SumsubCordovaIdensicMobileSdkPlugin", package: "SumsubCordovaIdensicMobileSdkPlugin") + ] + ) + ] +) diff --git a/ios/App/CapApp-SPM/README.md b/ios/App/CapApp-SPM/README.md new file mode 100644 index 0000000000..03964db900 --- /dev/null +++ b/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 0000000000..945afec8c7 --- /dev/null +++ b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/ios/App/OneSignalNotificationServiceExtension/Info.plist b/ios/App/OneSignalNotificationServiceExtension/Info.plist new file mode 100644 index 0000000000..0f63cbb0c5 --- /dev/null +++ b/ios/App/OneSignalNotificationServiceExtension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OneSignalNotificationServiceExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/ios/App/OneSignalNotificationServiceExtension/NotificationService.swift b/ios/App/OneSignalNotificationServiceExtension/NotificationService.swift new file mode 100644 index 0000000000..bf339a7d60 --- /dev/null +++ b/ios/App/OneSignalNotificationServiceExtension/NotificationService.swift @@ -0,0 +1,35 @@ +import UserNotifications +import OneSignalExtension + +final class NotificationService: UNNotificationServiceExtension { + var contentHandler: ((UNNotificationContent) -> Void)? + var receivedRequest: UNNotificationRequest! + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.receivedRequest = request + self.contentHandler = contentHandler + bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + + if let bestAttemptContent = bestAttemptContent { + OneSignalExtension.didReceiveNotificationExtensionRequest( + self.receivedRequest, + with: bestAttemptContent, + withContentHandler: self.contentHandler + ) + } + } + + override func serviceExtensionTimeWillExpire() { + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + OneSignalExtension.serviceExtensionTimeWillExpireRequest( + self.receivedRequest, + with: bestAttemptContent + ) + contentHandler(bestAttemptContent) + } + } +} diff --git a/ios/App/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements b/ios/App/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements new file mode 100644 index 0000000000..ace96d65d4 --- /dev/null +++ b/ios/App/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.me.peanut.wallet.onesignal + + + diff --git a/ios/debug.xcconfig b/ios/debug.xcconfig new file mode 100644 index 0000000000..53ce18dead --- /dev/null +++ b/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/jest.setup.ts b/jest.setup.ts index b29bf06365..a7452a20ed 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -15,6 +15,13 @@ process.env.VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || 'test-vapid-pri process.env.VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:test@example.com' process.env.PEANUT_API_KEY = process.env.PEANUT_API_KEY || 'test-peanut-api-key' +// jsdom has no ResizeObserver; components using it (e.g. react-fast-marquee) need a stub. +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} + // Add any global test setup here global.console = { ...console, diff --git a/package.json b/package.json index 95c5027293..7c3a95aa28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "peanut-ui", - "version": "0.2.0", + "version": "1.0.8", "private": true, + "packageManager": "pnpm@10.30.1", "engines": { "node": ">=21.1.0 <22.0.0 || ^20.9.0" }, @@ -37,17 +38,23 @@ "validate-links": "tsx scripts/verify-content.ts", "verify-content": "tsx scripts/verify-content.ts", "native:build": "node scripts/native-build.js", - "native:sync": "npx cap sync android", - "capgo:upload:dev": "node scripts/native-build.js && npx @capgo/cli@latest bundle upload --channel development --path ./out", - "capgo:upload:staging": "node scripts/native-build.js && npx @capgo/cli@latest bundle upload --channel staging --path ./out", + "native:sync": "pnpm exec cap sync android", + "native:release": "bash scripts/native-release.sh", + "capgo:upload:dev": "node scripts/native-build.js && pnpm dlx @capgo/cli@latest bundle upload --channel development --path ./out", + "capgo:upload:staging": "node scripts/native-build.js && pnpm dlx @capgo/cli@latest bundle upload --channel staging --path ./out", "test:unit:ci": "jest --coverage --reporters=default --reporters=jest-junit" }, "dependencies": { "@capacitor/android": "8.2.0", "@capacitor/app": "^8.1.0", "@capacitor/browser": "^8.0.3", + "@capacitor/camera": "^8.2.0", "@capacitor/cli": "8.2.0", + "@capacitor/clipboard": "^8.0.1", "@capacitor/core": "8.2.0", + "@capacitor/haptics": "^8.0.2", + "@capacitor/ios": "8.2.0", + "@capacitor/keyboard": "^8.0.3", "@capacitor/splash-screen": "^8.0.1", "@capacitor/status-bar": "^8.0.2", "@capgo/capacitor-crisp": "^8.0.27", @@ -58,6 +65,7 @@ "@justaname.id/react": "0.3.180", "@justaname.id/sdk": "0.2.177", "@noble/curves": "1.9.7", + "@onesignal/capacitor-plugin": "^1.0.6", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.5", @@ -219,6 +227,9 @@ "yaml": "^2.8.3", "serialize-javascript": "^7.0.5", "protobufjs": "^7.5.5" + }, + "patchedDependencies": { + "@zerodev/webauthn-key": "patches/@zerodev__webauthn-key.patch" } }, "size-limit": [ diff --git a/patches/@zerodev__webauthn-key.patch b/patches/@zerodev__webauthn-key.patch new file mode 100644 index 0000000000..e32073c9fb --- /dev/null +++ b/patches/@zerodev__webauthn-key.patch @@ -0,0 +1,42 @@ +diff --git a/_cjs/toWebAuthnKey.js b/_cjs/toWebAuthnKey.js +index 69e1ad488631b4b23d260f4d4beaa0a41200aeb1..c21decd7c2338f04401ec38025988f7e36da4c12 100644 +--- a/_cjs/toWebAuthnKey.js ++++ b/_cjs/toWebAuthnKey.js +@@ -83,7 +83,8 @@ const toWebAuthnKey = async ({ webAuthnKey, rpID, passkeyName, passkeyServerUrl, + if (!registerVerifyResult.verified) { + throw new Error("Registration not verified"); + } +- pubKey = registerCred.response.publicKey; ++ // iOS native (ASAuthorization via Capacitor shim) cannot expose the credential public key; fall back to the server-provided one. ++ pubKey = registerCred.response.publicKey ?? registerVerifyResult.pubkey; + } + if (!pubKey) { + throw new Error("No public key returned from registration credential"); +diff --git a/_esm/toWebAuthnKey.js b/_esm/toWebAuthnKey.js +index 98cf2cde07636c040865d7c351b34340e14d8310..8b49b9046aced1959489d766b6e0f6574fe4a27d 100644 +--- a/_esm/toWebAuthnKey.js ++++ b/_esm/toWebAuthnKey.js +@@ -87,7 +87,8 @@ export const toWebAuthnKey = async ({ webAuthnKey, rpID, passkeyName, passkeySer + throw new Error("Registration not verified"); + } + // Import the key +- pubKey = registerCred.response.publicKey; ++ // iOS native (ASAuthorization via Capacitor shim) cannot expose the credential public key; fall back to the server-provided one. ++ pubKey = registerCred.response.publicKey ?? registerVerifyResult.pubkey; + } + if (!pubKey) { + throw new Error("No public key returned from registration credential"); +diff --git a/toWebAuthnKey.ts b/toWebAuthnKey.ts +index b6d3cb24e74b913e5c19da817255d789219a8c00..1d1a3346fd47d8036b13a9d2a4c632a2c769e891 100644 +--- a/toWebAuthnKey.ts ++++ b/toWebAuthnKey.ts +@@ -163,7 +163,8 @@ export const toWebAuthnKey = async ({ + } + + // Import the key +- pubKey = registerCred.response.publicKey ++ // iOS native (ASAuthorization via Capacitor shim) cannot expose the credential public key; fall back to the server-provided one. ++ pubKey = registerCred.response.publicKey ?? registerVerifyResult.pubkey + } + + if (!pubKey) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9d2ab7d87..790756190f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,11 @@ overrides: serialize-javascript: ^7.0.5 protobufjs: ^7.5.5 +patchedDependencies: + '@zerodev/webauthn-key': + hash: dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28 + path: patches/@zerodev__webauthn-key.patch + importers: .: @@ -31,12 +36,27 @@ importers: '@capacitor/browser': specifier: ^8.0.3 version: 8.0.3(@capacitor/core@8.2.0) + '@capacitor/camera': + specifier: ^8.2.0 + version: 8.2.0(@capacitor/core@8.2.0) '@capacitor/cli': specifier: 8.2.0 version: 8.2.0 + '@capacitor/clipboard': + specifier: ^8.0.1 + version: 8.0.1(@capacitor/core@8.2.0) '@capacitor/core': specifier: 8.2.0 version: 8.2.0 + '@capacitor/haptics': + specifier: ^8.0.2 + version: 8.0.2(@capacitor/core@8.2.0) + '@capacitor/ios': + specifier: 8.2.0 + version: 8.2.0(@capacitor/core@8.2.0) + '@capacitor/keyboard': + specifier: ^8.0.3 + version: 8.0.3(@capacitor/core@8.2.0) '@capacitor/splash-screen': specifier: ^8.0.1 version: 8.0.1(@capacitor/core@8.2.0) @@ -67,6 +87,9 @@ importers: '@noble/curves': specifier: 1.9.7 version: 1.9.7 + '@onesignal/capacitor-plugin': + specifier: ^1.0.6 + version: 1.0.6(@capacitor/core@8.2.0) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -84,7 +107,7 @@ importers: version: 2.11.2(react-redux@9.2.0(@types/react@18.3.27)(react@19.2.4)(redux@5.0.1))(react@19.2.4) '@sentry/nextjs': specifier: ^8.39.0 - version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1) + version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1) '@serwist/next': specifier: ^9.0.10 version: 9.5.0(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1) @@ -105,16 +128,16 @@ importers: version: 5.4.9(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) '@zerodev/passkey-validator': specifier: ^5.6.0 - version: 5.6.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + version: 5.6.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(patch_hash=dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) '@zerodev/permissions': specifier: 5.5.0 - version: 5.5.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + version: 5.5.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(patch_hash=dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) '@zerodev/sdk': specifier: 5.5.7 version: 5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) '@zerodev/webauthn-key': specifier: ^5.5.0 - version: 5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + version: 5.5.0(patch_hash=dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) autoprefixer: specifier: ^10.4.20 version: 10.4.23(postcss@8.5.6) @@ -568,14 +591,39 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' + '@capacitor/camera@8.2.0': + resolution: {integrity: sha512-hYfrT6xpL936qoEkIpJzSnb0fQCaTkOux1cXzGBfH8QLOGqr6gSLiWZlZz/fqMPmMKJMNRBqlTQkj5fuMhVZog==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/cli@8.2.0': resolution: {integrity: sha512-1cMEk0d/I6tl1U+v/lnJR5Oylpx8ZBIHrvQxD5zK0MkjYOUyQAAGJgh97rkhGJqjAUvrGpa8H4BmyhNQN9a17A==} engines: {node: '>=22.0.0'} hasBin: true + '@capacitor/clipboard@8.0.1': + resolution: {integrity: sha512-iOlbTi8MojKyLnYE+M27priXid7vHd0PlDwyHohPzkuQ8Rkp6q7ykwZmPEUD+OnU/Ink7Qw/pUOfKgraKmA6Eg==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/core@8.2.0': resolution: {integrity: sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==} + '@capacitor/haptics@8.0.2': + resolution: {integrity: sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + + '@capacitor/ios@8.2.0': + resolution: {integrity: sha512-X2/VtM4qP/R1SM0VQ5W/VotEc6PS/KTooD33EijsfAHWBdee+xmBapW8SeNLnu16wJ+tsfWlvtipaJEyfKbRKQ==} + peerDependencies: + '@capacitor/core': ^8.2.0 + + '@capacitor/keyboard@8.0.3': + resolution: {integrity: sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/splash-screen@8.0.1': resolution: {integrity: sha512-c/ew/Z3eA7z8l06WoRAtzVF16VwYYrExmHmfGq1Cg675pVzaC/yuucB8/1xG1vhEfnW4fZ1KhSf/kzR1RiVYgg==} peerDependencies: @@ -1044,89 +1092,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1436,24 +1500,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.3': resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.3': resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.3': resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.3': resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} @@ -1522,6 +1590,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@onesignal/capacitor-plugin@1.0.6': + resolution: {integrity: sha512-06gUX322p74rFSvPzGoCrUgKl1++Twm4sVK4Y5htfGTKKvBYx5AKWkqWngamyzcT/pjq6ZHcnA/RnqJPYnNZrA==} + peerDependencies: + '@capacitor/core': '>=7.0.0' + '@opentelemetry/api-logs@0.208.0': resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} engines: {node: '>=8.0.0'} @@ -1849,41 +1922,49 @@ packages: resolution: {integrity: sha512-qNQk0H6q1CnwS9cnvyjk9a+JN8BTbxK7K15Bb5hYfJcKTG1hfloQf6egndKauYOO0wu9ldCMPBrEP1FNIQEhaA==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.16.4': resolution: {integrity: sha512-wEXSaEaYxGGoVSbw0i2etjDDWcqErKr8xSkTdwATP798efsZmodUAcLYJhN0Nd4W35Oq6qAvFGHpKwFrrhpTrA==} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.16.4': resolution: {integrity: sha512-CUFOlpb07DVOFLoYiaTfbSBRPIhNgwc/MtlYeg3p6GJJw+kEm/vzc9lohPSjzF2MLPB5hzsJdk+L/GjrTT3UPw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.16.4': resolution: {integrity: sha512-d8It4AH8cN9ReK1hW6ZO4x3rMT0hB2LYH0RNidGogV9xtnjLRU+Y3MrCeClLyOSGCibmweJJAjnwB7AQ31GEhg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.16.4': resolution: {integrity: sha512-d09dOww9iKyEHSxuOQ/Iu2aYswl0j7ExBcyy14D6lJ5ijQSP9FXcJYJsJ3yvzboO/PDEFjvRuF41f8O1skiPVg==} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.16.4': resolution: {integrity: sha512-lhjyGmUzTWHduZF3MkdUSEPMRIdExnhsqv8u1upX3A15epVn6YVwv4msFQPJl1x1wszkACPeDHGOtzHsITXGdw==} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.16.4': resolution: {integrity: sha512-ZtqqiI5rzlrYBm/IMMDIg3zvvVj4WO/90Dg/zX+iA8lWaLN7K5nroXb17MQ4WhI5RqlEAgrnYDXW+hok1D9Kaw==} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.16.4': resolution: {integrity: sha512-LM424h7aaKcMlqHnQWgTzO+GRNLyjcNnMpqm8SygEtFRVW693XS+XGXYvjORlmJtsyjo84ej1FMb3U2HE5eyjg==} cpu: [x64] os: [linux] + libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.16.4': resolution: {integrity: sha512-8w8U6A5DDWTBv3OUxSD9fNk37liZuEC5jnAc9wQRv9DeYKAXvuUtBfT09aIZ58swaci0q1WS48/CoMVEO6jdCA==} @@ -3046,41 +3127,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -8254,6 +8343,10 @@ snapshots: dependencies: '@capacitor/core': 8.2.0 + '@capacitor/camera@8.2.0(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@capacitor/cli@8.2.0': dependencies: '@ionic/cli-framework-output': 2.2.8 @@ -8276,10 +8369,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@capacitor/clipboard@8.0.1(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@capacitor/core@8.2.0': dependencies: tslib: 2.8.1 + '@capacitor/haptics@8.0.2(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + + '@capacitor/ios@8.2.0(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + + '@capacitor/keyboard@8.0.3(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@capacitor/splash-screen@8.0.1(@capacitor/core@8.2.0)': dependencies: '@capacitor/core': 8.2.0 @@ -9606,6 +9715,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@onesignal/capacitor-plugin@1.0.6(@capacitor/core@8.2.0)': + dependencies: + '@capacitor/core': 8.2.0 + '@opentelemetry/api-logs@0.208.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -10935,7 +11048,7 @@ snapshots: '@sentry/core@8.55.0': {} - '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)': + '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 @@ -10943,7 +11056,7 @@ snapshots: '@sentry-internal/browser-utils': 8.55.0 '@sentry/core': 8.55.0 '@sentry/node': 8.55.0 - '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) + '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) '@sentry/react': 8.55.0(react@19.2.4) '@sentry/vercel-edge': 8.55.0 '@sentry/webpack-plugin': 2.22.7(webpack@5.104.1) @@ -11012,6 +11125,16 @@ snapshots: '@opentelemetry/semantic-conventions': 1.39.0 '@sentry/core': 8.55.0 + '@sentry/opentelemetry@8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@sentry/core': 8.55.0 + '@sentry/react@8.55.0(react@19.2.4)': dependencies: '@sentry/browser': 8.55.0 @@ -12280,19 +12403,19 @@ snapshots: '@zerodev/sdk': 5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@zerodev/passkey-validator@5.6.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + '@zerodev/passkey-validator@5.6.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(patch_hash=dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: '@noble/curves': 1.9.7 '@simplewebauthn/browser': 8.3.7 '@zerodev/sdk': 5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) - '@zerodev/webauthn-key': 5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@zerodev/webauthn-key': 5.5.0(patch_hash=dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@zerodev/permissions@5.5.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + '@zerodev/permissions@5.5.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(patch_hash=dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: '@simplewebauthn/browser': 9.0.1 '@zerodev/sdk': 5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) - '@zerodev/webauthn-key': 5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@zerodev/webauthn-key': 5.5.0(patch_hash=dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) merkletreejs: 0.3.11 viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) @@ -12301,7 +12424,7 @@ snapshots: semver: 7.7.3 viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + '@zerodev/webauthn-key@5.5.0(patch_hash=dbbe28d978dbe8ccab7b5354cfadfacb8a4dbe83087016de6cacc111a9cfbb28)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: '@noble/curves': 1.9.7 '@simplewebauthn/browser': 8.3.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000000..ed9e646b5f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,22 @@ +# pnpm 10 reads its settings from here instead of .npmrc, so npm no longer +# warns about these (pnpm-only) keys. + +# Turbopack OTel instrumentation hoisting workaround. +# https://github.com/vercel/next.js/issues/68805 +publicHoistPattern: + - '*import-in-the-middle*' + - '*require-in-the-middle*' + +# Supply-chain freshness floor in minutes (14 days): block deps published more +# recently to dodge freshly-compromised releases. Emergency override: +# PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 pnpm install +minimumReleaseAge: 20160 + +# Exempt from the floor: protobufjs (critical RCE fix, GHSA-xx7c-cv9c-4p4r) and +# the @capgo/* native plugins (rolling Capacitor 8.x releases the build needs, +# which the floor would otherwise block). Revisit and prune periodically. +minimumReleaseAgeExclude: + - protobufjs + - '@capgo/capacitor-passkey' + - '@capgo/capacitor-crisp' + - '@capgo/capacitor-updater' diff --git a/scripts/native-build.js b/scripts/native-build.js index 33214c6e4f..09934ec733 100644 --- a/scripts/native-build.js +++ b/scripts/native-build.js @@ -22,6 +22,7 @@ const ITEMS_TO_DISABLE = [ { path: 'robots.ts', type: 'file' }, { path: 'manifest.ts', type: 'file' }, { path: 'jobs/route.ts', type: 'file' }, + { path: 'm/[slug]', type: 'dir' }, // web-only routes that conflict with static export { path: '[locale]', type: 'dir' }, // marketing/blog/seo pages { path: 'quests/[questId]', type: 'dir' }, // quest detail page (dynamicParams issues) @@ -66,12 +67,16 @@ const P0_TRANSFORMS = [ import { useEffect } from 'react' import { useRouter } from 'next/navigation' import { getAuthToken } from '@/utils/auth-token' +import { isDemoMode } from '@/utils/demo' export default function RootRedirect() { const router = useRouter() useEffect(() => { const token = getAuthToken() - router.replace(token ? '/home' : '/setup') + // Demo has no JWT — without the isDemoMode() check a demo user who hits + // the root (e.g. bounced from a web-only route) lands on /setup, whose + // landing screen disables demo and dumps them at Log In. + router.replace(token || isDemoMode() ? '/home' : '/setup') }, [router]) return null } @@ -225,6 +230,66 @@ function copyComponentsBeforeDisable() { } } +// Anti-rot guard. The static export (output: 'export') cannot build server-only +// routes — route handlers and `force-dynamic` pages. Those are renamed out of the +// way via ITEMS_TO_DISABLE, but that list is hand-maintained: when web work adds a +// NEW server route not in the list, `next build` used to fail deep in the build +// with a cryptic error (the "build rot" symptom). This scans the app tree up front +// and fails LOUDLY with the exact offending paths so the fix is obvious: add them +// to ITEMS_TO_DISABLE (or give the page a generateStaticParams). +function isCoveredByDisableList(relPath) { + return ITEMS_TO_DISABLE.some((item) => { + if (item.type === 'dir') { + return ( + relPath === item.path || relPath.startsWith(item.path + path.sep) || relPath.startsWith(item.path + '/') + ) + } + return relPath === item.path + }) +} + +function detectUncoveredServerRoutes(dir = APP_DIR, found = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.includes('.disabled') || entry.name.startsWith('_')) continue + const full = path.join(dir, entry.name) + const rel = path.relative(APP_DIR, full) + if (entry.isDirectory()) { + if (isCoveredByDisableList(rel)) continue + detectUncoveredServerRoutes(full, found) + continue + } + if (isCoveredByDisableList(rel)) continue + if (entry.name === 'route.ts' || entry.name === 'route.js') { + found.push({ rel, reason: 'route handler (cannot be statically exported)' }) + continue + } + if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) { + const content = fs.readFileSync(full, 'utf-8') + if (/export\s+const\s+dynamic\s*=\s*['"]force-dynamic['"]/.test(content)) { + found.push({ rel, reason: "export const dynamic = 'force-dynamic'" }) + } + } + } + return found +} + +function assertNoUncoveredServerRoutes() { + console.log('🔎 Scanning for server-only routes not covered by the disable list...') + const offenders = detectUncoveredServerRoutes() + if (offenders.length === 0) { + console.log(' ✓ none — every server-only route is handled') + return + } + const lines = offenders.map((o) => ` • src/app/${o.rel} (${o.reason})`).join('\n') + throw new Error( + `Native build would break: ${offenders.length} server-only route(s) are not handled for static export:\n` + + `${lines}\n\n` + + `Fix: add each path to ITEMS_TO_DISABLE in scripts/native-build.js (web-only routes),\n` + + `or give dynamic pages a generateStaticParams. This guard prevents the silent "build rot"\n` + + `where an unrelated web change breaks the native build.` + ) +} + function getDisabledPath(itemPath) { const dir = path.dirname(itemPath) const base = path.basename(itemPath) @@ -384,6 +449,9 @@ async function main() { let buildSucceeded = false try { + // fail fast & loud if a new server-only route slipped in (anti-rot guard) + assertNoUncoveredServerRoutes() + // clean cache FIRST to prevent stale route trees console.log('🧹 Cleaning build cache...') if (fs.existsSync(path.join(__dirname, '..', '.next'))) { @@ -399,6 +467,15 @@ async function main() { throw new Error('NEXT_PUBLIC_NATIVE_RP_ID is not set in .env.production.local — passkeys will fail') } console.log(`✅ NEXT_PUBLIC_NATIVE_RP_ID=${rpIdMatch[1].trim()}`) + + // app id is inlined into the bundle at build time; without it the native + // OneSignal SDK can't initialize and push notifications silently no-op. + const appIdMatch = envContent.match(/NEXT_PUBLIC_ONESIGNAL_APP_ID=(.+)/) + if (!appIdMatch || !appIdMatch[1].trim()) { + console.warn('⚠️ NEXT_PUBLIC_ONESIGNAL_APP_ID is not set — native push notifications will be disabled') + } else { + console.log('✅ NEXT_PUBLIC_ONESIGNAL_APP_ID is set') + } } else { console.warn('⚠️ .env.production.local not found — using default rpId (peanut.me)') } @@ -444,7 +521,7 @@ async function main() { console.log('\n🏗️ Building static export...\n') try { - execSync('NATIVE_BUILD=true npx next build --webpack', { + execSync('NATIVE_BUILD=true pnpm exec next build --webpack', { stdio: 'inherit', cwd: configDir, env: { ...process.env, NATIVE_BUILD: 'true' }, diff --git a/scripts/native-ios-postsync.js b/scripts/native-ios-postsync.js new file mode 100755 index 0000000000..a9432580aa --- /dev/null +++ b/scripts/native-ios-postsync.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +/** + * Post-`cap sync ios` fixups that Capacitor's SPM generator can't do itself. + * + * SumSub: @sumsub/cordova-idensic-mobile-sdk-plugin declares its native + * dependency `IdensicMobileSDK` only via a CocoaPods . Capacitor's SPM + * generator ignores that, so the generated Package.swift has no way to resolve + * `#import ` and the archive fails with + * "'IdensicMobileSDK/IdensicMobileSDK.h' file not found". + * + * `npx cap sync ios` regenerates the plugin's Package.swift and wipes its + * Frameworks dir on every run, so this must run *after* each sync. The iOS + * release workflow invokes it right after `cap sync`. + * + * Fix: vendor SumSub's xcframework as an SPM binaryTarget. + */ + +const fs = require('fs') +const path = require('path') +const { execSync } = require('child_process') + +// Must match the pin in the plugin's plugin.xml (). +const SUMSUB_VERSION = '1.42.0' + +const repoRoot = path.join(__dirname, '..') +const pluginDir = path.join(repoRoot, 'ios/capacitor-cordova-ios-plugins/sources/SumsubCordovaIdensicMobileSdkPlugin') +const frameworksDir = path.join(pluginDir, 'Frameworks') +const xcframework = path.join(frameworksDir, 'IdensicMobileSDK.xcframework') +const pkgSwiftPath = path.join(pluginDir, 'Package.swift') + +if (!fs.existsSync(pluginDir)) { + console.log('[postsync] SumSub plugin dir not present — skipping (plugin removed?)') + process.exit(0) +} + +// 1. Vendor the xcframework (download once; it survives within a single CI run). +if (!fs.existsSync(xcframework)) { + fs.mkdirSync(frameworksDir, { recursive: true }) + const zipUrl = `https://raw.githubusercontent.com/SumSubstance/IdensicMobileSDK-iOS-Release/master/${SUMSUB_VERSION}/IdensicMobileSDK-${SUMSUB_VERSION}.zip` + const zipPath = path.join(frameworksDir, 'IdensicMobileSDK.zip') + console.log(`[postsync] downloading IdensicMobileSDK ${SUMSUB_VERSION}…`) + execSync(`curl -fsSL -o "${zipPath}" "${zipUrl}"`, { stdio: 'inherit' }) + // Core subspec only needs IdensicMobileSDK.xcframework (top-level in the zip). + execSync(`unzip -oq "${zipPath}" "IdensicMobileSDK.xcframework/*" -d "${frameworksDir}"`, { + stdio: 'inherit', + }) + fs.rmSync(zipPath, { force: true }) + if (!fs.existsSync(xcframework)) { + console.error('[postsync] ERROR: IdensicMobileSDK.xcframework not found after extraction') + process.exit(1) + } + console.log('[postsync] vendored IdensicMobileSDK.xcframework') +} + +// 2. Patch the generated Package.swift to declare + depend on the binary target. +let pkg = fs.readFileSync(pkgSwiftPath, 'utf8') +if (pkg.includes('IdensicMobileSDK')) { + console.log('[postsync] Package.swift already patched') +} else { + const before = pkg + + // (a) declare the binary target at the top of the targets array + pkg = pkg.replace( + 'targets: [\n', + 'targets: [\n' + + ' .binaryTarget(\n' + + ' name: "IdensicMobileSDK",\n' + + ' path: "Frameworks/IdensicMobileSDK.xcframework"\n' + + ' ),\n' + ) + + // (b) add it to the plugin target's dependencies + pkg = pkg.replace( + '.product(name: "Cordova", package: "capacitor-swift-pm")\n', + '.product(name: "Cordova", package: "capacitor-swift-pm"),\n' + ' "IdensicMobileSDK"\n' + ) + + // (c) keep the framework dir out of the source-file glob. + // SwiftPM enforces argument order: `exclude:` must precede `publicHeadersPath:`. + pkg = pkg.replace( + 'path: ".",\n publicHeadersPath: "."', + 'path: ".",\n exclude: ["Frameworks"],\n publicHeadersPath: "."' + ) + + if (pkg === before) { + console.error('[postsync] ERROR: Package.swift did not match expected layout — patch anchors stale') + process.exit(1) + } + fs.writeFileSync(pkgSwiftPath, pkg) + console.log('[postsync] patched Package.swift with IdensicMobileSDK binary target') +} diff --git a/scripts/native-release.sh b/scripts/native-release.sh new file mode 100755 index 0000000000..ea8e860c01 --- /dev/null +++ b/scripts/native-release.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Build a signed Android release AAB with an auto-derived, monotonic versionCode. +# +# Version resolution (both consumed by android/app/build.gradle): +# versionName ← ANDROID_VERSION_NAME env, else package.json "version" +# versionCode ← ANDROID_VERSION_CODE env, else git commit count (monotonic, +# no manual bookkeeping; floored at 2 since the rejected first +# upload was code 1). +# +# Usage: +# pnpm native:release # auto version from git + package.json +# ANDROID_VERSION_NAME=1.0.0 pnpm native:release # override the user-facing name +# ANDROID_VERSION_CODE=9000 pnpm native:release # force a code (e.g. to leapfrog a prior upload) +# +# Requires android/keystore.properties (gitignored) for signing — see docs/NATIVE-RELEASE.md. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +VERSION_NAME="${ANDROID_VERSION_NAME:-$(node -p "require('./package.json').version")}" + +if [ -n "${ANDROID_VERSION_CODE:-}" ]; then + VERSION_CODE="$ANDROID_VERSION_CODE" +else + COUNT="$(git rev-list --count HEAD 2>/dev/null || echo 0)" + if [ "$COUNT" -lt 2 ]; then VERSION_CODE=2; else VERSION_CODE="$COUNT"; fi +fi + +export ANDROID_VERSION_NAME="$VERSION_NAME" +export ANDROID_VERSION_CODE="$VERSION_CODE" + +echo "▶ Android release — versionName=$VERSION_NAME versionCode=$VERSION_CODE" + +if [ ! -f android/keystore.properties ]; then + echo "⚠️ android/keystore.properties not found — the release will be unsigned and rejected by Play." + echo " See docs/NATIVE-RELEASE.md §4 (Signing & keystore)." +fi + +# 1. static export → 2. copy web assets + plugins into android/ → 3. signed bundle +node scripts/native-build.js +pnpm exec cap sync android +( cd android && ./gradlew :app:bundleRelease ) + +AAB="android/app/build/outputs/bundle/release/app-release.aab" +echo "" +if [ -f "$AAB" ]; then + echo "✅ AAB ready: $AAB (versionCode $VERSION_CODE, versionName $VERSION_NAME)" + echo " Upload to Play (internal track first). See docs/NATIVE-RELEASE.md §7." +else + echo "❌ Expected AAB not found at $AAB — check the Gradle output above." + exit 1 +fi diff --git a/sentry.client.config.ts b/sentry.client.config.ts index d82c2235c9..bc61150b09 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -19,7 +19,7 @@ if (process.env.NODE_ENV !== 'development') { dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: inferSentryEnvironment(), enabled: true, - tracesSampleRate: 1, + tracesSampleRate: 0.1, debug: false, beforeSend: beforeSendHandler, diff --git a/sentry.utils.ts b/sentry.utils.ts index 8daf910522..2a72d7990c 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -42,6 +42,10 @@ const IGNORED_ERRORS = { thirdPartySdkErrors: [ 'IndexedDB:Set:InternalError', // Vercel Analytics storage - fails in private browsing, not actionable 'Analytics SDK:', // Vercel Analytics errors + // qr-scanner console.warns this whenever location.protocol !== 'https:', + // which is always true on capacitor://localhost — it then proceeds and the + // camera works. Pure noise on native (PEANUT-UI-R1M). + 'The camera stream is only accessible if the page is transferred via https', ], } @@ -249,7 +253,7 @@ function isSensitiveKey(key: string): boolean { } function scrubObject(value: unknown, depth = 0): unknown { - if (depth > 10) return '[REDACTED: max depth]' + if (depth > 15) return '[REDACTED: max depth]' if (value === null || value === undefined) return value if (typeof value !== 'object') return value if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 9c99f92cb9..33c56d53c7 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -515,7 +515,7 @@ export default function OnrampBankPage() { message={pendingModal.message} /> - + ({ jest.mock('@/constants/payment.consts', () => ({ MIN_MANTECA_DEPOSIT_AMOUNT: 1, BRIDGE_DEFAULT_ACCOUNT_HOLDER_NAME: 'Bridge Financial', + resolveBridgeAccountHolderName: (name?: string | null) => name || 'Bridge Financial', })) jest.mock('@/constants/manteca.consts', () => ({ diff --git a/src/app/(mobile-ui)/dev/full-graph/page.tsx b/src/app/(mobile-ui)/dev/full-graph/page.tsx index ba4d5ff8ff..1b18b3cce3 100644 --- a/src/app/(mobile-ui)/dev/full-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/full-graph/page.tsx @@ -141,7 +141,7 @@ export default function FullGraphPage() { }) => ( <> {/* Controls Panel - Top Right */} -
+
{/* FORCES + VISIBILITY merged */}

Display & Forces

diff --git a/src/app/(mobile-ui)/dev/payment-graph/page.tsx b/src/app/(mobile-ui)/dev/payment-graph/page.tsx index 84c1ad616e..b2513726ea 100644 --- a/src/app/(mobile-ui)/dev/payment-graph/page.tsx +++ b/src/app/(mobile-ui)/dev/payment-graph/page.tsx @@ -107,7 +107,7 @@ export default function PaymentGraphPage() { }) => ( <> {/* Controls Panel - Top Right */} -
+

Display & Forces

diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index d610841292..1ddebfa490 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -31,6 +31,7 @@ import { useNativePlugins } from '@/hooks/useNativePlugins' // guarantees the patch is installed before any child page's mount-time router.push. import '@/hooks/useSafeBack' import { isCapacitor } from '@/utils/capacitor' +import { isDemoMode, enableDemoMode } from '@/utils/demo' const Layout = ({ children }: { children: React.ReactNode }) => { useNativePlugins() @@ -97,12 +98,16 @@ const Layout = ({ children }: { children: React.ReactNode }) => { const url = new URL(window.location.href) if (url.searchParams.get('__reproduce')) return } - if (!isPublicPath && isReady && !isFetchingUser && !user && !isRedirecting.current) { + // Demo mode: never bounce to /setup. isDemoMode() reads the #demo hash + // (reliable on the first render after the hard-nav); persist it so later + // navigations that drop the hash stay in demo mode. + if (isDemoMode()) enableDemoMode() + if (!isPublicPath && isReady && !isFetchingUser && !user && !isRedirecting.current && !isDemoMode()) { isRedirecting.current = true router.replace('/setup') - // hard navigation fallback in case soft navigation silently fails + // Hard-nav fallback if the soft nav silently fails; re-check at fire time. const fallback = setTimeout(() => { - window.location.replace('/setup') + if (!isDemoMode()) window.location.replace('/setup') }, 3000) return () => clearTimeout(fallback) } @@ -189,10 +194,12 @@ const Layout = ({ children }: { children: React.ReactNode }) => { id="scrollable-content" className={classNames( twMerge( - 'relative flex-1 overflow-y-auto bg-background p-6 pb-24 md:pb-6', - !!isSupport && 'p-0 pb-20 md:p-6', + 'relative flex-1 overflow-y-auto bg-background p-6 pb-[calc(6rem_+_env(safe-area-inset-bottom))] md:pb-6', + !!isSupport && 'p-0 pb-[calc(5rem_+_env(safe-area-inset-bottom))] md:p-6', !!isHome && 'p-0 md:p-6 md:pr-0', - isUserLoggedIn ? 'pb-24' : 'pb-4', + isUserLoggedIn + ? 'pb-[calc(6rem_+_env(safe-area-inset-bottom))]' + : 'pb-[calc(1rem_+_env(safe-area-inset-bottom))]', isDev && 'p-0 pb-0', isHome && isCapacitor() && 'px-0 pt-0' ) @@ -200,10 +207,12 @@ const Layout = ({ children }: { children: React.ReactNode }) => { >
@@ -213,7 +222,10 @@ const Layout = ({ children }: { children: React.ReactNode }) => { {/* Mobile navigation */} {!isDev && ( -
+
)} diff --git a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx index 3449a3620c..d5dafe327d 100644 --- a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx +++ b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx @@ -6,18 +6,31 @@ import NavHeader from '@/components/Global/NavHeader' import { useWallet } from '@/hooks/wallet/useWallet' import { printableUsdc } from '@/utils/balance.utils' import { getExchangeRateWidgetRedirectRoute } from '@/utils/exchangeRateWidget.utils' +import { useCapabilities } from '@/hooks/useCapabilities' +import { deriveRegionAccess } from '@/utils/regions.utils' import { useRouter } from 'next/navigation' import { useSafeBack } from '@/hooks/useSafeBack' +import { useMemo } from 'react' export default function ExchangeRatePage() { const router = useRouter() const onBack = useSafeBack('/profile', { replace: true }) const { balance } = useWallet() + const { rails } = useCapabilities() + const unlockedRegionPaths = useMemo( + () => deriveRegionAccess(rails).unlockedRegions.map((region) => region.path), + [rails] + ) const handleCtaAction = (sourceCurrency: string, destinationCurrency: string) => { const formattedBalance = parseFloat(printableUsdc(balance ?? 0n)) - const redirectRoute = getExchangeRateWidgetRedirectRoute(sourceCurrency, destinationCurrency, formattedBalance) + const redirectRoute = getExchangeRateWidgetRedirectRoute( + sourceCurrency, + destinationCurrency, + formattedBalance, + unlockedRegionPaths + ) router.push(redirectRoute) } diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index f305d66aa0..d2c45a152f 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -20,6 +20,7 @@ import type { RailCapability, CapabilityRestriction } from '@/types/capabilities // reason message even when the page never touches them. type TestRail = Pick & { reason?: { userMessage: string | null } + resolved?: RailCapability['resolved'] } type TestRestriction = { code: string; affectedRailIds: string[]; userMessage?: string | null } @@ -496,6 +497,7 @@ function capabilitiesForGate(state: GateState, opts: { userMessage?: string | nu canDo: (_op: string, o?: { provider?: string }) => payEnabled && (o?.provider === undefined || o.provider === 'manteca'), railsForProvider: (provider: string) => rails.filter((r) => r.provider === provider), + nextActions: [], restrictionForRail: (railId: string) => restrictions.find((r) => r.affectedRailIds.includes(railId)), } } @@ -699,6 +701,36 @@ describe('GROUP 1: Loading & KYC Gate', () => { expect(screen.getByText('Contact support to continue.')).toBeInTheDocument() }) + test('provide-email verdict maps to the unavailable modal, never the document-upload flow', () => { + // a fixable verdict whose only fix is adding an email must not open + // the Sumsub upload flow (this surface has no email form) — same + // mapping rule as deriveProviderRejection + mockUseCapabilities.mockReturnValue({ + ...capabilitiesForGate('provider_rejection_fixable', { userMessage: 'Add your email to continue.' }), + railsForProvider: () => [ + { + id: MANTECA_RAIL_ID, + provider: 'manteca', + status: 'blocked', + resolved: { + status: 'fixable', + blocking: { + code: 'email_required', + userMessage: 'Add your email to continue.', + selfHealable: true, + selfHealKind: 'provide-email', + }, + }, + } as TestRail, + ], + }) + + renderQrPay({ qrCode: 'mercadopago://pay?id=123', type: 'MERCADO_PAGO', t: '1' }) + + expect(screen.getByText('QR payments are not available')).toBeInTheDocument() + expect(screen.queryByText('Upload document')).not.toBeInTheDocument() + }) + test('Manteca fixable rejection shows updated-document modal', () => { setCapabilitiesGate('provider_rejection_fixable', { userMessage: 'Upload a clearer ID.' }) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index a5e11a4b33..738f30c8fb 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -1,5 +1,6 @@ 'use client' +import { railUserMessage, railVerdict } from '@/utils/capability-gate' import { useSearchParams, useRouter } from 'next/navigation' import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react' import { useSafeBack } from '@/hooks/useSafeBack' @@ -165,7 +166,7 @@ export default function QRPayPage() { // manteca 'pending' → IDENTITY_VERIFICATION_IN_PROGRESS. // otherwise → REQUIRES_IDENTITY_VERIFICATION. While loading → LOADING. // userMessage ← the rejecting rail's reason.userMessage (was useProviderRejectionStatus). - const { canDo, railsForProvider, isKycApproved, isLoading: isLoadingCapabilities } = useCapabilities() + const { canDo, railsForProvider, nextActions, isKycApproved, isLoading: isLoadingCapabilities } = useCapabilities() const { user, fetchUser } = useAuth() // On public routes (qr-pay) auth still auto-fetches via React Query, but trigger a one-shot @@ -192,42 +193,59 @@ export default function QRPayPage() { if (canDo('pay', { provider: 'manteca' })) { return { kycGateState: QrKycState.PROCEED_TO_PAY, qrKycUserMessage: null as string | null } } - const mantecaRails = railsForProvider('manteca') - const blockedRail = mantecaRails.find((rail) => rail.status === 'blocked') - if (blockedRail) { - // Blocked === blocked. The US-nationality refinement is now applied in - // the resolver itself (Sumsub-approved + US-restricted → status:enabled - // + operations.pay:enabled, caught by canDo above), so a `blocked` - // status here is a genuine block. - // + // Verdict-first via the shared railVerdict collapse (rail.resolved, + // BE-derived; legacy fallback for older/cached responses). The + // US-nationality refinement is applied in the resolver itself + // (Sumsub-approved + US-restricted → operations.pay enabled, caught by + // canDo above), so a blocked verdict is genuine. + const actionByKey = new Map(nextActions.map((action) => [action.key, action])) + const candidates = railsForProvider('manteca').map((rail) => ({ + rail, + verdict: railVerdict(rail, actionByKey), + })) + // provide-email is NOT a document fix: routing it into the Sumsub + // upload flow dead-ends the user, and this surface has no email form — + // map it to the blocked modal (same rule as deriveProviderRejection). + const isProvideEmail = ({ verdict }: (typeof candidates)[number]) => + verdict.blocking?.selfHealKind === 'provide-email' + const blocked = candidates.find( + (candidate) => candidate.verdict.status === 'blocked' || isProvideEmail(candidate) + ) + if (blocked) { // Country-not-supported is self-fixable: user uploaded a non-AR/BR doc // and can verify again with a different one. Split out for the right CTA. - if (blockedRail.reason?.code === 'country_not_supported') { + // (selfHealKind is the verdict home; the reason-code check covers legacy + // responses — the code rides on blocking.code verbatim.) + if ( + !isProvideEmail(blocked) && + (blocked.verdict.blocking?.selfHealKind === 'restart-identity' || + blocked.verdict.blocking?.code === 'country_not_supported') + ) { return { kycGateState: QrKycState.PROVIDER_RESTART_IDENTITY, - qrKycUserMessage: blockedRail.reason.userMessage ?? null, + qrKycUserMessage: railUserMessage(blocked.rail), } } return { kycGateState: QrKycState.PROVIDER_REJECTION_BLOCKED, - qrKycUserMessage: blockedRail.reason?.userMessage ?? null, + qrKycUserMessage: railUserMessage(blocked.rail), } } - const fixableRail = mantecaRails.find((rail) => rail.status === 'requires-info') - if (fixableRail) { + const fixable = candidates.find((candidate) => candidate.verdict.status === 'fixable') + if (fixable) { return { kycGateState: QrKycState.PROVIDER_REJECTION_FIXABLE, - qrKycUserMessage: fixableRail.reason?.userMessage ?? null, + qrKycUserMessage: railUserMessage(fixable.rail), } } - if (mantecaRails.some((rail) => rail.status === 'pending')) { + if (candidates.some(({ verdict }) => verdict.status === 'pending')) { return { kycGateState: QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS, qrKycUserMessage: null as string | null, } } return { kycGateState: QrKycState.REQUIRES_IDENTITY_VERIFICATION, qrKycUserMessage: null as string | null } - }, [isLoadingCapabilities, canDo, railsForProvider, user, userFetchSettled]) + }, [isLoadingCapabilities, canDo, railsForProvider, nextActions, user, userFetchSettled]) const shouldBlockPay = kycGateState !== QrKycState.PROCEED_TO_PAY @@ -1471,7 +1489,6 @@ export default function QRPayPage() { onClose={qrLimitIncreaseFlow.handleClose} onComplete={qrLimitIncreaseFlow.handleSdkComplete} onRefreshToken={qrLimitIncreaseFlow.refreshToken} - autoStart isMultiLevel />
diff --git a/src/app/(mobile-ui)/rewards/page.tsx b/src/app/(mobile-ui)/rewards/page.tsx index 2abe149b60..c9e95f4ff9 100644 --- a/src/app/(mobile-ui)/rewards/page.tsx +++ b/src/app/(mobile-ui)/rewards/page.tsx @@ -224,15 +224,19 @@ const PointsPage = () => { {/* invite graph with consolidated explanation */} {myGraphResult?.data && ( <> - - - + {/* only render the graph when there are people to show — an + empty graph renders as a blank box (demo / no invites yet) */} + {myGraphResult.data.nodes?.length > 0 && ( + + + + )}

{user?.invitedBy && ( <> diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 8da2a90967..685153e04e 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -35,6 +35,7 @@ import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts' import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer' import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder' import { isTxReverted } from '@/utils/general.utils' +import { appBaseUrl } from '@/utils/url.utils' import { ErrorHandler } from '@/utils/friendly-error.utils' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' @@ -209,7 +210,7 @@ export default function WithdrawCryptoPage() { const chargePayload: CreateChargeRequest = { pricing_type: 'fixed_price', local_price: { amount: usdValue.toString(), currency: 'USD' }, - baseUrl: window.location.origin, + baseUrl: appBaseUrl(), requestId: newRequest.uuid, requestProps: { chainId: completeWithdrawData.chain.chainId.toString(), @@ -425,14 +426,18 @@ export default function WithdrawCryptoPage() { setChargeDetails(null) }, [setCurrentView, clearErrors, setChargeDetails]) - // reset withdraw flow when this component unmounts + // reset withdraw flow when this component unmounts. Resetting on unmount (rather + // than in the success view's onComplete) avoids a race: a synchronous reset clears + // amountToWithdraw and flips currentView off STATUS, which re-triggers the guard + // below and pushes '/withdraw' over the '/home' navigation from "Back to home". useEffect(() => { return () => { resetRouteCalculation() resetPaymentRecorder() resetTokenContextProvider() // reset token selector context to make sure previously selected token is not cached + resetWithdrawFlow() } - }, [resetRouteCalculation, resetPaymentRecorder, resetTokenContextProvider]) + }, [resetRouteCalculation, resetPaymentRecorder, resetTokenContextProvider, resetWithdrawFlow]) // Display payment errors first (user actions), then route errors (system limitations) const displayError = paymentError @@ -536,9 +541,6 @@ export default function WithdrawCryptoPage() { address={withdrawData.address} /> } - onComplete={() => { - resetWithdrawFlow() - }} /> )} diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 88da6282ee..c5d99913fe 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -664,7 +664,6 @@ function MantecaBankWithdrawFlow() { onClose={limitIncreaseFlow.handleClose} onComplete={limitIncreaseFlow.handleSdkComplete} onRefreshToken={limitIncreaseFlow.refreshToken} - autoStart isMultiLevel />

- You're sending + You're withdrawing

{currencyCode} {formatNumberForDisplay(currencyAmount, { maxDecimals: 2 })} @@ -880,7 +879,7 @@ function MantecaBankWithdrawFlow() {

- You're sending + You're withdrawing

{currencyCode}{' '} diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index de9bc0a211..1daaceaa67 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -3,28 +3,47 @@ import { usePWAStatus } from '@/hooks/usePWAStatus' import { useAppDispatch } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' -import { useEffect, Suspense } from 'react' +import { useEffect, useState, Suspense } from 'react' import { setupSteps } from '../../components/Setup/Setup.consts' import '../../styles/globals.css' import PeanutLoading from '@/components/Global/PeanutLoading' import { Banner } from '@/components/Global/Banner' +import SupportDrawer from '@/components/Global/SupportDrawer' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { usePullToRefresh } from '@/hooks/usePullToRefresh' -import { isCapacitor } from '@/utils/capacitor' +import { isCapacitor, isIOSNative } from '@/utils/capacitor' function SetupLayoutContent({ children }: { children?: React.ReactNode }) { const dispatch = useAppDispatch() const isPWA = usePWAStatus() const { deviceType } = useDeviceType() - // configure status bar for native — matches mobile-ui layout behavior + /* + * Bottom-inset fill color. Periwinkle is for Android 15 edge-to-edge (matches + * the status-bar strip). On iOS the content directly above the home-indicator + * inset is the white panel, so periwinkle reads as a stray bar on Face ID + * devices — fill with white there instead. State + effect (not a render-time + * platform check) so the static export's prerendered HTML hydrates cleanly. + */ + const [bottomInsetFill, setBottomInsetFill] = useState('bg-secondary-3') + useEffect(() => { + if (isIOSNative()) setBottomInsetFill('bg-white') + }, []) + + // configure status bar for native. the setup/onboarding flow has a periwinkle + // top (illustration + feedback ribbon), so tint the status bar to match — on + // pre-edge-to-edge Android the OS paints this color; on Android 15+ it's a + // no-op (edge-to-edge forced) and the CSS safe zone below handles it. useEffect(() => { if (!isCapacitor()) return import('@capacitor/status-bar') - .then(({ StatusBar, Style }) => { - StatusBar.setOverlaysWebView({ overlay: false }) - StatusBar.setStyle({ style: Style.Light }) - StatusBar.setBackgroundColor({ color: '#ffffff' }) + .then(async ({ StatusBar, Style }) => { + // await so rejections (e.g. plugin missing in older native + // binaries that got this bundle via OTA update) hit the catch + // below instead of surfacing as unhandled rejections in Sentry + await StatusBar.setOverlaysWebView({ overlay: false }) + await StatusBar.setStyle({ style: Style.Light }) + await StatusBar.setBackgroundColor({ color: '#90A8ED' }) // secondary-3 }) .catch(() => {}) }, []) @@ -51,8 +70,27 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { return ( <> - + {/* Status-bar safe zone + feedback ribbon. + Android 15 (targetSdk 36) forces edge-to-edge, so the webview draws + UNDER the status bar — without this the ribbon/status icons collide + in a blank strip (see bug report). Fill the inset with the brand + periwinkle (matches the onboarding illustration) so the top reads as + intentional. env(safe-area-inset-top) resolves to 0 on web and on + non-edge-to-edge Android, so this is a no-op there. */} +

+ +
{children} + {/* Bottom safe-area zone. Mirrors the periwinkle status-bar strip above: + on Android 15 edge-to-edge the webview draws under the nav bar, where the + page's beige (bg-background) would otherwise show. Fill the inset with the + brand periwinkle so the bottom matches the top. No-op on web (inset = 0). */} +
+ ) } diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index f8df0de8c5..c348a3ac04 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -15,11 +15,17 @@ import { getFromCookie } from '@/utils/general.utils' import { useSearchParams } from 'next/navigation' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useAuth } from '@/context/authContext' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/0_Bruddle/Button' +import { PeanutWavingHello } from '@/assets/mascot' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' function SetupPageContent() { const { steps, inviteCode } = useSetupStore() const { step, handleNext, handleBack } = useSetupFlow() - const { logoutUser, isLoggingOut } = useAuth() + const { logoutUser, isLoggingOut, user, isFetchingUser } = useAuth() + const router = useRouter() const [direction, setDirection] = useState(0) const [currentStepIndex, setCurrentStepIndex] = useState(0) const [deferredPrompt, setDeferredPrompt] = useState(null) @@ -31,6 +37,39 @@ function SetupPageContent() { const [showBrowserNotSupportedModal, setShowBrowserNotSupportedModal] = useState(false) const { deviceType: detectedDeviceType } = useDeviceType() const searchParams = useSearchParams() + const [sessionChecked, setSessionChecked] = useState(false) + const [existingSessionUsername, setExistingSessionUsername] = useState(null) + + /* + * A device can arrive at /setup already authenticated: a half-completed + * earlier signup leaves durable credentials (jwt cookie in the native jar, + * web-authn-key cookie), and running signup on top of them silently no-ops + * — the passkey step would skip and the freshly chosen username would be + * discarded. Check once, at entry only: `sessionChecked` stays true for the + * rest of the flow, so the user becoming authenticated mid-signup (after + * registration) never re-triggers the prompt. + */ + useEffect(() => { + if (sessionChecked || isFetchingUser) return + setSessionChecked(true) + if (user?.user?.username) { + setExistingSessionUsername(user.user.username) + posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_PROMPTED, { + has_app_access: !!user.user.hasAppAccess, + }) + } + }, [sessionChecked, isFetchingUser, user]) + + const handleContinueSession = () => { + posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_CONTINUED) + router.push('/home') + } + + const handleStartFresh = async () => { + posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_LOGGED_OUT) + await logoutUser() + setExistingSessionUsername(null) + } useEffect(() => { const determineInitialStep = async () => { @@ -200,13 +239,35 @@ function SetupPageContent() { } }, [step, currentStepIndex, steps]) - if (isLoading) + if (isLoading || !sessionChecked) return (
) + if (existingSessionUsername) { + return ( + +
+ + +
+
+ ) + } + // if no step is determined and no blocking modal is shown, it's an issue if (!step && !showDeviceNotSupportedModal && !showBrowserNotSupportedModal) { console.warn('SetupPage: No current step found, and no blocking modal. Possibly init issue.') diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 369828192c..e1892bdc37 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -12,8 +12,8 @@ import StaleCardApprovalReEnableModal from '@/components/Global/StaleCardApprova import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' -import { PeanutProvider } from '@/config' -import { ContextProvider } from '@/context' +import { PeanutProvider } from '@/config/peanut.config' +import { ContextProvider } from '@/context/contextProvider' import { FooterVisibilityProvider } from '@/context/footerVisibility' import { HARNESS_ENABLED } from '@/constants/harness.consts' import { useOtaUpdates } from '@/hooks/useOtaUpdates' diff --git a/src/app/[...recipient]/payment-layout-wrapper.tsx b/src/app/[...recipient]/payment-layout-wrapper.tsx index 3c3ae5d18d..6cc35c27a3 100644 --- a/src/app/[...recipient]/payment-layout-wrapper.tsx +++ b/src/app/[...recipient]/payment-layout-wrapper.tsx @@ -47,8 +47,8 @@ export default function PaymentLayoutWrapper({ children }: { children: React.Rea >
{children} diff --git a/src/app/actions/card-comparison.ts b/src/app/actions/card-comparison.ts index c5e7a653b7..fd32a891fb 100644 --- a/src/app/actions/card-comparison.ts +++ b/src/app/actions/card-comparison.ts @@ -1,5 +1,3 @@ -'use server' - import { CARD_FX_MARKUP_BY_CURRENCY } from '@/constants/payment.consts' import { getCurrencyPrice } from '@/app/actions/currency' diff --git a/src/app/actions/supported-chains.ts b/src/app/actions/supported-chains.ts index 0b94ecc5b7..56eb628b3c 100644 --- a/src/app/actions/supported-chains.ts +++ b/src/app/actions/supported-chains.ts @@ -7,6 +7,10 @@ import ARBITRUM_ICON from '@/assets/chains/arbitrum.svg' // falls back to initials ("AO"). Prefer a bundled local asset for those. const CHAIN_ICON_OVERRIDES: Record = { '42161': ARBITRUM_ICON, + // Linea's chain-details icon is an SVG served via ipfs.io — next/image + // refuses SVG by default, so it rendered as "LI" initials. CoinGecko + // raster instead. (Avalanche/Mantle ipfs icons are PNG and render fine.) + '59144': 'https://coin-images.coingecko.com/asset_platforms/images/135/small/linea.jpeg?1706606705', } export async function getSupportedChainsAndTokens(): Promise> { diff --git a/src/app/actions/types/users.types.ts b/src/app/actions/types/users.types.ts index 63ca3b0f8d..ff1a2ab96f 100644 --- a/src/app/actions/types/users.types.ts +++ b/src/app/actions/types/users.types.ts @@ -12,7 +12,6 @@ export interface InitiateKycResponse { kycLink: string tosLink?: string bridgeKycStatus: string - tosStatus?: string error?: string // will be present on rejections reasons?: Array<{ developer_reason: string diff --git a/src/app/dev/kyc-flows/MermaidRenderer.tsx b/src/app/dev/kyc-flows/MermaidRenderer.tsx index 8dda86c5f5..ce01d560c6 100644 --- a/src/app/dev/kyc-flows/MermaidRenderer.tsx +++ b/src/app/dev/kyc-flows/MermaidRenderer.tsx @@ -37,7 +37,13 @@ export function MermaidRenderer({ diagrams, source }: Props) { const { svg } = await mermaid.render(`mermaid-${i}`, code) node.innerHTML = svg } catch (e) { - node.innerHTML = `
${e}
` + // Render the error as text, not HTML — `e` can contain the + // (untrusted) diagram source, so interpolating it into + // innerHTML would be a reflected-XSS sink. + const pre = document.createElement('pre') + pre.style.color = 'red' + pre.textContent = String(e) + node.replaceChildren(pre) } } } diff --git a/src/app/shhhhh/ShhhhhLandingPage.tsx b/src/app/shhhhh/ShhhhhLandingPage.tsx index 84f7db8878..e1ecff2656 100644 --- a/src/app/shhhhh/ShhhhhLandingPage.tsx +++ b/src/app/shhhhh/ShhhhhLandingPage.tsx @@ -408,7 +408,7 @@ export default function ShhhhhLandingPage() { {stats.map((stat) => (
{stat.value} diff --git a/src/assets/mascot/index.ts b/src/assets/mascot/index.ts index e11d769ba6..2a0422eaa6 100644 --- a/src/assets/mascot/index.ts +++ b/src/assets/mascot/index.ts @@ -2,17 +2,46 @@ // Everything mascot-shaped lives here — animated GIFs and stills alike. // Import from '@/assets/mascot' only; do not reach for raw file paths. -// Animated mascots (animated WebP, alpha background — downscaled 512→320px, gif2webp -q 70) -export { default as PeanutWhistling } from './peanut-whistling.webp' // whistling, peace-sign, mid-stride — chill / effortless: landing hero, setup intro, low-key "you're in" wins -export { default as PeanutPointing } from './peanut-pointing.webp' // grinning, pointing off-screen -export { default as PeanutCheering } from './peanut-cheering.webp' // both fists up, celebrating — big money wins (claim / payment success, confetti moments) -export { default as PeanutSad } from './peanut-sad.webp' // slumped, frowning, hands on hips — sad / dejected (errors) -export { default as PeanutCrying } from './peanut-crying.webp' // teary, hands to face — errors / empty states -export { default as PeanutTooCool } from './peanut-too-cool.webp' // pixel shades, hand on hip, big grin — confident "too cool" flex -export { default as PeanutThinking } from './peanut-thinking.webp' // pondering — loading / verification waits -export { default as PeanutWavingHello } from './peanut-waving-hello.webp' // one arm up, waving — greetings / setup -export { default as PeanutWalking } from './peanut-walking.webp' // mid-stride, arms swinging — physical-card waitlist ("on the way / shipping") -export { default as PeanutPointingDown } from './peanut-pointing-down.webp' // both hands pointing down — marketing CTA +import type { StaticImageData } from 'next/image' + +import { isLegacyWebKit } from '@/utils/webkit.utils' + +import cheeringGif from './peanut-cheering.gif' +import cheeringWebp from './peanut-cheering.webp' +import cryingGif from './peanut-crying.gif' +import cryingWebp from './peanut-crying.webp' +import pointingDownGif from './peanut-pointing-down.gif' +import pointingDownWebp from './peanut-pointing-down.webp' +import pointingGif from './peanut-pointing.gif' +import pointingWebp from './peanut-pointing.webp' +import sadGif from './peanut-sad.gif' +import sadWebp from './peanut-sad.webp' +import thinkingGif from './peanut-thinking.gif' +import thinkingWebp from './peanut-thinking.webp' +import tooCoolGif from './peanut-too-cool.gif' +import tooCoolWebp from './peanut-too-cool.webp' +import walkingGif from './peanut-walking.gif' +import walkingWebp from './peanut-walking.webp' +import wavingHelloGif from './peanut-waving-hello.gif' +import wavingHelloWebp from './peanut-waving-hello.webp' +import whistlingGif from './peanut-whistling.gif' +import whistlingWebp from './peanut-whistling.webp' + +// Legacy/unverifiable WebKit can't animate WebP (see isLegacyWebKit) — it gets the +// GIF fallbacks (bigger files, 1-bit alpha); everyone else the smaller WebP. +const pick = (webp: StaticImageData, gif: StaticImageData): StaticImageData => (isLegacyWebKit() ? gif : webp) + +// Animated mascots (alpha background — downscaled 512→320px; webp via gif2webp -q 70) +export const PeanutWhistling = pick(whistlingWebp, whistlingGif) // whistling, peace-sign, mid-stride — chill / effortless: landing hero, setup intro, low-key "you're in" wins +export const PeanutPointing = pick(pointingWebp, pointingGif) // grinning, pointing off-screen +export const PeanutCheering = pick(cheeringWebp, cheeringGif) // both fists up, celebrating — big money wins (claim / payment success, confetti moments) +export const PeanutSad = pick(sadWebp, sadGif) // slumped, frowning, hands on hips — sad / dejected (errors) +export const PeanutCrying = pick(cryingWebp, cryingGif) // teary, hands to face — errors / empty states +export const PeanutTooCool = pick(tooCoolWebp, tooCoolGif) // pixel shades, hand on hip, big grin — confident "too cool" flex +export const PeanutThinking = pick(thinkingWebp, thinkingGif) // pondering — loading / verification waits +export const PeanutWavingHello = pick(wavingHelloWebp, wavingHelloGif) // one arm up, waving — greetings / setup +export const PeanutWalking = pick(walkingWebp, walkingGif) // mid-stride, arms swinging — physical-card waitlist ("on the way / shipping") +export const PeanutPointingDown = pick(pointingDownWebp, pointingDownGif) // both hands pointing down — marketing CTA // Stills export { default as PEANUTMAN } from './peanutman.svg' diff --git a/src/assets/mascot/peanut-cheering.gif b/src/assets/mascot/peanut-cheering.gif new file mode 100644 index 0000000000..adbfa11a53 Binary files /dev/null and b/src/assets/mascot/peanut-cheering.gif differ diff --git a/src/assets/mascot/peanut-crying.gif b/src/assets/mascot/peanut-crying.gif new file mode 100644 index 0000000000..ad099947c2 Binary files /dev/null and b/src/assets/mascot/peanut-crying.gif differ diff --git a/src/assets/mascot/peanut-pointing-down.gif b/src/assets/mascot/peanut-pointing-down.gif new file mode 100644 index 0000000000..c57ebd3ca2 Binary files /dev/null and b/src/assets/mascot/peanut-pointing-down.gif differ diff --git a/src/assets/mascot/peanut-pointing.gif b/src/assets/mascot/peanut-pointing.gif new file mode 100644 index 0000000000..087e46beff Binary files /dev/null and b/src/assets/mascot/peanut-pointing.gif differ diff --git a/src/assets/mascot/peanut-sad.gif b/src/assets/mascot/peanut-sad.gif new file mode 100644 index 0000000000..c8beb3dd1e Binary files /dev/null and b/src/assets/mascot/peanut-sad.gif differ diff --git a/src/assets/mascot/peanut-thinking.gif b/src/assets/mascot/peanut-thinking.gif new file mode 100644 index 0000000000..e25b56e146 Binary files /dev/null and b/src/assets/mascot/peanut-thinking.gif differ diff --git a/src/assets/mascot/peanut-too-cool.gif b/src/assets/mascot/peanut-too-cool.gif new file mode 100644 index 0000000000..ffaebc7840 Binary files /dev/null and b/src/assets/mascot/peanut-too-cool.gif differ diff --git a/src/assets/mascot/peanut-walking.gif b/src/assets/mascot/peanut-walking.gif new file mode 100644 index 0000000000..1c61bfc912 Binary files /dev/null and b/src/assets/mascot/peanut-walking.gif differ diff --git a/src/assets/mascot/peanut-waving-hello.gif b/src/assets/mascot/peanut-waving-hello.gif new file mode 100644 index 0000000000..fae8211388 Binary files /dev/null and b/src/assets/mascot/peanut-waving-hello.gif differ diff --git a/src/assets/mascot/peanut-whistling.gif b/src/assets/mascot/peanut-whistling.gif new file mode 100644 index 0000000000..082313aaee Binary files /dev/null and b/src/assets/mascot/peanut-whistling.gif differ diff --git a/src/components/0_Bruddle/CloudsBackground.tsx b/src/components/0_Bruddle/CloudsBackground.tsx index bfc715ee72..8a00441fcf 100644 --- a/src/components/0_Bruddle/CloudsBackground.tsx +++ b/src/components/0_Bruddle/CloudsBackground.tsx @@ -1,7 +1,6 @@ 'use client' -import { motion } from 'framer-motion' -import { useEffect, useState } from 'react' +import { useEffect, useState, type CSSProperties } from 'react' const cloud1 = ( @@ -45,25 +44,23 @@ const Cloud: React.FC = ({ top, scale = 1, variant, side, speed, scr const distance = Math.abs(endX - startX) const duration = distance / speed + // CSS animation (see .cloud-drift in globals.css) so the drift runs on the + // compositor thread — framer-motion's rAF loop pinned the main thread on + // low-end devices (iPhone X) for the lifetime of the screen. + const style: CSSProperties & Record<'--cloud-from' | '--cloud-to' | '--cloud-scale', string> = { + position: 'absolute', + top: `${top}%`, + zIndex: 0, + animationDuration: `${duration}s`, + '--cloud-from': `${startX}px`, + '--cloud-to': `${endX}px`, + '--cloud-scale': `${scale}`, + } + return ( - +
{CloudSvg} - +
) } diff --git a/src/components/0_Bruddle/Toast.tsx b/src/components/0_Bruddle/Toast.tsx index 94c7207c49..1782868da9 100644 --- a/src/components/0_Bruddle/Toast.tsx +++ b/src/components/0_Bruddle/Toast.tsx @@ -59,12 +59,12 @@ const Toast: React.FC = ({ type = 'info', message, content, classN transition={{ type: 'spring', stiffness: 400, damping: 25 }} className={twMerge( 'border-2 px-6 py-1', - 'card shadow-4 min-w-fit max-w-[90vw] md:max-w-md', + 'card shadow-4 max-w-[calc(100vw_-_2rem)] md:max-w-md', colors[type], className )} > - {content ??

{message}

} + {content ??

{message}

} ) } @@ -132,20 +132,10 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => { [createToast, dismiss] ) - const getPositionClasses = (position: ToastPosition = 'top-right') => { - const positions: Record = { - 'top-right': 'top-4 right-4', - 'top-left': 'top-4 left-4', - 'bottom-right': 'bottom-[100px] right-4', - 'bottom-left': 'bottom-[100px] left-4', - } - return positions[position] - } - return ( <> -
+
{toasts.map((toast) => ( diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 44af109d3b..3b32ed05f3 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -15,7 +15,7 @@ import { RequestFulfillmentBankFlowStep, useRequestFulfillmentFlow } from '@/con import { formatAmount } from '@/utils/general.utils' import InfoCard from '@/components/Global/InfoCard' import CopyToClipboard from '@/components/Global/CopyToClipboard' -import { BRIDGE_DEFAULT_ACCOUNT_HOLDER_NAME } from '@/constants/payment.consts' +import { resolveBridgeAccountHolderName } from '@/constants/payment.consts' import { Button } from '@/components/0_Bruddle/Button' import { useOnrampQuote } from '@/hooks/useOnrampQuote' import { currencyToAccountType } from '@/utils/bridge.utils' @@ -296,10 +296,10 @@ Please use these details to complete your bank transfer.`

Bank Details

- {/* note: fallback to bridge as account holder name, to cover faster_payments onramp requests as bridge currently doesnt retrun a account holder name in api response */} + {/* resolveBridgeAccountHolderName maps Bridge's stale/absent legal entity name to the current one (Sp. Z.o.o. -> S.A.) */} diff --git a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx index 2ba3113ab2..0f1c3dd951 100644 --- a/src/components/AddMoney/components/ChooseNetworkDrawer.tsx +++ b/src/components/AddMoney/components/ChooseNetworkDrawer.tsx @@ -2,8 +2,9 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/Global/Drawer' import { ActionListCard } from '@/components/ActionListCard' -import ChainChip from './ChainChip' +import EvmChainChips from './EvmChainChips' import { CHAIN_LOGOS, SUPPORTED_EVM_CHAINS, getSupportedTokens } from '@/constants/rhino.consts' +import { useChainRollout } from '@/hooks/useChainRollout' import type { RhinoChainType } from '@/services/services.types' import Image from 'next/image' @@ -14,6 +15,10 @@ interface ChooseNetworkDrawerProps { } const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerProps) => { + // Count only rolled-out chains — the chips below are gated the same way, + // and "12 Networks" above 10 visible chips would be a lie. + const isChainRolledOut = useChainRollout() + const evmChainCount = SUPPORTED_EVM_CHAINS.filter(isChainRolledOut).length return ( !isOpen && onClose()}> @@ -27,7 +32,7 @@ const ChooseNetworkDrawer = ({ open, onClose, onSelect }: ChooseNetworkDrawerPro
onSelect('EVM')} className="mx-4 border-t border-dashed border-black py-3">
- {SUPPORTED_EVM_CHAINS.map((chain) => ( - - ))} +
diff --git a/src/components/AddMoney/components/EvmChainChips.tsx b/src/components/AddMoney/components/EvmChainChips.tsx new file mode 100644 index 0000000000..149fe10d68 --- /dev/null +++ b/src/components/AddMoney/components/EvmChainChips.tsx @@ -0,0 +1,24 @@ +import ChainChip from './ChainChip' +import { SUPPORTED_EVM_CHAINS, CHAIN_LOGOS, EVM_DEPOSIT_TOKEN_EXCEPTIONS } from '@/constants/rhino.consts' +import { useChainRollout } from '@/hooks/useChainRollout' + +/** + * The rollout-gated EVM deposit chain chips, annotated with per-chain token + * exceptions (USDT-only chains) — a USDC deposit on a chain where Rhino only + * accepts USDT has no webhook, so the annotation is a funds-safety surface, + * not decoration. Shared by ChooseNetworkDrawer and SupportedNetworksModal. + */ +const EvmChainChips = () => { + const isChainRolledOut = useChainRollout() + return ( + <> + {SUPPORTED_EVM_CHAINS.filter(isChainRolledOut).map((chain) => { + const tokenException = EVM_DEPOSIT_TOKEN_EXCEPTIONS[chain] + const label = tokenException ? `${chain} · ${tokenException.join('/')} only` : chain + return + })} + + ) +} + +export default EvmChainChips diff --git a/src/components/AddMoney/components/SupportedNetworksModal.tsx b/src/components/AddMoney/components/SupportedNetworksModal.tsx index 29953b2e9f..777af51c86 100644 --- a/src/components/AddMoney/components/SupportedNetworksModal.tsx +++ b/src/components/AddMoney/components/SupportedNetworksModal.tsx @@ -2,8 +2,7 @@ import Modal from '@/components/Global/Modal' import InfoCard from '@/components/Global/InfoCard' -import ChainChip from './ChainChip' -import { SUPPORTED_EVM_CHAINS, CHAIN_LOGOS } from '@/constants/rhino.consts' +import EvmChainChips from './EvmChainChips' interface SupportedNetworksModalProps { visible: boolean @@ -25,9 +24,7 @@ const SupportedNetworksModal = ({ visible, onClose }: SupportedNetworksModalProp

- {SUPPORTED_EVM_CHAINS.map((chain) => ( - - ))} +
+ {!isOfframp && ( +

+ A small bridging fee (~0.1%) applies — you'll receive slightly less than you + send. +

+ )} {isOfframp && (

Moving more than the max? Send it in multiple transfers. diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index a63caaf6f9..5391c36cde 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -78,7 +78,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // AddMoneyBankDetails (deposit-instructions display). if (flow === 'add') { const countrySlug = currentCountry?.path - router.push(countrySlug ? `/add-money/${countrySlug}/bank` : '/add-money') + // rewriteMethodPath → native: /add-money?country=&view=bank + router.push(countrySlug ? rewriteMethodPath(`/add-money/${countrySlug}/bank`) : '/add-money') return } setView('form') diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx index 6c1925031d..0aed3aa94e 100644 --- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx +++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx @@ -10,7 +10,7 @@ import { } from '@/utils/general.utils' import { useRouter, useSearchParams } from 'next/navigation' import { addMoneyCountryUrl, withdrawCountryUrl, rewriteMethodPath } from '@/utils/native-routes' -import { type FC, useEffect, useState, useTransition, useCallback } from 'react' +import { type FC, useEffect, useRef, useState, useTransition, useCallback } from 'react' import { useUserStore } from '@/redux/hooks' import { AccountType, type Account } from '@/interfaces/interfaces' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' @@ -90,7 +90,11 @@ export const AddWithdrawRouterView: FC = ({ shouldShowAllMethods = true } - const baseRoute = flow === 'add' ? '/add-money' : '/withdraw' + // apply the default view (saved accounts vs all methods) only once per mount. + // the user query re-dispatches a fresh `user` object on every refetch (window + // focus, 4s pending-rail poll), and re-running the default unconditionally + // yanked an open country list back to the saved-accounts view. + const hasAppliedDefaultView = useRef(false) useEffect(() => { setIsLoadingPreferences(true) @@ -107,12 +111,13 @@ export const AddWithdrawRouterView: FC = ({ if (bankAccounts.length > 0) { setSavedAccounts(bankAccounts as unknown as Account[]) - setShouldShowAllMethods(false) + if (!hasAppliedDefaultView.current) setShouldShowAllMethods(false) } else { setSavedAccounts([]) } - } else { - // 'add' flow logic + } else if (!hasAppliedDefaultView.current) { + // 'add' flow: the default view is a one-shot decision, so skip the + // localstorage re-read + state churn on later user refetches const prefs = user ? getUserPreferences(user.user.userId) : undefined const currentRecentMethods = prefs?.recentAddMethods ?? [] if (currentRecentMethods.length > 0) { @@ -122,6 +127,9 @@ export const AddWithdrawRouterView: FC = ({ setShouldShowAllMethods(true) } } + // latch only once the user has loaded, so the first real resolution + // (not the pre-auth null render) decides the default view + if (user) hasAppliedDefaultView.current = true setIsLoadingPreferences(false) }, [flow, user, setShouldShowAllMethods]) @@ -368,22 +376,12 @@ export const AddWithdrawRouterView: FC = ({ }) setIsSupportedTokensModalOpen(true) } else { - posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { - method_type: 'crypto', - country: 'crypto', - }) - // preserve method param if coming from send flow (though crypto shouldn't show this screen) - const queryParams = methodParam ? `?method=${methodParam}` : '' - const cryptoPath = `${baseRoute}/crypto${queryParams}` - // Set crypto method and navigate to main page for amount input - setSelectedMethod({ - type: 'crypto', - countryPath: 'crypto', - title: 'Crypto', - }) - startTransition(() => { - router.push(cryptoPath) - }) + // shared withdraw handler: analytics + set method in context, no + // navigation — the withdraw page owns the amount step and navigates + // to /withdraw/crypto after Continue. navigating here (pre-amount) + // trips the crypto page's "no amount" redirect guard, whose unmount + // cleanup resets the whole flow back to saved accounts. + handleMethodSelected({ id: 'crypto', type: 'crypto', title: 'Crypto', path: 'crypto' }) } }} flow={flow} diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 3b65d84884..832e52870d 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -7,7 +7,7 @@ import { type AddBankAccountPayload, BridgeAccountOwnerType, BridgeAccountType } import BaseInput from '@/components/0_Bruddle/BaseInput' import BaseSelect from '@/components/0_Bruddle/BaseSelect' import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts' -import { useParams, useRouter } from 'next/navigation' +import { useParams, useRouter, useSearchParams } from 'next/navigation' import { validateIban, validateBankAccount, @@ -26,6 +26,7 @@ import { validateMXCLabeAccount, validateUSBankAccount, } from '@/utils/withdraw.utils' +import { createSmartPasteHandler, type PasteFieldKind } from '@/utils/clipboard-extract.utils' import useSavedAccounts from '@/hooks/useSavedAccounts' import { useAppDispatch, useAppSelector } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' @@ -95,13 +96,23 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const [isSubmitting, setIsSubmitting] = useState(false) const [submissionError, setSubmissionError] = useState(null) const { country: countryNameParams } = useParams() + // Native/Capacitor passes country as a query param (?country=usa), so + // useParams() is empty there — fall back to searchParams and finally the + // `country` prop so this never derefs undefined (white-screen crash). + const searchParams = useSearchParams() const { amountToWithdraw, setSelectedBankAccount } = useWithdrawFlow() const router = useRouter() const savedAccounts = useSavedAccounts() const [isCheckingBICValid, setisCheckingBICValid] = useState(false) const STREET_ADDRESS_MAX_LENGTH = 35 // From bridge docs: street address can be max 35 characters - let selectedCountry = (countryNameFromProps ?? (countryNameParams as string)).toLowerCase() + let selectedCountry = ( + countryNameFromProps ?? + (countryNameParams as string) ?? + searchParams.get('country') ?? + country ?? + '' + ).toLowerCase() // Get persisted form data from Redux const persistedFormData = useAppSelector((state) => state.bankForm.formData) @@ -297,6 +308,23 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D } } + const smartPasteKindFor = (name: keyof IBankAccountDetails): PasteFieldKind | undefined => { + switch (name) { + case 'clabe': + return 'clabe' + case 'bic': + return 'bic' + case 'routingNumber': + return 'routingNumber' + case 'sortCode': + return 'ukSortCode' + case 'accountNumber': + return isIban ? 'iban' : isUk ? 'ukAccount' : 'usAccount' + default: + return undefined + } + } + const renderInput = ( name: keyof IBankAccountDetails, placeholder: string, @@ -306,49 +334,59 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D onBlur?: (field: any) => Promise | void, showCharCount?: boolean, maxLength?: number - ) => ( -

-
- ( - { - // remove any whitespace from the input field - // note: @dev not a great fix, this should also be fixed in the backend - if (typeof field.value === 'string') { - field.onChange(field.value.trim()) + ) => { + const smartPasteKind = smartPasteKindFor(name) + return ( +
+
+ ( + { + // remove any whitespace from the input field + // note: @dev not a great fix, this should also be fixed in the backend + if (typeof field.value === 'string') { + field.onChange(field.value.trim()) + } + field.onBlur() + if (onBlur) { + await onBlur(field) + } + }} + rightContent={ + showCharCount && maxLength ? ( + + {field.value?.length ?? 0}/{maxLength} + + ) : undefined } - }} - rightContent={ - showCharCount && maxLength ? ( - - {field.value?.length ?? 0}/{maxLength} - - ) : undefined - } - /> + /> + )} + /> +
+
+ {errors[name] && touchedFields[name] && ( + )} - /> -
-
- {errors[name] && touchedFields[name] && } +
-
- ) + ) + } const renderSelect = (name: keyof IBankAccountDetails, placeholder: string, options: any[], rules: any) => (
diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index f493604399..9f523be66c 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -300,3 +300,47 @@ describe('AddWithdrawCountriesList — PIX onramp maintenance tag', () => { expect(within(screen.getByTestId('method-bank')).queryByText('Maintenance')).toBeNull() }) }) + +/** + * When the BRL-via-PIX onramp degrades, the Pix option gets flagged "under + * maintenance" (config: pixBrazilOnrampMaintenance) — warn-only: it stays + * visible and clickable. + */ +describe('AddWithdrawCountriesList — PIX onramp maintenance tag', () => { + // snapshot/restore the shipped flag so each test can flip it without leaking + // state — and without coupling the restore to the committed default + let originalPixMaintenance: boolean + + beforeEach(() => { + mockPush.mockClear() + // a ready gate so a click can navigate — proving the option is not blocked + setCapabilities('ready', [{ status: 'enabled', channel: 'bank', country: 'US' }]) + originalPixMaintenance = underMaintenanceConfig.pixBrazilOnrampMaintenance + }) + + afterEach(() => { + underMaintenanceConfig.pixBrazilOnrampMaintenance = originalPixMaintenance + }) + + it('tags the Pix option "Maintenance" but keeps it clickable (warn-only)', () => { + underMaintenanceConfig.pixBrazilOnrampMaintenance = true + + render() + + const pixCard = screen.getByTestId('method-pix') + expect(within(pixCard).getByText('Maintenance')).toBeInTheDocument() + + // warn-only: still navigates into the deposit flow + fireEvent.click(pixCard) + expect(mockPush).toHaveBeenCalledWith('/add-money/brazil/manteca') + }) + + it('shows no maintenance tag when the flag is off, and never tags non-Pix methods', () => { + underMaintenanceConfig.pixBrazilOnrampMaintenance = false + + render() + + expect(within(screen.getByTestId('method-pix')).queryByText('Maintenance')).toBeNull() + expect(within(screen.getByTestId('method-bank')).queryByText('Maintenance')).toBeNull() + }) +}) diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx new file mode 100644 index 0000000000..3f91f08165 --- /dev/null +++ b/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx @@ -0,0 +1,177 @@ +/** + * AddWithdrawRouterView — regression tests for the withdraw method-selection bounce. + * + * two regressions pinned here: + * 1. clicking "Crypto" must set the method in context WITHOUT navigating to + * /withdraw/crypto (navigating pre-amount trips that page's "no amount" + * redirect guard, whose unmount cleanup resets the whole flow). + * 2. a user-object refetch (new identity, same data) must NOT force the view + * back from the country list to saved accounts. + * + * uses the real WithdrawFlowContextProvider (pure useState, no heavy deps) so + * the tests exercise the actual context wiring instead of a hand-rolled copy. + */ +import React, { useEffect } from 'react' +import { render, screen, fireEvent } from '@testing-library/react' + +const mockRouterPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush, back: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), + useSearchParams: () => ({ get: () => null }), + usePathname: () => '/withdraw', +})) + +jest.mock('posthog-js', () => ({ + __esModule: true, + default: { capture: jest.fn(), init: jest.fn() }, +})) + +jest.mock('@/utils/general.utils', () => ({ + getUserPreferences: jest.fn(() => undefined), + updateUserPreferences: jest.fn(), + getFromLocalStorage: jest.fn(() => null), +})) + +jest.mock('@/utils/native-routes', () => ({ + addMoneyCountryUrl: (p: string) => `/add-money/${p}`, + withdrawCountryUrl: (p: string, q?: string) => `/withdraw/${p}${q ?? ''}`, + rewriteMethodPath: (p: string) => p, +})) + +jest.mock('@/constants/manteca.consts', () => ({ + isMantecaCountry: jest.fn(() => false), +})) + +interface MockUser { + user: { userId: string } + accounts: Array<{ type: string; identifier: string; details: Record }> +} + +let mockUser: MockUser | null +jest.mock('@/redux/hooks', () => ({ + useUserStore: () => ({ user: mockUser }), +})) + +jest.mock('@/context/OnrampFlowContext', () => ({ + useOnrampFlow: () => ({ setFromBankSelected: jest.fn() }), +})) + +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: (props: { onClick?: () => void; disabled?: boolean; children?: React.ReactNode }) => ( + + ), +})) + +jest.mock('@/components/AddMoney/components/DepositMethodList', () => ({ + DepositMethodList: () =>
, +})) + +jest.mock('@/components/Global/NavHeader', () => ({ + __esModule: true, + default: (props: { title?: string }) =>
{props.title}
, +})) + +jest.mock('@/components/Global/Card', () => ({ + __esModule: true, + default: (props: { children?: React.ReactNode }) =>
{props.children}
, +})) + +jest.mock('@/components/Profile/AvatarWithBadge', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('../../Common/CountryList', () => ({ + CountryList: (props: { onCryptoClick?: () => void }) => ( +
+ +
+ ), +})) + +jest.mock('../../Global/PeanutLoading', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('../../Common/SavedAccountsView', () => ({ + __esModule: true, + default: (props: { onSelectNewMethodClick?: () => void }) => ( +
+ +
+ ), +})) + +jest.mock('../../Global/TokenAndNetworkConfirmationModal', () => ({ + __esModule: true, + default: () => null, +})) + +import { AddWithdrawRouterView } from '../AddWithdrawRouterView' +import { WithdrawFlowContextProvider, useWithdrawFlow } from '@/context/WithdrawFlowContext' + +const makeUser = (): MockUser => ({ + user: { userId: 'user-1' }, + accounts: [{ type: 'iban', identifier: 'BE10905272880104', details: {} }], +}) + +// exposes the real context's selectedMethod so tests can assert on it +const onSelectedMethodChange = jest.fn() +function SelectedMethodProbe() { + const { selectedMethod } = useWithdrawFlow() + useEffect(() => { + if (selectedMethod) onSelectedMethodChange(selectedMethod) + }, [selectedMethod]) + return null +} + +function Harness({ user }: { user: MockUser }) { + mockUser = user + return ( + + + + + ) +} + +describe('AddWithdrawRouterView — withdraw method selection', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('shows saved accounts by default when bank accounts exist', () => { + render() + expect(screen.getByTestId('saved-accounts-view')).toBeInTheDocument() + }) + + test('clicking Crypto sets the method in context and does NOT navigate', () => { + render() + fireEvent.click(screen.getByTestId('select-new-method')) + fireEvent.click(screen.getByTestId('crypto-option')) + + expect(onSelectedMethodChange).toHaveBeenCalledWith( + expect.objectContaining({ type: 'crypto', title: 'Crypto' }) + ) + expect(mockRouterPush).not.toHaveBeenCalled() + }) + + test('a user refetch (new object identity) does not bounce the country list back to saved accounts', () => { + const { rerender } = render() + fireEvent.click(screen.getByTestId('select-new-method')) + expect(screen.getByTestId('country-list')).toBeInTheDocument() + + // simulate the 4s pending-rail poll / window-focus refetch dispatching a fresh user object + rerender() + + expect(screen.getByTestId('country-list')).toBeInTheDocument() + expect(screen.queryByTestId('saved-accounts-view')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Card/cardApply.utils.ts b/src/components/Card/cardApply.utils.ts index 9c5591997c..367c96367b 100644 --- a/src/components/Card/cardApply.utils.ts +++ b/src/components/Card/cardApply.utils.ts @@ -5,8 +5,7 @@ * aborted so the caller can show a retry message (or stop entirely). * * Without this, the immediate post-Sumsub re-apply races against Sumsub and - * dumps the user back on the "Start Secure Verification" interstitial when - * the WebSDK re-opens against an already-approved applicant. The signal lets + * re-opens the WebSDK against an already-approved applicant. The signal lets * the caller stop the loop on unmount so we don't burn 15 sequential fetches * after the user navigates away from the pending screen. */ diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx index 04da701712..e241423f48 100644 --- a/src/components/Claim/Claim.tsx +++ b/src/components/Claim/Claim.tsx @@ -441,7 +441,7 @@ export const Claim = ({}) => { return ( {linkState === _consts.claimLinkStateType.LOADING && (
diff --git a/src/components/Claim/Link/views/MantecaDetailsStep.view.tsx b/src/components/Claim/Link/views/MantecaDetailsStep.view.tsx index 1637155772..a778748d5a 100644 --- a/src/components/Claim/Link/views/MantecaDetailsStep.view.tsx +++ b/src/components/Claim/Link/views/MantecaDetailsStep.view.tsx @@ -57,6 +57,7 @@ const MantecaDetailsStep: FC = ({ }} placeholder={countryConfig.accountNumberLabel} validate={validateDestinationAddress} + smartPasteKind="cbuCvuAlias" />
diff --git a/src/components/Global/Banner/ConnectivityBanner.tsx b/src/components/Global/Banner/ConnectivityBanner.tsx new file mode 100644 index 0000000000..1dad28e334 --- /dev/null +++ b/src/components/Global/Banner/ConnectivityBanner.tsx @@ -0,0 +1,14 @@ +import { GenericBanner } from './GenericBanner' + +export function ConnectivityBanner({ isOffline }: { isOffline: boolean }) { + return ( + + ) +} diff --git a/src/components/Global/Banner/__tests__/ConnectivityBanner.test.tsx b/src/components/Global/Banner/__tests__/ConnectivityBanner.test.tsx new file mode 100644 index 0000000000..05e8808582 --- /dev/null +++ b/src/components/Global/Banner/__tests__/ConnectivityBanner.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react' +import { ConnectivityBanner } from '../ConnectivityBanner' + +describe('ConnectivityBanner', () => { + // react-fast-marquee's autoFill duplicates children, so match all copies. + it('tells the user they are offline when the device has no connection', () => { + render() + expect(screen.getAllByText(/no internet connection/i).length).toBeGreaterThan(0) + }) + + it('tells the user we are unreachable (not to contact support) on a timeout', () => { + render() + expect(screen.getAllByText(/trouble reaching peanut/i).length).toBeGreaterThan(0) + expect(screen.queryAllByText(/support/i)).toHaveLength(0) + }) +}) diff --git a/src/components/Global/Banner/index.tsx b/src/components/Global/Banner/index.tsx index fbddaecc8d..3f80581b52 100644 --- a/src/components/Global/Banner/index.tsx +++ b/src/components/Global/Banner/index.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react' import { usePathname } from 'next/navigation' +import { ConnectivityBanner } from './ConnectivityBanner' +import { useConnectivity } from '@/hooks/useConnectivity' import { MaintenanceBanner } from './MaintenanceBanner' import { MarqueeWrapper } from '../MarqueeWrapper' import maintenanceConfig from '@/config/underMaintenance.config' @@ -10,18 +12,32 @@ import Image from 'next/image' import { useModalsContext } from '@/context/ModalsContext' import { GIT_COMMIT_HASH, IS_PRODUCTION } from '@/constants/general.consts' import { getRunMode, isRealMoneyMode, logRunMode } from '@/utils/mode' +import { isDemoMode } from '@/utils/demo' export function Banner() { const pathname = usePathname() + const connectivity = useConnectivity() if (!pathname) return null + // Connectivity wins over the beta/maintenance banners: if the app can't reach + // the backend, that's the most actionable thing to tell the user right now. + if (connectivity.show) { + return + } + // check if maintenance banner OR full maintenance is enabled - show on all pages if (maintenanceConfig.enableMaintenanceBanner || maintenanceConfig.enableFullMaintenance) { return } // don't show beta feedback banner on landing pages, setup page, or quests pages - if (pathname === '/' || pathname === '/setup' || pathname.startsWith('/quests') || pathname.startsWith('/lp')) + if ( + pathname === '/' || + pathname === '/setup' || + pathname === '/setup/' || + pathname.startsWith('/quests') || + pathname.startsWith('/lp') + ) return null // show beta feedback banner when not in maintenance @@ -43,6 +59,21 @@ function FeedbackBanner() { setIsSupportModalOpen(true) } + // Demo mode: this isn't a real account, so swap the feedback ask for a clear + // "you're in a demo" notice (non-interactive — no support modal). + if (isDemoMode()) { + return ( +
+ + + Demo mode — you’re previewing Peanut with a simulated wallet. Balances and transactions aren’t + real. + + +
+ ) + } + const mode = !IS_PRODUCTION ? getRunMode() : null const realMoney = !IS_PRODUCTION && isRealMoneyMode() diff --git a/src/components/Global/DocsLink.tsx b/src/components/Global/DocsLink.tsx new file mode 100644 index 0000000000..4bc287887c --- /dev/null +++ b/src/components/Global/DocsLink.tsx @@ -0,0 +1,39 @@ +'use client' + +import { type ReactNode } from 'react' +import { isCapacitor, openExternalUrl } from '@/utils/capacitor' +import { BASE_URL } from '@/constants/general.consts' + +interface DocsLinkProps { + /** App-relative path to web-only content, e.g. `/en/help/transaction-limits`, `/terms`. */ + href: string + className?: string + children: ReactNode + 'aria-label'?: string +} + +/** + * Link to web-only pages (help center, legal) that don't exist in the native + * static export. On web it's a normal new-tab link; in Capacitor those routes + * 404 → SPA falls back to home, so we open the absolute production URL in the + * in-app browser instead. + */ +export default function DocsLink({ href, className, children, ...rest }: DocsLinkProps) { + return ( + { + if (isCapacitor()) { + e.preventDefault() + void openExternalUrl(`${BASE_URL}${href}`) + } + }} + {...rest} + > + {children} + + ) +} diff --git a/src/components/Global/Drawer/index.tsx b/src/components/Global/Drawer/index.tsx index a8c2b7545f..e2dd623ecf 100644 --- a/src/components/Global/Drawer/index.tsx +++ b/src/components/Global/Drawer/index.tsx @@ -41,7 +41,9 @@ const DrawerContent = React.forwardRef< >
-
{children}
+
+ {children} +
diff --git a/src/components/Global/EarlyUserModal/index.tsx b/src/components/Global/EarlyUserModal/index.tsx index d2f0384057..199dea37f4 100644 --- a/src/components/Global/EarlyUserModal/index.tsx +++ b/src/components/Global/EarlyUserModal/index.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import ActionModal from '../ActionModal' import ShareButton from '../ShareButton' +import DocsLink from '@/components/Global/DocsLink' import { generateInviteCodeLink } from '@/utils/general.utils' import { useAuth } from '@/context/authContext' import { updateUserById } from '@/app/actions/users' @@ -50,14 +51,9 @@ const EarlyUserModal = () => { Share Invite link - + Learn more - + } /> diff --git a/src/components/Global/GeneralRecipientInput/index.tsx b/src/components/Global/GeneralRecipientInput/index.tsx index 6e4f7026f7..1a4b3c9e37 100644 --- a/src/components/Global/GeneralRecipientInput/index.tsx +++ b/src/components/Global/GeneralRecipientInput/index.tsx @@ -7,6 +7,7 @@ import * as Senty from '@sentry/nextjs' import { useCallback, useRef } from 'react' import { isIBAN } from 'validator' import { validateAndResolveRecipient } from '@/lib/validation/recipient' +import { isValidAddressForFamily, type WithdrawAddressFamily } from '@/lib/validation/addressFamily' import { BASE_URL } from '@/constants/general.consts' type GeneralRecipientInputProps = { @@ -17,6 +18,10 @@ type GeneralRecipientInputProps = { infoText?: string showInfoText?: boolean isWithdrawal?: boolean + /** Address family of the selected withdraw destination ('evm' default). + * Solana/Tron short-circuit the IBAN/US-routing/ENS branches — a base58 + * address is the only valid input for them. */ + addressFamily?: WithdrawAddressFamily } export type GeneralRecipientUpdate = { @@ -35,6 +40,7 @@ const GeneralRecipientInput = ({ infoText, showInfoText = true, isWithdrawal = false, + addressFamily = 'evm', }: GeneralRecipientInputProps) => { const recipientType = useRef('address') const errorMessage = useRef('') @@ -50,6 +56,19 @@ const GeneralRecipientInput = ({ const trimmedInput = recipient.trim().replace(`${BASE_URL}/`, '') const sanitizedInput = sanitizeBankAccount(trimmedInput) + // Non-EVM destination: base58 address or nothing — never IBAN, + // US-routing, ENS, or username. + if (addressFamily !== 'evm') { + const familyValid = isValidAddressForFamily(trimmedInput, addressFamily) + if (familyValid) { + resolvedAddress.current = trimmedInput + } else { + errorMessage.current = `Invalid ${addressFamily === 'solana' ? 'Solana' : 'Tron'} address` + } + recipientType.current = 'address' + return familyValid + } + if (isIBAN(sanitizedInput)) { type = 'iban' isValid = await validateBankAccount(sanitizedInput) @@ -82,7 +101,7 @@ const GeneralRecipientInput = ({ return false } }, - [isWithdrawal] + [isWithdrawal, addressFamily] ) const onInputUpdate = useCallback( @@ -140,6 +159,7 @@ const GeneralRecipientInput = ({ name="bank-account" infoText={showInfoText ? infoText : undefined} formatDisplayValue={formatDisplayValue} + smartPasteKind="recipient" />
) diff --git a/src/components/Global/IframeWrapper/StartVerificationView.tsx b/src/components/Global/IframeWrapper/StartVerificationView.tsx deleted file mode 100644 index 03a224783a..0000000000 --- a/src/components/Global/IframeWrapper/StartVerificationView.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { PeanutThinking } from '@/assets/mascot' -import { Button } from '@/components/0_Bruddle/Button' -import CloudsBackground from '@/components/0_Bruddle/CloudsBackground' -import Image from 'next/image' -import NavHeader from '../NavHeader' - -const StartVerificationView = ({ - onStartVerification, - onClose, -}: { - onStartVerification: () => void - onClose: () => void -}) => { - return ( -
-
- - verification -
- -
-
- -
-

Secure Verification. Limited Data Use.

-
-

- The verification is done using a trusted provider, which shares your verification status with - Peanut. -

-

- It operates under industry-standard security and privacy practices. -

-

Peanut never sees or stores your verification data.

-
- -
-
- ) -} - -export default StartVerificationView diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index 400ea683ab..363cfefedb 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -3,7 +3,6 @@ import Modal from '../Modal' import { Icon, type IconName } from '../Icons/Icon' import ActionModal from '../ActionModal' import { useRouter } from 'next/navigation' -import StartVerificationView from './StartVerificationView' import { useModalsContext } from '@/context/ModalsContext' import { Button, type ButtonVariant } from '@/components/0_Bruddle/Button' @@ -12,15 +11,13 @@ export type IFrameWrapperProps = { visible: boolean onClose: (source?: 'manual' | 'completed' | 'tos_accepted') => void closeConfirmMessage?: string - skipStartView?: boolean } -const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage, skipStartView }: IFrameWrapperProps) => { +const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWrapperProps) => { const enableConfirmationPrompt = closeConfirmMessage !== undefined const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') const [copied, setCopied] = useState(false) - const [isVerificationStarted, setIsVerificationStarted] = useState(skipStartView ?? false) const router = useRouter() const { setIsSupportModalOpen } = useModalsContext() @@ -72,8 +69,8 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage, skipStartVi } return { - title: 'Exit and lose progress?', - description: 'If you exit now, you’ll need to start the ID check again from scratch.', + title: 'Exit for now?', + description: 'You can come back and finish this anytime.', icon: 'alert' as IconName, iconContainerClassName: 'bg-secondary-1', ctas: [ @@ -135,49 +132,42 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage, skipStartVi preventClose={true} hideOverlay={false} > - {!isVerificationStarted ? ( - onClose('manual')} - onStartVerification={() => setIsVerificationStarted(true)} - /> - ) : ( -
-
-