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) { <> -
+
)} -

+

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

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

- -
-

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

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

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

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

{t('offlineScreen.title')}

-

{t('offlineScreen.description')}

+

{t('offlineScreen.title')}

+

{t('offlineScreen.description')}

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

+

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

{label}

+

{label}

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

{message}

+

{message}

{onRetry && ( - )} -
@@ -346,7 +352,7 @@ export default function QRScanner({ onScan, onClose, isOpen = true }: QRScannerP if (!isScanning) return null return createPortal( -
+
{/* modal uses !z-[60] to appear above this z-50 scanner portal (Dialog portals to body) */} {isPermissionDenied ? ( /* @@ -381,7 +387,7 @@ export default function QRScanner({ onScan, onClose, isOpen = true }: QRScannerP {!isCameraReady && (
- {t('qrScanner.startingCamera')} + {t('qrScanner.startingCamera')}
)} diff --git a/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx b/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx index 9ae648ef3c..fb735e0dad 100644 --- a/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx +++ b/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx @@ -29,7 +29,11 @@ jest.mock('use-haptic', () => ({ useHaptic: () => ({ triggerHaptic: jest.fn() }) jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) jest.mock('@/app/actions/ens', () => ({ resolveEns: jest.fn() })) jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) -jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, openExternalUrl: jest.fn() })) +jest.mock('@/utils/capacitor', () => ({ + isCapacitor: () => false, + isAndroidNative: () => false, + openExternalUrl: jest.fn(), +})) jest.mock('@/components/0_Bruddle/Toast', () => ({ useToast: () => ({ error: jest.fn() }) })) jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: { user: { username: 'satoshi' } } }) })) jest.mock('@/context/ModalsContext', () => ({ diff --git a/src/components/Global/QRScannerOverlay/__tests__/peanut-url-routing.test.tsx b/src/components/Global/QRScannerOverlay/__tests__/peanut-url-routing.test.tsx index 55aaebe541..e5262f80a5 100644 --- a/src/components/Global/QRScannerOverlay/__tests__/peanut-url-routing.test.tsx +++ b/src/components/Global/QRScannerOverlay/__tests__/peanut-url-routing.test.tsx @@ -20,7 +20,11 @@ const mockServerFetch = jest.fn() let capturedOnScan: QRScanHandler | undefined -jest.mock('@/utils/capacitor', () => ({ isCapacitor: jest.fn(), openExternalUrl: jest.fn() })) +jest.mock('@/utils/capacitor', () => ({ + isCapacitor: jest.fn(), + isAndroidNative: () => false, + openExternalUrl: jest.fn(), +})) jest.mock('@/utils/api-fetch', () => ({ serverFetch: (...args: unknown[]) => mockServerFetch(...args) })) jest.mock('@/app/actions/ens', () => ({ resolveEns: jest.fn().mockResolvedValue(null) })) jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) diff --git a/src/components/Global/QRScannerOverlay/index.tsx b/src/components/Global/QRScannerOverlay/index.tsx index 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')} @@ -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')} 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 (
-
+

0%

120%

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

{title}

- {amountDisplay &&

$ {amountDisplay}

} +

{title}

+ {amountDisplay &&

$ {amountDisplay}

} {description && ( -

+

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

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

{t('supportDrawer.chatLoadFailed')}

-

{t('supportDrawer.chatLoadFailedDescription')}

- +

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

+

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

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

{title}

+ {icon && } +

{title}

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

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

- -
- ) -} - -export default TopNavbar diff --git a/src/components/Global/UnsupportedBrowserModal/index.tsx b/src/components/Global/UnsupportedBrowserModal/index.tsx index 1642870d3e..37be94d081 100644 --- a/src/components/Global/UnsupportedBrowserModal/index.tsx +++ b/src/components/Global/UnsupportedBrowserModal/index.tsx @@ -103,12 +103,13 @@ const UnsupportedBrowserModalContent = ({ toast.error(t('unsupportedBrowserModal.copyErrorToast')) } }, - className: 'bg-primary-1 hover:bg-primary-2 text-black sm:py-3', + className: 'bg-action-primary hover:bg-action-primary-hover text-black sm:py-3', shadowSize: '4', }, { variant: 'transparent-dark', - className: 'text-grey-1 text-xs font-medium h-2 mt-1 hover:text-grey-1 active:text-grey-1', + className: + 'text-foreground-secondary text-body-xs font-medium h-2 mt-1 hover:text-foreground-secondary active:text-foreground-secondary', text: t('unsupportedBrowserModal.pasteHint'), }, ] @@ -120,7 +121,7 @@ const UnsupportedBrowserModalContent = ({ title={t('unsupportedBrowserModal.title')} description={t('unsupportedBrowserModal.description')} icon={'alert' as IconName} - iconContainerClassName="bg-primary-1" + iconContainerClassName="bg-action-primary" iconProps={{ className: 'text-black' }} ctas={copyLinkAction} hideModalCloseButton={!allowClose} diff --git a/src/components/Global/ValidatedInput/index.tsx b/src/components/Global/ValidatedInput/index.tsx index 3634f793cc..1353537d40 100644 --- a/src/components/Global/ValidatedInput/index.tsx +++ b/src/components/Global/ValidatedInput/index.tsx @@ -7,7 +7,7 @@ import { AnimatePresence, motion } from 'framer-motion' import * as Sentry from '@sentry/nextjs' import { useTranslations } from 'next-intl' import { type ChangeEvent, useEffect, useRef, useState } from 'react' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' import { Icon } from '../Icons/Icon' import Loading from '../Loading' @@ -173,17 +173,17 @@ const ValidatedInput = ({
-
+
{infoText && ( -
+
)} @@ -205,9 +205,7 @@ const ValidatedInput = ({ : undefined } className={twMerge( - `notranslate h-12 w-full border-0 bg-white - pr-1 text-sm font-medium outline-none focus:outline-none - active:bg-white dark:bg-n-1 dark:text-white dark:placeholder:text-white/75`, + `notranslate w-full border-0 bg-background-default pr-1 text-body-s font-medium outline-none focus:outline-none focus-visible:outline-none active:bg-background-default`, !!infoText ? 'pl-0' : 'pl-4' )} placeholder={placeholder} @@ -227,20 +225,20 @@ const ValidatedInput = ({ className={`h-full ${ isValidating ? 'opacity-100' - : 'bg-white opacity-100 transition-opacity hover:opacity-100 md:opacity-0' + : 'bg-background-default opacity-100 transition-opacity hover:opacity-100 md:opacity-0' }`} > {isValidating ? ( -
+
) : !!isSetupFlow && !isValid && !isInputChanging ? (
- +
) : !!isSetupFlow && !!isValid && !isInputChanging ? ( -
- +
+
) : ( )}
@@ -272,7 +270,7 @@ const ValidatedInput = ({ dismissSuggestion() onUpdate({ value: suggestion, isValid: false, isChanging: true }) }} - className="flex w-full items-start gap-1.5 rounded-sm border border-n-1 bg-white px-3 py-2 text-left text-xs font-medium text-n-1 transition-colors hover:bg-n-3 dark:border-white dark:bg-n-1 dark:text-white dark:hover:bg-n-2" + className="flex w-full items-start gap-1.5 rounded-sm border border-border-default bg-background-default px-3 py-2 text-left text-body-xs font-medium text-foreground-primary transition-colors hover:bg-background-disabled" > diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx deleted file mode 100644 index df8d0dcca0..0000000000 --- a/src/components/Global/WalletNavigation/index.tsx +++ /dev/null @@ -1,157 +0,0 @@ -'use client' -import PEANUT_LOGO from '@/assets/logos/peanut-logo.svg' -import DirectSendQr from '@/components/Global/DirectSendQR' -import { Icon, type IconName, Icon as NavIcon } from '@/components/Global/Icons/Icon' -import IndicatorDot from '@/components/Global/IndicatorDot' -import underMaintenanceConfig from '@/config/underMaintenance.config' -import { useModalsContext } from '@/context/ModalsContext' -import { isSameRoute } from '@/constants/routes' -import { useSupportUnread } from '@/hooks/useSupportUnread' -import { useUserStore } from '@/redux/hooks' -import classNames from 'classnames' -import Image from 'next/image' -import { useLocale, useTranslations } from 'next-intl' -import { localizeDocsHref } from '@/components/Global/DocsLink' -import Link from 'next/link' -import { usePathname, useRouter } from 'next/navigation' -import { useAppHaptic } from '@/hooks/useAppHaptic' - -type NavPathProps = { - labelKey: 'send' | 'request' | 'add' | 'withdraw' | 'history' | 'docs' | 'support' - href: string - icon: IconName - size?: number -} - -// todo: update icons based on new the design -const desktopPaths: NavPathProps[] = [ - { labelKey: 'send', href: '/send', icon: 'arrow-up-right', size: 14 }, - { labelKey: 'request', href: '/request', icon: 'arrow-down-left', size: 14 }, - { labelKey: 'add', href: '/add-money', icon: 'arrow-down', size: 15 }, - { labelKey: 'withdraw', href: '/withdraw', icon: 'arrow-up', size: 15 }, - { labelKey: 'history', href: '/history', icon: 'history', size: 15 }, - { labelKey: 'docs', href: '/en/help', icon: 'docs', size: 14 }, - { labelKey: 'support', href: '/support', icon: 'peanut-support', size: 14 }, -] - -type NavSectionProps = { - paths: NavPathProps[] - pathName: string -} - -const NavSection: React.FC = ({ paths, pathName }) => { - const t = useTranslations('navigation') - const locale = useLocale() - const router = useRouter() - return ( - <> - {paths.map(({ labelKey, href, icon, size }, index) => ( -
- { - if (isSameRoute(pathName, href)) { - router.refresh() - } - }} - > - - {t(labelKey)} - - {index === 4 &&
} -
- ))} - - ) -} - -type MobileNavProps = { - pathName: string -} - -const MobileNav: React.FC = ({ pathName }) => { - const t = useTranslations('navigation') - const { setIsSupportModalOpen } = useModalsContext() - const { triggerHaptic } = useAppHaptic() - const hasUnreadSupport = useSupportUnread() - - return ( -
- {/* Home Link */} - triggerHaptic()} - href="/home" - translate="no" - className={classNames( - 'notranslate flex flex-col items-center justify-center object-contain hover:cursor-pointer', - { 'text-primary-1': isSameRoute(pathName, '/home') } - )} - > - - {t('home')} - - - {/* QR Button - Main Action */} - - - {/* Support Link */} - -
- ) -} - -const WalletNavigation: React.FC = () => { - const t = useTranslations('navigation') - const pathName = usePathname() - const { user } = useUserStore() - const isLoggedIn = !!user?.user.userId || false - - return ( -
-
- - {t('peanutLogoAlt')} - -
- -
-
- {isLoggedIn && } -
- ) -} - -export default WalletNavigation diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 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}
)} diff --git a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx index b32f69fa0e..cd579b8492 100644 --- a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx +++ b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx @@ -41,22 +41,22 @@ export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunch role="button" tabIndex={0} onClick={handleTryDoor} - className="relative mb-3 cursor-pointer overflow-hidden rounded-sm border border-n-1 bg-white p-5" + className="relative mb-3 cursor-pointer overflow-hidden rounded-sm border border-border-default bg-white p-4" >
-

