diff --git a/.coderabbit.yaml b/.coderabbit.yaml index b2318480b3..fb7c13b418 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -2,6 +2,10 @@ # projects, plists, resources) isn't worth AI review and pushes large # mobile PRs over the 150-file review limit. reviews: + # dev is the integration branch PRs actually target; without this, + # CodeRabbit refuses to review them even on a manual trigger. + base_branches: + - dev path_filters: - '!ios/**' - '!android/**' diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml index c37511a2f4..0782281575 100644 --- a/.github/workflows/code-analysis.yml +++ b/.github/workflows/code-analysis.yml @@ -79,8 +79,10 @@ jobs: # ---- BASE analysis (fetch + worktree the PR base ref) - name: Checkout base ref into a worktree + env: + BASE_REF: ${{ github.base_ref }} run: | - git fetch origin "${{ github.base_ref }}":base-ref + git fetch origin "$BASE_REF":base-ref git worktree add ../base-tree base-ref - name: Analyze base diff --git a/.github/workflows/content-publish-automerge.yml b/.github/workflows/content-publish-automerge.yml index 4ae049de28..0b25bfcd32 100644 --- a/.github/workflows/content-publish-automerge.yml +++ b/.github/workflows/content-publish-automerge.yml @@ -131,7 +131,7 @@ jobs: # Key off the `ci-success` check run, NOT `workflow_run.conclusion`. # `ci-success` is the single check the "Protect Prod" ruleset gates - # on; the run conclusion also folds in advisory jobs (e2e). Gate on + # on; the run conclusion also folds in advisory jobs (ds-shots). Gate on # exactly what the ruleset gates on. # # `Tests` runs on `pull_request` (and `push` for dev/main), so a diff --git a/.github/workflows/pr-assets-cleanup.yml b/.github/workflows/pr-assets-cleanup.yml new file mode 100644 index 0000000000..25489a6832 --- /dev/null +++ b/.github/workflows/pr-assets-cleanup.yml @@ -0,0 +1,38 @@ +name: PR assets cleanup + +# DORMANT since 2026-08-28: ds-shots no longer publishes this branch. The job +# runs PR-authored build code, so it was dropped to contents:read and the PNGs +# now travel as a workflow artifact. This workflow is kept, not deleted, +# because the planned pull_request/workflow_run split restores branch +# publishing from a job that never executes PR code. Until then it finds +# nothing to delete on every run, which is harmless. +# +# The ds-shots comment in tests.yml renders its before/after PNGs from a +# `pr-assets-` branch, because GitHub markdown cannot show a workflow +# artifact. Delete that branch when the PR ends, or the branches pile up — 20+ +# hand-made ones were still on the remote in August 2026. + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + delete-assets-branch: + name: delete-assets-branch + runs-on: ubuntu-latest + steps: + - name: Delete pr-assets- + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + # Most PRs never make this branch — a docs PR skips ds-shots, + # and a fork PR cannot push. A missing branch is normal, so a + # 422 from the delete is not a failure. + gh api -X DELETE "repos/$REPO/git/refs/heads/pr-assets-$PR" \ + && echo "deleted pr-assets-$PR" \ + || echo "no pr-assets-$PR branch to delete" diff --git a/.github/workflows/supply-chain-check.yml b/.github/workflows/supply-chain-check.yml index ce4fbc3930..d99add4197 100644 --- a/.github/workflows/supply-chain-check.yml +++ b/.github/workflows/supply-chain-check.yml @@ -37,9 +37,11 @@ jobs: run: pnpm install --frozen-lockfile - name: Resolve base package.json + env: + BASE_REF: ${{ github.base_ref }} run: | - git fetch origin "${{ github.base_ref }}" - git show "origin/${{ github.base_ref }}:package.json" > /tmp/base-package.json + git fetch origin "$BASE_REF" + git show "origin/$BASE_REF:package.json" > /tmp/base-package.json - name: Check min release age run: node scripts/check-min-release-age.mjs --base /tmp/base-package.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 14851be058..67c1fee8ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,10 +6,15 @@ on: # concurrency groups, so neither cancelled the other). # content-publish-automerge's merge-on-green requires "at least one and # ALL green" ci-success check runs per SHA, so one run per SHA satisfies it. + # feat/design-system is in both lists for the DS release train: PRs based + # on it need the suite (and ds-shots), and pushes to it write the ds-shots + # baseline a based PR can restore — Actions cache scoping only lets a PR + # read caches from its own ref, its base branch, and the default branch. + # Remove it from all three allowlists once the DS branch merges to dev. push: - branches: [main, dev] + branches: [main, dev, feat/design-system] pull_request: - branches: [main, dev, develop] + branches: [main, dev, develop, feat/design-system] workflow_dispatch: # Cancel in-progress runs on the same PR / branch when a newer commit lands. @@ -27,7 +32,7 @@ jobs: # Prettier formatting + content-link validation. Fast, no node_modules # needed beyond what setup-node cache restores. # (Renamed from "lint" — the job never actually ran a linter; ESLint now - # lives in its own advisory job below.) + # lives in its own blocking job below.) format: runs-on: ubuntu-latest steps: @@ -60,9 +65,32 @@ jobs: - name: Validate sitemap, footer, and blog links run: pnpm validate-links - # Blocking ESLint pass. In ci-success.needs, so a red eslint job blocks the - # PR. `pnpm lint` fails on errors only — the remaining warnings do not gate. - # Ratchet to a warn floor (`--max-warnings`) once that count is down. + # DS debt ratchet (DS 10, TASK-21450). Runs scripts/ds-lint-counts.mjs + # --check against the committed baseline: debt counts (raw hex, inline + # styles, stock text sizes, non-DS classes in views, useSearchParams files) + # may only go down. A PR that lowers a count should also tighten the + # baseline (--write-baseline) so the gain locks in. Pure-node script — no + # pnpm install needed. + ds-lint: + runs-on: ubuntu-latest + steps: + # no submodules/token: the count script only reads src/**/*.ts(x) + # and the only submodule (src/content) holds none. no git ops after + # checkout either, so don't persist credentials. + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: DS lint-count ratchet + run: node scripts/ds-lint-counts.mjs --check + + # Blocking ESLint pass (DS 10, TASK-21450). Errors reached zero on + # feat/design-system, so this gates the PR via ci-success.needs. Warnings + # do not fail the job; add a warn floor only if the team decides one. eslint: runs-on: ubuntu-latest steps: @@ -112,7 +140,51 @@ jobs: - name: Typecheck run: pnpm typecheck + # Isolated on purpose. The BE's living fixture lives in peanut-api-ts, so + # fetching it needs a cross-repo secret — and a secret must never share a + # job with PR-authored code, which can plant a GITHUB_PATH wrapper during + # postinstall and read it out of the environment. This job checks nothing + # out, installs nothing, runs no repo code, and hands the result to `unit` + # as data. + # + # Non-blocking by design: no credential in this repo is currently scoped to + # read peanut-api-ts (MONO_READ_TOKEN is mono-only — it 404s here), so the + # fetch warns and produces no artifact, and the drift checks in + # render-snapshot skip. Provision a contents:read token for peanut-api-ts + # and the check goes live with no other change. + be-baseline: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Fetch the BE render-snapshot baseline + id: fetch + env: + GH_TOKEN: ${{ secrets.MONO_READ_TOKEN }} + BE_REF: ${{ github.base_ref || 'dev' }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "::warning::No token for the BE baseline fetch — baseline-vs-BE drift checks will skip." + exit 0 + fi + if ! gh api "repos/peanutprotocol/peanut-api-ts/contents/test/integration/__fixtures__/history-real-staging.jsonl?ref=${BE_REF}" \ + --jq '.content' > /tmp/be-baseline.b64; then + echo "::warning::Could not read the BE fixture at ${BE_REF} — the token is not scoped to peanut-api-ts. Drift checks will skip." + exit 0 + fi + base64 -d < /tmp/be-baseline.b64 > /tmp/be-baseline.jsonl + test -s /tmp/be-baseline.jsonl + echo 'fetched=true' >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v4 + if: steps.fetch.outputs.fetched == 'true' + with: + name: be-baseline + path: /tmp/be-baseline.jsonl + retention-days: 1 + unit: + needs: be-baseline runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -132,20 +204,25 @@ jobs: - name: Generate Next types run: pnpm next typegen - # Fetch the canonical 49-entry HistoryEntry baseline from the BE - # repo. Living source-of-truth = peanut-api-ts/test/integration/ - # __fixtures__/history-real-staging.jsonl. We don't vendor a copy - # in this repo because it bloats every FE diff by ~3.7K lines. - # PR base branch tracks the BE branch the FE is coordinated with - # (dev when targeting dev; the paired BE PR's HEAD when in flight). - - name: Fetch BE render-snapshot baseline - env: - GH_TOKEN: ${{ secrets.PEANUT_ALL_READ_TOKEN }} + # The BE fixture arrives as an ARTIFACT from the isolated be-baseline + # job. Nothing here ever sees a credential: this job has already run + # PR-controlled checkout, postinstall and typegen, so a secret in + # scope could be lifted by a GITHUB_PATH wrapper. + - name: Download the BE render-snapshot baseline + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: be-baseline + path: /tmp/be-baseline + + - name: Import the BE baseline run: | - BE_REF="${{ github.base_ref || 'dev' }}" - gh api "repos/peanutprotocol/peanut-api-ts/contents/test/integration/__fixtures__/history-real-staging.jsonl?ref=${BE_REF}" \ - --jq '.content' | base64 -d > /tmp/be-baseline.jsonl - node scripts/import-be-baseline.mjs /tmp/be-baseline.jsonl \ + set -euo pipefail + if [ ! -s /tmp/be-baseline/be-baseline.jsonl ]; then + echo 'No BE baseline artifact — the drift checks will skip.' + exit 0 + fi + node scripts/import-be-baseline.mjs /tmp/be-baseline/be-baseline.jsonl \ src/components/TransactionDetails/__tests__/fixtures/be-entries.json - name: Run unit tests @@ -176,22 +253,99 @@ jobs: path: test-results/junit.xml retention-days: 7 - # Playwright e2e — advisory until TEST_HARNESS_SECRET + harness API - # reachability are wired in repo settings (per the prior comment). - e2e: + # Can this PR move a pixel? Answered from the PR file list over the API, so + # a docs-only, workflow-only or test-only PR skips the capture with no + # checkout and no install. + ds-shots-filter: + name: ds-shots-filter + # A fork PR gets a read-only GITHUB_TOKEN and no SUBMODULE_TOKEN, so + # neither the submodule checkout nor the comment can work. Skip it. + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest - # Without a cap a stalled step runs to the 6h default. "Install - # Playwright browsers" hung three times on 2026-08-18 (~30min each) - # while sibling runs cleared it in under two minutes. An unbounded hang - # is worse than a failure here: `ci-success` never reports, and - # content-publish-automerge merges on all-green-of-N, so N stays 0 and - # a content publish stalls with no error anywhere. Fail fast instead. - timeout-minutes: 20 + # Reads the PR file list and nothing else. + permissions: + contents: read + pull-requests: read + outputs: + render: ${{ steps.check.outputs.render }} + steps: + - name: Look for a file that changes what a screen renders + id: check + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # Everything a fixture screen is built from. src/ is most of + # it; the config files decide how src/ compiles, and the lock + # file decides which version of every dependency renders. + # e2e/flows/ and the regression config are here because the + # behaviour specs run inside ds-shots — without them a PR + # touching only those files would skip the job that runs them. + KEEP='^(src/|e2e/(shots|flows)/|scripts/visual-(diff|comment)\.mjs$|playwright\.(shots|regression)\.config\.ts$|(tailwind|postcss|next)\.config\.|package\.json$|pnpm-lock\.yaml$|\.github/workflows/tests\.yml$)' + # Inside the set above but never on camera. src/content is the + # blog and SEO submodule, and no fixture renders those pages. + DROP='(^src/content(/|$)|/__tests__/|/__mocks__/|\.test\.tsx?$|\.md$)' + + FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[].filename') + HITS=$(printf '%s\n' "$FILES" | grep -E "$KEEP" | grep -vE "$DROP" || true) + + if [ -z "$HITS" ]; then + echo 'render=false' >> "$GITHUB_OUTPUT" + echo 'Nothing here can move a pixel. Skipping the visual diff.' + exit 0 + fi + echo 'render=true' >> "$GITHUB_OUTPUT" + echo 'Files that can move a pixel:' + printf '%s\n' "$HITS" + + # Visual diff over src/dev/fixtures/registry.ts — 30 fixtures at four phone + # widths, 120 PNGs. No API, no database, no provider key and no secret: a + # fixture answers every request the app makes. + # + # ADVISORY. It is deliberately NOT in `ci-success.needs`, so it can never + # block a merge. It is a review aid. + # + # Two roles, one set of steps, because the capture half is the same work: + # • push to dev, main or feat/design-system (the DS release train) → + # capture that commit and cache it as a baseline. + # The cache has to be written from the base branch: an entry written by + # a PR run is scoped to that PR, and no sibling PR can read it. Written + # from dev, ten PRs on that base restore it instead of building it. + # • pull request → capture the head, diff it against that baseline, and + # keep one comment up to date. + ds-shots: + name: ds-shots + needs: [ds-shots-filter] + # `always()` because ds-shots-filter never runs on a push, and a skipped + # `needs` would otherwise skip this job too. + if: >- + always() && + ((github.event_name == 'push' && contains(fromJSON('["dev","main","feat/design-system"]'), github.ref_name)) + || needs.ds-shots-filter.outputs.render == 'true') + runs-on: ubuntu-latest + # Build ~4m + capture ~2m on a runner, plus install. A hang past this is + # a bug, not a slow day. + timeout-minutes: 30 + # Read-only on purpose. This job checks out the PR and runs its + # install, build and test code, and a later step is not an isolation + # boundary: a postinstall can plant a GITHUB_PATH or GITHUB_ENV hook + # that fires in any step after it. Nothing here may hold a credential + # that can write to the repo, so the diff travels as an artifact and a + # job summary instead of a pushed branch and a PR comment. + permissions: + contents: read steps: - uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.SUBMODULE_TOKEN }} + # The asset push below builds its own remote URL, so the + # submodule PAT does not need to stay in .git/config. + persist-credentials: false - uses: pnpm/action-setup@v4 @@ -202,9 +356,10 @@ jobs: - run: pnpm install --frozen-lockfile - # Cache the browser binaries — the download is the step that hangs, - # so the cheapest fix is to not do it on most runs. Keyed on the - # Playwright version, because browser builds are pinned to it. + # Browser cache, moved here verbatim from the deleted `e2e` job. The + # download is the step that hangs, so the cheapest fix is to not do + # it on most runs. Keyed on the Playwright version, because browser + # builds are pinned to it. - name: Resolve Playwright version id: pw run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" @@ -255,26 +410,141 @@ jobs: echo "::error::playwright install-deps failed after 3 attempts" exit 1 - - name: Run E2E tests - run: pnpm test:e2e + - name: Build + env: + # Without this DEV_TOOLS_ENABLED is false, ?__fixture= is + # ignored, and every screen redirects to /setup. It is a + # NEXT_PUBLIC_ value, so it must be set on the build, not on + # the capture — Next inlines it into the bundle here. + NEXT_PUBLIC_VERCEL_ENV: preview + # BASE_URL falls back to https://peanut.me when unset, which + # flips the dev layout's prod gate and notFound()s every + # /dev page the regression specs drive. Locally .env sets + # it; CI has no .env, so set it here. + NEXT_PUBLIC_BASE_URL: http://127.0.0.1:3080 + run: pnpm build + + # The capture fails a shot that still has a spinner on camera, on + # purpose. No continue-on-error: a red step is how anyone finds out. + - name: Capture screenshots + env: + SHOTS_OUT: e2e/__shots__/${{ github.event_name == 'push' && 'base' || 'head' }} + run: pnpm test:visual:capture + + - name: Save this commit as the baseline + if: github.event_name == 'push' + # A re-run of the same commit finds the key taken. Losing that + # race costs nothing — the baseline is already there. continue-on-error: true + uses: actions/cache/save@v4 + with: + path: e2e/__shots__/base + key: ds-shots-${{ github.sha }} - - name: Upload Playwright report - if: always() + - name: Restore the baseline capture + if: github.event_name == 'pull_request' + id: baseline + uses: actions/cache/restore@v4 + with: + path: e2e/__shots__/base + key: ds-shots-${{ github.event.pull_request.base.sha }} + # If the exact base commit is not cached — it landed minutes + # ago, or the entry was evicted — take the newest baseline + # there is. The comment names the commit it actually used. + restore-keys: ds-shots- + + - name: Diff against the baseline + if: github.event_name == 'pull_request' + id: diff + run: | + set -euo pipefail + MATCHED='${{ steps.baseline.outputs.cache-matched-key }}' + if [ -z "$MATCHED" ]; then + echo 'ready=false' >> "$GITHUB_OUTPUT" + echo 'No baseline is cached yet.' + exit 0 + fi + echo "ready=true" >> "$GITHUB_OUTPUT" + echo "baseline=${MATCHED#ds-shots-}" >> "$GITHUB_OUTPUT" + node scripts/visual-diff.mjs e2e/__shots__/base e2e/__shots__/head \ + --out=e2e/__shots__/diff --json > e2e/__shots__/report.json + + # The listing runs PR-authored code (scripts/visual-comment.mjs is + # itself pixel-moving, so a PR may edit it) — deliberately no token + # in this step's env for it to read. + - name: List the changed screenshots + if: steps.diff.outputs.ready == 'true' + id: wanted + run: | + set -euo pipefail + node scripts/visual-comment.mjs e2e/__shots__/report.json --list > e2e/__shots__/wanted.txt + if [ -s e2e/__shots__/wanted.txt ]; then + echo 'any=true' >> "$GITHUB_OUTPUT" + else + echo 'any=false' >> "$GITHUB_OUTPUT" + echo 'Nothing changed, so nothing to publish.' + fi + + # The PNGs ride out as an artifact. Publishing them to a branch + # needed contents:write in this job, which is exactly what a PR's + # own build code must never be able to reach. + - name: Upload the changed screenshots + if: steps.diff.outputs.ready == 'true' && steps.wanted.outputs.any == 'true' + uses: actions/upload-artifact@v4 + with: + name: visual-diff-${{ github.event.pull_request.number }} + path: | + e2e/__shots__/base + e2e/__shots__/head + e2e/__shots__/diff + retention-days: 7 + # Rendered into the job summary, which needs no permissions. The + # script omits the tags when --assets is empty, so this is a + # text report plus the artifact above. + - name: Render the visual diff summary + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + if [ '${{ steps.diff.outputs.ready }}' != 'true' ]; then + { + echo '## 🖼 Visual diff — no baseline yet' + echo + echo "Nothing is cached for \`${BASE_REF}\`. The next push to that" + echo 'branch captures one, and this fills in on your next push here.' + } > e2e/__shots__/body.md + else + node scripts/visual-comment.mjs e2e/__shots__/report.json \ + --baseline='${{ steps.diff.outputs.baseline }}' \ + --head='${{ github.event.pull_request.head.sha }}' > e2e/__shots__/body.md + echo >> e2e/__shots__/body.md + echo 'Images are in the **visual-diff** artifact on this run.' >> e2e/__shots__/body.md + fi + cat e2e/__shots__/body.md >> "$GITHUB_STEP_SUMMARY" + + - name: Run regression specs + run: pnpm test:e2e:regression + + # The page snapshot in error-context.md is the only way to see + # WHAT rendered on the runner when a spec dies in CI but passes + # locally. Without this upload the failure is undebuggable. + - name: Upload regression artifacts on failure + if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report - path: playwright-report/ + name: regression-results-${{ github.run_number }} + path: e2e/__results__/ retention-days: 7 # Coverage + test-summary aggregator. Mirrors the pattern from # peanut-api-ts/.github/workflows/tests.yaml `report` job. report: runs-on: ubuntu-latest - # Wait for format + typecheck + e2e too — the report header says - # "all green", which would lie if only unit results were - # considered (per CR feedback on PR #1908). - needs: [format, typecheck, unit, e2e] + # Wait for format + typecheck too — the report header says "all + # green", which would lie if only unit results were considered + # (per CR feedback on PR #1908). + needs: [format, typecheck, unit] if: always() permissions: contents: read @@ -548,7 +818,7 @@ jobs: ci-success: name: ci-success if: always() - needs: [format, eslint, typecheck, unit, e2e, report, human-authors] + needs: [format, ds-lint, eslint, typecheck, unit, report, human-authors] runs-on: ubuntu-latest steps: - name: Verify all required jobs passed @@ -557,10 +827,10 @@ jobs: echo "::error::One or more required jobs failed or were cancelled" echo "Job results:" echo " format: ${{ needs.format.result }}" + echo " ds-lint: ${{ needs['ds-lint'].result }}" echo " eslint: ${{ needs.eslint.result }}" echo " typecheck: ${{ needs.typecheck.result }}" echo " unit: ${{ needs.unit.result }}" - echo " e2e: ${{ needs.e2e.result }}" echo " report: ${{ needs.report.result }}" echo " human-authors: ${{ needs['human-authors'].result }}" exit 1 diff --git a/.gitignore b/.gitignore index 70d2ea2ed6..4884460cf1 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,8 @@ next-env.d.ts # local files playground/ +# ...except the design-system doc-site Playground route, which is a real tracked route +!src/app/(mobile-ui)/dev/ds/playground/ # yarn and npm lockfiles package-lock.json @@ -93,6 +95,7 @@ e2e/__results__/ e2e/__baseline__/ e2e/__report__/ e2e/__snapshots__/ +e2e/__shots__/ e2e/.auth/ # native app signing diff --git a/.prettierignore b/.prettierignore index ad8cbf1437..31be51e624 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,5 +17,9 @@ src/components/TransactionDetails/__tests__/fixtures/render-baseline.json src/types/api.openapi.json src/types/api.generated.ts +# generated from globals.css @theme via `pnpm gen:ds-tokens` — stable +# JSON.stringify formatting so a prettier bump can't flip the drift gate. +src/app/(mobile-ui)/dev/ds/foundations/tokens.generated.ts + # static single-file quiz (hand-tuned inline CSS/JS; not prettier-formatted) public/onboarding-quiz/ diff --git a/docs/TESTING.md b/docs/TESTING.md index c6b6ab8a6c..87ec314e78 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -6,8 +6,14 @@ Full testing strategy (pyramid, CI/CD, post-release monitoring) lives in the mon ## Quick commands (this repo) ```bash -npm test # Jest unit + component (710+ tests, ~25s) -npx playwright test --project=mobile # E2E smoke (49 tests, ~8 min) -npx tsx e2e/scripts/generate-report.ts --save-baseline # Visual regression baseline -npx tsx e2e/scripts/generate-report.ts # Compare against baseline +npm test # Jest unit + component (~30s) +NEXT_PUBLIC_VERCEL_ENV=preview npm run build # both Playwright configs need this build +npm run test:e2e:regression # behaviour specs in e2e/flows (~1 min) +npm run test:visual:capture # one PNG per fixture per width +npm run test:visual:diff # compare two capture directories ``` + +Browse the fixtures at `/dev/fixtures`, or open one with `?__fixture=`. + +Anything that needs a real backend, provider or chain goes to the Nutcracker +harness in mono (`engineering/qa`), not to Playwright here. diff --git a/e2e/flows/add-money.spec.ts b/e2e/flows/add-money.spec.ts deleted file mode 100644 index 16c490f3d8..0000000000 --- a/e2e/flows/add-money.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Add money (onramp) flow. - * - * Uses 'verified-ar' persona for AR tests and 'verified-us' for US tests - * so country-specific forms render instead of "Country not found" errors. - * - * Exercises: - * - OnrampFlowContext (flow context being consolidated) - * - AddMoneyBankDetails (pain #23 UI, Bridge fee bug site) - * - ExchangeRate component (Bridge fee display) - * - Country-specific onramp paths (AR/Manteca, EUR/Bridge, USD/Bridge) - * - MUI usage in some components (being killed) - * - * Captures entry + country selection without real payment. - */ - -import { test, expect, devices } from '@playwright/test' -import { captureStep, collectConsoleLogs } from '../utils/capture' -import { installApiMocks } from '../utils/mock-api' -import { usePersona } from '../utils/personas' - -test.describe('Add money flow', () => { - test('add-money landing', async ({ page }, testInfo) => { - const consoleLogs = collectConsoleLogs(page) - - await page.goto('/add-money') - await captureStep(page, testInfo, { name: '01-add-money-landing' }) - - await page.waitForTimeout(2000) - await captureStep(page, testInfo, { name: '02-add-money-loaded' }) - - consoleLogs.flush(testInfo, 'add-money') - }) - - test('add-money/AR/bank — Argentina bank onramp (verified-ar)', async ({ browser }, testInfo) => { - const context = await browser.newContext({ ...devices['Pixel 7'] }) - const persona = await usePersona(context, 'verified-ar') - - const page = await context.newPage() - const consoleLogs = collectConsoleLogs(page) - await installApiMocks(page) - - await page.goto('/add-money/AR/bank') - await captureStep(page, testInfo, { name: '01-add-money-ar-bank-initial' }) - - await page.waitForTimeout(3000) - await captureStep(page, testInfo, { name: '02-add-money-ar-bank-loaded' }) - - if (persona) { - testInfo.annotations.push({ - type: 'persona', - description: `verified-ar (${persona.userId})`, - }) - } - - consoleLogs.flush(testInfo, 'add-money-ar-bank') - await context.close() - }) - - test('add-money/US/bank — US bank onramp (verified-us)', async ({ browser }, testInfo) => { - const context = await browser.newContext({ ...devices['Pixel 7'] }) - const persona = await usePersona(context, 'verified-us') - - const page = await context.newPage() - const consoleLogs = collectConsoleLogs(page) - await installApiMocks(page) - - await page.goto('/add-money/US/bank') - await captureStep(page, testInfo, { name: '01-add-money-us-bank-initial' }) - - await page.waitForTimeout(3000) - await captureStep(page, testInfo, { name: '02-add-money-us-bank-loaded' }) - - if (persona) { - testInfo.annotations.push({ - type: 'persona', - description: `verified-us (${persona.userId})`, - }) - } - - consoleLogs.flush(testInfo, 'add-money-us-bank') - await context.close() - }) - - // Regression: the NavHeader back button must LEAVE the amount screen on the first tap. - // Before fix/addmoney-back-nuqs-replace the flow used nuqs { history: 'push' }, so every - // amount keystroke stacked a same-screen history entry and useSafeBack's router.back() - // only stepped through stale amounts — the back button looked dead (MP/bank reports). - test('add-money/AR/bank — back button leaves the amount screen after typing (verified-ar)', async ({ - browser, - }, testInfo) => { - const context = await browser.newContext({ ...devices['Pixel 7'] }) - await usePersona(context, 'verified-ar') - - const page = await context.newPage() - const consoleLogs = collectConsoleLogs(page) - await installApiMocks(page) - - await page.goto('/add-money/AR/bank') - - // amount step renders (verified persona skips the "country not found" gate) - const amountInput = page.locator('input[inputmode="decimal"]').first() - await amountInput.waitFor({ state: 'visible', timeout: 15000 }) - - // typing writes ?amount= — with the old { history: 'push' } this stacked back-stack - // entries; with the default 'replace' it does not. - await amountInput.fill('100') - await captureStep(page, testInfo, { name: '01-add-money-ar-bank-amount-typed' }) - - // one tap must exit to the country page, not linger on /bank with a stale amount - await page.locator('[data-testid="nav-back"]').first().click() - await expect(page).toHaveURL(/\/add-money\/AR(?:\?.*)?$/) - await captureStep(page, testInfo, { name: '02-add-money-ar-bank-back-left-screen' }) - - consoleLogs.flush(testInfo, 'add-money-ar-bank-back') - await context.close() - }) - - // Same regression, the originally-reported flow: Manteca (MP) AR deposit at - // /add-money/argentina/manteca. Both add-money amount screens shared the - // { history: 'push' } bug; this covers the reported variant directly. - test('add-money/argentina/manteca — back button leaves the amount screen after typing (verified-ar)', async ({ - browser, - }, testInfo) => { - const context = await browser.newContext({ ...devices['Pixel 7'] }) - await usePersona(context, 'verified-ar') - - const page = await context.newPage() - const consoleLogs = collectConsoleLogs(page) - await installApiMocks(page) - - await page.goto('/add-money/argentina/manteca') - - // amount step renders (verified persona; currency rate is mocked) - const amountInput = page.locator('input[inputmode="decimal"]').first() - await amountInput.waitFor({ state: 'visible', timeout: 15000 }) - - await amountInput.fill('100') - await captureStep(page, testInfo, { name: '01-add-money-manteca-amount-typed' }) - - // one tap must exit to the country page, not linger on /manteca with a stale amount - await page.locator('[data-testid="nav-back"]').first().click() - await expect(page).toHaveURL(/\/add-money\/argentina(?:\?.*)?$/) - await captureStep(page, testInfo, { name: '02-add-money-manteca-back-left-screen' }) - - consoleLogs.flush(testInfo, 'add-money-manteca-back') - await context.close() - }) -}) diff --git a/e2e/flows/claim-flow.spec.ts b/e2e/flows/claim-flow.spec.ts deleted file mode 100644 index 42b307d883..0000000000 --- a/e2e/flows/claim-flow.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Claim flow — regression coverage using route interception. - * - * Uses page.route() to intercept the Peanut API send-links endpoint, - * returning mock link data. The pubKey is echoed from the request URL - * so the SDK's generateKeysFromString crypto check passes. - */ - -import { test } from '@playwright/test' -import { devices } from '@playwright/test' -import { captureStep, collectConsoleLogs } from '../utils/capture' -import { dismissModals } from '../utils/dismiss-modals' -import { interceptSendLinks } from '../utils/mock-api' - -const CLAIM_URL = '/claim?c=42161&v=v4.3&i=0&p=testpassword123&t=ui' - -test.describe('Claim flow (mocked)', () => { - test('claim page shows amount and claim UI', async ({ browser }, testInfo) => { - const context = await browser.newContext({ ...devices['Pixel 7'] }) - const page = await context.newPage() - const consoleLogs = collectConsoleLogs(page) - - await interceptSendLinks(page, { - status: 'completed', - amount: '1000000', - tokenSymbol: 'USDC', - }) - - await page.goto(CLAIM_URL) - await dismissModals(page) - await captureStep(page, testInfo, { name: '01-claim-landing' }) - - await page.waitForTimeout(4000) - await dismissModals(page) - await captureStep(page, testInfo, { name: '02-claim-loaded' }) - - const amountDisplay = page.locator('[data-test="claim-amount"], [class*="amount"], text=/\\$|USD|USDC/i') - if ( - await amountDisplay - .first() - .isVisible({ timeout: 5000 }) - .catch(() => false) - ) { - await captureStep(page, testInfo, { name: '03-claim-amount-visible' }) - } - - const claimButton = page.locator( - 'button:has-text("Claim"), button:has-text("Receive"), button:has-text("Accept")' - ) - if ( - await claimButton - .first() - .isVisible({ timeout: 3000 }) - .catch(() => false) - ) { - await captureStep(page, testInfo, { name: '04-claim-button-visible' }) - } - - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) - await captureStep(page, testInfo, { name: '05-claim-scrolled' }) - - consoleLogs.flush(testInfo, 'claim-amount-ui') - await context.close() - }) - - test('already-claimed link shows claimed state', async ({ browser }, testInfo) => { - const context = await browser.newContext({ ...devices['Pixel 7'] }) - const page = await context.newPage() - const consoleLogs = collectConsoleLogs(page) - - await interceptSendLinks(page, { - status: 'CLAIMED', - claim: { - txHash: '0xmocktxhash', - claimerAddress: '0x1234567890abcdef1234567890abcdef12345678', - }, - }) - - await page.goto(CLAIM_URL) - await dismissModals(page) - await captureStep(page, testInfo, { name: '01-claimed-landing' }) - - await page.waitForTimeout(3000) - await dismissModals(page) - await captureStep(page, testInfo, { name: '02-claimed-loaded' }) - - const claimedIndicator = page.locator('text=/claimed|completed|already|expired/i, [data-test="claimed-status"]') - if ( - await claimedIndicator - .first() - .isVisible({ timeout: 5000 }) - .catch(() => false) - ) { - await captureStep(page, testInfo, { name: '03-claimed-indicator-visible' }) - } - - consoleLogs.flush(testInfo, 'claim-already-claimed') - await context.close() - }) -}) diff --git a/e2e/flows/claim.spec.ts b/e2e/flows/claim.spec.ts deleted file mode 100644 index ece977dabb..0000000000 --- a/e2e/flows/claim.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Claim flow — claiming a send link. - * - * Exercises: - * - Claim/Link/Initial.view.tsx (pain #5, CC 211) - * - BankFlowManager.view.tsx (pain #21) - * - Claim.tsx (pain #22) - * - TransactionDetailsReceipt (pain #3, 3 receipt shapes) - * - * We need a real send link pubKey to test. Since we're using the test harness, - * we rely on the API seeding a link via the state factories. - * For now, test the unauthenticated claim landing state. - */ - -import { test, expect } from '@playwright/test' -import { captureStep, collectConsoleLogs } from '../utils/capture' - -test.describe('Claim flow', () => { - test('claim page without pubKey — error state', async ({ page }, testInfo) => { - const consoleLogs = collectConsoleLogs(page) - - await page.goto('/claim') - await captureStep(page, testInfo, { name: '01-claim-no-pubkey' }) - - await page.waitForTimeout(2000) - await captureStep(page, testInfo, { name: '02-claim-no-pubkey-settled' }) - - consoleLogs.flush(testInfo, 'claim-no-pubkey') - }) - - test('claim page with invalid pubKey', async ({ page }, testInfo) => { - const consoleLogs = collectConsoleLogs(page) - - await page.goto('/claim?pubKey=0xinvalid') - await captureStep(page, testInfo, { name: '01-claim-invalid-pubkey' }) - - await page.waitForTimeout(3000) - await captureStep(page, testInfo, { name: '02-claim-invalid-pubkey-settled' }) - - consoleLogs.flush(testInfo, 'claim-invalid') - }) -}) diff --git a/e2e/flows/dev-showcase.spec.ts b/e2e/flows/dev-showcase.spec.ts index debf4d74f9..d08b98444d 100644 --- a/e2e/flows/dev-showcase.spec.ts +++ b/e2e/flows/dev-showcase.spec.ts @@ -1,44 +1,80 @@ /** - * Dev routes — design system showcase + component gallery. + * Dev routes — the /dev/ds design-system showcase. * * These are the CRITICAL snapshots for M2. When we kill MUI, flow contexts, * or Redux, the design system showcase should still render identically. * - * The dev showcase renders every Bruddle primitive + Global component with - * every variant — the canonical regression target. + * The /dev/ds doc-site pages render every Bruddle primitive + Global + * component with every variant — the canonical regression target. The sweep + * below walks every doc page from the showcase's own nav config, so new + * pages join the regression net automatically. */ -import { test } from '@playwright/test' -import { captureStep, collectConsoleLogs } from '../utils/capture' +import { expect, test } from '@playwright/test' +import { captureStep, collectConsoleLogs, getConsoleErrors } from '../utils/capture' import { dismissModals } from '../utils/dismiss-modals' +// type-only IconName import inside nav-config is erased at runtime, so this is safe to import here +import { SIDEBAR_CONFIG, TIERS } from '../../src/app/(mobile-ui)/dev/ds/_components/nav-config' -test.describe('Dev showcase (design system)', () => { - test('/dev/components landing', async ({ page }, testInfo) => { - const c = collectConsoleLogs(page) - await page.goto('/dev/components', { waitUntil: 'domcontentloaded' }) - await dismissModals(page) - await captureStep(page, testInfo, { name: '01-dev-components' }) - await page.waitForTimeout(2000) - await captureStep(page, testInfo, { name: '02-dev-components-settled' }) - c.flush(testInfo, 'dev-components') - }) +// tier indexes + every doc page; playground sub-items live under /dev (standalone harnesses), not /dev/ds +const DS_DOC_ROUTES = [...TIERS, ...Object.values(SIDEBAR_CONFIG).flat()] + .map((item) => item.href) + .filter((href) => href.startsWith('/dev/ds')) + +// known sandbox noise the showcase cannot control (F-28: the sweep used to +// collect console errors and assert nothing — a page could throw on every +// render and stay green). Everything else that logs console.error fails. +const SANDBOX_ERROR_ALLOW = [ + /429/, // public RPC rate limits in the sandbox + /ERR_NETWORK|Failed to fetch|NetworkError|net::ERR/i, // providers absent in sandbox + /favicon/i, + // the regression harness serves a static `next start` with no backend: + // service-worker registration fails there, and API-bound resource loads + // come back 401/403. both are environment, not the showcase. + /SW registration failed/, + /the server responded with a status of 40[13]/, + // public RPCs refuse CORS from the harness's 127.0.0.1 origin — external + // target + local origin only, so an app-own CORS bug still fails. + /Access to fetch at 'https:\/\/[^']+' from origin 'http:\/\/127\.0\.0\.1/, +] +const assertNoConsoleErrors = (entries: Array<{ type: string; text: string }>, where: string) => { + const errors = getConsoleErrors(entries).filter((e) => !SANDBOX_ERROR_ALLOW.some((p) => p.test(e.text))) + expect(errors, `${where}: unexpected console errors\n${errors.map((e) => e.text).join('\n')}`).toEqual([]) +} +test.describe('Dev showcase (design system)', () => { test('/dev — root dev page', async ({ page }, testInfo) => { const c = collectConsoleLogs(page) - await page.goto('/dev', { waitUntil: 'domcontentloaded' }) + const res = await page.goto('/dev', { waitUntil: 'domcontentloaded' }) + expect(res?.ok(), '/dev responded non-2xx').toBeTruthy() await dismissModals(page) await captureStep(page, testInfo, { name: '01-dev-root' }) c.flush(testInfo, 'dev-root') + assertNoConsoleErrors(c.entries, '/dev') }) test('/dev/ds — design system root', async ({ page }, testInfo) => { const c = collectConsoleLogs(page) - const res = await page.goto('/dev/ds', { waitUntil: 'domcontentloaded' }).catch(() => null) - if (!res) return // not all repos have this route + const res = await page.goto('/dev/ds', { waitUntil: 'domcontentloaded' }) + expect(res?.ok(), '/dev/ds responded non-2xx').toBeTruthy() await dismissModals(page) await captureStep(page, testInfo, { name: '01-ds-root' }) await page.waitForTimeout(1500) await captureStep(page, testInfo, { name: '02-ds-root-settled' }) c.flush(testInfo, 'ds-root') + assertNoConsoleErrors(c.entries, '/dev/ds') + }) + + test('/dev/ds doc pages — full component sweep', async ({ page }, testInfo) => { + const c = collectConsoleLogs(page) + for (const route of DS_DOC_ROUTES) { + const res = await page.goto(route, { waitUntil: 'domcontentloaded' }) + expect(res?.ok(), `${route} responded non-2xx`).toBeTruthy() + await dismissModals(page) + await page.waitForTimeout(800) + await captureStep(page, testInfo, { name: route.replace('/dev/ds', 'ds').replaceAll('/', '-') }) + } + c.flush(testInfo, 'ds-doc-sweep') + assertNoConsoleErrors(c.entries, 'ds doc sweep') }) }) diff --git a/e2e/flows/home.spec.ts b/e2e/flows/home.spec.ts deleted file mode 100644 index 1c3013828d..0000000000 --- a/e2e/flows/home.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Home page flow — primary landing for authenticated users. - * - * Uses the 'with-history' persona so screenshots show realistic activity - * instead of empty new-user state. Falls back to default user if persona - * isn't available. - * - * Mocks API calls (history, metrics) because the UI defaults to - * api.peanut.me (prod) which rejects local JWT tokens. - */ - -import { test, expect, devices } from '@playwright/test' -import { captureStep, collectConsoleLogs } from '../utils/capture' -import { dismissModals } from '../utils/dismiss-modals' -import { installApiMocks } from '../utils/mock-api' -import { usePersona } from '../utils/personas' - -test.describe('Home page', () => { - test('authenticated home renders with core elements', async ({ page }, testInfo) => { - const consoleLogs = collectConsoleLogs(page) - await installApiMocks(page) - - await page.goto('/home') - await page.waitForTimeout(3000) - await dismissModals(page) - await captureStep(page, testInfo, { name: '01-home-initial' }) - - // Wait for content to load past any loading spinners - await page.waitForTimeout(5000) - await dismissModals(page) - await captureStep(page, testInfo, { name: '02-home-settled' }) - - // Verify no error states - const errorState = page.locator('text=/Error loading/i') - expect(await errorState.count()).toBe(0) - - // Verify page rendered (not stuck on loading/error) - const bodyText = await page.locator('body').innerText() - expect(bodyText).toContain('Send') - - // Scroll to see history - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) - await captureStep(page, testInfo, { name: '03-home-scrolled' }) - - consoleLogs.flush(testInfo, 'home') - }) - - test('home with history persona — shows activity', async ({ browser }, testInfo) => { - const context = await browser.newContext({ ...devices['Pixel 7'] }) - const persona = await usePersona(context, 'with-history') - - const page = await context.newPage() - const consoleLogs = collectConsoleLogs(page) - await installApiMocks(page) - - await page.goto('/home') - await page.waitForTimeout(3000) - await dismissModals(page) - await captureStep(page, testInfo, { name: '01-home-history-initial' }) - - await page.waitForTimeout(5000) - await dismissModals(page) - await captureStep(page, testInfo, { name: '02-home-history-settled' }) - - // Verify no error states - const errorState = page.locator('text=/Error loading/i') - expect(await errorState.count()).toBe(0) - - // Scroll to see history section - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) - await captureStep(page, testInfo, { name: '03-home-history-scrolled' }) - - if (persona) { - testInfo.annotations.push({ - type: 'persona', - description: `with-history (${persona.userId})`, - }) - } - - consoleLogs.flush(testInfo, 'home-history') - await context.close() - }) -}) diff --git a/e2e/flows/icon-regression.spec.ts b/e2e/flows/icon-regression.spec.ts index 2005d3b181..ceb2dc91e5 100644 --- a/e2e/flows/icon-regression.spec.ts +++ b/e2e/flows/icon-regression.spec.ts @@ -8,26 +8,37 @@ * is inline `style={{ fill: 'none' }}` in LucideWrapper which beats * class-level CSS on specificity. This spec verifies the inline style is * set on every `svg.lucide` — pure DOM assertion, no screenshots. - * 2. All Lucide icons render at `stroke-width="2"` (Lucide default, matches - * lucide.dev) — never `2.25` or any override. + * 2. Every icon carries the stroke weight its viewBox asks for. Lucide draws + * at stroke-width 2 on a 24-unit grid, and Icon.tsx crops seven icons to + * a 20-unit viewBox (VIEWBOX_BOOST), which scales the stroke down to + * 2 × 20/24. Both cases are the one rule `2 × span / 24`, so the check + * reads each icon's own viewBox instead of naming the seven. * - * No harness auth needed — purely renders dev pages. + * No API and no login. `?__fixture=home` writes the fake session the app layout + * needs to render anything — see src/dev/fixtures/active.ts. Any fixture name + * does; these pages call no API of their own. */ import { test, expect } from '@playwright/test' test.describe('Icon rendering regression', () => { - test('every icon on /dev/ds/foundations/icons has inline fill:none and stroke-width=2', async ({ page }) => { - await page.goto('/dev/ds/foundations/icons', { waitUntil: 'domcontentloaded' }) + test('every icon on /dev/ds/foundations/icons has inline fill:none and the stroke its viewBox asks for', async ({ + page, + }) => { + await page.goto('/dev/ds/foundations/icons?__fixture=home', { waitUntil: 'domcontentloaded' }) - // Dev-mode compile + client hydration can take a while on first hit. - await page.waitForSelector('svg.lucide', { timeout: 60_000 }) + // 'attached', not 'visible': the assertions below read attributes, and + // on a slow CI runner the icon grid can sit at zero size (fonts/layout + // pending) long after the SVGs are in the DOM. + await page.waitForSelector('svg.lucide', { state: 'attached', timeout: 120_000 }) const attrs = await page.$$eval('svg.lucide', (nodes) => nodes.map((n) => ({ name: n.className.baseVal.match(/lucide-[a-z0-9-]+/g)?.slice(-1)[0] ?? 'unknown', inlineFill: (n as SVGSVGElement).style.fill, - strokeWidth: n.getAttribute('stroke-width'), + strokeWidth: Number(n.getAttribute('stroke-width')), + // Third number of "minX minY width height" — the grid the icon draws on. + span: Number((n.getAttribute('viewBox') ?? '0 0 24 24').split(' ')[2]), })) ) @@ -36,17 +47,20 @@ test.describe('Icon rendering regression', () => { const badFill = attrs.filter((a) => a.inlineFill !== 'none' && a.inlineFill !== 'currentcolor') expect(badFill, `Lucide icons with unexpected inline fill: ${JSON.stringify(badFill)}`).toEqual([]) - const badStroke = attrs.filter((a) => a.strokeWidth !== '2') - expect(badStroke, `Lucide icons with non-default stroke-width: ${JSON.stringify(badStroke)}`).toEqual([]) + const badStroke = attrs.filter((a) => Math.abs(a.strokeWidth - (2 * a.span) / 24) > 0.001) + expect( + badStroke, + `Lucide icons whose stroke does not match their viewBox: ${JSON.stringify(badStroke)}` + ).toEqual([]) }) test('icons inside button elements keep fill:none (the /setup blob regression)', async ({ page }) => { // The dev icons page puts every icon in a grid card — not inside -
+
)} -

