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..15bc5d9110 --- /dev/null +++ b/.github/workflows/pr-assets-cleanup.yml @@ -0,0 +1,31 @@ +name: PR assets cleanup + +# 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/tests.yml b/.github/workflows/tests.yml index 14851be058..eb16d1e272 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: @@ -176,22 +204,96 @@ 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 + # 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 - # 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 + # Build ~4m + capture ~2m on a runner, plus install. A hang past this is + # a bug, not a slow day. + timeout-minutes: 30 + permissions: + # push the pr-assets- branch that holds the PNGs + contents: write + # keep the one comment up to date + pull-requests: write 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 +304,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 +358,186 @@ 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 + + - name: Publish the changed screenshots + if: steps.diff.outputs.ready == 'true' + id: assets + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # GitHub renders an from a URL and never from a workflow + # artifact, so the PNGs need a home the comment can link to. + # The repo convention is a pr-assets- branch read over + # raw.githubusercontent (PRs #2727, #2733, #2748, #2758). + node scripts/visual-comment.mjs e2e/__shots__/report.json --list > e2e/__shots__/wanted.txt + if [ ! -s e2e/__shots__/wanted.txt ]; then + echo 'Nothing changed, so nothing to publish.' + exit 0 + fi + + SUB="visual/${HEAD_SHA:0:7}" + WORK="$RUNNER_TEMP/pr-assets" + mkdir -p "$WORK/$SUB/base" "$WORK/$SUB/head" "$WORK/$SUB/diff" + while read -r f; do + [ -n "$f" ] || continue + cp "e2e/__shots__/base/$f" "$WORK/$SUB/base/$f" + cp "e2e/__shots__/head/$f" "$WORK/$SUB/head/$f" + # A resized screen has no diff image. Not an error. + cp "e2e/__shots__/diff/$f" "$WORK/$SUB/diff/$f" 2>/dev/null || true + done < e2e/__shots__/wanted.txt + + # A fresh orphan branch every push, force-pushed, so the + # branch holds only the current images and never grows. The + # commit-sha path keeps raw.githubusercontent from serving a + # cached image from the previous push. + git -C "$WORK" init -q -b "pr-assets-$PR" + git -C "$WORK" add -A + git -C "$WORK" \ + -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -qm "visual shots for #$PR" + git -C "$WORK" push -qf \ + "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git" \ + "pr-assets-$PR" + + echo "url=https://raw.githubusercontent.com/$GITHUB_REPOSITORY/pr-assets-$PR/$SUB" \ + >> "$GITHUB_OUTPUT" + + - name: Render the comment + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + if [ '${{ steps.diff.outputs.ready }}' != 'true' ]; then + { + echo '' + echo '## 🖼 Visual diff — no baseline yet' + echo + echo 'Nothing is cached for `${{ github.base_ref }}`. The next push to that' + echo 'branch captures one, and this comment fills in on your next push here.' + } > e2e/__shots__/body.md + else + node scripts/visual-comment.mjs e2e/__shots__/report.json \ + --assets='${{ steps.assets.outputs.url }}' \ + --baseline='${{ steps.diff.outputs.baseline }}' \ + --head='${{ github.event.pull_request.head.sha }}' > e2e/__shots__/body.md + fi + cat e2e/__shots__/body.md >> "$GITHUB_STEP_SUMMARY" + + # `always()` so a failed capture replaces the previous comment + # instead of leaving a green one that is now a lie. + - name: Comment on the PR + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs') + const tag = '' + const out = 'e2e/__shots__/body.md' + const body = fs.existsSync(out) + ? fs.readFileSync(out, 'utf8') + : `${tag}\n## 🖼 Visual diff — the capture failed\n\nNo screenshots this time. The job log says why.` + + const comments = await github.paginate(github.rest.issues.listComments, { + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }) + const existing = comments.find((c) => c.body && c.body.includes(tag)) + const target = { owner: context.repo.owner, repo: context.repo.repo } + if (existing) { + await github.rest.issues.updateComment({ ...target, comment_id: existing.id, body }) + } else { + await github.rest.issues.createComment({ ...target, issue_number: context.issue.number, body }) + } + + # The four kept behaviour specs (setup reload-loop guard, icon + # regression, send-link hydration, share-asset capture) run + # nowhere else in CI. Last on purpose: a red spec fails the job + # without aborting the baseline/diff/comment steps above. + - 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 +811,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 +820,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 483db77c2b..8295fb6a56 100644 --- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx +++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx @@ -57,9 +57,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 5fed16ccbd..dd00815166 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, @@ -351,14 +351,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/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index f0a9f898e2..872fbb0a41 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -138,7 +138,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 +146,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 +154,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 +163,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'), @@ -237,7 +237,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 +247,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 +257,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'), @@ -318,8 +318,8 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa
-
{step.title}
-
{step.description}
+
{step.title}
+
{step.description}
)} 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')}

)} -
+
-
{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/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/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 fc3219cf64..b62c4a4ab0 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -135,7 +135,7 @@ export const InitiateKycModal = ({ description={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/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..f7d5685703 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -3,7 +3,7 @@ import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' 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' @@ -85,10 +85,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) && ( {t('progress.verifyingDescription')}

} ctas={[ @@ -101,7 +101,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 +131,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 +159,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 4931943813..938ae748e5 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -329,7 +329,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'), @@ -341,7 +341,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', }, ], } @@ -351,7 +351,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'), @@ -366,7 +366,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', }, ], } @@ -378,7 +378,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" @@ -397,7 +397,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/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx index 242ee312b5..9c419b7485 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' @@ -33,7 +33,7 @@ export const KycActionRequired = ({ {!rejectLabels?.length && actionMessage ? ( - + {t('actionMessageActionRequired')} ) : ( )} 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__/KycStates.test.tsx b/src/components/Kyc/states/__tests__/KycStates.test.tsx index 82e4a2bbed..3c3311e9b7 100644 --- a/src/components/Kyc/states/__tests__/KycStates.test.tsx +++ b/src/components/Kyc/states/__tests__/KycStates.test.tsx @@ -35,11 +35,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() 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 36dc559109..197038d1bc 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -282,7 +282,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 5e8a7b1e1d..0dfe1816ab 100644 --- a/src/components/LandingPage/RegulatedRails.tsx +++ b/src/components/LandingPage/RegulatedRails.tsx @@ -61,14 +61,14 @@ export function RegulatedRails({ locale = DEFAULT_LOCALE }: { locale?: Locale })
- + - + -

+

{i18n.landingRailsHeading}

{i18n.landingRailsBody}

