From c74f83ffd78b7e6ed1c8ec82afac6c616a0020c5 Mon Sep 17 00:00:00 2001 From: Hollujay Date: Wed, 30 Sep 2026 04:54:51 +0100 Subject: [PATCH 1/8] feat: i18n, PWA offline mode, high-contrast theme and coverage CI gate Closes #1329, #1330, #1332, #1333 --- .github/codecov.yml | 23 ++- .github/workflows/ci.yml | 31 ++++ backend/vitest.config.ts | 2 +- frontend/public/icon-192.png | Bin 0 -> 683 bytes frontend/public/icon-512.png | Bin 0 -> 2315 bytes frontend/public/icon-maskable-512.png | Bin 0 -> 2113 bytes frontend/public/manifest.webmanifest | 38 +++++ frontend/public/sw.js | 86 +++++++++++ frontend/src/__tests__/i18n.test.tsx | 96 ++++++++++++ frontend/src/__tests__/offline.test.tsx | 65 ++++++++ frontend/src/__tests__/theme.test.tsx | 62 ++++++++ frontend/src/app/globals.css | 146 ++++++++++++++++-- frontend/src/app/layout.tsx | 21 ++- .../src/app/settings/settings-content.tsx | 53 ++++--- frontend/src/components/LanguageSelector.tsx | 27 ++++ frontend/src/components/ModeToggle.tsx | 57 ++++--- frontend/src/components/Navbar.tsx | 23 ++- frontend/src/components/OfflineBanner.tsx | 41 +++++ .../src/components/ServiceWorkerRegistrar.tsx | 14 ++ frontend/src/context/i18n-provider.tsx | 93 +++++++++++ frontend/src/context/theme-provider.tsx | 6 +- frontend/src/hooks/useNetworkStatus.ts | 70 +++++++++ frontend/src/lib/themes.ts | 25 +++ frontend/src/locales/en.json | 40 +++++ frontend/src/locales/es.json | 40 +++++ frontend/src/locales/fr.json | 40 +++++ frontend/src/locales/index.ts | 57 +++++++ frontend/src/locales/ja.json | 40 +++++ frontend/src/locales/pt.json | 40 +++++ frontend/src/locales/zh.json | 40 +++++ frontend/vitest.config.ts | 15 +- 31 files changed, 1219 insertions(+), 72 deletions(-) create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png create mode 100644 frontend/public/icon-maskable-512.png create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/__tests__/i18n.test.tsx create mode 100644 frontend/src/__tests__/offline.test.tsx create mode 100644 frontend/src/__tests__/theme.test.tsx create mode 100644 frontend/src/components/LanguageSelector.tsx create mode 100644 frontend/src/components/OfflineBanner.tsx create mode 100644 frontend/src/components/ServiceWorkerRegistrar.tsx create mode 100644 frontend/src/context/i18n-provider.tsx create mode 100644 frontend/src/hooks/useNetworkStatus.ts create mode 100644 frontend/src/lib/themes.ts create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/locales/es.json create mode 100644 frontend/src/locales/fr.json create mode 100644 frontend/src/locales/index.ts create mode 100644 frontend/src/locales/ja.json create mode 100644 frontend/src/locales/pt.json create mode 100644 frontend/src/locales/zh.json diff --git a/.github/codecov.yml b/.github/codecov.yml index ea535ffe..fc71553c 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,23 +1,44 @@ coverage: + precision: 2 + round: down + range: "60...90" status: project: backend: target: 60% + # Fail the PR if backend coverage drops by more than 1%. + threshold: 1% flags: - backend contracts: target: 70% + threshold: 1% flags: - contracts frontend: target: auto + # Fail the PR if frontend coverage drops by more than 1%. + threshold: 1% flags: - frontend patch: default: target: 60% +flags: + backend: + paths: + - backend/ + carryforward: true + frontend: + paths: + - frontend/ + carryforward: true + contracts: + carryforward: true + comment: - layout: "diff, flags, files" + # Per-file coverage change table on every PR. + layout: "reach, diff, flags, files" behavior: default require_changes: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10981f6e..ba441fa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: frontend: name: Frontend CI runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -56,6 +59,19 @@ jobs: run: npm run test:coverage working-directory: frontend + - name: Post frontend coverage summary on PR + if: github.event_name == 'pull_request' && !cancelled() + # Fork PRs get a read-only token; never fail the build over the comment. + continue-on-error: true + uses: davelosert/vitest-coverage-report-action@v2 + with: + working-directory: frontend + name: Frontend coverage + json-summary-path: coverage/coverage-summary.json + json-final-path: coverage/coverage-final.json + file-coverage-mode: changes + vite-config-path: vitest.config.ts + - name: Upload frontend coverage to Codecov uses: codecov/codecov-action@v5 with: @@ -82,6 +98,9 @@ jobs: if: >- github.event_name == 'push' || needs.changes.outputs.backend == 'true' + permissions: + contents: read + pull-requests: write services: postgres: image: postgres:16-alpine@sha256:e013e867e712fec275706a6c51c966f0bb0c93cfa8f51000f85a15f9865a28cb @@ -141,6 +160,18 @@ jobs: DATABASE_URL: postgresql://postgres:password@127.0.0.1:5432/flowfi_test NODE_ENV: test + - name: Post backend coverage summary on PR + if: github.event_name == 'pull_request' && !cancelled() + continue-on-error: true + uses: davelosert/vitest-coverage-report-action@v2 + with: + working-directory: backend + name: Backend coverage + json-summary-path: coverage/coverage-summary.json + json-final-path: coverage/coverage-final.json + file-coverage-mode: changes + vite-config-path: vitest.config.ts + - name: Upload backend coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 914bbb42..9f968968 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ enabled: true, provider: 'v8', reportsDirectory: './coverage', - reporter: ['text', 'json', 'html', 'lcov'], + reporter: ['text', 'json', 'json-summary', 'html', 'lcov'], exclude: [ 'node_modules/**', 'dist/**', diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..d4a9f9f50c2077164e16524afd47245688fcfea5 GIT binary patch literal 683 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfvL#T#WAGf*4sM=c@H`8xLoXD zPS3mI9a-Ye|E($Yndkndi1O_1oAqOT822KS|1x6L109c60 z11tm-014qxqp|(??^Kos2Br?VhHEbwITRWgW?g0$aL~17V7zh($Y5YJSI2`}V094Fy_T|r1hKs_5 z=RjJ&Fu0t*B>z$zXwZ`%j2&|48((nN*i~Kxngx{Nclg4xZx$=Zf)FS#rG6@#fJ0)# z3!u`57Yl*j*mJ5to?dLv zt}@=_T41H%43c(kSOd}n3@ z$*gdk-=R;w;Toe8BhVl|h`a#Ei@b7*!aTOtd>C&T~gVwZWeO O2s~Z=T-G@yGywo&l*iuy literal 0 HcmV?d00001 diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..4ceadcc5396fcc6ac5acaaf89b1ac814891ddafa GIT binary patch literal 2315 zcmc&#Z%9>V82_F39`(%9PT@#g*_oGIV{4`L;j#suTUX2qxy51?|aVk zdw;+8_dL(*nP62#PIf^yfSk(hfjxkrser*h^#$9y0pmes!2fyp@%TtzPP1+5{^pY{ z6@$&?N54D1VtDOdy}q_%bg&SOxgWp;bU=fRMNQur{>7qbU25No0(pI^ezm5_KPH!u zv%gNPB$N2rF}48)svAsFZQ{{^2F-?bYeq=eb<-JSBR%qg3v^8Zj5x>v*ktzA8!3={ zrT~JNeo{;hDDdzgam1#U37q)XkDB^<2ri6p{OO)MuuwDYClWgjdMyDTWL zJ7AFu!F>skeRu)pF_aB-9G}+#$Go_}Kg$ffoX*nZxGR&Kez!I!HdO|4BFm{9H^^}- z&4IezbJ7IBQM8EYfueKR~IyG-yWSJXrWz0=`}qA4TO zqj+0-$1e0yW?dW1rlF4&=q0|_Mk!r$XlVE4N?=0rlgX)U6B;MEx`9d|GI>ZvsIxFh ze8L|oDsgmmsIIbCMUA!5N4ucAj4We*gXP*xir*04?^K|CLhYQND=(JgYCe8B>r{U+ ze$Ue|M$OlPaF7dAN2wO;Fp%eoez+X#G9#ZAAyvj9Y$@Un7HCJ zVXh`?F<6j{E!9bnfz2X%o0Dzgo*p^$K7vc-#jQ$Evl7HLBA+7cRa@I!QU<;f(IeEo znUP^DP0HXBfu9l1OUf>CF0HTi$oE{`7S6vTZ_+K4k7Xxem*9Y=3Q(PQNQ>!8IXNCD zd8yWarEWg5tKZ8SFMAW)HL3NtUdm?t?(w0jg*ShGj>pz>?sE)mWQD#B2^$91tM$i% zlI*BH<%xDG+UsikF)8L!nv9)|EMt=z-)mH2RPXwbYY3w4$)e_fu!~WZ#p~ypJho;E zO1sUstAQacRAN%XLZ5JRn3U$wN8CCVCnd;rW3kgaEKYsESyvO7Oqi0)KFaMU+0*-K z-LJ;Wl5=9xE$0!D5LW3rC4Rq$Y8SfPg)4#@k=R=>;GzB@mUj4r!T-rgumyUNoQkNP poV#8xWcrF-7X*)H|DOtCFFbYc=6zZ_+W7O=X2Bh`@medx%IMEJ|=6f`Y}cV70b(?VLNSPGaWILwn%vo#A}vJLmh(bQBdj z&C!X`0AMaS=r{@>;#CBh{u&eW`vGLRz+o?Lct1HdXl_;G9rlmxU61clNxd^JVKS+D z$M?4Bx~pGOz}_7PGGQ5jg0MAQ*Mc!n$!zDEXpJS-(6tf%e>*T;xj&Sup!y1Ql{5(| zC{IHo1LijgfWapH2_V4(Una^#ad+%ASfG7i2mzLp1fb}BGzVg@0vpj#iiazLYJpbJ zra5SY10>`-#Q7X=iHl-;{{mmQ!HFgdO#O@%;lnby3%#lJ!AKm)hX4vANFRn2C^}(K z0GCp&o(J_T+1uZtB;cyV3pY44tAKYVMS{nC_@xQLx#Z6%S8fae6h_cucXSZ@960>V zY6F=X)B>eV67-R_3XG+oJT*ZpcyuqHgwSv1cY>bft7$<2wLk@CNZ`8-?%*J1AWB7p z`f^3)9}c9#@g{zC;=6EGe=cTCpv!I)Qv$&gQZNgRV1Atfx!uA3T#>R;c@~!R@Y?^N zK_qp+v%W^!!e#lMaz?FqG-rzlFQ6e^*C$Jua>)=M;k{)+2AW!d-N_Jp4b(kwD!lS8 z$T4FxC~2ZFm7%YFrRY6^Yq&w{#4U+opJ=gER0emq7M?|N4tbs%+InU{sHoheJ<3Vli;RMTQx^fTgjix(LI!xBF3a$ zwo-@Z`B`q|AI?ijJ4TKw@J42LY<%FZDAs7BDJt}5chC;*eUQ;XBD`+r&8TT`p`D&L z@pGUJ*!%~F-R$S=*^;)IzD^_=A;&mLXY(A%#bzQYoF&EsE zB^-I&L}#)|f}vTaXWoeLW+`OVE;3mGbRdluM}@PJW~ZG~iFmI{GpF-&oBq}lVq~fl z8C1YLTsr-V?ato~uUB)OE43PoA$Of{th9J0L-Cbd*9n&vDY`tRw<{s9F8lhAOvMHG ND>zW-7|3(C{08C(pIiU{ literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 00000000..f9fd38ac --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,38 @@ +{ + "name": "FlowFi", + "short_name": "FlowFi", + "description": "Real-time payment streams on Stellar", + "id": "/", + "start_url": "/dashboard", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#020617", + "theme_color": "#020617", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "Create Stream", + "url": "/streams/create" + } + ] +} \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..ea9a99bf --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,86 @@ +/* FlowFi service worker (#1330): stale-while-revalidate for static assets, + * fonts and API GETs so streams stay viewable read-only when offline. */ +const VERSION = "v1"; +const STATIC_CACHE = `flowfi-static-${VERSION}`; +const API_CACHE = `flowfi-api-${VERSION}`; +const PAGES_CACHE = `flowfi-pages-${VERSION}`; +const KNOWN = [STATIC_CACHE, API_CACHE, PAGES_CACHE]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(PAGES_CACHE) + .then((c) => c.addAll(["/", "/dashboard"]).catch(() => undefined)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k.startsWith("flowfi-") && !KNOWN.includes(k)).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +async function notifySynced() { + const clients = await self.clients.matchAll(); + for (const c of clients) c.postMessage({ type: "flowfi-synced", at: Date.now() }); +} + +function staleWhileRevalidate(request, cacheName, { announce = false } = {}) { + return caches.open(cacheName).then(async (cache) => { + const cached = await cache.match(request); + const network = fetch(request) + .then((response) => { + if (response && response.ok) { + cache.put(request, response.clone()); + if (announce) notifySynced(); + } + return response; + }) + .catch(() => undefined); + if (cached) { + network.catch(() => undefined); + return cached; + } + return (await network) || Response.error(); + }); +} + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (!/^https?:$/.test(url.protocol)) return; + + // Never cache event streams, auth or wallet traffic. + if (request.headers.get("accept") === "text/event-stream") return; + if (/\/(auth|events\/subscribe|sse)/.test(url.pathname)) return; + + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(PAGES_CACHE).then((c) => c.put(request, copy)); + return response; + }) + .catch(async () => (await caches.match(request)) || (await caches.match("/dashboard")) || (await caches.match("/")) || Response.error()), + ); + return; + } + + const isApi = /\/v1\/(streams|users|stats|events)/.test(url.pathname) || url.pathname.startsWith("/api/"); + if (isApi) { + event.respondWith(staleWhileRevalidate(request, API_CACHE, { announce: true })); + return; + } + + const isStatic = + url.origin === self.location.origin + ? url.pathname.startsWith("/_next/static/") || /\.(?:png|svg|ico|webp|woff2?|css|js)$/.test(url.pathname) + : /fonts\.(?:googleapis|gstatic)\.com$/.test(url.hostname); + if (isStatic) event.respondWith(staleWhileRevalidate(request, STATIC_CACHE)); +}); diff --git a/frontend/src/__tests__/i18n.test.tsx b/frontend/src/__tests__/i18n.test.tsx new file mode 100644 index 00000000..189eff0a --- /dev/null +++ b/frontend/src/__tests__/i18n.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { I18nProvider, useI18n, interpolate } from "@/context/i18n-provider"; +import { LanguageSelector } from "@/components/LanguageSelector"; +import { LOCALES, LOCALE_STORAGE_KEY, dictionaries, detectBrowserLocale, matchLocale } from "@/locales"; + +function Probe() { + const { t, formatNumber, formatDate, locale } = useI18n(); + return ( +
+ {t("nav.home")} + {formatNumber(1234567.5)} + {formatDate(Date.UTC(2024, 0, 15), { dateStyle: "long", timeZone: "UTC" })} + {locale} +
+ ); +} + +describe("locales", () => { + it("every locale defines exactly the English keys with non-empty values", () => { + const keys = Object.keys(dictionaries.en).sort(); + for (const l of LOCALES) { + expect(Object.keys(dictionaries[l]).sort(), l).toEqual(keys); + for (const v of Object.values(dictionaries[l])) expect(v.trim()).not.toBe(""); + } + }); + + it("keeps interpolation placeholders consistent across locales", () => { + for (const key of Object.keys(dictionaries.en) as (keyof typeof dictionaries.en)[]) { + const ph = (dictionaries.en[key].match(/\{\w+\}/g) ?? []).sort(); + for (const l of LOCALES) expect((dictionaries[l][key].match(/\{\w+\}/g) ?? []).sort(), `${l}:${key}`).toEqual(ph); + } + }); + + it("matches browser language tags to supported locales", () => { + expect(matchLocale("pt-BR")).toBe("pt"); + expect(matchLocale("zh-Hans-CN")).toBe("zh"); + expect(matchLocale("de")).toBeNull(); + expect(matchLocale(null)).toBeNull(); + expect(detectBrowserLocale({ language: "de", languages: ["de", "fr-CA"] })).toBe("fr"); + expect(detectBrowserLocale({ language: "de", languages: ["de"] })).toBe("en"); + }); + + it("interpolates variables and leaves unknown ones", () => { + expect(interpolate("{count} mins", { count: 5 })).toBe("5 mins"); + expect(interpolate("{a}{b}", { a: "x" })).toBe("x{b}"); + expect(interpolate("plain")).toBe("plain"); + }); +}); + +describe("I18nProvider", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.lang = ""; + }); + + it("uses the browser language when nothing is stored", async () => { + Object.defineProperty(window.navigator, "languages", { value: ["es-MX"], configurable: true }); + Object.defineProperty(window.navigator, "language", { value: "es-MX", configurable: true }); + await act(async () => { + render(); + }); + expect(screen.getByTestId("locale").textContent).toBe("es"); + expect(screen.getByTestId("home").textContent).toBe("Inicio"); + expect(document.documentElement.lang).toBe("es"); + }); + + it("prefers the stored locale, persists changes and localizes formatting", async () => { + localStorage.setItem(LOCALE_STORAGE_KEY, "fr"); + await act(async () => { + render(); + }); + expect(screen.getByTestId("home").textContent).toBe("Accueil"); + expect(screen.getByTestId("num").textContent).toMatch(/1\D234\D567,5/); + expect(screen.getByTestId("date").textContent).toContain("janvier"); + + fireEvent.change(screen.getByRole("combobox"), { target: { value: "ja" } }); + expect(screen.getByTestId("home").textContent).toBe("ホーム"); + expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe("ja"); + }); + + it("ignores an invalid stored locale", async () => { + localStorage.setItem(LOCALE_STORAGE_KEY, "xx"); + Object.defineProperty(window.navigator, "languages", { value: ["en-US"], configurable: true }); + Object.defineProperty(window.navigator, "language", { value: "en-US", configurable: true }); + await act(async () => { + render(); + }); + expect(screen.getByTestId("locale").textContent).toBe("en"); + }); + + it("falls back to English outside a provider", () => { + render(); + expect(screen.getByTestId("home").textContent).toBe("Home"); + }); +}); diff --git a/frontend/src/__tests__/offline.test.tsx b/frontend/src/__tests__/offline.test.tsx new file mode 100644 index 00000000..d724acdb --- /dev/null +++ b/frontend/src/__tests__/offline.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { OfflineBanner } from "@/components/OfflineBanner"; +import { LAST_SYNCED_KEY, minutesSince } from "@/hooks/useNetworkStatus"; + +function setOnline(value: boolean) { + Object.defineProperty(window.navigator, "onLine", { value, configurable: true }); +} + +describe("OfflineBanner", () => { + beforeEach(() => { + localStorage.clear(); + setOnline(true); + }); + + it("renders nothing while online and records a sync time", async () => { + await act(async () => { + render(); + }); + expect(screen.queryByTestId("offline-banner")).toBeNull(); + expect(Number(localStorage.getItem(LAST_SYNCED_KEY))).toBeGreaterThan(0); + }); + + it("shows the banner with last-synced age when the connection drops", async () => { + await act(async () => { + render(); + }); + localStorage.setItem(LAST_SYNCED_KEY, String(Date.now() - 7 * 60_000)); + setOnline(false); + await act(async () => { + window.dispatchEvent(new Event("offline")); + }); + const banner = screen.getByTestId("offline-banner"); + expect(banner).toHaveTextContent("You are offline"); + expect(banner).toHaveTextContent("Offline (Last synced 7 mins ago)"); + }); + + it("hides again on reconnect", async () => { + setOnline(false); + await act(async () => { + render(); + }); + expect(screen.getByTestId("offline-banner")).toHaveTextContent("never"); + setOnline(true); + await act(async () => { + window.dispatchEvent(new Event("online")); + }); + expect(screen.queryByTestId("offline-banner")).toBeNull(); + }); + + it("computes elapsed minutes", () => { + expect(minutesSince(0, 5 * 60_000 + 1)).toBe(5); + expect(minutesSince(10, 0)).toBe(0); + }); +}); + +describe("PWA assets", () => { + it("manifest is installable", async () => { + const fs = await import("node:fs"); + const m = JSON.parse(fs.readFileSync("public/manifest.webmanifest", "utf8")); + expect(m.display).toBe("standalone"); + expect(m.icons.map((i: { sizes: string }) => i.sizes)).toEqual(expect.arrayContaining(["192x192", "512x512"])); + for (const i of m.icons) expect(fs.existsSync(`public${i.src}`)).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/theme.test.tsx b/frontend/src/__tests__/theme.test.tsx new file mode 100644 index 00000000..d5ef16ab --- /dev/null +++ b/frontend/src/__tests__/theme.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { applyTheme, isThemeChoice, THEME_CHOICES } from "@/lib/themes"; + +const setTheme = vi.fn(); +let currentTheme = "dark"; +vi.mock("next-themes", () => ({ + useTheme: () => ({ theme: currentTheme, setTheme }), +})); + +import { ModeToggle } from "@/components/ModeToggle"; + +describe("ModeToggle", () => { + beforeEach(() => { + setTheme.mockClear(); + currentTheme = "dark"; + }); + + it("offers System, Light, Dark and High contrast", async () => { + await act(async () => { + render(); + await new Promise((r) => requestAnimationFrame(() => r(null))); + }); + const radios = screen.getAllByRole("radio"); + expect(radios.map((r) => r.getAttribute("aria-label"))).toEqual(["System", "Light", "Dark", "High contrast"]); + expect(screen.getByRole("radio", { name: "Dark" })).toHaveAttribute("aria-checked", "true"); + }); + + it("switches theme on click", async () => { + await act(async () => { + render(); + await new Promise((r) => requestAnimationFrame(() => r(null))); + }); + fireEvent.click(screen.getByRole("radio", { name: "High contrast" })); + expect(setTheme).toHaveBeenCalledWith("high-contrast"); + }); +}); + +describe("themes lib", () => { + it("validates theme choices", () => { + expect(THEME_CHOICES).toContain("high-contrast"); + expect(isThemeChoice("high-contrast")).toBe(true); + expect(isThemeChoice("neon")).toBe(false); + }); + + it("applies class + data-theme to the root", () => { + const root = document.createElement("html"); + applyTheme("high-contrast", root); + expect(root.dataset.theme).toBe("high-contrast"); + expect(root.classList.contains("high-contrast")).toBe(true); + applyTheme("light", root); + expect(root.classList.contains("high-contrast")).toBe(false); + expect(root.dataset.theme).toBe("light"); + }); + + it("resolves system via matchMedia", () => { + const root = document.createElement("html"); + window.matchMedia = vi.fn().mockReturnValue({ matches: true }) as unknown as typeof window.matchMedia; + applyTheme("system", root); + expect(root.dataset.theme).toBe("dark"); + }); +}); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2bae629b..3c1d25c0 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; -@custom-variant dark (&:where(.dark, .dark *)); +/* High-contrast is a dark-based theme, so `dark:` utilities apply to it too. */ +@custom-variant dark (&:where(.dark, .dark *, [data-theme="high-contrast"], [data-theme="high-contrast"] *)); .receipt-print { display: none; @@ -87,36 +88,92 @@ } :root { + /* + * Semantic design tokens (#1332). Components and the legacy variables + * below read from these; each theme only redefines the tokens. + */ + --color-bg-canvas: #eaf5ff; + --color-bg-canvas-alt: #fff4e6; + --color-bg-surface: rgba(255, 255, 255, 0.8); + --color-border-subtle: rgba(19, 38, 61, 0.12); + --color-text-primary: #13263d; + --color-text-secondary: #33475f; + --color-text-muted: #4c617e; + --color-brand-primary: #0f7a99; + --color-brand-accent: #10b981; + --color-focus-ring: #6366f1; + --background: #020617; --foreground: #f8fafc; - --accent: #10b981; + --accent: var(--color-brand-accent); --accent-hover: #059669; - --accent-secondary: #6366f1; + --accent-secondary: var(--color-focus-ring); --accent-tertiary: #0ea5e9; --glass: rgba(255, 255, 255, 0.02); --glass-border: rgba(255, 255, 255, 0.08); --glass-highlight: rgba(255, 255, 255, 0.12); - --bg-sky: #eaf5ff; - --bg-sand: #fff4e6; - --text-main: #13263d; - --text-muted: #4c617e; - --surface: rgba(255, 255, 255, 0.8); - --surface-border: rgba(19, 38, 61, 0.12); - --wallet-accent: #0f7a99; + --bg-sky: var(--color-bg-canvas); + --bg-sand: var(--color-bg-canvas-alt); + --text-main: var(--color-text-primary); + --text-muted: var(--color-text-muted); + --surface: var(--color-bg-surface); + --surface-border: var(--color-border-subtle); + --wallet-accent: var(--color-brand-primary); --accent-strong: #0d5378; --danger: #b12f3f; } :root.dark { + --color-bg-canvas: #0a1128; + --color-bg-canvas-alt: #131b2f; + --color-bg-surface: rgba(15, 23, 42, 0.8); + --color-border-subtle: rgba(255, 255, 255, 0.12); + --color-text-primary: #f1f5f9; + --color-text-secondary: #cbd5e1; + --color-text-muted: #94a3b8; + --color-brand-primary: #38bdf8; + --background: #020617; --foreground: #f8fafc; - --bg-sky: #0a1128; - --bg-sand: #131b2f; - --text-main: #f1f5f9; - --text-muted: #94a3b8; - --surface: rgba(15, 23, 42, 0.8); - --surface-border: rgba(255, 255, 255, 0.12); +} + +/* + * High-contrast theme (#1332): pure black/white with a yellow focus ring. + * Contrast on the #000 canvas: primary #fff 21:1, secondary #f0f0f0 18.9:1, + * muted #d9d9d9 15.7:1, brand #5cffb1 15.6:1, accent/focus #ffff00 19.6:1, + * borders #fff 21:1 - all above the WCAG AAA 7:1 threshold. + */ +[data-theme="high-contrast"] { + color-scheme: dark; + --color-bg-canvas: #000000; + --color-bg-canvas-alt: #000000; + --color-bg-surface: #000000; + --color-border-subtle: #ffffff; + --color-text-primary: #ffffff; + --color-text-secondary: #f0f0f0; + --color-text-muted: #d9d9d9; + --color-brand-primary: #5cffb1; + --color-brand-accent: #ffff00; + --color-focus-ring: #ffff00; + + --background: #000000; + --foreground: #ffffff; + --accent-hover: #ffe600; + --accent-tertiary: #5cffb1; + --glass: #000000; + --glass-border: #ffffff; + --glass-highlight: #ffffff; + --accent-strong: #ffff00; + --danger: #ff8a8a; +} + +[data-theme="light"] { + color-scheme: light; +} + +[data-theme="dark"] { + color-scheme: dark; } @theme inline { @@ -248,6 +305,63 @@ body { linear-gradient(160deg, var(--bg-sky) 0%, var(--bg-sand) 100%); } +[data-theme="high-contrast"] body { + background: #000; + color: #fff; +} + +/* + * Utility classes across the app hard-code slate/zinc/white-alpha colours. + * In high-contrast mode collapse them onto the token palette so every text, + * border and surface meets 7:1 without touching each component. + */ +[data-theme="high-contrast"] [class*="text-slate-"], +[data-theme="high-contrast"] [class*="text-gray-"], +[data-theme="high-contrast"] [class*="text-zinc-"], +[data-theme="high-contrast"] [class*="text-white/"], +[data-theme="high-contrast"] [class*="text-black"] { + color: var(--color-text-primary) !important; +} + +[data-theme="high-contrast"] [class*="border-white/"], +[data-theme="high-contrast"] [class*="border-black/"], +[data-theme="high-contrast"] [class*="border-slate-"], +[data-theme="high-contrast"] [class*="border-gray-"], +[data-theme="high-contrast"] [class*="border-zinc-"], +[data-theme="high-contrast"] [class*="border-glass"] { + border-color: var(--color-border-subtle) !important; +} + +[data-theme="high-contrast"] [class*="bg-white/"], +[data-theme="high-contrast"] [class*="bg-black/"], +[data-theme="high-contrast"] [class*="bg-slate-"], +[data-theme="high-contrast"] [class*="bg-gray-"], +[data-theme="high-contrast"] [class*="bg-zinc-"], +[data-theme="high-contrast"] [class*="from-"], +[data-theme="high-contrast"] [class*="via-"], +[data-theme="high-contrast"] [class*="to-"] { + background-image: none !important; + background-color: var(--color-bg-canvas) !important; +} + +[data-theme="high-contrast"] .blur-3xl { + display: none; +} + +[data-theme="high-contrast"] :is(button, a, input, select, textarea, [role="button"]) { + border-color: var(--color-border-subtle); +} + +[data-theme="high-contrast"] :is(button, input, select, textarea) { + border-width: 2px; + border-style: solid; +} + +[data-theme="high-contrast"] :focus-visible { + outline: 3px solid var(--color-focus-ring); + outline-offset: 3px; +} + .app-shell { min-height: 100vh; padding: clamp(1.2rem, 2.4vw, 2.8rem); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 7b2863ca..648ce38c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { IBM_Plex_Mono, Sora } from "next/font/google"; import React from "react"; @@ -6,6 +6,10 @@ import "./globals.css"; import { WalletProvider } from "@/context/wallet-context"; import { Toaster } from "react-hot-toast"; import { ThemeProvider } from "@/context/theme-provider"; +import { I18nProvider } from "@/context/i18n-provider"; +import { THEMES, THEME_STORAGE_KEY } from "@/lib/themes"; +import { OfflineBanner } from "@/components/OfflineBanner"; +import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import { Navbar } from "@/components/Navbar"; import { QueryProvider } from "@/components/providers/query-provider"; import { NetworkProvider } from "@/context/NetworkContext"; @@ -23,7 +27,13 @@ const mono = IBM_Plex_Mono({ weight: ["400", "500"], }); +export const viewport: Viewport = { + themeColor: "#020617", +}; + export const metadata: Metadata = { + manifest: "/manifest.webmanifest", + appleWebApp: { capable: true, title: "FlowFi", statusBarStyle: "black-translucent" }, title: "FlowFi | Real-time Payment Streams", description: "The trustless infrastructure to stream salaries, tokens, and rewards in real-time.", @@ -77,15 +87,19 @@ export default function RootLayout({ + + + + diff --git a/frontend/src/app/settings/settings-content.tsx b/frontend/src/app/settings/settings-content.tsx index 6bccad9a..c76c1b35 100644 --- a/frontend/src/app/settings/settings-content.tsx +++ b/frontend/src/app/settings/settings-content.tsx @@ -13,19 +13,29 @@ import { copyToClipboard } from "@/lib/clipboard"; import { DisconnectConfirmModal } from "@/components/wallet/DisconnectConfirmModal"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { SOUND_STORAGE_KEY } from "@/lib/transaction-feedback"; +import { applyTheme, isThemeChoice, type ThemeChoice } from "@/lib/themes"; +import { LanguageSelector } from "@/components/LanguageSelector"; +import { useI18n } from "@/context/i18n-provider"; type DisplayCurrency = "USD" | "EUR" | "GBP" | "XLM" | "USDC"; type AmountFormat = "full" | "compact"; type DecimalPlaces = 2 | 4 | 7; interface Settings { - theme: "light" | "dark" | "system"; + theme: ThemeChoice; displayCurrency: DisplayCurrency; amountFormat: AmountFormat; decimalPlaces: DecimalPlaces; transactionSounds: boolean; } +const THEME_LABEL_KEYS = { + system: "theme.system", + light: "theme.light", + dark: "theme.dark", + "high-contrast": "theme.highContrast", +} as const; + const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "1.0.0"; const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_STREAMING_CONTRACT || "CDV4K...7ZQY"; const INDEXER_URL = `${getApiBaseUrl()}/v1`; @@ -50,9 +60,9 @@ function loadSavedSettings(): Settings { if (typeof window === "undefined") return DEFAULT_SETTINGS; const savedDecimals = localStorage.getItem(STORAGE_KEYS.decimalPlaces); return { - theme: - (localStorage.getItem(STORAGE_KEYS.theme) as Settings["theme"]) || - DEFAULT_SETTINGS.theme, + theme: isThemeChoice(localStorage.getItem(STORAGE_KEYS.theme)) + ? (localStorage.getItem(STORAGE_KEYS.theme) as Settings["theme"]) + : DEFAULT_SETTINGS.theme, displayCurrency: (localStorage.getItem(STORAGE_KEYS.displayCurrency) as DisplayCurrency) || DEFAULT_SETTINGS.displayCurrency, @@ -68,12 +78,7 @@ function loadSavedSettings(): Settings { function applyThemeClass(theme: Settings["theme"]): void { if (typeof window === "undefined") return; - if (theme === "system") { - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - document.documentElement.classList.toggle("dark", prefersDark); - } else { - document.documentElement.classList.toggle("dark", theme === "dark"); - } + applyTheme(theme); } function persistSettings(settings: Settings): void { @@ -86,6 +91,7 @@ function persistSettings(settings: Settings): void { export default function SettingsContent() { const router = useRouter(); + const { t } = useI18n(); const { session, disconnect, isHydrated } = useWallet(); const [browserPush, setBrowserPush] = useState(false); @@ -207,10 +213,10 @@ export default function SettingsContent() {

- Settings + {t("settings.title")}

- Manage your FlowFi preferences + {t("settings.subtitle")}

@@ -280,30 +286,39 @@ export default function SettingsContent() {

- Appearance + {t("settings.appearance")}

- Choose your theme preference + {t("settings.appearanceHint")}

-
{(["light", "dark", "system"] as const).map((t) => ( +
{(["system", "light", "dark", "high-contrast"] as const).map((choice) => ( ))}
+ {/* Language */} +
+
+

{t("settings.language")}

+

{t("settings.languageHint")}

+
+ +
+ {/* Display Preferences */}
diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx new file mode 100644 index 00000000..ba58ff69 --- /dev/null +++ b/frontend/src/components/LanguageSelector.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Languages } from "lucide-react"; +import { useI18n } from "@/context/i18n-provider"; +import { LOCALES, LOCALE_LABELS, isLocale } from "@/locales"; + +export function LanguageSelector({ className = "" }: { className?: string }) { + const { locale, setLocale, t } = useI18n(); + + return ( + + ); +} diff --git a/frontend/src/components/ModeToggle.tsx b/frontend/src/components/ModeToggle.tsx index 4a95a771..2e3c117d 100644 --- a/frontend/src/components/ModeToggle.tsx +++ b/frontend/src/components/ModeToggle.tsx @@ -1,11 +1,23 @@ "use client"; -import { Moon, Sun } from "lucide-react"; +import { Contrast, Monitor, Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; +import { useI18n } from "@/context/i18n-provider"; +import { THEME_CHOICES, isThemeChoice, type ThemeChoice } from "@/lib/themes"; +import type { TranslationKey } from "@/locales"; + +const ICONS = { system: Monitor, light: Sun, dark: Moon, "high-contrast": Contrast } as const; +const LABEL_KEYS: Record = { + system: "theme.system", + light: "theme.light", + dark: "theme.dark", + "high-contrast": "theme.highContrast", +}; export function ModeToggle() { - const { theme, setTheme, resolvedTheme } = useTheme(); + const { theme, setTheme } = useTheme(); + const { t } = useI18n(); const [isMounted, setIsMounted] = useState(false); useEffect(() => { @@ -13,30 +25,37 @@ export function ModeToggle() { return () => cancelAnimationFrame(rafId); }, []); - const toggleTheme = () => { - setTheme(resolvedTheme === "light" ? "dark" : "light"); - }; - // Prevent hydration mismatch if (!isMounted) { return ( - + {session?.publicKey ?
{ setEditing(subscription); setSecretKey(undefined); setCreating(true); }} onToggle={async (subscription) => { await updateWebhook(subscription.id, session.publicKey, { isActive: !subscription.isActive }); await refresh(); }} onDelete={async (subscription) => { if (window.confirm("Delete this webhook endpoint?")) { await deleteWebhook(subscription.id, session.publicKey); await refresh(); } }} onDeliveries={setDelivery} />
:
Connect a wallet to manage developer webhooks.
}
{creating && session?.publicKey && { setCreating(false); setEditing(null); setSecretKey(undefined); }} onSave={async (data) => { await save(data); }} onRegenerate={editing ? async () => { const next = await regenerateWebhookSecret(editing.id, session.publicKey); setSecretKey(next); return next; } : undefined} />}{delivery && session?.publicKey && setDelivery(null)} />}; -} \ No newline at end of file +} diff --git a/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx b/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx index 2a1cbfae..eb589e3a 100644 --- a/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx +++ b/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx @@ -1,5 +1,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const renderWithQueryClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return render( + + {ui} + + ); +}; import userEvent from "@testing-library/user-event"; import React from "react"; @@ -124,7 +138,7 @@ function createMockStream() { describe("StreamDetailsContent loading skeleton", () => { beforeEach(() => { vi.clearAllMocks(); - global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [], total: 0 }) } as Response); origUseWallet.mockReturnValue({ session: mockSession, isHydrated: true, @@ -137,7 +151,7 @@ describe("StreamDetailsContent loading skeleton", () => { () => new Promise(() => {}) // never resolves ); - render(); + renderWithQueryClient(); // Should show skeleton elements, not a simple spinner const skeletonRegion = screen.getByRole("status"); @@ -152,17 +166,21 @@ describe("StreamDetailsContent loading skeleton", () => { it("transitions from skeleton to stream content when data loads successfully", async () => { const mockStream = createMockStream(); - vi.mocked(global.fetch) - .mockResolvedValueOnce({ + vi.mocked(global.fetch).mockImplementation(async (input) => { + const url = input.toString(); + if (url.includes("/events")) { + return { + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response; + } + return { ok: true, json: async () => mockStream, - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ events: [], total: 0 }), - } as Response); + } as Response; + }); - render(); + renderWithQueryClient(); // Initially shows skeleton expect(screen.getByRole("status")).toBeInTheDocument(); @@ -176,7 +194,7 @@ describe("StreamDetailsContent loading skeleton", () => { expect(screen.queryByRole("status")).not.toBeInTheDocument(); // Stream-specific content should be visible - expect(screen.getByText(/stream #42/i)).toBeInTheDocument(); + expect(screen.getAllByText(/stream #42/i).length).toBeGreaterThan(0); }); it("transitions from skeleton to not-found state when stream is confirmed missing", async () => { @@ -186,7 +204,7 @@ describe("StreamDetailsContent loading skeleton", () => { json: async () => ({ error: "Stream not found" }), } as Response); - render(); + renderWithQueryClient(); // Initially shows skeleton expect(screen.getByRole("status")).toBeInTheDocument(); @@ -210,7 +228,7 @@ describe("StreamDetailsContent loading skeleton", () => { json: async () => ({ error: "Stream not found" }), } as Response); - render(); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText(/stream not found/i)).toBeInTheDocument(); @@ -231,9 +249,13 @@ describe("StreamDetailsContent loading skeleton", () => { .mockResolvedValueOnce({ ok: true, json: async () => ({ events: [], total: 0 }), + } as Response) + .mockResolvedValue({ + ok: true, + json: async () => ({ events: [], total: 0 }), } as Response); - render(); + renderWithQueryClient(); // Once the stream loads, the Claimable stat card should show the value // returned by the shared hook, formatted in token units (stroops → XLM). @@ -250,18 +272,22 @@ describe("StreamDetailsContent loading skeleton", () => { async function renderLoaded(streamOverrides: Record = {}) { const mockStream = { ...createMockStream(), ...streamOverrides }; - vi.mocked(global.fetch) - .mockResolvedValueOnce({ + vi.mocked(global.fetch).mockImplementation(async (input) => { + const url = input.toString(); + if (url.includes("/events")) { + return { + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response; + } + return { ok: true, json: async () => mockStream, - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ events: [], total: 0 }), - } as Response); + } as Response; + }); const user = userEvent.setup(); - render(); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText(/stream details/i)).toBeInTheDocument(); @@ -293,7 +319,7 @@ describe("StreamDetailsContent handleWithdraw", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; - global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [], total: 0 }) } as Response); mockWalletForRecipient(); }); @@ -307,7 +333,7 @@ describe("StreamDetailsContent handleWithdraw", () => { await waitFor(() => { expect(mockSoroban.withdrawFromStream).toHaveBeenCalled(); }); - expect(mockToast.success).toHaveBeenCalledWith("Withdrawal successful!"); + expect(mockToast.success).toHaveBeenCalledWith("Withdrawal successful!", undefined); }); it("shows error toast when withdrawFromStream throws", async () => { @@ -342,7 +368,7 @@ describe("StreamDetailsContent handleTopUp", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; - global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [], total: 0 }) } as Response); // TopUp is only visible for the sender origUseWallet.mockReturnValue({ session: mockSession, @@ -369,7 +395,7 @@ describe("StreamDetailsContent handleTopUp", () => { await waitFor(() => { expect(mockSoroban.topUpStream).toHaveBeenCalled(); }); - expect(mockToast.success).toHaveBeenCalledWith("Stream topped up successfully!"); + expect(mockToast.success).toHaveBeenCalledWith("Stream topped up successfully!", undefined); }); it("shows error toast when topUpStream throws", async () => { @@ -400,7 +426,7 @@ describe("StreamDetailsContent handleTopUp", () => { const addFundsBtn = screen.getByRole("button", { name: /add funds/i }); await user.click(addFundsBtn); - expect(mockToast.error).toHaveBeenCalledWith("Please enter a valid amount"); + expect(mockToast.error).toHaveBeenCalledWith("Amount is required"); expect(mockSoroban.topUpStream).not.toHaveBeenCalled(); }); }); @@ -411,7 +437,7 @@ describe("StreamDetailsContent handlePause", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; - global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [], total: 0 }) } as Response); origUseWallet.mockReturnValue({ session: mockSession, isHydrated: true, @@ -431,7 +457,7 @@ describe("StreamDetailsContent handlePause", () => { { streamId: BigInt(STREAM_ID) }, ); }); - expect(mockToast.success).toHaveBeenCalledWith("Stream paused"); + expect(mockToast.success).toHaveBeenCalledWith("Stream paused", undefined); }); it("shows error toast when pauseStream throws", async () => { @@ -453,7 +479,7 @@ describe("StreamDetailsContent handleResume", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; - global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [], total: 0 }) } as Response); origUseWallet.mockReturnValue({ session: mockSession, isHydrated: true, @@ -473,7 +499,7 @@ describe("StreamDetailsContent handleResume", () => { { streamId: BigInt(STREAM_ID) }, ); }); - expect(mockToast.success).toHaveBeenCalledWith("Stream resumed"); + expect(mockToast.success).toHaveBeenCalledWith("Stream resumed", undefined); }); it("shows error toast when resumeStream throws", async () => { @@ -495,7 +521,7 @@ describe("StreamDetailsContent handleCancel", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; - global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [], total: 0 }) } as Response); origUseWallet.mockReturnValue({ session: mockSession, isHydrated: true, @@ -542,7 +568,7 @@ describe("StreamDetailsContent live-claimable interval", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; - global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [], total: 0 }) } as Response); }); it("shows live claimable indicator with a pulsing dot", async () => { diff --git a/frontend/src/components/TransactionTracker.test.tsx b/frontend/src/components/TransactionTracker.test.tsx index 4a5ba316..22c3e381 100644 --- a/frontend/src/components/TransactionTracker.test.tsx +++ b/frontend/src/components/TransactionTracker.test.tsx @@ -176,7 +176,7 @@ describe("TransactionTracker polling effect", () => { await Promise.resolve(); }); - expect(toast.success).toHaveBeenCalledWith("Stream created successfully!"); + expect(toast.success).toHaveBeenCalledWith("Stream created successfully!", undefined); // Capture fetch + first poll; any further polls would add more calls. expect(fetchMock).toHaveBeenCalledTimes(2); @@ -260,7 +260,7 @@ describe("delayed withdrawal confirmation (#1206)", () => { await vi.advanceTimersByTimeAsync(6_000); }); expect(onConfirmed).toHaveBeenCalledTimes(1); - expect(toast.success).toHaveBeenCalledWith("Withdrawn successfully!"); + expect(toast.success).toHaveBeenCalledWith("Withdrawn successfully!", undefined); }); it("checkConfirmation requires withdrawnAmount to exceed the baseline", () => { diff --git a/frontend/src/components/dashboard/DashboardIncoming.tsx b/frontend/src/components/dashboard/DashboardIncoming.tsx index 666aa40d..69f8b084 100644 --- a/frontend/src/components/dashboard/DashboardIncoming.tsx +++ b/frontend/src/components/dashboard/DashboardIncoming.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import IncomingStreams from "../IncomingStreams"; import type { Stream } from "@/lib/dashboard"; -import { InboxIcon } from "./dashboard-view"; import { BatchClaimDrawer } from "./BatchClaimDrawer"; import { ShareAddressModal } from "./ShareAddressModal"; import { Share2, Waves } from "lucide-react"; @@ -97,7 +96,7 @@ export function DashboardIncoming({ {/* Title and Description */}

No Incoming Streams Yet

- You haven't received any streaming payments yet. Share your payment address with a sender to start receiving continuous payments. + You haven't received any streaming payments yet. Share your payment address with a sender to start receiving continuous payments.

{/* Action Button */} diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index e3a79ab7..237b5b78 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -54,11 +54,7 @@ import { StreamDetailsModal } from "./StreamDetailsModal"; import { Button } from "../ui/Button"; import { CashflowProjectionChart } from "./CashflowProjectionChart"; -// @ts-expect-error unused var -const DashboardOverviewDynamic = dynamic( - () => import("./DashboardOverview").then((m) => m.DashboardOverview), - { ssr: false }, -); + const DashboardIncomingDynamic = dynamic( () => import("./DashboardIncoming").then((m) => m.DashboardIncoming), { ssr: false }, @@ -773,7 +769,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { incomingStreams={snapshot!.incomingStreams} onWithdraw={handleIncomingWithdraw} withdrawingStreamId={withdrawingIncomingStreamId} - onBatchClaimSuccess={refetchSnapshot} + onBatchClaimSuccess={() => void refetchSnapshot()} /> ); } diff --git a/frontend/src/components/stream-creation/ScheduleStep.tsx b/frontend/src/components/stream-creation/ScheduleStep.tsx index f328da8c..1261881a 100644 --- a/frontend/src/components/stream-creation/ScheduleStep.tsx +++ b/frontend/src/components/stream-creation/ScheduleStep.tsx @@ -1,30 +1,20 @@ "use client"; -import { useState, useEffect } from "react"; import { AlertTriangle } from "lucide-react"; +import { type StreamFormData } from "@/hooks/useStreamForm"; + interface ScheduleStepProps { - formData: { - duration: string; - durationUnit: string; - descriptionTag: string; - memo?: string; - }; + formData: StreamFormData; errors: Record; - onUpdate: (data: Partial) => void; + onUpdate: (data: Partial) => void; } const MAX_MEMO_BYTES = 28; export function ScheduleStep({ formData, errors, onUpdate }: ScheduleStepProps) { - const [memoByteCount, setMemoByteCount] = useState(0); const memo = formData.memo || ""; - - useEffect(() => { - // Calculate UTF-8 byte length - const bytes = new TextEncoder().encode(memo).length; - setMemoByteCount(bytes); - }, [memo]); + const memoByteCount = new TextEncoder().encode(memo).length; const isNearLimit = memoByteCount >= MAX_MEMO_BYTES * 0.8; const isOverLimit = memoByteCount > MAX_MEMO_BYTES; @@ -53,7 +43,7 @@ export function ScheduleStep({ formData, errors, onUpdate }: ScheduleStepProps) />