diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 6067c974fc..0417bb8bf0 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -27,7 +27,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 +60,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:
@@ -510,7 +533,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, e2e, report, human-authors]
runs-on: ubuntu-latest
steps:
- name: Verify all required jobs passed
@@ -519,6 +542,7 @@ 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 }}"
diff --git a/.gitignore b/.gitignore
index 70d2ea2ed6..b33c663412 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
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/e2e/flows/dev-showcase.spec.ts b/e2e/flows/dev-showcase.spec.ts
index debf4d74f9..b7f378d762 100644
--- a/e2e/flows/dev-showcase.spec.ts
+++ b/e2e/flows/dev-showcase.spec.ts
@@ -1,44 +1,72 @@
/**
- * 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,
+]
+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/eslint.config.js b/eslint.config.js
index 1b85fccec3..0dba07f089 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -18,6 +18,123 @@ const copyPropsFromCatalog = require('./eslint-rules/copy-props-from-catalog')
// — the guard is preventative; cleanup belongs in a separate sweep.
const BANNED_BARREL_PATHS = ['@/constants', '@/components', '@/assets', '@/context', '@/interfaces', '@/config']
+const RESTRICTED_IMPORT_PATHS = BANNED_BARREL_PATHS.map((path) => ({
+ name: path,
+ message: `Import from a specific file instead of the '${path}' barrel — barrels force the bundler to load every re-export and hurt build perf. See CLAUDE.md.`,
+}))
+
+// DS 10 (TASK-21450): URL state belongs to nuqs. CLAUDE.md "URL as State": use
+// useQueryStates, never manually parse/set query params with router.push or
+// URLSearchParams. Existing offenders are allowlisted below (ratchet — remove
+// entries as files migrate); only NEW files are blocked from the pattern.
+// tw.ts wraps tailwind-merge with the DS token groups registered; a raw import
+// (e.g. a copy-pasted shadcn cn() helper) reintroduces the silent class-deletion
+// bug tw.ts exists to fix — unrecognised DS tokens get treated as conflicting
+// colors and dropped. Only src/utils/tw.ts itself may import the package.
+const TAILWIND_MERGE_IMPORT_RESTRICTION = {
+ name: 'tailwind-merge',
+ message:
+ "Import { twMerge } from '@/utils/tw' — raw tailwind-merge doesn't know the DS token groups and silently deletes DS classes.",
+}
+
+const USE_SEARCH_PARAMS_IMPORT_RESTRICTION = {
+ name: 'next/navigation',
+ importNames: ['useSearchParams'],
+ message:
+ "Don't read query params with useSearchParams — use useQueryStates from 'nuqs' (typed parsers, URL as state). See CLAUDE.md 'URL as State'. DS 10 ratchet: existing files are allowlisted; new files must use nuqs.",
+}
+
+const QUERY_STRING_PUSH_MESSAGE =
+ "Don't build a query string by hand for router.push/replace — write URL state with useQueryStates from 'nuqs' (its setter updates the params in place; pathname-only navigation is fine). See CLAUDE.md 'URL as State'. DS 10 ratchet: existing files are allowlisted; new files must use nuqs."
+
+// Best-effort: catches router.push('/x?y=1') and router.push(`/x?y=${z}`) — a '?'
+// in a string/template argument means a hand-built query string. Concatenations
+// ('/x' + qs) and variables slip through; keeping the selector simple keeps it
+// false-positive-free for pathname-only pushes.
+const QUERY_STRING_PUSH_RESTRICTIONS = [
+ {
+ selector:
+ "CallExpression[callee.object.name='router'][callee.property.name=/^(push|replace)$/] > Literal[value=/\\?/]",
+ message: QUERY_STRING_PUSH_MESSAGE,
+ },
+ {
+ selector:
+ "CallExpression[callee.object.name='router'][callee.property.name=/^(push|replace)$/] > TemplateLiteral > TemplateElement[value.raw=/\\?/]",
+ message: QUERY_STRING_PUSH_MESSAGE,
+ },
+]
+
+// Pre-DS-10 syntax restrictions — shared so the DS 10 allowlist block below can
+// re-apply them while dropping only the query-string-push restriction.
+const RESTRICTED_SYNTAX_BASE = [
+ {
+ selector: "CallExpression[callee.object.name='router'][callee.property.name='back']",
+ message:
+ "Don't call router.back() directly — it no-ops on deep-link entries (cold tab, QR scan, push notification). Use useSafeBack(fallbackUrl) from '@/hooks/useSafeBack' instead. See PR #1965.",
+ },
+ {
+ // Only matches the simple () => router.push|replace(x) arrow-body shape —
+ // multi-statement handlers (state resets, conditional branches) keep their
+ // freedom since they often combine navigation with intentional side effects.
+ selector:
+ "JSXAttribute[name.name=/^(onPrev|onBack)$/] > JSXExpressionContainer > ArrowFunctionExpression[body.type='CallExpression'][body.callee.object.name='router'][body.callee.property.name=/^(push|replace)$/]",
+ message:
+ 'Bare router.push/replace as onPrev/onBack creates a parent↔child cycle once the parent uses useSafeBack (the push grows in-app history, useSafeBack pops back to this screen, repeat). Use useSafeBack(parentUrl) — pass { replace: true } to preserve replace semantics. See PR #1997.',
+ },
+ {
+ selector:
+ "MemberExpression[object.object.name='window'][object.property.name='history'][property.name='length']",
+ message:
+ "window.history.length is the pre-useSafeBack idiom (history.length > 1 ? back : push). It misfires on cold-load from external referrers — useSafeBack's pushState counter is more accurate. See PR #1965.",
+ },
+ {
+ // nuqs `history: 'push'` stacks a browser-history entry on every URL write.
+ // For per-keystroke params (e.g. `amount`) that poisons the back stack:
+ // useSafeBack → router.back() then steps through stale same-screen states
+ // and the back button looks dead (add-money MP/bank reports, June 2026).
+ selector: "CallExpression[callee.name=/^useQueryStates?$/] Property[key.name='history'][value.value='push']",
+ message:
+ "Don't pass { history: 'push' } to nuqs useQueryState(s) — a history entry per URL write breaks the back button (useSafeBack steps through same-screen states instead of leaving). Use the default 'replace'; the URL stays shareable. If a flow genuinely needs push-per-step, add a scoped file exemption with a comment (see useNativePlugins).",
+ },
+ {
+ // Toast copy must come from next-intl. `react/jsx-no-literals` below
+ // only inspects JSX children, so toasts fired from hooks and contexts
+ // (authContext, useLogin, useSendMoney, QRScanner) shipped English to
+ // every locale unnoticed.
+ //
+ // Deliberately NOT extended to `throw new Error('…')`: those messages
+ // are developer/Sentry breadcrumbs that the friendly-error mapper
+ // collapses to `errors.genericSupport` before any user sees them, so
+ // translating them would only fragment Sentry issue grouping.
+ selector:
+ "CallExpression[callee.object.name='toast'][callee.property.name=/^(error|success|info|warning|loading)$/] > :matches(Literal, TemplateLiteral):first-child",
+ message:
+ "Don't pass a string literal to toast.* — copy must come from next-intl. Import the right namespace with useTranslations and pass t('…'). If the value genuinely isn't copy (an id, a URL), assign it to a named const first.",
+ },
+ {
+ // iOS has never implemented the Vibration API — not in any version,
+ // Safari or WKWebView — so navigator.vibrate() is a permanent no-op
+ // there, and the `'vibrate' in navigator` guard that usually wraps it
+ // makes the failure completely silent. On Android it works but only
+ // above a duration threshold no call site was passing. Every native
+ // haptic in the app was dead this way until 1.0.48.
+ selector: "CallExpression[callee.object.name='navigator'][callee.property.name='vibrate']",
+ message:
+ "Don't call navigator.vibrate() directly — it is a permanent no-op on iOS (no Vibration API in any version) and silently does nothing. Use notifyHaptic / impactHaptic / vibrateHaptic / cancelHaptic from '@/utils/haptics', which drive @capacitor/haptics on native, or useAppHaptic() from '@/hooks/useAppHaptic' for a light tap in a component.",
+ },
+ {
+ // Settling a promise WITH a Capacitor plugin object probes its .then,
+ // and the registerPlugin proxy answers any property with a
+ // native-method wrapper that never invokes the callbacks it is handed
+ // — so the promise stays pending forever and even the .catch is dead.
+ // Shipped twice: getPreferences() (1.0.44) and the Crisp helper
+ // (1.0.45–1.0.47). Return { Plugin } instead.
+ selector: 'ReturnStatement > Identifier[name=/^(Capacitor[A-Z]|Preferences$)/]',
+ message:
+ 'Never return a Capacitor plugin object across an await/then boundary — resolving a promise with it probes .then, which the plugin proxy turns into a native call that never settles the promise. Wrap it: `return { Plugin }` and destructure at the call site. See src/utils/crisp.ts and src/utils/auth-token.ts.',
+ },
+]
+
module.exports = [
{
ignores: [
@@ -89,14 +206,15 @@ module.exports = [
// `jsx`/`global` are styled-jsx's
{/* Top Control Bar */}
-
+
{/* Top Row: Navigation, Title, Stats, Controls */}
{/* Left: Title & Stats */}
@@ -2193,28 +2192,28 @@ export default function InvitesGraph(props: InvitesGraphProps) {
<>
←
{t('invitesGraph.back')}
-
+
>
)}
-
+
{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:
@@ -2223,7 +2222,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
: filteredGraphData.stats.totalEdges) + externalLinks.length,
})}
{externalNodesConfig.enabled && externalLinks.length > 0 && (
- (+{externalLinks.length} ext)
+ (+{externalLinks.length} ext)
)}
@@ -2234,7 +2233,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
{/* Second Row: Search (hidden in payment mode - no usernames) */}
{mode !== 'payment' && (
-
+
{/* Search Results Dropdown */}
{searchQuery && searchResults.length > 1 && (
-
+
{searchResults.map((node: any) => (
{node.isExternal && (
-
+
{node.externalType === 'WALLET'
? '💳'
: node.externalType === 'BANK'
@@ -2288,9 +2287,9 @@ export default function InvitesGraph(props: InvitesGraphProps) {
: '🏪'}
)}
- {node.displayName}
+ {node.displayName}
-
+
{node.isExternal
? node.totalUsd
? `${node.uniqueUsers} users, $${node.totalUsd.toFixed(0)}`
@@ -2309,13 +2308,9 @@ export default function InvitesGraph(props: InvitesGraphProps) {
{/* Selected User/Node Banner */}
{selectedUserId && (
-
+
{t('invitesGraph.focusedOn')}{' '}
{selectedUserId.startsWith('ext_')
@@ -2325,7 +2320,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
selectedUserId}
-
+
{t('invitesGraph.clear')}
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 (
+
+
+
+
+ {/* 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 ?
:
}
{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) => {
>
-
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 ? (
-
-
+
+ {hideBackBtn ? (
+
+ ) : !onPrev ? (
+
+
@@ -77,13 +104,15 @@ const NavHeader = ({
)}
+ {rightElement}
{showLogoutBtn && (
logoutUser()}
loading={isLoggingOut}
variant="stroke"
icon="logout"
- className={twMerge('h-7 w-7 p-0 md:hidden')}
+ aria-label={tNav('logout')}
+ className={navCircleBtn}
/>
)}
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')}
+
+
+
-
+ >
+ ),
+ },
+ ]}
+ footer={
+
+
+
-
-
- {t('noMoreJailModal.startUsingCta')}
-
-
-
-
-
-
-
-
- {/* Peanutman animation */}
-
-
+ }
+ />
)
}
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')}
{
const t = useTranslations('global')
return (
-
-
-
-
-
-
+
+
+
+
{type === 'request' ? t('peanutActionCard.requestTitle') : t('peanutActionCard.sendTitle')}
-
+
{type === 'request'
? t('peanutActionCard.requestDescription')
: t('peanutActionCard.sendDescription')}
-
{t('peanutActionCard.perfectToDm')}
+
{t('peanutActionCard.perfectToDm')}
diff --git a/src/components/Global/PeanutActionDetailsCard/index.tsx b/src/components/Global/PeanutActionDetailsCard/index.tsx
index a9b274b5be..295e52ff2b 100644
--- a/src/components/Global/PeanutActionDetailsCard/index.tsx
+++ b/src/components/Global/PeanutActionDetailsCard/index.tsx
@@ -4,11 +4,10 @@ import { printableAddress } from '@/utils/general.utils'
import { AVATAR_TEXT_DARK, getColorForUsername } from '@/utils/color.utils'
import { useTranslations } from 'next-intl'
import { useCallback } from 'react'
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@/utils/tw'
import Attachment from '../Attachment'
import Card from '../Card'
import { Icon, type IconName } from '../Icons/Icon'
-import RouteExpiryTimer from '../RouteExpiryTimer'
import Image, { type StaticImageData } from 'next/image'
import { getFlagUrl } from '@/constants/countryCurrencyMapping'
import Loading from '../Loading'
@@ -41,14 +40,6 @@ export interface PeanutActionDetailsCardProps {
avatarSize?: AvatarSize
countryCodeForFlag?: string
currencySymbol?: string
- // Cross-chain timer props
- showTimer?: boolean
- timerExpiry?: string
- isTimerLoading?: boolean
- onTimerNearExpiry?: () => void
- onTimerExpired?: () => void
- disableTimerRefetch?: boolean
- timerError?: string | null
isLoading?: boolean
logo?: StaticImageData
/**
@@ -74,13 +65,6 @@ export default function PeanutActionDetailsCard({
className,
fileUrl,
avatarSize = 'medium',
- showTimer = false,
- timerExpiry,
- isTimerLoading = false,
- onTimerNearExpiry,
- onTimerExpired,
- disableTimerRefetch = false,
- timerError = null,
countryCodeForFlag,
currencySymbol,
isLoading = false,
@@ -135,7 +119,7 @@ export default function PeanutActionDetailsCard({
}
if (transactionType === 'REGIONAL_METHOD_CLAIM') title = recipientName // Render the string as is for regional method
return (
-
+
{icon && } {title}
)
@@ -203,7 +187,7 @@ export default function PeanutActionDetailsCard({
/>
)}
{!isRegionalMethodClaim && (
-
+
)}
@@ -233,12 +217,12 @@ export default function PeanutActionDetailsCard({
)}
-
+
{getTitle()}
{isLoading ? (
) : (
-
+
{(transactionType === 'ADD_MONEY' || isAddBankAccount || isClaimLinkBankAccount) &&
currencySymbol
? `${currencySymbol}`
@@ -257,16 +241,6 @@ export default function PeanutActionDetailsCard({
)}
- {showTimer && (
-
- )}
diff --git a/src/components/Global/PeanutLoading/index.tsx b/src/components/Global/PeanutLoading/index.tsx
deleted file mode 100644
index de615fa238..0000000000
--- a/src/components/Global/PeanutLoading/index.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { PEANUTMAN } from '@/assets/mascot'
-import Image from 'next/image'
-import { twMerge } from 'tailwind-merge'
-
-export default function PeanutLoading({
- coverFullScreen = false,
- message,
-}: {
- coverFullScreen?: boolean
- message?: string
-}) {
- return (
-
-
-
-
- {message ?? 'Loading...'}
-
-
-
{message}
-
- )
-}
diff --git a/src/components/Global/QRBottomDrawer/index.tsx b/src/components/Global/QRBottomDrawer/index.tsx
index ed9ebe1958..4c0fa8fca8 100644
--- a/src/components/Global/QRBottomDrawer/index.tsx
+++ b/src/components/Global/QRBottomDrawer/index.tsx
@@ -78,27 +78,29 @@ const QRBottomDrawer = ({ url, collapsedTitle, expandedTitle, text, buttonText,
a WebView there is no retractable toolbar, so there vh == dvh exactly.
The scroll area is capped to the expanded window instead of the shared
- 80vh: 3.625rem is the drag-handle block above it (p-5 top + my-4 + the
- handle), and being rem-based it grows with the reader's font size, so
+ 80vh: 3.3125rem is the drag-handle block above it (p-4 top + the handle's
+ mt-2/mb-6), and being rem-based it grows with the reader's font size, so
the scroll region lands on the bottom of the viewport at any setting.
Without this, content taller than the window is simply cut off — the
80vh cap is never reached, so nothing scrolls. QR_DRAWER_EXPANDED_PX
reaches the cap through a CSS variable because Tailwind only emits an
arbitrary value it can read literally in the source. */}
-
-
+
+
{activeSnapPoint === snapPoints[0] ? collapsedTitle : expandedTitle}
-
+ {/* pb-1 = the button's 4px offset shadow; without it the drawer's
+ overflow-auto scroll wrapper clips the shadow at the bottom */}
+
-
{text}
-
+
{text}
+
{buttonText}
diff --git a/src/components/Global/QRCodeWrapper/index.tsx b/src/components/Global/QRCodeWrapper/index.tsx
index 042ff3f6da..f504a86616 100644
--- a/src/components/Global/QRCodeWrapper/index.tsx
+++ b/src/components/Global/QRCodeWrapper/index.tsx
@@ -1,9 +1,9 @@
import { PEANUTMAN_PFP } from '@/assets/mascot'
-import PeanutLoading from '@/components/Global/PeanutLoading'
+import Loading from '@/components/Global/Loading'
import Image from 'next/image'
import { useState, useEffect } from 'react'
import QRCode from 'react-qr-code'
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@/utils/tw'
interface QRCodeWrapperProps {
url: string
@@ -56,13 +56,13 @@ const QRCodeWrapper = ({
{/* Centered Logo */}
-
diff --git a/src/components/Global/QRScanner/CameraPermissionModal.tsx b/src/components/Global/QRScanner/CameraPermissionModal.tsx
index 2a5a52e902..cbbe12058c 100644
--- a/src/components/Global/QRScanner/CameraPermissionModal.tsx
+++ b/src/components/Global/QRScanner/CameraPermissionModal.tsx
@@ -4,6 +4,7 @@ import Image from 'next/image'
import type { StaticImageData } from 'next/image'
import { useTranslations } from 'next-intl'
import ActionModal from '@/components/Global/ActionModal'
+import { LinkButton } from '@/components/0_Bruddle/LinkButton'
import Carousel from '@/components/Global/Carousel'
import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType'
import { useGetBrowserType, BrowserType } from '@/hooks/useGetBrowserType'
@@ -80,8 +81,8 @@ export default function CameraPermissionModal({ visible, onRetry, onClose, onPas
visible={visible}
onClose={onClose}
icon="camera"
- iconContainerClassName="bg-yellow-400"
- iconProps={{ className: 'text-black' }}
+ iconContainerClassName="bg-action-secondary"
+ iconProps={{ className: 'text-foreground-primary' }}
title={t('qrScanner.cameraPermission.title')}
modalClassName="!z-[60]"
modalPanelClassName="max-w-md mx-8"
@@ -103,14 +104,10 @@ export default function CameraPermissionModal({ visible, onRetry, onClose, onPas
]
: []),
]}
- footer={
-
- {t('qrScanner.cameraPermission.dismiss')}
-
- }
+ 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 (
-
+
{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')}
@@ -123,7 +123,7 @@ function PasteActions({
className="justify mx-auto mt-10 flex items-center gap-1.5 text-center text-white underline underline-offset-2"
>
- {t('qrScanner.clickToPaste')}
+ {t('qrScanner.clickToPaste')}
{detectedAddress ? (
- {printableAddress(detectedAddress)}
+ {printableAddress(detectedAddress)}
) : showPasteChip ? (
- {t('qrScanner.useCopiedCode')}
+ {t('qrScanner.useCopiedCode')}
) : null}
>
@@ -232,14 +232,20 @@ function ErrorView({
const tCommon = useTranslations('common')
return (
-
{message}
+
{message}
{onRetry && (
-
+
{tCommon('retry')}
)}
-
+
{tCommon('close')}
@@ -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 8564e7abb5..95b74535a3 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')}
{
setModalContent(EModalType.WILL_BE_NOTIFIED)
@@ -66,7 +66,7 @@ function WillBeNotifiedContent({ qrType, setIsModalOpen }: ModalContentProps) {
const tCommon = useTranslations('common')
return (
-
+
{t('qrScannerOverlay.willBeNotified', { qrName: NAME_BY_QR_TYPE[qrType] ?? '' })}
- {t('qrScannerOverlay.directSendCrossChain')}
- {t('qrScannerOverlay.directSendConfirm')}
+ {t('qrScannerOverlay.directSendCrossChain')}
+ {t('qrScannerOverlay.directSendConfirm')}
) => {
@@ -121,8 +121,8 @@ function ExternalUrlContent({ redirectTo, setIsModalOpen }: ModalContentProps) {
const tCommon = useTranslations('common')
return (
-
{t('qrScannerOverlay.externalUrlIntro')}
-
{t('qrScannerOverlay.externalUrlTrust')}
+
{t('qrScannerOverlay.externalUrlIntro')}
+
{t('qrScannerOverlay.externalUrlTrust')}
{
@@ -155,7 +155,7 @@ function UnrecognizedContent({ setIsModalOpen }: ModalContentProps) {
const t = useTranslations('global')
return (
-
{t('qrScannerOverlay.unrecognized')}
+
{t('qrScannerOverlay.unrecognized')}
setIsModalOpen(false)} className="mt-4 w-full" shadowType="primary" shadowSize="4">
{t('qrScannerOverlay.okay')}
@@ -167,8 +167,8 @@ function PixRecurringContent({ setIsModalOpen }: ModalContentProps) {
const t = useTranslations('global')
return (
-
{t('qrScannerOverlay.pixRecurringIntro')}
-
{t('qrScannerOverlay.pixRecurringBody')}
+
{t('qrScannerOverlay.pixRecurringIntro')}
+
{t('qrScannerOverlay.pixRecurringBody')}
setIsModalOpen(false)} className="mt-4 w-full" shadowType="primary" shadowSize="4">
{t('qrScannerOverlay.okay')}
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/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 (
-
+
@@ -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 (
- openSupportWithMessage(t('supportCta.prefilledMessage'))}
- className="mt-2 cursor-pointer text-sm text-grey-1 underline underline-offset-2"
- >
+ openSupportWithMessage(t('supportCta.prefilledMessage'))} className="mt-2">
{t('supportCta.needHelp')}
-
+
)
}
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}
diff --git a/src/components/Global/TokenAndNetworkConfirmationModal/index.tsx b/src/components/Global/TokenAndNetworkConfirmationModal/index.tsx
index d63a1e68ba..6353a20a20 100644
--- a/src/components/Global/TokenAndNetworkConfirmationModal/index.tsx
+++ b/src/components/Global/TokenAndNetworkConfirmationModal/index.tsx
@@ -1,6 +1,6 @@
import ActionModal from '@/components/Global/ActionModal'
+import SlideToConfirm from '@/components/0_Bruddle/SlideToConfirm'
import { useTranslations } from 'next-intl'
-import { Slider } from '@/components/Slider'
import ChainChip from '@/components/AddMoney/components/ChainChip'
import {
RHINO_SUPPORTED_EVM_CHAINS,
@@ -26,12 +26,12 @@ export default function TokenAndNetworkConfirmationModal({
visible={isVisible}
onClose={onClose}
icon={'alert'}
- iconContainerClassName="bg-yellow-1"
+ iconContainerClassName="bg-action-secondary"
modalClassName="z-[9999]"
title={t('tokenAndNetworkConfirmationModal.title')}
description={
-
{t('tokenAndNetworkConfirmationModal.warning')}
+
{t('tokenAndNetworkConfirmationModal.warning')}
@@ -70,10 +70,7 @@ export default function TokenAndNetworkConfirmationModal({
}
footer={
- v && onAccept()}
- title={t('tokenAndNetworkConfirmationModal.slideToProceed')}
- />
+
}
ctas={[]}
diff --git a/src/components/Global/TokenSelector/Components/NetworkButton.tsx b/src/components/Global/TokenSelector/Components/NetworkButton.tsx
index 13b8bce156..794ad23ac5 100644
--- a/src/components/Global/TokenSelector/Components/NetworkButton.tsx
+++ b/src/components/Global/TokenSelector/Components/NetworkButton.tsx
@@ -9,7 +9,7 @@ import AvatarWithBadge from '@/components/Profile/AvatarWithBadge'
import Image from 'next/image'
import { useTranslations } from 'next-intl'
import React, { useState } from 'react'
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@/utils/tw'
import { Icon } from '../../Icons/Icon'
interface NetworkButtonProps {
@@ -34,8 +34,8 @@ const NetworkButton: React.FC = ({
@@ -60,7 +60,9 @@ const NetworkButton: React.FC = ({
)}
-
{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 (
-
+
)
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 && (
-
+
= ({
)}
-
{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 && (
-
+
= ({ 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 */}
- {
- triggerHaptic()
- setIsSupportModalOpen(true)
- }}
- translate="no"
- className={classNames(
- 'notranslate flex flex-col items-center justify-center object-contain hover:cursor-pointer',
- { 'text-primary-1': isSameRoute(pathName, '/support') }
- )}
- >
-
-
- {/* role="status" so the dot is announced. aria-label alone on a
- bare span is ignored by assistive tech (generic role). */}
- {hasUnreadSupport && (
-
- )}
-
- {t('support')}
-
-
- )
-}
-
-const WalletNavigation: React.FC = () => {
- const t = useTranslations('navigation')
- const pathName = usePathname()
- const { user } = useUserStore()
- const isLoggedIn = !!user?.user.userId || false
-
- return (
-
- )
-}
-
-export default WalletNavigation
diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx
index bd04c521bb..cf3539d8c7 100644
--- a/src/components/Home/ActivationCTAs.tsx
+++ b/src/components/Home/ActivationCTAs.tsx
@@ -135,7 +135,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'),
@@ -143,7 +143,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'),
@@ -151,7 +151,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'),
@@ -160,7 +160,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'),
@@ -234,7 +234,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'),
@@ -244,7 +244,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'),
@@ -254,7 +254,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'),
@@ -315,8 +315,8 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa
-
{step.title}
-
{step.description}
+
{step.title}
+
{step.description}
{step.dismissable && onDismissCard && (
-
+
{tCommon('maybeLater')}
)}
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')}
@@ -130,7 +130,7 @@ const CarouselCTA = ({
{/* Content */}
{title}
-
{description}
+
{description}
)
diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx
index 6be536278b..31f1e847e3 100644
--- a/src/components/Home/HomeCarouselCTA/index.tsx
+++ b/src/components/Home/HomeCarouselCTA/index.tsx
@@ -65,7 +65,7 @@ const HomeCarouselCTA = () => {
title: {t.rich('rewardReady', { amount: perk.amountUsd, b: (chunks) => {chunks} })}
,
description,
icon: 'gift' as IconName,
- iconContainerClassName: 'bg-primary-1',
+ iconContainerClassName: 'bg-action-primary',
onClick: () => setSelectedPerk(perk),
isPerkClaim: true,
iconSize: 16,
diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx
index fcef6ab985..9beff703cd 100644
--- a/src/components/Home/HomeHistory.tsx
+++ b/src/components/Home/HomeHistory.tsx
@@ -1,8 +1,10 @@
'use client'
import TransactionCard from '@/components/TransactionDetails/TransactionCard'
+import { Section } from '@/components/0_Bruddle/Section'
import { mapTransactionDataForDrawer } from '@/components/TransactionDetails/transactionTransformer'
import { type HistoryEntry, useTransactionHistory } from '@/hooks/useTransactionHistory'
+import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer'
import type { IntentKind } from '@/components/TransactionDetails/strategies/registry'
import { useWebSocket } from '@/hooks/useWebSocket'
import { useUserStore } from '@/redux/hooks'
@@ -13,7 +15,7 @@ import { TRANSACTIONS } from '@/constants/query.consts'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@/utils/tw'
import Card from '../Global/Card'
import { type CardPosition, getCardPosition } from '../Global/Card/card.utils'
import EmptyState from '../Global/EmptyStates/EmptyState'
@@ -59,6 +61,9 @@ const HomeHistory = ({
}) => {
const t = useTranslations('home.history')
const { user } = useUserStore()
+ // one `?tx=` subscription for the whole widget — rows are memo'd and get
+ // isSelected/open/close as props (see useTransactionDetailsDrawer)
+ const { isTransactionSelected, openTransactionDetails, closeTransactionDetails } = useTransactionDetailsDrawer()
const isLoggedIn = !!user?.user.userId || false
// Only filter when user is requesting for some different user's history
const filterMutualTxs = username !== user?.user.username
@@ -335,14 +340,13 @@ const HomeHistory = ({
// show loading state
if (isLoading) {
return (
-
-
{t('activity')}
+
{Array.from({ length: 5 }).map((_, index) => (
))}
-
+
)
}
@@ -361,14 +365,13 @@ const HomeHistory = ({
Sentry.captureException(error)
}
return (
-
-
{t('activity')} {' '}
+
+
)
}
@@ -389,8 +392,7 @@ const HomeHistory = ({
// show empty state UI if no processed entries yet (but source data may still be processing)
if (!isLoading && !combinedEntries.length && !hasSourceEntries) {
return (
-
-
{t('activity')}
+
{isViewingOwnHistory &&
user &&
(() => {
@@ -411,16 +413,16 @@ const HomeHistory = ({
{!isViewingOwnHistory && (
)}
-
+
)
}
return (
-
+
{/* link to the full history page */}
{pendingRequests.length > 0 && (
<>
-
{t('pendingTransactions')}
+
{t('pendingTransactions')}
{/* map over the latest entries and render transactioncard */}
{pendingRequests.map((item, index) => {
@@ -443,6 +445,9 @@ const HomeHistory = ({
isPending={true}
haveSentMoneyToUser={transactionDetails.haveSentMoneyToUser}
hideTxnAmount={hideTxnAmount}
+ isSelected={isTransactionSelected(transactionDetails.id)}
+ onOpen={openTransactionDetails}
+ onClose={closeTransactionDetails}
/>
)
})}
@@ -450,11 +455,13 @@ const HomeHistory = ({
>
)}
{!isViewingOwnHistory ? (
-
{t('latestTransactions')}
+
{t('latestTransactions')}
) : (
triggerHaptic()}>
-
{t('activity')}
-
+
{t('activity')}
+
+
+
)}
{/* container for the transaction cards */}
@@ -520,6 +527,9 @@ const HomeHistory = ({
position={position}
haveSentMoneyToUser={haveSentMoneyToUser}
hideTxnAmount={hideTxnAmount}
+ isSelected={isTransactionSelected(transactionDetails.id)}
+ onOpen={openTransactionDetails}
+ onClose={closeTransactionDetails}
/>
)
})}
@@ -532,11 +542,12 @@ export default HomeHistory
export const HistorySkeleton = ({ position }: { position: CardPosition }) => {
return (
-
-
-
-
-
+ // p-4 matches ListItem row height so content doesn't jump on load
+
+
+
)
diff --git a/src/components/Home/InvitesIcon.tsx b/src/components/Home/InvitesIcon.tsx
index b7a8815bb5..05eb21431a 100644
--- a/src/components/Home/InvitesIcon.tsx
+++ b/src/components/Home/InvitesIcon.tsx
@@ -1,6 +1,6 @@
import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg'
import Image from 'next/image'
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@/utils/tw'
const InvitesIcon = ({
animate = true,
diff --git a/src/components/Home/PendingVerificationTasks.tsx b/src/components/Home/PendingVerificationTasks.tsx
index bc436d98ca..48dabd5ffd 100644
--- a/src/components/Home/PendingVerificationTasks.tsx
+++ b/src/components/Home/PendingVerificationTasks.tsx
@@ -290,25 +290,27 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism
const deadline = formatEffectiveDate(task.effectiveDate)
return (
-
+
{dismissible && !!task.effectiveDate && (
handleDismissTask(task)}
- className="absolute right-3 top-3 z-10 cursor-pointer p-0 text-black outline-none"
+ className="absolute top-3 right-3 z-10 cursor-pointer p-0 text-black outline-none"
>
)}
-
+
-
{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')}
-
+
{tCommon('maybeLater')}
>
@@ -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..212dc48bb4 100644
--- a/src/components/Invites/InvitesPage.test.tsx
+++ b/src/components/Invites/InvitesPage.test.tsx
@@ -123,9 +123,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,
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}
{ctaLabel}
@@ -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 && (
-
+
{tCommon('skipForNow')}
-
+
)}
)}
{/* Step 2: Enable Notifications (skippable) */}
{step === 'notifications' && (
-
-
{t('notificationsTitle')}
-
{t('notificationsDescription')}
+
+
{t('notificationsTitle')}
+
{t('notificationsDescription')}
{tNotifications('enable')}
-
setStep('jail')} className="text-sm underline">
+ setStep('jail')} className="self-center">
{tNotifications('notNow')}
-
+
)}
{/* 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'
- )}
- />
-
-
- {tCommon('next')}
-
+ {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"
+ />
+
+
+ {tCommon('next')}
+
+
+
+ {!isValid && !isChanging && !!inviteCode && (
+
{tSetup('waitlist.inviterNotFound')}
+ )}
- {!isValid && !isChanging && !!inviteCode && (
-
- )}
-
- {error && }
+ {error && {error} }
-
+
{isLoggingOut ? t('pleaseWait') : t('logInDifferentAccount')}
-
+
)}
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')}
-
-
+ 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/SumsubNativeSdk.tsx b/src/components/Kyc/SumsubNativeSdk.tsx
index ef38df31a4..6d63b9ed46 100644
--- a/src/components/Kyc/SumsubNativeSdk.tsx
+++ b/src/components/Kyc/SumsubNativeSdk.tsx
@@ -155,7 +155,7 @@ export const SumsubNativeSdk = ({
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"
+ classOverlay="bg-black/50"
video={false}
className="z-[100] !p-0 md:!p-6"
classButtonClose="hidden"
diff --git a/src/components/Kyc/SumsubSdkErrorView.tsx b/src/components/Kyc/SumsubSdkErrorView.tsx
index 871b0710fd..d2c48deee9 100644
--- a/src/components/Kyc/SumsubSdkErrorView.tsx
+++ b/src/components/Kyc/SumsubSdkErrorView.tsx
@@ -15,8 +15,8 @@ export const SumsubSdkErrorView = ({ onClose, message }: SumsubSdkErrorViewProps
return (
-
-
{message}
+
+
{message}
{tCommon('close')}
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 */}
{strings.signUp}
@@ -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 */}
-
+
-
+
-
+
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}
-
+
{breadcrumbs.map((crumb, i) => (
diff --git a/src/components/Marketing/HelpLanding.tsx b/src/components/Marketing/HelpLanding.tsx
index 4c1d2d6a4e..95d355b330 100644
--- a/src/components/Marketing/HelpLanding.tsx
+++ b/src/components/Marketing/HelpLanding.tsx
@@ -63,9 +63,9 @@ export default function HelpLanding({ articles, categories, locale, strings }: H
return (
<>
{/* Search */}
-
+
-
+
setSearchTerm(e.target.value)}
- 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"
/>
@@ -85,7 +85,7 @@ export default function HelpLanding({ articles, categories, locale, strings }: H
{filteredCategories.map((category) => (
-
+
{category}
diff --git a/src/components/Marketing/LocaleSuggestion.tsx b/src/components/Marketing/LocaleSuggestion.tsx
index 955b7f624e..7559e092b5 100644
--- a/src/components/Marketing/LocaleSuggestion.tsx
+++ b/src/components/Marketing/LocaleSuggestion.tsx
@@ -93,7 +93,7 @@ export function LocaleSuggestion({ locale }: { locale: Locale }) {
type="button"
onClick={dismiss}
aria-label={i18n.dismiss}
- className="absolute right-3 top-1/2 -translate-y-1/2 px-1 leading-none opacity-60 hover:opacity-100"
+ className="absolute top-1/2 right-3 -translate-y-1/2 px-1 leading-none opacity-60 hover:opacity-100"
>
×
diff --git a/src/components/Marketing/LocaleSwitcher.tsx b/src/components/Marketing/LocaleSwitcher.tsx
index 3e3cb9e199..52c6c6121d 100644
--- a/src/components/Marketing/LocaleSwitcher.tsx
+++ b/src/components/Marketing/LocaleSwitcher.tsx
@@ -71,7 +71,7 @@ export function LocaleSwitcher({ locale, label }: { locale: Locale; label: strin
{open && (
{SUPPORTED_LOCALES.map((loc) => {
const isCurrent = loc === locale
@@ -86,7 +86,7 @@ export function LocaleSwitcher({ locale, label }: { locale: Locale; label: strin
persistLocale(toAppLocale(loc))
setOpen(false)
}}
- className={`block whitespace-nowrap px-3 py-2 text-sm transition-colors hover:bg-black hover:text-white ${
+ className={`block px-3 py-2 text-sm whitespace-nowrap transition-colors hover:bg-black hover:text-white ${
isCurrent ? 'bg-primary-1/20 font-bold text-n-1' : 'text-n-1'
}`}
>
diff --git a/src/components/Marketing/mdx/Callout.tsx b/src/components/Marketing/mdx/Callout.tsx
index 79b9c80dbe..13a615b102 100644
--- a/src/components/Marketing/mdx/Callout.tsx
+++ b/src/components/Marketing/mdx/Callout.tsx
@@ -9,8 +9,8 @@ interface CalloutProps {
const STYLES: Record = {
info: { bg: 'bg-primary-3/20', border: 'border-primary-3', label: 'Info' },
- tip: { bg: 'bg-green-50', border: 'border-green-300', label: 'Tip' },
- warning: { bg: 'bg-yellow-50', border: 'border-yellow-300', label: 'Important' },
+ tip: { bg: 'bg-green-200', border: 'border-green-400', label: 'Tip' },
+ warning: { bg: 'bg-yellow-200', border: 'border-yellow-400', label: 'Important' },
}
/** Highlighted callout box for tips, warnings, or important info. */
@@ -20,7 +20,7 @@ export function Callout({ type = 'info', children }: CalloutProps) {
return (
- {style.label}
+ {style.label}
{children}
diff --git a/src/components/Marketing/mdx/ExchangeWidget.tsx b/src/components/Marketing/mdx/ExchangeWidget.tsx
index cdd3b3bd41..10eb882661 100644
--- a/src/components/Marketing/mdx/ExchangeWidget.tsx
+++ b/src/components/Marketing/mdx/ExchangeWidget.tsx
@@ -35,7 +35,7 @@ function ExchangeWidgetInner({ destinationCurrency, sourceCurrency = 'USD' }: Ex
}, [destinationCurrency, sourceCurrency, to, from, setQuery])
return (
-
+
@@ -78,7 +78,7 @@ export function ExchangeWidget({ destinationCurrency, sourceCurrency }: Exchange
diff --git a/src/components/Marketing/mdx/Hero.tsx b/src/components/Marketing/mdx/Hero.tsx
index fef564a831..52c1734ba9 100644
--- a/src/components/Marketing/mdx/Hero.tsx
+++ b/src/components/Marketing/mdx/Hero.tsx
@@ -28,11 +28,11 @@ export function Hero({ title, subtitle, cta, ctaHref }: HeroProps) {
-
+
{title}
{subtitle && (
-
+
{subtitle}
)}
diff --git a/src/components/Marketing/mdx/components.tsx b/src/components/Marketing/mdx/components.tsx
index aa592df5dc..8c919087f8 100644
--- a/src/components/Marketing/mdx/components.tsx
+++ b/src/components/Marketing/mdx/components.tsx
@@ -72,7 +72,7 @@ export const mdxComponents: MdxComponentMap = {
// Element overrides — prose styling
h1: (props: React.HTMLAttributes
) => (
),
@@ -80,14 +80,14 @@ export const mdxComponents: MdxComponentMap = {
),
h3: (props: React.HTMLAttributes) => (
),
@@ -102,10 +102,10 @@ export const mdxComponents: MdxComponentMap = {
/>
),
ul: (props: React.HTMLAttributes) => (
-
+
),
ol: (props: React.HTMLAttributes) => (
-
+
),
li: (props: React.HTMLAttributes) => (
@@ -120,7 +120,7 @@ export const mdxComponents: MdxComponentMap = {
),
th: (props: React.HTMLAttributes) => (
),
@@ -129,7 +129,7 @@ export const mdxComponents: MdxComponentMap = {
),
blockquote: (props: React.HTMLAttributes) => (
),
diff --git a/src/components/Migration/DownloadQR.tsx b/src/components/Migration/DownloadQR.tsx
index da82b4ceb5..78a0d50a64 100644
--- a/src/components/Migration/DownloadQR.tsx
+++ b/src/components/Migration/DownloadQR.tsx
@@ -25,7 +25,7 @@ export default function DownloadQR({ surface }: { surface: MigrationSurface }) {
return (
- {t('qr.scanHint')}
+ {t('qr.scanHint')}
{/* desktop can install directly too (e.g. Google Play from the browser) */}
diff --git a/src/components/Migration/MigrationDownloadModal.tsx b/src/components/Migration/MigrationDownloadModal.tsx
index 5bc04110f6..ebb3023101 100644
--- a/src/components/Migration/MigrationDownloadModal.tsx
+++ b/src/components/Migration/MigrationDownloadModal.tsx
@@ -75,7 +75,7 @@ export default function MigrationDownloadModal({
const remindLaterCta = {
text: t(isUrgent ? 'downloadPrompt.remindLater' : 'downloadPrompt.maybeLater'),
variant: 'transparent' as const,
- className: 'underline h-6 text-xs font-normal',
+ className: 'underline h-6 text-body-xs font-normal',
onClick: snooze,
}
diff --git a/src/components/Migration/MigrationHero.tsx b/src/components/Migration/MigrationHero.tsx
index cbbe055f0f..818bd1f690 100644
--- a/src/components/Migration/MigrationHero.tsx
+++ b/src/components/Migration/MigrationHero.tsx
@@ -1,5 +1,5 @@
import Image from 'next/image'
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@/utils/tw'
import { PEANUTMAN_MOBILE } from '@/assets/mascot'
import starImage from '@/assets/icons/star.png'
@@ -15,10 +15,10 @@ export default function MigrationHero({ className }: { className?: string }) {
return (
{STARS.map((pos) => (
diff --git a/src/components/Migration/StoreBadges.tsx b/src/components/Migration/StoreBadges.tsx
index fc6189341a..c06065c0ed 100644
--- a/src/components/Migration/StoreBadges.tsx
+++ b/src/components/Migration/StoreBadges.tsx
@@ -31,7 +31,7 @@ export default function StoreBadges({
icon={s === 'ios' ? 'apple-logo' : 'google-play'}
className={
isHero
- ? 'w-52 bg-white px-6 py-3 text-base font-extrabold hover:bg-white/90 md:py-7 md:text-lg'
+ ? 'w-52 bg-white px-6 py-3 text-button-m font-extrabold hover:bg-white/90 md:py-7 md:text-button-l'
: 'w-auto px-4'
}
>
diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx
index 65be01304a..5792e721ec 100644
--- a/src/components/Migration/SunsetScreen.tsx
+++ b/src/components/Migration/SunsetScreen.tsx
@@ -2,7 +2,7 @@
import { useEffect } from 'react'
import posthog from 'posthog-js'
import { useTranslations } from 'next-intl'
-import { Button } from '@/components/0_Bruddle/Button'
+import { LinkButton } from '@/components/0_Bruddle/LinkButton'
import MigrationHero from '@/components/Migration/MigrationHero'
import StoreButtons from '@/components/Migration/StoreButtons'
import SupportDrawer from '@/components/Global/SupportDrawer'
@@ -33,18 +33,14 @@ export default function SunsetScreen() {
{/* centered on desktop to match the centered store CTA below */}
-
{t('sunset.heading')}
-
{t('sunset.sub')}
+
{t('sunset.heading')}
+
{t('sunset.sub')}
- setIsSupportModalOpen(true)}
- >
+ setIsSupportModalOpen(true)} className="self-center">
{t('sunset.supportLink')}
-
+
{/* the layout's SupportDrawer never mounts when this screen replaces it */}
diff --git a/src/components/Notifications/SetupNotificationsModal.tsx b/src/components/Notifications/SetupNotificationsModal.tsx
index 3a9432ee12..27f474f90f 100644
--- a/src/components/Notifications/SetupNotificationsModal.tsx
+++ b/src/components/Notifications/SetupNotificationsModal.tsx
@@ -60,7 +60,7 @@ export default function SetupNotificationsModal() {
onClick: handleAllowClick,
variant: 'purple',
shadowSize: '4',
- className: 'py-2.5',
+ className: 'py-2',
loading: isRequestingPermission,
disabled: isRequestingPermission,
},
diff --git a/src/components/Payment/PaymentInfoRow.tsx b/src/components/Payment/PaymentInfoRow.tsx
index a290645751..797dd6ab07 100644
--- a/src/components/Payment/PaymentInfoRow.tsx
+++ b/src/components/Payment/PaymentInfoRow.tsx
@@ -1,9 +1,15 @@
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@/utils/tw'
import { Icon } from '../Global/Icons/Icon'
import Loading from '../Global/Loading'
import CopyToClipboard from '../Global/CopyToClipboard'
import { Tooltip } from '../Tooltip'
+/**
+ * Label/value/copy row for receipts and confirm screens. Rows stack inside a
+ * Card, so this does NOT render ListItem (a bordered Card row) — nesting
+ * would double borders. Styling is token-only; a receipt-row board decision
+ * is flagged for design.
+ */
export interface PaymentInfoRowProps {
label: string | React.ReactNode
value: number | string | React.ReactNode
@@ -28,15 +34,15 @@ export const PaymentInfoRow = ({
return (
-
{label}
+
{label}
{moreInfoText && (
@@ -54,7 +60,7 @@ export const PaymentInfoRow = ({
the row to the token's full width and escape the layout.
break-word only activates when a word can't fit, so
normal values render unchanged. */}
-
+
{value}
{allowCopy && typeof value === 'string' && (
diff --git a/src/components/Payment/Views/Error.validation.view.tsx b/src/components/Payment/Views/Error.validation.view.tsx
index e839656abf..48d481544d 100644
--- a/src/components/Payment/Views/Error.validation.view.tsx
+++ b/src/components/Payment/Views/Error.validation.view.tsx
@@ -43,14 +43,14 @@ function ValidationErrorView({
}
return (
-
+
-
{title}
-
{message}
+
{title}
+
{message}
{showLearnMore && (
-
+
{t('validation.learnHow')}
)}
diff --git a/src/components/PeanutRagdoll/index.tsx b/src/components/PeanutRagdoll/index.tsx
index 740a910f10..2872a8b4c9 100644
--- a/src/components/PeanutRagdoll/index.tsx
+++ b/src/components/PeanutRagdoll/index.tsx
@@ -57,7 +57,7 @@ export default function PeanutRagdoll() {
}, [])
return (
-
+
${lifetimeEarnedUsd.toFixed(2)}
-
+
+{formatPoints(animated)} {pointsLabel}
@@ -29,7 +29,7 @@ const InviteePointsBadge = ({ points, inView, lifetimeEarnedUsd }: InviteePoints
}
return (
-
+
+{formatPoints(animated)} {pointsLabel}
)
diff --git a/src/components/Profile/AvatarWithBadge.tsx b/src/components/Profile/AvatarWithBadge.tsx
index 3046bf34d5..5669aa9146 100644
--- a/src/components/Profile/AvatarWithBadge.tsx
+++ b/src/components/Profile/AvatarWithBadge.tsx
@@ -1,7 +1,7 @@
import { getInitialsFromName } from '@/utils/general.utils'
import { getColorForUsername } from '@/utils/color.utils'
import React, { useMemo, useState } from 'react'
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@/utils/tw'
import { Icon, type IconName } from '../Global/Icons/Icon'
import Image, { type StaticImageData } from 'next/image'
@@ -44,12 +44,17 @@ const AvatarWithBadge: React.FC
= ({
fallback,
}) => {
const [logoFailed, setLogoFailed] = useState(false)
+ // board 17802:61529 sizes XS/S/M/L are 24/32/48/64 — the boxes here already
+ // matched, under different names, but every initials step was raw stock
+ // type and none of the five sat on the DS scale. Board type per box:
+ // 24 and 32 = Label/M, 48 = Body/M-SemiBold, 64 = Heading/S. `large` (96)
+ // has no board row and takes the next heading step up.
const sizeClasses: Record = {
- tiny: 'h-6 w-6 text-[10px]',
- 'extra-small': 'h-8 w-8 text-xs',
- small: 'h-12 w-12 text-sm',
- medium: 'h-16 w-16 text-2xl',
- large: 'h-24 w-24 text-3xl',
+ tiny: 'h-6 w-6 text-label-m',
+ 'extra-small': 'h-8 w-8 text-label-m',
+ small: 'h-12 w-12 text-body-m-semibold',
+ medium: 'h-16 w-16 text-heading-s',
+ large: 'h-24 w-24 text-heading-m',
}
const iconSizeMap: Record = {
@@ -70,13 +75,7 @@ const AvatarWithBadge: React.FC = ({
if (logo && !logoFailed) {
return (