+

{t('title')}

-

{t('subtitle')}

+

{t('subtitle')}

)} -
+
-
{copy.title}
-
{copy.description}
+
{copy.title}
+
+ {copy.description} +
{deadline && ( -
+
{t('pendingTasks.completeBefore', { deadline })}
)} @@ -331,7 +333,7 @@ export default function PendingVerificationTasks({ dismissible = false }: { dism ) })} - {error &&

{error}

} + {error &&

{error}

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

+${perk.amountUsd}

+

+${perk.amountUsd}

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

+

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

-

+

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

) : inviteeName ? ( -

+

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

) : ( -

{t('rewardClaimed')}

+

{t('rewardClaimed')}

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

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

{/* Title */} -

+

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

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

{t('holdToUnwrap')}

+

{t('holdToUnwrap')}

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

{t('youCanNow')}

- { if (unlockedChannels === 'all') { diff --git a/src/components/IdentityVerification/UnlockRegionModal.tsx b/src/components/IdentityVerification/UnlockRegionModal.tsx index 7ab93a35d2..fb3d20334d 100644 --- a/src/components/IdentityVerification/UnlockRegionModal.tsx +++ b/src/components/IdentityVerification/UnlockRegionModal.tsx @@ -2,7 +2,7 @@ import { useTranslations } from 'next-intl' import ActionModal from '../Global/ActionModal' -import InfoCard from '../Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { Icon } from '../Global/Icons/Icon' import { type Region } from '@/utils/regions.utils' import { useRegionLabel } from '@/hooks/useRegionLabel' @@ -61,7 +61,7 @@ const UnlockRegionModal = ({ description={

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

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

{t('whatYoullUnlock')}

- +

{t('whatYoullUnlock')}

+
- -

{tKyc('doesntStoreDocumentsPeriod')}

+ +

{tKyc('doesntStoreDocumentsPeriod')}

} diff --git a/src/components/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx index 9309eb64a0..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}

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

{t('emailTitle')}

-

{t('emailDescription')}

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

{t('emailTitle')}

+

{t('emailDescription')}

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

{t('notificationsTitle')}

-

{t('notificationsDescription')}

+
+

{t('notificationsTitle')}

+

{t('notificationsDescription')}

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

{t('inviteOnlyTitle')}

+
+

{t('inviteOnlyTitle')}

-

+

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

-

{t('skipTheLine')}

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

{t('skipTheLine')}

+ + {/* input + its field error form one column, 4px apart (form-field board 17788:19179) */} +
+
+ toInviteCode(v).length >= USERNAME_MIN_LENGTH} + onUpdate={({ value, isValid, isChanging }) => { + setIsValid(isValid) + setIsChanging(isChanging) + setInviteCode(value) + }} + isSetupFlow + isInputChanging={isChanging} + className="rounded-sm" + /> + + +
+ + {!isValid && !isChanging && !!inviteCode && ( + {tSetup('waitlist.inviterNotFound')} + )}
- {!isValid && !isChanging && !!inviteCode && ( - - )} - - {error && } + {error && {error}} - +
)}
diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index b35c5c8a3b..0ad7c5a3ec 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -135,7 +135,7 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip, reasonCode }: Bridg text: t('bridgeTos.notNow'), onClick: onSkip, variant: 'transparent' as const, - className: 'underline text-sm font-medium w-full h-fit mt-3', + className: 'underline text-body-s w-full h-fit mt-3', }, ]} /> diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index fc3219cf64..b62c4a4ab0 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -135,7 +135,7 @@ export const InitiateKycModal = ({ description={getDescription()} preventClose icon={(error || isBlocked || isRestartIdentity || isRegionUnavailable ? 'alert' : 'badge') as IconName} - iconContainerClassName={isBlocked || isRestartIdentity || isRegionUnavailable ? 'bg-yellow-1' : ''} + iconContainerClassName={isBlocked || isRestartIdentity || isRegionUnavailable ? 'bg-action-secondary' : ''} modalPanelClassName="max-w-full m-2" ctaClassName="grid grid-cols-1 gap-3" ctas={[ diff --git a/src/components/Kyc/KYCStatusDrawerItem.tsx b/src/components/Kyc/KYCStatusDrawerItem.tsx index 58d34a272e..110df7316c 100644 --- a/src/components/Kyc/KYCStatusDrawerItem.tsx +++ b/src/components/Kyc/KYCStatusDrawerItem.tsx @@ -1,18 +1,20 @@ import { useTranslations } from 'next-intl' -import Card from '@/components/Global/Card' import StatusBadge, { type StatusType } from '../Global/Badges/StatusBadge' import { KYCStatusIcon } from './KYCStatusIcon' +// centered drawer head per the TX Details chrome (board 17490:115877): +// icon bubble → type line → status badge. The rows below it live in the +// state views' receipt Card. export const KYCStatusDrawerItem = ({ status, customText }: { status: StatusType; customText?: string }) => { const t = useTranslations('kyc') return ( - +
-
-

{t('identityVerification')}

+
+

{t('identityVerification')}

- +
) } diff --git a/src/components/Kyc/KYCStatusIcon.tsx b/src/components/Kyc/KYCStatusIcon.tsx index 5c42367099..35db7b6064 100644 --- a/src/components/Kyc/KYCStatusIcon.tsx +++ b/src/components/Kyc/KYCStatusIcon.tsx @@ -1,5 +1,5 @@ import AvatarWithBadge from '../Profile/AvatarWithBadge' export const KYCStatusIcon = () => { - return + return } diff --git a/src/components/Kyc/KycFailedContent.tsx b/src/components/Kyc/KycFailedContent.tsx index 3cefdb8240..18ffcbd5ba 100644 --- a/src/components/Kyc/KycFailedContent.tsx +++ b/src/components/Kyc/KycFailedContent.tsx @@ -1,6 +1,6 @@ import { useTranslations } from 'next-intl' import { RejectLabelsList } from './RejectLabelsList' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' interface KycFailedContentProps { rejectLabels?: string[] | null @@ -13,9 +13,7 @@ export const KycFailedContent = ({ rejectLabels, isTerminal }: KycFailedContentP const t = useTranslations('kyc') if (isTerminal) { - return ( - - ) + return {t('terminalDescription')} } return diff --git a/src/components/Kyc/KycReverificationPendingModal.tsx b/src/components/Kyc/KycReverificationPendingModal.tsx index 7b6fb0a765..eb1e54f04a 100644 --- a/src/components/Kyc/KycReverificationPendingModal.tsx +++ b/src/components/Kyc/KycReverificationPendingModal.tsx @@ -28,7 +28,7 @@ export const KycReverificationPendingModal = ({ isOpen, onClose, message }: KycR visible={isOpen} onClose={onClose} icon="clock" - iconContainerClassName="bg-yellow-1 text-black" + iconContainerClassName="bg-action-secondary text-black" title={t('reverificationPending.title')} description={

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

} ctas={[ diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 52de16797e..f7d5685703 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -3,7 +3,7 @@ import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' -import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' +import { Drawer, DrawerContent } from '../Global/Drawer' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { useCallback } from 'react' import { useIdentityVerification } from '@/hooks/useIdentityVerification' @@ -85,10 +85,9 @@ export const KycStatusDrawer = ({ isOpen, onClose, onKeepMounted }: KycStatusDra return ( <> - - {t('statusDrawerTitle')} + {renderContent()} - {sumsubFlow.error &&

{sumsubFlow.error}

} + {sumsubFlow.error &&

{sumsubFlow.error}

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

{t('identityVerification')}

-
-

{subtitle}

- -
-
+ className={className} + leading={} + title={t('identityVerification')} + body={ +
+ {subtitle} +
-
- + } + /> {(isDrawerOpen || keepDrawerMounted) && ( {t('progress.verifyingDescription')}

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

{error}

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

{flow.error}

} + {flow.error &&

{flow.error}

} diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index 4931943813..938ae748e5 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -329,7 +329,7 @@ const SumsubWebSdkModal = ({ title: t('wrapper.troubleTitle'), description: t('wrapper.troubleDescription'), icon: 'question-mark' as IconName, - iconContainerClassName: 'bg-primary-1', + iconContainerClassName: 'bg-action-primary', ctas: [ { text: t('wrapper.chatWithSupport'), @@ -341,7 +341,7 @@ const SumsubWebSdkModal = ({ text: tCommon('cancel'), onClick: () => setIsHelpModalOpen(false), variant: 'transparent' as ButtonVariant, - className: 'underline text-sm font-medium w-full h-fit mt-3', + className: 'underline text-body-s w-full h-fit mt-3', }, ], } @@ -351,7 +351,7 @@ const SumsubWebSdkModal = ({ title: t('wrapper.exitForNowTitle'), description: t('wrapper.exitForNowDescription'), icon: 'alert' as IconName, - iconContainerClassName: 'bg-secondary-1', + iconContainerClassName: 'bg-action-secondary', ctas: [ { text: t('wrapper.exit'), @@ -366,7 +366,7 @@ const SumsubWebSdkModal = ({ text: tCommon('continue'), onClick: () => setIsHelpModalOpen(false), variant: 'transparent' as ButtonVariant, - className: 'underline text-sm font-medium w-full h-fit mt-3', + className: 'underline text-body-s w-full h-fit mt-3', }, ], } @@ -378,7 +378,7 @@ const SumsubWebSdkModal = ({ visible={visible} onClose={onClose} classWrap="h-full w-full !max-w-none sm:!max-w-[600px] border-none sm:m-auto m-0" - classOverlay={`bg-black bg-opacity-50 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} + classOverlay={`bg-black/50 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} video={false} className={`z-[100] !p-0 md:!p-6 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} classButtonClose="hidden" @@ -397,7 +397,7 @@ const SumsubWebSdkModal = ({ }} className="flex items-center gap-1 p-1" > - + diff --git a/src/components/Kyc/modals/KycActionRequiredModal.tsx b/src/components/Kyc/modals/KycActionRequiredModal.tsx index ab4ec20238..2f465e50c8 100644 --- a/src/components/Kyc/modals/KycActionRequiredModal.tsx +++ b/src/components/Kyc/modals/KycActionRequiredModal.tsx @@ -26,7 +26,7 @@ export const KycActionRequiredModal = ({ visible={visible} onClose={onClose} icon="alert" - iconContainerClassName="bg-yellow-1" + iconContainerClassName="bg-action-secondary" title={t('actionRequiredTitle')} description={t('actionRequiredDescription')} content={ diff --git a/src/components/Kyc/modals/KycFailedModal.tsx b/src/components/Kyc/modals/KycFailedModal.tsx index b3ee737bb7..1bc7e4c0c1 100644 --- a/src/components/Kyc/modals/KycFailedModal.tsx +++ b/src/components/Kyc/modals/KycFailedModal.tsx @@ -39,7 +39,7 @@ export const KycFailedModal = ({ visible={visible} onClose={onClose} icon={'alert'} - iconContainerClassName="bg-yellow-1" + iconContainerClassName="bg-action-secondary" title={isTerminal ? t('failedTitleTerminal') : t('failedTitleRetry')} description={!isTerminal && t('failedDescriptionRetry')} content={ diff --git a/src/components/Kyc/modals/KycProcessingModal.tsx b/src/components/Kyc/modals/KycProcessingModal.tsx index 63fa585b52..cd61dc2840 100644 --- a/src/components/Kyc/modals/KycProcessingModal.tsx +++ b/src/components/Kyc/modals/KycProcessingModal.tsx @@ -16,7 +16,7 @@ export const KycProcessingModal = ({ visible, onClose }: KycProcessingModalProps visible={visible} onClose={onClose} icon="clock" - iconContainerClassName="bg-yellow-1" + iconContainerClassName="bg-action-secondary" title={t('processingTitle')} description={t('processingDescription')} ctas={[ diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx index 242ee312b5..9c419b7485 100644 --- a/src/components/Kyc/states/KycActionRequired.tsx +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -1,7 +1,7 @@ import { useTranslations } from 'next-intl' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { RejectLabelsList } from '../RejectLabelsList' -import InfoCard from '@/components/Global/InfoCard' +import { Notification } from '@/components/0_Bruddle/Notification' import { Button } from '@/components/0_Bruddle/Button' import type { IconName } from '@/components/Global/Icons/Icon' @@ -33,7 +33,7 @@ export const KycActionRequired = ({ {!rejectLabels?.length && actionMessage ? ( - + {t('actionMessageActionRequired')} ) : ( )} diff --git a/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx b/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx index b398d5fbbe..9216a80488 100644 --- a/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx +++ b/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx @@ -22,18 +22,6 @@ jest.mock('../../KYCStatusDrawerItem', () => ({ KYCStatusDrawerItem: () =>
, })) -// InfoCard is the leaf both branches render through; surface its text so we can -// assert the actual copy. RejectLabelsList is intentionally NOT mocked. -jest.mock('@/components/Global/InfoCard', () => ({ - __esModule: true, - default: ({ title, description }: { title?: string; description?: string }) => ( - <> - {title ?
{title}
: null} - {description ?
{description}
: null} - - ), -})) - describe('KycActionRequired — real reject-label copy', () => { it('renders the DUPLICATE_EMAIL guidance, not the generic resubmit message', () => { render( diff --git a/src/components/Kyc/states/__tests__/KycStates.test.tsx b/src/components/Kyc/states/__tests__/KycStates.test.tsx index 82e4a2bbed..3c3311e9b7 100644 --- a/src/components/Kyc/states/__tests__/KycStates.test.tsx +++ b/src/components/Kyc/states/__tests__/KycStates.test.tsx @@ -35,11 +35,6 @@ jest.mock('@/components/Global/Card', () => ({ default: ({ children }: { children: ReactNode }) =>
{children}
, })) -jest.mock('@/components/Global/InfoCard', () => ({ - __esModule: true, - default: ({ description }: { description: string }) =>
{description}
, -})) - describe('KYC state cards', () => { it('does not pass the click event to action-required resume', () => { const onResume = jest.fn() diff --git a/src/components/LandingPage/CloudsCss.tsx b/src/components/LandingPage/CloudsCss.tsx index 661287d293..311a010f81 100644 --- a/src/components/LandingPage/CloudsCss.tsx +++ b/src/components/LandingPage/CloudsCss.tsx @@ -20,7 +20,7 @@ const defaultClouds: CloudConfig[] = [ export function CloudsCss({ clouds = defaultClouds }: { clouds?: CloudConfig[] }) { return ( -
+
{clouds.map((cloud, i) => (
-
+
setSearchTerm(e.target.value)} - className="h-10 w-full rounded-sm border-[1.15px] border-black pl-10 pr-10 font-normal caret-[#FF90E8] focus:border-black focus:outline-none focus:ring-0" + className="h-10 w-full rounded-sm border-[1.15px] border-black pr-10 pl-10 font-normal caret-[#FF90E8] focus:border-black focus:ring-0 focus:outline-none" />
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index 36dc559109..197038d1bc 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -282,7 +282,7 @@ export function LandingPageClient({
{heroConfig.primaryCta.subtext && ( - + {heroConfig.primaryCta.subtext} )} diff --git a/src/components/LandingPage/PartnerLockup.tsx b/src/components/LandingPage/PartnerLockup.tsx index 5a1a8fcb2c..fe9e455a17 100644 --- a/src/components/LandingPage/PartnerLockup.tsx +++ b/src/components/LandingPage/PartnerLockup.tsx @@ -1,6 +1,6 @@ import Image, { type StaticImageData } from 'next/image' import Link from 'next/link' -import { twMerge } from 'tailwind-merge' +import { twMerge } from '@/utils/tw' /** * A country flag over its payment rail's mark, linking to the page that explains diff --git a/src/components/LandingPage/ProblemFold.tsx b/src/components/LandingPage/ProblemFold.tsx index a08839cb36..57595f0ad0 100644 --- a/src/components/LandingPage/ProblemFold.tsx +++ b/src/components/LandingPage/ProblemFold.tsx @@ -53,7 +53,7 @@ export function ProblemFold({ strings }: { strings: LandingStrings }) { style={{ backgroundColor: bgColor }} >
-

+

{problem.heading}

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

+

{card.title}

{card.body}

diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx index 5e8a7b1e1d..0dfe1816ab 100644 --- a/src/components/LandingPage/RegulatedRails.tsx +++ b/src/components/LandingPage/RegulatedRails.tsx @@ -61,14 +61,14 @@ export function RegulatedRails({ locale = DEFAULT_LOCALE }: { locale?: Locale })
- + - + -

+

{i18n.landingRailsHeading}

{i18n.landingRailsBody}

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

+

{i18n.landingWorksWith}

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

+

{t('tagline')}

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

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

{strings.dropLinkHeading}

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

{feature.title}

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

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

-
+

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

+

{typeLabels[t]}

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