@@ -92,7 +92,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 182c64f58c..1362b85349 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 01018f0f7c..768f3325af 100644 --- a/src/components/LandingPage/securityBuiltIn.tsx +++ b/src/components/LandingPage/securityBuiltIn.tsx @@ -88,7 +88,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 cee83e56a6..5c055afe6d 100644 --- a/src/components/LandingPage/yourMoney.tsx +++ b/src/components/LandingPage/yourMoney.tsx @@ -22,7 +22,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} -
} - hideBottomBorder={false} /> )} {instructions.bank_beneficiary_name && ( - @@ -200,11 +181,10 @@ export function BridgeDepositInstructions({ transaction }: { transaction: Transa />
} - hideBottomBorder={true} /> )} {instructions.bank_beneficiary_address && ( - @@ -215,7 +195,6 @@ export function BridgeDepositInstructions({ transaction }: { transaction: Transa />

} - hideBottomBorder={true} /> )} diff --git a/src/components/TransactionDetails/provider-rows/CardAdjustmentNotice.tsx b/src/components/TransactionDetails/provider-rows/CardAdjustmentNotice.tsx index 6168c8c299..ada728c68e 100644 --- a/src/components/TransactionDetails/provider-rows/CardAdjustmentNotice.tsx +++ b/src/components/TransactionDetails/provider-rows/CardAdjustmentNotice.tsx @@ -1,7 +1,7 @@ 'use client' import { useTranslations } from 'next-intl' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { parseCents } from '@/components/TransactionDetails/transaction-details.utils' @@ -32,10 +32,8 @@ export function CardAdjustmentNotice({ transaction }: { transaction: Transaction if (deltaCents <= 0) return null return ( - + + {t('settlementAdjustedNotice', { amount: `$${(deltaCents / 100).toFixed(2)}` })} + ) } diff --git a/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx b/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx index 87d703d4ea..f0fc35ad5f 100644 --- a/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx +++ b/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx @@ -2,7 +2,7 @@ import Image from 'next/image' import { type ReactNode } from 'react' -import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { DataRow } from '@/components/0_Bruddle/DataRow' import { type DisputeStatus, type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { declineReasonCode, type DeclineReasonCode } from '@/utils/cardDeclineReason' import { getFlagUrl } from '@/constants/countryCurrencyMapping' @@ -101,16 +101,7 @@ export function hasCardPaymentRowsContent(transaction: TransactionDetails): bool * in transactionTransformer for any Rain CARD_SPEND or card-refund * entry. Backend mirror: src/transaction-intent/history.ts. */ -export function CardPaymentRows({ - transaction, - isLastRow, -}: { - transaction: TransactionDetails - /** When true, suppresses bottom border on the LAST visible sub-row so it - * meets the Card's edge cleanly. The parent receipt computes this via - * `shouldHideBorder('cardPayment')`. */ - isLastRow: boolean -}) { +export function CardPaymentRows({ transaction }: { transaction: TransactionDetails }) { const t = useTranslations('transaction') const card = transaction.extraDataForDrawer?.cardPayment if (!card) return null @@ -270,13 +261,8 @@ export function CardPaymentRows({ return ( <> - {subRows.map((row, idx) => ( - + {subRows.map((row) => ( + ))} ) diff --git a/src/components/TransactionDetails/provider-rows/CardUsdAbroadNotice.tsx b/src/components/TransactionDetails/provider-rows/CardUsdAbroadNotice.tsx index a67ba369e8..2b9604b001 100644 --- a/src/components/TransactionDetails/provider-rows/CardUsdAbroadNotice.tsx +++ b/src/components/TransactionDetails/provider-rows/CardUsdAbroadNotice.tsx @@ -1,6 +1,6 @@ 'use client' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { extractMerchantIso2 } from '@/components/TransactionDetails/transaction-details.utils' import { LOCAL_RAIL_BY_COUNTRY } from '@/components/TransactionDetails/provider-rows/local-rail-countries' @@ -47,11 +47,8 @@ export function CardUsdAbroadNotice({ transaction }: { transaction: TransactionD if (LOCAL_RAIL_BY_COUNTRY[iso2]) return null return ( - + + {t('nudge.usdAbroadDescription')} + ) } diff --git a/src/components/TransactionDetails/provider-rows/LocalRailNudge.tsx b/src/components/TransactionDetails/provider-rows/LocalRailNudge.tsx index c0c6880b3a..698a287c3a 100644 --- a/src/components/TransactionDetails/provider-rows/LocalRailNudge.tsx +++ b/src/components/TransactionDetails/provider-rows/LocalRailNudge.tsx @@ -1,6 +1,6 @@ 'use client' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { extractMerchantIso2 } from '@/components/TransactionDetails/transaction-details.utils' import { LOCAL_RAIL_BY_COUNTRY } from '@/components/TransactionDetails/provider-rows/local-rail-countries' @@ -41,15 +41,12 @@ function LocalRailNudgeBody({ local }: { local: (typeof LOCAL_RAIL_BY_COUNTRY)[s if (!percent) return null return ( - + {t('nudge.localRailDescription', { country: local.countryName, rail: local.rail, percent, })} - /> + ) } diff --git a/src/components/TransactionDetails/provider-rows/MantecaDepositInfo.tsx b/src/components/TransactionDetails/provider-rows/MantecaDepositInfo.tsx index 1f4ef6871d..469abebc2c 100644 --- a/src/components/TransactionDetails/provider-rows/MantecaDepositInfo.tsx +++ b/src/components/TransactionDetails/provider-rows/MantecaDepositInfo.tsx @@ -1,6 +1,6 @@ 'use client' -import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { DataRow } from '@/components/0_Bruddle/DataRow' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { MANTECA_COUNTRIES_CONFIG, @@ -34,7 +34,7 @@ export function MantecaDepositInfo({ return ( <> {depositDetails.depositAddress && ( - )} - {depositDetails.depositAlias && ( - - )} + {depositDetails.depositAlias && } {country?.id === 'AR' && ( <> {/* Not copy: the field name the user's Argentine banking app shows. Translating it would break the match they are transcribing. */} {/* eslint-disable-next-line local/copy-props-from-catalog */} - - + + )} diff --git a/src/components/TransactionDetails/provider-rows/RequestPotContributorRows.tsx b/src/components/TransactionDetails/provider-rows/RequestPotContributorRows.tsx new file mode 100644 index 0000000000..91d86da14e --- /dev/null +++ b/src/components/TransactionDetails/provider-rows/RequestPotContributorRows.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useTranslations } from 'next-intl' +import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' +import { type ReceiptViewModel } from '@/components/TransactionDetails/useReceiptViewModel' +import { formatCurrency, printableUserHandle } from '@/utils/general.utils' + +/** + * Contributors block for request-pot receipts (Activity/Request board + * 17835:83962): "Contributors (N)" then avatar + name + amount per row. + * Renders inside ReceiptDetailsCard's `divide-y` as one dashed-divided + * section. + */ +export function RequestPotContributorRows({ vm }: { vm: ReceiptViewModel }) { + const t = useTranslations('transaction') + const { requestPotContributors } = vm + if (requestPotContributors.length === 0) return null + + return ( +
+ + {t('contributors', { count: requestPotContributors.length })} + + {requestPotContributors.map((contributor) => ( +
+
+ {/* AvatarWithBadge derives the per-username colors + itself when none are passed */} + + + {printableUserHandle(contributor.username ?? '')} + +
+ ${formatCurrency(contributor.amount)} +
+ ))} +
+ ) +} diff --git a/src/components/TransactionDetails/provider-rows/RequestPotProgressRow.tsx b/src/components/TransactionDetails/provider-rows/RequestPotProgressRow.tsx new file mode 100644 index 0000000000..3404cbcaab --- /dev/null +++ b/src/components/TransactionDetails/provider-rows/RequestPotProgressRow.tsx @@ -0,0 +1,42 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { formatCurrency } from '@/utils/general.utils' + +/** + * Request-pot progress row for the receipt card (Activity/Request board + * 17835:83951): "$X contributed / $Y remaining" + slim bar. Renders nothing + * when no goal is set — no-goal pots already show "$X collected" as the head + * amount. Renders inside ReceiptDetailsCard's `divide-y`. + */ +export function RequestPotProgressRow({ transaction }: { transaction: TransactionDetails }) { + const tGlobal = useTranslations('global') + + const potGoal = transaction.isRequestPotLink ? Number(transaction.amount) : 0 + const potCollected = Number(transaction.totalAmountCollected ?? 0) + if (!transaction.isRequestPotLink || potGoal <= 0) return null + + return ( +
+
+ + {tGlobal('progressBar.contributed', { amount: `$${formatCurrency(potCollected.toString())}` })} + + + {tGlobal('progressBar.remaining', { + amount: `$${formatCurrency(Math.max(potGoal - potCollected, 0).toString())}`, + })} + +
+ {/* ponytail: native as the slim board bar (17835:83956) — + no inline style, ratchet-clean; the legacy coin-marker + ProgressBar is off-board for receipts */} + +
+ ) +} diff --git a/src/components/TransactionDetails/provider-rows/__tests__/CardAdjustmentNotice.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/CardAdjustmentNotice.test.tsx index 9a0ca20ec3..d46b0613a7 100644 --- a/src/components/TransactionDetails/provider-rows/__tests__/CardAdjustmentNotice.test.tsx +++ b/src/components/TransactionDetails/provider-rows/__tests__/CardAdjustmentNotice.test.tsx @@ -7,12 +7,6 @@ import React from 'react' import { screen } from '@testing-library/react' import { renderWithIntl as render } from '@/test-utils/intl' -jest.mock('@/components/Global/InfoCard', () => ({ - __esModule: true, - default: ({ description }: { description?: React.ReactNode }) =>
{description}
, -})) - -// import must come after jest.mock import { CardAdjustmentNotice } from '../CardAdjustmentNotice' import type { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' diff --git a/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx index 654bf74bd3..787b6ad189 100644 --- a/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx +++ b/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx @@ -60,7 +60,6 @@ describe('CardPaymentRows — settlement adjustment breakdown', () => { render( ) expect(screen.getByText('Initial hold')).toBeInTheDocument() @@ -73,7 +72,6 @@ describe('CardPaymentRows — settlement adjustment breakdown', () => { render( ) expect(screen.getByText('Initial hold')).toBeInTheDocument() @@ -85,7 +83,6 @@ describe('CardPaymentRows — settlement adjustment breakdown', () => { render( ) expect(screen.getByText('Initial hold')).toBeInTheDocument() @@ -96,7 +93,6 @@ describe('CardPaymentRows — settlement adjustment breakdown', () => { render( ) expect(screen.getByText('Initial hold')).toBeInTheDocument() @@ -107,7 +103,6 @@ describe('CardPaymentRows — settlement adjustment breakdown', () => { render( ) expect(screen.queryByText('Initial hold')).not.toBeInTheDocument() diff --git a/src/components/TransactionDetails/transaction-name-keys.ts b/src/components/TransactionDetails/transaction-name-keys.ts index bc7c27b6da..2dde6cc552 100644 --- a/src/components/TransactionDetails/transaction-name-keys.ts +++ b/src/components/TransactionDetails/transaction-name-keys.ts @@ -59,6 +59,24 @@ type ReaperFailKey = (typeof REAPER_FAIL_KEYS)[keyof typeof REAPER_FAIL_KEYS] | export type TransactionNameKey = (typeof TRANSACTION_NAME_KEYS)[keyof typeof TRANSACTION_NAME_KEYS] | ReaperFailKey +/** + * Labels that are complete on their own — the receipt title renders them + * bare instead of interpolating them into direction wording. Without this + * escape getTitle produced compounds like "Sending to Send didn't complete" + * (reaper fail copy) and "Received from Refund from Starbucks" (refunds). + * `name.request` is here so an unresolved open request reads "Request", not + * "Request is requesting". + */ +export const SELF_DESCRIBING_NAME_KEYS: ReadonlySet = new Set([ + ...Object.values(REAPER_FAIL_KEYS), + REAPER_FAIL_FALLBACK_KEY, + TRANSACTION_NAME_KEYS.failedQrPayment, + TRANSACTION_NAME_KEYS.refundFrom, + TRANSACTION_NAME_KEYS.cardRefund, + TRANSACTION_NAME_KEYS.refund, + TRANSACTION_NAME_KEYS.request, +]) + type TransactionTranslator = ReturnType> /** Localize an FE-generated transaction label; ICU params carry the data bits. */ diff --git a/src/components/TransactionDetails/useReceiptActions.ts b/src/components/TransactionDetails/useReceiptActions.ts new file mode 100644 index 0000000000..7068841cf0 --- /dev/null +++ b/src/components/TransactionDetails/useReceiptActions.ts @@ -0,0 +1,115 @@ +'use client' + +import { useQueryClient } from '@tanstack/react-query' +import { captureException } from '@sentry/nextjs' +import { useTranslations } from 'next-intl' +import useClaimLink from '@/components/Claim/useClaimLink' +import { useToast } from '@/components/0_Bruddle/Toast' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { TRANSACTIONS } from '@/constants/query.consts' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useUserStore } from '@/redux/hooks' +import { chargesApi } from '@/services/charges' +import { requestsApi } from '@/services/requests' + +/** + * Receipt CTA side effects (DS 09). Views never import api modules — every + * charges/requests/claim-link call the receipt's action buttons need lives + * here. No state: loading/UI state stays in the view (ephemeral), server + * state invalidates through the query client. + */ +export function useReceiptActions(transaction: TransactionDetails | null) { + const queryClient = useQueryClient() + const { fetchBalance } = useWallet() + const { cancelLinkAndClaim, pollForClaimConfirmation } = useClaimLink() + const { user } = useUserStore() + const toast = useToast() + const t = useTranslations('transaction') + + const invalidateTransactions = () => queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) + + /** Requester closes their own request (pot → close, charge → cancel). */ + const closeRequest = async (): Promise => { + if (!transaction) return false + try { + if (transaction.isRequestPotLink) { + await requestsApi.close(transaction.id) + } else { + await chargesApi.cancel(transaction.id) + } + await invalidateTransactions() + return true + } catch (error) { + captureException(error) + console.error('Error canceling charge:', error) + return false + } + } + + /** Requestee rejects a request sent to them. */ + const rejectRequest = async (): Promise => { + if (!transaction) return false + try { + await chargesApi.cancel(transaction.id) + await invalidateTransactions() + return true + } catch (error) { + captureException(error) + console.error('Error canceling charge:', error) + return false + } + } + + /** + * Sender cancels a pending send link by claiming it back. Resolves true on + * success (toast shown), false on failure (error toast shown). Refresh + * failures after a successful claim still count as success — the money is + * back either way. + */ + const cancelSendLink = async (): Promise => { + if (!transaction) return false + try { + if (!user?.accounts) { + throw new Error('User not found for cancellation') + } + const walletAddress = user.accounts.find((acc) => acc.type === 'peanut-wallet')?.identifier + if (!walletAddress) { + throw new Error('No wallet address found for cancellation') + } + if (!transaction.extraDataForDrawer?.link) { + throw new Error('No link found for cancellation') + } + + await cancelLinkAndClaim({ + link: transaction.extraDataForDrawer.link, + walletAddress, + userId: user?.user?.userId, + }) + + try { + const isConfirmed = await pollForClaimConfirmation(transaction.extraDataForDrawer.link) + if (!isConfirmed) { + console.warn('Transaction confirmation timeout - proceeding with refresh') + } + fetchBalance() + await invalidateTransactions() + toast.success(t('toast.linkCancelled')) + } catch (invalidateError) { + console.error('Failed to update after claim:', invalidateError) + captureException(invalidateError, { + tags: { feature: 'cancel-link' }, + extra: { userId: user?.user?.userId }, + }) + toast.success(t('toast.linkCancelledRefresh')) + } + return true + } catch (error) { + captureException(error) + console.error('Error claiming link:', error) + toast.error(t('toast.cancelLinkFailed')) + return false + } + } + + return { closeRequest, rejectRequest, cancelSendLink } +} diff --git a/src/components/TransactionDetails/useReceiptViewModel.ts b/src/components/TransactionDetails/useReceiptViewModel.ts index aba3b38f84..4fd02f6936 100644 --- a/src/components/TransactionDetails/useReceiptViewModel.ts +++ b/src/components/TransactionDetails/useReceiptViewModel.ts @@ -24,12 +24,6 @@ import { countryData } from '@/components/AddMoney/consts' import { getContributorsFromCharge, formatCurrency, isStableCoin } from '@/utils/general.utils' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' -const ROW_GROUPS = { - dateRows: ['createdAt', 'cancelled', 'claimed', 'completed', 'closed'] as TransactionDetailsRowKey[], - txnDetails: ['tokenAndNetwork', 'txId'] as TransactionDetailsRowKey[], - fees: ['networkFee', 'peanutFee'] as TransactionDetailsRowKey[], -} as const - const ALL_ROWS_HIDDEN = transactionDetailsRowKeys.reduce( (acc, key) => { acc[key] = false @@ -53,16 +47,10 @@ export interface ReceiptViewModel { * Manteca deposit-info row for the country-specific address label). */ country: (typeof countryData)[number] | undefined - /** Per-row visibility config — drives both rendering and border logic. */ + /** Per-row visibility config — drives rendering. Dividers between rows + * come from the details card's `divide-y`, so no last-row border logic. */ rowVisibilityConfig: Record - /** True when this row is the last visible one in the receipt — the row - * uses this to suppress its bottom border so it meets the Card edge. */ - shouldHideBorder: (rowKey: TransactionDetailsRowKey) => boolean - - /** Same idea but scoped to a row group (date rows, txn details, fees). */ - shouldHideGroupBorder: (rowKey: TransactionDetailsRowKey, groupName: keyof typeof ROW_GROUPS) => boolean - /** Whether the share-receipt button should render at all. */ shouldShowShareReceipt: boolean @@ -243,45 +231,12 @@ export function useReceiptViewModel( mantecaDepositInfo: !isPublic && isMantecaOnrampEntry(transaction) && transaction.status === 'pending', // Gate on whether CardPaymentRows would actually emit a sub-row; // otherwise an "all-data-absent" card spend leaves the slot - // visible-but-empty and `shouldHideBorder` mis-attributes the - // last-visible row. + // visible-but-empty (a stray divider in the details card). cardPayment: isCardPaymentEntry(transaction) && hasCardPaymentRowsContent(transaction), closed: !!(transaction.status === 'closed' && transaction.cancelledDate), } }, [transaction, isPublic, isPendingBankRequest, isPeanutWalletToken, isSendLinkSenderCancelled]) - const visibleRows = useMemo( - () => transactionDetailsRowKeys.filter((key) => rowVisibilityConfig[key]), - [rowVisibilityConfig] - ) - - const shouldHideBorder = useMemo(() => { - const lastVisibleRow = visibleRows[visibleRows.length - 1] - return (rowKey: TransactionDetailsRowKey) => rowKey === lastVisibleRow - }, [visibleRows]) - - const lastVisibleInGroups = useMemo(() => { - const lastIn = (groupKeys: readonly TransactionDetailsRowKey[]) => { - const v = groupKeys.filter((key) => rowVisibilityConfig[key]) - return v[v.length - 1] - } - return { - dateRows: lastIn(ROW_GROUPS.dateRows), - txnDetails: lastIn(ROW_GROUPS.txnDetails), - fees: lastIn(ROW_GROUPS.fees), - } - }, [rowVisibilityConfig]) - - const shouldHideGroupBorder = useMemo(() => { - return (rowKey: TransactionDetailsRowKey, groupName: keyof typeof ROW_GROUPS) => { - const isLastInGroup = rowKey === lastVisibleInGroups[groupName] - const isGlobalLast = shouldHideBorder(rowKey) - // Last-in-group keeps its border unless it's also the global last; - // otherwise always hide (group rows pack visually). - return isLastInGroup ? isGlobalLast : true - } - }, [lastVisibleInGroups, shouldHideBorder]) - const shouldShowShareReceipt = useMemo(() => { if (isPublic) return false if (!transaction || isPendingSentLink || isPendingRequester || isPendingRequestee) return false @@ -311,8 +266,6 @@ export function useReceiptViewModel( isCardSpend, country, rowVisibilityConfig, - shouldHideBorder, - shouldHideGroupBorder, shouldShowShareReceipt, requestPotContributors, formattedTotalAmountCollected, diff --git a/src/components/TransactionDetails/useTokenDisplay.ts b/src/components/TransactionDetails/useTokenDisplay.ts new file mode 100644 index 0000000000..7e5e58cae1 --- /dev/null +++ b/src/components/TransactionDetails/useTokenDisplay.ts @@ -0,0 +1,50 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { slugify } from '@/utils/general.utils' + +interface TokenDisplayData { + symbol: string + icon: string +} + +/** + * Token symbol + icon for the receipt's token-and-network row. Wire data wins; + * CoinGecko is the fallback for legacy entries that only carry a chain name. + * Tanstack query (not useState/useEffect) per the DS state decision table. + */ +export function useTokenDisplay(transaction: TransactionDetails | null): { + tokenData: TokenDisplayData | null + isLoading: boolean +} { + const details = transaction?.tokenDisplayDetails + const fromWire = + details?.tokenIconUrl && details?.tokenSymbol + ? { symbol: details.tokenSymbol, icon: details.tokenIconUrl } + : null + const needsFetch = !!details && !fromWire && !!details.chainName && !!transaction?.tokenAddress + + const { data, isLoading } = useQuery({ + queryKey: ['coingecko-token', details?.chainName, transaction?.tokenAddress], + enabled: needsFetch, + staleTime: Infinity, + retry: false, + queryFn: async (): Promise => { + const chainName = slugify(details!.chainName!) + const res = await fetch( + `https://api.coingecko.com/api/v3/coins/${chainName}/contract/${transaction!.tokenAddress}` + ) + if (!res.ok) { + throw new Error(`CoinGecko API error: ${res.status} ${res.statusText}`) + } + const tokenDetails = await res.json() + return { symbol: tokenDetails.symbol, icon: tokenDetails.image.large } + }, + }) + + return { + tokenData: fromWire ?? data ?? null, + isLoading: needsFetch && isLoading, + } +} diff --git a/src/components/Global/ProgressBar/index.tsx b/src/components/User/PotProgress.tsx similarity index 56% rename from src/components/Global/ProgressBar/index.tsx rename to src/components/User/PotProgress.tsx index 2c9355db0b..3a5ab56b18 100644 --- a/src/components/Global/ProgressBar/index.tsx +++ b/src/components/User/PotProgress.tsx @@ -2,16 +2,21 @@ import COIN_ICON from '@/assets/icons/coin.svg' import { useTranslations } from 'next-intl' import Image from 'next/image' import React from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' +import ProgressBar, { type ProgressBarMarker } from '@/components/0_Bruddle/ProgressBar' import { formatExtendedNumber } from '@/utils/general.utils' -interface ProgressBarProps { +interface PotProgressProps { goal: number progress: number isClosed: boolean } -const ProgressBar: React.FC = ({ goal, progress, isClosed }) => { +/** + * request-pot goal progress: status text + amount labels + goal/progress markers + * over the ProgressBar primitive. + */ +const PotProgress: React.FC = ({ goal, progress, isClosed }) => { const t = useTranslations('global') const isOverGoal = progress > goal && goal > 0 const isGoalAchieved = progress >= goal && !isOverGoal && goal > 0 @@ -74,15 +79,16 @@ const ProgressBar: React.FC = ({ goal, progress, isClosed }) = return t('progressBar.belowGoal', { percentage: 100 - percentage }) } - const getBackgroundColor = () => { - if (!isClosed) return 'bg-grey-2' - return isGoalAchieved ? 'bg-success-3' : 'bg-error-4' + const getTrackColor = () => { + if (isOverGoal) return 'bg-action-secondary' + if (!isClosed) return 'bg-background-disabled' + return isGoalAchieved ? 'bg-green-500' : 'bg-red-200' } const renderStatusText = () => { if (!isClosed) return null return ( -
+
coin

{getStatusText()}

@@ -92,11 +98,9 @@ const ProgressBar: React.FC = ({ goal, progress, isClosed }) = const renderLabels = () => { if (!isClosed) { return ( -
-

{t('progressBar.contributed', { amount: formatCurrency(progress) })}

-

- {t('progressBar.remaining', { amount: formatCurrency(Math.max(goal - progress, 0)) })} -

+
+

{t('progressBar.contributed', { amount: formatCurrency(progress) })}

+

{t('progressBar.remaining', { amount: formatCurrency(Math.max(goal - progress, 0)) })}

) } @@ -104,10 +108,13 @@ const ProgressBar: React.FC = ({ goal, progress, isClosed }) = if (isOverGoal) { return (
-

+

100%

-

{formatCurrency(progress)}

+

{formatCurrency(progress)}

) } @@ -121,7 +128,7 @@ const ProgressBar: React.FC = ({ goal, progress, isClosed }) =

= ({ goal, progress, isClosed }) = {formatCurrency(progress)}

-

100%

+

100%

) } - const renderMarker = (color: string, position: string | number, _isPercentage = true) => { - const positionStyle = typeof position === 'string' ? { left: position } : { left: `${position}%` } - return ( -
-
-
- ) - } - - const renderGoalMarker = () => { - const markerColor = isGoalAchieved ? 'bg-success-3' : 'bg-error-4' - const containerClasses = twMerge( - 'absolute top-1/2 z-10 -translate-y-1/2 transition-all duration-300', - isGoalAchieved ? 'right-0' : '-translate-x-1/2' - ) - const containerStyle = isGoalAchieved ? {} : { left: '100%' } - - return ( -
- {isGoalAchieved &&

100%

} -
-
- ) - } - - const renderProgressBar = () => { - const barBaseClasses = 'absolute left-0 h-1.5 rounded-full transition-all duration-300 ease-in-out' + const getMarkers = (): ProgressBarMarker[] => { + if (!isClosed) return [] if (isOverGoal) { - return ( - <> -
-
- {isClosed && ( - <> - {renderMarker('bg-success-3', goalPercentage)} - {renderMarker('bg-yellow-1', '100%', false)} - - )} - - ) + return [ + { position: goalPercentage, className: 'bg-green-500' }, + { position: 'end', className: 'bg-action-secondary' }, + ] } - return ( - <> -
-
- {isClosed && ( - <> - {!isGoalAchieved && renderMarker('bg-success-3', progressPercentage)} - {renderGoalMarker()} - - )} - + const markers: ProgressBarMarker[] = [] + if (!isGoalAchieved) markers.push({ position: progressPercentage, className: 'bg-green-500' }) + markers.push( + isGoalAchieved + ? { + position: 'end', + className: 'bg-green-500', + label:

100%

, + } + : { position: 100, className: 'bg-red-200' } ) + return markers } return ( @@ -200,9 +171,14 @@ const ProgressBar: React.FC = ({ goal, progress, isClosed }) = {renderStatusText()} {renderLabels()} -
{renderProgressBar()}
+
) } -export default ProgressBar +export default PotProgress diff --git a/src/components/User/UserCard.tsx b/src/components/User/UserCard.tsx index 3a801df180..94f50b43b5 100644 --- a/src/components/User/UserCard.tsx +++ b/src/components/User/UserCard.tsx @@ -8,8 +8,7 @@ import Card from '../Global/Card' import { Icon, type IconName } from '../Global/Icons/Icon' import AvatarWithBadge, { type AvatarSize } from '../Profile/AvatarWithBadge' import { VerifiedUserLabel } from '../UserHeader' -import { twMerge } from 'tailwind-merge' -import ProgressBar from '../Global/ProgressBar' +import PotProgress from './PotProgress' import { ContributorsDrawer } from '@/features/payments/flows/contribute-pot/components/ContributorsDrawer' import type { PotContributor } from '@/features/payments/flows/contribute-pot/ContributePotFlowContext' @@ -62,7 +61,7 @@ const UserCard = ({ if (type === 'request_pay') title = t('userCard.isRequesting', { name: fullName ?? username }) if (type === 'request_fulfilment') title = t('userCard.sendingTo', { name: fullName ?? username }) return ( -
+
{icon && } {title}
) @@ -99,10 +98,10 @@ const UserCard = ({ <> {type === 'request_fulfilment' && (
-

${amount}

+

${amount}

- -

{t('userCard.sendExactAmount')}

+ +

{t('userCard.sendExactAmount')}

)} @@ -111,10 +110,15 @@ const UserCard = ({ )} @@ -125,14 +129,14 @@ const UserCard = ({ username={username} isVerified={isVerified} haveSentMoneyToUser={haveSentMoneyToUser} - className="text-base font-medium" + className="text-body-m" /> )}
{amount !== undefined && amountCollected !== undefined && type === 'request_pay' && amount > 0 && ( - = amount} /> + = amount} /> )} {/* request pot contributors drawer */} diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx index 0815c60c12..328951d171 100644 --- a/src/components/UserHeader/index.tsx +++ b/src/components/UserHeader/index.tsx @@ -3,7 +3,7 @@ import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import Link from 'next/link' import { Icon } from '../Global/Icons/Icon' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Tooltip } from '../Tooltip' import { useMemo } from 'react' import { useAuth } from '@/context/authContext' @@ -37,12 +37,25 @@ export const UserHeader = ({ username, fullName }: UserHeaderProps) => { className="h-5 w-5 text-[10px] md:h-6 md:w-6 md:text-[11px]" name={nameForAvatar} /> - {username} + {username} ) } +/** + * Default type for the name. It has to be a type TOKEN, not a bare weight + * class: a caller passing `text-heading-s` (ProfileHeader does) merges with a + * token in the same conflict group and wins outright, but it can never beat a + * `font-semibold`, because a font-size utility resolves its weight through + * `var(--tw-font-weight, …)` and any font-weight class fills that var. The + * profile name measured 24px at weight 600 for exactly that reason — right + * size off the caller's token, wrong weight off this component's default. + * `text-body-m-semibold` is 16/600/20, which is what the old + * `font-semibold md:text-base` pair already computed to. + */ +const LABEL_TYPE = 'text-body-m-semibold' + export const VerifiedUserLabel = ({ name, username, @@ -69,13 +82,13 @@ export const VerifiedUserLabel = ({ // A kyc-verified user always gets at least a single badge. if (isVerified) { - badge = + badge = tooltipContent = isAuthenticatedUserVerified ? "You're a verified user." : 'This is a verified user.' } // if they are also verified and the viewer has sent them money, it's upgraded to a double badge. if (isVerified && haveSentMoneyToUser) { - badge = + badge = tooltipContent = "This is a verified user and you've sent them money before." } @@ -90,19 +103,16 @@ export const VerifiedUserLabel = ({ return (
- {isCryptoAddressComputed ? ( - + {/* The AddressLink lane only wins when the caller passed no worded + copy (name === username). A caller that DID word the name — the + receipt head's "Added from {ens}" — must not have its title + discarded for a raw-address username (the bug behind the + type-less crypto-deposit receipt). */} + {isCryptoAddressComputed && name === username ? ( + ) : (
{ diff --git a/src/components/Username/ValidatedUsernameWrapper.tsx b/src/components/Username/ValidatedUsernameWrapper.tsx index 78f5dcfc49..868769774c 100644 --- a/src/components/Username/ValidatedUsernameWrapper.tsx +++ b/src/components/Username/ValidatedUsernameWrapper.tsx @@ -27,7 +27,7 @@ import { useState, useEffect, type ReactNode } from 'react' import { verifyPeanutUsername } from '@/lib/validation/recipient' import type { ValidationErrorViewProps } from '@/components/Payment/Views/Error.validation.view' import ValidationErrorView from '@/components/Payment/Views/Error.validation.view' -import PeanutLoading from '@/components/Global/PeanutLoading' +import Loading from '@/components/Global/Loading' interface ValidatedUsernameWrapperProps { username: string @@ -87,7 +87,7 @@ export function ValidatedUsernameWrapper({ if (isValidating) { return (
- +
) } @@ -95,7 +95,7 @@ export function ValidatedUsernameWrapper({ // show error if validation failed if (error) { return ( -
+
) @@ -105,7 +105,7 @@ export function ValidatedUsernameWrapper({ if (!isValidated) { return (
- +
) } diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx index c323623094..94a16c54bd 100644 --- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx +++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx @@ -1,11 +1,10 @@ 'use client' import { Button } from '@/components/0_Bruddle/Button' +import { Notification } from '@/components/0_Bruddle/Notification' import AddressLink from '@/components/Global/AddressLink' import Card from '@/components/Global/Card' import DisplayIcon from '@/components/Global/DisplayIcon' -import ErrorAlert from '@/components/Global/ErrorAlert' -import InfoCard from '@/components/Global/InfoCard' import NavHeader from '@/components/Global/NavHeader' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' @@ -127,7 +126,7 @@ export default function ConfirmWithdrawView({
-
+
{chainIconUrl && ( -
+
)} @@ -184,7 +183,13 @@ export default function ConfirmWithdrawView({ /> } + value={ + + } /> - {showHighFeeWarning && ( - - )} + {showHighFeeWarning && {t('confirm.highFeeWarning')}} {error ? (
) diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx index 50b6cb5d34..55652493e4 100644 --- a/src/components/Withdraw/views/Initial.withdraw.view.tsx +++ b/src/components/Withdraw/views/Initial.withdraw.view.tsx @@ -1,7 +1,8 @@ 'use client' import { Button } from '@/components/0_Bruddle/Button' -import ErrorAlert from '@/components/Global/ErrorAlert' +import { PageStack } from '@/components/0_Bruddle/PageStack' +import { Notification } from '@/components/0_Bruddle/Notification' import GeneralRecipientInput, { type GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput' import NavHeader from '@/components/Global/NavHeader' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' @@ -176,7 +177,7 @@ export default function InitialWithdrawView({ return ( // flex/gap shell per the page-layout rules — space-y on the outer div // conflicts with centering and clipped the CTA on short viewports -
+
@@ -216,7 +217,7 @@ export default function InitialWithdrawView({ the user must see where funds will actually go before any review/warning step (external tester feedback). */} {!!recipient.name && !!recipient.address && isValidRecipient && !inputChanging && ( -

+

{recipient.name} {t('resolvesTo')}{' '} {printableAddress(recipient.address)}

@@ -241,8 +242,10 @@ export default function InitialWithdrawView({ {t('review')} - {error.showError && !!error.errorMessage && } + {error.showError && !!error.errorMessage && ( + {error.errorMessage} + )}
-
+ ) } diff --git a/src/components/Withdraw/views/PixKeySend.view.tsx b/src/components/Withdraw/views/PixKeySend.view.tsx index f1bca7d49d..8c237338d4 100644 --- a/src/components/Withdraw/views/PixKeySend.view.tsx +++ b/src/components/Withdraw/views/PixKeySend.view.tsx @@ -1,12 +1,13 @@ 'use client' import { useState } from 'react' +import { PageStack } from '@/components/0_Bruddle/PageStack' +import { Notification } from '@/components/0_Bruddle/Notification' import { useRouter } from 'next/navigation' import { useSafeBack } from '@/hooks/useSafeBack' import { Button } from '@/components/0_Bruddle/Button' import NavHeader from '@/components/Global/NavHeader' import ValidatedInput from '@/components/Global/ValidatedInput' -import ErrorAlert from '@/components/Global/ErrorAlert' import { Icon } from '@/components/Global/Icons/Icon' import { isPixEmvcoQr, normalizePixInput, validatePixKey } from '@/utils/withdraw.utils' import { pixKeyToQrPayUrl } from '@/utils/pix.utils' @@ -50,11 +51,11 @@ export default function PixKeySendView({ destinationParam }: { destinationParam? } return ( -
+ -
+
-

{t('pixKey.heading')}

+

{t('pixKey.heading')}

-
+
{t('pixKey.info')}
@@ -86,9 +87,9 @@ export default function PixKeySendView({ destinationParam }: { destinationParam? {tCommon('continue')} - {errorMessage && } + {errorMessage && {errorMessage}}
-
-
+
+ ) } diff --git a/src/components/Withdraw/views/__tests__/Initial.withdraw.view.test.tsx b/src/components/Withdraw/views/__tests__/Initial.withdraw.view.test.tsx index 7013ee820e..b26e0c3554 100644 --- a/src/components/Withdraw/views/__tests__/Initial.withdraw.view.test.tsx +++ b/src/components/Withdraw/views/__tests__/Initial.withdraw.view.test.tsx @@ -63,11 +63,6 @@ jest.mock('@/components/Global/TokenSelector/TokenSelector', () => ({ default: () => null, })) -jest.mock('@/components/Global/ErrorAlert', () => ({ - __esModule: true, - default: () => null, -})) - jest.mock('@/constants/zerodev.consts', () => ({ PEANUT_WALLET_CHAIN: { id: 1, name: 'Ethereum' }, PEANUT_WALLET_TOKEN: '0x0000000000000000000000000000000000000000', diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts index cf7bd0e551..68b791fbca 100644 --- a/src/config/underMaintenance.config.ts +++ b/src/config/underMaintenance.config.ts @@ -11,6 +11,8 @@ * 2. enableMaintenanceBanner: shows a banner on ALL pages (including landing and support) * - pages remain functional, just shows a warning banner * - use this when you want to warn users about ongoing maintenance + * - scope it with maintenanceBannerPaths: [] = every page; ['/withdraw', '/add-money'] + * = only those pages (prefix match, so '/add-money' covers '/add-money/brazil') * * 3. disabledPaymentProviders: array of payment providers to disable * - blocks QR payments for specific providers (e.g., 'MANTECA') @@ -71,6 +73,8 @@ export type PaymentProvider = 'MANTECA' interface MaintenanceConfig { enableFullMaintenance: boolean enableMaintenanceBanner: boolean + /** Path prefixes the maintenance banner shows on. Empty = every page. Only scopes enableMaintenanceBanner; enableFullMaintenance always shows it everywhere. */ + maintenanceBannerPaths: string[] disabledPaymentProviders: PaymentProvider[] disableXchainWithdraw: boolean disableXchainSend: boolean @@ -92,7 +96,8 @@ const DISABLE_XCHAIN_WITHDRAW_GLOBALLY = false const underMaintenanceConfig: MaintenanceConfig = { enableFullMaintenance: false, // set to true to redirect all pages to /maintenance - enableMaintenanceBanner: false, // set to true to show maintenance banner on all pages + enableMaintenanceBanner: false, // set to true to show maintenance banner (scope with maintenanceBannerPaths) + maintenanceBannerPaths: [], // [] = every page; e.g. ['/withdraw', '/add-money'] targets those pages only disabledPaymentProviders: [], // set to ['MANTECA'] to disable Manteca QR payments /** * Cross-chain withdraw is force-disabled in the iOS app, on top of the global diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 089a1f886c..80a5f899cf 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -91,9 +91,6 @@ export const ANALYTICS_EVENTS = { DEPOSIT_CONFIRMED: 'deposit_confirmed', DEPOSIT_COMPLETED: 'deposit_completed', DEPOSIT_FAILED: 'deposit_failed', - // offramp.xyz migrants must self-report their Offramp username/email - // before the migration deposit address is revealed (payout reconciliation) - OFFRAMP_HANDLE_SUBMITTED: 'offramp_handle_submitted', // ── Withdraw ── WITHDRAW_AMOUNT_ENTERED: 'withdraw_amount_entered', diff --git a/src/constants/dev-tools.consts.ts b/src/constants/dev-tools.consts.ts new file mode 100644 index 0000000000..90e53637bc --- /dev/null +++ b/src/constants/dev-tools.consts.ts @@ -0,0 +1,39 @@ +// Build-time gate for the /dev/devices viewport harness. Next.js inlines +// `process.env.NEXT_PUBLIC_*` at compile time, so a production build folds this +// to `false` and the harness never ships. Preview builds keep it, so a PR can +// be reviewed across device widths on its own vercel URL. +// +// Both the page and the in-pane agent read this constant. They must agree — a +// page that renders without an agent in the panes is a dead harness. +export const DEV_TOOLS_ENABLED = + process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' + +// Where the /dev pages may answer. peanut.me is the only host that blocks them; +// localhost, staging and vercel previews keep every tool. A build with no +// NEXT_PUBLIC_BASE_URL reads as peanut.me, so the gate fails closed. +// Cannot import BASE_URL from general.consts — that module pulls in viem and +// proxy.ts runs on the edge. +export const DEV_ROUTES_ENABLED = + DEV_TOOLS_ENABLED || (process.env.NEXT_PUBLIC_BASE_URL || 'https://peanut.me') !== 'https://peanut.me' + +// The /dev routes that stay reachable on peanut.me. payment-graph is the +// activity visualisation shown at events. full-graph is deliberately absent: +// the legacy page loads the same team-gated dataset without the explorer's +// telemetry suppression. safe-area reads device insets and must run on the +// production native build, where the bad insets are. +const PROD_ALLOWED_DEV_ROUTES = ['/dev/payment-graph', '/dev/safe-area'] + +// True for any /dev path that must not answer on peanut.me. One check for both +// route groups — `src/app/dev/*` and `src/app/(mobile-ui)/dev/*`. +export function isBlockedDevRoute(pathname: string): boolean { + if (pathname !== '/dev' && !pathname.startsWith('/dev/')) return false + return !PROD_ALLOWED_DEV_ROUTES.some((route) => pathname === route || pathname.startsWith(`${route}/`)) +} + +// The one call every gate makes — proxy.ts on the web, the two dev layouts on +// the native static export. The flag and the predicate are only meaningful +// together; a call site that uses isBlockedDevRoute alone would 404 dev +// tooling on staging and previews. +export function shouldBlockDevRoute(pathname: string): boolean { + return !DEV_ROUTES_ENABLED && isBlockedDevRoute(pathname) +} diff --git a/src/constants/routes.ts b/src/constants/routes.ts index c7f4601508..b74fcbdda7 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -41,7 +41,6 @@ export const DEDICATED_ROUTES = [ 'qr-pay', 'badges', 'limits', - 'notifications', 'recover-funds', 'card-recovery', 'recover-wallet', @@ -132,9 +131,10 @@ export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invit /** * Regex for dev-only public routes: ALL /dev pages (index + every tool/preview). * Only matched when IS_DEV is true (build-time NODE_ENV==='development'), so this - * never applies on prod/preview builds. /dev is also independently notFound()'d on - * peanut.me by dev/layout.tsx (except payment-graph/safe-area), so dev tooling is - * doubly walled off from prod — this just removes the login-redirect friction locally. + * never applies on prod/preview builds. On peanut.me the /dev pages are blocked + * by shouldBlockDevRoute — proxy.ts answers a real 404 on the web, and the two + * dev layouts notFound() on the native static export — except the allowlist in + * dev-tools.consts.ts. This regex just removes login-redirect friction locally. */ export const DEV_ONLY_PUBLIC_ROUTES_REGEX = /^\/dev(\/|$)/ diff --git a/src/content b/src/content index 9101046215..a1f0f0ccad 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 9101046215f3eb24c447176d3e99de883c0aacd6 +Subproject commit a1f0f0ccad291781e0825b937cfb4084229f5010 diff --git a/src/context/RainCooldownContext.tsx b/src/context/RainCooldownContext.tsx index c444f5aa7e..1c3a533776 100644 --- a/src/context/RainCooldownContext.tsx +++ b/src/context/RainCooldownContext.tsx @@ -55,8 +55,8 @@ const CooldownPillContent = ({ endsAt }: { endsAt: number }) => { const remainingMs = Math.max(0, endsAt - now) return (
- - + + {t('rainCooldownPill')} · {formatRemaining(remainingMs)}
@@ -105,8 +105,7 @@ export function RainCooldownProvider({ children }: { children: ReactNode }) { toast({ id: COOLDOWN_TOAST_ID, duration: 'persistent', - position: 'bottom-right', - className: 'border-yellow-1', + className: 'border-action-secondary', content: , }) }, [cooldownEndsAt, toast, dismiss]) diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx index de219f401e..372e85ced1 100644 --- a/src/context/kernelClient.context.tsx +++ b/src/context/kernelClient.context.tsx @@ -26,6 +26,7 @@ import type { Address, Hash } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { isDemoMode } from '@/utils/demo' import { DEMO_ADDRESS } from '@/constants/demo-data' +import { ensureActiveFixture } from '@/dev/fixtures/active' import { captureException, captureMessage } from '@sentry/nextjs' import { retryAsync } from '@/utils/retry.utils' import { isStaleClientForUser, isStaleKeyError, createStaleSessionError } from '@/utils/walletCredential.utils' @@ -358,8 +359,10 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { return } - // Demo mode: no passkey/kernel client — synthesize the address and report ready. - if (isDemoMode()) { + // Demo mode, and dev fixtures: no passkey/kernel client — synthesize the + // address and report ready. Without this a fixture screen waits forever + // on a kernel address it can never get. + if (isDemoMode() || ensureActiveFixture()) { dispatch(zerodevActions.setAddress(DEMO_ADDRESS)) dispatch(zerodevActions.setIsKernelClientReady(true)) return diff --git a/src/dev/devsync-agent.ts b/src/dev/devsync-agent.ts new file mode 100644 index 0000000000..5d9370a62f --- /dev/null +++ b/src/dev/devsync-agent.ts @@ -0,0 +1,341 @@ +/** + * devsync agent - Milestone 1 + * + * Runs inside each harness iframe. Import once, as early as possible, behind + * DEV_TOOLS_ENABLED. No-ops when not inside a harness pane, so it is safe to + * call unconditionally behind that gate. + * + * if (DEV_TOOLS_ENABLED) { + * import('@/dev/devsync-agent').then((m) => m.initDevsyncAgent()) + * } + */ + +const CHANNEL = 'devsync' + +type ToHarness = + | { t: 'ready'; pane: string; w: number; h: number } + | { t: 'log'; pane: string; level: 'error' | 'warn'; text: string } + | { t: 'overflow'; pane: string; hits: { sel: string; overhang: number }[] } + | { t: 'measured'; pane: string; sel: string; px: number | null; pct: number | null } + +// Peer-to-peer: agents hear each other directly on the channel and mirror +// state so every pane behaves like a clone. `from` is the self-filter. The +// harness also listens to `route` to keep its path bar in sync. +type Peer = + | { t: 'hover'; from: string; sel: string } + | { t: 'route'; from: string; path: string } + | { t: 'scroll'; from: string; sel: string | null; ratio: number } + | { t: 'input'; from: string; sel: string; value: string } + | { t: 'click'; from: string; sel: string } + +type FromHarness = { t: 'navigate'; path: string } | { t: 'scan' } | { t: 'clear' } | { t: 'reload' } + +/** window.name survives every navigation. frameElement is the same-origin fallback. */ +function resolvePaneId(): string | null { + if (typeof window === 'undefined') return null + if (window.name?.startsWith('pane:')) return window.name.slice(5) + try { + const el = window.frameElement as HTMLElement | null + return el?.dataset?.pane ?? null + } catch { + return null + } +} + +/** + * Build a selector that has a decent chance of resolving in a sibling pane. + * data-testid first, because it is stable across breakpoints. nth-of-type path + * as a fallback, which will miss whenever the DOM branches per breakpoint. + * Missing is fine. Guessing is not. + */ +function selectorFor(el: Element): string { + const testid = el.getAttribute('data-testid') + if (testid) return `[data-testid="${CSS.escape(testid)}"]` + + if (el.id) return `#${CSS.escape(el.id)}` + + const parts: string[] = [] + let node: Element | null = el + while (node && node !== document.body && parts.length < 12) { + const parent: Element | null = node.parentElement + if (!parent) break + const tag = node.tagName.toLowerCase() + const sameTag = Array.from(parent.children).filter((c) => c.tagName === node!.tagName) + parts.unshift(sameTag.length > 1 ? `${tag}:nth-of-type(${sameTag.indexOf(node) + 1})` : tag) + node = parent + } + return `body > ${parts.join(' > ')}` +} + +function viewportWidth(): number { + return document.documentElement.clientWidth +} + +export function initDevsyncAgent(): void { + const maybePane = resolvePaneId() + if (!maybePane) return + // rebind so hoisted function declarations below see `string`, not `string | null` + const pane: string = maybePane + + const bc = new BroadcastChannel(CHANNEL) + const send = (m: ToHarness | Peer) => bc.postMessage(m) + + // ---- identity + route reporting ------------------------------------- + // Heartbeat: the harness remounts panes that go silent (e.g. a pane that + // landed on a connection-refused error page has no agent at all). + send({ t: 'ready', pane, w: viewportWidth(), h: window.innerHeight }) + window.setInterval(() => send({ t: 'ready', pane, w: viewportWidth(), h: window.innerHeight }), 3000) + + let lastPath = location.pathname + location.search + let routeTimer: number | undefined + // Debounced: nuqs writes query params per keystroke via replaceState, and + // followers hard-load - broadcast once the URL settles, not per keystroke. + const reportRoute = () => { + window.clearTimeout(routeTimer) + routeTimer = window.setTimeout(() => { + const p = location.pathname + location.search + if (p === lastPath) return + lastPath = p + send({ t: 'route', from: pane, path: p }) + }, 300) + } + // Covers both history API navigation and back/forward. + for (const k of ['pushState', 'replaceState'] as const) { + const orig = history[k] + history[k] = function (this: History, ...args: Parameters) { + const r = orig.apply(this, args) + queueMicrotask(reportRoute) + return r + } + } + window.addEventListener('popstate', reportRoute) + + // ---- error aggregation ---------------------------------------------- + const origError = console.error.bind(console) + console.error = (...args: unknown[]) => { + send({ t: 'log', pane, level: 'error', text: args.map(String).join(' ').slice(0, 400) }) + origError(...args) + } + window.addEventListener('error', (e) => { + send({ t: 'log', pane, level: 'error', text: `${e.message} (${e.filename}:${e.lineno})` }) + }) + window.addEventListener('unhandledrejection', (e) => { + send({ t: 'log', pane, level: 'error', text: `unhandled rejection: ${String(e.reason)}` }) + }) + + // ---- overflow detection --------------------------------------------- + const OUTLINE_ATTR = 'data-devsync-overflow' + function clearOutlines() { + document.querySelectorAll(`[${OUTLINE_ATTR}]`).forEach((el) => { + ;(el as HTMLElement).style.outline = '' + el.removeAttribute(OUTLINE_ATTR) + }) + } + function scanOverflow() { + clearOutlines() + const vw = viewportWidth() + const hits: { sel: string; overhang: number }[] = [] + // If the document itself does not scroll horizontally, every apparent + // overhang is clipped by some ancestor and is not a page-scroll bug. + if (document.documentElement.scrollWidth <= vw + 1) { + send({ t: 'overflow', pane, hits }) + return + } + document.querySelectorAll('body *').forEach((el) => { + const r = el.getBoundingClientRect() + if (r.width === 0 || r.height === 0) return + const overhang = Math.max(Math.round(r.right - vw), Math.round(-r.left)) + if (overhang <= 1) return + // Skip display:none and position:fixed - fixed elements never scroll the page. + if (el.offsetParent === null) return + el.style.outline = '2px solid #e5484d' + el.setAttribute(OUTLINE_ATTR, '') + hits.push({ sel: selectorFor(el), overhang }) + }) + hits.sort((a, b) => b.overhang - a.overhang) + send({ t: 'overflow', pane, hits: hits.slice(0, 20) }) + } + + // ---- measure overlay -------------------------------------------------- + let highlighted: HTMLElement | null = null + function highlight(el: HTMLElement | null) { + if (highlighted) highlighted.style.boxShadow = '' + highlighted = el + if (el) el.style.boxShadow = 'inset 0 0 0 1px #2680eb' + } + function measure(sel: string) { + let el: HTMLElement | null = null + try { + el = document.querySelector(sel) + } catch { + el = null + } + if (!el) { + highlight(null) + send({ t: 'measured', pane, sel, px: null, pct: null }) + return + } + highlight(el) + const px = Math.round(el.getBoundingClientRect().width) + send({ t: 'measured', pane, sel, px, pct: Math.round((px / viewportWidth()) * 100) }) + } + + let hoverTimer: number | undefined + document.addEventListener( + 'mouseover', + (e) => { + const el = e.target as HTMLElement + if (!el || el === document.body) return + window.clearTimeout(hoverTimer) + // Debounced so a mouse crossing the pane does not spam the channel. + hoverTimer = window.setTimeout(() => { + const sel = selectorFor(el) + send({ t: 'hover', from: pane, sel }) + measure(sel) + }, 60) + }, + { passive: true } + ) + + // ---- clone sync: scroll + form input ---------------------------------- + // Capture-phase listener sees scrolls of inner containers too (scroll + // does not bubble, but it does capture). Ratio-based so panes of + // different heights land on the same content. + let lastRemoteScroll = 0 + document.addEventListener( + 'scroll', + (e) => { + if (Date.now() - lastRemoteScroll < 250) return + const isDoc = e.target === document + const el = isDoc ? document.documentElement : (e.target as HTMLElement) + const max = el.scrollHeight - el.clientHeight + if (max <= 0) return + send({ t: 'scroll', from: pane, sel: isDoc ? null : selectorFor(el), ratio: el.scrollTop / max }) + }, + { capture: true, passive: true } + ) + function applyScroll(sel: string | null, ratio: number) { + let el: HTMLElement | null = document.documentElement + if (sel) { + try { + el = document.querySelector(sel) + } catch { + el = null + } + } + if (!el) return + lastRemoteScroll = Date.now() + el.scrollTop = ratio * (el.scrollHeight - el.clientHeight) + } + + let applyingInput = false + document.addEventListener( + 'input', + (e) => { + if (applyingInput) return + const el = e.target as HTMLInputElement | null + if (!el || typeof el.value !== 'string') return + send({ t: 'input', from: pane, sel: selectorFor(el), value: el.value }) + }, + true + ) + function applyInput(sel: string, value: string) { + let el: HTMLElement | null = null + try { + el = document.querySelector(sel) + } catch { + el = null + } + if (!el) return + const proto = + el instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : el instanceof HTMLSelectElement + ? HTMLSelectElement.prototype + : el instanceof HTMLInputElement + ? HTMLInputElement.prototype + : null + if (!proto) return + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set + if (!setter) return + // React ignores plain .value writes - go through the native setter, + // then dispatch input/change so controlled components pick it up. + applyingInput = true + setter.call(el, value) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + applyingInput = false + } + + // ---- clone sync: clicks ----------------------------------------------- + // Mirrors real user clicks so useState-driven UI (modals, drawers, camera + // steps) opens in every pane, not just the routed screens. WARNING: this + // also means mutating actions fire once per pane - fine against the local + // sandbox backend, never point this at prod. + let applyingClick = false + document.addEventListener( + 'click', + (e) => { + // isTrusted filters out both our own synthetic clicks and any the app fires + if (applyingClick || !e.isTrusted) return + const el = e.target instanceof Element ? e.target : null + if (!el) return + send({ t: 'click', from: pane, sel: selectorFor(el) }) + }, + true + ) + function applyClick(sel: string) { + let el: HTMLElement | null = null + try { + el = document.querySelector(sel) + } catch { + el = null + } + if (!el) return + applyingClick = true + el.click() + applyingClick = false + } + + // ---- route following ---------------------------------------------------- + // Deterministic per-pane stagger: click-sync usually SPA-navigates a pane + // on its own, and 6 simultaneous hard loads can overwhelm the dev server + // (the "localhost refused to connect" panes). Delay, then re-check. + const followDelay = 200 + (Array.from(pane).reduce((a, c) => a + c.charCodeAt(0), 0) % 5) * 250 + let followTimer: number | undefined + function followRoute(path: string) { + window.clearTimeout(followTimer) + followTimer = window.setTimeout(() => { + if (location.pathname + location.search !== path) location.href = path + }, followDelay) + } + + // ---- inbound ---------------------------------------------------------- + bc.onmessage = (e: MessageEvent) => { + const m = e.data + if (m.t === 'navigate') { + if (location.pathname + location.search !== m.path) location.href = m.path + } else if (m.t === 'route') { + // follow a peer's navigation (staggered hard load; no-op if click + // sync already SPA-navigated this pane there). + if (m.from !== pane) followRoute(m.path) + } else if (m.t === 'scroll') { + if (m.from !== pane) applyScroll(m.sel, m.ratio) + } else if (m.t === 'input') { + if (m.from !== pane) applyInput(m.sel, m.value) + } else if (m.t === 'click') { + if (m.from !== pane) applyClick(m.sel) + } else if (m.t === 'scan') { + scanOverflow() + } else if (m.t === 'hover') { + if (m.from !== pane) measure(m.sel) + } else if (m.t === 'clear') { + clearOutlines() + highlight(null) + } else if (m.t === 'reload') { + location.reload() + } + } + + window.addEventListener('pagehide', () => bc.close()) +} diff --git a/src/dev/fixtures/FixtureBanner.tsx b/src/dev/fixtures/FixtureBanner.tsx new file mode 100644 index 0000000000..6d96d6bd62 --- /dev/null +++ b/src/dev/fixtures/FixtureBanner.tsx @@ -0,0 +1,44 @@ +'use client' + +// Every API answer on this tab is faked while a fixture is active, but a +// logged-in user who opens a ?__fixture= link keeps their real session cookie +// — without this strip there is no visible difference between fixture data and +// their real account. Dev tooling: plain elements on purpose, no design-system +// ceremony. + +import { useEffect, useState } from 'react' +import { DEV_TOOLS_ENABLED } from '@/constants/dev-tools.consts' +import { FIXTURE_PARAM, peekActiveFixture } from '@/dev/fixtures/active' + +export function FixtureBanner() { + // read after mount: peekActiveFixture is window-only, so rendering from it + // during SSR/hydration would mismatch. + const [name, setName] = useState(null) + useEffect(() => { + if (DEV_TOOLS_ENABLED) setName(peekActiveFixture()) + }, []) + if (!name) return null + + return ( + // plain , full navigation: ?__fixture=off is read by + // ensureActiveFixture on the next load, which clears the session and + // the fake cookie. + + fixture: {name} — API faked · tap to exit + + ) +} diff --git a/src/dev/fixtures/__tests__/fixtures.test.ts b/src/dev/fixtures/__tests__/fixtures.test.ts new file mode 100644 index 0000000000..7c073e0652 --- /dev/null +++ b/src/dev/fixtures/__tests__/fixtures.test.ts @@ -0,0 +1,67 @@ +import { existsSync, readdirSync } from 'fs' +import { join } from 'path' +import { FIXTURES } from '@/dev/fixtures/registry' + +const names = Object.keys(FIXTURES) + +const APP_DIR = join(process.cwd(), 'src', 'app', '(mobile-ui)') + +// A dynamic segment is a real route: /limits/manteca is served by limits/[provider]. +function routeExists(route: string): boolean { + let dir = APP_DIR + for (const segment of route.split('/').filter(Boolean)) { + if (existsSync(join(dir, segment))) { + dir = join(dir, segment) + continue + } + const dynamic = readdirSync(dir).find((entry) => entry.startsWith('[')) + if (!dynamic) return false + dir = join(dir, dynamic) + } + return existsSync(join(dir, 'page.tsx')) +} + +describe('fixture registry', () => { + it('has unique kebab-case names', () => { + expect(new Set(names).size).toBe(names.length) + for (const name of names) expect(name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/) + }) + + // A fixture whose route was renamed or deleted opens a 404 and looks like the + // fixture is broken. The name also becomes a screenshot filename, so a stale + // route quietly rots a screenshot job. + it('points every fixture at a route that exists', () => { + for (const [name, fixture] of Object.entries(FIXTURES)) { + expect(routeExists(fixture.route) ? fixture.route : `${name} → missing ${fixture.route}`).toBe( + fixture.route + ) + } + }) +}) + +// The whole system hangs off one build-time constant. If that gate ever stops +// holding, a production build ships fake API answers to real users. +describe('production gate', () => { + beforeEach(() => { + jest.resetModules() + window.history.replaceState({}, '', '/?__fixture=home') + window.sessionStorage.setItem('peanut_fixture', 'home') + }) + + afterEach(() => { + window.sessionStorage.clear() + window.history.replaceState({}, '', '/') + }) + + it('reports no fixture when dev tools are disabled', () => { + jest.doMock('@/constants/dev-tools.consts', () => ({ DEV_TOOLS_ENABLED: false })) + const { ensureActiveFixture } = require('@/dev/fixtures/active') + expect(ensureActiveFixture()).toBeNull() + }) + + it('reports the fixture when dev tools are enabled', () => { + jest.doMock('@/constants/dev-tools.consts', () => ({ DEV_TOOLS_ENABLED: true })) + const { ensureActiveFixture } = require('@/dev/fixtures/active') + expect(ensureActiveFixture()).toBe('home') + }) +}) diff --git a/src/dev/fixtures/active.ts b/src/dev/fixtures/active.ts new file mode 100644 index 0000000000..66322d3ba6 --- /dev/null +++ b/src/dev/fixtures/active.ts @@ -0,0 +1,90 @@ +// Which fixture, if any, this tab is running. Kept tiny and dependency-free: +// api-fetch imports it on every call, and DEV_TOOLS_ENABLED folds it to +// `return null` in a production build. + +import { DEV_TOOLS_ENABLED } from '@/constants/dev-tools.consts' + +export const FIXTURE_PARAM = '__fixture' +// exported for e2e/shots/fixtures.spec.ts, which reads it back through the +// browser to prove fixture mode actually engaged before taking a screenshot. +export const FIXTURE_STORAGE_KEY = 'peanut_fixture' +const JWT_COOKIE = 'jwt-token' + +// sessionStorage, not React state or a URL param alone: the app does a lot of +// nuqs/router navigation and the param is dropped on the first route change, +// so a param-only fixture would evaporate. Session scope also means the fake +// session dies with the tab instead of following a developer around. +function stored(): string | null { + try { + return window.sessionStorage.getItem(FIXTURE_STORAGE_KEY) + } catch { + return null + } +} + +function activate(name: string): void { + try { + window.sessionStorage.setItem(FIXTURE_STORAGE_KEY, name) + } catch {} + // The frontend only checks that a jwt-token cookie EXISTS (proxy.ts, + // auth-token.ts). Every API answer is faked, so an opaque value is enough — + // no real JWT, no login call. Only set it when absent: the cookie is + // origin-wide, so overwriting would destroy a real session in every tab. + if (jwtCookieValue() === null) document.cookie = `${JWT_COOKIE}=fixture; path=/` +} + +function jwtCookieValue(): string | null { + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${JWT_COOKIE}=([^;]*)`)) + return match ? match[1] : null +} + +/** Drops the fixture and the fake session, so the tab behaves normally again. */ +export function clearFixture(): void { + if (typeof window === 'undefined') return + try { + window.sessionStorage.removeItem(FIXTURE_STORAGE_KEY) + } catch {} + // only remove the cookie this module wrote — ensureActiveFixture runs on + // every API call, so while ?__fixture=off sits in the URL an unguarded + // delete would keep destroying a freshly created real session. + if (jwtCookieValue() === 'fixture') { + document.cookie = `${JWT_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT` + } +} + +/** + * Name of the running fixture, or null. Reads `?__fixture=` first so a + * pasted URL always wins, then falls back to the session. `?__fixture=off` + * clears it. + * + * `ensure`, not `get`: promoting a URL param to the session writes + * sessionStorage and the cookie. Idempotent, so the render-time callers and a + * StrictMode double-render are both safe. + */ +export function ensureActiveFixture(): string | null { + if (!DEV_TOOLS_ENABLED || typeof window === 'undefined') return null + const fromUrl = new URLSearchParams(window.location.search).get(FIXTURE_PARAM) + if (fromUrl === 'off' || fromUrl === '') { + clearFixture() + return null + } + if (fromUrl && fromUrl !== stored()) activate(fromUrl) + return fromUrl ?? stored() +} + +/** + * Read-only twin of ensureActiveFixture, for React render bodies. Same + * resolution — URL param first, then the session — but zero writes: no + * sessionStorage, no cookie, and `?__fixture=off` just reads as null. + * + * The split: writing during render is a render-phase side effect (unsafe under + * StrictMode/concurrent rendering), so anything that runs while rendering + * calls this. The promotion write stays in ensureActiveFixture, which + * non-render sites (api-fetch, effects, callbacks) keep calling. + */ +export function peekActiveFixture(): string | null { + if (!DEV_TOOLS_ENABLED || typeof window === 'undefined') return null + const fromUrl = new URLSearchParams(window.location.search).get(FIXTURE_PARAM) + if (fromUrl === 'off' || fromUrl === '') return null + return fromUrl ?? stored() +} diff --git a/src/dev/fixtures/registry.ts b/src/dev/fixtures/registry.ts new file mode 100644 index 0000000000..1c68182085 --- /dev/null +++ b/src/dev/fixtures/registry.ts @@ -0,0 +1,349 @@ +// Named app states for visual checks. Open any of them with +// `?__fixture=`, or browse them at /dev/fixtures. +// +// The baseline is the demo API (utils/demo-api.ts): a verified user with a +// balance, four contacts, four history entries and working rails. A fixture +// only states what differs, so most screen defaults need no overrides at all. +// +// Account shapes below are adapted from the old e2e/utils/mock-api.ts, whose +// responses were checked against production on 2026-04-16. That file is gone; +// this registry replaced it. + +import type { Fixture } from './types' + +// Hugo's overflow case: a username no header was designed for, and a points +// total that is nine digits with separators. +const LONG_USERNAME = 'bh12ui2buibui52bi' +const HUGE_POINTS = 1_200_244_192 + +const LONG_FULL_NAME = 'Maximiliano Alejandro Fernández de la Vega y Santibáñez' + +// The withdraw screen reads saved accounts from `user.accounts` — NOT from +// GET /users/accounts, which nothing on that screen calls. Overriding the +// wrong endpoint is why this fixture used to show "No accounts yet". +// Arrays replace on merge, so the wallet row has to be repeated here: useWallet +// matches the balance on it. Same shape as DEMO_USER.accounts[0], copied rather +// than imported — this file stays dependency-free so Playwright can load it. +const WALLET_ACCOUNT = { + id: 'demo-account', + userId: 'demo-user', + bridgeAccountId: '', + type: 'peanut-wallet', + identifier: '0xdec0debad1dec0debad1dec0debad1dec0debad1', + details: { bankName: null, accountOwnerName: 'Demo User', countryCode: '', countryName: '' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + chainId: '42161', +} + +const BANK_ACCOUNTS = [ + { + id: 'fixture-iban-1', + userId: 'demo-user', + bridgeAccountId: '', + type: 'iban', + identifier: 'ES27007509842206070802', + details: { bankName: 'Banco Fixture', accountOwnerName: 'Demo User', countryCode: 'ESP', countryName: 'spain' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + chainId: null, + }, + { + id: 'fixture-us-1', + userId: 'demo-user', + bridgeAccountId: '', + type: 'us', + identifier: '938636999398030', + routingNumber: '021000021', + details: { + bankName: 'Fixture Bank', + accountOwnerName: 'Demo User', + countryCode: 'USA', + countryName: 'united-states', + }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + chainId: null, + }, +] + +// The activity list is not only transactions: it also injects a row per badge +// in `user.badges` and one identity-verification row. An empty state needs all +// three cleared, or "no transactions" still renders four rows. +const NO_TIMELINE_EXTRAS = { + 'GET /users/history': { entries: [], hasMore: false }, + 'GET /users/me': { user: { badges: [] }, identityVerification: { status: 'not_started' } }, +} + +const RICH_POINTS = { + userId: 'demo-user', + directPoints: 900_000_000, + transitivePoints: 300_244_192, + totalPoints: HUGE_POINTS, + currentTier: 3, + nextTierThreshold: 2_000_000_000, + pointsToNextTier: 799_755_808, +} + +const CASH_STATUS_EARNED = { + hasCashbackLeft: true, + lifetimeEarned: 25.5, + lifetimeBreakdown: { cashback: 10, inviterRewards: 10, withdrawPerks: 3, depositPerks: 2, other: 0.5 }, + rewards: { pendingUsd: 5, lifetimeEarnedUsd: 25.5 }, +} + +const INVITES_ONE = { + invitees: [ + { + inviteeId: 'fixture-invitee-1', + username: 'testfriend1', + fullName: 'Test Friend', + kycVerified: true, + contributedPoints: 50, + showFullName: false, + lifetimeEarnedUsd: 0.5, + }, + ], + summary: { + multiplier: 1, + pendingInvites: 0, + totalContributedPoints: 50, + totalDirectPoints: 200, + totalInvites: 1, + verifiedInvites: 1, + totalLifetimeEarnedUsd: 0.5, + totalPendingUsd: 0, + }, +} + +const HUGE_HISTORY_ENTRY = { + uuid: 'fixture-huge-tx', + type: 'TRANSACTION_INTENT', + timestamp: new Date('2026-08-01T10:00:00.000Z'), + amount: '9876543.21', + chainId: '42161', + tokenSymbol: 'USDC', + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + status: 'COMPLETED', + userRole: 'RECIPIENT', + senderAccount: { identifier: LONG_USERNAME, type: 'PEANUT_WALLET', isUser: true, username: LONG_USERNAME }, + recipientAccount: { identifier: 'demo', type: 'PEANUT_WALLET', isUser: true, username: 'demo' }, + extraData: { kind: 'DIRECT_TRANSFER', usdAmount: '9876543.21' }, + memo: 'Series B wire, split three ways with a memo long enough to wrap', +} + +export const FIXTURES: Record = { + // --------------------------------------------------------------------- + // One per screen — the known-good default for each. + // --------------------------------------------------------------------- + home: { route: '/home', about: 'Home: balance, activity and CTAs for a verified user.' }, + profile: { route: '/profile', about: 'Profile menu, verified user, card row present.' }, + 'profile-edit': { route: '/profile/edit', about: 'Personal details form, pre-filled.' }, + 'identity-verification': { + route: '/profile/identity-verification', + about: 'Unlocked regions for a user whose ID check passed.', + }, + 'settings-language': { route: '/settings/language', about: 'Language picker, English selected.' }, + rewards: { + route: '/rewards', + about: 'Points total, tier badge and invite list.', + responses: { 'GET /points/cash-status': CASH_STATUS_EARNED, 'GET /points/invites': INVITES_ONE }, + }, + 'rewards-invites': { + route: '/rewards/invites', + about: 'Invite list with one verified friend.', + responses: { 'GET /points/invites': INVITES_ONE }, + }, + badges: { route: '/badges', about: 'Badge wall with three earned badges.' }, + history: { route: '/history', about: 'Activity list, four entries, both directions.' }, + 'add-money': { route: '/add-money', about: 'Add money: the crypto and bank-transfer picker.' }, + 'add-money-crypto': { route: '/add-money/crypto', about: 'Crypto deposit: the network picker.' }, + withdraw: { + route: '/withdraw', + about: 'Withdraw with two saved bank accounts (a Spanish IBAN and a US account).', + responses: { 'GET /users/me': { accounts: [WALLET_ACCOUNT, ...BANK_ACCOUNTS] } }, + }, + limits: { route: '/limits', about: 'Payment limits: the unlocked regions and the crypto note.' }, + send: { route: '/send', about: 'Send: the method picker — link, contacts, bank or Mercado Pago.' }, + request: { route: '/request', about: 'Request money: amount entry.' }, + + // --------------------------------------------------------------------- + // Hazards — user text and numbers that break layouts. + // --------------------------------------------------------------------- + 'hugo-long-username': { + route: '/profile', + about: `Hugo's case: the ${LONG_USERNAME.length}-character username "${LONG_USERNAME}" in the header and the share-link pill.`, + responses: { + 'GET /users/me': { user: { username: LONG_USERNAME, fullName: LONG_USERNAME, showFullName: false } }, + 'GET /points': RICH_POINTS, + 'GET /points/invites': INVITES_ONE, + }, + }, + 'long-full-name': { + route: '/profile', + about: 'Profile header with a 55-character full name shown instead of the username.', + responses: { 'GET /users/me': { user: { fullName: LONG_FULL_NAME, showFullName: true } } }, + }, + 'long-name-history': { + route: '/history', + about: 'Activity row where a long counterparty name is clipped by a nine-digit amount.', + responses: { + 'GET /users/history': { entries: [HUGE_HISTORY_ENTRY], hasMore: false }, + }, + }, + 'huge-amount-history': { + route: '/history', + about: 'A $9,876,543.21 transfer next to normal amounts.', + responses: { + 'GET /users/history': { + entries: [HUGE_HISTORY_ENTRY, { ...HUGE_HISTORY_ENTRY, uuid: 'fixture-small-tx', amount: '1.00' }], + hasMore: false, + }, + }, + }, + // /limits is a region list; the numbers live one screen deeper, on the + // per-provider page. Aim at that page or the big values never reach the shot. + 'huge-limits': { + route: '/limits/manteca', + about: 'Eleven-digit ARS monthly caps: checks the number abbreviation and the progress bar.', + responses: { + 'GET /users/limits': { + manteca: [ + { + exchangeCountry: 'ARG', + type: 'EXCHANGE', + asset: 'ARS', + yearlyLimit: '999999999999', + availableYearlyLimit: '987654321098', + monthlyLimit: '99999999999', + availableMonthlyLimit: '98765432109', + }, + ], + }, + }, + }, + 'huge-rewards-cash': { + route: '/rewards', + about: 'Points and cashback both at implausible totals.', + responses: { + 'GET /points': RICH_POINTS, + 'GET /points/cash-status': { + hasCashbackLeft: true, + lifetimeEarned: 1234567.89, + lifetimeBreakdown: { + cashback: 1000000, + inviterRewards: 200000, + withdrawPerks: 30000, + depositPerks: 4000, + other: 567.89, + }, + rewards: { pendingUsd: 98765.43, lifetimeEarnedUsd: 1234567.89 }, + }, + }, + }, + + // --------------------------------------------------------------------- + // Empty states. + // --------------------------------------------------------------------- + 'empty-history': { + route: '/history', + about: 'Nothing on the timeline yet: no transaction, no badge, no ID check.', + responses: NO_TIMELINE_EXTRAS, + }, + 'empty-home': { + route: '/home', + about: 'Fresh account: nothing on the timeline, so the activity block is gone. The balance comes from the demo overlay, not the API.', + responses: { + ...NO_TIMELINE_EXTRAS, + 'GET /points': { totalPoints: 0, directPoints: 0, transitivePoints: 0, currentTier: 0 }, + }, + }, + 'empty-invites': { + route: '/rewards/invites', + about: 'Nobody invited yet.', + responses: { 'GET /points/invites': { invitees: [], summary: { totalInvited: 0, totalPointsEarned: 0 } } }, + }, + 'empty-accounts': { + route: '/withdraw', + about: 'Withdraw with no saved bank account — the add-account path.', + responses: { 'GET /users/me': { accounts: [WALLET_ACCOUNT] } }, + }, + 'empty-rewards': { + route: '/rewards', + about: 'Zero points, tier 0, no invites.', + responses: { + 'GET /points': { + totalPoints: 0, + directPoints: 0, + transitivePoints: 0, + currentTier: 0, + nextTierThreshold: 100, + pointsToNextTier: 100, + }, + 'GET /points/invites': { invitees: [], summary: { totalInvited: 0, totalPointsEarned: 0 } }, + }, + }, + + // --------------------------------------------------------------------- + // Verification states. + // + // The region screens read `capabilities.rails`, never + // `identityVerification.status`. Override the rails or the screen shows a + // fully unlocked user whatever the status says. + // --------------------------------------------------------------------- + unverified: { + route: '/profile/identity-verification', + about: 'ID check never started: no region unlocked, all four locked.', + responses: { + 'GET /users/me': { + identityVerification: { status: 'not_started' }, + capabilities: { rails: [], nextActions: [], restrictions: [] }, + }, + }, + }, + 'kyc-action-required': { + route: '/profile/identity-verification', + about: 'Bridge asks for more verification: the task card and its Complete verification button.', + responses: { + 'GET /users/me': { + capabilities: { + rails: [ + { + id: 'bridge.ach_us', + provider: 'bridge', + method: 'ACH_US', + channel: 'bank', + country: 'US', + currency: 'USD', + status: 'requires-info', + blockingActions: ['bridge-hosted:proof-of-address'], + }, + ], + nextActions: [ + { + key: 'bridge-hosted:proof-of-address', + kind: 'bridge-hosted', + purpose: 'unlock-bridge-ach', + requirementKey: 'proof_of_address', + }, + ], + restrictions: [], + }, + }, + }, + }, + + // --------------------------------------------------------------------- + // Error states. + // --------------------------------------------------------------------- + 'error-history': { + route: '/history', + about: 'Activity fails to load while the rest of the app works.', + fails: ['GET /users/history'], + }, + 'error-limits': { + route: '/limits/manteca', + about: 'The caps screen when GET /users/limits fails.', + fails: ['GET /users/limits'], + }, +} diff --git a/src/dev/fixtures/respond.ts b/src/dev/fixtures/respond.ts new file mode 100644 index 0000000000..13560e7b61 --- /dev/null +++ b/src/dev/fixtures/respond.ts @@ -0,0 +1,54 @@ +// Answers an API call from the active fixture. Loaded lazily from api-fetch, +// behind DEV_TOOLS_ENABLED, so no reachable production code path runs it. +// (The chunk itself is still emitted — webpack creates one for every import() +// it finds in the source, dead branch or not. Same for /dev/devices.) + +import { demoRespond } from '@/utils/demo-api' +import { FIXTURES } from './registry' +import { ensureActiveFixture } from './active' + +const JSON_HEADERS = { 'content-type': 'application/json' } + +let warned = false + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +/** Objects merge key by key. Arrays and primitives replace, so `[]` empties a list. */ +function merge(base: unknown, patch: unknown): unknown { + if (!isPlainObject(base) || !isPlainObject(patch)) return patch + const out: Record = { ...base } + for (const [key, value] of Object.entries(patch)) out[key] = merge(out[key], value) + return out +} + +export async function fixtureRespond(path: string, options?: RequestInit): Promise { + const name = ensureActiveFixture() + const fixture = name ? FIXTURES[name] : undefined + if (!fixture) { + if (!warned) { + warned = true + console.error( + `[fixtures] unknown fixture "${name}". Valid names:\n ${Object.keys(FIXTURES).sort().join('\n ')}` + ) + } + // Still serve the demo defaults — a wrong name must not hang the screen. + return demoRespond(path, options) + } + + const method = (options?.method ?? 'GET').toUpperCase() + const key = `${method} ${path.split('?')[0].replace(/\/+$/, '')}` + + if (fixture.fails?.includes(key)) { + return new Response(JSON.stringify({ error: 'fixture failure' }), { status: 500, headers: JSON_HEADERS }) + } + + // demo-api already answers every route the app calls, with a shape-aware + // fallback for the rest. A fixture only says what differs from that. + const base = await demoRespond(path, options) + const override = fixture.responses?.[key] + if (override === undefined) return base + + const data = await base.json().catch(() => ({})) + return new Response(JSON.stringify(merge(data, override)), { status: base.status, headers: JSON_HEADERS }) +} diff --git a/src/dev/fixtures/types.ts b/src/dev/fixtures/types.ts new file mode 100644 index 0000000000..5ecefa37da --- /dev/null +++ b/src/dev/fixtures/types.ts @@ -0,0 +1,15 @@ +/** One named app state — a screen plus the API answers that produce it. */ +export type Fixture = { + /** Route the fixture is built for. The listing page links here with the param applied. */ + route: string + /** What the fixture proves. One line, shown on /dev/fixtures. */ + about: string + /** + * Overrides on top of the demo-api response, keyed `METHOD /path`. + * Deep-merged: objects merge key by key, arrays and primitives replace. + * So an empty state is `{ entries: [] }` and a renamed user is one line. + */ + responses?: Record + /** `METHOD /path` keys that answer 500, for error-state screens. */ + fails?: string[] +} diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx new file mode 100644 index 0000000000..c85eb15713 --- /dev/null +++ b/src/features/home/HomePage.tsx @@ -0,0 +1,76 @@ +'use client' + +import PageContainer from '@/components/0_Bruddle/PageContainer' +import Loading from '@/components/Global/Loading' +import ActivationCTAs from '@/components/Home/ActivationCTAs' +import CardLaunchCTA from '@/components/Home/CardLaunchCTA' +import EnableAutoBalanceBanner from '@/components/Home/EnableAutoBalanceBanner' +import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA' +import HomeHistory from '@/components/Home/HomeHistory' +import PendingVerificationTasks from '@/components/Home/PendingVerificationTasks' +import { HomeActionDrawers } from './components/HomeActionDrawers' +import { HomeModals } from './components/HomeModals' +import { useHomeFlow } from './useHomeFlow' +import { BalanceSection } from './views/BalanceSection' +import { HomeTopNav } from './views/HomeTopNav' + +/** + * home page (figma board 17830:75689): top nav (avatar / rewards), balance + * block with add-send-request submenu, cta card slot, activity feed. + * + * cta surfaces (carousel, activation ctas, card launch, pending verification + * tasks) are composed as-is — restyling them belongs to the activation + * project, not the ds rebuild. the unverified "verify" page state renders + * through ActivationCTAs in the card slot. + */ +export function HomePage() { + const { + isPageLoading, + username, + avatarName, + isActivated, + activationStep, + dismissCardStep, + spendableBalance, + isFetchingSpendableBalance, + isSpendableBalanceStale, + isBalanceHidden, + toggleBalanceVisibility, + } = useHomeFlow() + + if (isPageLoading) { + return + } + + return ( + +
+ + +
+ + + + {isActivated ? ( + + ) : ( + + )} + +
+
+ + +
+ ) +} diff --git a/src/features/home/__tests__/HomeActionDrawers.test.tsx b/src/features/home/__tests__/HomeActionDrawers.test.tsx new file mode 100644 index 0000000000..bfb22fa9d6 --- /dev/null +++ b/src/features/home/__tests__/HomeActionDrawers.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { withNuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { HomeActionDrawers } from '../components/HomeActionDrawers' + +// F-28: the real nuqs pipeline runs (parser, enum validation, url writes) via +// the official testing adapter — the old suite mocked all of nuqs, so the +// ?drawer= URL contract was asserted nowhere. + +const mockPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +jest.mock('@/hooks/useAppHaptic', () => ({ + useAppHaptic: () => ({ triggerHaptic: jest.fn() }), +})) + +beforeAll(() => { + window.matchMedia = + window.matchMedia || + ((query: string) => + ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + onchange: null, + }) as MediaQueryList) +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +const renderWithUrl = (search: string, onUrlUpdate?: (e: UrlUpdateEvent) => void) => + render(, { + wrapper: withNuqsTestingAdapter({ searchParams: search, onUrlUpdate }), + }) + +describe('HomeActionDrawers', () => { + it('renders nothing when no drawer param is set', () => { + renderWithUrl('') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('URL contract: the key is `drawer` and values outside the enum read as closed', () => { + renderWithUrl('?drawer=nonsense') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('opens the send drawer from ?drawer=send and routes on click after clearing the param', async () => { + const urlUpdates: UrlUpdateEvent[] = [] + renderWithUrl('?drawer=send', (e) => urlUpdates.push(e)) + + expect(screen.getByText('sendToFriends')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('home-drawer-send-withdraw')) + // push must wait for the queued url reset (coderabbit #2780) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/withdraw')) + // the drawer param was cleared from the URL before routing + expect(urlUpdates.at(-1)?.searchParams.get('drawer')).toBeNull() + }) + + it('opens the add drawer with bank and crypto options only', async () => { + renderWithUrl('?drawer=add') + + fireEvent.click(screen.getByTestId('home-drawer-add-bank')) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money?method=bank')) + + fireEvent.click(screen.getByTestId('home-drawer-add-crypto')) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto')) + + // withdraw is reachable via the SEND drawer only (product ruling) + expect(screen.queryByTestId('home-drawer-add-withdraw')).not.toBeInTheDocument() + }) +}) diff --git a/src/features/home/__tests__/useBalanceVisibility.test.ts b/src/features/home/__tests__/useBalanceVisibility.test.ts new file mode 100644 index 0000000000..d140d7b720 --- /dev/null +++ b/src/features/home/__tests__/useBalanceVisibility.test.ts @@ -0,0 +1,58 @@ +/** + * @jest-environment jsdom + */ +import { act, renderHook } from '@testing-library/react' +import { useBalanceVisibility } from '../useBalanceVisibility' +import { getUserPreferences } from '@/utils/general.utils' + +jest.mock('posthog-js', () => ({ capture: jest.fn() })) + +// unique id per test — the store keeps a module-level in-memory cache +let n = 0 +let USER_ID = '' + +describe('useBalanceVisibility', () => { + beforeEach(() => { + localStorage.clear() + USER_ID = `ds07-test-user-${n++}` + }) + + it('keeps working in-session when localStorage writes fail', () => { + const spy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota') + }) + const { result } = renderHook(() => useBalanceVisibility(USER_ID)) + act(() => result.current.toggleBalanceVisibility()) + expect(result.current.isBalanceHidden).toBe(true) + spy.mockRestore() + }) + + it('defaults to visible and toggles + persists the preference', () => { + const { result } = renderHook(() => useBalanceVisibility(USER_ID)) + expect(result.current.isBalanceHidden).toBe(false) + + act(() => result.current.toggleBalanceVisibility()) + expect(result.current.isBalanceHidden).toBe(true) + expect(getUserPreferences(USER_ID)?.balanceHidden).toBe(true) + + act(() => result.current.toggleBalanceVisibility()) + expect(result.current.isBalanceHidden).toBe(false) + expect(getUserPreferences(USER_ID)?.balanceHidden).toBe(false) + }) + + it('keeps every subscriber in sync (balance display + activity amounts)', () => { + const a = renderHook(() => useBalanceVisibility(USER_ID)) + const b = renderHook(() => useBalanceVisibility(USER_ID)) + + act(() => a.result.current.toggleBalanceVisibility()) + expect(a.result.current.isBalanceHidden).toBe(true) + expect(b.result.current.isBalanceHidden).toBe(true) + }) + + it('is a no-op without a userId', () => { + const { result } = renderHook(() => useBalanceVisibility(undefined)) + expect(result.current.isBalanceHidden).toBe(false) + act(() => result.current.toggleBalanceVisibility()) + expect(result.current.isBalanceHidden).toBe(false) + }) +}) diff --git a/src/features/home/__tests__/useHomeFlow.test.ts b/src/features/home/__tests__/useHomeFlow.test.ts new file mode 100644 index 0000000000..555fd52544 --- /dev/null +++ b/src/features/home/__tests__/useHomeFlow.test.ts @@ -0,0 +1,100 @@ +/** + * @jest-environment jsdom + */ +import { renderHook } from '@testing-library/react' +import { useHomeFlow } from '../useHomeFlow' + +const mockFetchUser = jest.fn() +const mockResetClaimBankFlow = jest.fn() +const mockResetWithdrawFlow = jest.fn() +const mockDisconnect = jest.fn() + +let mockUser: any = null +let mockIsFetchingUser = false +let mockWagmiConnected = false + +jest.mock('@/hooks/wallet/useWallet', () => ({ + useWallet: () => ({ spendableBalance: 123n, isFetchingSpendableBalance: false }), +})) +jest.mock('@/redux/hooks', () => ({ + useUserStore: () => ({ user: mockUser }), +})) +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ isFetchingUser: mockIsFetchingUser, fetchUser: mockFetchUser }), +})) +jest.mock('@/hooks/useActivationStatus', () => ({ + useActivationStatus: () => ({ isActivated: true, activationStep: 'verify', dismissCardStep: jest.fn() }), +})) +jest.mock('@/context/ClaimBankFlowContext', () => ({ + useClaimBankFlow: () => ({ resetFlow: mockResetClaimBankFlow }), +})) +jest.mock('@/context/WithdrawFlowContext', () => ({ + useWithdrawFlow: () => ({ resetWithdrawFlow: mockResetWithdrawFlow }), +})) +jest.mock('@/hooks/useCardInfo', () => ({ + useCardInfo: jest.fn(() => ({})), +})) +jest.mock('wagmi', () => ({ + useAccount: () => ({ isConnected: mockWagmiConnected }), + useDisconnect: () => ({ disconnect: mockDisconnect }), +})) +jest.mock('../useBalanceVisibility', () => ({ + useBalanceVisibility: () => ({ isBalanceHidden: false, toggleBalanceVisibility: jest.fn() }), +})) + +const userWith = (u: object) => ({ user: { userId: 'u1', username: 'kush', ...u } }) + +describe('useHomeFlow', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUser = null + mockIsFetchingUser = false + mockWagmiConnected = false + }) + + it('gates the page on first user fetch only', () => { + mockIsFetchingUser = true + const { result } = renderHook(() => useHomeFlow()) + expect(result.current.isPageLoading).toBe(true) + }) + + it('does not gate when the username is already known', () => { + mockIsFetchingUser = true + mockUser = userWith({}) + const { result } = renderHook(() => useHomeFlow()) + expect(result.current.isPageLoading).toBe(false) + }) + + it('re-fetches the user and resets money flows on mount', () => { + renderHook(() => useHomeFlow()) + expect(mockFetchUser).toHaveBeenCalledTimes(1) + expect(mockResetClaimBankFlow).toHaveBeenCalled() + expect(mockResetWithdrawFlow).toHaveBeenCalled() + }) + + it('disconnects an external wallet on home', () => { + mockWagmiConnected = true + renderHook(() => useHomeFlow()) + expect(mockDisconnect).toHaveBeenCalled() + }) + + it('leaves the external wallet alone when not connected', () => { + renderHook(() => useHomeFlow()) + expect(mockDisconnect).not.toHaveBeenCalled() + }) + + it('derives avatarName from the showFullName preference', () => { + mockUser = userWith({ showFullName: true, fullName: 'Kushagra S' }) + expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S') + + mockUser = userWith({ showFullName: false, fullName: 'Kushagra S' }) + expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('kush') + + // usernameless: full name still seeds the initials + mockUser = { user: { userId: 'u1', username: null, fullName: 'Kushagra S' } } + expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S') + + mockUser = { user: { userId: 'u1', username: null } } + expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBeUndefined() + }) +}) diff --git a/src/features/home/components/HomeActionDrawers.tsx b/src/features/home/components/HomeActionDrawers.tsx new file mode 100644 index 0000000000..6722ffcb51 --- /dev/null +++ b/src/features/home/components/HomeActionDrawers.tsx @@ -0,0 +1,110 @@ +'use client' + +import { Drawer, DrawerContent, DrawerTitle } from '@/components/Global/Drawer' +import { Icon, type IconName } from '@/components/Global/Icons/Icon' +import { ListItem } from '@/components/0_Bruddle/ListItem' +import { getCardPosition } from '@/components/Global/Card/card.utils' +import { useHomeDrawer, type HomeDrawer } from '../useHomeDrawer' +import { useRouter } from 'next/navigation' +import { useRef } from 'react' +import { useTranslations } from 'next-intl' + +type HomeDrawerKey = 'sendToFriends' | 'withdrawToOwnAccounts' +type HomeDrawerBodyKey = 'sendToFriendsDescription' | 'withdrawToOwnAccountsDescription' +type AddMethodKey = 'bankTransfer' | 'crypto' + +interface DrawerOption { + key: string + /** i18n namespace + key — 'drawers' = home.drawers, 'methods' = addMoney.methods */ + titleKey: ['drawers', HomeDrawerKey] | ['methods', AddMethodKey] + bodyKey?: HomeDrawerBodyKey + icon: IconName + href: string +} + +// send drawer board 17831:79186; add drawer board 17830:76839. withdraw is +// reachable from home through the SEND drawer only (product ruling 2026-08-21: +// the add drawer is bank transfer + crypto, per the board's visible items). +const DRAWER_OPTIONS: Record = { + send: [ + { + key: 'send-friends', + titleKey: ['drawers', 'sendToFriends'], + bodyKey: 'sendToFriendsDescription', + // person iconography, not arrows: the home CTAs that open this + // drawer are already arrows, so repeating them here said nothing. + // friends = several people, own accounts = one person (you). + icon: 'users', + href: '/send', + }, + { + key: 'withdraw', + titleKey: ['drawers', 'withdrawToOwnAccounts'], + bodyKey: 'withdrawToOwnAccountsDescription', + icon: 'user', + href: '/withdraw', + }, + ], + add: [ + { key: 'bank', titleKey: ['methods', 'bankTransfer'], icon: 'bank', href: '/add-money?method=bank' }, + { key: 'crypto', titleKey: ['methods', 'crypto'], icon: 'credit-card', href: '/add-money/crypto' }, + ], +} + +/** + * home IA bottom drawers (figma boards 17831:79186 / 17830:76839): the Add and + * Send quick actions open a drawer with routing options instead of navigating. + * open state is url-backed via useHomeDrawer (?drawer=add|send). + */ +export function HomeActionDrawers() { + const [drawer, setDrawer] = useHomeDrawer() + const t = useTranslations('home.drawers') + const tMethods = useTranslations('addMoney.methods') + const tNav = useTranslations('navigation') + const router = useRouter() + // keep the last open drawer rendered through vaul's exit animation so the + // sheet doesn't empty mid-slide when the url param clears + const lastDrawerRef = useRef(null) + if (drawer) lastDrawerRef.current = drawer + const content = drawer ?? lastDrawerRef.current + + const navigate = async (href: string) => { + // clear the drawer param first so browser-back from the destination + // lands on a closed home; nuqs queues url updates, so await the reset + // before routing or the ?drawer entry can survive in history + await setDrawer(null) + router.push(href) + } + + return ( + !isOpen && setDrawer(null)}> + + {content && ( +
+ + {tNav(content)} + +
+ {DRAWER_OPTIONS[content].map((option, index, all) => ( + } + title={ + option.titleKey[0] === 'methods' + ? tMethods(option.titleKey[1] as AddMethodKey) + : t(option.titleKey[1] as HomeDrawerKey) + } + body={option.bodyKey ? t(option.bodyKey) : undefined} + chevron + onClick={() => navigate(option.href)} + data-testid={`home-drawer-${content}-${option.key}`} + /> + ))} +
+
+ )} +
+
+ ) +} diff --git a/src/features/home/components/HomeModals.tsx b/src/features/home/components/HomeModals.tsx new file mode 100644 index 0000000000..843db20ae6 --- /dev/null +++ b/src/features/home/components/HomeModals.tsx @@ -0,0 +1,226 @@ +'use client' + +import { updateUserById } from '@/app/actions/users' +import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' +import { PostSignupActionManager } from '@/components/Global/PostSignupActionManager' +import { MIGRATION_SURFACES } from '@/constants/migration.consts' +import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' +import { useAuth } from '@/context/authContext' +import { useModalsContext } from '@/context/ModalsContext' +import { useCapabilities } from '@/hooks/useCapabilities' +import { useNotifications } from '@/hooks/useNotifications' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useUserStore } from '@/redux/hooks' +import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' +import { lazy, Suspense, useEffect, useState } from 'react' +import { formatUnits } from 'viem' + +// lazy load heavy modal components (~20-30KB each) to reduce initial bundle size +// components are only loaded when user triggers them +// wrapped in error boundaries to gracefully handle chunk load failures +const BalanceWarningModal = lazy(() => import('@/components/Global/BalanceWarningModal')) +const SetupNotificationsModal = lazy(() => import('@/components/Notifications/SetupNotificationsModal')) +const NoMoreJailModal = lazy(() => import('@/components/Global/NoMoreJailModal')) +const EarlyUserModal = lazy(() => import('@/components/Global/EarlyUserModal')) +const WelcomeUnlockModal = lazy(() => import('@/components/Home/WelcomeUnlockModal')) +const IosPwaInstallModal = lazy(() => import('@/components/Global/IosPwaInstallModal')) +const MigrationDownloadModal = lazy(() => import('@/components/Migration/MigrationDownloadModal')) +const ScanToDownloadModal = lazy(() => import('@/components/Migration/ScanToDownloadModal')) +const ReviewPromptModal = lazy(() => import('@/components/Migration/ReviewPromptModal')) + +// guard against malformed env values — NaN would silently disable the +// warning (threshold compare always false) or break dismissal expiry +const parsedThreshold = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500') +const BALANCE_WARNING_THRESHOLD = Number.isNaN(parsedThreshold) ? 500 : parsedThreshold +const parsedExpiry = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') +const BALANCE_WARNING_EXPIRY = Number.isNaN(parsedExpiry) ? 1814400 : parsedExpiry // 21 days in seconds + +/** + * home modal orchestration — the priority chain the old home page carried + * inline. migration download outranks everything, then notifications, kyc + * celebration, post-signup, ios pwa, balance warning, review prompt. + */ +export function HomeModals() { + const { showPermissionModal } = useNotifications() + const { isGetAppModalOpen, setIsGetAppModalOpen, isIosPwaInstallModalOpen } = useModalsContext() + const { balance, isFetchingBalance } = useWallet() + const { user } = useUserStore() + const { fetchUser } = useAuth() + const { isKycApproved } = useCapabilities() + + const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false) + const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false) + const [showKycModal, setShowKycModal] = useState(false) + // migration download prompt outranks every other home modal (self-gating, + // only during the pwa-sunset notice window) + const [showMigrationModal, setShowMigrationModal] = useState(false) + + // the migration prompt outranks the post-signup modal; unmounting the + // manager skips its onVisibilityChange(false), so clear the state here or + // it stays stuck true and suppresses the balance-warning/review modals + useEffect(() => { + if (showMigrationModal) setIsPostSignupActionModalVisible(false) + }, [showMigrationModal]) + + // show the "you're unlocked" celebration exactly once: the user has a usable + // rail (isKycApproved) and has never dismissed it (activationCelebratedAt is + // null, stamped server-side on dismiss). a kyc re-approval can't resurface it. + useEffect(() => { + if (isKycApproved && !user?.user.activationCelebratedAt) { + setShowKycModal(true) + } + }, [isKycApproved, user?.user.activationCelebratedAt]) + + // balance warning: only when balance is above threshold, unseen recently, + // and no higher-priority modal is active + useEffect(() => { + if (isFetchingBalance || balance === undefined || !user) return + if (typeof window === 'undefined') return + + const userPreferences = getUserPreferences(user.user.userId) + const hasSeenBalanceWarning = + (userPreferences?.hasSeenBalanceWarning?.expiry ?? 0) > Date.now() && + userPreferences?.hasSeenBalanceWarning?.value + const balanceInUsd = Number(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) + + if ( + balanceInUsd > BALANCE_WARNING_THRESHOLD && + !hasSeenBalanceWarning && + !showMigrationModal && // highest priority + !showPermissionModal && + !showKycModal && + !isPostSignupActionModalVisible + ) { + setShowBalanceWarningModal(true) + } + }, [ + balance, + isFetchingBalance, + showMigrationModal, + showPermissionModal, + showKycModal, + isPostSignupActionModalVisible, + user, + ]) + + return ( + <> + {showPermissionModal && !showBalanceWarningModal && !showMigrationModal && ( + + + + + + )} + + + + + + + + {/* desktop target of the get-the-app carousel cta */} + {isGetAppModalOpen && ( + + + setIsGetAppModalOpen(false)} + surface={MIGRATION_SURFACES.HOME_BANNER} + /> + + + )} + + {/* these modals manage their own state internally */} + {!showBalanceWarningModal && !showMigrationModal && ( + <> + + + + + + + + + + + + + )} + + {/* mount-gated so the ~20-30KB chunk only loads when the modal can show */} + {showKycModal && ( + + + { + // close the modal immediately for better ux + setShowKycModal(false) + // update the database and refetch user to ensure sync + if (user?.user.userId) { + await updateUserById({ + userId: user.user.userId, + dismissActivationCelebration: true, + }) + await fetchUser() + } + }} + /> + + + )} + + + + { + setShowBalanceWarningModal(false) + // no non-null assertion: user can log out while the modal is open + if (user?.user.userId) { + updateUserPreferences(user.user.userId, { + hasSeenBalanceWarning: { + value: true, + expiry: Date.now() + BALANCE_WARNING_EXPIRY * 1000, + }, + }) + } + }} + /> + + + + {/* mount-gated: the modal is purely context-driven, so the chunk + only loads once something opens it */} + {isIosPwaInstallModalOpen && ( + + + + + + )} + + {/* card pioneer modal — eligibility check happens during the flow (geo + screen), not here. unmounted while the migration prompt shows (it + re-checks on remount); the effect above clears its stuck state */} + {!showMigrationModal && ( + + )} + + {/* app review nudge (native only, once ever) — lowest priority */} + {!showMigrationModal && + !showPermissionModal && + !showBalanceWarningModal && + !showKycModal && + !isPostSignupActionModalVisible && ( + + + + + + )} + + ) +} diff --git a/src/features/home/useBalanceVisibility.ts b/src/features/home/useBalanceVisibility.ts new file mode 100644 index 0000000000..ba4fdd2457 --- /dev/null +++ b/src/features/home/useBalanceVisibility.ts @@ -0,0 +1,54 @@ +'use client' + +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' +import posthog from 'posthog-js' +import { useCallback, useSyncExternalStore } from 'react' + +// balance visibility is a persistent user preference (localStorage), not +// ephemeral ui state — so it lives in a tiny external store instead of a +// useState. the in-memory map is the source of truth for the session +// (hydrated from the preference once per user), so the toggle keeps working +// even when localStorage writes fail (safari private mode / quota), and +// getSnapshot costs a map lookup instead of a localStorage read per render. +// ponytail: writes that bypass this hook (updateUserPreferences directly) +// won't notify subscribers — move notification into updateUserPreferences if +// a second writer ever appears. +const cache = new Map() +const listeners = new Set<() => void>() + +const readHidden = (userId: string): boolean => { + if (!cache.has(userId)) { + cache.set(userId, getUserPreferences(userId)?.balanceHidden ?? false) + } + return cache.get(userId)! +} + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +const notify = () => listeners.forEach((listener) => listener()) + +export function useBalanceVisibility(userId: string | undefined) { + const isBalanceHidden = useSyncExternalStore( + subscribe, + () => (userId ? readHidden(userId) : false), + () => false + ) + + const toggleBalanceVisibility = useCallback(() => { + if (!userId) return + const next = !readHidden(userId) + cache.set(userId, next) + // best-effort persistence — the in-memory value drives this session + updateUserPreferences(userId, { balanceHidden: next }) + posthog.capture(ANALYTICS_EVENTS.BALANCE_VISIBILITY_TOGGLED, { is_hidden: next }) + notify() + }, [userId]) + + return { isBalanceHidden, toggleBalanceVisibility } +} diff --git a/src/features/home/useHomeDrawer.ts b/src/features/home/useHomeDrawer.ts new file mode 100644 index 0000000000..e05cd121be --- /dev/null +++ b/src/features/home/useHomeDrawer.ts @@ -0,0 +1,13 @@ +'use client' + +import { parseAsStringEnum, useQueryState } from 'nuqs' + +export const HOME_DRAWERS = ['add', 'send'] as const +export type HomeDrawer = (typeof HOME_DRAWERS)[number] + +/** + * home IA drawer state (figma section 17609:2334): which bottom drawer is open + * on /home. url-backed (?drawer=add|send) so the state deep-links and survives + * refresh; null = closed. + */ +export const useHomeDrawer = () => useQueryState('drawer', parseAsStringEnum([...HOME_DRAWERS])) diff --git a/src/features/home/useHomeFlow.ts b/src/features/home/useHomeFlow.ts new file mode 100644 index 0000000000..128727bb61 --- /dev/null +++ b/src/features/home/useHomeFlow.ts @@ -0,0 +1,72 @@ +'use client' + +import { useAuth } from '@/context/authContext' +import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' +import { useWithdrawFlow } from '@/context/WithdrawFlowContext' +import { useActivationStatus } from '@/hooks/useActivationStatus' +import { useCardInfo } from '@/hooks/useCardInfo' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useUserStore } from '@/redux/hooks' +import { useEffect } from 'react' +import { useAccount, useDisconnect } from 'wagmi' +import { useBalanceVisibility } from './useBalanceVisibility' + +/** + * flow hook for the home page — owns every behaviour so the page and views + * stay dumb (same model as features/payments/flows/semantic-request). + */ +export function useHomeFlow() { + const { spendableBalance, isFetchingSpendableBalance, isSpendableBalanceStale } = useWallet() + const { user } = useUserStore() + const { isFetchingUser, fetchUser } = useAuth() + const { isActivated, activationStep, dismissCardStep } = useActivationStatus() + const { resetFlow: resetClaimBankFlow } = useClaimBankFlow() + const { resetWithdrawFlow } = useWithdrawFlow() + const { isConnected: isWagmiConnected } = useAccount() + const { disconnect: disconnectWagmi } = useDisconnect() + + // fire-and-forget: warms the card-info cache so /card mounts fast. + // return values intentionally unused — only the fetch side effect matters. + useCardInfo() + + const username = user?.user.username + const userId = user?.user.userId + const { isBalanceHidden, toggleBalanceVisibility } = useBalanceVisibility(userId) + + // re-fetch user on mount to pick up activation status changes (e.g. after qr payment) + useEffect(() => { + fetchUser() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // landing on home resets any in-progress money flows + useEffect(() => { + resetClaimBankFlow() + resetWithdrawFlow() + }, [resetClaimBankFlow, resetWithdrawFlow]) + + // always reset external wallet connection on home page + useEffect(() => { + if (isWagmiConnected) { + disconnectWagmi() + } + }, [isWagmiConnected, disconnectWagmi]) + + // respect the showFullName preference for the avatar initials; a + // usernameless user still gets initials from their full name (initials + // only — the preference governs showing the full name, not its initials) + const avatarName = (user?.user.showFullName && user?.user.fullName) || username || user?.user.fullName || undefined + + return { + isPageLoading: isFetchingUser && !username, + username, + avatarName, + isActivated, + activationStep, + dismissCardStep, + spendableBalance, + isFetchingSpendableBalance, + isSpendableBalanceStale, + isBalanceHidden, + toggleBalanceVisibility, + } +} diff --git a/src/features/home/views/BalanceSection.tsx b/src/features/home/views/BalanceSection.tsx new file mode 100644 index 0000000000..57f3b38eae --- /dev/null +++ b/src/features/home/views/BalanceSection.tsx @@ -0,0 +1,123 @@ +'use client' + +import { Icon, type IconName } from '@/components/Global/Icons/Icon' +import Loading from '@/components/Global/Loading' +import { printableUsdc } from '@/utils/balance.utils' +import { formatExtendedNumber } from '@/utils/general.utils' +import { useAppHaptic } from '@/hooks/useAppHaptic' +import { twMerge } from '@/utils/tw' +import { useTranslations } from 'next-intl' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' +import Link from 'next/link' +import { useHomeDrawer, type HomeDrawer } from '../useHomeDrawer' + +interface BalanceSectionProps { + balance: bigint | undefined + isFetching: boolean + /** balance is the persisted last-known-good while the live sum is still + * pending — dim it rather than asserting it as current */ + isStale?: boolean + isHidden: boolean + onToggleVisibility: () => void +} + +// home IA (figma section 17609:2334): add + send open a bottom drawer, +// request navigates directly (a Link, so it keeps prefetch + anchor semantics) +const SUBMENU_ACTIONS: Array<{ key: 'add' | 'send' | 'request'; icon: IconName; drawer?: HomeDrawer; href?: string }> = + [ + { key: 'add', icon: 'plus', drawer: 'add' }, + { key: 'send', icon: 'arrow-up-right', drawer: 'send' }, + { key: 'request', icon: 'arrow-down-left', href: '/request' }, + ] + +/** + * balance block (figma home board 17830:75689): centered usd balance with + * visibility toggle, plus the add / send / request submenu underneath. + * submenu states per component 17533:117867 (default / pressed). + */ +export function BalanceSection({ balance, isFetching, isStale, isHidden, onToggleVisibility }: BalanceSectionProps) { + const t = useAppTranslations('home') + const tNav = useTranslations('navigation') + const { triggerHaptic } = useAppHaptic() + const [openDrawer, setOpenDrawer] = useHomeDrawer() + + const handleAction = (action: (typeof SUBMENU_ACTIONS)[number]) => { + triggerHaptic() + if (action.drawer) setOpenDrawer(action.drawer) + } + + return ( +
+
+ {isFetching || balance === undefined ? ( + + ) : ( + + $ + + {isHidden ? '****' : formatExtendedNumber(printableUsdc(balance))} + + + )} + {/* toggle stays reachable even when the balance query errors + (balance undefined, not fetching) — it also controls the + activity amounts, matching the old page's behavior */} + {!isFetching && ( + + )} +
+
+ {SUBMENU_ACTIONS.map((action) => { + const inner = ( + <> + + + + {tNav(action.key)} + + ) + const shared = { + className: 'flex w-14 cursor-pointer flex-col items-center gap-2', + 'data-testid': `home-submenu-${action.key}`, + } + return action.href ? ( + triggerHaptic()} {...shared}> + {inner} + + ) : ( + + ) + })} +
+
+ ) +} diff --git a/src/features/home/views/HomeTopNav.tsx b/src/features/home/views/HomeTopNav.tsx new file mode 100644 index 0000000000..30ae46baee --- /dev/null +++ b/src/features/home/views/HomeTopNav.tsx @@ -0,0 +1,62 @@ +'use client' + +import { Icon } from '@/components/Global/Icons/Icon' +import InvitesIcon from '@/components/Home/InvitesIcon' +import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' +import { useAppHaptic } from '@/hooks/useAppHaptic' +import { useAppTranslations } from '@/i18n/app/useAppTranslations' +import Link from 'next/link' + +interface HomeTopNavProps { + avatarName?: string + showRewards: boolean +} + +/** + * home top navigation row (figma home board 17830:75689): 32px avatar + * top-left linking to /profile (Vlad follow-up: one size down from 48), + * rewards link top-right. The link keeps a 44px hit area via after: inset. + */ +export function HomeTopNav({ avatarName, showRewards }: HomeTopNavProps) { + const t = useAppTranslations('home') + const { triggerHaptic } = useAppHaptic() + + return ( +
+ triggerHaptic()} + // 32px visual — extend the pressable area to 44px (touch-target law) + className="relative block after:absolute after:-inset-1.5" + aria-label={t('openProfile')} + > + {/* figma avatar = initials on the avatar palette. a user with no + name string at all still gets an avatar-toned circle (yellow — + the palette's no-name default) instead of a generic icon */} + + + {showRewards && ( + triggerHaptic()} + // 20px visual — extend the pressable area to 44px (touch-target law) + className="relative flex items-center gap-1 after:absolute after:-inset-3" + > + + {t('rewards')} + + + )} +
+ ) +} diff --git a/src/features/limits/components/CryptoLimitsSection.tsx b/src/features/limits/components/CryptoLimitsSection.tsx index b95e637bf1..eabd5a3212 100644 --- a/src/features/limits/components/CryptoLimitsSection.tsx +++ b/src/features/limits/components/CryptoLimitsSection.tsx @@ -1,6 +1,7 @@ 'use client' import Card from '@/components/Global/Card' +import { Section } from '@/components/0_Bruddle/Section' import { Icon } from '@/components/Global/Icons/Icon' import { Tooltip } from '@/components/Tooltip' import { useTranslations } from 'next-intl' @@ -11,16 +12,15 @@ import { useTranslations } from 'next-intl' export default function CryptoLimitsSection() { const t = useTranslations('limits.crypto') return ( -
-

{t('title')}

+
- {t('noLimits')} + {t('noLimits')} - +
-
+
) } diff --git a/src/features/limits/components/FiatLimitsLockedCard.tsx b/src/features/limits/components/FiatLimitsLockedCard.tsx index bb7a1833db..e0c5aad32e 100644 --- a/src/features/limits/components/FiatLimitsLockedCard.tsx +++ b/src/features/limits/components/FiatLimitsLockedCard.tsx @@ -1,6 +1,7 @@ 'use client' import Card from '@/components/Global/Card' +import { Section } from '@/components/0_Bruddle/Section' import { Icon } from '@/components/Global/Icons/Icon' import { Button } from '@/components/0_Bruddle/Button' import { useTranslations } from 'next-intl' @@ -12,16 +13,15 @@ export default function FiatLimitsLockedCard() { const router = useRouter() return ( -
-

{t('title')}

+
-
+
{t('locked')}
-
{t('description')}
+
{t('description')}
-
+
) } diff --git a/src/features/limits/components/IncreaseLimitsButton.tsx b/src/features/limits/components/IncreaseLimitsButton.tsx index ec95525752..7a4c8c74dc 100644 --- a/src/features/limits/components/IncreaseLimitsButton.tsx +++ b/src/features/limits/components/IncreaseLimitsButton.tsx @@ -41,7 +41,7 @@ export default function IncreaseLimitsButton() { if (actionFlow.isComplete) { return ( -
+
{t('submitted')}
) @@ -59,7 +59,7 @@ export default function IncreaseLimitsButton() { {t('cta')} - {actionFlow.error &&

{actionFlow.error}

} + {actionFlow.error &&

{actionFlow.error}

} + {t('docsLink')} ) diff --git a/src/features/limits/components/LimitsError.tsx b/src/features/limits/components/LimitsError.tsx index 5374810cc5..1e2fba31dc 100644 --- a/src/features/limits/components/LimitsError.tsx +++ b/src/features/limits/components/LimitsError.tsx @@ -2,17 +2,21 @@ import { Button } from '@/components/0_Bruddle/Button' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { useTranslations } from 'next-intl' -import { useRouter } from 'next/navigation' -export default function LimitsError() { +interface LimitsErrorProps { + /** re-runs the limits query. router.refresh() never refetched react-query. */ + onRetry: () => void + isRetrying?: boolean +} + +export default function LimitsError({ onRetry, isRetrying }: LimitsErrorProps) { const t = useTranslations('limits') const tCommon = useTranslations('common') - const router = useRouter() return (
-
diff --git a/src/features/limits/components/LimitsProgressBar.tsx b/src/features/limits/components/LimitsProgressBar.tsx deleted file mode 100644 index 58f2079dd0..0000000000 --- a/src/features/limits/components/LimitsProgressBar.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import { twMerge } from 'tailwind-merge' -import { getLimitColorClass } from '../utils' - -interface LimitsProgressBarProps { - total: number - remaining: number -} - -/** - * progress bar for limits display - * - * note: not using Global/ProgressBar because that component is designed for - * request pots goals with specific labels ("contributed", "remaining"), markers, - * and goal-achieved states. this component serves a different purpose - showing - * limit usage with color thresholds based on remaining percentage. - */ -const LimitsProgressBar = ({ total, remaining }: LimitsProgressBarProps) => { - const remainingPercent = total > 0 ? (remaining / total) * 100 : 0 - const clampedPercent = Math.min(Math.max(remainingPercent, 0), 100) - - return ( -
-
-
- ) -} - -export default LimitsProgressBar diff --git a/src/features/limits/components/LimitsWarningCard.tsx b/src/features/limits/components/LimitsWarningCard.tsx index a6ca49e246..a93c1f9181 100644 --- a/src/features/limits/components/LimitsWarningCard.tsx +++ b/src/features/limits/components/LimitsWarningCard.tsx @@ -1,11 +1,10 @@ 'use client' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { Icon } from '@/components/Global/Icons/Icon' import { useTranslations } from 'next-intl' import Link from 'next/link' import { useModalsContext } from '@/context/ModalsContext' -import { twMerge } from 'tailwind-merge' import { LIMITS_COPY, type LimitsWarningItem } from '../utils' export type LimitsWarningType = 'warning' | 'error' @@ -72,59 +71,53 @@ export default function LimitsWarningCard({ } return ( - -
    - {items.map((item, index) => ( -
  • - {item.isLink && item.href ? ( - - {item.icon && ( - - )} - {itemText(item)} - - ) : ( - itemText(item) - )} -
  • - ))} -
- {onIncreaseLimits ? ( - <> -
- - - ) : showSupportLink ? ( - <> -
- - - ) : null} -
- } - /> + className={className} + > +
+
    + {items.map((item, index) => ( +
  • + {item.isLink && item.href ? ( + + {item.icon && } + {itemText(item)} + + ) : ( + itemText(item) + )} +
  • + ))} +
+ {onIncreaseLimits ? ( + <> +
+ + + ) : showSupportLink ? ( + <> +
+ + + ) : null} +
+ ) } diff --git a/src/features/limits/components/PeriodToggle.tsx b/src/features/limits/components/PeriodToggle.tsx deleted file mode 100644 index 9092a08383..0000000000 --- a/src/features/limits/components/PeriodToggle.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client' - -import { Root, List, Trigger } from '@radix-ui/react-tabs' -import { useTranslations } from 'next-intl' - -type Period = 'monthly' | 'yearly' - -interface PeriodToggleProps { - value: Period - onChange: (period: Period) => void - className?: string -} - -/** - * pill toggle for switching between monthly and yearly limit views - * uses radix tabs for accessibility - */ -export default function PeriodToggle({ value, onChange, className }: PeriodToggleProps) { - const t = useTranslations('limits.period') - return ( - onChange(v as Period)} className={className}> - - - {t('monthly')} - - - {t('yearly')} - - - - ) -} diff --git a/src/features/limits/utils.ts b/src/features/limits/utils.ts index c7bc7a99bf..416d38c2ad 100644 --- a/src/features/limits/utils.ts +++ b/src/features/limits/utils.ts @@ -138,7 +138,7 @@ export function getLimitColorClass(remainingPercent: number, type: 'bg' | 'text' return type === 'bg' ? 'bg-success-3' : 'text-success-1' } if (remainingPercent > LIMIT_WARNING_THRESHOLD) { - return type === 'bg' ? 'bg-yellow-1' : 'text-yellow-1' + return type === 'bg' ? 'bg-action-secondary' : 'text-action-secondary' } return type === 'bg' ? 'bg-error-4' : 'text-error-4' } diff --git a/src/features/limits/views/BridgeLimitsView.tsx b/src/features/limits/views/BridgeLimitsView.tsx index 02a79f4326..cf0a6c2fdc 100644 --- a/src/features/limits/views/BridgeLimitsView.tsx +++ b/src/features/limits/views/BridgeLimitsView.tsx @@ -1,6 +1,8 @@ 'use client' import NavHeader from '@/components/Global/NavHeader' +import { PageStack } from '@/components/0_Bruddle/PageStack' +import { Section } from '@/components/0_Bruddle/Section' import Card from '@/components/Global/Card' import { Icon } from '@/components/Global/Icons/Icon' import { useLimits } from '@/hooks/useLimits' @@ -8,10 +10,10 @@ import { useSafeBack } from '@/hooks/useSafeBack' import { MAX_QR_PAYMENT_AMOUNT_FOREIGN } from '@/constants/payment.consts' import { useTranslations } from 'next-intl' import Image from 'next/image' -import * as Accordion from '@radix-ui/react-accordion' +import { Accordion } from '@/components/0_Bruddle/Accordion' import { useQueryState, parseAsStringEnum } from 'nuqs' import { useState, useMemo } from 'react' -import PeanutLoading from '@/components/Global/PeanutLoading' +import Loading from '@/components/Global/Loading' import { getQrCountriesWithFlags, type QrCountryId } from '../consts' import { BANK_TRANSFER_REGIONS, type BridgeRegion, formatAmountWithCurrency } from '../utils' import LimitsError from '../components/LimitsError' @@ -26,7 +28,7 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState' const BridgeLimitsView = () => { const t = useTranslations('limits.provider') const onBack = useSafeBack('/limits') - const { bridgeLimits, isLoading, error, hasMantecaLimits } = useLimits() + const { bridgeLimits, isLoading, error, hasMantecaLimits, refetch, isRefetching } = useLimits() // url state for source region (where user came from) const [region] = useQueryState( @@ -47,24 +49,23 @@ const BridgeLimitsView = () => { const showBankTransferLimits = (BANK_TRANSFER_REGIONS as readonly string[]).includes(region) return ( -
- + + - {isLoading && } + {isLoading && } - {error && } + {error && refetch()} isRetrying={isRefetching} />} {!isLoading && !error && bridgeLimits && ( <> {/* main limits card - only for bank transfer regions */} {showBankTransferLimits && ( -
-

{t('fiatLimits')}

+
- - + + {t.rich('addMoneyLimit', { medium: (chunks) => {chunks}, amount: formatAmountWithCurrency( @@ -75,8 +76,8 @@ const BridgeLimitsView = () => {
- - + + {t.rich('withdrawLimit', { medium: (chunks) => {chunks}, amount: formatAmountWithCurrency( @@ -88,60 +89,46 @@ const BridgeLimitsView = () => {
-
+
)} {/* qr payment limits accordion - for bridge users without manteca kyc */} {!hasMantecaLimits && ( -
-

{t('qrPaymentLimits')}

- - setExpandedCountry(value as QrCountryId | undefined)} - > - {qrCountries.map((country, index) => ( - - - -
- {country.name} - {country.name} -
- -
-
- -
- - - {t('qrPayLimit', { - amount: `$${MAX_QR_PAYMENT_AMOUNT_FOREIGN.toLocaleString()}`, - })} - -
-
-
- ))} -
-
-
+
+ setExpandedCountry(value as QrCountryId | undefined)} + > + {qrCountries.map((country) => ( + + +
+ + {country.name} +
+
+ +
+ + + {t('qrPayLimit', { + amount: `$${MAX_QR_PAYMENT_AMOUNT_FOREIGN.toLocaleString()}`, + })} + +
+
+
+ ))} +
+
)} @@ -149,7 +136,7 @@ const BridgeLimitsView = () => { )} {!isLoading && !error && !bridgeLimits && } -
+ ) } diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx index 28f88cb9b0..99b4971a97 100644 --- a/src/features/limits/views/LimitsPageView.tsx +++ b/src/features/limits/views/LimitsPageView.tsx @@ -1,8 +1,10 @@ 'use client' -import { ActionListCard } from '@/components/ActionListCard' +import { ListGroup } from '@/components/0_Bruddle/ListGroup' +import { ListItem } from '@/components/0_Bruddle/ListItem' +import { PageStack } from '@/components/0_Bruddle/PageStack' +import { Section } from '@/components/0_Bruddle/Section' import { findActiveCard } from '@/components/Card/cardState.utils' -import { getCardPosition } from '@/components/Global/Card/card.utils' import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' import StatusBadge from '@/components/Global/Badges/StatusBadge' @@ -24,7 +26,7 @@ import { useRegionLabel } from '@/hooks/useRegionLabel' import CryptoLimitsSection from '../components/CryptoLimitsSection' import FiatLimitsLockedCard from '../components/FiatLimitsLockedCard' import REST_OF_WORLD_GLOBE_ICON from '@/assets/icons/rest-of-world-globe.svg' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { getProviderRoute } from '../utils' const LimitsPageView = () => { @@ -76,11 +78,11 @@ const LimitsPageView = () => { }, [lockedRegions]) return ( -
- + + {/* page description */} - + {t('pageDescription')} {/* fiat limits section */} {!hasAnyKyc && } @@ -97,10 +99,9 @@ const LimitsPageView = () => { {/* rest of world - always shown with coming soon */} {hasRestOfWorld && ( -
-

{t('otherRegions')}

- + { position="single" title={restOfWorldName} onClick={() => {}} - isDisabled={true} - rightContent={} + disabled={true} + trailing={} /> -
+

)} {/* card limits — separate from KYC/region limits; managed per-card via Rain */} {activeCard && ( -
-

{t('cardLimits.title')}

- + } + leading={} title={t('cardLimits.manage')} - description={t('cardLimits.description')} + body={
{t('cardLimits.description')}
} + chevron onClick={() => router.push('/card/limit')} /> -
+
)} {/* crypto limits section */} -
+ ) } @@ -151,34 +152,34 @@ const UnlockedRegionsList = ({ regions, hasMantecaKyc }: UnlockedRegionsListProp const router = useRouter() return ( -
- {regions.length > 0 &&

{t('unlockedRegions')}

} - {regions.map((region, index) => { - const label = regionLabel(region) - return ( - - } - position={getCardPosition(index, regions.length)} - title={label.name} - onClick={() => { - const route = getProviderRoute(region.path, hasMantecaKyc) - router.push(route) - }} - description={label.description} - descriptionClassName="text-xs" - /> - ) - })} -
+
0 ? t('unlockedRegions') : undefined}> + + {regions.map((region) => { + const label = regionLabel(region) + return ( + + } + title={label.name} + onClick={() => { + const route = getProviderRoute(region.path, hasMantecaKyc) + router.push(route) + }} + body={
{label.description}
} + chevron + /> + ) + })} +
+
) } @@ -197,37 +198,37 @@ const LockedRegionsList = ({ regions, pendingRegionPaths }: LockedRegionsListPro const isPendingRegion = (regionPath: string) => pendingRegionPaths.has(regionPath) return ( -
- {regions.length > 0 &&

{t('lockedRegions')}

} - {regions.map((region, index) => { - const isPending = isPendingRegion(region.path) - const label = regionLabel(region) - return ( - - } - position={getCardPosition(index, regions.length)} - title={label.name} - onClick={() => { - if (!isPending) { - router.push('/profile/identity-verification') +
0 ? t('lockedRegions') : undefined}> + + {regions.map((region) => { + const isPending = isPendingRegion(region.path) + const label = regionLabel(region) + return ( + } - }} - isDisabled={isPending} - description={label.description} - descriptionClassName="text-xs" - rightContent={isPending && } - /> - ) - })} -
+ title={label.name} + onClick={() => { + if (!isPending) { + router.push('/profile/identity-verification') + } + }} + disabled={isPending} + body={
{label.description}
} + trailing={isPending && } + chevron={!isPending} + /> + ) + })} + + ) } diff --git a/src/features/limits/views/MantecaLimitsView.tsx b/src/features/limits/views/MantecaLimitsView.tsx index 4feea5ce1e..80e14ba21f 100644 --- a/src/features/limits/views/MantecaLimitsView.tsx +++ b/src/features/limits/views/MantecaLimitsView.tsx @@ -7,10 +7,10 @@ import { useLimits } from '@/hooks/useLimits' import { useSafeBack } from '@/hooks/useSafeBack' import { useTranslations } from 'next-intl' import { useState } from 'react' -import PeriodToggle from '../components/PeriodToggle' -import LimitsProgressBar from '../components/LimitsProgressBar' +import SegmentedControl from '@/components/0_Bruddle/SegmentedControl' +import ProgressBar from '@/components/0_Bruddle/ProgressBar' import Image from 'next/image' -import PeanutLoading from '@/components/Global/PeanutLoading' +import Loading from '@/components/Global/Loading' import { getLimitData, getLimitColorClass, @@ -29,17 +29,18 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState' */ const MantecaLimitsView = () => { const t = useTranslations('limits.provider') + const tPeriod = useTranslations('limits.period') const onBack = useSafeBack('/limits') - const { mantecaLimits, isLoading, error } = useLimits() + const { mantecaLimits, isLoading, error, refetch, isRefetching } = useLimits() const [period, setPeriod] = useState('monthly') return ( -
- +
+ - {isLoading && } + {isLoading && } - {error && } + {error && refetch()} isRetrying={isRefetching} />} {!isLoading && !error && mantecaLimits && mantecaLimits.length > 0 && ( <> @@ -67,21 +68,32 @@ const MantecaLimitsView = () => { className="size-5 rounded-full object-cover" /> )} - + {t('totalAllowed', { asset: limit.asset })}
- + setPeriod(v as LimitsPeriod)} + aria-label={tPeriod('selectAriaLabel')} + />
-
+
{formatAmountWithCurrency(limitData.limit, limit.asset)}
- + -
- {t('remaining', { period })} +
+ {t('remaining', { period })} {formatAmountWithCurrency(limitData.remaining, limit.asset)} @@ -90,7 +102,7 @@ const MantecaLimitsView = () => { ) })} {/* info text */} -
+

{t('appliesTo')}

diff --git a/src/features/payment-network-explorer/FacetChecklist.tsx b/src/features/payment-network-explorer/FacetChecklist.tsx index a9b273824e..ca34e6fabe 100644 --- a/src/features/payment-network-explorer/FacetChecklist.tsx +++ b/src/features/payment-network-explorer/FacetChecklist.tsx @@ -24,14 +24,14 @@ export default function FacetChecklist({ return (
- + {label} {tooltip} {selected.length > 0 && ( @@ -40,7 +40,7 @@ export default function FacetChecklist({ {facets.length === 0 ? (

{emptyLabel}

) : ( -
+
{facets.map((facet) => { const checked = selected.includes(facet.value) return ( @@ -59,7 +59,7 @@ export default function FacetChecklist({ {facet.label} - {facet.observedCount.toLocaleString()} + {facet.observedCount.toLocaleString()} ) })} diff --git a/src/features/payment-network-explorer/FilterPanel.tsx b/src/features/payment-network-explorer/FilterPanel.tsx index e77a1680a8..cb8083f8f5 100644 --- a/src/features/payment-network-explorer/FilterPanel.tsx +++ b/src/features/payment-network-explorer/FilterPanel.tsx @@ -30,7 +30,7 @@ export default function FilterPanel({ filters, relationships, onChange, onReset
- + Window The backend aggregates payments over a fixed window. There is no time filter. @@ -40,7 +40,7 @@ export default function FilterPanel({ filters, relationships, onChange, onReset
- + Top users Server-side cap: top N users by points, plus recent signups. Changing it reloads the graph. @@ -69,7 +69,7 @@ export default function FilterPanel({ filters, relationships, onChange, onReset />
- + Direction One row per payer, recipient and type. Both ways keeps every pair with any reverse payment; the @@ -94,7 +94,7 @@ export default function FilterPanel({ filters, relationships, onChange, onReset
- Minimums + Minimums