+

{mode === 'payment' ? t('invitesGraph.paymentNetwork') : t('invitesGraph.inviteNetwork')}

-
- +
+ {t('invitesGraph.nodes', { count: combinedGraphNodes.length })} {externalNodesConfig.enabled && combinedGraphNodes.filter((n: any) => n.isExternal).length > 0 && ( - + (+{combinedGraphNodes.filter((n: any) => n.isExternal).length} ext) )} - + {/* In payment mode, show P2P edges; in other modes, show invite edges */} {t('invitesGraph.edges', { count: @@ -2227,7 +2226,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { : filteredGraphData.stats.totalEdges) + externalLinks.length, })} {externalNodesConfig.enabled && externalLinks.length > 0 && ( - (+{externalLinks.length} ext) + (+{externalLinks.length} ext) )}
@@ -2238,7 +2237,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { {/* Second Row: Search (hidden in payment mode - no usernames) */} {mode !== 'payment' && ( -
+
handleSearch(e.target.value)} placeholder={t('invitesGraph.searchPlaceholder')} - className="w-full rounded-lg border border-gray-300 py-1.5 pl-9 pr-9 text-sm transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20" + className="/20 w-full rounded-lg border py-1.5 pr-9 pl-9 text-body-s transition-colors focus:ring-2 focus:outline-none" /> {searchQuery && ( )}
{searchResults.length > 0 && ( - + {searchResults.length} {searchResults.length === 1 ? 'match' : 'matches'} )}
{/* Search Results Dropdown */} {searchQuery && searchResults.length > 1 && ( -
+
{searchResults.map((node: any) => (
diff --git a/src/components/Global/Layout/index.tsx b/src/components/Global/Layout/index.tsx index 3a131dace0..a3f8c5be7d 100644 --- a/src/components/Global/Layout/index.tsx +++ b/src/components/Global/Layout/index.tsx @@ -35,14 +35,12 @@ const Layout = ({ children, className }: LayoutProps) => { } `}
-
+
{/* @dev note: temp, remove banner later */} - {/* */}
{children}
@@ -59,8 +57,7 @@ const Layout = ({ children, className }: LayoutProps) => { > { setShowModal(false) }} diff --git a/src/components/Global/PeanutLoading/CyclingLoading.tsx b/src/components/Global/Loading/CyclingLoading.tsx similarity index 84% rename from src/components/Global/PeanutLoading/CyclingLoading.tsx rename to src/components/Global/Loading/CyclingLoading.tsx index 9c5e895132..54a504d43c 100644 --- a/src/components/Global/PeanutLoading/CyclingLoading.tsx +++ b/src/components/Global/Loading/CyclingLoading.tsx @@ -37,12 +37,8 @@ export default function CyclingLoading() { {word} . - - . - - - . - + . + .
diff --git a/src/components/Global/Loading/index.tsx b/src/components/Global/Loading/index.tsx index 3ad81d2678..d94fde7240 100644 --- a/src/components/Global/Loading/index.tsx +++ b/src/components/Global/Loading/index.tsx @@ -1,14 +1,56 @@ -type LoadingProps = { - className?: string -} +import { PEANUTMAN } from '@/assets/mascot' +import Image from 'next/image' +import { twMerge } from '@/utils/tw' + +/** 'spinner' = inline border spinner (buttons, rows). 'mascot' = screen-level + * spinning peanutman (the old PeanutLoading, folded in by DS 06). the union is + * discriminated so mascot-only props don't silently no-op on the spinner. */ +type LoadingProps = + | { + variant?: 'spinner' + className?: string + coverFullScreen?: never + message?: never + } + | { + variant: 'mascot' + className?: never + /** overlay the whole screen */ + coverFullScreen?: boolean + /** caption under the mascot */ + message?: string + } -const Loading = ({ className = 'h-4 w-4' }: LoadingProps) => ( -
- Loading... -
-) +const Loading = ({ className, variant = 'spinner', coverFullScreen = false, message }: LoadingProps) => { + if (variant === 'mascot') { + return ( +
+
+
+ Peanut mascot + {/* one accessible copy: the visible caption announces when present */} + {!message && Loading...} +
+ {message &&
{message}
} +
+
+ ) + } + + return ( +
+ Loading... +
+ ) +} export default Loading diff --git a/src/components/Global/PeanutLoading/words.ts b/src/components/Global/Loading/words.ts similarity index 100% rename from src/components/Global/PeanutLoading/words.ts rename to src/components/Global/Loading/words.ts diff --git a/src/components/Global/LogoutButton/index.tsx b/src/components/Global/LogoutButton/index.tsx index 83b6300a37..df49b52721 100644 --- a/src/components/Global/LogoutButton/index.tsx +++ b/src/components/Global/LogoutButton/index.tsx @@ -22,7 +22,7 @@ const LogoutButton = () => { size="medium" variant="transparent" onClick={logout} - className="flex w-fit items-center gap-3 px-0 hover:text-gray-1 md:px-3" + className="flex w-fit items-center gap-3 px-0 hover:text-foreground-secondary md:px-3" > {isLoggingOut ? : {t('logout')}} {isLoggingOut ? t('loggingOut') : t('logout')} diff --git a/src/components/Global/MantecaDetailsCard/index.tsx b/src/components/Global/MantecaDetailsCard/index.tsx deleted file mode 100644 index 67586b940a..0000000000 --- a/src/components/Global/MantecaDetailsCard/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { type FC } from 'react' -import Card from '../Card' -import { PaymentInfoRow, type PaymentInfoRowProps } from '@/components/Payment/PaymentInfoRow' - -export interface MantecaCardRow extends PaymentInfoRowProps { - key: React.Key -} - -interface MantecaDetailsCardProps { - rows: MantecaCardRow[] -} - -const MantecaDetailsCard: FC = ({ rows }) => { - return ( - - {rows.map(({ key, ...row }) => ( - - ))} - - ) -} - -export default MantecaDetailsCard diff --git a/src/components/Global/MarqueeWrapper/index.tsx b/src/components/Global/MarqueeWrapper/index.tsx index dcc2d64ccf..271bf52f03 100644 --- a/src/components/Global/MarqueeWrapper/index.tsx +++ b/src/components/Global/MarqueeWrapper/index.tsx @@ -34,7 +34,7 @@ export function MarqueeWrapper({ ) } -const wordClass = 'text-lg font-bold uppercase md:text-xl' +const wordClass = 'text-heading-card uppercase md:text-heading-xs' // A word is either plain text or a link — the strip looks identical either way, // so a linked word still reads as part of the run of words. @@ -60,8 +60,12 @@ export function MarqueeComp({ backgroundColor?: string }) { return ( -
- +
+ {Array.isArray(message) ? message.map((msg, index) => (
diff --git a/src/components/Global/Modal/index.tsx b/src/components/Global/Modal/index.tsx index 2a83f3aab4..c2ed5dea4e 100644 --- a/src/components/Global/Modal/index.tsx +++ b/src/components/Global/Modal/index.tsx @@ -1,6 +1,6 @@ import { Dialog, DialogBackdrop, DialogPanel, Transition } from '@headlessui/react' import { Fragment, useRef } from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Icon } from '../Icons/Icon' type ModalProps = { @@ -64,7 +64,7 @@ const Modal = ({ * Wire it explicitly here, gated by `preventClose` so * destructive-confirmation modals still keep the gate. */} { if (!preventClose) onClose() }} @@ -84,9 +84,9 @@ const Modal = ({ // transform-gpu + will-change promote the panel to its own // compositor layer up front, so the scale/opacity enter tween // doesn't hitch on first-frame rasterization (Android WebView) - `relative bottom-0 z-10 mx-0 w-full max-w-[26rem] transform-gpu self-end rounded-md border-0 bg-white outline-none will-change-transform dark:bg-n-1 sm:m-auto sm:self-auto ${ + `relative bottom-0 z-10 mx-0 w-full max-w-[26rem] transform-gpu self-end rounded-md border-0 bg-white will-change-transform outline-none sm:m-auto sm:self-auto dark:bg-black ${ video - ? 'static aspect-video max-w-[64rem] overflow-hidden bg-n-1 shadow-[0_2.5rem_8rem_rgba(0,0,0,0.5)] dark:border-transparent' + ? 'static aspect-video max-w-[64rem] overflow-hidden bg-black shadow-[0_2.5rem_8rem_rgba(0,0,0,0.5)] dark:border-transparent' : '' } ${classWrap}` )} @@ -97,7 +97,7 @@ const Modal = ({ <>
{title} @@ -108,15 +108,19 @@ const Modal = ({ children )} + {/* board 17800:57256 / 17829:74079: 40px circular close + button, black border + 4px hard shadow, overlapping + the panel's top-right corner */} ) : ( diff --git a/src/components/Global/MoreInfo/index.tsx b/src/components/Global/MoreInfo/index.tsx index 3c12d4d2cf..003986be1a 100644 --- a/src/components/Global/MoreInfo/index.tsx +++ b/src/components/Global/MoreInfo/index.tsx @@ -77,9 +77,9 @@ const MoreInfo = ({ text }: MoreInfoProps) => { >
-
+
{text}
diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx index 2c3f5dfd33..3718b721bb 100644 --- a/src/components/Global/NavHeader/index.tsx +++ b/src/components/Global/NavHeader/index.tsx @@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl' import type enMessages from '@/i18n/app/messages/en.json' import { Button } from '@/components/0_Bruddle/Button' import Link from 'next/link' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Icon, type IconName } from '../Icons/Icon' import { useAuth } from '@/context/authContext' @@ -22,8 +22,18 @@ interface NavHeaderProps { icon?: IconName showLogoutBtn?: boolean titleClassName?: string + /** trailing slot (board navigation.top.trailing) — step indicators, actions */ + rightElement?: React.ReactNode + /** render no back button at all (board navigation.top.trailing.*) — + * ex-FlowHeader flows that hid the button on step 1 */ + hideBackBtn?: boolean } +// board 17802:61534 top-nav circle button: 40px visual, no shadow, pseudo-element +// extends the hit area to 44px (touch-target law — was 28px, the "opened support +// instead of going back" bug) +const navCircleBtn = 'relative size-10 w-10 p-0 shadow-none after:absolute after:-inset-0.5' + const NavHeader = ({ title, titleKey, @@ -34,16 +44,26 @@ const NavHeader = ({ disableBackBtn, showLogoutBtn = false, titleClassName, + rightElement, + hideBackBtn = false, }: NavHeaderProps) => { const { logoutUser, isLoggingOut } = useAuth() const tNav = useTranslations('navigation') + const tCommon = useTranslations('common') const label = title ?? (titleKey ? tNav(titleKey) : undefined) return ( -
- {!onPrev ? ( - -
)} + {rightElement} {showLogoutBtn && (
diff --git a/src/components/Global/NavigationArrow/index.tsx b/src/components/Global/NavigationArrow/index.tsx index 492005a2e4..c416c21f2e 100644 --- a/src/components/Global/NavigationArrow/index.tsx +++ b/src/components/Global/NavigationArrow/index.tsx @@ -1,5 +1,5 @@ import { Icon } from '@/components/Global/Icons/Icon' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' interface NavigationArrowProps { size?: number diff --git a/src/components/Global/NoMoreJailModal/index.tsx b/src/components/Global/NoMoreJailModal/index.tsx index 9c1173418e..14ee7043e5 100644 --- a/src/components/Global/NoMoreJailModal/index.tsx +++ b/src/components/Global/NoMoreJailModal/index.tsx @@ -6,8 +6,7 @@ import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' import Image from 'next/image' import PEANUT_LOGO_BLACK from '@/assets/logos/peanut-logo-dark.svg' import { PEANUTMAN } from '@/assets/mascot' -import Modal from '../Modal' -import { Button } from '@/components/0_Bruddle/Button' +import ActionModal from '@/components/Global/ActionModal' import { PeanutWhistling } from '@/assets/mascot' const NoMoreJailModal = () => { @@ -29,49 +28,45 @@ const NoMoreJailModal = () => { }, []) return ( - - {/* Main content container */} -
-
-
-
-

- {t('noMoreJailModal.title')} -

- -
-

- {t('noMoreJailModal.line1')} -
- {t('noMoreJailModal.line2')} -

+ title={t('noMoreJailModal.title')} + description={ +

+ {t('noMoreJailModal.line1')} +
+ {t('noMoreJailModal.line2')} +

+ } + ctas={[ + { + text: '', + shadowSize: '4', + variant: 'purple', + onClick: onClose, + children: ( + <> +
{t('noMoreJailModal.startUsingCta')}
+
+ Peanut Logo + Peanut Logo
-
+ + ), + }, + ]} + footer={ +
+
+ Peanut Man
- - -
-
- - {/* Peanutman animation */} -
-
- Peanut Man
-
- + } + /> ) } diff --git a/src/components/Global/OfflineScreen/index.tsx b/src/components/Global/OfflineScreen/index.tsx index 2ed8a67d88..338959f895 100644 --- a/src/components/Global/OfflineScreen/index.tsx +++ b/src/components/Global/OfflineScreen/index.tsx @@ -143,8 +143,8 @@ export default function OfflineScreen() {
-

{t('offlineScreen.title')}

-

{t('offlineScreen.description')}

+

{t('offlineScreen.title')}

+

{t('offlineScreen.description')}

- } + footer={{t('qrScanner.cameraPermission.dismiss')}} content={
-

+

{steps ? t('qrScanner.cameraPermission.withStepsHint') : t('qrScanner.cameraPermission.noStepsHint')} @@ -128,7 +125,7 @@ export default function CameraPermissionModal({ visible, onRetry, onClose, onPas className="w-full rounded-sm" placeholder="blur" /> -

{label}

+

{label}

) })} diff --git a/src/components/Global/QRScanner/index.tsx b/src/components/Global/QRScanner/index.tsx index 93fb7993ff..52b940c609 100644 --- a/src/components/Global/QRScanner/index.tsx +++ b/src/components/Global/QRScanner/index.tsx @@ -61,7 +61,7 @@ function PinkCorner({ className }: { className?: string }) { stroke="currentColor" strokeWidth="5" strokeLinecap="round" - className="text-primary-1" + className="text-action-primary" /> ) @@ -71,7 +71,7 @@ function PaymentMethodBadge({ src, alt, name }: { src: string; alt: string; name return (
{alt} - + {name}
@@ -82,18 +82,18 @@ function ScannerControls({ onClose, onToggleCamera }: { onClose: () => void; onT const t = useTranslations('global') return ( // portalled overlay escapes the layout's safe-area padding; max() keeps the old 2.5rem on web -
+
- {t('qrScanner.scanToPay')} + {t('qrScanner.scanToPay')} {detectedAddress ? ( ) : showPasteChip ? ( ) : null} @@ -232,14 +232,20 @@ function ErrorView({ const tCommon = useTranslations('common') return (
-

{message}

+

{message}

{onRetry && ( - )} -
@@ -346,7 +352,7 @@ export default function QRScanner({ onScan, onClose, isOpen = true }: QRScannerP if (!isScanning) return null return createPortal( -
+
{/* modal uses !z-[60] to appear above this z-50 scanner portal (Dialog portals to body) */} {isPermissionDenied ? ( /* @@ -381,7 +387,7 @@ export default function QRScanner({ onScan, onClose, isOpen = true }: QRScannerP {!isCameraReady && (
- {t('qrScanner.startingCamera')} + {t('qrScanner.startingCamera')}
)} diff --git a/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx b/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx index 9ae648ef3c..fb735e0dad 100644 --- a/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx +++ b/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx @@ -29,7 +29,11 @@ jest.mock('use-haptic', () => ({ useHaptic: () => ({ triggerHaptic: jest.fn() }) jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) jest.mock('@/app/actions/ens', () => ({ resolveEns: jest.fn() })) jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) -jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, openExternalUrl: jest.fn() })) +jest.mock('@/utils/capacitor', () => ({ + isCapacitor: () => false, + isAndroidNative: () => false, + openExternalUrl: jest.fn(), +})) jest.mock('@/components/0_Bruddle/Toast', () => ({ useToast: () => ({ error: jest.fn() }) })) jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: { user: { username: 'satoshi' } } }) })) jest.mock('@/context/ModalsContext', () => ({ diff --git a/src/components/Global/QRScannerOverlay/__tests__/peanut-url-routing.test.tsx b/src/components/Global/QRScannerOverlay/__tests__/peanut-url-routing.test.tsx index 55aaebe541..e5262f80a5 100644 --- a/src/components/Global/QRScannerOverlay/__tests__/peanut-url-routing.test.tsx +++ b/src/components/Global/QRScannerOverlay/__tests__/peanut-url-routing.test.tsx @@ -20,7 +20,11 @@ const mockServerFetch = jest.fn() let capturedOnScan: QRScanHandler | undefined -jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn(), openExternalUrl: jest.fn() })) +jest.mock('@/utils/capacitor', () => ({ + isCapacitor: jest.fn(), + isAndroidNative: () => false, + openExternalUrl: jest.fn(), +})) jest.mock('@/utils/api-fetch', () => ({ serverFetch: (...args: unknown[]) => mockServerFetch(...args) })) jest.mock('@/app/actions/ens', () => ({ resolveEns: jest.fn().mockResolvedValue(null) })) jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) diff --git a/src/components/Global/QRScannerOverlay/index.tsx b/src/components/Global/QRScannerOverlay/index.tsx index b21007e175..772b43fc18 100644 --- a/src/components/Global/QRScannerOverlay/index.tsx +++ b/src/components/Global/QRScannerOverlay/index.tsx @@ -44,8 +44,8 @@ function NotSupportedContent({ setModalContent, qrType }: ModalContentProps) { const t = useTranslations('global') return (
- {t('qrScannerOverlay.notSupportedWorking')} - {t('qrScannerOverlay.notSupportedGetNotified')} + {t('qrScannerOverlay.notSupportedWorking')} + {t('qrScannerOverlay.notSupportedGetNotified')} @@ -177,8 +177,8 @@ function PixRecurringContent({ setIsModalOpen }: ModalContentProps) { const t = useTranslations('global') return (
- {t('qrScannerOverlay.pixRecurringIntro')} - {t('qrScannerOverlay.pixRecurringBody')} + {t('qrScannerOverlay.pixRecurringIntro')} + {t('qrScannerOverlay.pixRecurringBody')} diff --git a/src/components/Global/RainCooldown/IntroModal.tsx b/src/components/Global/RainCooldown/IntroModal.tsx index 80aaebd2ee..725574da2b 100644 --- a/src/components/Global/RainCooldown/IntroModal.tsx +++ b/src/components/Global/RainCooldown/IntroModal.tsx @@ -23,7 +23,7 @@ const RainCooldownIntroModal = () => { visible={showIntroModal} onClose={dismissIntroModal} icon="clock" - iconContainerClassName="bg-yellow-1" + iconContainerClassName="bg-action-secondary" title={t('rainCooldownIntroModal.title')} description={t('rainCooldownIntroModal.description')} ctas={[ @@ -37,7 +37,7 @@ const RainCooldownIntroModal = () => { footer={ {t('rainCooldownIntroModal.readMore')} diff --git a/src/components/Global/RateUnavailable/RateGateScreen.tsx b/src/components/Global/RateUnavailable/RateGateScreen.tsx new file mode 100644 index 0000000000..4789f0b092 --- /dev/null +++ b/src/components/Global/RateUnavailable/RateGateScreen.tsx @@ -0,0 +1,31 @@ +'use client' + +import NavHeader from '@/components/Global/NavHeader' +import Loading from '@/components/Global/Loading' +import RateUnavailable from '@/components/Global/RateUnavailable' + +interface RateGateScreenProps { + title: string + onBack: () => void + isLoading: boolean + onRetry: () => void +} + +/** + * Full-screen FX-rate gate (dev #2843, ported): keeps the header mounted so + * back always works while the rate loads, and surfaces the retry when the + * fetch fails — a bare loader here used to spin forever with no way out + * (#1848). New component justification: this exact header+centered-gate block + * was being repeated inline in flow pages, where the page de-inlining ratchet + * counts it; one shared screen keeps pages composing recipes. + */ +const RateGateScreen = ({ title, onBack, isLoading, onRetry }: RateGateScreenProps) => ( +
+ +
+ {isLoading ? : } +
+
+) + +export default RateGateScreen diff --git a/src/components/Global/RateUnavailable/index.tsx b/src/components/Global/RateUnavailable/index.tsx index 13a8422171..e122e95ae2 100644 --- a/src/components/Global/RateUnavailable/index.tsx +++ b/src/components/Global/RateUnavailable/index.tsx @@ -1,7 +1,7 @@ 'use client' import { Button } from '@/components/0_Bruddle/Button' -import ErrorAlert from '@/components/Global/ErrorAlert' +import { Notification } from '@/components/0_Bruddle/Notification' import { useTranslations } from 'next-intl' interface RateUnavailableProps { @@ -20,7 +20,8 @@ const RateUnavailable = ({ onRetry, className }: RateUnavailableProps) => { return (
- + {/* dev shipped this on ErrorAlert; that component is retired here — Notification is the branch mapping */} + {t('rateUnavailable')} diff --git a/src/components/Global/ReConsentModal/index.tsx b/src/components/Global/ReConsentModal/index.tsx index 6310c2913b..1a073835ac 100644 --- a/src/components/Global/ReConsentModal/index.tsx +++ b/src/components/Global/ReConsentModal/index.tsx @@ -138,16 +138,16 @@ const ReConsentModal = () => { icon="info" title={t('reConsent.title')} content={ -
+
{/* The first sentence answers the question this modal actually raises * ("is something being taken from me?") before anything else. The * what-changed line describes the 2026-07-15 tos-v1 rewrite — revisit * it when a future version bump shows this modal for a different * change. "No rush" is literal: "Not now" snoozes to the effective * date (see utils.ts). */} -

{t('reConsent.reassurance')}

-

{t('reConsent.whatChanged')}

-
    +

    {t('reConsent.reassurance')}

    +

    {t('reConsent.whatChanged')}

    +
      {outdatedDocs.map((doc) => { const label = DOC_LABELS[doc.slug] ?? { name: doc.slug, href: `/${doc.slug}` } return ( @@ -159,7 +159,7 @@ const ReConsentModal = () => { ) })}
    - {error &&

    {error}

    } + {error &&

    {error}

    }
} checkbox={{ @@ -184,7 +184,7 @@ const ReConsentModal = () => { disabled: submitting, onClick: handlePostpone, // secondary de-emphasis: .btn is font-bold by default - className: 'sm:flex-none font-normal text-grey-1', + className: 'sm:flex-none font-normal text-foreground-secondary', }, ]} ctaClassName={STACKED_CTAS} diff --git a/src/components/Global/RouteExpiryTimer/index.tsx b/src/components/Global/RouteExpiryTimer/index.tsx deleted file mode 100644 index 6c4e3a2e1e..0000000000 --- a/src/components/Global/RouteExpiryTimer/index.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useTranslations } from 'next-intl' -import React, { useState, useEffect, useCallback, useMemo } from 'react' -import { twMerge } from 'tailwind-merge' - -interface RouteExpiryTimerProps { - expiry?: string // Unix timestamp in seconds - isLoading?: boolean - onNearExpiry?: () => void // Called when timer gets close to expiry (e.g., 30 seconds) - onExpired?: () => void // Called when timer expires - className?: string - nearExpiryThresholdPercentage?: number - disableRefetch?: boolean // Disable refetching when user is signing transaction - error?: string | null // Error message to display instead of timer -} - -interface TimeRemaining { - minutes: number - seconds: number - totalMs: number -} - -const RouteExpiryTimer: React.FC = ({ - expiry, - isLoading = false, - onNearExpiry, - onExpired, - className, - nearExpiryThresholdPercentage = 0.1, // 10% of total duration - disableRefetch = false, - error = null, -}) => { - const t = useTranslations('global') - const [timeRemaining, setTimeRemaining] = useState(null) - const [hasTriggeredNearExpiry, setHasTriggeredNearExpiry] = useState(false) - const [hasExpired, setHasExpired] = useState(false) - - const totalDurationMs = useMemo(() => { - if (!expiry) return 0 - const expiryMs = parseInt(expiry, 10) * 1000 - const diff = expiryMs - Date.now() - return Math.max(0, diff) - }, [expiry]) - const nearExpiryThresholdMs = useMemo(() => totalDurationMs * nearExpiryThresholdPercentage, [totalDurationMs]) - - const calculateTimeRemaining = useCallback((): TimeRemaining | null => { - if (!expiry) return null - - const now = new Date().getTime() - // Expiry is Unix timestamp in seconds, convert to milliseconds - const expiryTime = parseInt(expiry) * 1000 - - // Check if expiry time is valid - if (isNaN(expiryTime)) { - console.warn('Invalid expiry time:', expiry) - return null - } - - const diff = expiryTime - now - - if (diff <= 0) { - return { minutes: 0, seconds: 0, totalMs: 0 } - } - - const minutes = Math.floor(diff / 60000) - const seconds = Math.floor((diff % 60000) / 1000) - - return { minutes, seconds, totalMs: diff } - }, [expiry]) - - useEffect(() => { - if (!expiry || isLoading) { - setTimeRemaining(null) - setHasTriggeredNearExpiry(false) - setHasExpired(false) - return - } - - const updateTimer = () => { - const remaining = calculateTimeRemaining() - setTimeRemaining(remaining) - - if (!remaining || remaining.totalMs <= 0) { - if (!hasExpired) { - setHasExpired(true) - onExpired?.() - } - return - } - - // Trigger near expiry callback only if refetch is not disabled - if ( - !disableRefetch && - !hasTriggeredNearExpiry && - remaining.totalMs <= nearExpiryThresholdMs && - remaining.totalMs > 0 - ) { - setHasTriggeredNearExpiry(true) - onNearExpiry?.() - } - } - - // Initial calculation - updateTimer() - - // Set up interval to update every second - const interval = setInterval(updateTimer, 1000) - - return () => clearInterval(interval) - }, [ - expiry, - isLoading, - calculateTimeRemaining, - onNearExpiry, - onExpired, - nearExpiryThresholdMs, - hasTriggeredNearExpiry, - hasExpired, - disableRefetch, - ]) - - const formatTime = (time: TimeRemaining): string => { - const paddedMinutes = time.minutes.toString().padStart(2, '0') - const paddedSeconds = time.seconds.toString().padStart(2, '0') - return `${paddedMinutes}:${paddedSeconds}` - } - - const progressPercentage = useMemo((): number => { - if (!timeRemaining || !totalDurationMs) return 0 - const elapsedMs = totalDurationMs - timeRemaining.totalMs - return Math.max(0, Math.min(100, (elapsedMs / totalDurationMs) * 100)) - }, [timeRemaining, totalDurationMs]) - - const progressColor = useMemo((): string => { - if (!timeRemaining) return 'bg-grey-3' - - // Green for first 70% - if (progressPercentage < 70) return 'bg-green-500' - // Yellow for 70-85% - if (progressPercentage < 85) return 'bg-yellow-500' - // Red for final 15% - return 'bg-red' - }, [progressPercentage, timeRemaining]) - - const shouldPulse = useMemo((): boolean => { - if (isLoading) return true - if (!timeRemaining) return false - // Pulse when in red zone (85%+ progress) OR near expiry threshold - return (progressPercentage >= 85 || timeRemaining.totalMs <= nearExpiryThresholdMs) && timeRemaining.totalMs > 0 - }, [progressPercentage, timeRemaining, isLoading, nearExpiryThresholdMs]) - - const getText = (): string => { - if (error) return error - if (isLoading) return t('routeExpiryTimer.findingBestRate') - if (!expiry) return t('routeExpiryTimer.noQuote') - if (!timeRemaining) return t('routeExpiryTimer.quoteExpired') - if (timeRemaining.totalMs <= 0) return t('routeExpiryTimer.quoteExpired') - return t('routeExpiryTimer.priceLocked', { time: formatTime(timeRemaining) }) - } - - return ( -
- {/* Status text */} -
- 0) - ? 'text-grey-1' - : 'text-error' - )} - > - {getText()} - -
- - {/* Progress bar */} -
-
-
-
- ) -} - -export default RouteExpiryTimer diff --git a/src/components/Global/SecurityVerificationOverlay/index.tsx b/src/components/Global/SecurityVerificationOverlay/index.tsx index aa5cdacd73..10dc17153e 100644 --- a/src/components/Global/SecurityVerificationOverlay/index.tsx +++ b/src/components/Global/SecurityVerificationOverlay/index.tsx @@ -1,5 +1,5 @@ 'use client' -import PeanutLoading from '@/components/Global/PeanutLoading' +import Loading from '@/components/Global/Loading' import { useTranslations } from 'next-intl' import { useModalsContext } from '@/context/ModalsContext' @@ -22,12 +22,12 @@ export default function SecurityVerificationOverlay() { if (!isSecurityVerificationOpen) return null return (
- +
) } diff --git a/src/components/Global/Select/index.tsx b/src/components/Global/Select/index.tsx deleted file mode 100644 index 198798637f..0000000000 --- a/src/components/Global/Select/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Listbox, Transition } from '@headlessui/react' -import { useRef } from 'react' -import { createPortal } from 'react-dom' -import { twMerge } from 'tailwind-merge' -import { Icon } from '../Icons/Icon' - -type SelectItem = { - id: string - title: string -} - -type SelectProps = { - label?: string - className?: string - classButton?: string - classArrow?: string - classOptions?: string - classOption?: string - placeholder?: string - items: SelectItem[] - value: SelectItem | null | undefined - onChange: (item: SelectItem) => void - up?: boolean - small?: boolean - classPlaceholder?: string -} - -const Select = ({ - label, - className, - classButton, - classArrow, - classOptions, - classOption, - placeholder, - items, - value, - onChange, - up, - small, -}: SelectProps) => { - const buttonRef = useRef(null) - - return ( -
- {label &&
{label}
} - - {({ open }) => ( - <> - - {value ? ( - {value.title} - ) : ( - {placeholder} - )} - - - {open && - buttonRef.current && - createPortal( - - - {items.map((item) => ( - - {item.title} - - ))} - - , - document.body - )} - - )} - -
- ) -} - -export default Select diff --git a/src/components/Global/Slider/index.tsx b/src/components/Global/Slider/index.tsx index ae44d1397d..e3b49f14b5 100644 --- a/src/components/Global/Slider/index.tsx +++ b/src/components/Global/Slider/index.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import * as SliderPrimitive from '@radix-ui/react-slider' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' const SNAP_POINTS = [25, 100 / 3, 50, 100] // 100/3 = 33.333...% for equal 3-person splits const SNAP_THRESHOLD = 5 // ±5% proximity to trigger snap @@ -12,10 +12,14 @@ function Slider({ defaultValue = [100], value: controlledValue, onValueChange, + // radix renders role="slider" on the Thumb, so the accessible name must land there + 'aria-label': ariaLabel, ...props }: React.ComponentProps) { - // Use internal state for the slider value to enable magnetic snapping - const [internalValue, setInternalValue] = React.useState(defaultValue || controlledValue) + // Use internal state for the slider value to enable magnetic snapping. + // Seed from the controlled value when given, so a controlled slider does + // not first paint at defaultValue (100) and visibly jump after mount. + const [internalValue, setInternalValue] = React.useState(controlledValue ?? defaultValue) // Sync internal state when controlled value changes from external source. // The parent derives the controlled value from a cent-rounded amount, so a @@ -62,7 +66,7 @@ function Slider({ return (
-
+

0%

120%

@@ -74,37 +78,41 @@ function Slider({ max={120} step={1} className={twMerge( - 'relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50', + 'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50', className )} {...props} > - {/* Vertical tick mark - only visible when at a snap point */} + {/* Vertical snap tick - only visible when at a snap point */} {activeSnapPoint !== undefined && ( -
+
)} {/* White circle with border on top of the tick */} -
+
{/* Current value label */} -
+
{internalValue[0] % 1 === 0 ? internalValue[0].toFixed(0) : internalValue[0].toFixed(2)}%
diff --git a/src/components/Global/StaleCardApproval/ReEnableModal.tsx b/src/components/Global/StaleCardApproval/ReEnableModal.tsx index 61eecd7b9b..438e8cdfcb 100644 --- a/src/components/Global/StaleCardApproval/ReEnableModal.tsx +++ b/src/components/Global/StaleCardApproval/ReEnableModal.tsx @@ -89,7 +89,7 @@ export default function StaleCardApprovalReEnableModal() { visible={visible} onClose={close} icon="credit-card" - iconContainerClassName="bg-yellow-1" + iconContainerClassName="bg-action-secondary" title={succeeded ? t('staleCardApprovalModal.successTitle') : t('staleCardApprovalModal.title')} description={ succeeded diff --git a/src/components/Global/StatusPill/index.tsx b/src/components/Global/StatusPill/index.tsx index 9f400c20c9..3ce47af5e7 100644 --- a/src/components/Global/StatusPill/index.tsx +++ b/src/components/Global/StatusPill/index.tsx @@ -1,5 +1,5 @@ import { Icon, type IconName } from '../Icons/Icon' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { type StatusType } from '../Badges/StatusBadge' export type StatusPillType = Exclude @@ -8,48 +8,46 @@ interface StatusPillProps { status: StatusPillType } +/** + * icon-only status chip per the states board (17966:12128): 3px padding, + * 14px icon, round, on the badge background tokens — same status → color + * mapping as StatusBadge. + */ const StatusPill = ({ status }: StatusPillProps) => { - const colorClasses: Record = { - completed: 'border-success-5 bg-success-2 text-success-4', - pending: 'border-yellow-8 bg-secondary-4 text-yellow-6', - cancelled: 'border-error-2 bg-error-1 text-error', - refunded: 'border-success-5 bg-success-2 text-success-4', - failed: 'border-error-2 bg-error-1 text-error', - processing: 'border-yellow-8 bg-secondary-4 text-yellow-6', - soon: 'border-yellow-8 bg-secondary-4 text-yellow-6', - closed: 'border-success-5 bg-success-2 text-success-4', + const bgClasses: Record = { + completed: 'bg-background-badge-success', + closed: 'bg-background-badge-success', + refunded: 'bg-background-badge-success', + pending: 'bg-background-badge-attention', + processing: 'bg-background-badge-info', + soon: 'bg-background-badge-accent', + cancelled: 'bg-background-badge-error', + failed: 'bg-background-badge-error', } + // badge board type=icon glyphs (17312:137472-480, 18072:25494/25504/25520): + // processing = refresh arrow, soon = triangle, cancelled = ban — distinct + // from failed's x (the old map rendered cancelled and failed identically) const iconClasses: Record = { completed: 'success', failed: 'cancel', - processing: 'pending', - soon: 'pending', + processing: 'retry', + soon: 'alert', pending: 'pending', - cancelled: 'cancel', + cancelled: 'ban', refunded: 'undo', closed: 'success', } - const iconSize: Record = { - completed: 7, - failed: 6, - processing: 10, - soon: 7, - pending: 8, - cancelled: 6, - refunded: 8, - closed: 7, - } - return (
- +
) } diff --git a/src/components/Global/SuccessViewComponents/SuccessViewDetailsCard.tsx b/src/components/Global/SuccessViewComponents/SuccessViewDetailsCard.tsx index 7713b836d0..bf13b3b585 100644 --- a/src/components/Global/SuccessViewComponents/SuccessViewDetailsCard.tsx +++ b/src/components/Global/SuccessViewComponents/SuccessViewDetailsCard.tsx @@ -24,16 +24,16 @@ export const SuccessViewDetailsCard: React.FC = ({ {/* Left side: Icon, Amount, Description */}
-

{title}

- {amountDisplay &&

$ {amountDisplay}

} +

{title}

+ {amountDisplay &&

$ {amountDisplay}

} {description && ( -

+

{t('successViewDetailsCard.for', { description })}

)} diff --git a/src/components/Global/SupportCTA/index.tsx b/src/components/Global/SupportCTA/index.tsx index 233f1d15ae..19a6796a4f 100644 --- a/src/components/Global/SupportCTA/index.tsx +++ b/src/components/Global/SupportCTA/index.tsx @@ -1,5 +1,6 @@ 'use client' +import { LinkButton } from '@/components/0_Bruddle/LinkButton' import { useModalsContext } from '@/context/ModalsContext' import { useTranslations } from 'next-intl' @@ -10,13 +11,9 @@ const SupportCTA = () => { const { openSupportWithMessage } = useModalsContext() return (
- +
) } diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx index 1011af6f50..a26cd977a8 100644 --- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx +++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx @@ -62,9 +62,10 @@ jest.mock('@/hooks/useCrispUserData', () => ({ jest.mock('@/hooks/useCrispTokenId', () => ({ useCrispTokenId: () => mockUseCrispTokenId(), })) -jest.mock('../../PeanutLoading', () => ({ +jest.mock('../../Loading', () => ({ __esModule: true, - default: () =>
, + default: (props: any) => + props.variant === 'mascot' ?
:
, })) jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn() })) jest.mock('@capgo/capacitor-crisp', () => ({ CapacitorCrisp: nativeCrisp })) diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index 98725746ee..c0826d5c6d 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -6,7 +6,7 @@ import { useModalsContext } from '@/context/ModalsContext' import { useCrispUserData } from '@/hooks/useCrispUserData' import { useCrispTokenId } from '@/hooks/useCrispTokenId' import { useVisualViewport } from '@/hooks/useVisualViewport' -import PeanutLoading from '../PeanutLoading' +import Loading from '../Loading' import { Button } from '@/components/0_Bruddle/Button' import { SUPPORT_EMAIL, @@ -460,14 +460,21 @@ const SupportDrawer = () => {
{(!isCrispReady || isAwaitingToken) && !isCrispFailed && (
- +
)} {isCrispFailed && (
-

{t('supportDrawer.chatLoadFailed')}

-

{t('supportDrawer.chatLoadFailedDescription')}

- +

+ {t('supportDrawer.chatLoadFailed')} +

+

+ {t('supportDrawer.chatLoadFailedDescription')} +

+
{SUPPORT_EMAIL}
- {isSearch ? t('tokenSelector.moreNetworksButton') : chainName} + + {isSearch ? t('tokenSelector.moreNetworksButton') : chainName} + ) } diff --git a/src/components/Global/TokenSelector/Components/NetworkListItem.tsx b/src/components/Global/TokenSelector/Components/NetworkListItem.tsx index 9ef4b377d1..43340827f6 100644 --- a/src/components/Global/TokenSelector/Components/NetworkListItem.tsx +++ b/src/components/Global/TokenSelector/Components/NetworkListItem.tsx @@ -1,6 +1,6 @@ import Image from 'next/image' import React, { useState } from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Button } from '@/components/0_Bruddle/Button' import Card from '@/components/Global/Card' @@ -39,7 +39,7 @@ const NetworkListItem: React.FC = ({ type="button" variant="transparent" className={twMerge( - 'w-full transform-none rounded-sm p-0 text-left shadow-sm hover:transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-1' + 'w-full transform-none rounded-sm p-0 text-left shadow-sm hover:transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-action-primary' )} onClick={isComingSoon ? undefined : onClick} disabled={isComingSoon} @@ -48,14 +48,14 @@ const NetworkListItem: React.FC = ({
-
+
{iconUrl && !iconError ? ( = ({ )}
- + {name}
@@ -81,7 +86,7 @@ const NetworkListItem: React.FC = ({ ) : rightContent ? ( rightContent ) : ( - + )}
diff --git a/src/components/Global/TokenSelector/Components/NetworkListView.tsx b/src/components/Global/TokenSelector/Components/NetworkListView.tsx index 578a076991..abbfbb906c 100644 --- a/src/components/Global/TokenSelector/Components/NetworkListView.tsx +++ b/src/components/Global/TokenSelector/Components/NetworkListView.tsx @@ -13,7 +13,7 @@ import EmptyState from '../../EmptyStates/EmptyState' import NavHeader from '../../NavHeader' import { type NetworkConfig } from '../TokenSelector.consts' import NetworkListItem from './NetworkListItem' -import SearchInput from './SearchInput' +import { SearchInput } from '@/components/SearchInput' interface NetworkListViewProps { chains: Record @@ -65,7 +65,7 @@ const NetworkListView: React.FC = ({ }, [chains, searchValue, allowedChainIds, comingSoonNetworks]) return ( -
+
= ({ placeholder={t('tokenSelector.searchNetworkPlaceholder')} /> -
+
{filteredChains.length > 0 ? ( filteredChains.map((chain) => ( = ({ children, className, maxHeight = '' }) => { return ( -
+
{children}
) diff --git a/src/components/Global/TokenSelector/Components/SearchInput.tsx b/src/components/Global/TokenSelector/Components/SearchInput.tsx deleted file mode 100644 index e5666f18d0..0000000000 --- a/src/components/Global/TokenSelector/Components/SearchInput.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Button } from '@/components/0_Bruddle/Button' -import BaseInput from '@/components/0_Bruddle/BaseInput' -import { Icon } from '@/components/Global/Icons/Icon' -import { useTranslations } from 'next-intl' -import React from 'react' -import { twMerge } from 'tailwind-merge' - -interface SearchInputProps extends Omit, 'onChange' | 'value'> { - value: string - onChange: (value: string) => void - onClear: () => void - placeholder?: string - className?: string - inputClassName?: string -} - -const SearchInput: React.FC = ({ - value, - onChange, - onClear, - placeholder, - className, - inputClassName, - ...rest -}) => { - const t = useTranslations('global') - - return ( -
- onChange(e.target.value)} - {...rest} - /> - - {value && ( - - )} -
- ) -} - -export default SearchInput diff --git a/src/components/Global/TokenSelector/Components/TokenListItem.tsx b/src/components/Global/TokenSelector/Components/TokenListItem.tsx index 4d915b9aac..e9e7a996d9 100644 --- a/src/components/Global/TokenSelector/Components/TokenListItem.tsx +++ b/src/components/Global/TokenSelector/Components/TokenListItem.tsx @@ -14,7 +14,7 @@ import { formatAmountWithSignificantDigits, formatAmount } from '@/utils/general import Image from 'next/image' import { useTranslations } from 'next-intl' import React, { useContext, useMemo, useState } from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Icon } from '../../Icons/Icon' interface TokenListItemProps { @@ -60,7 +60,7 @@ const TokenListItem: React.FC = ({
= ({
-
+
{!balance.logoURI || tokenPlaceholder || tokenImageError ? ( @@ -94,7 +94,7 @@ const TokenListItem: React.FC = ({ /> )} {chainDetails.iconURI && !chainLogoPlaceholder && !chainImageError && ( -
+
{`${chainDetails.name} = ({ )}
- {balance.symbol} + {balance.symbol} {t.rich('tokenSelector.onChain', { chainName: chainDetails.name, @@ -127,8 +128,8 @@ const TokenListItem: React.FC = ({ {!isPopularToken && !!formattedBalance ? (
-
{formattedBalance}
-
+
{formattedBalance}
+
{/* token value in usd */} {balance.price && balance.price * Number(formattedBalance) > 0 ? `$ ${formatAmount(balance.price * Number(formattedBalance))}` @@ -137,7 +138,11 @@ const TokenListItem: React.FC = ({
) : ( (isEnabled || isPopularToken) && ( - + ) )}
diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx index 891585d425..57ec04cafd 100644 --- a/src/components/Global/TokenSelector/TokenSelector.tsx +++ b/src/components/Global/TokenSelector/TokenSelector.tsx @@ -12,7 +12,7 @@ import Image from 'next/image' import { useTranslations } from 'next-intl' import React, { type ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Button } from '@/components/0_Bruddle/Button' import Divider from '@/components/0_Bruddle/Divider' @@ -32,7 +32,7 @@ import { Icon, type IconName } from '../Icons/Icon' import NetworkButton from './Components/NetworkButton' import NetworkListView from './Components/NetworkListView' import ScrollableList from './Components/ScrollableList' -import SearchInput from './Components/SearchInput' +import { SearchInput } from '@/components/SearchInput' import TokenListItem from './Components/TokenListItem' import { RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN, @@ -41,7 +41,7 @@ import { TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS, } from './TokenSelector.consts' import { useChainRollout } from '@/hooks/useChainRollout' -import { Drawer, DrawerContent, DrawerTitle } from '../Drawer' +import { Drawer, DrawerContent } from '../Drawer' import underMaintenanceConfig from '@/config/underMaintenance.config' // USDC logo for the hardcoded USDC-on-Arbitrum fallback (when the token list @@ -59,8 +59,8 @@ interface SectionProps { const Section: React.FC = ({ title, icon, children, className, titleClassName }) => (
- {icon && } -

{title}

+ {icon && } +

{title}

{children}
@@ -411,7 +411,8 @@ const TokenSelector: React.FC = ({ classNameButton, viewT variant="stroke" onClick={openDrawer} className={twMerge( - 'flex min-h-16 w-full items-center justify-between bg-white p-4 hover:bg-white hover:text-black', + // boxy like Card (rounded-sm), not the default button pill + 'flex min-h-16 w-full items-center justify-between rounded-sm bg-background-default p-4 hover:bg-background-default hover:text-foreground-primary', classNameButton )} shadowSize="4" @@ -433,7 +434,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT )} {buttonChainLogoURI && buttonLogoURI && ( -
+
{`Chain = ({ classNameButton, viewT )}
- + {buttonSymbol || t('tokenSelector.selectAToken')} {buttonChainName && ( - + {t.rich('tokenSelector.onChain', { chainName: buttonChainName, c: (chunks) => {chunks}, @@ -460,19 +461,18 @@ const TokenSelector: React.FC = ({ classNameButton, viewT {(viewType === 'withdraw' || viewType === 'claim') && selectedTokenAddress?.toLowerCase() === PEANUT_WALLET_TOKEN.toLowerCase() && selectedChainID === PEANUT_WALLET_CHAIN.id.toString() && ( - + {t('tokenSelector.noFeesWithToken')} )}
- +
- - {t('tokenSelector.drawerTitle')} +
{showNetworkList ? ( = ({ classNameButton, viewT comingSoonNetworks={restrictToRhino ? [] : TOKEN_SELECTOR_COMING_SOON_NETWORKS} /> ) : ( -
+
{/* Info banner when cross-chain is disabled */} {isCrossChainDisabled && ( -
+
{t('tokenSelector.crossChainUnavailable')}
@@ -500,7 +500,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT <>
-
+
{popularChainsForButtons.map((chain) => ( = ({ classNameButton, viewT
- + )} @@ -538,8 +538,8 @@ const TokenSelector: React.FC = ({ classNameButton, viewT placeholder={t('tokenSelector.searchTokenPlaceholder')} />
- - + + {t('tokenSelector.sponsoredHint')}
@@ -554,7 +554,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT : popularTokensListTitle } icon={searchValue ? 'search' : 'star'} - titleClassName="text-grey-1 font-medium" + titleClassName="text-foreground-secondary font-medium" className="relative space-y-4" > {selectedNetworkName && !isCrossChainDisabled && clearChainSelection()} diff --git a/src/components/Global/TopNavbar/index.tsx b/src/components/Global/TopNavbar/index.tsx deleted file mode 100644 index f3b0f10ba1..0000000000 --- a/src/components/Global/TopNavbar/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' -import { useTranslations } from 'next-intl' -import { usePathname } from 'next/navigation' -import LogoutButton from '../LogoutButton' - -const PATH_TITLE_KEYS = { - '/home': 'dashboard', - '/send': 'send', - '/request/create': 'request', - '/request/pay': 'pay', - '/cashout': 'cashout', - '/history': 'history', - '/support': 'support', - '/claim': 'claim', -} as const - -const TopNavbar = () => { - const t = useTranslations('navigation') - const pathname = usePathname() - const titleKey = PATH_TITLE_KEYS[pathname as keyof typeof PATH_TITLE_KEYS] - - return ( -
-

{titleKey ? t(titleKey) : 'Peanut'}

- -
- ) -} - -export default TopNavbar diff --git a/src/components/Global/UnsupportedBrowserModal/index.tsx b/src/components/Global/UnsupportedBrowserModal/index.tsx index 1642870d3e..37be94d081 100644 --- a/src/components/Global/UnsupportedBrowserModal/index.tsx +++ b/src/components/Global/UnsupportedBrowserModal/index.tsx @@ -103,12 +103,13 @@ const UnsupportedBrowserModalContent = ({ toast.error(t('unsupportedBrowserModal.copyErrorToast')) } }, - className: 'bg-primary-1 hover:bg-primary-2 text-black sm:py-3', + className: 'bg-action-primary hover:bg-action-primary-hover text-black sm:py-3', shadowSize: '4', }, { variant: 'transparent-dark', - className: 'text-grey-1 text-xs font-medium h-2 mt-1 hover:text-grey-1 active:text-grey-1', + className: + 'text-foreground-secondary text-body-xs font-medium h-2 mt-1 hover:text-foreground-secondary active:text-foreground-secondary', text: t('unsupportedBrowserModal.pasteHint'), }, ] @@ -120,7 +121,7 @@ const UnsupportedBrowserModalContent = ({ title={t('unsupportedBrowserModal.title')} description={t('unsupportedBrowserModal.description')} icon={'alert' as IconName} - iconContainerClassName="bg-primary-1" + iconContainerClassName="bg-action-primary" iconProps={{ className: 'text-black' }} ctas={copyLinkAction} hideModalCloseButton={!allowClose} diff --git a/src/components/Global/ValidatedInput/index.tsx b/src/components/Global/ValidatedInput/index.tsx index 3634f793cc..1353537d40 100644 --- a/src/components/Global/ValidatedInput/index.tsx +++ b/src/components/Global/ValidatedInput/index.tsx @@ -7,7 +7,7 @@ import { AnimatePresence, motion } from 'framer-motion' import * as Sentry from '@sentry/nextjs' import { useTranslations } from 'next-intl' import { type ChangeEvent, useEffect, useRef, useState } from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Icon } from '../Icons/Icon' import Loading from '../Loading' @@ -173,17 +173,17 @@ const ValidatedInput = ({
-
+
{infoText && ( -
+
)} @@ -205,9 +205,7 @@ const ValidatedInput = ({ : undefined } className={twMerge( - `notranslate h-12 w-full border-0 bg-white - pr-1 text-sm font-medium outline-none focus:outline-none - active:bg-white dark:bg-n-1 dark:text-white dark:placeholder:text-white/75`, + `notranslate w-full border-0 bg-background-default pr-1 text-body-s font-medium outline-none focus:outline-none focus-visible:outline-none active:bg-background-default`, !!infoText ? 'pl-0' : 'pl-4' )} placeholder={placeholder} @@ -227,20 +225,20 @@ const ValidatedInput = ({ className={`h-full ${ isValidating ? 'opacity-100' - : 'bg-white opacity-100 transition-opacity hover:opacity-100 md:opacity-0' + : 'bg-background-default opacity-100 transition-opacity hover:opacity-100 md:opacity-0' }`} > {isValidating ? ( -
+
) : !!isSetupFlow && !isValid && !isInputChanging ? (
- +
) : !!isSetupFlow && !!isValid && !isInputChanging ? ( -
- +
+
) : ( )}
@@ -272,7 +270,7 @@ const ValidatedInput = ({ dismissSuggestion() onUpdate({ value: suggestion, isValid: false, isChanging: true }) }} - className="flex w-full items-start gap-1.5 rounded-sm border border-n-1 bg-white px-3 py-2 text-left text-xs font-medium text-n-1 transition-colors hover:bg-n-3 dark:border-white dark:bg-n-1 dark:text-white dark:hover:bg-n-2" + className="flex w-full items-start gap-1.5 rounded-sm border border-border-default bg-background-default px-3 py-2 text-left text-body-xs font-medium text-foreground-primary transition-colors hover:bg-background-disabled" > diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx deleted file mode 100644 index df8d0dcca0..0000000000 --- a/src/components/Global/WalletNavigation/index.tsx +++ /dev/null @@ -1,157 +0,0 @@ -'use client' -import PEANUT_LOGO from '@/assets/logos/peanut-logo.svg' -import DirectSendQr from '@/components/Global/DirectSendQR' -import { Icon, type IconName, Icon as NavIcon } from '@/components/Global/Icons/Icon' -import IndicatorDot from '@/components/Global/IndicatorDot' -import underMaintenanceConfig from '@/config/underMaintenance.config' -import { useModalsContext } from '@/context/ModalsContext' -import { isSameRoute } from '@/constants/routes' -import { useSupportUnread } from '@/hooks/useSupportUnread' -import { useUserStore } from '@/redux/hooks' -import classNames from 'classnames' -import Image from 'next/image' -import { useLocale, useTranslations } from 'next-intl' -import { localizeDocsHref } from '@/components/Global/DocsLink' -import Link from 'next/link' -import { usePathname, useRouter } from 'next/navigation' -import { useAppHaptic } from '@/hooks/useAppHaptic' - -type NavPathProps = { - labelKey: 'send' | 'request' | 'add' | 'withdraw' | 'history' | 'docs' | 'support' - href: string - icon: IconName - size?: number -} - -// todo: update icons based on new the design -const desktopPaths: NavPathProps[] = [ - { labelKey: 'send', href: '/send', icon: 'arrow-up-right', size: 14 }, - { labelKey: 'request', href: '/request', icon: 'arrow-down-left', size: 14 }, - { labelKey: 'add', href: '/add-money', icon: 'arrow-down', size: 15 }, - { labelKey: 'withdraw', href: '/withdraw', icon: 'arrow-up', size: 15 }, - { labelKey: 'history', href: '/history', icon: 'history', size: 15 }, - { labelKey: 'docs', href: '/en/help', icon: 'docs', size: 14 }, - { labelKey: 'support', href: '/support', icon: 'peanut-support', size: 14 }, -] - -type NavSectionProps = { - paths: NavPathProps[] - pathName: string -} - -const NavSection: React.FC = ({ paths, pathName }) => { - const t = useTranslations('navigation') - const locale = useLocale() - const router = useRouter() - return ( - <> - {paths.map(({ labelKey, href, icon, size }, index) => ( -
- { - if (isSameRoute(pathName, href)) { - router.refresh() - } - }} - > - - {t(labelKey)} - - {index === 4 &&
} -
- ))} - - ) -} - -type MobileNavProps = { - pathName: string -} - -const MobileNav: React.FC = ({ pathName }) => { - const t = useTranslations('navigation') - const { setIsSupportModalOpen } = useModalsContext() - const { triggerHaptic } = useAppHaptic() - const hasUnreadSupport = useSupportUnread() - - return ( -
- {/* Home Link */} - triggerHaptic()} - href="/home" - translate="no" - className={classNames( - 'notranslate flex flex-col items-center justify-center object-contain hover:cursor-pointer', - { 'text-primary-1': isSameRoute(pathName, '/home') } - )} - > - - {t('home')} - - - {/* QR Button - Main Action */} - - - {/* Support Link */} - -
- ) -} - -const WalletNavigation: React.FC = () => { - const t = useTranslations('navigation') - const pathName = usePathname() - const { user } = useUserStore() - const isLoggedIn = !!user?.user.userId || false - - return ( -
-
- - {t('peanutLogoAlt')} - -
- -
-
- {isLoggedIn && } -
- ) -} - -export default WalletNavigation diff --git a/src/components/Global/__tests__/DotFaceAvatar.test.tsx b/src/components/Global/__tests__/DotFaceAvatar.test.tsx new file mode 100644 index 0000000000..dc57cace72 --- /dev/null +++ b/src/components/Global/__tests__/DotFaceAvatar.test.tsx @@ -0,0 +1,42 @@ +/** @jest-environment jsdom */ +/** + * DotFaceAvatar — the generated self-avatar. + * + * Contract: fully deterministic from the username (same input, same face, + * every render and device), case-insensitive, and distinct inputs are free + * to differ. Only used for the user's own identity; counterparties keep + * the initials avatar. + */ +import React from 'react' +import { render as rtlRender, screen } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' +import DotFaceAvatar, { dotFaceTraits } from '@/components/Global/DotFaceAvatar' + +const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) + +describe('DotFaceAvatar', () => { + it('is deterministic and case-insensitive for the same username', () => { + expect(dotFaceTraits('test022')).toEqual(dotFaceTraits('TEST022')) + const { container: a } = render() + const { container: b } = render() + expect(a.innerHTML).toBe(b.innerHTML) + }) + + it('derives every trait from the palette and trait ranges', () => { + for (const name of ['alice', 'bob88', 'mariana.br', 'x']) { + const t = dotFaceTraits(name) + // the avatar board's seven triples — the same set the initials + // avatar uses, so the two never clash side by side + expect(['pink', 'yellow', 'orange', 'blue', 'purple', 'red', 'green']).toContain(t.background) + expect(t.eyeStyle).toBeGreaterThanOrEqual(0) + expect(t.eyeStyle).toBeLessThan(5) + expect(t.mouthStyle).toBeGreaterThanOrEqual(0) + expect(t.mouthStyle).toBeLessThan(5) + } + }) + + it('renders an accessible image named after the user', () => { + render() + expect(screen.getByRole('img', { name: 'Avatar for test022' })).toBeInTheDocument() + }) +}) diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index f0a9f898e2..dff267cf95 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -6,17 +6,19 @@ import { Button } from '@/components/0_Bruddle/Button' import { type ActivationStep } from '@/hooks/useActivationStatus' import { Icon, type IconName } from '@/components/Global/Icons/Icon' import { useRouter } from 'next/navigation' +import ActionModal from '@/components/Global/ActionModal' import { useModalsContext } from '@/context/ModalsContext' import Card from '../Global/Card' -import CardLaunchCTABanner from '@/components/Home/CardLaunchCTA/CardLaunchCTABanner' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslations } from 'next-intl' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useCapabilities } from '@/hooks/useCapabilities' +import GettingStartedChecklist from '@/components/Home/GettingStartedChecklist' +import { useResidenceRestrictions } from '@/hooks/useResidenceRestrictions' import { useCardInfo } from '@/hooks/useCardInfo' import { useIdentityVerification } from '@/hooks/useIdentityVerification' -import ActionModal from '@/components/Global/ActionModal' +import { REGION_RESTRICTED_CTA_HREF } from '@/components/Kyc/KycRegionRestrictedContent' import { useAuth } from '@/context/authContext' import { buildContactSupportMessage } from '@/utils/contact-support.utils' import ProvideEmailStep from '@/components/Kyc/ProvideEmailStep' @@ -49,6 +51,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa const t = useTranslations('home.activation') const tCommon = useTranslations('common') const tIdentity = useTranslations('identity') + const tRegion = useTranslations('kyc.regionRestricted') const router = useRouter() const { setIsQRScannerOpen, openSupportWithMessage } = useModalsContext() const { rails, channelOf, nextActions } = useCapabilities() @@ -62,7 +65,12 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa // (Sumsub processing / action_required). The user already took the verify // action; the identity-verification page surfaces the in-progress modal, // and bouncing them through here again would imply they need to re-act. - const { isProcessing: isIdentityProcessing, needsAction: isIdentityActionRequired } = useIdentityVerification() + const { + isProcessing: isIdentityProcessing, + needsAction: isIdentityActionRequired, + isRegionRestricted, + } = useIdentityVerification() + const residenceRestrictions = useResidenceRestrictions() // The activation funnel gates deposit/outbound, which routes through bank or // qr-only channels — never through card. Top-level status (not per-op @@ -138,7 +146,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa () => ({ verify: { icon: 'globe-lock', - iconBg: 'bg-primary-1', + iconBg: 'bg-action-primary', title: t('steps.verify.title'), description: t('steps.verify.description'), ctaLabel: t('steps.verify.cta'), @@ -146,7 +154,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa }, deposit: { icon: 'arrow-down', - iconBg: 'bg-primary-1', + iconBg: 'bg-action-primary', title: t('steps.deposit.title'), description: t('steps.deposit.description'), ctaLabel: t('steps.deposit.cta'), @@ -154,7 +162,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa }, card: { icon: 'credit-card', - iconBg: 'bg-yellow-1', + iconBg: 'bg-action-secondary', title: t('steps.card.title'), description: t('steps.card.description'), ctaLabel: t('steps.card.cta'), @@ -163,7 +171,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa }, outbound: { icon: 'qr-code', - iconBg: 'bg-primary-1', + iconBg: 'bg-action-primary', title: t('steps.outbound.title'), description: t('steps.outbound.description'), ctaLabel: t('steps.outbound.cta'), @@ -176,7 +184,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa // Inline self-heal so the home "Upload document" CTA opens the Sumsub document // re-upload directly, instead of routing to /profile/identity-verification (which // only showed the regions list, forcing the user to hunt for the Upload-document - // CTA again). Mirrors the add-money bank flow + UnlockedRegions view. + // CTA again). Mirrors the add-money bank flow + the Unlock payments view. const kycFlow = useMultiPhaseKycFlow({}) const lastTrackedStep = useRef(null) @@ -221,6 +229,23 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa (hasFixableRejection || hasBlockedRejection) const step: StepConfig | null = useMemo(() => { + // Highest precedence, ahead of every funnel step AND every provider + // rejection: a region-restricted user can never finish the funnel, so + // "Unlock payments" is a CTA that leads nowhere. They stay on 'verify' + // forever (the milestone never advances past `registered`), which is + // exactly the state that would nag them indefinitely. Replace the card + // with the explanation and point them at what still works. + if (isRegionRestricted) { + return { + icon: 'globe-lock', + iconBg: 'bg-action-primary', + title: tRegion('title'), + description: tRegion('homeDescription'), + ctaLabel: tRegion('cta'), + href: REGION_RESTRICTED_CTA_HREF, + } + } + if (activationStep === 'completed' && !hasProviderRejection) return null // Hide the verify CTA while identity is processing — user already @@ -228,6 +253,12 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa // action_required is the exception: that means we DO need them back. if (activationStep === 'verify' && isIdentityProcessing && !isIdentityActionRequired) return null + // A fully restricted residence (no bank rails AND no card) has nothing + // behind "Unlock payments" — the ID check could only end on a terminal + // rejection, so the offer itself is dishonest. Partial restrictions + // keep the CTA: one half of the unlock still works. + if (activationStep === 'verify' && residenceRestrictions.banking && residenceRestrictions.card) return null + if (hasProviderRejection) { // Email-blocked (status=blocked) outranks a fixable RFI (status=requires-info) // — the canonical `deriveGate` order, and the order this card's onClick @@ -237,7 +268,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa if (isEmailBlocked) { return { icon: 'globe-lock', - iconBg: 'bg-primary-1', + iconBg: 'bg-action-primary', title: t('addEmail.title'), description: localizedRejectionMessage || t('addEmail.description'), ctaLabel: t('addEmail.cta'), @@ -247,7 +278,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa if (hasFixableRejection) { return { icon: 'globe-lock', - iconBg: 'bg-primary-1', + iconBg: 'bg-action-primary', title: t('completeSetup.title'), description: localizedRejectionMessage || t('completeSetup.description'), ctaLabel: t('completeSetup.cta'), @@ -257,7 +288,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa // blocked return { icon: 'globe-lock', - iconBg: 'bg-primary-1', + iconBg: 'bg-action-primary', title: t('verificationIssue.title'), description: t('verificationIssue.description'), ctaLabel: t('verificationIssue.cta'), @@ -288,28 +319,21 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa localizedRejectionMessage, isIdentityProcessing, isIdentityActionRequired, + residenceRestrictions, hasCardAccess, + isRegionRestricted, + tRegion, ]) if (!step) return null - // The card step renders the mysterious /shhhhh-tone launch banner (#2295's - // CardLaunchCTABanner) instead of the plain funnel card — so non-activated - // card-eligible users get the same CTA as the activated launch splash. - // Keeps the funnel's /card routing + the "Maybe later" dismissal. - if (activationStep === 'card') { - return ( - { - posthog.capture(ANALYTICS_EVENTS.CARD_LAUNCH_CTA_CLICKED) - // /shhhhh (not /card): the landing page explains the feature - // and funnels into the canonical flow — /card alone is confusing. - router.push('/shhhhh') - }} - onDismiss={() => onDismissCard?.()} - /> - ) - } + // Happy-path funnel steps render as the 3-item getting-started checklist + // (one status language with the Unlock payments screen). Interrupts keep + // their dedicated card below — provider rejections, email blocks, and the + // region-restricted terminal explanation, which outranks the checklist: + // every step on the list (bank money in, the card) is a door this user + // cannot open, so offering the list would be dishonest. + if (!hasProviderRejection && !isRegionRestricted) return return ( @@ -318,15 +342,20 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa
-
{step.title}
-
{step.description}
+
{step.title}
+
{step.description}
{step.dismissable && onDismissCard && ( - )} diff --git a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx index b32f69fa0e..cd579b8492 100644 --- a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx +++ b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx @@ -41,22 +41,22 @@ export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunch role="button" tabIndex={0} onClick={handleTryDoor} - className="relative mb-3 cursor-pointer overflow-hidden rounded-sm border border-n-1 bg-white p-5" + className="relative mb-3 cursor-pointer overflow-hidden rounded-sm border border-border-default bg-white p-4" >
-

+

{t('title')}

-

{t('subtitle')}

+

{t('subtitle')}

+ ) + })} + +
+ ) +} + +export default GettingStartedChecklist diff --git a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx index 2d5ad895a0..5475eb3f5c 100644 --- a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx +++ b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx @@ -6,7 +6,7 @@ import type { StaticImageData } from 'next/image' import Image from 'next/image' import { useTranslations } from 'next-intl' import React from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { CAROUSEL_CLOSE_BUTTON_POSITION, CAROUSEL_CLOSE_ICON_SIZE } from '@/constants/carousel.consts' import { useAppHaptic } from '@/hooks/useAppHaptic' import { Card } from '@/components/0_Bruddle/Card' @@ -101,7 +101,7 @@ const CarouselCTA = ({
@@ -130,7 +130,7 @@ const CarouselCTA = ({ {/* Content */}

{title}

-

{description}

+

{description}

) diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx index 6be536278b..31f1e847e3 100644 --- a/src/components/Home/HomeCarouselCTA/index.tsx +++ b/src/components/Home/HomeCarouselCTA/index.tsx @@ -65,7 +65,7 @@ const HomeCarouselCTA = () => { title:

{t.rich('rewardReady', { amount: perk.amountUsd, b: (chunks) => {chunks} })}

, description, icon: 'gift' as IconName, - iconContainerClassName: 'bg-primary-1', + iconContainerClassName: 'bg-action-primary', onClick: () => setSelectedPerk(perk), isPerkClaim: true, iconSize: 16, diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index 2c12204f29..0a28f8a97f 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -1,8 +1,10 @@ 'use client' import TransactionCard from '@/components/TransactionDetails/TransactionCard' +import { Section } from '@/components/0_Bruddle/Section' import { mapTransactionDataForDrawer } from '@/components/TransactionDetails/transactionTransformer' import { type HistoryEntry, useTransactionHistory } from '@/hooks/useTransactionHistory' +import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' import type { IntentKind } from '@/components/TransactionDetails/strategies/registry' import { useWebSocket } from '@/hooks/useWebSocket' import { useUserStore } from '@/redux/hooks' @@ -13,7 +15,7 @@ import { TRANSACTIONS } from '@/constants/query.consts' import Link from 'next/link' import { useTranslations } from 'next-intl' import { useCallback, useEffect, useMemo, useState } from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import Card from '../Global/Card' import { type CardPosition, getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' @@ -65,6 +67,9 @@ const HomeHistory = ({ }) => { const t = useTranslations('home.history') const { user } = useUserStore() + // one `?tx=` subscription for the whole widget — rows are memo'd and get + // isSelected/open/close as props (see useTransactionDetailsDrawer) + const { isTransactionSelected, openTransactionDetails, closeTransactionDetails } = useTransactionDetailsDrawer() const isLoggedIn = !!user?.user.userId || false // Only filter when user is requesting for some different user's history const filterMutualTxs = username !== user?.user.username @@ -306,6 +311,19 @@ const HomeHistory = ({ // dispatches a strategy + builds derived view-model state per call; // without this memo each rendered row recomputes on every parent // rerender (websocket tick, hover, etc.). + const pendingRequests = useMemo(() => { + if (!combinedEntries.length) return [] + return combinedEntries.filter( + (entry): entry is HistoryEntry => + !isKycStatusItem(entry) && + !isBadgeHistoryItem(entry) && + !isCardUnlockHistoryItem(entry) && + String(entry.type) === 'REQUEST' && + entry.userRole === 'SENDER' && + entry.status === 'NEW' + ) + }, [combinedEntries]) + const drawerByUuid = useMemo(() => { const m = new Map>() for (const item of combinedEntries) { @@ -318,14 +336,13 @@ const HomeHistory = ({ // show loading state if (isLoading) { return ( -
-

{t('activity')}

+
{Array.from({ length: 5 }).map((_, index) => ( ))}
-
+ ) } @@ -344,14 +361,13 @@ const HomeHistory = ({ Sentry.captureException(error) } return ( -
-

{t('activity')}

{' '} +
-
+ ) } @@ -372,8 +388,7 @@ const HomeHistory = ({ // show empty state UI if no processed entries yet (but source data may still be processing) if (!isLoading && !combinedEntries.length && !hasSourceEntries) { return ( -
-

{t('activity')}

+
{isViewingOwnHistory && user && (() => { @@ -394,19 +409,55 @@ const HomeHistory = ({ {!isViewingOwnHistory && ( )} -
+ ) } return ( -
+
{/* link to the full history page */} + {pendingRequests.length > 0 && ( + <> +

{t('pendingTransactions')}

+
+ {/* map over the latest entries and render transactioncard */} + {pendingRequests.map((item, index) => { + const { transactionDetails, transactionCardType } = + drawerByUuid.get(item.uuid) ?? mapTransactionDataForDrawer(item) + + // determine card position for styling (first, middle, last, single) + const position = getCardPosition(index, pendingRequests.length) + + return ( + + ) + })} +
+ + )} {!isViewingOwnHistory ? ( -

{t('latestTransactions')}

+

{t('latestTransactions')}

) : ( triggerHaptic()}> -

{t('activity')}

- +

{t('activity')}

+ + + )} {/* container for the transaction cards */} @@ -464,6 +515,9 @@ const HomeHistory = ({ position={position} haveSentMoneyToUser={haveSentMoneyToUser} hideTxnAmount={hideTxnAmount} + isSelected={isTransactionSelected(transactionDetails.id)} + onOpen={openTransactionDetails} + onClose={closeTransactionDetails} /> ) })} @@ -476,11 +530,12 @@ export default HomeHistory export const HistorySkeleton = ({ position }: { position: CardPosition }) => { return ( - -
-
-
-
+ // p-4 matches ListItem row height so content doesn't jump on load + +
+
+
+
) diff --git a/src/components/Home/InvitesIcon.tsx b/src/components/Home/InvitesIcon.tsx index b7a8815bb5..05eb21431a 100644 --- a/src/components/Home/InvitesIcon.tsx +++ b/src/components/Home/InvitesIcon.tsx @@ -1,6 +1,6 @@ import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import Image from 'next/image' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' const InvitesIcon = ({ animate = true, diff --git a/src/components/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx index bc436d98ca..48dabd5ffd 100644 --- a/src/components/Home/PendingVerificationTasks.tsx +++ b/src/components/Home/PendingVerificationTasks.tsx @@ -290,25 +290,27 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism const deadline = formatEffectiveDate(task.effectiveDate) return ( -
+
{dismissible && !!task.effectiveDate && ( )} -
+
-
{copy.title}
-
{copy.description}
+
{copy.title}
+
+ {copy.description} +
{deadline && ( -
+
{t('pendingTasks.completeBefore', { deadline })}
)} @@ -331,7 +333,7 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism ) })} - {error &&

{error}

} + {error &&

{error}

}
)} diff --git a/src/components/Home/PerkClaimModal.tsx b/src/components/Home/PerkClaimModal.tsx index 15a4cfef99..b5f5e46cf9 100644 --- a/src/components/Home/PerkClaimModal.tsx +++ b/src/components/Home/PerkClaimModal.tsx @@ -216,23 +216,23 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp preventClose={isExiting} icon="check" iconProps={{ className: 'text-white' }} - iconContainerClassName="bg-success-3" + iconContainerClassName="bg-green-500" title="" description={
-

+${perk.amountUsd}

+

+${perk.amountUsd}

{isSurpriseMoment ? ( <> {/* Approved copy — see notion: notifs-copy-33083811757980638a27effc79a033f3 */} -

+

{t('surpriseTitle', { amount: perk.amountUsd })}

-

+

{claimCount === 0 ? t('surpriseDescriptionFirst') : t('surpriseDescriptionNext')}

) : inviteeName ? ( -

+

{t.rich('usedPeanut', { inviteeName, @@ -240,7 +240,7 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp })}

) : ( -

{t('rewardClaimed')}

+

{t('rewardClaimed')}

)}
} @@ -265,7 +265,10 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp > {t('shareAndEarn')} - @@ -275,7 +278,7 @@ function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProp {tCommon('done')}

{ onDismiss() router.push('/rewards') @@ -341,7 +344,7 @@ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProp return (

{/* Title */} -

+

{t.rich('usedPeanut', { inviteeName: inviteeName ?? '', @@ -353,7 +356,7 @@ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProp

{/* Glow effect behind gift */}
@@ -361,13 +364,13 @@ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProp
{/* Gift box */}
0 ? 'scale-[0.98]' : ''}`} + className={`gift-box-shine relative h-32 w-44 overflow-hidden rounded-xl border-4 border-action-primary bg-gradient-to-br from-action-primary/20 via-white to-action-primary/20 shadow-xl transition-transform ${holdProgress > 0 ? 'scale-[0.98]' : ''}`} > {/* Vertical ribbon */} -
+
{/* Horizontal ribbon */} -
+
{/* Light rays from center */}
20 && ( -
+
)} {holdProgress > 40 && ( -
+
)} {holdProgress > 60 && ( -
+
)} {/* Gift icon */}
30 ? 'animate-bounce' : ''}`} + className={`rounded-full bg-action-primary p-3 shadow-lg transition-transform ${holdProgress > 30 ? 'animate-bounce' : ''}`} >
@@ -403,7 +406,7 @@ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProp
{/* Left ribbon tail */}
{/* Right ribbon tail */}
{/* Left loop */}
{/* Right loop */}
{/* Center knot */} -
+
{/* Particles flying out */} {holdProgress > 30 && ( <> -
+
-
+
)} {holdProgress > 60 && ( <> -
+
-
+
@@ -469,7 +460,7 @@ function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProp
{/* Instructions */} -

{t('holdToUnwrap')}

+

{t('holdToUnwrap')}

) } diff --git a/src/components/Home/WelcomeUnlockModal/index.tsx b/src/components/Home/WelcomeUnlockModal/index.tsx index 774ad7b2f7..48943c2a8c 100644 --- a/src/components/Home/WelcomeUnlockModal/index.tsx +++ b/src/components/Home/WelcomeUnlockModal/index.tsx @@ -2,8 +2,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { useTranslations } from 'next-intl' import ActionModal from '@/components/Global/ActionModal' -import type { IconName } from '@/components/Global/Icons/Icon' -import InfoCard from '@/components/Global/InfoCard' +import { type IconName } from '@/components/Global/Icons/Icon' +import { Notification } from '@/components/0_Bruddle/Notification' import { countryData, type CountryData } from '@/components/AddMoney/consts' import { isMantecaSupportedCountryCode } from '@/constants/manteca.consts' import { useCapabilities } from '@/hooks/useCapabilities' @@ -103,7 +103,7 @@ const WelcomeUnlockModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () visible={isOpen} onClose={onClose} icon={'globe-lock' as IconName} - iconContainerClassName="bg-primary-1 text-black" + iconContainerClassName="bg-action-primary text-black" title={t('title')} ctas={[ { @@ -123,11 +123,9 @@ const WelcomeUnlockModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () content={

{t('youCanNow')}

- { if (unlockedChannels === 'all') { diff --git a/src/components/Home/__tests__/ActivationCTAs.test.tsx b/src/components/Home/__tests__/ActivationCTAs.test.tsx index e3bfa82238..a969ab9bd0 100644 --- a/src/components/Home/__tests__/ActivationCTAs.test.tsx +++ b/src/components/Home/__tests__/ActivationCTAs.test.tsx @@ -46,8 +46,23 @@ jest.mock('@/hooks/useCapabilities', () => ({ jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: mockUser }), })) +let mockRegionRestricted = false jest.mock('@/hooks/useIdentityVerification', () => ({ - useIdentityVerification: () => ({ isProcessing: false, needsAction: false }), + useIdentityVerification: () => ({ + isProcessing: false, + needsAction: false, + isRegionRestricted: mockRegionRestricted, + }), +})) +let mockResidenceRestrictions = { banking: false, card: false } +jest.mock('@/hooks/useResidenceRestrictions', () => ({ + useResidenceRestrictions: () => mockResidenceRestrictions, +})) +// Happy-path funnel steps render the checklist; these suites test the +// interrupt cards, so the checklist itself is a marker (own suite covers it). +jest.mock('@/components/Home/GettingStartedChecklist', () => ({ + __esModule: true, + default: () =>
getting-started-checklist
, })) jest.mock('@/context/ModalsContext', () => ({ useModalsContext: () => ({ setIsQRScannerOpen: mockSetIsQRScannerOpen, openSupportWithMessage: jest.fn() }), @@ -87,7 +102,6 @@ jest.mock('@/components/Kyc/SumsubKycModals', () => ({ })) import ActivationCTAs from '../ActivationCTAs' -import posthog from 'posthog-js' const bankRejected = { id: 'bridge.sepa_eu', @@ -103,6 +117,53 @@ beforeEach(() => { mockRails = [] mockUser = { user: { isActivated: false, userId: 'u1' } } mockHasCardAccess = false + mockResidenceRestrictions = { banking: false, card: false } + mockRegionRestricted = false +}) + +describe('ActivationCTAs — residence restrictions', () => { + it('a fully restricted residence hides the verify CTA entirely', () => { + mockResidenceRestrictions = { banking: true, card: true } + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('a partial restriction keeps the verify step, rendered as the checklist', () => { + mockResidenceRestrictions = { banking: false, card: true } + render() + expect(screen.getByText('getting-started-checklist')).toBeInTheDocument() + }) +}) + +describe('ActivationCTAs — region-restricted outranks every funnel step', () => { + it('replaces the verify nag, which this user can never satisfy', () => { + mockRegionRestricted = true + render() + + expect(screen.getByText("We can't verify IDs from your country")).toBeInTheDocument() + expect(screen.queryByText('Verification issue')).not.toBeInTheDocument() + }) + + it('outranks the getting-started checklist — every listed step is a closed door', () => { + mockRegionRestricted = true + mockHasCardAccess = true + render() + + expect(screen.getByText("We can't verify IDs from your country")).toBeInTheDocument() + expect(screen.queryByText('getting-started-checklist')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Send or request money')) + expect(mockPush).toHaveBeenCalledWith('/send') + expect(mockPush).not.toHaveBeenCalledWith('/shhhhh') + }) + + it('never opens support — support cannot lift a jurisdictional block', () => { + mockRegionRestricted = true + mockRails = [bankRejected] + render() + + fireEvent.click(screen.getByText('Send or request money')) + expect(mockPush).toHaveBeenCalledWith('/send') + }) }) describe('ActivationCTAs — rejection override respects existing transacting ability', () => { @@ -110,8 +171,8 @@ describe('ActivationCTAs — rejection override respects existing transacting ab mockRails = [enabledCardRail, bankRejected] render() expect(screen.queryByText('Complete your setup')).not.toBeInTheDocument() - // Falls through to the normal funnel step instead of the rejection card. - expect(screen.getByText('Deposit')).toBeInTheDocument() + // Falls through to the checklist instead of the rejection card. + expect(screen.getByText('getting-started-checklist')).toBeInTheDocument() }) it('a BE-activated user with a rejected bank rail does NOT see the nag', () => { @@ -128,7 +189,7 @@ describe('ActivationCTAs — rejection override respects existing transacting ab expect(screen.getByText('We need a valid proof of address document.')).toBeInTheDocument() }) - it('a card-ELIGIBLE user (access, no card) with a rejected bank rail sees the deposit CTA, not the nag', () => { + it('a card-ELIGIBLE user (access, no card) with a rejected bank rail sees the checklist, not the nag', () => { // The 2026-08-20 deposit-first gate moved this cohort off the card // step; without this shield they would trade the card banner for a // "Contact support" dead end over a rail the old region-picker detour @@ -137,7 +198,7 @@ describe('ActivationCTAs — rejection override respects existing transacting ab mockHasCardAccess = true render() expect(screen.queryByText('Complete your setup')).not.toBeInTheDocument() - expect(screen.getByText('Deposit')).toBeInTheDocument() + expect(screen.getByText('getting-started-checklist')).toBeInTheDocument() }) it('fixable rejection: Upload document heals inline (handleFixableRejection), does not navigate away', () => { @@ -179,67 +240,14 @@ describe('ActivationCTAs — rejection override respects existing transacting ab }) }) -/** - * The outbound step was QR-only ("Make your first payment" → scanner) while - * card spend counts as activation too. Card-access users now get card-inclusive - * "Spend with Peanut" copy and a card/QR chooser; users without card access - * keep the exact old behavior so a gated card is never teased. - */ -describe('ActivationCTAs — outbound step spend chooser (card + QR)', () => { - it('without card access: QR-only copy unchanged, CTA goes straight to the scanner, no chooser', () => { - render() - expect(screen.getByText('Make your first payment')).toBeInTheDocument() - expect(screen.getByText('Start paying to Pix and MercadoPago QR codes')).toBeInTheDocument() - fireEvent.click(screen.getByText('Start Spending')) - expect(mockSetIsQRScannerOpen).toHaveBeenCalledWith(true) - expect(screen.queryByTestId('spend-chooser')).not.toBeInTheDocument() - }) - - it('while card access is still loading (undefined): treated as no access — scanner, no chooser', () => { - mockHasCardAccess = undefined - render() - expect(screen.getByText('Make your first payment')).toBeInTheDocument() - fireEvent.click(screen.getByText('Start Spending')) - expect(mockSetIsQRScannerOpen).toHaveBeenCalledWith(true) - expect(screen.queryByTestId('spend-chooser')).not.toBeInTheDocument() - }) - - it('with card access: card-inclusive copy, CTA opens the chooser (not the scanner) and tracks it', () => { - mockHasCardAccess = true - render() - expect(screen.getByText('Spend with Peanut')).toBeInTheDocument() - fireEvent.click(screen.getByText('Start Spending')) - expect(screen.getByTestId('spend-chooser')).toBeInTheDocument() - expect(mockSetIsQRScannerOpen).not.toHaveBeenCalled() - expect(posthog.capture).toHaveBeenCalledWith('activation_spend_chooser_shown') +describe('ActivationCTAs — happy path renders the checklist', () => { + it.each(['verify', 'deposit', 'card', 'outbound'] as const)('%s step renders the checklist', (step) => { + render() + expect(screen.getByText('getting-started-checklist')).toBeInTheDocument() }) - it('chooser → card navigates to /card and tracks the choice', () => { - mockHasCardAccess = true - render() - fireEvent.click(screen.getByText('Start Spending')) - fireEvent.click(screen.getByText('Pay with your card')) - expect(mockPush).toHaveBeenCalledWith('/card') - expect(posthog.capture).toHaveBeenCalledWith('activation_spend_chooser_selected', { choice: 'card' }) - }) - - it('chooser → QR opens the existing scanner and tracks the choice', () => { - mockHasCardAccess = true - render() - fireEvent.click(screen.getByText('Start Spending')) - fireEvent.click(screen.getByText('Scan a QR code')) - expect(mockSetIsQRScannerOpen).toHaveBeenCalledWith(true) - expect(mockPush).not.toHaveBeenCalled() - expect(posthog.capture).toHaveBeenCalledWith('activation_spend_chooser_selected', { choice: 'qr' }) - }) - - it('card access revoked while the chooser is open: chooser closes (no stale card option)', () => { - mockHasCardAccess = true - const { rerender } = render() - fireEvent.click(screen.getByText('Start Spending')) - expect(screen.getByTestId('spend-chooser')).toBeInTheDocument() - mockHasCardAccess = false - rerender() - expect(screen.queryByTestId('spend-chooser')).not.toBeInTheDocument() + it('completed without rejection renders nothing', () => { + const { container } = render() + expect(container.firstChild).toBeNull() }) }) diff --git a/src/components/Home/__tests__/GettingStartedChecklist.test.tsx b/src/components/Home/__tests__/GettingStartedChecklist.test.tsx new file mode 100644 index 0000000000..f9432b882d --- /dev/null +++ b/src/components/Home/__tests__/GettingStartedChecklist.test.tsx @@ -0,0 +1,132 @@ +/** @jest-environment jsdom */ +/** + * GettingStartedChecklist — the 3-item home to-do list. + * + * Contract: always exactly three rows; registration pre-checked; the add-money + * label follows residence and carries the KYC cost only while unverified; the + * third slot is the card when eligible, otherwise the first payment (never a + * dangling card step); renders nothing once everything is done. + */ +import React from 'react' +import { render as rtlRender, screen, fireEvent } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' +import GettingStartedChecklist from '@/components/Home/GettingStartedChecklist' + +const render = () => rtlRender(, { wrapper: IntlWrapper }) + +const mockPush = jest.fn() +jest.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) + +const mockSetIsQRScannerOpen = jest.fn() +jest.mock('@/context/ModalsContext', () => ({ + useModalsContext: () => ({ setIsQRScannerOpen: mockSetIsQRScannerOpen }), +})) + +let mockUser: { + user?: { activationMilestone?: string } + residence?: { declared: string | null; verified: string | null } +} | null = null +jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: mockUser }) })) + +let mockRestrictions = { banking: false, card: false } +jest.mock('@/hooks/useResidenceRestrictions', () => ({ + useResidenceRestrictions: () => mockRestrictions, +})) + +let mockIsEligible: boolean | undefined = true +jest.mock('@/hooks/useCardInfo', () => ({ useCardInfo: () => ({ isEligible: mockIsEligible }) })) + +let mockOverview: unknown = null +jest.mock('@/hooks/useRainCardOverview', () => ({ useRainCardOverview: () => ({ overview: mockOverview }) })) +jest.mock('@/components/Card/cardState.utils', () => ({ + findActiveCard: (overview: unknown) => (overview ? { id: 'card-1' } : null), +})) + +describe('GettingStartedChecklist', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUser = { user: { activationMilestone: 'registered' }, residence: { declared: 'BR', verified: null } } + mockRestrictions = { banking: false, card: false } + mockIsEligible = true + mockOverview = null + }) + + it('renders exactly three rows with registration pre-checked', () => { + render() + expect(screen.getAllByRole('button')).toHaveLength(3) + expect(screen.getByText('Create your account')).toBeInTheDocument() + expect(screen.getByText('Done. Your money has a username now')).toBeInTheDocument() + }) + + it.each([ + ['BR', 'Add money with PIX'], + ['MX', 'Add money via SPEI'], + ['US', 'Add money from your bank'], + ['DE', 'Add money via SEPA'], + ['NG', 'Add money'], + ])('the add-money label follows residence: %s → %s', (iso2, label) => { + mockUser = { user: { activationMilestone: 'registered' }, residence: { declared: iso2, verified: null } } + render() + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it('carries the KYC cost only while unverified', () => { + render() + expect(screen.getByText('Bank deposits need a one-time ID check · about 10 min')).toBeInTheDocument() + mockUser = { user: { activationMilestone: 'verified' }, residence: { declared: 'BR', verified: 'BR' } } + render() + expect(screen.getAllByText(/Bank deposits need/).length).toBe(1) // only the first render's copy + }) + + it('marks add money done once funded', () => { + mockUser = { user: { activationMilestone: 'funded' }, residence: { declared: 'BR', verified: 'BR' } } + render() + const addMoney = screen.getByText('Add money with PIX').closest('button') + expect(addMoney).toBeDisabled() + }) + + it('third slot is the card when eligible, and it routes to /card', () => { + render() + fireEvent.click(screen.getByText('Get your Peanut card')) + expect(mockPush).toHaveBeenCalledWith('/card') + expect(screen.queryByText('Make your first payment')).not.toBeInTheDocument() + }) + + it('third slot falls back to first payment when the card is unavailable, opening the scanner', () => { + mockRestrictions = { banking: false, card: true } + render() + expect(screen.queryByText('Get your Peanut card')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Make your first payment')) + expect(mockSetIsQRScannerOpen).toHaveBeenCalledWith(true) + }) + + it('ineligible card (server says no) also falls back to first payment', () => { + mockIsEligible = false + render() + expect(screen.queryByText('Get your Peanut card')).not.toBeInTheDocument() + expect(screen.getByText('Make your first payment')).toBeInTheDocument() + }) + + it('unknown eligibility (still loading) never shows the card step', () => { + // The first-payment step is always a valid action; a card step the + // server may yet deny is not. Undefined must not read as eligible. + mockIsEligible = undefined + render() + expect(screen.queryByText('Get your Peanut card')).not.toBeInTheDocument() + expect(screen.getByText('Make your first payment')).toBeInTheDocument() + }) + + it('renders nothing once every item is done', () => { + mockUser = { user: { activationMilestone: 'funded' }, residence: { declared: 'BR', verified: 'BR' } } + mockOverview = { cards: [{}] } // findActiveCard mock: truthy overview = active card + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('add money taps into /add-money', () => { + render() + fireEvent.click(screen.getByText('Add money with PIX')) + expect(mockPush).toHaveBeenCalledWith('/add-money') + }) +}) diff --git a/src/components/IdentityVerification/UnlockMethodModal.tsx b/src/components/IdentityVerification/UnlockMethodModal.tsx new file mode 100644 index 0000000000..fb744085bd --- /dev/null +++ b/src/components/IdentityVerification/UnlockMethodModal.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useTranslations } from 'next-intl' +import ActionModal from '../Global/ActionModal' +import KycPrepChecklist, { type KycPrepPath } from '@/components/Kyc/KycPrepChecklist' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' + +interface UnlockMethodModalProps { + visible: boolean + onClose: () => void + onUnlock: () => void + /** Display label of the tapped method row (already localized). */ + methodLabel: string | null + /** Which prep checklist applies: extended for Manteca (BR/AR), standard elsewhere. */ + path?: KycPrepPath + isLoading?: boolean +} + +/** + * Method-worded unlock sheet for the Unlock payments screen. The tap promised + * a product ("SEPA transfers · Unlock"), so the sheet speaks about that + * product — never about regions. The body is the prep checklist: what to have + * ready and how long it takes, stated BEFORE the SDK opens, so nobody starts + * the check and then goes hunting for documents halfway through. + */ +const UnlockMethodModal = ({ + visible, + onClose, + onUnlock, + methodLabel, + path = 'standard', + isLoading, +}: UnlockMethodModalProps) => { + const t = useTranslations('profile.unlockPayments.unlockModal') + const tPrep = useTranslations('kyc.prep') + const tCommon = useTranslations('common') + + return ( + } + descriptionClassName="text-black" + icon="shield" + iconContainerClassName="bg-action-primary" + iconProps={{ className: 'text-black' }} + ctas={[ + { + shadowSize: '4', + icon: 'check-circle', + text: isLoading ? tCommon('loading') : tPrep('startCta'), + disabled: isLoading, + onClick: onUnlock, + variant: 'purple', + }, + { + text: t('notNow'), + onClick: onClose, + variant: 'stroke', + }, + ]} + footer={} + /> + ) +} + +export default UnlockMethodModal diff --git a/src/components/IdentityVerification/UnlockRegionModal.tsx b/src/components/IdentityVerification/UnlockRegionModal.tsx index 7ab93a35d2..fb3d20334d 100644 --- a/src/components/IdentityVerification/UnlockRegionModal.tsx +++ b/src/components/IdentityVerification/UnlockRegionModal.tsx @@ -2,7 +2,7 @@ import { useTranslations } from 'next-intl' import ActionModal from '../Global/ActionModal' -import InfoCard from '../Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { Icon } from '../Global/Icons/Icon' import { type Region } from '@/utils/regions.utils' import { useRegionLabel } from '@/hooks/useRegionLabel' @@ -61,7 +61,7 @@ const UnlockRegionModal = ({ description={

{t.rich('unlockDescription', bold)}

} descriptionClassName="text-black" icon="shield" - iconContainerClassName="bg-primary-1" + iconContainerClassName="bg-action-primary" iconProps={{ className: 'text-black' }} ctas={[ { @@ -74,17 +74,11 @@ const UnlockRegionModal = ({ ]} content={
-

{t('whatYoullUnlock')}

- +

{t('whatYoullUnlock')}

+
- -

{tKyc('doesntStoreDocumentsPeriod')}

+ +

{tKyc('doesntStoreDocumentsPeriod')}

} diff --git a/src/components/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx index 9309eb64a0..a1befa5669 100644 --- a/src/components/Invites/InvitesPage.test.tsx +++ b/src/components/Invites/InvitesPage.test.tsx @@ -65,23 +65,9 @@ jest.mock('@/hooks/useGuestStoreHandoff', () => ({ })) jest.mock('@/services/badge-campaigns', () => ({ claimAndSettlePendingBadgeCampaigns: (badgeCampaigns: readonly string[]) => mockClaimBadgeCampaigns(badgeCampaigns), - destinationForConfirmedBadgeCampaignAcquisition: ( - claims: Array<{ outcome: string; acquisition?: { destination: string } }> - ) => { - const destinations = new Set( - claims - .filter( - (claim) => - (claim.outcome === 'awarded' || claim.outcome === 'already_owned') && - !!claim.acquisition && - claim.acquisition.destination !== 'normal_app' - ) - .map((claim) => claim.acquisition!.destination) - ) - return destinations.size === 1 && destinations.has('offramp_migration') - ? '/add-money/crypto?network=EVM&source=offramp' - : '/home' - }, + // every destination maps to /home since the offramp migration surface + // was removed (TASK-20535); mirror the real service + destinationForConfirmedBadgeCampaignAcquisition: () => '/home', isConfirmedBadgeCampaignClaim: (claim: { outcome: string }) => claim.outcome === 'awarded' || claim.outcome === 'already_owned', isUnavailableBadgeCampaignClaim: (claim: { outcome: string }) => @@ -123,9 +109,9 @@ jest.mock('./InvitesPageLayout', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -jest.mock('../Global/PeanutLoading', () => ({ +jest.mock('../Global/Loading', () => ({ __esModule: true, - default: () =>
Loading
, + default: (props: any) => (props.variant === 'mascot' ?
Loading
:
), })) jest.mock('../Payment/Views/Error.validation.view', () => ({ __esModule: true, @@ -205,7 +191,7 @@ describe('invite and badge campaign routing boundaries', () => { render() await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['offramp'])) - await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) }) it('falls back to the normal app when code-only legacy acquisition is unconfirmed', async () => { @@ -301,7 +287,7 @@ describe('invite and badge campaign routing boundaries', () => { render() await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith([badgeCampaign])) - await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) }) it.each([ @@ -353,7 +339,9 @@ describe('invite and badge campaign routing boundaries', () => { await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/profile/alice')) }) - it('lets a confirmed bespoke campaign destination override a personal inviter profile', async () => { + // no campaign carries a bespoke destination anymore (offramp migration + // surface removed), so the inviter profile keeps navigation + it('keeps the personal inviter profile when a confirmed claim resolves the default destination', async () => { mockSearch = 'code=alice&badge_campaign=offramp' mockQueryResult.data = { success: true, @@ -378,7 +366,7 @@ describe('invite and badge campaign routing boundaries', () => { render() - await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/profile/alice')) }) it('preserves a safe financial continuation over campaign and inviter navigation', async () => { @@ -420,7 +408,8 @@ describe('invite and badge campaign routing boundaries', () => { render() await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/claim?step=claim&id=payment-1')) - expect(mockPush).not.toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp') + // exactly one navigation: campaign and inviter destinations must not fire + expect(mockPush).toHaveBeenCalledTimes(1) }) it('shows Invalid Invite only for an invalid code with no independent campaign', async () => { diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 3dfbe1225f..200a650b36 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -1,9 +1,9 @@ 'use client' import { Suspense, useEffect, useMemo, useRef, useState } from 'react' -import PeanutLoading from '../Global/PeanutLoading' +import Loading from '../Global/Loading' import ValidationErrorView from '../Payment/Views/Error.validation.view' import InvitesPageLayout from './InvitesPageLayout' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Button } from '@/components/0_Bruddle/Button' import { PeanutWavingHello } from '@/assets/mascot' import { useRouter, useSearchParams } from 'next/navigation' @@ -306,12 +306,12 @@ function InvitePageContent() { }, [isDeadBareLink, router]) if (isClaimingBadgeCampaigns || !shouldShowContent || isDeadBareLink) { - return + return } if (showsInvalidInvite) { return ( -
+
-
-

{title}

-

{description}

+
+

{title}

+

{description}

@@ -371,7 +371,7 @@ function InvitePageContent() { export default function InvitesPage() { return ( - }> + }> ) diff --git a/src/components/Invites/InvitesPageLayout.tsx b/src/components/Invites/InvitesPageLayout.tsx index 6a6270f7a2..0bd570ae8f 100644 --- a/src/components/Invites/InvitesPageLayout.tsx +++ b/src/components/Invites/InvitesPageLayout.tsx @@ -4,7 +4,7 @@ import { RAGDOLL_ENABLED } from '@/constants/ragdoll.consts' import dynamic from 'next/dynamic' import Image from 'next/image' import { useTranslations } from 'next-intl' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import CloudsBackground from '../0_Bruddle/CloudsBackground' import starImage from '@/assets/icons/star.png' @@ -42,7 +42,7 @@ const InvitesPageLayout = ({ image, children, showRagdoll = false }: InvitesPage // h-full against this pane and would collapse to 0. // md:h-[100dvh] below covers the desktop side. 'h-[55dvh]', - 'relative flex w-full flex-row items-center justify-center overflow-hidden bg-secondary-3/100 px-4 md:h-[100dvh] md:w-7/12 md:px-6' + 'relative flex w-full flex-row items-center justify-center overflow-hidden bg-blue-300/100 px-4 md:h-[100dvh] md:w-7/12 md:px-6' )} > {/* render animated star decorations */} @@ -64,7 +64,7 @@ const InvitesPageLayout = ({ image, children, showRagdoll = false }: InvitesPage {showRagdoll && PeanutRagdoll ? ( diff --git a/src/components/Invites/JoinWaitlistPage.test.tsx b/src/components/Invites/JoinWaitlistPage.test.tsx index e339ad1508..01922aeab3 100644 --- a/src/components/Invites/JoinWaitlistPage.test.tsx +++ b/src/components/Invites/JoinWaitlistPage.test.tsx @@ -91,11 +91,10 @@ jest.mock('@/components/0_Bruddle/Button', () => ({ ), })) -jest.mock('../Global/ErrorAlert', () => ({ +jest.mock('../Global/Loading', () => ({ __esModule: true, - default: ({ description }: { description: string }) =>
{description}
, + default: (props: any) => (props.variant === 'mascot' ?
Loading
:
), })) -jest.mock('../Global/PeanutLoading', () => ({ __esModule: true, default: () =>
Loading
})) jest.mock('@/components/0_Bruddle/BaseInput', () => ({ BaseInput: () => })) describe('JoinWaitlistPage invite onboarding boundary', () => { diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 4ed1844421..6551ffba17 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -1,17 +1,19 @@ 'use client' import { useAuth } from '@/context/authContext' +import { FieldError } from '@/components/0_Bruddle/FieldError' +import { Notification } from '@/components/0_Bruddle/Notification' import { invitesApi } from '@/services/invites' import { useEffect, useRef, useState } from 'react' import InvitesPageLayout from './InvitesPageLayout' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import ValidatedInput from '../Global/ValidatedInput' import { Button } from '@/components/0_Bruddle/Button' -import ErrorAlert from '../Global/ErrorAlert' +import { LinkButton } from '@/components/0_Bruddle/LinkButton' import { PeanutWavingHello, PeanutPointing } from '@/assets/mascot' import { useRouter } from 'next/navigation' import { useQuery } from '@tanstack/react-query' -import PeanutLoading from '../Global/PeanutLoading' +import Loading from '../Global/Loading' import { useSetupStore } from '@/redux/hooks' import { useNotifications } from '@/hooks/useNotifications' import { updateUserById } from '@/app/actions/users' @@ -290,40 +292,42 @@ const JoinWaitlistPage = () => { const stepImage = step === 'jail' ? PeanutPointing.src : PeanutWavingHello.src - if (isAutoAccepting) return + if (isAutoAccepting) return return (
{/* Step 1: Email Collection */} {step === 'email' && ( -
-

{t('emailTitle')}

-

{t('emailDescription')}

- - { - setEmailValue(e.target.value) - setEmailError('') - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit() - }} - className="h-12" - /> - - {emailError && } +
+

{t('emailTitle')}

+

{t('emailDescription')}

+ + {/* input + its field error form one column, 4px apart (form-field board 17788:19179) */} +
+ { + setEmailValue(e.target.value) + setEmailError('') + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit() + }} + className="h-12" + /> + {emailError && {emailError}} +
{emailError && ( - + )}
)} {/* Step 2: Enable Notifications (skippable) */} {step === 'notifications' && ( -
-

{t('notificationsTitle')}

-

{t('notificationsDescription')}

+
+

{t('notificationsTitle')}

+

{t('notificationsDescription')}

- +
)} {/* Step 3: Jail Screen */} - {step === 'jail' && isLoadingWaitlistPosition && } + {step === 'jail' && isLoadingWaitlistPosition && } {step === 'jail' && !isLoadingWaitlistPosition && ( -
-

{t('inviteOnlyTitle')}

+
+

{t('inviteOnlyTitle')}

-

+

{data?.position ? t('inLineWithPosition', { position: data.position }) : t('inLine')}

-

{t('skipTheLine')}

- -
- toInviteCode(v).length >= USERNAME_MIN_LENGTH} - onUpdate={({ value, isValid, isChanging }) => { - setIsValid(isValid) - setIsChanging(isChanging) - setInviteCode(value) - }} - isSetupFlow - isInputChanging={isChanging} - className={twMerge( - !isValid && !isChanging && !!inviteCode && 'border-error dark:border-error', - isValid && - !isChanging && - !!inviteCode && - 'border-secondary-8 dark:border-secondary-8', - 'rounded-sm' - )} - /> - - +

{t('skipTheLine')}

+ + {/* input + its field error form one column, 4px apart (form-field board 17788:19179) */} +
+
+ toInviteCode(v).length >= USERNAME_MIN_LENGTH} + onUpdate={({ value, isValid, isChanging }) => { + setIsValid(isValid) + setIsChanging(isChanging) + setInviteCode(value) + }} + isSetupFlow + isInputChanging={isChanging} + className="rounded-sm" + /> + + +
+ + {!isValid && !isChanging && !!inviteCode && ( + {tSetup('waitlist.inviterNotFound')} + )}
- {!isValid && !isChanging && !!inviteCode && ( - - )} - - {error && } + {error && {error}} - +
)}
diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx index 7e717087af..d7d3a60ca1 100644 --- a/src/components/Kyc/AdvisoryPreemptModal.tsx +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -8,19 +8,26 @@ interface AdvisoryPreemptModalProps { isLoading?: boolean /** Launch the verification flow. */ onCompleteNow: () => void + /** Continue with the transfer without completing the requirement now. */ + onDoLater: () => void + /** Plain dismiss (X / backdrop): close without proceeding anywhere. */ + onClose: () => void } /** - * Mandatory pre-empt for a pending Bridge verification requirement on the bank - * rails. Non-closable and non-skippable: the user must complete the verification - * before they can continue with a bank transfer. There is no "Not now" / X / - * backdrop dismiss — the only way forward is "Complete now". + * Pre-empt for a pending Bridge verification requirement on the bank rails. + * The rail is still ENABLED until the effective date, so the honest shape is + * an informed choice, not a trap: "Complete now" launches the verification, + * "Do this later" is a real button that continues the transfer, and the + * deadline (when known) says exactly when later stops being an option. */ export default function AdvisoryPreemptModal({ visible, effectiveDate, isLoading = false, onCompleteNow, + onDoLater, + onClose, }: AdvisoryPreemptModalProps) { const t = useTranslations('kyc') const format = useFormatter() @@ -37,12 +44,7 @@ export default function AdvisoryPreemptModal({ return ( {}} - preventClose - hideModalCloseButton + onClose={onClose} icon="badge" title={t('advisory.title')} description={ @@ -56,6 +58,12 @@ export default function AdvisoryPreemptModal({ shadowSize: '4', disabled: isLoading, }, + { + text: t('advisory.doLater'), + onClick: onDoLater, + variant: 'stroke', + disabled: isLoading, + }, ]} /> ) diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index b35c5c8a3b..0ad7c5a3ec 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -135,7 +135,7 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip, reasonCode }: Bridg text: t('bridgeTos.notNow'), onClick: onSkip, variant: 'transparent' as const, - className: 'underline text-sm font-medium w-full h-fit mt-3', + className: 'underline text-body-s w-full h-fit mt-3', }, ]} /> diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index 89e93a363a..90ac170a29 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -3,9 +3,15 @@ import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import ActionModal from '@/components/Global/ActionModal' +import { useKycDegraded } from '@/hooks/useKycDegraded' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import posthog from 'posthog-js' import { reasonCodeKey } from '@/constants/capability-reason-labels.consts' import { type IconName } from '@/components/Global/Icons/Icon' import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' +import KycPrepChecklist from '@/components/Kyc/KycPrepChecklist' +import { useIdentityVerification } from '@/hooks/useIdentityVerification' +import { KycRegionRestrictedModal } from '@/components/Kyc/modals/KycRegionRestrictedModal' interface InitiateKycModalProps { visible: boolean @@ -23,6 +29,9 @@ interface InitiateKycModalProps { reasonCode?: string /** country name shown in cross_region variant (e.g. "Brazil", "Argentina") */ regionName?: string + /** Which prep checklist the SDK-bound variants show: extended for the + * Manteca (BR/AR) flows, standard elsewhere. */ + prepPath?: 'standard' | 'extended' } // confirmation modal shown before starting identity check or document resubmission. @@ -42,10 +51,22 @@ export const InitiateKycModal = ({ providerMessage, reasonCode, regionName, + prepPath = 'standard', }: InitiateKycModalProps) => { const t = useTranslations('kyc') const tCommon = useTranslations('common') const tIdentity = useTranslations('identity') + // Enforced HERE rather than at each call site on purpose. Six gates open this + // modal (add-money, withdraw, the two bank pages, and both Manteca flow + // managers), and each one computes its variant from a rail gate that cannot + // see WHY identity failed: a region-restricted user reads as `needs-identity` + // (no rail + unverified) and would be offered "Unlock now" → the Sumsub SDK + // → the same guaranteed rejection, or as `blocked-rejection` → contact + // support. Both contradict the region screen. Short-circuiting at the one + // component they all share makes the invariant impossible for a future call + // site to miss. + const { isRegionRestricted } = useIdentityVerification() + const isKycDegraded = useKycDegraded() const reasonKey = reasonCodeKey(reasonCode) const resolvedProviderMessage = reasonKey ? tIdentity(reasonKey) : providerMessage const isProviderRejection = variant === 'provider_rejection' @@ -127,15 +148,67 @@ export const InitiateKycModal = ({ const cta = getCta() + // Outage outranks everything, including the region screen: whatever the + // user's state, opening the SDK during a verification outage burns an + // attempt against a wall. Same choke-point rationale as the region check + // below — six gates share this modal, so the invariant lives here once. + if (isKycDegraded) { + return ( + { + posthog.capture(ANALYTICS_EVENTS.KYC_DEGRADED_NOTIFY_REQUESTED) + // cohort tag: ops pushes to exactly these users when + // the flag flips back off + posthog.setPersonProperties({ kyc_down_notify_requested: true }) + onClose() + }, + }, + { text: tCommon('gotIt'), variant: 'stroke', onClick: onClose }, + ]} + /> + ) + } + + // Render the ONE definition of this screen rather than a second copy of it: + // a local re-implementation could drift from the drawer/profile surface, and + // "these two never disagree" is the property this whole change rests on. + if (isRegionRestricted) { + return + } + return ( +

{getDescription()}

+ +
+ ) : ( + getDescription() + ) + } preventClose icon={(error || isBlocked || isRestartIdentity || isRegionUnavailable ? 'alert' : 'badge') as IconName} - iconContainerClassName={isBlocked || isRestartIdentity || isRegionUnavailable ? 'bg-yellow-1' : ''} + iconContainerClassName={isBlocked || isRestartIdentity || isRegionUnavailable ? 'bg-action-secondary' : ''} modalPanelClassName="max-w-full m-2" ctaClassName="grid grid-cols-1 gap-3" ctas={[ diff --git a/src/components/Kyc/KYCStatusDrawerItem.tsx b/src/components/Kyc/KYCStatusDrawerItem.tsx index 58d34a272e..110df7316c 100644 --- a/src/components/Kyc/KYCStatusDrawerItem.tsx +++ b/src/components/Kyc/KYCStatusDrawerItem.tsx @@ -1,18 +1,20 @@ import { useTranslations } from 'next-intl' -import Card from '@/components/Global/Card' import StatusBadge, { type StatusType } from '../Global/Badges/StatusBadge' import { KYCStatusIcon } from './KYCStatusIcon' +// centered drawer head per the TX Details chrome (board 17490:115877): +// icon bubble → type line → status badge. The rows below it live in the +// state views' receipt Card. export const KYCStatusDrawerItem = ({ status, customText }: { status: StatusType; customText?: string }) => { const t = useTranslations('kyc') return ( - +
-
-

{t('identityVerification')}

+
+

{t('identityVerification')}

- +
) } diff --git a/src/components/Kyc/KYCStatusIcon.tsx b/src/components/Kyc/KYCStatusIcon.tsx index 5c42367099..35db7b6064 100644 --- a/src/components/Kyc/KYCStatusIcon.tsx +++ b/src/components/Kyc/KYCStatusIcon.tsx @@ -1,5 +1,5 @@ import AvatarWithBadge from '../Profile/AvatarWithBadge' export const KYCStatusIcon = () => { - return + return } diff --git a/src/components/Kyc/KycFailedContent.tsx b/src/components/Kyc/KycFailedContent.tsx index 3cefdb8240..18ffcbd5ba 100644 --- a/src/components/Kyc/KycFailedContent.tsx +++ b/src/components/Kyc/KycFailedContent.tsx @@ -1,6 +1,6 @@ import { useTranslations } from 'next-intl' import { RejectLabelsList } from './RejectLabelsList' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' interface KycFailedContentProps { rejectLabels?: string[] | null @@ -13,9 +13,7 @@ export const KycFailedContent = ({ rejectLabels, isTerminal }: KycFailedContentP const t = useTranslations('kyc') if (isTerminal) { - return ( - - ) + return {t('terminalDescription')} } return diff --git a/src/components/Kyc/KycPrepChecklist.tsx b/src/components/Kyc/KycPrepChecklist.tsx new file mode 100644 index 0000000000..ae9b6413e1 --- /dev/null +++ b/src/components/Kyc/KycPrepChecklist.tsx @@ -0,0 +1,48 @@ +'use client' + +import { Icon } from '@/components/Global/Icons/Icon' +import { useTranslations } from 'next-intl' + +export type KycPrepPath = 'standard' | 'extended' + +/** + * The "before you start" prep content shown before the verification SDK + * opens: what to have ready, how long it takes, and the heads-up that a + * follow-up document can be requested. The extended path (Manteca BR/AR) + * adds the tax ID and the regulatory questions the provider asks there. + * Rendered inside the unlock/initiate modals, never as its own route, so + * every entry into the SDK passes through it. + */ +const KycPrepChecklist = ({ path }: { path: KycPrepPath }) => { + const t = useTranslations('kyc.prep') + const items = path === 'extended' ? (['id', 'selfie', 'taxId', 'questions'] as const) : (['id', 'selfie'] as const) + + return ( +
+

{t(`intro.${path}`)}

+
+ {items.map((item) => ( +
+ + + {t(`items.${item}.title`)} + + {t(`items.${item}.body`)} + + +
+ ))} +
+
+ {t('howLongLabel')} + {t(`howLong.${path}`)} +
+

{t('extraDocNote')}

+
+ ) +} + +export default KycPrepChecklist diff --git a/src/components/Kyc/KycRegionRestrictedContent.tsx b/src/components/Kyc/KycRegionRestrictedContent.tsx new file mode 100644 index 0000000000..57f0e9e71b --- /dev/null +++ b/src/components/Kyc/KycRegionRestrictedContent.tsx @@ -0,0 +1,48 @@ +import { useTranslations } from 'next-intl' +import { useRouter } from 'next/navigation' +import { useCallback } from 'react' + +/** + * Copy + CTA for a terminal rejection caused by the document's jurisdiction. + * + * Shared by the drawer state and the modal so the two can never drift into + * telling the same user different things. Three rules hold everywhere this + * renders, and each is load-bearing: + * + * 1. NO retry. The document's country is unacceptable, not the document, so + * "try again" is a promise we know we cannot keep. + * 2. NO contact-support CTA. Support cannot lift a jurisdictional block; the + * ticket only costs the user their time and ours. + * 3. NO country named. The restricted set lives in the Sumsub dashboard, and + * the string says "your country" — so compliance can change the list + * without a copy change, a re-translation, or a deploy. + * + * What is left is the one useful thing: tell them plainly, and hand them the + * part of the app that still works. + */ + +/** Where the CTA sends them — the capability they keep. */ +export const REGION_RESTRICTED_CTA_HREF = '/send' + +export const useRegionRestrictedCta = (onNavigate?: () => void) => { + const t = useTranslations('kyc.regionRestricted') + const router = useRouter() + + const onClick = useCallback(() => { + onNavigate?.() + router.push(REGION_RESTRICTED_CTA_HREF) + }, [onNavigate, router]) + + return { label: t('cta'), onClick } +} + +export const KycRegionRestrictedContent = () => { + const t = useTranslations('kyc.regionRestricted') + + return ( +
+

{t('description')}

+

{t('stillAvailable')}

+
+ ) +} diff --git a/src/components/Kyc/KycReverificationPendingModal.tsx b/src/components/Kyc/KycReverificationPendingModal.tsx index 7b6fb0a765..eb1e54f04a 100644 --- a/src/components/Kyc/KycReverificationPendingModal.tsx +++ b/src/components/Kyc/KycReverificationPendingModal.tsx @@ -28,7 +28,7 @@ export const KycReverificationPendingModal = ({ isOpen, onClose, message }: KycR visible={isOpen} onClose={onClose} icon="clock" - iconContainerClassName="bg-yellow-1 text-black" + iconContainerClassName="bg-action-secondary text-black" title={t('reverificationPending.title')} description={

{message ?? t('reverificationPending.description')}

} ctas={[ diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 52de16797e..f2c0372c0e 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -1,13 +1,15 @@ import { KycActionRequired } from './states/KycActionRequired' import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' +import { KycRegionRestricted } from './states/KycRegionRestricted' import { KycProcessing } from './states/KycProcessing' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' -import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' +import { Drawer, DrawerContent } from '../Global/Drawer' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { useCallback } from 'react' import { useIdentityVerification } from '@/hooks/useIdentityVerification' import { useTranslations } from 'next-intl' +import { useModalsContext } from '@/context/ModalsContext' interface KycStatusDrawerProps { isOpen: boolean @@ -21,8 +23,9 @@ interface KycStatusDrawerProps { // provider names, no rail reads. Resuming/retrying launches Sumsub via the kept // useMultiPhaseKycFlow plumbing. export const KycStatusDrawer = ({ isOpen, onClose, onKeepMounted }: KycStatusDrawerProps) => { - const { identity, status } = useIdentityVerification() + const { identity, status, isRegionRestricted, isTerminalFailure } = useIdentityVerification() const t = useTranslations('kyc') + const { setIsSupportModalOpen } = useModalsContext() // close drawer and release the keep-mounted hold const handleFlowDone = useCallback(() => { @@ -63,6 +66,11 @@ export const KycStatusDrawer = ({ isOpen, onClose, onKeepMounted }: KycStatusDra /> ) case 'failed': + // Region-restricted rejections are terminal in a way KycFailed's + // "Retry verification" button contradicts — branch before it. + if (isRegionRestricted) { + return + } return ( setIsSupportModalOpen(true)} /> ) default: @@ -85,10 +95,9 @@ export const KycStatusDrawer = ({ isOpen, onClose, onKeepMounted }: KycStatusDra return ( <> - - {t('statusDrawerTitle')} + {renderContent()} - {sumsubFlow.error &&

{sumsubFlow.error}

} + {sumsubFlow.error &&

{sumsubFlow.error}

}
diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index e1e332df3b..e60b7fd321 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -1,9 +1,8 @@ import { useState, useMemo, useCallback } from 'react' -import Card from '@/components/Global/Card' +import { ListItem } from '@/components/0_Bruddle/ListItem' import { type CardPosition } from '@/components/Global/Card/card.utils' import { KycStatusDrawer } from './KycStatusDrawer' import { type HTMLAttributes } from 'react' -import { twMerge } from 'tailwind-merge' import StatusPill from '../Global/StatusPill' import { KYCStatusIcon } from './KYCStatusIcon' import { useIdentityVerification } from '@/hooks/useIdentityVerification' @@ -67,26 +66,21 @@ export const KycStatusItem = ({ return ( <> - { setIsDrawerOpen(true) }} - className={twMerge('cursor-pointer', className)} - > -
-
- -
-

{t('identityVerification')}

-
-

{subtitle}

- -
-
+ className={className} + leading={} + title={t('identityVerification')} + body={ +
+ {subtitle} +
-
- + } + /> {(isDrawerOpen || keepDrawerMounted) && ( { + if (!(isOpen && phase === 'verifying')) { + setStillGoing(false) + return + } + const timer = setTimeout(() => setStillGoing(true), 90_000) + return () => clearTimeout(timer) + }, [isOpen, phase]) + const handleGoHome = () => { onClose() router.push('/home') } if (phase === 'verifying') { + // Dismissible on purpose (no preventClose): waiting is optional. The + // decision arrives as a notification either way, so closing costs + // nothing — the CTA says exactly that. return ( {t('progress.verifyingDescription')}

} + description={ +

{stillGoing ? t('progress.verifyingStillGoing') : t('progress.verifyingDescription')}

+ } ctas={[ { - text: tCommon('goToHome'), + text: t('progress.closeAndNotify'), onClick: handleGoHome, variant: 'purple', className: 'w-full', shadowSize: '4', }, ]} - preventClose - hideModalCloseButton footer={} /> ) @@ -101,7 +117,7 @@ export const KycVerificationInProgressModal = ({ visible={isOpen} onClose={onClose} isLoadingIcon - iconContainerClassName="bg-yellow-1 text-black" + iconContainerClassName="bg-action-secondary text-black" title={title} description={description} ctas={ @@ -131,7 +147,7 @@ export const KycVerificationInProgressModal = ({ visible={isOpen} onClose={onClose} icon={'check' as IconName} - iconContainerClassName="bg-success-1 text-white" + iconContainerClassName="bg-green-500 text-white" title={t('progress.bridgeTosTitle')} description={description} ctas={[ @@ -159,7 +175,7 @@ export const KycVerificationInProgressModal = ({ visible={isOpen} onClose={onClose} icon={'check' as IconName} - iconContainerClassName="bg-success-1 text-white" + iconContainerClassName="bg-green-500 text-white" title={t('progress.completeTitle')} description={t('progress.completeDescription')} ctas={[ diff --git a/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx b/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx index c799ad0f19..2e300f1060 100644 --- a/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx +++ b/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx @@ -1,12 +1,12 @@ import { useTranslations } from 'next-intl' import { Icon } from '@/components/Global/Icons/Icon' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' export const PeanutDoesntStoreAnyPersonalInformation = ({ className }: { className?: string }) => { const t = useTranslations('kyc') return ( -
+
{t('doesntStoreDocuments')}
diff --git a/src/components/Kyc/ProvideEmailStep.tsx b/src/components/Kyc/ProvideEmailStep.tsx index 51097d2e8b..dd19e34848 100644 --- a/src/components/Kyc/ProvideEmailStep.tsx +++ b/src/components/Kyc/ProvideEmailStep.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import ActionModal from '@/components/Global/ActionModal' +import { FieldError } from '@/components/0_Bruddle/FieldError' import ProfileEditField from '@/components/Profile/components/ProfileEditField' import { updateUserById } from '@/app/actions/users' import { useAuth } from '@/context/authContext' @@ -87,7 +88,7 @@ export default function ProvideEmailStep({ visible, onComplete, onSkip }: Provid text: t('provideEmail.notNow'), onClick: onSkip, variant: 'transparent' as const, - className: 'underline text-sm font-medium w-full h-fit mt-3', + className: 'underline text-body-s w-full h-fit mt-3', }, ]} content={ @@ -99,7 +100,7 @@ export default function ProvideEmailStep({ visible, onComplete, onSkip }: Provid placeholder={t('provideEmail.emailPlaceholder')} type="email" /> - {error &&

{error}

} + {error && {error}}
} /> diff --git a/src/components/Kyc/RejectLabelsList.tsx b/src/components/Kyc/RejectLabelsList.tsx index e869d39b38..7f557b2dbf 100644 --- a/src/components/Kyc/RejectLabelsList.tsx +++ b/src/components/Kyc/RejectLabelsList.tsx @@ -1,9 +1,9 @@ import { useMemo } from 'react' import { useTranslations } from 'next-intl' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { rejectLabelCode } from '@/constants/sumsub-reject-labels.consts' -// renders sumsub reject labels as individual InfoCards, with a generic fallback +// renders sumsub reject labels as individual notifications, with a generic fallback // when no labels are provided. shared between drawer states and modals. export const RejectLabelsList = ({ rejectLabels }: { rejectLabels?: string[] | null }) => { const t = useTranslations('kyc') @@ -23,13 +23,15 @@ export const RejectLabelsList = ({ rejectLabels }: { rejectLabels?: string[] | n }, [labels, t]) if (!reasons) { - return + return {t('rejectLabelsFallbackDescription')} } return (
{reasons.map((reason, i) => ( - + + {reason.description} + ))}
) diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index 132f38f02f..189c4c78d5 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -20,7 +20,7 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu {flow.isLoading ? 'Loading...' : (buttonProps.children ?? 'Start Verification')} - {flow.error &&

{flow.error}

} + {flow.error &&

{flow.error}

} diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index 37b109feba..f749aefcfe 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -378,7 +378,7 @@ const SumsubWebSdkModal = ({ title: t('wrapper.troubleTitle'), description: t('wrapper.troubleDescription'), icon: 'question-mark' as IconName, - iconContainerClassName: 'bg-primary-1', + iconContainerClassName: 'bg-action-primary', ctas: [ { text: t('wrapper.chatWithSupport'), @@ -390,7 +390,7 @@ const SumsubWebSdkModal = ({ text: tCommon('cancel'), onClick: () => setIsHelpModalOpen(false), variant: 'transparent' as ButtonVariant, - className: 'underline text-sm font-medium w-full h-fit mt-3', + className: 'underline text-body-s w-full h-fit mt-3', }, ], } @@ -400,7 +400,7 @@ const SumsubWebSdkModal = ({ title: t('wrapper.exitForNowTitle'), description: t('wrapper.exitForNowDescription'), icon: 'alert' as IconName, - iconContainerClassName: 'bg-secondary-1', + iconContainerClassName: 'bg-action-secondary', ctas: [ { text: t('wrapper.exit'), @@ -415,7 +415,7 @@ const SumsubWebSdkModal = ({ text: tCommon('continue'), onClick: () => setIsHelpModalOpen(false), variant: 'transparent' as ButtonVariant, - className: 'underline text-sm font-medium w-full h-fit mt-3', + className: 'underline text-body-s w-full h-fit mt-3', }, ], } @@ -427,7 +427,7 @@ const SumsubWebSdkModal = ({ visible={visible} onClose={onClose} classWrap="h-full w-full !max-w-none sm:!max-w-[600px] border-none sm:m-auto m-0" - classOverlay={`bg-black bg-opacity-50 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} + classOverlay={`bg-black/50 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} video={false} className={`z-[100] !p-0 md:!p-6 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} classButtonClose="hidden" @@ -446,7 +446,7 @@ const SumsubWebSdkModal = ({ }} className="flex items-center gap-1 p-1" > - + diff --git a/src/components/Kyc/modals/KycActionRequiredModal.tsx b/src/components/Kyc/modals/KycActionRequiredModal.tsx index ab4ec20238..2f465e50c8 100644 --- a/src/components/Kyc/modals/KycActionRequiredModal.tsx +++ b/src/components/Kyc/modals/KycActionRequiredModal.tsx @@ -26,7 +26,7 @@ export const KycActionRequiredModal = ({ visible={visible} onClose={onClose} icon="alert" - iconContainerClassName="bg-yellow-1" + iconContainerClassName="bg-action-secondary" title={t('actionRequiredTitle')} description={t('actionRequiredDescription')} content={ diff --git a/src/components/Kyc/modals/KycFailedModal.tsx b/src/components/Kyc/modals/KycFailedModal.tsx index b3ee737bb7..1bc7e4c0c1 100644 --- a/src/components/Kyc/modals/KycFailedModal.tsx +++ b/src/components/Kyc/modals/KycFailedModal.tsx @@ -39,7 +39,7 @@ export const KycFailedModal = ({ visible={visible} onClose={onClose} icon={'alert'} - iconContainerClassName="bg-yellow-1" + iconContainerClassName="bg-action-secondary" title={isTerminal ? t('failedTitleTerminal') : t('failedTitleRetry')} description={!isTerminal && t('failedDescriptionRetry')} content={ diff --git a/src/components/Kyc/modals/KycProcessingModal.tsx b/src/components/Kyc/modals/KycProcessingModal.tsx index 63fa585b52..cd61dc2840 100644 --- a/src/components/Kyc/modals/KycProcessingModal.tsx +++ b/src/components/Kyc/modals/KycProcessingModal.tsx @@ -16,7 +16,7 @@ export const KycProcessingModal = ({ visible, onClose }: KycProcessingModalProps visible={visible} onClose={onClose} icon="clock" - iconContainerClassName="bg-yellow-1" + iconContainerClassName="bg-action-secondary" title={t('processingTitle')} description={t('processingDescription')} ctas={[ diff --git a/src/components/Kyc/modals/KycRegionRestrictedModal.tsx b/src/components/Kyc/modals/KycRegionRestrictedModal.tsx new file mode 100644 index 0000000000..98839683ab --- /dev/null +++ b/src/components/Kyc/modals/KycRegionRestrictedModal.tsx @@ -0,0 +1,47 @@ +import { useTranslations } from 'next-intl' +import ActionModal from '@/components/Global/ActionModal' +import { KycRegionRestrictedContent, useRegionRestrictedCta } from '../KycRegionRestrictedContent' + +interface KycRegionRestrictedModalProps { + visible: boolean + onClose: () => void +} + +/** + * Terminal rejection caused by the document's jurisdiction. + * + * Deliberately takes no `onRetry` and no `onContactSupport` — the two endings + * this screen exists to replace. Not offering them is the feature, so they are + * absent from the props rather than merely unused, and a future caller cannot + * quietly reintroduce either one. + */ +export const KycRegionRestrictedModal = ({ visible, onClose }: KycRegionRestrictedModalProps) => { + const t = useTranslations('kyc.regionRestricted') + const cta = useRegionRestrictedCta(onClose) + + return ( + + +
+ } + modalPanelClassName="max-w-full m-2" + ctaClassName="grid grid-cols-1 gap-3" + ctas={[ + { + text: cta.label, + onClick: cta.onClick, + variant: 'purple', + shadowSize: '4', + className: 'h-11', + }, + ]} + /> + ) +} diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx index 7145de4d9f..cb31c46777 100644 --- a/src/components/Kyc/states/KycActionRequired.tsx +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -1,7 +1,7 @@ import { useTranslations } from 'next-intl' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { RejectLabelsList } from '../RejectLabelsList' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { Button } from '@/components/0_Bruddle/Button' import type { IconName } from '@/components/Global/Icons/Icon' @@ -39,7 +39,7 @@ export const KycActionRequired = ({ {isGenericAction ? ( - + {t('actionMessageActionRequired')} ) : ( )} diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 65b0222701..9983bd1676 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -1,28 +1,51 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' -import { RejectLabelsList } from '../RejectLabelsList' +import { KycFailedContent } from '../KycFailedContent' import Card from '@/components/Global/Card' import { useMemo } from 'react' import { useFormatter, useTranslations } from 'next-intl' // this component shows the identity-verification status when it's failed/rejected. +// +// `isTerminal` decides the ending, and getting it wrong is user-visible in both +// directions. Terminal (fraud, sanctions, age, forgery) means the decision is +// made: no retry — it cannot pass — and no reject labels, because naming the +// cause carries compliance exposure and tips off the people it describes. +// Support IS offered there, because a human can review a misclassification. +// Non-terminal means our check errored, so a retry is genuinely worth offering. +// // reads the provider-agnostic identity fields + normalized reject labels. The // backend's actionMessage is a pure function of status, so its presence gates the // reason row while the copy itself comes from the catalog. No provider names. +type KycFailedProps = { + actionMessage?: string + rejectLabels?: string[] | null + reviewedAt?: string + onRetry: () => void + isLoading?: boolean +} & ( + | { + // A terminal render drops the retry button, so "Contact support" is the + // only action left — the type forces the container to wire it up. + isTerminal: true + onContactSupport: () => void + } + | { + isTerminal?: false + onContactSupport?: () => void + } +) + export const KycFailed = ({ actionMessage, rejectLabels, reviewedAt, onRetry, isLoading, -}: { - actionMessage?: string - rejectLabels?: string[] | null - reviewedAt?: string - onRetry: () => void - isLoading?: boolean -}) => { + isTerminal = false, + onContactSupport, +}: KycFailedProps) => { const t = useTranslations('kyc') const tCommon = useTranslations('common') const format = useFormatter() @@ -50,18 +73,24 @@ export const KycFailed = ({ {hasReason && }
- + - + {isTerminal ? ( + + ) : ( + + )}
) } diff --git a/src/components/Kyc/states/KycRegionRestricted.tsx b/src/components/Kyc/states/KycRegionRestricted.tsx new file mode 100644 index 0000000000..a6e263ace0 --- /dev/null +++ b/src/components/Kyc/states/KycRegionRestricted.tsx @@ -0,0 +1,45 @@ +import { useMemo } from 'react' +import { useFormatter, useTranslations } from 'next-intl' +import { Button } from '@/components/0_Bruddle/Button' +import Card from '@/components/Global/Card' +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { KycRegionRestrictedContent, useRegionRestrictedCta } from '../KycRegionRestrictedContent' + +/** + * Drawer state for a terminal rejection caused by the document's jurisdiction — + * the sibling of {@link KycFailed}, minus the retry button. + * + * KycFailed offers "Retry verification" to everyone it renders. For this cohort + * that button is a promise we cannot keep, which is the whole reason this is a + * separate component rather than a prop on that one: the retry is not + * conditionally hidden here, it does not exist. + */ +export const KycRegionRestricted = ({ reviewedAt, onNavigate }: { reviewedAt?: string; onNavigate?: () => void }) => { + const t = useTranslations('kyc') + const format = useFormatter() + const cta = useRegionRestrictedCta(onNavigate) + + const rejectedOn = useMemo(() => { + if (!reviewedAt) return t('notAvailable') + const date = new Date(reviewedAt) + if (isNaN(date.getTime())) return t('notAvailable') + return format.dateTime(date, { year: 'numeric', month: 'long', day: 'numeric' }) + }, [reviewedAt, format, t]) + + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx b/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx index b398d5fbbe..9216a80488 100644 --- a/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx +++ b/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx @@ -22,18 +22,6 @@ jest.mock('../../KYCStatusDrawerItem', () => ({ KYCStatusDrawerItem: () =>
, })) -// InfoCard is the leaf both branches render through; surface its text so we can -// assert the actual copy. RejectLabelsList is intentionally NOT mocked. -jest.mock('@/components/Global/InfoCard', () => ({ - __esModule: true, - default: ({ title, description }: { title?: string; description?: string }) => ( - <> - {title ?
{title}
: null} - {description ?
{description}
: null} - - ), -})) - describe('KycActionRequired — real reject-label copy', () => { it('renders the DUPLICATE_EMAIL guidance, not the generic resubmit message', () => { render( diff --git a/src/components/Kyc/states/__tests__/KycRegionRestricted.test.tsx b/src/components/Kyc/states/__tests__/KycRegionRestricted.test.tsx new file mode 100644 index 0000000000..a6b8321982 --- /dev/null +++ b/src/components/Kyc/states/__tests__/KycRegionRestricted.test.tsx @@ -0,0 +1,187 @@ +import { fireEvent, screen } from '@testing-library/react' +import { renderWithIntl } from '@/test-utils/intl' +import { type ReactNode } from 'react' +import { KycRegionRestricted } from '../KycRegionRestricted' +import { KycRegionRestrictedModal } from '../../modals/KycRegionRestrictedModal' +import { InitiateKycModal } from '../../InitiateKycModal' + +let mockRegionRestricted = false +jest.mock('@/hooks/useIdentityVerification', () => ({ + useIdentityVerification: () => ({ isRegionRestricted: mockRegionRestricted }), +})) +let mockKycDegraded = false +jest.mock('@/hooks/useKycDegraded', () => ({ useKycDegraded: () => mockKycDegraded })) + +const push = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: (...args: unknown[]) => push(...args) }), +})) + +jest.mock('../../KYCStatusDrawerItem', () => ({ + KYCStatusDrawerItem: () =>
, +})) + +jest.mock('@/components/Payment/PaymentInfoRow', () => ({ + PaymentInfoRow: ({ label, value }: { label: string; value: string }) => ( +
+ {label}: {value} +
+ ), +})) + +jest.mock('@/components/Global/Card', () => ({ + __esModule: true, + default: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +jest.mock('use-haptic', () => ({ useHaptic: () => ({ triggerHaptic: jest.fn() }) })) +jest.mock('@/hooks/useLongPress', () => ({ + useLongPress: () => ({ isLongPressed: false, pressProgress: 0, handlers: {} }), +})) + +beforeEach(() => { + push.mockClear() + mockRegionRestricted = false +}) + +// The surfaces differ (drawer card vs modal) but the promises they make must +// not, so the contract assertions run against both. +const surfaces: Array<[string, () => void]> = [ + ['drawer state', () => renderWithIntl()], + ['modal', () => renderWithIntl()], +] + +describe.each(surfaces)('region-restricted %s', (_name, renderSurface) => { + it('explains the block without naming a country', () => { + renderSurface() + + expect(screen.getByText(/doesn't accept documents issued in your country/i)).toBeInTheDocument() + // The Sumsub list must be changeable without touching this copy. + expect(document.body.textContent).not.toMatch(/russia|china|hong kong/i) + }) + + it('tells the user what they can still do', () => { + renderSurface() + expect(screen.getByText(/funds are safe/i)).toBeInTheDocument() + expect(screen.getByText(/send and receive money/i)).toBeInTheDocument() + }) + + // Asserted on CONTROLS, not on prose: the body copy legitimately contains + // "Re-uploading won't change the result", and a text-level ban would forbid + // the sentence that does the explaining. + it('offers exactly one action, and it is not a retry or a support punt', () => { + renderSurface() + + // The modal also renders an unlabelled X for dismissal — chrome, not an + // action. Count only the buttons that make an offer to the user. + const actions = screen.getAllByRole('button').filter((b) => b.textContent?.trim()) + expect(actions).toHaveLength(1) + expect(actions[0]).toHaveTextContent('Send or request money') + }) + + it('never labels a control with a retry or support action', () => { + renderSurface() + + for (const pattern of [/try again/i, /retry/i, /re-?submit/i, /upload/i, /support/i]) { + expect(screen.queryByRole('button', { name: pattern })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: pattern })).not.toBeInTheDocument() + } + }) + + it('sends the user to the part of the app that still works', () => { + renderSurface() + fireEvent.click(screen.getByText('Send or request money')) + expect(push).toHaveBeenCalledWith('/send') + }) +}) + +describe('region-restricted drawer state', () => { + it('shows when the decision landed', () => { + renderWithIntl() + expect(screen.getByText(/Rejected on: August 1, 2026/)).toBeInTheDocument() + }) + + it('degrades to N/A rather than crashing on a missing or unparseable date', () => { + const { unmount } = renderWithIntl() + expect(screen.getByText(/Rejected on: N\/A/)).toBeInTheDocument() + unmount() + + renderWithIntl() + expect(screen.getByText(/Rejected on: N\/A/)).toBeInTheDocument() + }) + + it('runs the caller hook before navigating, so the drawer closes behind it', () => { + const onNavigate = jest.fn() + renderWithIntl() + + fireEvent.click(screen.getByText('Send or request money')) + + expect(onNavigate).toHaveBeenCalledTimes(1) + expect(push).toHaveBeenCalledWith('/send') + }) +}) + +describe('InitiateKycModal — verification-outage short-circuit', () => { + // Outage outranks everything, including the region screen: opening the SDK + // during an outage burns an attempt against a wall regardless of state. + const variants = ['default', 'blocked', 'provider_rejection', 'restart_identity', 'cross_region'] as const + + afterEach(() => { + mockKycDegraded = false + mockRegionRestricted = false + }) + + it.each(variants)('overrides the %s variant with the outage message', (variant) => { + mockKycDegraded = true + renderWithIntl() + expect(screen.getByText('Verification is temporarily down')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /notify me/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /unlock/i })).not.toBeInTheDocument() + }) + + it('outranks even the region-restricted screen', () => { + mockKycDegraded = true + mockRegionRestricted = true + renderWithIntl() + expect(screen.getByText('Verification is temporarily down')).toBeInTheDocument() + expect(screen.queryByText(/doesn't accept documents issued in your country/i)).not.toBeInTheDocument() + }) +}) + +describe('InitiateKycModal — region-restricted short-circuit', () => { + // The six bank/withdraw gates compute their variant from a rail gate that + // cannot see WHY identity failed. Whatever they ask for, a region-restricted + // user must never be offered verification or support. + const variants = ['default', 'blocked', 'provider_rejection', 'restart_identity', 'cross_region'] as const + + it.each(variants)('overrides the %s variant with the region screen', (variant) => { + mockRegionRestricted = true + renderWithIntl() + + expect(screen.getByText(/doesn't accept documents issued in your country/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /unlock/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /support/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /upload/i })).not.toBeInTheDocument() + }) + + it('never starts a Sumsub flow — the CTA navigates instead of verifying', () => { + mockRegionRestricted = true + const onVerify = jest.fn() + renderWithIntl() + + fireEvent.click(screen.getByText('Send or request money')) + + expect(onVerify).not.toHaveBeenCalled() + expect(push).toHaveBeenCalledWith('/send') + }) + + it('leaves every other user on the normal unlock flow', () => { + mockRegionRestricted = false + const onVerify = jest.fn() + renderWithIntl() + + expect(screen.getByText('Unlock your account')).toBeInTheDocument() + fireEvent.click(screen.getByText('Unlock now')) + expect(onVerify).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/Kyc/states/__tests__/KycStates.test.tsx b/src/components/Kyc/states/__tests__/KycStates.test.tsx index ed3bab84da..9914a55f44 100644 --- a/src/components/Kyc/states/__tests__/KycStates.test.tsx +++ b/src/components/Kyc/states/__tests__/KycStates.test.tsx @@ -26,6 +26,8 @@ jest.mock('../../RejectLabelsList', () => ({ RejectLabelsList: () =>
, })) +const mockSetIsSupportModalOpen = jest.fn() + jest.mock('@/components/Payment/PaymentInfoRow', () => ({ PaymentInfoRow: () =>
, })) @@ -35,11 +37,6 @@ jest.mock('@/components/Global/Card', () => ({ default: ({ children }: { children: ReactNode }) =>
{children}
, })) -jest.mock('@/components/Global/InfoCard', () => ({ - __esModule: true, - default: ({ description }: { description: string }) =>
{description}
, -})) - describe('KYC state cards', () => { it('does not pass the click event to action-required resume', () => { const onResume = jest.fn() @@ -92,3 +89,53 @@ describe('KYC state cards', () => { expect(onRetry).toHaveBeenCalledWith() }) }) + +describe('KycFailed — terminal vs retryable', () => { + beforeEach(() => mockSetIsSupportModalOpen.mockClear()) + + it('a terminal rejection offers support, never a retry', () => { + // Fraud / sanctions / age / forgery. The retry button here was the bug: + // UnlockedRegions hardcoded isTerminalRejection's inputs to null, so this + // branch never rendered and every terminal user was told to try again. + const onRetry = jest.fn() + render( + + ) + + expect(screen.queryByText('Retry verification')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Contact support')) + expect(mockSetIsSupportModalOpen).toHaveBeenCalledTimes(1) + expect(onRetry).not.toHaveBeenCalled() + }) + + it('a terminal rejection never names the cause', () => { + // Naming fraud or sanctions carries compliance exposure and tips off the + // people it describes — KycFailedContent shows the generic card instead. + render( + + ) + + expect(screen.queryByTestId('reject-labels-list')).not.toBeInTheDocument() + expect(document.body.textContent).not.toMatch(/forgery|sanctions/i) + }) + + it('a retryable failure keeps the retry button and the per-label guidance', () => { + const onRetry = jest.fn() + render() + + expect(screen.getByTestId('reject-labels-list')).toBeInTheDocument() + expect(screen.queryByText('Contact support')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Retry verification')) + expect(onRetry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/LandingPage/CloudsCss.tsx b/src/components/LandingPage/CloudsCss.tsx index 661287d293..311a010f81 100644 --- a/src/components/LandingPage/CloudsCss.tsx +++ b/src/components/LandingPage/CloudsCss.tsx @@ -20,7 +20,7 @@ const defaultClouds: CloudConfig[] = [ export function CloudsCss({ clouds = defaultClouds }: { clouds?: CloudConfig[] }) { return ( -
+
{clouds.map((cloud, i) => (
-
+
setSearchTerm(e.target.value)} - className="h-10 w-full rounded-sm border-[1.15px] border-black pl-10 pr-10 font-normal caret-[#FF90E8] focus:border-black focus:outline-none focus:ring-0" + className="h-10 w-full rounded-sm border-[1.15px] border-black pr-10 pl-10 font-normal caret-[#FF90E8] focus:border-black focus:ring-0 focus:outline-none" />
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index 456d1a94e5..53515df7ae 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -283,7 +283,7 @@ export function LandingPageClient({
{heroConfig.primaryCta.subtext && ( - + {heroConfig.primaryCta.subtext} )} diff --git a/src/components/LandingPage/PartnerLockup.tsx b/src/components/LandingPage/PartnerLockup.tsx index 5a1a8fcb2c..fe9e455a17 100644 --- a/src/components/LandingPage/PartnerLockup.tsx +++ b/src/components/LandingPage/PartnerLockup.tsx @@ -1,6 +1,6 @@ import Image, { type StaticImageData } from 'next/image' import Link from 'next/link' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' /** * A country flag over its payment rail's mark, linking to the page that explains diff --git a/src/components/LandingPage/ProblemFold.tsx b/src/components/LandingPage/ProblemFold.tsx index a08839cb36..57595f0ad0 100644 --- a/src/components/LandingPage/ProblemFold.tsx +++ b/src/components/LandingPage/ProblemFold.tsx @@ -53,7 +53,7 @@ export function ProblemFold({ strings }: { strings: LandingStrings }) { style={{ backgroundColor: bgColor }} >
-

+

{problem.heading}

@@ -61,10 +61,10 @@ export function ProblemFold({ strings }: { strings: LandingStrings }) { {cards.map((card, i) => (
{card.icon}
-

+

{card.title}

{card.body}

diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx index 1bf2fb0cb1..b530957690 100644 --- a/src/components/LandingPage/RegulatedRails.tsx +++ b/src/components/LandingPage/RegulatedRails.tsx @@ -69,14 +69,14 @@ export function RegulatedRails({ locale = DEFAULT_LOCALE }: { locale?: Locale })
- + - + -

+

{i18n.landingRailsHeading}

{i18n.landingRailsBody}

@@ -100,7 +100,7 @@ export function RegulatedRails({ locale = DEFAULT_LOCALE }: { locale?: Locale })
-

+

{i18n.landingWorksWith}

diff --git a/src/components/LandingPage/ScarcityCounter.tsx b/src/components/LandingPage/ScarcityCounter.tsx index 4aa43215d0..67aa81ccf2 100644 --- a/src/components/LandingPage/ScarcityCounter.tsx +++ b/src/components/LandingPage/ScarcityCounter.tsx @@ -30,7 +30,7 @@ export function ScarcityCounter({ label }: { label: (count: number) => string }) return ( diff --git a/src/components/LandingPage/ShhhhhFold.tsx b/src/components/LandingPage/ShhhhhFold.tsx index 8e60ff2879..ea409ac2ae 100644 --- a/src/components/LandingPage/ShhhhhFold.tsx +++ b/src/components/LandingPage/ShhhhhFold.tsx @@ -34,12 +34,12 @@ export function ShhhhhFold() { {t('wordmark')} -

+

{t('tagline')}

diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx index 5de652c7f5..5c502e08a7 100644 --- a/src/components/LandingPage/StickyMobileCTA.tsx +++ b/src/components/LandingPage/StickyMobileCTA.tsx @@ -69,7 +69,7 @@ export function StickyMobileCTA({ strings }: { strings: LandingStrings }) { <> {
diff --git a/src/components/LandingPage/TweetCarousel.tsx b/src/components/LandingPage/TweetCarousel.tsx index a4648d6709..3d65386808 100644 --- a/src/components/LandingPage/TweetCarousel.tsx +++ b/src/components/LandingPage/TweetCarousel.tsx @@ -68,7 +68,7 @@ const Avatar = ({ tweet }: { tweet: Tweet }) => { /** X/Twitter verified badge icon */ const VerifiedBadge = () => ( - + ) @@ -146,10 +146,10 @@ const FeaturedCard = ({ tweet }: { tweet: Tweet }) => {
) : ( -
+
-
🥜
-
🥜
+
🥜
+
🥜
🥜
@@ -202,7 +202,7 @@ const Column = ({ column }: { column: ColumnType }) => { if (column.type === 'featured') { return ( -
+
) @@ -210,7 +210,7 @@ const Column = ({ column }: { column: ColumnType }) => { if (column.type === 'tiny') { return ( -
+
{column.tweets.map((tweet) => ( ))} @@ -219,7 +219,7 @@ const Column = ({ column }: { column: ColumnType }) => { } return ( -
+
{column.tweets.map((tweet) => ( ))} @@ -338,7 +338,7 @@ const TweetCarousel = ({ strings }: { strings: LandingStrings }) => { if (columns.length === 0) return null return ( -
+

{strings.wallOfLove} diff --git a/src/components/LandingPage/dropLink.tsx b/src/components/LandingPage/dropLink.tsx index 115a92fa4b..408ae24277 100644 --- a/src/components/LandingPage/dropLink.tsx +++ b/src/components/LandingPage/dropLink.tsx @@ -18,7 +18,7 @@ export function DropLink({ strings }: { strings: LandingStrings }) { style={{ backgroundColor: businessBgColor }} >
-
+

{strings.dropLinkHeading}

@@ -26,7 +26,7 @@ export function DropLink({ strings }: { strings: LandingStrings }) {
{/* Mobile Floating Icons */} @@ -122,7 +122,7 @@ export function DropLink({ strings }: { strings: LandingStrings }) {
{/* Desktop Floating Icons */} {cta.subtext && ( - {cta.subtext} + {cta.subtext} )}
) @@ -159,7 +159,7 @@ export function Hero({ return (
@@ -180,7 +180,7 @@ export function Hero({ @@ -227,7 +227,7 @@ export function Hero({ diff --git a/src/components/LandingPage/noFees.tsx b/src/components/LandingPage/noFees.tsx index 9b7920ba41..56fbcd07cc 100644 --- a/src/components/LandingPage/noFees.tsx +++ b/src/components/LandingPage/noFees.tsx @@ -5,7 +5,7 @@ import Star from '@/assets/illustrations/star.svg' import Image from 'next/image' import ExchangeRateWidget from '../Global/ExchangeRateWidget' import { useRouter } from 'next/navigation' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { ContextualLinks } from './ContextualLinks' import { AnimateOnView } from '@/components/Global/AnimateOnView' import { CloudsCss } from './CloudsCss' @@ -55,14 +55,14 @@ export function NoFees({
{/* Animated stars */} - + Floating Star - + Floating Star Floating Star - + Floating Star diff --git a/src/components/LandingPage/securityBuiltIn.tsx b/src/components/LandingPage/securityBuiltIn.tsx index 1e299e5cc3..2934b7ea9a 100644 --- a/src/components/LandingPage/securityBuiltIn.tsx +++ b/src/components/LandingPage/securityBuiltIn.tsx @@ -90,7 +90,7 @@ export function SecurityBuiltIn({ locale = DEFAULT_LOCALE }: { locale?: Locale }

{feature.title}

{feature.description} diff --git a/src/components/LandingPage/sendInSeconds.tsx b/src/components/LandingPage/sendInSeconds.tsx index 7ed679331b..5dfba1c493 100644 --- a/src/components/LandingPage/sendInSeconds.tsx +++ b/src/components/LandingPage/sendInSeconds.tsx @@ -63,7 +63,7 @@ export function SendInSeconds({ locale = DEFAULT_LOCALE }: { locale?: Locale }) alt="Exclamations" width={200} height={300} - className="absolute right-72 top-1/3 hidden -translate-y-1/2 transform md:block" + className="absolute top-1/3 right-72 hidden -translate-y-1/2 transform md:block" /> {/* Main content */} @@ -88,7 +88,7 @@ export function SendInSeconds({ locale = DEFAULT_LOCALE }: { locale?: Locale })

{i18n.landingSendTagline1} diff --git a/src/components/LandingPage/yourMoney.tsx b/src/components/LandingPage/yourMoney.tsx index 0256161611..d406706bd9 100644 --- a/src/components/LandingPage/yourMoney.tsx +++ b/src/components/LandingPage/yourMoney.tsx @@ -24,7 +24,7 @@ export function YourMoney({ locale = DEFAULT_LOCALE }: { locale?: Locale }) { return (

-
+

{i18n.landingGlobalCashLine1}
{i18n.landingGlobalCashLine2} diff --git a/src/components/Marketing/ContentLanding.tsx b/src/components/Marketing/ContentLanding.tsx index 2b6e8ee892..b24b0a4463 100644 --- a/src/components/Marketing/ContentLanding.tsx +++ b/src/components/Marketing/ContentLanding.tsx @@ -90,7 +90,7 @@ export function ContentLinkList({ items, strings, grouped }: ContentLinkListProp if (inType.length === 0) return null return (
-

+

{typeLabels[t]}

{renderLinkRows(inType)} @@ -142,9 +142,9 @@ export default function ContentLanding({ items, strings }: Props) { return ( <> -
+
-
+
setFilters({ q: e.target.value || null })} - className="h-12 w-full rounded-sm border border-n-1 bg-white pl-10 pr-4 text-base caret-primary-1 focus:outline-none focus:ring-1 focus:ring-n-1" + className="h-12 w-full rounded-sm border border-n-1 bg-white pr-4 pl-10 text-base caret-primary-1 focus:ring-1 focus:ring-n-1 focus:outline-none" />
diff --git a/src/components/Marketing/ContentPage.tsx b/src/components/Marketing/ContentPage.tsx index c3b8bd1eb9..5f5ea585f3 100644 --- a/src/components/Marketing/ContentPage.tsx +++ b/src/components/Marketing/ContentPage.tsx @@ -42,9 +42,9 @@ export function ContentPage({ children, breadcrumbs, article, locale = DEFAULT_L {article && } -
+
{children} -