From 18be8ac6d6f81c671a101d23fca06dd6988d18e0 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:09:54 -0300 Subject: [PATCH 01/42] feat(pos-app): refresh theme colors, dark-mode contrast, and splash - Update accent color to #0666FF across light/dark palettes - Add foreground-primary-fix so cards/keyboard read against the dark bg - Add new splash logos (incl. Android-specific) and icon assets - Copy and layout polish across home, scan, payment, and settings screens Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/AGENTS.md | 4 +- dapps/pos-app/app.json | 10 +- dapps/pos-app/app/_layout.tsx | 8 +- dapps/pos-app/app/amount.tsx | 4 +- dapps/pos-app/app/index.tsx | 100 ++++++++++-------- dapps/pos-app/app/logs.tsx | 7 +- dapps/pos-app/app/payment-failure.tsx | 4 +- dapps/pos-app/app/payment-success.tsx | 15 ++- dapps/pos-app/app/scan.tsx | 85 +++++++++++---- dapps/pos-app/app/settings.tsx | 8 +- .../pos-app/assets/app_icons/splash_logo.png | Bin 0 -> 9934 bytes .../assets/app_icons/splash_logo_android.png | Bin 0 -> 13802 bytes .../assets/images/plus_circle_fill.png | Bin 0 -> 1753 bytes dapps/pos-app/assets/images/receipt.png | Bin 681 -> 811 bytes dapps/pos-app/components/card.tsx | 4 +- dapps/pos-app/components/filter-buttons.tsx | 4 +- dapps/pos-app/components/numeric-keyboard.tsx | 2 +- dapps/pos-app/components/pin-modal.tsx | 4 +- dapps/pos-app/components/radio-list.tsx | 2 +- dapps/pos-app/components/settings-item.tsx | 2 +- dapps/pos-app/components/transaction-card.tsx | 2 +- dapps/pos-app/constants/theme.ts | 36 ++++--- dapps/pos-app/hooks/use-merchant-flow.ts | 5 +- dapps/pos-app/utils/misc.test.ts | 47 ++++++-- dapps/pos-app/utils/misc.ts | 26 ++++- dapps/pos-app/utils/navigation.ts | 8 +- 26 files changed, 251 insertions(+), 136 deletions(-) create mode 100644 dapps/pos-app/assets/app_icons/splash_logo.png create mode 100644 dapps/pos-app/assets/app_icons/splash_logo_android.png create mode 100644 dapps/pos-app/assets/images/plus_circle_fill.png diff --git a/dapps/pos-app/AGENTS.md b/dapps/pos-app/AGENTS.md index 481c7b9be..c9b910ba4 100644 --- a/dapps/pos-app/AGENTS.md +++ b/dapps/pos-app/AGENTS.md @@ -98,8 +98,8 @@ Uses **Expo Router** with file-based routing: ### 1. Payment Flow 1. **Home Screen** (`app/index.tsx`) - - "New sale" button to start payment - - "Activity" button to view transaction history + - "New payment" button to start payment + - "Transactions" button to view transaction history - "Settings" button for configuration - Validates merchant setup before allowing payments diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json index 1a13f24f5..b016b1013 100644 --- a/dapps/pos-app/app.json +++ b/dapps/pos-app/app.json @@ -47,10 +47,14 @@ [ "expo-splash-screen", { - "image": "./assets/app_icons/splash_icon.png", - "imageWidth": 200, + "image": "./assets/app_icons/splash_logo.png", + "imageWidth": 270, "resizeMode": "contain", - "backgroundColor": "#000000" + "backgroundColor": "#000000", + "android": { + "image": "./assets/app_icons/splash_logo_android.png", + "imageWidth": 288 + } } ], [ diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index a7095f68b..b6ffe99b6 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -235,7 +235,13 @@ export default Sentry.wrap(function RootLayout() { }} > - + {isValid diff --git a/dapps/pos-app/app/index.tsx b/dapps/pos-app/app/index.tsx index 8b5b544d6..f06c27580 100644 --- a/dapps/pos-app/app/index.tsx +++ b/dapps/pos-app/app/index.tsx @@ -11,8 +11,8 @@ import { Platform, StyleSheet, View } from "react-native"; export default function HomeScreen() { const [assets] = useAssets([ - require("@/assets/images/plus.png"), - require("@/assets/images/clock.png"), + require("@/assets/images/plus_circle_fill.png"), + require("@/assets/images/receipt.png"), require("@/assets/images/gear.png"), ]); @@ -46,53 +46,66 @@ export default function HomeScreen() { testID="start-payment-button" onPress={handleStartPayment} style={[ - styles.actionButton, - { backgroundColor: Theme["foreground-primary"] }, + styles.baseActionButton, + { height: 320, width: "100%" }, + { backgroundColor: Theme["foreground-primary-fix"] }, ]} > - Start payment + + New payment + - - + + + ); } @@ -100,6 +113,7 @@ export default function HomeScreen() { const styles = StyleSheet.create({ container: { flex: 1, + width: "100%", paddingHorizontal: Spacing["spacing-5"], paddingTop: Spacing["spacing-2"], paddingBottom: Platform.OS === "web" ? 0 : Spacing["spacing-7"], @@ -107,14 +121,16 @@ const styles = StyleSheet.create({ alignItems: "center", gap: Spacing["spacing-3"], }, - actionButton: { - flex: 1, + baseActionButton: { justifyContent: "center", alignItems: "center", - width: "100%", borderRadius: BorderRadius["5"], gap: Spacing["spacing-4"], }, + actionButton: { + height: 140, + width: "48%", + }, actionButtonImage: { width: 32, height: 32, diff --git a/dapps/pos-app/app/logs.tsx b/dapps/pos-app/app/logs.tsx index 786b5e0bb..bfd3a1a39 100644 --- a/dapps/pos-app/app/logs.tsx +++ b/dapps/pos-app/app/logs.tsx @@ -39,7 +39,10 @@ function LogItem({ item }: { item: LogEntry }) { return ( @@ -94,7 +97,7 @@ export default function LogsScreen() { return ( - + Clear logs diff --git a/dapps/pos-app/app/payment-failure.tsx b/dapps/pos-app/app/payment-failure.tsx index 278f58b4a..292d4dc58 100644 --- a/dapps/pos-app/app/payment-failure.tsx +++ b/dapps/pos-app/app/payment-failure.tsx @@ -77,8 +77,8 @@ export default function PaymentFailureScreen() { ]} > Start payment diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index 52b11a647..7537def29 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -195,10 +195,9 @@ export default function PaymentSuccessScreen() { ]} > {isPrinting ? "Printing receipt…" : "Print receipt"} @@ -220,7 +219,9 @@ export default function PaymentSuccessScreen() { onPress={handleNewPayment} > Start new payment @@ -273,10 +274,6 @@ const styles = StyleSheet.create({ borderRadius: BorderRadius["5"], gap: Spacing["spacing-2"], }, - buttonText: { - fontSize: 16, - lineHeight: 18, - }, buttonIcon: { width: 16, height: 16, diff --git a/dapps/pos-app/app/scan.tsx b/dapps/pos-app/app/scan.tsx index 2b90a486c..1d1270020 100644 --- a/dapps/pos-app/app/scan.tsx +++ b/dapps/pos-app/app/scan.tsx @@ -15,7 +15,7 @@ import { formatAmountWithSymbol, getCurrency, } from "@/utils/currency"; -import { formatCountdown } from "@/utils/misc"; +import { formatCountdown, formatCountdownSpoken } from "@/utils/misc"; import { resetNavigation } from "@/utils/navigation"; import { AMOUNT_TOO_LOW, parseMinAmountCents } from "@/utils/payment-errors"; import { showErrorToast, showSuccessToast } from "@/utils/toast"; @@ -24,13 +24,17 @@ import * as Clipboard from "expo-clipboard"; import { Image } from "expo-image"; import { router, UnknownOutputParams, useLocalSearchParams } from "expo-router"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { StyleSheet, View } from "react-native"; +import { AccessibilityInfo, StyleSheet, View } from "react-native"; import { v4 as uuidv4 } from "uuid"; interface ScreenParams extends UnknownOutputParams { amount: string; } +// Remaining-seconds marks at which to announce the countdown to screen readers +// (descending). One minute left is the primary cue; 30s and 10s add urgency. +const COUNTDOWN_ANNOUNCE_THRESHOLDS = [60, 30, 10]; + export default function ScanScreen() { const params = useLocalSearchParams(); const [assets] = useAssets([ @@ -102,7 +106,7 @@ export default function ScanScreen() { [amount], ); - const handleOnClosePress = () => { + const handleOnCancelPress = () => { // Before the first status poll resolves, `paymentStatusData` is undefined // but the payment is already open at the gateway — cancel it then too. const status = paymentStatusData?.status; @@ -205,6 +209,32 @@ export default function ScanScreen() { onExpired: () => onFailure("expired"), }); + // The visible countdown is plain (non-live) text so screen readers don't + // announce every second. Instead we announce the remaining time only when it + // crosses these thresholds, giving low-vision users the urgency cue without + // the per-second chatter. + const announcedThresholdsRef = useRef>(new Set()); + useEffect(() => { + announcedThresholdsRef.current = new Set(); + }, [expiresAt]); + useEffect(() => { + if (!isCountdownActive) return; + const crossed = COUNTDOWN_ANNOUNCE_THRESHOLDS.filter( + (threshold) => remainingSeconds <= threshold, + ); + const hasNewCrossing = crossed.some( + (threshold) => !announcedThresholdsRef.current.has(threshold), + ); + if (hasNewCrossing) { + crossed.forEach((threshold) => + announcedThresholdsRef.current.add(threshold), + ); + AccessibilityInfo.announceForAccessibility( + `Payment expires in ${formatCountdownSpoken(remainingSeconds)}`, + ); + } + }, [remainingSeconds, isCountdownActive]); + const isProcessing = paymentStatusData?.status === "processing"; const showNfc = nfcEnabled && nfcMode === "hce"; @@ -216,17 +246,17 @@ export default function ScanScreen() { - Waiting for confirmation + Waiting for confirmation... - This usually takes a few seconds. + This usually takes a few seconds. Keep this screen open. @@ -237,7 +267,7 @@ export default function ScanScreen() { )} - Payment expires in + Expires in Cancel @@ -317,7 +363,7 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", gap: Spacing["spacing-6"], - paddingHorizontal: Spacing["spacing-5"], + paddingHorizontal: Spacing["spacing-7"], }, scanContainer: { flex: 1, @@ -364,18 +410,15 @@ const styles = StyleSheet.create({ justifyContent: "center", gap: Spacing["spacing-1"], }, - closeButton: { + cancelButton: { alignItems: "center", justifyContent: "center", borderRadius: BorderRadius["4"], marginHorizontal: Spacing["spacing-5"], + borderWidth: 1, height: 48, }, nfcIcon: { - // The artwork is not centered within its bounding box (the hand holding the - // card sits to the right), so the unbalanced marginLeft nudges it back to - // optically align with the amount text below it. Intentional — do not add a - // matching marginRight. marginLeft: Spacing["spacing-5"], width: 80, height: 60, diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index f055b5e42..4a5c3ff5c 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -439,8 +439,8 @@ export default function SettingsScreen() { ]} > @@ -494,8 +494,8 @@ export default function SettingsScreen() { ]} > diff --git a/dapps/pos-app/assets/app_icons/splash_logo.png b/dapps/pos-app/assets/app_icons/splash_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f51afda0dba1a927f8a15dfe9bcb04911fb3b3a6 GIT binary patch literal 9934 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 literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/plus_circle_fill.png b/dapps/pos-app/assets/images/plus_circle_fill.png new file mode 100644 index 0000000000000000000000000000000000000000..2abb8fb4887796e9129820a356d132a258220fd8 GIT binary patch literal 1753 zcmV;~1}6E5P)k;h$B|=3WX>M!X>A2 zP9n|W0jJ`KD90$`NK6(^!z~L3LiW|vk{#I+Qo*DFkIuGL1_zTuW!+T zjj}aKmxJl=h={!|X?AOIq=L!ym~Bs&yTWOTsZN-eeGh&xN6j6q|1z1FR% zI~$$Hl)c^5b=K8c&M}m+Ij&0%DlJE>W{!B^rjcK%a3$|E;&H{?ea~IpdDYFzwl&g* zdowe{!5NiVx$TPn{|tpskDPhSA4*}BTLI=mSIG+$N&c`FT7AHl#q;L$2M~DRJndE6ODcrK)RQiATb2kS4nAWYsNuRCnms+(fvMUa_63QPg zg&$J%qv?xF5pSF`?AC6e1P50LSsds2yVA(5CM=GD5*i=)hgwdU6qX}vQ^qnHe_Ob7 zYj-b{U|14ouyrYw-Wa>mt`#ST5(>FJxfMtDkI}dj23PiEx9XzemE4|`#hF9#%~g5I zds!UL5Y)B#WobE^J8?v@b11%8tnSaV+7o&S#TWO!`z3KCYs(Q2NzeNsac^`_)fj>^ z#KYQ2U($mai#U1J7BBb2kuh2ZagfP?za?bO(L2OL<(Rm2t43vr>%-0QEt%oz6TC2a zia21aIjFj^cDa1dV+*#4XY0m6)QwvkVi34e&%ADc%35(E%-mw6cKmhIh)=d2Z8aE& z#;q)C7yVukyxk(5{tRVESeI$jn;OST(;qp7y0@V@X!EAF1KHKR4M*#pd0TDKBJS-! zI(-SH51gI7Y5V5Z`{$58?@1u87fqma!BCm5uH;+CMmtaO^139@Q|Q7Hn_^Jx+xBsj zU+OY>il=YrP^$KDte&=wGi7$r0clv|=@n{0%{;4X1*oIbv_t7Ny?1rz)R4+=nr7c(PzduL1JGL> zi=^2+OKw}+;kdJJx@o#xN3WPug~TBrvyXkj@#RF?AO~eI%dtpP4?jh`f6Y&L*l;E` zm2nq?Gq~lHkr6OF79qvMg01C@M44ekMis|GBF#{&S(YMxauoF!gOPC}4kNK>20shS z4MC0~w&ZYxGjS{>k$7476ycM@u_zYj;#fqa35o@OQiO#a)PLzi6pJ@;%1Gjn!LK#3 zAe?0$$W)*#%{;?=T4bk->iGH?)dT>_qAeARXk~#F4!&E~w=FJP$ zKT{SS&+7lyH*MvQckM#Q&M>>)wcP8<_UA-%eakPoHlNvh-ZaS9pHe!LAnm4bCStzx z^So!iKHE=PUC7oH{S>%R?x;y}72j89)=Nk9o}O=>Qa&TRRqpUX=b7~yt+F?gRV!py z^)zZFFE}N!Vv}Z`?OzsSd;P3$(f*qEpBR6VYFqPu3RmQm6OWnpPxxg1MR&%=yie9o zj+a<1D_u}8`YHI}gvErCHU#jSjqhDh5eDhMNd64EUTWa`OH77 zUOnM*sR}=D$r>TW7C~!U*SWr%c)lDt-*S1LeOBP}o#!03>d&4dVt+fYdDqD!OWy1_ zz2IC*l=}+j!weA>e;L27fBEI9(b~&fPFkzJVFuii_VP@X^OC>8GIv6B)m~A?k z$5{GH$2@kuFXtAHsr3gg>m;-bip4yiuGzMzvjYOeUJFWYl9Nt3b5IbNd>K4l{an^L HB{Ts5X7Ec zB7<;UcTp;p1~6nDe(%Kvq5PN(e^Z`091cx9RaBqD?;|TPnM{0G`9%g(E|(v|tKfzk zvDbVuocOu)@;V%k#{u@=iV6T*rsg=#I7=qi09L-;ZZD|-X0sVjV5W>qe;jK-(d3?i zBskb@Hk&4@Rnh}`?=`94$P)FgDe}4SwbSj;D zNgIG*ce9v)ZM2r{BW(bOarovDs)F>vV-D_i2D;tu5XF!Tz{Y{1QQAWA7JWKxAuVwL z2)N}1a;}kNKlv5lI|GV0(x*J*;}8!yK$0ZV2H?V@Q}GBYcW`L~I-L&30YUl^DrvjY z8QAal4Xi{R%kGmN)|EbXO+jy|p|O`C*H5eR!6S zh=AiXg6b>U>cP7uicy-|3+@b8%X2+RpYS-j2GGgp`; @@ -27,7 +27,7 @@ export function Card({ children, onPress, style }: Props) { diff --git a/dapps/pos-app/components/filter-buttons.tsx b/dapps/pos-app/components/filter-buttons.tsx index b774c89b4..f67969d37 100644 --- a/dapps/pos-app/components/filter-buttons.tsx +++ b/dapps/pos-app/components/filter-buttons.tsx @@ -29,7 +29,7 @@ function FilterButtonsBase({ onPress={onStatusPress} style={[ styles.button, - { backgroundColor: theme["foreground-primary"] }, + { backgroundColor: theme["foreground-primary-fix"] }, ]} > @@ -48,7 +48,7 @@ function FilterButtonsBase({ onPress={onDateRangePress} style={[ styles.button, - { backgroundColor: theme["foreground-primary"] }, + { backgroundColor: theme["foreground-primary-fix"] }, ]} > diff --git a/dapps/pos-app/components/numeric-keyboard.tsx b/dapps/pos-app/components/numeric-keyboard.tsx index d4962a8bc..db47734ec 100644 --- a/dapps/pos-app/components/numeric-keyboard.tsx +++ b/dapps/pos-app/components/numeric-keyboard.tsx @@ -36,7 +36,7 @@ function NumericKeyboardBase({ onKeyPress, style }: NumericKeyboardProps) { onPress={() => handlePress(key)} style={[ styles.key, - { backgroundColor: Theme["foreground-primary"] }, + { backgroundColor: Theme["foreground-primary-fix"] }, ]} > {key === "erase" ? ( diff --git a/dapps/pos-app/components/pin-modal.tsx b/dapps/pos-app/components/pin-modal.tsx index 795aeb3ab..8f926b588 100644 --- a/dapps/pos-app/components/pin-modal.tsx +++ b/dapps/pos-app/components/pin-modal.tsx @@ -204,7 +204,7 @@ function PinModalBase({ activeOpacity={0.7} style={[ styles.key, - { backgroundColor: theme["foreground-primary"] }, + { backgroundColor: theme["foreground-primary-fix"] }, ]} > {key === "erase" ? ( @@ -238,7 +238,7 @@ function PinModalBase({ activeOpacity={0.7} style={[ styles.cancelButton, - { backgroundColor: theme["foreground-secondary"] }, + { backgroundColor: theme["foreground-primary-fix"] }, ]} > diff --git a/dapps/pos-app/components/radio-list.tsx b/dapps/pos-app/components/radio-list.tsx index 7ccc51747..122068e9c 100644 --- a/dapps/pos-app/components/radio-list.tsx +++ b/dapps/pos-app/components/radio-list.tsx @@ -39,7 +39,7 @@ export function RadioList({ { backgroundColor: isSelected ? Theme["foreground-accent-primary-10"] - : Theme["foreground-primary"], + : Theme["foreground-primary-fix"], borderColor: isSelected ? Theme["bg-accent-primary"] : "transparent", diff --git a/dapps/pos-app/components/settings-item.tsx b/dapps/pos-app/components/settings-item.tsx index 86b59b2d7..6f6d96a6d 100644 --- a/dapps/pos-app/components/settings-item.tsx +++ b/dapps/pos-app/components/settings-item.tsx @@ -34,7 +34,7 @@ export function SettingsItem({ testID={testID} style={[ styles.container, - { backgroundColor: Theme["foreground-primary"] }, + { backgroundColor: Theme["foreground-primary-fix"] }, disabled && { opacity: 0.4 }, ]} > diff --git a/dapps/pos-app/components/transaction-card.tsx b/dapps/pos-app/components/transaction-card.tsx index 45c8b169b..0749e79ed 100644 --- a/dapps/pos-app/components/transaction-card.tsx +++ b/dapps/pos-app/components/transaction-card.tsx @@ -27,7 +27,7 @@ function TransactionCardBase({ onPress={onPress} style={[ styles.container, - { backgroundColor: theme["foreground-primary"] }, + { backgroundColor: theme["foreground-primary-fix"] }, style, ]} > diff --git a/dapps/pos-app/constants/theme.ts b/dapps/pos-app/constants/theme.ts index a81faea8d..acd61a193 100644 --- a/dapps/pos-app/constants/theme.ts +++ b/dapps/pos-app/constants/theme.ts @@ -4,24 +4,27 @@ export const Colors = { light: { // Foreground colors "foreground-primary": "#F3F3F3", + // Same as foreground-primary in light; steps up to foreground-secondary in + // dark so card/keyboard surfaces stay distinguishable from bg-primary. + "foreground-primary-fix": "#F3F3F3", "foreground-secondary": "#E9E9E9", "foreground-tertiary": "#D0D0D0", - "foreground-accent-primary-10": "#0988F01A", // 10% opacity - "foreground-accent-primary-40": "#0988F040", // 40% opacity - "foreground-accent-primary-60": "#0988F060", // 60% opacity + "foreground-accent-primary-10": "#0666FF1A", // 10% opacity + "foreground-accent-primary-40": "#0666FF40", // 40% opacity + "foreground-accent-primary-60": "#0666FF60", // 60% opacity // Icon colors "icon-default": "#9A9A9A", "icon-invert": "#202020", "icon-success": "#30A46B", - "icon-accent-primary": "#0988F0", + "icon-accent-primary": "#0666FF", "icon-error": "#DF4A34", // Background colors "bg-primary": "#FFFFFF", "bg-invert": "#202020", - "bg-accent-primary": "#0988F0", - "bg-payment-success": "#0988F0", + "bg-accent-primary": "#0666FF", + "bg-payment-success": "#0666FF", // Text colors "text-primary": "#202020", @@ -35,35 +38,38 @@ export const Colors = { "border-primary": "#E9E9E9", "border-secondary": "#D0D0D0", "border-payment-success": "#E9E9E9", - "border-accent-primary": "#0988F0", + "border-accent-primary": "#0666FF", }, dark: { // Foreground colors "foreground-primary": "#252525", + // Stepped up to foreground-secondary so card/keyboard surfaces read against + // the near-black bg-primary (#252525 was effectively invisible). + "foreground-primary-fix": "#2A2A2A", "foreground-secondary": "#2A2A2A", "foreground-tertiary": "#363636", - "foreground-accent-primary-10": "#0988F01A", // 10% opacity - "foreground-accent-primary-40": "#0988F040", // 40% opacity - "foreground-accent-primary-60": "#0988F060", // 60% opacity + "foreground-accent-primary-10": "#0666FF1A", // 10% opacity + "foreground-accent-primary-40": "#0666FF40", // 40% opacity + "foreground-accent-primary-60": "#0666FF60", // 60% opacity // Icon colors "icon-default": "#9A9A9A", "icon-invert": "#FFFFFF", "icon-success": "#30A46B", - "icon-accent-primary": "#0988F0", + "icon-accent-primary": "#0666FF", "icon-error": "#DF4A34", // Background colors "bg-primary": "#202020", "bg-invert": "#FFFFFF", - "bg-accent-primary": "#0988F0", - "bg-payment-success": "#0988F0", + "bg-accent-primary": "#0666FF", + "bg-payment-success": "#0666FF", // Text colors "text-primary": "#FFFFFF", "text-secondary": "#9A9A9A", "text-tertiary": "#BBBBBB", - "text-invert": "#202020", + "text-invert": "#181818", "text-white": "#FFFFFF", "text-payment-success": "#FFFFFF", @@ -71,7 +77,7 @@ export const Colors = { "border-primary": "#363636", "border-secondary": "#4F4F4F", "border-payment-success": "#E9E9E9", - "border-accent-primary": "#0988F0", + "border-accent-primary": "#0666FF", }, }; diff --git a/dapps/pos-app/hooks/use-merchant-flow.ts b/dapps/pos-app/hooks/use-merchant-flow.ts index 3941792cf..82d1fd071 100644 --- a/dapps/pos-app/hooks/use-merchant-flow.ts +++ b/dapps/pos-app/hooks/use-merchant-flow.ts @@ -1,5 +1,6 @@ import { useLogsStore } from "@/store/useLogsStore"; import { useSettingsStore } from "@/store/useSettingsStore"; +import { formatCountdown } from "@/utils/misc"; import { showErrorToast, showSuccessToast } from "@/utils/toast"; import { useCallback, useEffect, useState } from "react"; @@ -61,9 +62,7 @@ export function useMerchantFlow() { const formatLockoutMessage = useCallback(() => { const remaining = getLockoutRemainingSeconds(); - const minutes = Math.floor(remaining / 60); - const seconds = remaining % 60; - return `Too many failed attempts. Try again in ${minutes}:${seconds.toString().padStart(2, "0")}`; + return `Too many failed attempts. Try again in ${formatCountdown(remaining)}`; }, [getLockoutRemainingSeconds]); const handleMerchantIdInputChange = useCallback((value: string) => { diff --git a/dapps/pos-app/utils/misc.test.ts b/dapps/pos-app/utils/misc.test.ts index ae962d595..0c686753c 100644 --- a/dapps/pos-app/utils/misc.test.ts +++ b/dapps/pos-app/utils/misc.test.ts @@ -1,4 +1,9 @@ -import { formatCountdown, getDate, getDeviceIdentifier } from "./misc"; +import { + formatCountdown, + formatCountdownSpoken, + getDate, + getDeviceIdentifier, +} from "./misc"; // Mock react-native-device-info const mockGetUniqueId = jest.fn(); @@ -78,20 +83,40 @@ describe("getDeviceIdentifier", () => { }); describe("formatCountdown", () => { - it("formats minutes and seconds with M:SSs format", () => { - expect(formatCountdown(312)).toBe("5:12s"); - expect(formatCountdown(65)).toBe("1:05s"); - expect(formatCountdown(0)).toBe("0:00s"); - expect(formatCountdown(9)).toBe("0:09s"); - expect(formatCountdown(60)).toBe("1:00s"); - expect(formatCountdown(599)).toBe("9:59s"); + it("formats minutes and seconds with M:SS colon notation and no suffix", () => { + expect(formatCountdown(312)).toBe("5:12"); + expect(formatCountdown(65)).toBe("1:05"); + expect(formatCountdown(45)).toBe("0:45"); + expect(formatCountdown(0)).toBe("0:00"); + expect(formatCountdown(9)).toBe("0:09"); + expect(formatCountdown(60)).toBe("1:00"); + expect(formatCountdown(599)).toBe("9:59"); }); - it("clamps negative values to 0:00s", () => { - expect(formatCountdown(-5)).toBe("0:00s"); + it("clamps negative values to 0:00", () => { + expect(formatCountdown(-5)).toBe("0:00"); }); it("floors fractional seconds", () => { - expect(formatCountdown(65.8)).toBe("1:05s"); + expect(formatCountdown(65.8)).toBe("1:05"); + }); +}); + +describe("formatCountdownSpoken", () => { + it("spells out minutes and seconds for screen readers", () => { + expect(formatCountdownSpoken(312)).toBe("5 minutes 12 seconds"); + expect(formatCountdownSpoken(65)).toBe("1 minute 5 seconds"); + }); + + it("singularizes and omits zero units", () => { + expect(formatCountdownSpoken(60)).toBe("1 minute"); + expect(formatCountdownSpoken(61)).toBe("1 minute 1 second"); + expect(formatCountdownSpoken(45)).toBe("45 seconds"); + expect(formatCountdownSpoken(1)).toBe("1 second"); + }); + + it("reads zero (and negatives) as 0 seconds", () => { + expect(formatCountdownSpoken(0)).toBe("0 seconds"); + expect(formatCountdownSpoken(-5)).toBe("0 seconds"); }); }); diff --git a/dapps/pos-app/utils/misc.ts b/dapps/pos-app/utils/misc.ts index b71a5d89c..66d87ab6d 100644 --- a/dapps/pos-app/utils/misc.ts +++ b/dapps/pos-app/utils/misc.ts @@ -52,12 +52,32 @@ export function formatDateTime(dateString?: string): string { } /** - * Formats a number of seconds into "M:SSs" display format. - * Examples: 312 -> "5:12s", 65 -> "1:05s", 9 -> "0:09s" + * Formats a number of seconds into "M:SS" display format (colon notation, no + * suffix). Examples: 312 -> "5:12", 65 -> "1:05", 45 -> "0:45", 9 -> "0:09" */ export function formatCountdown(totalSeconds: number): string { const clamped = Math.max(0, Math.floor(totalSeconds)); const minutes = Math.floor(clamped / 60); const seconds = clamped % 60; - return `${minutes}:${String(seconds).padStart(2, "0")}s`; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +/** + * Formats a number of seconds into a screen-reader-friendly phrase, spelled out + * so assistive tech doesn't read "5:12" as "five colon twelve". + * Examples: 312 -> "5 minutes 12 seconds", 65 -> "1 minute 5 seconds", + * 60 -> "1 minute", 45 -> "45 seconds", 1 -> "1 second", 0 -> "0 seconds". + */ +export function formatCountdownSpoken(totalSeconds: number): string { + const clamped = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(clamped / 60); + const seconds = clamped % 60; + const parts: string[] = []; + if (minutes > 0) { + parts.push(`${minutes} minute${minutes === 1 ? "" : "s"}`); + } + if (seconds > 0 || minutes === 0) { + parts.push(`${seconds} second${seconds === 1 ? "" : "s"}`); + } + return parts.join(" "); } diff --git a/dapps/pos-app/utils/navigation.ts b/dapps/pos-app/utils/navigation.ts index 362db5763..78183c4e9 100644 --- a/dapps/pos-app/utils/navigation.ts +++ b/dapps/pos-app/utils/navigation.ts @@ -19,10 +19,6 @@ export const getHeaderTintColor = ( : "text-primary"; }; -export const resetNavigation = (href?: Href) => { - router.dismissAll(); - router.replace("/"); - if (href) { - router.navigate(href); - } +export const resetNavigation = (href: Href = "/") => { + router.dismissTo(href); }; From b8987c5bb56dfd14895fe1d91347b40a09e86047 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:10:07 -0300 Subject: [PATCH 02/42] refactor(pos-app): remove wallet theme variants, keep default only Drop all branded wallet theme variants and the Settings picker, leaving the app on the default theme. The variant machinery (Variant type, Variants record, VariantList, store field, receipt/header logo hooks) is kept so a variant can be re-added later; a comment in variants.ts documents the steps. - Narrow VariantName to "default" and reduce Variants to the default entry - Remove the "Wallet theme" SettingsItem, its bottom sheet, and lock logic - Simplify getMergedTheme to return the base palette (merge kept as a comment) - Bump settings persist version to 17, migrating stale variants to default - Delete branded brand PNGs and the unused MONEY2020 printer logo - Update store tests for default-only + a migration reset test Co-Authored-By: Claude Opus 4.8 --- .../__tests__/store/useSettingsStore.test.ts | 87 ++------ dapps/pos-app/app/settings.tsx | 42 ---- .../assets/images/variants/binance_brand.png | Bin 6133 -> 0 bytes .../assets/images/variants/imin_brand.png | Bin 5048 -> 0 bytes .../assets/images/variants/ledger_brand.png | Bin 2405 -> 0 bytes .../images/variants/money2020_brand.png | Bin 5086 -> 0 bytes .../assets/images/variants/phantom_brand.png | Bin 8423 -> 0 bytes .../assets/images/variants/solana_brand.png | Bin 7002 -> 0 bytes .../assets/images/variants/solflare_brand.png | Bin 10409 -> 0 bytes .../assets/images/variants/trezor_brand.png | Bin 2757 -> 0 bytes .../assets/images/variants/xmoney_brand.png | Bin 10178 -> 0 bytes dapps/pos-app/constants/printer-logos.ts | 3 - dapps/pos-app/constants/variants.ts | 192 +----------------- dapps/pos-app/hooks/use-theme-color.ts | 26 +-- dapps/pos-app/store/useSettingsStore.ts | 8 +- 15 files changed, 44 insertions(+), 314 deletions(-) delete mode 100644 dapps/pos-app/assets/images/variants/binance_brand.png delete mode 100644 dapps/pos-app/assets/images/variants/imin_brand.png delete mode 100644 dapps/pos-app/assets/images/variants/ledger_brand.png delete mode 100644 dapps/pos-app/assets/images/variants/money2020_brand.png delete mode 100644 dapps/pos-app/assets/images/variants/phantom_brand.png delete mode 100644 dapps/pos-app/assets/images/variants/solana_brand.png delete mode 100644 dapps/pos-app/assets/images/variants/solflare_brand.png delete mode 100644 dapps/pos-app/assets/images/variants/trezor_brand.png delete mode 100644 dapps/pos-app/assets/images/variants/xmoney_brand.png diff --git a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts index 1621fc853..44c9dbe3f 100644 --- a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts +++ b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts @@ -1,8 +1,5 @@ import { useSettingsStore } from "@/store/useSettingsStore"; -import { - DEFAULT_LOGO_BASE64, - MONEY2020_LOGO_BASE64, -} from "@/constants/printer-logos"; +import { DEFAULT_LOGO_BASE64 } from "@/constants/printer-logos"; import { resetSettingsStore } from "../utils/store-helpers"; // Get the mocked secure store @@ -90,73 +87,17 @@ describe("useSettingsStore", () => { }); describe("setVariant", () => { - it("should set variant", () => { + it("should set the default variant", () => { const { setVariant } = useSettingsStore.getState(); - setVariant("solflare"); + setVariant("default"); - expect(useSettingsStore.getState().variant).toBe("solflare"); - }); - - it("should update theme when variant has defaultTheme", () => { - // Solflare variant has defaultTheme: "dark" - const { setVariant } = useSettingsStore.getState(); - - // Start with light theme - useSettingsStore.getState().setThemeMode("light"); - expect(useSettingsStore.getState().themeMode).toBe("light"); - - // Set solflare variant which has dark as default - setVariant("solflare"); - - expect(useSettingsStore.getState().variant).toBe("solflare"); - expect(useSettingsStore.getState().themeMode).toBe("dark"); - }); - - it("should support all variant types", () => { - const variants = [ - "default", - "solflare", - "binance", - "phantom", - "solana", - "trezor", - "ledger", - ] as const; - - variants.forEach((variantName) => { - useSettingsStore.getState().setVariant(variantName); - expect(useSettingsStore.getState().variant).toBe(variantName); - }); + expect(useSettingsStore.getState().variant).toBe("default"); }); }); describe("getVariantPrinterLogo", () => { - it("should return the default logo for variants without a printerLogo", () => { - useSettingsStore.getState().setVariant("default"); - expect(useSettingsStore.getState().getVariantPrinterLogo()).toBe( - DEFAULT_LOGO_BASE64, - ); - - useSettingsStore.getState().setVariant("solflare"); - expect(useSettingsStore.getState().getVariantPrinterLogo()).toBe( - DEFAULT_LOGO_BASE64, - ); - }); - - it("should return the variant's printerLogo when set", () => { - useSettingsStore.getState().setVariant("money2020"); - expect(useSettingsStore.getState().getVariantPrinterLogo()).toBe( - MONEY2020_LOGO_BASE64, - ); - }); - - it("should reflect the current variant when it changes", () => { - useSettingsStore.getState().setVariant("money2020"); - expect(useSettingsStore.getState().getVariantPrinterLogo()).toBe( - MONEY2020_LOGO_BASE64, - ); - + it("should return the default logo for the default variant", () => { useSettingsStore.getState().setVariant("default"); expect(useSettingsStore.getState().getVariantPrinterLogo()).toBe( DEFAULT_LOGO_BASE64, @@ -530,14 +471,14 @@ describe("useSettingsStore", () => { useSettingsStore.getState().setThemeMode("dark"); useSettingsStore.getState().setMerchantId("merchant-persist-123"); useSettingsStore.getState().setDeviceId("device-persist-456"); - useSettingsStore.getState().setVariant("solflare"); + useSettingsStore.getState().setVariant("default"); // Verify all values are maintained const state = useSettingsStore.getState(); expect(state.themeMode).toBe("dark"); expect(state.merchantId).toBe("merchant-persist-123"); expect(state.deviceId).toBe("device-persist-456"); - expect(state.variant).toBe("solflare"); + expect(state.variant).toBe("default"); }); it("should track hydration state correctly", () => { @@ -586,7 +527,7 @@ describe("useSettingsStore", () => { // Change other settings useSettingsStore.getState().setThemeMode("dark"); - useSettingsStore.getState().setVariant("binance"); + useSettingsStore.getState().setVariant("default"); // Biometric should still be enabled expect(useSettingsStore.getState().biometricEnabled).toBe(true); @@ -598,7 +539,7 @@ describe("useSettingsStore", () => { // Check persist name and version are set (for storage key) expect(persistOptions?.name).toBe("settings"); - expect(persistOptions?.version).toBe(16); + expect(persistOptions?.version).toBe(17); // Verify storage is configured (MMKV in production, mock in tests) expect(persistOptions?.storage).toBeDefined(); @@ -611,5 +552,15 @@ describe("useSettingsStore", () => { expect(typeof useSettingsStore.persist.hasHydrated).toBe("function"); expect(typeof useSettingsStore.persist.getOptions).toBe("function"); }); + + it("should reset a stale branded variant to default on migration", () => { + const migrate = useSettingsStore.persist?.getOptions?.().migrate; + expect(migrate).toBeDefined(); + + // Simulate persisted state from before variants were removed. + const migrated: any = migrate!({ variant: "solflare" }, 16); + + expect(migrated.variant).toBe("default"); + }); }); }); diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index 4a5c3ff5c..659e60852 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -7,7 +7,6 @@ import { SettingsItem } from "@/components/settings-item"; import { Switch } from "@/components/switch"; import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; -import { VariantList, VariantName, Variants } from "@/constants/variants"; import { useBiometricAuth } from "@/hooks/use-biometric-auth"; import { useMerchantFlow } from "@/hooks/use-merchant-flow"; import { useNfcCapabilities } from "@/hooks/use-nfc-capabilities"; @@ -33,7 +32,6 @@ import { ScrollView } from "react-native-gesture-handler"; type ActiveSheet = | "theme" - | "walletTheme" | "currency" | "merchantId" | "customerApiKey" @@ -67,7 +65,6 @@ export default function SettingsScreen() { const themeMode = useSettingsStore((state) => state.themeMode); const setThemeMode = useSettingsStore((state) => state.setThemeMode); const variant = useSettingsStore((state) => state.variant); - const setVariant = useSettingsStore((state) => state.setVariant); const getVariantPrinterLogo = useSettingsStore( (state) => state.getVariantPrinterLogo, ); @@ -113,15 +110,6 @@ export default function SettingsScreen() { handleCancelSecurityFlow, } = useMerchantFlow(); - const variantOptions: RadioOption[] = useMemo( - () => - VariantList.map((v) => ({ - value: v.id, - label: v.name, - })), - [], - ); - const currencyOptions: RadioOption[] = useMemo( () => CURRENCIES.map((c) => ({ @@ -139,11 +127,7 @@ export default function SettingsScreen() { const buildVersion = Platform.OS === "web" ? "web" : Application.nativeBuildVersion; - const currentVariant = VariantList.find((v) => v.id === variant); const currentCurrency = getCurrency(currency); - // Branded variants lock the theme to their default, unless they opt into manual switching. - const isThemeLocked = - variant !== "default" && !Variants[variant].allowThemeToggle; const closeSheet = () => { if (activeSheet === "customerApiKey") { @@ -158,11 +142,6 @@ export default function SettingsScreen() { closeSheet(); }; - const handleVariantChange = (value: VariantName) => { - setVariant(value); - closeSheet(); - }; - const handleCurrencyChange = (value: CurrencyCode) => { setCurrency(value); closeSheet(); @@ -269,14 +248,6 @@ export default function SettingsScreen() { title="Theme" value={THEME_LABELS[themeMode]} onPress={() => setActiveSheet("theme")} - disabled={isThemeLocked} - /> - - setActiveSheet("walletTheme")} /> - {/* Wallet Theme Bottom Sheet */} - - - - {/* Currency Bottom Sheet */} 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 O06i^Z_0(5y@#s{@MF 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 diff --git a/dapps/pos-app/assets/images/variants/phantom_brand.png b/dapps/pos-app/assets/images/variants/phantom_brand.png deleted file mode 100644 index 0c1ef6be03d7316e6241da7370bf87e550d6ea1b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8423 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 diff --git a/dapps/pos-app/assets/images/variants/solana_brand.png b/dapps/pos-app/assets/images/variants/solana_brand.png deleted file mode 100644 index 87275c08b495480696320daecea1e0e2ca39c552..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7002 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 diff --git a/dapps/pos-app/assets/images/variants/xmoney_brand.png b/dapps/pos-app/assets/images/variants/xmoney_brand.png deleted file mode 100644 index 9701f4cfee4a75f6d89f492bff873ce58967f24a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10178 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 @@ -35,178 +31,6 @@ export const Variants: Record = { dark: {}, }, }, - solflare: { - name: "Solflare", - variantLogo: require("@/assets/images/variants/solflare_brand.png"), - defaultTheme: "dark", - colors: { - light: { - "icon-accent-primary": "#FFEF46", - "bg-accent-primary": "#FFEF46", - "bg-payment-success": "#FFEF46", - "text-payment-success": "#202020", - "border-payment-success": "#363636", - "text-invert": "#202020", // Used in button text. Default one doesnt work with yellow - }, - dark: { - "icon-accent-primary": "#FFEF46", - "bg-accent-primary": "#FFEF46", - "bg-payment-success": "#FFEF46", - "text-payment-success": "#202020", - "border-payment-success": "#363636", - }, - }, - }, - binance: { - name: "Binance", - variantLogo: require("@/assets/images/variants/binance_brand.png"), - defaultTheme: "light", - colors: { - light: { - "icon-accent-primary": "#FCD533", - "bg-accent-primary": "#FCD533", - "bg-payment-success": "#FCD533", - "text-payment-success": "#202020", - "border-payment-success": "#363636", - "text-invert": "#202020", // Used in button text. Default one doesnt work with yellow - }, - dark: { - "icon-accent-primary": "#FCD533", - "bg-accent-primary": "#FCD533", - "bg-payment-success": "#FCD533", - "text-payment-success": "#202020", - "border-payment-success": "#363636", - }, - }, - }, - phantom: { - name: "Phantom", - variantLogo: require("@/assets/images/variants/phantom_brand.png"), - defaultTheme: "light", - colors: { - light: { - "icon-accent-primary": "#AB9FF2", - "bg-accent-primary": "#AB9FF2", - "bg-payment-success": "#AB9FF2", - "text-payment-success": "#FFFFFF", - "border-payment-success": "#E9E9E9", - }, - dark: { - "icon-accent-primary": "#AB9FF2", - "bg-accent-primary": "#AB9FF2", - "bg-payment-success": "#AB9FF2", - "text-payment-success": "#FFFFFF", - "border-payment-success": "#E9E9E9", - }, - }, - }, - solana: { - name: "Solana", - variantLogo: require("@/assets/images/variants/solana_brand.png"), - defaultTheme: "dark", - colors: { - light: { - "icon-accent-primary": "#9945FF", - "bg-accent-primary": "#9945FF", - "bg-payment-success": "#9945FF", - "text-payment-success": "#FFFFFF", - "border-payment-success": "#FFFFFF", - }, - dark: { - "icon-accent-primary": "#9945FF", - "bg-accent-primary": "#9945FF", - "bg-payment-success": "#9945FF", - "text-payment-success": "#FFFFFF", - "border-payment-success": "#FFFFFF", - "text-invert": "#FFFFFF", // Used in button text. Default one doesnt work with purple - }, - }, - }, - trezor: { - name: "Trezor", - variantLogo: require("@/assets/images/variants/trezor_brand.png"), - defaultTheme: "light", - colors: { - light: { - "icon-accent-primary": "#60E198", - "bg-accent-primary": "#60E198", - "bg-payment-success": "#60E198", - "text-payment-success": "#1F1F1F", - "border-payment-success": "#363636", - "text-invert": "#1F1F1F", - }, - dark: { - "icon-accent-primary": "#60E198", - "bg-accent-primary": "#60E198", - "bg-payment-success": "#60E198", - "text-payment-success": "#1F1F1F", - "border-payment-success": "#363636", - }, - }, - }, - ledger: { - name: "Ledger", - variantLogo: require("@/assets/images/variants/ledger_brand.png"), - defaultTheme: "light", - colors: { - light: { - "icon-accent-primary": "#000000", - "bg-accent-primary": "#000000", - "bg-payment-success": "#000000", - "text-payment-success": "#FFFFFF", - "border-payment-success": "#E9E9E9", - }, - dark: { - "icon-accent-primary": "#000000", - "bg-accent-primary": "#000000", - "bg-payment-success": "#000000", - "text-payment-success": "#FFFFFF", - "border-payment-success": "#E9E9E9", - }, - }, - }, - imin: { - name: "iMin", - variantLogo: require("@/assets/images/variants/imin_brand.png"), - defaultTheme: "light", - colors: { - light: { - "icon-accent-primary": "#3E4D59", - "bg-accent-primary": "#3E4D59", - "bg-payment-success": "#000000", - "text-payment-success": "#FFFFFF", - "border-payment-success": "#E9E9E9", - }, - dark: { - "icon-accent-primary": "#3E4D59", - "bg-accent-primary": "#3E4D59", - "bg-payment-success": "#000000", - "text-payment-success": "#FFFFFF", - "border-payment-success": "#E9E9E9", - }, - }, - }, - money2020: { - name: "Money 20/20", - variantLogo: require("@/assets/images/variants/money2020_brand.png"), - printerLogo: MONEY2020_LOGO_BASE64, - defaultTheme: "light", - allowThemeToggle: true, - colors: { - light: {}, - dark: {}, - }, - }, - xmoney: { - name: "xMoney", - variantLogo: require("@/assets/images/variants/xmoney_brand.png"), - defaultTheme: "light", - allowThemeToggle: true, - colors: { - light: {}, - dark: {}, - }, - }, }; export const VariantList = Object.entries(Variants).map(([key, value]) => ({ diff --git a/dapps/pos-app/hooks/use-theme-color.ts b/dapps/pos-app/hooks/use-theme-color.ts index 5f59e0ed5..295b5c2e0 100644 --- a/dapps/pos-app/hooks/use-theme-color.ts +++ b/dapps/pos-app/hooks/use-theme-color.ts @@ -1,22 +1,18 @@ import { Colors } from "@/constants/theme"; -import { VariantName, Variants } from "@/constants/variants"; import { useSettingsStore } from "@/store/useSettingsStore"; import { ColorSchemeName, useColorScheme } from "react-native"; type ColorScheme = "light" | "dark"; -type ThemeColors = (typeof Colors)["light"]; type ColorName = keyof typeof Colors.light & keyof typeof Colors.dark; -const mergedThemeCache = new Map(); - -function getMergedTheme(variant: VariantName, theme: ColorScheme): ThemeColors { - const cacheKey = `${variant}:${theme}`; - let merged = mergedThemeCache.get(cacheKey); - if (!merged) { - merged = { ...Colors[theme], ...Variants[variant].colors[theme] }; - mergedThemeCache.set(cacheKey, merged); - } - return merged; +// Only the base palette ships today, so the theme is just Colors[theme]. +// Wallet theme variants are disabled; to re-enable brand color overrides, read +// the active `variant` from the store in the hooks below and merge it here: +// import { Variants } from "@/constants/variants"; +// return { ...Colors[theme], ...(Variants[variant] ?? Variants.default).colors[theme] }; +// (a small cache keyed by `${variant}:${theme}` avoids re-merging every render.) +function getTheme(theme: ColorScheme) { + return Colors[theme]; } function resolveTheme( @@ -31,22 +27,20 @@ function resolveTheme( export function useThemeColor(colorName: ColorName) { const themeMode = useSettingsStore((state) => state.themeMode) ?? "light"; - const variant = useSettingsStore((state) => state.variant); // Reactive: re-renders when the OS theme changes while set to "system", // instead of calling the native Appearance API on every render. const systemScheme = useColorScheme(); const theme = resolveTheme(themeMode, systemScheme); - return getMergedTheme(variant, theme)[colorName]; + return getTheme(theme)[colorName]; } export function useTheme(scheme?: ColorScheme) { const themeMode = useSettingsStore((state) => state.themeMode); - const variant = useSettingsStore((state) => state.variant); const systemScheme = useColorScheme(); const theme = scheme ?? resolveTheme(themeMode || "light", systemScheme); - return getMergedTheme(variant, theme); + return getTheme(theme); } diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index ac321de69..7d625fc29 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -279,7 +279,7 @@ export const useSettingsStore = create()( }), { name: "settings", - version: 16, + version: 17, storage, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { @@ -345,6 +345,12 @@ export const useSettingsStore = create()( persistedState.nfcEnabled = persistedState.nfcEnabled ?? true; } + if (version < 17) { + // Wallet theme variants were removed; reset any persisted branded + // variant so it maps back to the only remaining option. + persistedState.variant = "default"; + } + return persistedState; }, onRehydrateStorage: () => async (state, error) => { From 12794dcd0b9beedfcd5aeee233db1c581a25dbc4 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:55:59 -0300 Subject: [PATCH 03/42] feat(pos-app): add success animation and simplify header theming - Add SuccessAnimation Skottie component (Success.json), a one-shot loader that holds on its final frame, shown above the amount on the payment-success screen - Drop per-route header helpers; render HeaderImage through a single screenOptions title callback and narrow its tintColor typing - Refine payment error copy and payment success/failure screen theming - Sync stale test expectations (payment-errors copy, credential logs) Co-Authored-By: Claude Opus 4.8 --- .../hooks/use-url-credentials.test.ts | 6 +- dapps/pos-app/app/_layout.tsx | 31 ++----- dapps/pos-app/app/payment-failure.tsx | 49 ++++------- dapps/pos-app/app/payment-success.tsx | 50 +++++------ dapps/pos-app/assets/lottie/Success.json | 1 + .../pos-app/components/success-animation.tsx | 87 +++++++++++++++++++ dapps/pos-app/utils/navigation.ts | 19 ---- dapps/pos-app/utils/payment-errors.test.ts | 6 +- dapps/pos-app/utils/payment-errors.ts | 14 +-- 9 files changed, 152 insertions(+), 111 deletions(-) create mode 100644 dapps/pos-app/assets/lottie/Success.json create mode 100644 dapps/pos-app/components/success-animation.tsx diff --git a/dapps/pos-app/__tests__/hooks/use-url-credentials.test.ts b/dapps/pos-app/__tests__/hooks/use-url-credentials.test.ts index df4d359a8..0c48b4b42 100644 --- a/dapps/pos-app/__tests__/hooks/use-url-credentials.test.ts +++ b/dapps/pos-app/__tests__/hooks/use-url-credentials.test.ts @@ -164,9 +164,10 @@ describe("useUrlCredentials", () => { const logs = useLogsStore.getState().logs; const infoLogs = logs.filter((l) => l.level === "info"); - expect(infoLogs).toHaveLength(2); + expect(infoLogs).toHaveLength(3); expect(infoLogs[0].message).toContain("Merchant ID set from URL"); expect(infoLogs[1].message).toContain("Customer API key set from URL"); + expect(infoLogs[2].message).toContain("Credentials updated"); }); }); @@ -305,11 +306,12 @@ describe("useUrlCredentials — postMessage", () => { const logs = useLogsStore.getState().logs; const infoLogs = logs.filter((l) => l.level === "info"); - expect(infoLogs).toHaveLength(2); + expect(infoLogs).toHaveLength(3); expect(infoLogs[0].message).toContain("Merchant ID set from postMessage"); expect(infoLogs[1].message).toContain( "Customer API key set from postMessage", ); + expect(infoLogs[2].message).toContain("Credentials updated"); }); it("cleans up listener on unmount", async () => { diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index b6ffe99b6..2f7b40153 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -17,11 +17,6 @@ import { useFonts } from "expo-font"; import { useTheme } from "@/hooks/use-theme-color"; import { useUrlCredentials } from "@/hooks/use-url-credentials"; -import { - getHeaderBackgroundColor, - getHeaderTintColor, - shouldCenterHeaderTitle, -} from "@/utils/navigation"; import * as Sentry from "@sentry/react-native"; import { WalletConnectLoading } from "@/components/walletconnect-loading"; @@ -190,28 +185,20 @@ export default Sentry.wrap(function RootLayout() { { - const centerTitle = shouldCenterHeaderTitle(route.name); - const headerTintColor = getHeaderTintColor(route.name); - const headerBackgroundColor = getHeaderBackgroundColor( - route.name, - ); - return { - headerTitle: centerTitle ? HeaderImage : "", - headerRight: !centerTitle - ? () => ( - - ) - : undefined, + headerTitle: ({ tintColor }) => ( + + ), headerShadowVisible: false, - headerTintColor: Theme[headerTintColor], + headerTintColor: Theme["text-primary"], headerBackButtonDisplayMode: "minimal", headerTitleAlign: "center", headerStyle: { - backgroundColor: Theme[headerBackgroundColor], + backgroundColor: Theme["bg-primary"], }, headerRightContainerStyle: { ...(Platform.OS === "web" && { diff --git a/dapps/pos-app/app/payment-failure.tsx b/dapps/pos-app/app/payment-failure.tsx index 292d4dc58..d54b88880 100644 --- a/dapps/pos-app/app/payment-failure.tsx +++ b/dapps/pos-app/app/payment-failure.tsx @@ -66,30 +66,23 @@ export default function PaymentFailureScreen() { {subtitle} - - - + Start new payment + + ); } @@ -117,21 +110,11 @@ const styles = StyleSheet.create({ height: 48, marginBottom: Spacing["spacing-6"], }, - buttonContainer: { - width: "100%", - gap: Spacing["spacing-3"], - }, button: { - flexDirection: "row", alignItems: "center", justifyContent: "center", paddingHorizontal: Spacing["spacing-5"], paddingVertical: Spacing["spacing-5"], borderRadius: BorderRadius["5"], - gap: Spacing["spacing-2"], - }, - plusIcon: { - width: 12.5, - height: 12.5, }, }); diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index 7537def29..6f402331f 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -10,6 +10,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/button"; +import { SuccessAnimation } from "@/components/success-animation"; import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; import { useDisableBackButton } from "@/hooks/use-disable-back-button"; @@ -40,8 +41,8 @@ const finalScale = Math.ceil(diagonalLength / initialCircleSize) + 2; export default function PaymentSuccessScreen() { useDisableBackButton(); - const Theme = useTheme("light"); - const DarkTheme = useTheme("dark"); + const Theme = useTheme(); + const LightTheme = useTheme("light"); const params = useLocalSearchParams(); const themeMode = useSettingsStore((state) => state.themeMode); const currencyCode = useSettingsStore((state) => state.currency); @@ -148,7 +149,7 @@ export default function PaymentSuccessScreen() { style={[ styles.circle, { - backgroundColor: Theme["bg-payment-success"], + backgroundColor: Theme["bg-primary"], width: initialCircleSize, height: initialCircleSize, borderRadius: initialCircleSize / 2, @@ -162,34 +163,38 @@ export default function PaymentSuccessScreen() { + - Payment successful + {formatAmountWithSymbol(amount, currency)} - {formatAmountWithSymbol(amount, currency)} + Payment successful - {isPrinterConnected && ( + {!isPrinterConnected && ( )} @@ -213,7 +218,7 @@ export default function PaymentSuccessScreen() { style={[ styles.button, { - backgroundColor: DarkTheme["bg-invert"], + backgroundColor: Theme["bg-accent-primary"], }, ]} onPress={handleNewPayment} @@ -221,7 +226,7 @@ export default function PaymentSuccessScreen() { Start new payment @@ -251,14 +256,9 @@ const styles = StyleSheet.create({ width: "100%", }, amountDescription: { - fontSize: 18, - lineHeight: 20, textAlign: "center", - marginBottom: Spacing["spacing-3"], }, amountValue: { - fontSize: 38, - lineHeight: 38, textAlign: "center", }, buttonContainer: { diff --git a/dapps/pos-app/assets/lottie/Success.json b/dapps/pos-app/assets/lottie/Success.json new file mode 100644 index 000000000..904e2e705 --- /dev/null +++ b/dapps/pos-app/assets/lottie/Success.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":61,"w":1080,"h":1080,"nm":"Stage 2 Success","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[503.074,490.441,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-259.959,51.665],[-26.241,253.799],[333.811,-154.681]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.023529411765,0.4,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":130,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.16],"y":[1]},"o":{"x":[0.84],"y":[0]},"t":0,"s":[0]},{"t":42,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":1800,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/dapps/pos-app/components/success-animation.tsx b/dapps/pos-app/components/success-animation.tsx new file mode 100644 index 000000000..3d237447a --- /dev/null +++ b/dapps/pos-app/components/success-animation.tsx @@ -0,0 +1,87 @@ +import { Canvas, Group, Skia, Skottie } from "@shopify/react-native-skia"; +import { useEffect, useMemo } from "react"; +import { AppState, AppStateStatus } from "react-native"; +import { + useDerivedValue, + useFrameCallback, + useSharedValue, +} from "react-native-reanimated"; + +const SUCCESS = require("@/assets/lottie/Success.json"); + +// The animation is authored on a 1080x1080 canvas. +const SOURCE_SIZE = 1080; + +export function SuccessAnimation({ + width = 120, + height = 95, +}: { + width?: number; + height?: number; +}) { + // Create the Skottie animation during render (not at module scope) so it + // happens after LoadSkiaWeb() resolves on web (see index.web.tsx). + const animation = useMemo( + () => Skia.Skottie.Make(JSON.stringify(SUCCESS)), + [], + ); + + // Read the (constant) frame rate / length on the JS thread so the worklet + // below only does arithmetic — no per-frame JSI calls into the Skottie + // object. Safe defaults keep the worklet valid if the animation failed to + // load (Skia.Skottie.Make can return null). + const fps = animation?.fps() ?? 60; + const totalFrames = (animation?.duration() ?? 1) * fps; + + // Drive the animation with a controllable clock instead of Skia's useClock(), + // which ticks every frame for as long as the component is mounted with no way + // to pause. We accumulate elapsed time (rather than reading an absolute + // clock) so pausing and resuming is seamless: while the callback is inactive + // no frames fire, so `frame` stops changing, the Skottie holds its last frame + // and Skia stops redrawing. Starts inactive; the effect below activates it. + const elapsedMs = useSharedValue(0); + const frameCallback = useFrameCallback((info) => { + "worklet"; + elapsedMs.value += info.timeSincePreviousFrame ?? 0; + }, false); + + // One-shot: play once and hold on the final frame. Once elapsed time passes + // the animation duration, `frame` stays pinned at the last frame so Skia + // stops repainting — no loop. + const frame = useDerivedValue(() => + Math.min((elapsedMs.value / 1000) * fps, totalFrames - 1), + ); + + // Pause the clock while the app is backgrounded (the animation is mounted but + // not visible) so it doesn't keep burning CPU/GPU on the UI thread. Resumes + // automatically when the app returns to the foreground. + useEffect(() => { + const syncActive = (state: AppStateStatus = AppState.currentState) => + frameCallback.setActive(!!animation && state === "active"); + syncActive(); + const subscription = AppState.addEventListener("change", syncActive); + return () => { + subscription.remove(); + frameCallback.setActive(false); + }; + }, [frameCallback, animation]); + + // Scale the square source artwork uniformly (contain) into the target box so + // it is never squashed, then center it within the canvas. + const scale = Math.min(width, height) / SOURCE_SIZE; + const scaledSize = SOURCE_SIZE * scale; + const translateX = (width - scaledSize) / 2; + const translateY = (height - scaledSize) / 2; + + if (!animation) { + return null; + } + + return ( + + + + + + ); +} diff --git a/dapps/pos-app/utils/navigation.ts b/dapps/pos-app/utils/navigation.ts index 78183c4e9..7d9907e6e 100644 --- a/dapps/pos-app/utils/navigation.ts +++ b/dapps/pos-app/utils/navigation.ts @@ -1,24 +1,5 @@ -import { Colors } from "@/constants/theme"; import { Href, router } from "expo-router"; -export const shouldCenterHeaderTitle = (routeName: string) => { - return routeName === "index" || routeName === "payment-success"; -}; - -export const getHeaderBackgroundColor = ( - routeName: string, -): keyof typeof Colors.light | keyof typeof Colors.dark => { - return routeName === "payment-success" ? "bg-payment-success" : "bg-primary"; -}; - -export const getHeaderTintColor = ( - routeName: string, -): keyof typeof Colors.light | keyof typeof Colors.dark => { - return routeName === "payment-success" - ? "text-payment-success" - : "text-primary"; -}; - export const resetNavigation = (href: Href = "/") => { router.dismissTo(href); }; diff --git a/dapps/pos-app/utils/payment-errors.test.ts b/dapps/pos-app/utils/payment-errors.test.ts index f5c0e05c9..0dcb53df5 100644 --- a/dapps/pos-app/utils/payment-errors.test.ts +++ b/dapps/pos-app/utils/payment-errors.test.ts @@ -31,7 +31,7 @@ describe("getPaymentErrorMessage", () => { minAmountCents: "14", currencyCode: "USD", }); - expect(result.title).toBe("Amount is too low"); + expect(result.title).toBe("This amount is too low"); expect(result.subtitle).toContain("$0.14"); }); @@ -74,8 +74,8 @@ describe("getPaymentErrorMessage", () => { it('returns invalid API key message for "invalid_api_key" status', () => { const result = getPaymentErrorMessage("invalid_api_key"); - expect(result.title).toBe("This payment didn't go through"); - expect(result.subtitle).toContain("API key is invalid"); + expect(result.title).toBe("This terminal can't take payments"); + expect(result.subtitle).toContain("lost access"); }); it('returns cancelled message for "cancelled" status', () => { diff --git a/dapps/pos-app/utils/payment-errors.ts b/dapps/pos-app/utils/payment-errors.ts index 1eadb756e..31c7936e5 100644 --- a/dapps/pos-app/utils/payment-errors.ts +++ b/dapps/pos-app/utils/payment-errors.ts @@ -8,7 +8,7 @@ interface PaymentErrorMessage { const DEFAULT_ERROR: PaymentErrorMessage = { title: "This payment didn't go through", subtitle: - "No funds were moved. Start a new payment, or check your connection and try again.", + "No funds were moved. Check the terminal's connection before trying again.", }; const ERROR_MESSAGES: Record = { @@ -18,17 +18,17 @@ const ERROR_MESSAGES: Record = { }, cancelled: { title: "Payment cancelled", - subtitle: "No funds were moved. Start a new payment when you're ready.", + subtitle: "No funds were moved.", }, invalid_api_key: { - title: "This payment didn't go through", + title: "This terminal can't take payments", subtitle: - "Your API key is invalid. No funds were moved. Check your credentials in Settings and try again.", + "No funds were moved. This terminal has lost access and needs attention before it can take payments.", }, params_validation: { title: "This payment didn't go through", subtitle: - "No funds were moved. Something's off with this payment's details. Check your settings and start a new payment.", + "No funds were moved. Something's off with this payment's details.", }, }; @@ -68,8 +68,8 @@ export function getPaymentErrorMessage( context.currencyCode, ); return { - title: "Amount is too low", - subtitle: `Payments must be at least ${minAmount} to cover network fees. Start a new payment with a higher amount.`, + title: "This amount is too low", + subtitle: `Payments must be at least ${minAmount} to cover network fees.`, }; } From b16a6a3264f45275802e18f3cb20173450a4a2ab Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:01:10 -0300 Subject: [PATCH 04/42] feat(pos-app): route to Settings on invalid_api_key payment failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a payment fails with invalid_api_key, retrying just loops back into a flow that will fail again — the terminal needs its API key fixed. Swap the failure CTA to "Go to Settings" and navigate to the Settings screen for that error; all other errors keep the "Start new payment" behavior. - Add exported INVALID_API_KEY constant in utils/payment-errors.ts - Branch the failure CTA label + handler on the errorCode param - Exit the payment flow via dismissAll() then push("/settings") so Back → Home Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/payment-failure.tsx | 22 ++++++++++++++++++---- dapps/pos-app/utils/payment-errors.ts | 3 +++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/dapps/pos-app/app/payment-failure.tsx b/dapps/pos-app/app/payment-failure.tsx index d54b88880..5231a5105 100644 --- a/dapps/pos-app/app/payment-failure.tsx +++ b/dapps/pos-app/app/payment-failure.tsx @@ -9,7 +9,10 @@ import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useSettingsStore } from "@/store/useSettingsStore"; -import { getPaymentErrorMessage } from "@/utils/payment-errors"; +import { + getPaymentErrorMessage, + INVALID_API_KEY, +} from "@/utils/payment-errors"; import { useAssets } from "expo-asset"; // The params can't be declared optional here: `UnknownOutputParams` indexes to @@ -34,7 +37,18 @@ export default function PaymentFailureScreen() { currencyCode, }); - const handleRetry = () => { + // An invalid API key can't be fixed by retrying — the merchant needs Settings. + const isInvalidApiKey = params.errorCode === INVALID_API_KEY; + + const handlePrimaryPress = () => { + if (isInvalidApiKey) { + // Leave the payment flow entirely and land on Settings so the merchant + // can fix credentials; settings isn't in this stack, so dismissTo won't + // reach it — pop back to root, then push Settings. + router.dismissAll(); + router.push("/settings"); + return; + } router.dismissTo("/amount"); }; @@ -67,7 +81,7 @@ export default function PaymentFailureScreen() { diff --git a/dapps/pos-app/utils/payment-errors.ts b/dapps/pos-app/utils/payment-errors.ts index 31c7936e5..4aefbb8df 100644 --- a/dapps/pos-app/utils/payment-errors.ts +++ b/dapps/pos-app/utils/payment-errors.ts @@ -35,6 +35,9 @@ const ERROR_MESSAGES: Record = { /** Synthetic code: the API reports this as a generic `params_validation`. */ export const AMOUNT_TOO_LOW = "amount_too_low"; +/** API error code: the terminal's API key is invalid or has lost access. */ +export const INVALID_API_KEY = "invalid_api_key"; + // "Validation error: Amount must be at least 14 to cover fees" (14 = cents) const MIN_AMOUNT_PATTERN = /amount must be at least\s+(\d+)/i; From 119e30aacd2b81d92d40209fffee431ea401ed08 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:15:17 -0300 Subject: [PATCH 05/42] chore: simplified AGENTS.md, removed project id from env as it was unused --- dapps/pos-app/.env.example | 1 - dapps/pos-app/AGENTS.md | 832 +++--------------------------------- dapps/pos-app/jest.setup.js | 1 - 3 files changed, 58 insertions(+), 776 deletions(-) diff --git a/dapps/pos-app/.env.example b/dapps/pos-app/.env.example index e0f6c552b..683a45fff 100644 --- a/dapps/pos-app/.env.example +++ b/dapps/pos-app/.env.example @@ -1,4 +1,3 @@ -EXPO_PUBLIC_PROJECT_ID="" EXPO_PUBLIC_SENTRY_DSN="" SENTRY_AUTH_TOKEN="" EXPO_PUBLIC_API_URL="" diff --git a/dapps/pos-app/AGENTS.md b/dapps/pos-app/AGENTS.md index c9b910ba4..beefc1c84 100644 --- a/dapps/pos-app/AGENTS.md +++ b/dapps/pos-app/AGENTS.md @@ -4,48 +4,11 @@ This file provides guidance to AI agents when working with code in this reposito ## Project Overview -**WPay Mobile POS** is a React Native point-of-sale application that enables merchants to accept cryptocurrency payments via WalletConnect. The app allows merchants to: +**WPay Mobile POS** is a React Native point-of-sale application that enables merchants to accept cryptocurrency payments via WalletConnect. Merchants generate QR codes for payment requests, accept payments from WalletConnect-compatible wallets, print thermal receipts, and manage settings. -- Generate QR codes for payment requests -- Accept payments through WalletConnect-compatible wallets -- Print thermal receipts for completed transactions -- Manage merchant settings and configurations -- Support multiple branded variants (white-labeling) +Built with **Expo** and **React Native**, supporting Android, iOS, and Web. Check `package.json` for exact versions and dependencies. -The app is built with **Expo** and **React Native**, supporting Android, iOS, and Web platforms. - -## Tech Stack - -### Core Technologies - -- **React Native**: 0.81.5 -- **Expo**: ^54.0.23 (with Expo Router for navigation) -- **TypeScript**: ~5.9.2 -- **React**: 19.1.0 - -### Key Libraries - -- **@tanstack/react-query**: Data fetching and caching -- **zustand**: State management (lightweight alternative to Redux) -- **react-hook-form**: Form handling -- **expo-router**: File-based routing -- **react-native-thermal-pos-printer**: Thermal printer integration -- **react-native-qrcode-skia**: QR code generation -- **@shopify/react-native-skia**: Graphics rendering -- **expo-secure-store**: Secure credential storage -- **react-native-mmkv**: Fast key-value storage -- **@sentry/react-native**: Error tracking and monitoring - -### Development Tools - -- **ESLint**: Code linting -- **Prettier**: Code formatting -- **Jest**: Testing framework -- **patch-package**: Library patching for custom fixes - -## Architecture - -### Project Structure +## Project Structure ``` pos-app/ @@ -58,155 +21,28 @@ pos-app/ │ ├── settings.tsx # Settings & configuration │ ├── activity.tsx # Transaction history screen │ └── logs.tsx # Debug logs viewer +├── api/ # Vercel serverless proxies (web only) ├── components/ # Reusable UI components -├── constants/ # Theme, variants, spacing, etc. -├── hooks/ # Custom React hooks -├── services/ # API client and payment services -├── store/ # Zustand state stores -├── utils/ # Utility functions -└── assets/ # Images, fonts, icons +├── constants/ # Theme, spacing, printer logos, etc. +├── hooks/ # Custom React hooks +├── services/ # API client and payment services +├── store/ # Zustand state stores (useSettingsStore, useLogsStore) +├── utils/ # Utility functions (printer, currency, secure storage) +└── assets/ # Images, fonts, icons ``` -### State Management - -The app uses **Zustand** for state management with two main stores: - -1. **`useSettingsStore`** (`store/useSettingsStore.ts`) - - Merchant ID and API key - - Theme mode (light/dark) - - Selected variant - - Device ID - - Biometric authentication settings - - Printer connection status - - Transaction filter preference (for Activity screen) - - Date range filter preference (for Activity screen) - -2. **`useLogsStore`** (`store/useLogsStore.ts`) - - Debug logs for troubleshooting - - Log levels: info, warning, error - -### Navigation - -Uses **Expo Router** with file-based routing: - -- Routes are defined by file structure in `app/` directory -- Navigation via `router.push()`, `router.replace()`, `router.dismiss()` -- Type-safe routing with TypeScript - -## Key Features - -### 1. Payment Flow - -1. **Home Screen** (`app/index.tsx`) - - "New payment" button to start payment - - "Transactions" button to view transaction history - - "Settings" button for configuration - - Validates merchant setup before allowing payments - -2. **Amount Input** (`app/amount.tsx`) - - Custom numeric keyboard component - - Amount formatting (always 2 decimal places) - - Form validation with react-hook-form - -3. **QR Code Display** (`app/scan.tsx`) - - Generates payment request via API - - Displays QR code for wallet scanning - - Polls payment status every 2 seconds - - Handles payment success/failure navigation - - Shows WalletConnect loading animation - -4. **Payment Success** (`app/payment-success.tsx`) - - Animated expanding circle background - - Displays payment details - - Option to print receipt - - "New Payment" button to start over - -5. **Payment Failure** (`app/payment-failure.tsx`) - - Displays error information - - Allows retry or return to home - -6. **Activity Screen** (`app/activity.tsx`) - - Transaction history list with pull-to-refresh - - Filter tabs: All, Failed, Pending, Completed - - Transaction detail modal on tap - - Empty state when no transactions - - Uses unified API for data fetching - -### 2. Receipt Printing - -- **Thermal Printer Support** (`utils/printer.ts`) - - Bluetooth/USB printer connection - - Receipt generation with: - - Variant-specific logo (base64 encoded) - - Transaction ID, date, payment method - - Amount in USD - - Token symbol and amount (if applicable) - - Network name - - Automatic paper cutting after print - - Error handling and logging - -### 3. Settings & Configuration - -- **Merchant Setup** (`app/settings.tsx`) - - Merchant ID input - - API key configuration (stored securely) - - Device ID generation/management - - Variant selection dropdown - - Theme mode toggle (light/dark) - - Biometric authentication toggle - - Printer connection testing - - Test receipt printing - - App version display - - Logs viewer access - -### 4. Security Features - -- **Secure Storage**: API keys stored in `expo-secure-store` -- **Biometric Authentication**: Face ID / Touch ID support -- **PIN Protection**: Optional PIN modal for sensitive actions -- **Secure Credentials**: Never logged or exposed - -### 5. Theme System - -- **Light/Dark Mode**: System-aware theme switching -- **Variant Support**: Multiple branded variants (see Variants System section) -- **Dynamic Colors**: Theme colors adapt based on variant selection -- **Accessibility**: Proper contrast ratios maintained - ## Payment API Integration -### API Client (`services/client.ts`) +### Platform-Specific Service Files -- Base URL from `EXPO_PUBLIC_API_URL` environment variable -- Shared `getApiHeaders()` helper for authenticated requests -- Request/response interceptors -- Error handling - -### Payment Service (`services/payment.ts` / `services/payment.web.ts`) - -> **Important: Platform-specific service files.** The payment service has two implementations: +> **Important:** The payment service has two implementations: > > - **`services/payment.ts`** — Native (iOS/Android): uses `apiClient` from `services/client.ts` to call the merchant API directly. > - **`services/payment.web.ts`** — Web: uses Vercel serverless proxies (`/api/*`) to avoid CORS issues. Each API function calls a corresponding proxy in the `api/` directory. > > **When adding new API functions, you must add them to BOTH files** and create a corresponding Vercel serverless proxy in `api/`. The same pattern applies to `services/transactions.ts` / `services/transactions.web.ts`. -**`startPayment(request)`** - -- Creates new payment request -- Requires merchant ID and API key -- Returns payment ID and QR code URI - -**`getPaymentStatus(paymentId)`** - -- Polls payment status -- Returns payment state (pending, completed, failed) -- Includes transaction details when completed - -**`cancelPayment(paymentId)`** - -- Cancels a payment (only works from `requires_action` state) -- Returns 400 if payment is already in a terminal or processing state +Vercel proxies share `extractCredentials()` and `getApiHeaders()` from `api/_utils.ts`. React Query hooks for these services live in `services/hooks.ts`. ### Authentication Headers @@ -219,387 +55,80 @@ All Payment API requests include: - `Sdk-Version`: "1.0.0" - `Sdk-Platform`: "react-native" (native) or "web" (Vercel proxies) -### Transactions Service (`services/transactions.ts`) - -**`getTransactions(options)`** - -- Fetches merchant transaction history -- Endpoint: `GET /v1/merchants/payments` -- Uses `getApiHeaders()` for authentication (same as payment endpoints) -- Supports filtering by status, date range (`startTs`/`endTs`), pagination (`cursor`/`limit`) -- Returns `TransactionsResponse` with nested camelCase DTOs (`PaymentRecord`, `AmountWithDisplay`, `BuyerInfo`, `TransactionInfo`, `SettlementInfo`) - -### Server-Side Proxy (`api/transactions.ts`) - -- Vercel serverless function that proxies transaction requests (web only) -- Uses shared `extractCredentials()` and `getApiHeaders()` from `api/_utils.ts` -- Client sends `x-api-key` and `x-merchant-id` headers; proxy forwards with full auth headers -- Avoids CORS issues by making requests server-side - -### useTransactions Hook (`services/hooks.ts`) +## Environment Variables -```typescript -import { useTransactions } from "@/services/hooks"; +Required in `.env`: -const { data, isLoading, isError, refetch } = useTransactions({ - filter: "all", // "all" | "pending" | "completed" | "failed" | "expired" | "cancelled" - dateRangeFilter: "today", // "all_time" | "today" | "7_days" | "this_week" | "this_month" - enabled: true, -}); +```bash +EXPO_PUBLIC_API_URL="" # Payment API base URL ``` -- React Query hook with built-in caching (5 min stale time, 30 min cache) -- Automatic retry on failure (2 retries) -- Client-side filtering via `filter` option -- Logs errors to `useLogsStore` for debugging - -## Environment Variables - -Required environment variables (`.env`): +Optional: ```bash -EXPO_PUBLIC_PROJECT_ID="" # WalletConnect project ID EXPO_PUBLIC_SENTRY_DSN="" # Sentry error tracking DSN -SENTRY_AUTH_TOKEN="" # Sentry authentication token -EXPO_PUBLIC_API_URL="" # Payment API base URL +SENTRY_AUTH_TOKEN="" # Sentry authentication token for release builds EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" # Default merchant ID (optional) EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY="" # Default customer API key (optional) ``` -Copy `.env.example` to `.env` and fill in values. +Copy `.env.example` to `.env` and fill in the values you need. Never commit `.env` files or credentials. ## Development Setup -### Prerequisites - -- Node.js (LTS version recommended) -- Android Studio (for Android development) -- Xcode (for iOS development on macOS) -- Expo CLI - -### Package Manager - -This project uses **npm** (not pnpm or yarn). Always use `npm` commands for installing dependencies and running scripts. - -### Getting Started - -1. **Install dependencies** - - ```bash - npm install - ``` +This project uses **npm** (not pnpm or yarn). Always use `npm` commands. -2. **Set up environment variables** - - ```bash - cp .env.example .env - # Edit .env with your values - ``` - -3. **Create native folders** - - ```bash - npm run prebuild - ``` - -4. **Start development server** - ```bash - npm run android # Android - npm run ios # iOS - npm run web # Web - ``` +```bash +npm install # Install dependencies +cp .env.example .env # Set up environment variables +npm run prebuild # Create native folders +``` ### Available Scripts - `npm start`: Start Expo dev server -- `npm run android`: Run on Android -- `npm run ios`: Run on iOS -- `npm run web`: Run on web +- `npm run android` / `npm run ios` / `npm run web`: Run on each platform - `npm run android:build`: Build Android release APK -- `npm run android:build:aab`: Build Android App Bundle (AAB) for release. Output: `android/app/build/outputs/bundle/release/app-release.aab` +- `npm run android:build:aab`: Build Android App Bundle (AAB) for release - `npm run lint`: Run ESLint - `npm test`: Run Jest tests -## Important Files & Directories - -### Core Application Files - -- **`app/_layout.tsx`**: Root layout with navigation setup -- **`app/index.tsx`**: Home screen entry point -- **`app/amount.tsx`**: Payment amount input -- **`app/scan.tsx`**: QR code display and payment polling -- **`app/payment-success.tsx`**: Success screen with animations -- **`app/payment-failure.tsx`**: Error handling screen -- **`app/settings.tsx`**: Settings and configuration - -### Services & API - -- **`services/client.ts`**: API client and shared auth headers (`getApiHeaders`) — native only -- **`services/payment.ts`**: Payment API functions (native: direct API) -- **`services/payment.web.ts`**: Payment API functions (web: uses Vercel serverless proxies) -- **`services/transactions.ts`**: Transaction fetching (native: direct API) -- **`services/transactions.web.ts`**: Transaction fetching (web: server-side proxy) -- **`services/hooks.ts`**: React Query hooks for API calls (including `useTransactions`) -- **`api/payment.ts`**: Vercel serverless proxy for payment creation (web) -- **`api/payment-status.ts`**: Vercel serverless proxy for payment status (web) -- **`api/cancel-payment.ts`**: Vercel serverless proxy for payment cancellation (web) -- **`api/transactions.ts`**: Vercel serverless proxy for transaction list (web) - -### Utilities - -- **`utils/printer.ts`**: Thermal printer integration -- **`utils/currency.ts`**: Currency formatting utilities -- **`utils/misc.ts`**: Date formatting and helpers -- **`utils/navigation.ts`**: Navigation helpers -- **`utils/secure-storage.ts`**: Secure storage wrapper -- **`utils/biometrics.ts`**: Biometric authentication helpers - -### State Management - -- **`store/useSettingsStore.ts`**: App settings and configuration -- **`store/useLogsStore.ts`**: Debug logging store - -### Constants - -- **`constants/theme.ts`**: Base theme color definitions -- **`constants/variants.ts`**: Variant configurations -- **`constants/printer-logos.ts`**: Base64-encoded printer logos -- **`constants/spacing.ts`**: Spacing scale constants - -### Components - -- **`components/qr-code.tsx`**: QR code display component -- **`components/numeric-keyboard.tsx`**: Custom numeric input -- **`components/pin-modal.tsx`**: PIN entry modal -- **`components/button.tsx`**: Themed button component -- **`components/themed-text.tsx`**: Theme-aware text component -- **`components/status-badge.tsx`**: Transaction status badge (Completed/Pending/Failed) -- **`components/transaction-card.tsx`**: Transaction list item -- **`components/filter-tabs.tsx`**: Filter tabs for Activity screen -- **`components/transaction-detail-modal.tsx`**: Transaction detail bottom sheet -- **`components/empty-state.tsx`**: Reusable empty state component - -## Variants System - -This POS app supports a **variants system** that allows for minor UI customizations while maintaining the same core functionality. Variants enable white-labeling and branding customization for different clients or use cases. - -### Architecture - -#### Core Components - -1. **Theme System** (`constants/theme.ts`) - - Defines base color palette for light and dark modes - - Provides default colors used across the app - - Colors can be overridden by variants - -2. **Variants Configuration** (`constants/variants.ts`) - - Defines available variants and their customizations - - Each variant can override theme colors, logos, and default theme mode - - Variants are selected via settings and stored in Zustand store - -3. **Printer Logo** (`constants/printer-logos.ts`) - - Contains base64-encoded logos used on thermal printer receipts (e.g. `DEFAULT_LOGO_BASE64`, `MONEY2020_LOGO_BASE64`) - - Variants may opt into a per-variant receipt logo via `printerLogo`; variants without one fall back to `DEFAULT_LOGO_BASE64` - -### How Variants Work - -#### Variant Structure - -Each variant is defined with: - -- **name**: Display name (e.g., "Solflare", "Binance") -- **variantLogo**: Variant-only image asset (loaded via `require()`), omitted for the `default` variant. The header composes this with `brand.png` and `plus.png` at runtime to render ` + `. Width auto-sizes from the asset's intrinsic aspect ratio at a fixed render height — export logos trimmed of transparent padding. -- **defaultTheme**: Optional default theme mode ("light" or "dark") -- **colors**: Color overrides for light and dark themes - -The thermal printer receipt uses the variant's `printerLogo` when set (e.g. `money2020` → `MONEY2020_LOGO_BASE64`), falling back to `DEFAULT_LOGO_BASE64` otherwise. Resolve the correct logo at print time via `getVariantPrinterLogo()` from the settings store. - -#### Color Override System - -Variants can override any color from the base theme: - -- Colors are merged with base theme colors -- Only specified colors are overridden; others use defaults -- Both light and dark theme overrides are supported - -#### Example Variant - -```typescript -solflare: { - name: "Solflare", - variantLogo: require("@/assets/images/variants/solflare_brand.png"), - defaultTheme: "dark", - colors: { - light: { - "icon-accent-primary": "#FFEF46", - "bg-accent-primary": "#FFEF46", - "bg-payment-success": "#FFEF46", - "text-payment-success": "#202020", - "border-payment-success": "#363636", - "text-invert": "#202020", - }, - dark: { - // Similar overrides for dark theme - }, - }, -} -``` - -### Available Variants - -1. **default**: Base variant with blue accent colors (#0988F0) -2. **solflare**: Yellow/gold branding (#FFEF46) -3. **binance**: Yellow branding (#FCD533) -4. **phantom**: Purple branding (#AB9FF2) -5. **solana**: Purple branding (#9945FF) - -### Key Color Tokens - -Commonly overridden colors in variants: - -- `bg-accent-primary`: Primary accent background -- `bg-payment-success`: Payment success screen background -- `icon-accent-primary`: Accent icon color -- `text-payment-success`: Text color on success screen -- `border-payment-success`: Border color for success elements -- `text-invert`: Inverted text (for dark backgrounds) - -### Usage in Components - -#### Accessing Theme Colors - -```typescript -import { useTheme } from "@/hooks/use-theme-color"; - -const Theme = useTheme(); -// Theme["bg-payment-success"] will use variant override if set -``` - -#### Variant Selection - -Variants are stored in Zustand store (`store/useSettingsStore.ts`): - -- Selected variant persists across app sessions -- Can be changed in Settings screen -- Affects all themed components immediately - -### Creating New Variants - -#### Steps - -1. **Add variant logo image** - - Place the variant-only mark (no WPay wordmark, no "+") in `assets/images/variants/_brand.png` - - PNG format recommended - - The header renders `brand.png` + `plus.png` + your variant logo as three separate images - -2. **Define variant in `constants/variants.ts`** - - Add variant name to `VariantName` type - - Add variant configuration to `Variants` object with `variantLogo` - - Specify color overrides for light/dark themes +## Theming -3. **Update version code** (if needed) - - Increment `expo.android.versionCode` in `app.json` +- Theme colors are defined in `constants/theme.ts` and accessed via `useTheme()` from `hooks/use-theme-color.ts` (e.g. `Theme["bg-accent-primary"]`). +- Light/dark mode is toggled in Settings and persisted in `store/useSettingsStore.ts`. +- Thermal printer receipt logos are base64-encoded strings in `constants/printer-logos.ts` — this is a hardware constraint of the printer library, not an optimization choice. -#### Example: Adding a New Variant +Only the `default` variant ships today; branded variant colors and the Settings selector are disabled. To re-enable variants, follow the inline checklist in `constants/variants.ts` and `hooks/use-theme-color.ts`, including restoring the Settings UI. -```typescript -// In variants.ts -export type VariantName = - | "default" - | "solflare" - | "binance" - | "phantom" - | "solana" - | "myvariant"; // Add here - -export const Variants: Record = { - // ... existing variants - myvariant: { - name: "My Variant", - variantLogo: require("@/assets/images/variants/myvariant_brand.png"), - defaultTheme: "light", - colors: { - light: { - "bg-accent-primary": "#CUSTOM_COLOR", - "bg-payment-success": "#CUSTOM_COLOR", - // ... other overrides - }, - dark: { - // ... dark theme overrides - }, - }, - }, -}; -``` - -### Important Notes - -1. **Color Contrast**: When overriding colors, ensure sufficient contrast for accessibility - - Light backgrounds need dark text - - Dark backgrounds need light text - - Some variants use `text-invert` override for better contrast - -2. **Printer Logo**: Receipts use the variant's `printerLogo` when set, falling back to the shared `DEFAULT_LOGO_BASE64` - - Defined in `constants/printer-logos.ts` as `data:image/png;base64,...` strings - - Use `getVariantPrinterLogo()` from the settings store to resolve the logo at print time - - Logo size is automatically handled by the printer library - -3. **Default Theme**: Variants can specify a default theme mode - - Users can still switch themes manually - - Default applies on first launch - -4. **Payment Success Color**: The `bg-payment-success` color is used for: - - Payment success screen background (expanding circle animation) - - Success screen buttons - - Success screen text (via `text-payment-success`) +## Desktop Web Frame -5. **Variant Persistence**: Selected variant is stored in Zustand store - - Persists across app restarts - - Can be changed in Settings screen +On desktop web browsers the app renders inside a simulated POS device frame (`components/desktop-frame-wrapper.web.tsx`, applied in `index.web.tsx`). Detection uses the `useIsDesktopWeb` hook; on mobile web and native, children render unchanged. Frame dimensions live in `constants/desktop-frame.ts`. -### Testing Variants +React Native's `` renders at the viewport level, escaping the device frame. Use `` instead for modals that should appear inside the frame: -1. Open Settings screen -2. Select different variants from dropdown -3. Verify: - - Brand logo changes in header - - Accent colors update throughout app - - Payment success screen uses variant colors +- **Native** (`components/framed-modal.tsx`): plain React Native `` +- **Web** (`components/framed-modal.web.tsx`): renders via `createPortal` into the frame container (provided by `components/modal-portal-context.tsx`); handles Escape to close; falls back to in-place absolute positioning when no portal container exists (mobile web) -### Related Files - -- `constants/theme.ts`: Base theme colors -- `constants/variants.ts`: Variant definitions -- `constants/printer-logos.ts`: Printer logo base64 strings -- `store/useSettingsStore.ts`: Variant selection state -- `app/settings.tsx`: Variant selection UI -- `hooks/use-theme-color.ts`: Theme color hook with variant support +`FramedModal` only provides the container — children must include their own overlay and content styling. ## Production Builds ### Android Release -1. **Required Files** (get from mobile team or 1Password): +1. **Required files** (get from mobile team or 1Password): - `android/secrets.properties` - `android/app/wc_rn_upload.keystore` -2. **Build Release APK**: - - ```bash - npm run android:build - ``` - - Output: `android/app/build/outputs/apk/release/app-release.apk` - - For an **Android App Bundle (AAB)** instead (required for Play Store uploads): +2. **Build**: ```bash - npm run android:build:aab + npm run android:build # APK → android/app/build/outputs/apk/release/app-release.apk + npm run android:build:aab # AAB → android/app/build/outputs/bundle/release/app-release.aab ``` - Output: `android/app/build/outputs/bundle/release/app-release.aab` - 3. **Install via USB**: + ```bash adb devices # Get device ID adb -s install android/app/build/outputs/apk/release/app-release.apk @@ -607,285 +136,40 @@ export const Variants: Record = { ### Version Management -**⚠️ Important: For every new feature or change, you MUST update the Android version code in `app.json`.** - -- **Increment version code**: Update `expo.android.versionCode` in `app.json` for each change -- **Current version code**: Check the current value in `app.json` and increment by 1 -- **Why**: Android requires a unique version code for each release. Without incrementing, new builds cannot be installed over previous versions -- **Example**: If current version code is `15`, change it to `16` for your changes - -## Key Dependencies & Their Purposes - -- **@tanstack/react-query**: Manages API calls, caching, and polling for payment status -- **zustand**: Lightweight state management for settings and logs -- **expo-router**: File-based routing system -- **react-native-thermal-pos-printer**: Bluetooth/USB thermal printer integration -- **react-native-qrcode-skia**: QR code generation for payment requests -- **expo-secure-store**: Secure storage for API keys and sensitive data -- **react-native-mmkv**: Fast key-value storage for non-sensitive data -- **expo-local-authentication**: Biometric authentication (Face ID/Touch ID) -- **@sentry/react-native**: Error tracking and crash reporting -- **react-hook-form**: Form handling and validation -- **react-native-reanimated**: Animations (used in payment success screen) - -## Common Patterns - -### Theme Usage - -```typescript -import { useTheme } from "@/hooks/use-theme-color"; - -const Theme = useTheme(); -// Access colors: Theme["bg-accent-primary"] -``` - -### Navigation - -```typescript -import { router } from "expo-router"; - -// Navigate to screen -router.push("/amount"); - -// Navigate with params -router.push({ - pathname: "/scan", - params: { amount: "10.00" }, -}); - -// Replace current screen -router.replace("/payment-success"); - -// Dismiss modal -router.dismiss(); -``` - -### API Calls - -```typescript -import { usePaymentStatus } from "@/services/hooks"; - -const { data, isLoading, error } = usePaymentStatus(paymentId, { - enabled: !!paymentId, - refetchInterval: 2000, // Poll every 2 seconds -}); -``` - -### Secure Storage - -```typescript -import { secureStorage, SECURE_STORAGE_KEYS } from "@/utils/secure-storage"; - -// Store -await secureStorage.setItem(SECURE_STORAGE_KEYS.CUSTOMER_API_KEY, apiKey); - -// Retrieve -const apiKey = await secureStorage.getItem( - SECURE_STORAGE_KEYS.CUSTOMER_API_KEY, -); -``` +**⚠️ Important: For every new feature or change, you MUST increment `expo.android.versionCode` in `app.json`.** Android requires a unique version code for each release; without incrementing, new builds cannot be installed over previous versions. ## Code Quality Guidelines ### Debugging and Logging -**⚠️ Important: Do NOT leave `console.log()` statements in production code.** - -- **Use the logging system**: For debugging, use the app's built-in logging system via `useLogsStore`: +Do not leave ad-hoc console statements in React Native application code. Use the app's built-in logging system instead: - ```typescript - import { useLogsStore } from "@/store/useLogsStore"; - - const addLog = useLogsStore((state) => state.addLog); - addLog("info", "Payment completed", "payment-success", "handlePrintReceipt"); - ``` - -- **Remove console.logs before committing**: Always remove any `console.log()`, `console.error()`, or other console statements before committing code. +```typescript +import { useLogsStore } from "@/store/useLogsStore"; -- **View logs in app**: Users can view logs in the Settings screen → View Logs +const addLog = useLogsStore((state) => state.addLog); +addLog("info", "Payment completed", "payment-success", "handlePrintReceipt"); +``` -- **Production builds**: Console statements can impact performance and expose sensitive information in production builds. +Logs are viewable in-app via Settings → View Logs. Console logging is acceptable in server-side Vercel functions, build/setup scripts, and low-level error fallbacks where the in-app store is unavailable. Never log credentials or other sensitive values. ### After Making Changes -**Always run these checks and fix any errors before committing:** +Run these checks before committing: ```bash -npm run lint # Check and fix ESLint errors -npx prettier --write . # Format code with Prettier -npx tsc --noEmit # Check for TypeScript errors +npm run lint # Check ESLint errors +npm run format:check # Check formatting without changing unrelated files +npx tsc --noEmit # Check TypeScript errors npm test # Run Jest tests ``` -Fix any errors found. Pre-existing TypeScript errors in unrelated files can be ignored. - -### Before Creating a PR - -**Always run lint and prettier before creating a PR to ensure code is clean:** - -```bash -npm run lint --fix # Fix all auto-fixable lint issues -npx prettier --write . # Format all files with Prettier -``` - -These must pass without errors before pushing or creating a PR. +Fix failures introduced by your change. If a check has unrelated pre-existing failures, report them clearly. Format only files you changed, for example with `npx prettier --write `. **When moving exports between modules**, update any `jest.mock()` calls in tests that mock the source or destination module. Mocks that use a manual factory (e.g., `jest.mock("@/services/client", () => ({ ... }))`) replace the entire module — any export not included in the factory becomes `undefined` at runtime, which silently breaks tests. ### Code Style -- Follow TypeScript best practices -- Use ESLint and Prettier for consistent formatting - Prefer functional components with hooks - Use TypeScript types/interfaces for all props and data structures -- No trailing whitespace - -## Troubleshooting - -### Printer Issues - -- Check Bluetooth permissions in Android settings -- Verify printer is paired and connected -- Check logs in Settings → View Logs -- Test connection via Settings → Test Printer Connection - -### Payment Issues - -- Verify merchant ID and API key in Settings -- Check network connectivity -- Review logs for API errors -- Ensure `EXPO_PUBLIC_API_URL` is correctly configured - -### Build Issues - -- Run `npm run prebuild` after dependency changes -- Clear Metro cache: `npx expo start --clear` -- Clean Android build: `cd android && ./gradlew clean` - -## Desktop Web Frame System - -When the app is viewed on desktop web browsers, it renders inside a simulated POS device frame to provide a realistic preview of the mobile experience. This system handles frame rendering, scaling, and modal positioning. - -### Architecture - -#### Core Components - -1. **Desktop Frame Wrapper** (`components/desktop-frame-wrapper.web.tsx`) - - Wraps the entire app in a device frame on desktop web - - Detects desktop vs mobile web using `useIsDesktopWeb` hook - - Auto-scales the frame to fit the browser window - - Provides modal portal context for rendering modals inside the frame - - On mobile web or native, renders children unchanged (no frame) - -2. **Desktop Frame Constants** (`constants/desktop-frame.ts`) - - Defines device dimensions (width, height) - - Bezel styling (width, color, radius) - - Screen radius for rounded corners - - Background colors for light/dark themes - - Box shadow for depth effect - -3. **useIsDesktopWeb Hook** (`hooks/use-is-desktop-web.ts`) - - Returns `true` when running on desktop web (window width > 768px) - - Returns `false` on mobile web or native platforms - - Listens for window resize events to update dynamically - -### Web Entry Point - -The desktop frame is applied in `index.web.tsx`: - -```typescript -import { DesktopFrameWrapper } from "@/components/desktop-frame-wrapper.web"; - -function WrappedApp() { - return ( - - - - ); -} -``` - -### Modal Portal System - -React Native's `` component renders at the viewport level with fixed positioning, which causes modals to appear outside the device frame on desktop web. To solve this, a portal system renders modals inside the frame. - -#### Components - -1. **Modal Portal Context** (`components/modal-portal-context.tsx`) - - Provides a ref to the modal container element - - Used by web modals to render via `createPortal` - -2. **FramedModal** (`components/framed-modal.tsx` / `framed-modal.web.tsx`) - - Platform-specific modal wrapper - - **Native** (`framed-modal.tsx`): Uses React Native's `` directly - - **Web** (`framed-modal.web.tsx`): Uses `createPortal` to render inside the frame container - -#### Usage - -Replace `` with `` for modals that should appear inside the device frame: - -```typescript -import { FramedModal } from "./framed-modal"; - -function MyModal({ visible, onClose, children }) { - return ( - - {/* Modal content - include your own overlay and container */} - - - {children} - - - - ); -} -``` - -#### How It Works - -1. `DesktopFrameWrapper` creates a container div with `ref={modalContainerRef}` -2. `ModalPortalProvider` makes this ref available via context -3. `FramedModal.web.tsx` uses `useModalPortal()` to get the container ref -4. When visible, it renders children via `createPortal(content, containerRef.current)` -5. This positions the modal inside the frame instead of at viewport level - -### Frame Scaling - -The frame automatically scales to fit the browser window: - -- Calculates available height (window height minus label) -- Computes scale factor: `Math.min(1, availableHeight / totalFrameHeight)` -- Applies CSS transform: `transform: scale(${scale})` -- Maintains aspect ratio and centers the frame - -### Theme Support - -The frame adapts to light/dark mode: - -- Background color changes based on color scheme -- Screen background matches app theme -- Bezel color remains constant (device hardware appearance) - -### Related Files - -- `index.web.tsx`: Web entry point with DesktopFrameWrapper -- `components/desktop-frame-wrapper.web.tsx`: Frame wrapper component -- `components/modal-portal-context.tsx`: Modal portal context provider -- `components/framed-modal.tsx`: Native modal wrapper -- `components/framed-modal.web.tsx`: Web modal with portal support -- `constants/desktop-frame.ts`: Frame dimension constants -- `hooks/use-is-desktop-web.ts`: Desktop detection hook - -### Important Notes - -1. **Platform-specific files**: The `.web.tsx` suffix ensures the web version is used only on web platform -2. **Modal children**: `FramedModal` only provides the container; children must include their own overlay and content styling -3. **Escape key**: `FramedModal.web` handles Escape key to close modals -4. **Mobile web fallback**: If no portal container exists (mobile web), the modal renders in place with absolute positioning - -## Additional Resources - -- **README.md**: Setup and development instructions -- **app.json**: Expo configuration -- **package.json**: Dependencies and scripts -- **tsconfig.json**: TypeScript configuration +- Use ESLint and Prettier for consistent formatting diff --git a/dapps/pos-app/jest.setup.js b/dapps/pos-app/jest.setup.js index 518c43853..08fec2758 100644 --- a/dapps/pos-app/jest.setup.js +++ b/dapps/pos-app/jest.setup.js @@ -279,7 +279,6 @@ global.fetch = jest.fn(); // Force test-only URLs to prevent accidental real endpoint calls // Using .invalid TLD per RFC 2606 to ensure these can never resolve process.env.EXPO_PUBLIC_API_URL = "https://api.test.example.com"; -process.env.EXPO_PUBLIC_PROJECT_ID = "test-project-id"; // Cleanup function to reset mocks between tests afterEach(() => { From 5949b71b27a19a6a9ea00d46470625f5d4eb9f91 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:38:51 -0300 Subject: [PATCH 06/42] feat(pos): animate payment success reveal --- dapps/pos-app/app/_layout.tsx | 6 +- dapps/pos-app/app/payment-success.tsx | 89 ++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index 2f7b40153..ad2790152 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -239,7 +239,11 @@ export default Sentry.wrap(function RootLayout() { diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index 6f402331f..f877a899f 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -10,6 +10,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/button"; +import HeaderImage from "@/components/header-image"; import { SuccessAnimation } from "@/components/success-animation"; import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; @@ -38,6 +39,9 @@ const { width: screenWidth, height: screenHeight } = Dimensions.get("screen"); const diagonalLength = Math.sqrt(screenWidth ** 2 + screenHeight ** 2); const initialCircleSize = 20; const finalScale = Math.ceil(diagonalLength / initialCircleSize) + 2; +const contentOffset = 16; +const contentRevealDelay = 700; +const contentRevealDuration = 200; export default function PaymentSuccessScreen() { useDisableBackButton(); @@ -56,10 +60,16 @@ export default function PaymentSuccessScreen() { const { amount } = params; const [isPrinterConnected, setIsPrinterConnected] = useState(false); const [isPrinting, setIsPrinting] = useState(false); + const [isThemeBackgroundVisible, setIsThemeBackgroundVisible] = + useState(false); + const [isSuccessAnimationVisible, setIsSuccessAnimationVisible] = + useState(false); const isPrintingRef = useRef(false); const circleScale = useSharedValue(1); + const backgroundOverlayOpacity = useSharedValue(0); const contentOpacity = useSharedValue(0); + const contentTranslateY = useSharedValue(contentOffset); const handleNewPayment = () => { resetNavigation("/amount"); @@ -127,10 +137,32 @@ export default function PaymentSuccessScreen() { }, [addLog]); useEffect(() => { - circleScale.value = withTiming(finalScale, { - duration: 400, - }); - contentOpacity.value = withDelay(150, withTiming(1, { duration: 200 })); + circleScale.value = withTiming(finalScale, { duration: 400 }); + backgroundOverlayOpacity.value = withDelay( + 400, + withTiming(1, { duration: 300 }), + ); + contentOpacity.value = withDelay( + contentRevealDelay, + withTiming(1, { duration: contentRevealDuration }), + ); + contentTranslateY.value = withDelay( + contentRevealDelay, + withTiming(0, { duration: contentRevealDuration }), + ); + const themeBackgroundTimeout = setTimeout( + () => setIsThemeBackgroundVisible(true), + contentRevealDelay, + ); + const successAnimationTimeout = setTimeout( + () => setIsSuccessAnimationVisible(true), + contentRevealDelay, + ); + + return () => { + clearTimeout(themeBackgroundTimeout); + clearTimeout(successAnimationTimeout); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -140,6 +172,11 @@ export default function PaymentSuccessScreen() { const contentAnimatedStyle = useAnimatedStyle(() => ({ opacity: contentOpacity.value, + transform: [{ translateY: contentTranslateY.value }], + })); + + const backgroundOverlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: backgroundOverlayOpacity.value, })); return ( @@ -149,7 +186,7 @@ export default function PaymentSuccessScreen() { style={[ styles.circle, { - backgroundColor: Theme["bg-primary"], + backgroundColor: Theme["bg-accent-primary"], width: initialCircleSize, height: initialCircleSize, borderRadius: initialCircleSize / 2, @@ -158,8 +195,20 @@ export default function PaymentSuccessScreen() { ]} /> - {/* Content that fades in after circle expands */} + + + {/* Content fades in after the blue-to-theme transition completes. */} + + + - + + {isSuccessAnimationVisible && ( + + )} + - + ); } @@ -241,6 +302,7 @@ export default function PaymentSuccessScreen() { const styles = StyleSheet.create({ container: { flex: 1, + overflow: "hidden", paddingHorizontal: Spacing["spacing-5"], paddingBottom: Platform.OS === "web" ? 0 : Spacing["spacing-5"], }, @@ -251,10 +313,21 @@ const styles = StyleSheet.create({ marginLeft: -initialCircleSize / 2, marginTop: -initialCircleSize / 2, }, + backgroundOverlay: { + ...StyleSheet.absoluteFill, + }, contentContainer: { flex: 1, width: "100%", }, + header: { + alignItems: "center", + paddingBottom: Spacing["spacing-4"], + }, + successAnimationContainer: { + width: 200, + height: 175, + }, amountDescription: { textAlign: "center", }, From d7ea12a76c9be1549be0e150752f0aa761165f45 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:57:26 -0300 Subject: [PATCH 07/42] feat(pos): make home actions responsive --- dapps/pos-app/app/_layout.tsx | 10 ++++++- dapps/pos-app/app/index.tsx | 49 +++++++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index ad2790152..f916a97e5 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -221,7 +221,15 @@ export default Sentry.wrap(function RootLayout() { }; }} > - + state.merchantId); const isCustomerApiKeySet = useSettingsStore( (state) => state.isCustomerApiKeySet, @@ -40,14 +45,31 @@ export default function HomeScreen() { router.push("/settings"); }; + const isCompact = windowHeight < compactScreenHeight; + const secondaryActionHeight = isCompact ? 112 : 140; + const primaryActionMinHeight = isCompact ? 200 : 320; + const topSpacing = isCompact + ? Spacing["spacing-6"] + : Spacing["extra-spacing-1"]; + const bottomSpacing = Math.max( + bottom + Spacing["spacing-3"], + Spacing["spacing-7"], + ); + return ( - + + - - + ); diff --git a/dapps/pos-app/app/payment-failure.tsx b/dapps/pos-app/app/payment-failure.tsx index 5231a5105..1fb32f4c2 100644 --- a/dapps/pos-app/app/payment-failure.tsx +++ b/dapps/pos-app/app/payment-failure.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Button } from "@/components/button"; import { ThemedText } from "@/components/themed-text"; -import { BorderRadius, Spacing } from "@/constants/spacing"; +import { Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useSettingsStore } from "@/store/useSettingsStore"; import { @@ -80,20 +80,8 @@ export default function PaymentFailureScreen() { {subtitle} - @@ -124,11 +112,4 @@ const styles = StyleSheet.create({ height: 48, marginBottom: Spacing["spacing-6"], }, - button: { - alignItems: "center", - justifyContent: "center", - paddingHorizontal: Spacing["spacing-5"], - paddingVertical: Spacing["spacing-5"], - borderRadius: BorderRadius["5"], - }, }); diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index df9f2141c..7cb08875f 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -13,7 +13,7 @@ import { Button } from "@/components/button"; import HeaderImage from "@/components/header-image"; import { SuccessAnimation } from "@/components/success-animation"; import { ThemedText } from "@/components/themed-text"; -import { BorderRadius, Spacing } from "@/constants/spacing"; +import { Spacing } from "@/constants/spacing"; import { useDisableBackButton } from "@/hooks/use-disable-back-button"; import { useTheme } from "@/hooks/use-theme-color"; import { useLogsStore } from "@/store/useLogsStore"; @@ -46,7 +46,6 @@ const contentRevealDuration = 200; export default function PaymentSuccessScreen() { useDisableBackButton(); const Theme = useTheme(); - const LightTheme = useTheme("light"); const params = useLocalSearchParams(); const themeMode = useSettingsStore((state) => state.themeMode); const currencyCode = useSettingsStore((state) => state.currency); @@ -254,21 +253,13 @@ export default function PaymentSuccessScreen() { {isPrinterConnected && ( @@ -351,11 +335,6 @@ const styles = StyleSheet.create({ }, button: { flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingHorizontal: Spacing["spacing-5"], - paddingVertical: Spacing["spacing-5"], - borderRadius: BorderRadius["5"], gap: Spacing["spacing-2"], }, buttonIcon: { diff --git a/dapps/pos-app/app/scan.tsx b/dapps/pos-app/app/scan.tsx index 1d1270020..dfb374aea 100644 --- a/dapps/pos-app/app/scan.tsx +++ b/dapps/pos-app/app/scan.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/button"; import QRCode from "@/components/qr-code"; import { ThemedText } from "@/components/themed-text"; import { WalletConnectLoading } from "@/components/walletconnect-loading"; -import { BorderRadius, Spacing } from "@/constants/spacing"; +import { Spacing } from "@/constants/spacing"; import { useCountdown } from "@/hooks/use-countdown"; import { useNfcPayment } from "@/hooks/use-nfc-payment"; import { useTheme } from "@/hooks/use-theme-color"; @@ -334,18 +334,14 @@ export default function ScanScreen() { )} {!isProcessing && ( @@ -411,12 +407,7 @@ const styles = StyleSheet.create({ gap: Spacing["spacing-1"], }, cancelButton: { - alignItems: "center", - justifyContent: "center", - borderRadius: BorderRadius["4"], marginHorizontal: Spacing["spacing-5"], - borderWidth: 1, - height: 48, }, nfcIcon: { marginLeft: Spacing["spacing-5"], diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index 659e60852..c73f42831 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -384,22 +384,16 @@ export default function SettingsScreen() { ]} /> , + ).getByTestId(testID); + + return { button, style: StyleSheet.flatten(button.props.style) }; +} + +describe("Button", () => { + it("applies shared dimensions and full width by default", () => { + const { style } = getButtonStyle("default-button"); + + expect(style).toMatchObject({ + height: 54, + width: "100%", + borderRadius: 16, + backgroundColor: Colors.light["bg-accent-primary"], + }); + }); + + it("supports the neutral secondary variant", () => { + const button = render( + , + ).getByTestId("secondary-button"); + + const flattenedStyle = StyleSheet.flatten(button.props.style); + expect(flattenedStyle.backgroundColor).toBeUndefined(); + expect(flattenedStyle.borderColor).toBe(Colors.light["border-secondary"]); + expect(flattenedStyle.borderWidth).toBe(1); + }); + + it("supports the neutral tertiary variant and compact width", () => { + const button = render( + , + ).getByTestId("tertiary-button"); + + const style = StyleSheet.flatten(button.props.style); + expect(style.width).toBeUndefined(); + expect(style.backgroundColor).toBe(Colors.light["bg-invert"]); + }); + + it("passes disabled state through and applies the disabled treatment", () => { + const button = render( + , + ).getByTestId("disabled-button"); + + expect(button.props.disabled).toBe(true); + expect(StyleSheet.flatten(button.props.style).opacity).toBe(0.6); + }); +}); diff --git a/dapps/pos-app/components/button.tsx b/dapps/pos-app/components/button.tsx index 751204127..7efd535cf 100644 --- a/dapps/pos-app/components/button.tsx +++ b/dapps/pos-app/components/button.tsx @@ -1,30 +1,77 @@ -import { PressableScale } from "pressto"; +import { BorderRadius } from "@/constants/spacing"; +import { useTheme } from "@/hooks/use-theme-color"; import React from "react"; -import { StyleProp, ViewStyle } from "react-native"; +import { StyleProp, StyleSheet, ViewStyle } from "react-native"; +import { Pressable } from "./pressable"; -interface Props { +interface ButtonBaseProps { children: React.ReactNode; style?: StyleProp; onPress: () => void; disabled?: boolean; + fullWidth?: boolean; testID?: string; } -export const Button: React.FC = ({ +export type ButtonProps = + | (ButtonBaseProps & { type: "accent"; variant: "primary" }) + | (ButtonBaseProps & { type: "neutral"; variant: "secondary" | "tertiary" }); + +export function Button({ children, style, onPress, - disabled, + disabled = false, + fullWidth = true, testID, -}) => { + type, + variant, +}: ButtonProps) { + const theme = useTheme(); + + const variantStyle = + type === "accent" + ? { + backgroundColor: theme["bg-accent-primary"], + } + : variant === "secondary" + ? { + borderColor: theme["border-secondary"], + borderWidth: 1, + } + : { + backgroundColor: theme["bg-invert"], + }; + return ( - {children} - + ); -}; +} + +const styles = StyleSheet.create({ + button: { + height: 54, + borderRadius: BorderRadius["4"], + alignItems: "center", + justifyContent: "center", + }, + fullWidth: { + width: "100%", + }, + disabled: { + opacity: 0.6, + }, +}); diff --git a/dapps/pos-app/components/card.tsx b/dapps/pos-app/components/card.tsx index 535db3160..6b258084d 100644 --- a/dapps/pos-app/components/card.tsx +++ b/dapps/pos-app/components/card.tsx @@ -1,7 +1,7 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; -import { Button } from "./button"; +import { Pressable } from "./pressable"; interface Props { children: React.ReactNode; @@ -13,7 +13,7 @@ export function Card({ children, onPress, style }: Props) { const Theme = useTheme(); return onPress ? ( - + ) : ( {icon && {icon}} @@ -39,10 +36,12 @@ function EmptyStateBase({ title, subtitle, icon, cta }: EmptyStateProps) { {cta && ( @@ -72,10 +71,5 @@ const styles = StyleSheet.create({ }, cta: { marginTop: Spacing["spacing-5"], - paddingHorizontal: Spacing["spacing-6"], - paddingVertical: Spacing["spacing-4"], - borderRadius: BorderRadius["4"], - alignItems: "center", - justifyContent: "center", }, }); diff --git a/dapps/pos-app/components/filter-buttons.tsx b/dapps/pos-app/components/filter-buttons.tsx index f67969d37..867e1f47a 100644 --- a/dapps/pos-app/components/filter-buttons.tsx +++ b/dapps/pos-app/components/filter-buttons.tsx @@ -4,7 +4,7 @@ import { useAssets } from "expo-asset"; import { Image } from "expo-image"; import { memo } from "react"; import { StyleSheet, View } from "react-native"; -import { Button } from "./button"; +import { Pressable } from "./pressable"; import { ThemedText } from "./themed-text"; interface FilterButtonsProps { @@ -25,7 +25,7 @@ function FilterButtonsBase({ return ( - - + ); } diff --git a/dapps/pos-app/components/numeric-keyboard.tsx b/dapps/pos-app/components/numeric-keyboard.tsx index db47734ec..d0e9ab93d 100644 --- a/dapps/pos-app/components/numeric-keyboard.tsx +++ b/dapps/pos-app/components/numeric-keyboard.tsx @@ -4,7 +4,7 @@ import { useAssets } from "expo-asset"; import { Image } from "expo-image"; import { memo } from "react"; import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; -import { Button } from "./button"; +import { Pressable } from "./pressable"; import { ThemedText } from "./themed-text"; export interface NumericKeyboardProps { @@ -31,7 +31,7 @@ function NumericKeyboardBase({ onKeyPress, style }: NumericKeyboardProps) { {keys.map((row, rowIndex) => ( {row.map((key) => ( - + ))} ))} diff --git a/dapps/pos-app/components/pressable.test.tsx b/dapps/pos-app/components/pressable.test.tsx new file mode 100644 index 000000000..e9514ed67 --- /dev/null +++ b/dapps/pos-app/components/pressable.test.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { fireEvent, render } from "@testing-library/react-native"; +import { Text } from "react-native"; + +import { Pressable } from "@/components/pressable"; + +jest.mock("react-native", () => ({ + StyleSheet: { + flatten: (style: unknown) => + Array.isArray(style) ? Object.assign({}, ...style) : style, + }, + Text: "Text", + View: "View", +})); + +jest.mock("pressto", () => { + const mockReact = jest.requireActual("react"); + + return { + PressableScale: ({ children, ...props }: React.PropsWithChildren) => + mockReact.createElement("View", props, children), + }; +}); + +describe("Pressable", () => { + it("delegates press behavior, disabled state, styles, and test IDs", () => { + const onPress = jest.fn(); + const { getByTestId } = render( + + Press + , + ); + + const pressable = getByTestId("generic-pressable"); + expect(pressable.props.enabled).toBe(false); + expect(pressable.props.style).toEqual({ opacity: 0.4 }); + + fireEvent.press(pressable); + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dapps/pos-app/components/pressable.tsx b/dapps/pos-app/components/pressable.tsx new file mode 100644 index 000000000..c80b0c1cd --- /dev/null +++ b/dapps/pos-app/components/pressable.tsx @@ -0,0 +1,30 @@ +import { PressableScale } from "pressto"; +import React from "react"; +import { StyleProp, ViewStyle } from "react-native"; + +interface Props { + children: React.ReactNode; + style?: StyleProp; + onPress: () => void; + disabled?: boolean; + testID?: string; +} + +export const Pressable: React.FC = ({ + children, + style, + onPress, + disabled, + testID, +}) => { + return ( + + {children} + + ); +}; diff --git a/dapps/pos-app/components/settings-bottom-sheet.tsx b/dapps/pos-app/components/settings-bottom-sheet.tsx index 1fdd96c92..f81a7c658 100644 --- a/dapps/pos-app/components/settings-bottom-sheet.tsx +++ b/dapps/pos-app/components/settings-bottom-sheet.tsx @@ -20,7 +20,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; -import { Button } from "./button"; +import { Pressable as ScalePressable } from "./pressable"; import { FramedModal } from "./framed-modal"; import { ThemedText } from "./themed-text"; @@ -95,7 +95,7 @@ export function SettingsBottomSheet({ > {title} - + )} - + ); } diff --git a/dapps/pos-app/components/transaction-card.tsx b/dapps/pos-app/components/transaction-card.tsx index 0749e79ed..d3376430b 100644 --- a/dapps/pos-app/components/transaction-card.tsx +++ b/dapps/pos-app/components/transaction-card.tsx @@ -5,7 +5,7 @@ import { formatShortDate } from "@/utils/misc"; import { PaymentRecord } from "@/utils/types"; import { memo } from "react"; import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; -import { Button } from "./button"; +import { Pressable } from "./pressable"; import { StatusBadge } from "./status-badge"; import { ThemedText } from "./themed-text"; @@ -23,7 +23,7 @@ function TransactionCardBase({ const theme = useTheme(); return ( - + ); } diff --git a/dapps/pos-app/components/transaction-detail-modal.tsx b/dapps/pos-app/components/transaction-detail-modal.tsx index 0f43dd8dc..727beabac 100644 --- a/dapps/pos-app/components/transaction-detail-modal.tsx +++ b/dapps/pos-app/components/transaction-detail-modal.tsx @@ -18,7 +18,7 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -import { Button } from "./button"; +import { Pressable as ScalePressable } from "./pressable"; import { FramedModal } from "./framed-modal"; import { StatusBadge } from "./status-badge"; import { ThemedText } from "./themed-text"; @@ -106,7 +106,7 @@ function DetailRow({ ); if (onPress) { - return ; + return {content}; } return content; @@ -176,7 +176,7 @@ function TransactionDetailModalBase({ ]} > - + Date: Wed, 19 Aug 2026 15:57:32 -0300 Subject: [PATCH 11/42] feat(pos-app): gate NFC/HCE behind EXPO_PUBLIC_NFC_HCE_ENABLED flag Disable NFC/HCE tap-to-pay by default via a single build-time flag so it can be re-enabled without code changes. - add utils/feature-flags.ts exposing isNfcHceEnabled from EXPO_PUBLIC_NFC_HCE_ENABLED - add plugins/withHceFeatureFlag.js to inject a matching HCE_ENABLED into the AndroidManifest; register it in app.json - gate scan-screen HCE activation/UI and the settings toggle on the flag - guard both setActivity (setPreferredService) and enable() in HceModule so a disabled build never registers as an HCE handler - remove dangling ./assets/images/variants entry that broke prebuild - replace nfc.png with a smaller icon to fix downscaling artifacts Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/.env.example | 2 ++ dapps/pos-app/app.json | 2 +- dapps/pos-app/app/scan.tsx | 8 +++-- dapps/pos-app/app/settings.tsx | 5 ++- dapps/pos-app/assets/images/nfc.png | Bin 51919 -> 6405 bytes .../java/com/reown/mobilepos/hce/HceModule.kt | 34 +++++++++++++++--- dapps/pos-app/plugins/withHceFeatureFlag.js | 32 +++++++++++++++++ dapps/pos-app/utils/feature-flags.ts | 5 +++ 8 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 dapps/pos-app/plugins/withHceFeatureFlag.js create mode 100644 dapps/pos-app/utils/feature-flags.ts diff --git a/dapps/pos-app/.env.example b/dapps/pos-app/.env.example index 683a45fff..1b1ec5655 100644 --- a/dapps/pos-app/.env.example +++ b/dapps/pos-app/.env.example @@ -3,3 +3,5 @@ SENTRY_AUTH_TOKEN="" EXPO_PUBLIC_API_URL="" EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" EXPO_PUBLIC_DEFAULT_CUSTOMER_API_KEY="" +# "true" enables NFC/HCE tap-to-pay; anything else (or unset) disables it +EXPO_PUBLIC_NFC_HCE_ENABLED="" diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json index b016b1013..8050c3ecc 100644 --- a/dapps/pos-app/app.json +++ b/dapps/pos-app/app.json @@ -69,6 +69,7 @@ "./plugins/withAndroidVariants.js", "./plugins/withAndroidVariantIcons.js", "./plugins/withHceNfc.js", + "./plugins/withHceFeatureFlag.js", [ "expo-font", { @@ -110,7 +111,6 @@ "./assets/images/tokens", "./assets/images/chains", "./assets/images/payment_methods", - "./assets/images/variants", "./assets/app_icons" ] } diff --git a/dapps/pos-app/app/scan.tsx b/dapps/pos-app/app/scan.tsx index dfb374aea..43f3be5fc 100644 --- a/dapps/pos-app/app/scan.tsx +++ b/dapps/pos-app/app/scan.tsx @@ -17,6 +17,7 @@ import { } from "@/utils/currency"; import { formatCountdown, formatCountdownSpoken } from "@/utils/misc"; import { resetNavigation } from "@/utils/navigation"; +import { isNfcHceEnabled } from "@/utils/feature-flags"; import { AMOUNT_TOO_LOW, parseMinAmountCents } from "@/utils/payment-errors"; import { showErrorToast, showSuccessToast } from "@/utils/toast"; import { useAssets } from "expo-asset"; @@ -59,8 +60,9 @@ export default function ScanScreen() { const { nfcMode } = useNfcPayment({ paymentUrl: qrUri, - // HCE runs whenever the device supports it; `nfcEnabled` only controls UI visibility below. - enabled: true, + // NFC/HCE is gated by a build-time kill-switch (EXPO_PUBLIC_NFC_HCE_ENABLED). + // When off, no payment URL is emitted and the native side never enables HCE. + enabled: isNfcHceEnabled, onNfcReady: () => { addLog("info", "NFC HCE activated", "scan", "useNfcPayment", { paymentId, @@ -236,7 +238,7 @@ export default function ScanScreen() { }, [remainingSeconds, isCountdownActive]); const isProcessing = paymentStatusData?.status === "processing"; - const showNfc = nfcEnabled && nfcMode === "hce"; + const showNfc = isNfcHceEnabled && nfcEnabled && nfcMode === "hce"; return ( diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index c73f42831..4cb5bba88 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -17,6 +17,7 @@ import { ThemeMode } from "@/utils/types"; import { getBiometricLabel } from "@/utils/biometrics"; import { buildReceiptLogo } from "@/utils/build-receipt-logo"; import { CURRENCIES, CurrencyCode, getCurrency } from "@/utils/currency"; +import { isNfcHceEnabled } from "@/utils/feature-flags"; import { connectPrinter, printReceipt, @@ -165,7 +166,9 @@ export default function SettingsScreen() { }; const showNfcToggle = - Platform.OS === "android" && nfcCapabilities.isHceSupported; + isNfcHceEnabled && + Platform.OS === "android" && + nfcCapabilities.isHceSupported; const handleTestPrinterPress = async () => { try { diff --git a/dapps/pos-app/assets/images/nfc.png b/dapps/pos-app/assets/images/nfc.png index ec15b936a6b93ce16f5c166aeae1cb298e9a7599..05ea2461621e09957146bde10ffb146378dae2ef 100644 GIT binary patch literal 6405 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 TGG<+)00000NkvXXu0mjfasyW* literal 51919 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 diff --git a/dapps/pos-app/modules/hce/android/src/main/java/com/reown/mobilepos/hce/HceModule.kt b/dapps/pos-app/modules/hce/android/src/main/java/com/reown/mobilepos/hce/HceModule.kt index 8eea137eb..894c6b236 100644 --- a/dapps/pos-app/modules/hce/android/src/main/java/com/reown/mobilepos/hce/HceModule.kt +++ b/dapps/pos-app/modules/hce/android/src/main/java/com/reown/mobilepos/hce/HceModule.kt @@ -1,14 +1,33 @@ package com.reown.mobilepos.hce import android.content.Context +import android.content.pm.PackageManager import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class HceModule : Module() { + companion object { + private const val META_DATA_HCE_ENABLED = "com.reown.mobilepos.hce.HCE_ENABLED" + } + private val context: Context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() + // Build-time kill-switch injected by plugins/withHceFeatureFlag.js (kept in sync + // with the JS flag EXPO_PUBLIC_NFC_HCE_ENABLED). When false, HCE never starts. + private val hceEnabled: Boolean by lazy { + try { + val appInfo = context.packageManager.getApplicationInfo( + context.packageName, + PackageManager.GET_META_DATA + ) + appInfo.metaData?.getBoolean(META_DATA_HCE_ENABLED, false) ?: false + } catch (e: Exception) { + false + } + } + override fun definition() = ModuleDefinition { Name("HceModule") @@ -22,13 +41,20 @@ class HceModule : Module() { } OnActivityEntersForeground { - NfcManager.setActivity(appContext.currentActivity) - NfcManager.enable() + // Skip entirely when disabled: setActivity() registers the app as the + // preferred foreground HCE service (CardEmulation.setPreferredService), + // which must not happen when the feature is off. + if (hceEnabled) { + NfcManager.setActivity(appContext.currentActivity) + NfcManager.enable() + } } OnActivityEntersBackground { - NfcManager.disable() - NfcManager.setActivity(null) + if (hceEnabled) { + NfcManager.disable() + NfcManager.setActivity(null) + } } AsyncFunction("getNfcCapabilities") { diff --git a/dapps/pos-app/plugins/withHceFeatureFlag.js b/dapps/pos-app/plugins/withHceFeatureFlag.js new file mode 100644 index 000000000..0d2c2b4b5 --- /dev/null +++ b/dapps/pos-app/plugins/withHceFeatureFlag.js @@ -0,0 +1,32 @@ +const { withAndroidManifest } = require("@expo/config-plugins"); + +// Kept in sync with the JS flag in utils/feature-flags.ts. This injects the same +// value into the merged AndroidManifest as an meta-data entry so the +// native HCE module (com.reown.mobilepos.hce) can gate NfcManager.enable() without +// coupling to the app's variant-specific applicationId / BuildConfig. +const META_DATA_NAME = "com.reown.mobilepos.hce.HCE_ENABLED"; + +function setMetaData(application, name, value) { + if (!application["meta-data"]) { + application["meta-data"] = []; + } + const metaData = application["meta-data"]; + const existing = metaData.find((m) => m.$?.["android:name"] === name); + if (existing) { + existing.$["android:value"] = value; + } else { + metaData.push({ $: { "android:name": name, "android:value": value } }); + } +} + +const withHceFeatureFlag = (config) => + withAndroidManifest(config, (c) => { + const enabled = process.env.EXPO_PUBLIC_NFC_HCE_ENABLED === "true"; + const application = c.modResults.manifest.application?.[0]; + if (application) { + setMetaData(application, META_DATA_NAME, String(enabled)); + } + return c; + }); + +module.exports = withHceFeatureFlag; diff --git a/dapps/pos-app/utils/feature-flags.ts b/dapps/pos-app/utils/feature-flags.ts new file mode 100644 index 000000000..5d9c5752c --- /dev/null +++ b/dapps/pos-app/utils/feature-flags.ts @@ -0,0 +1,5 @@ +// NFC/HCE tap-to-pay kill-switch. Baked into the JS bundle at build time. +// The native side is gated by the matching injected by +// plugins/withHceFeatureFlag.js. Set EXPO_PUBLIC_NFC_HCE_ENABLED="true" to enable. +export const isNfcHceEnabled = + process.env.EXPO_PUBLIC_NFC_HCE_ENABLED === "true"; From e86db9b999d0bb93450c071ce7b378d7497f4f8b Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:02:33 -0300 Subject: [PATCH 12/42] feat(pos-app): centralize cta button text --- dapps/pos-app/app/amount.tsx | 9 ++---- dapps/pos-app/app/payment-failure.tsx | 4 +-- dapps/pos-app/app/payment-success.tsx | 32 +++++++------------- dapps/pos-app/app/scan.tsx | 4 +-- dapps/pos-app/app/settings.tsx | 21 ++------------ dapps/pos-app/components/button.test.tsx | 12 ++++---- dapps/pos-app/components/button.tsx | 37 +++++++++++++++++++++--- dapps/pos-app/components/empty-state.tsx | 4 +-- 8 files changed, 58 insertions(+), 65 deletions(-) diff --git a/dapps/pos-app/app/amount.tsx b/dapps/pos-app/app/amount.tsx index 38fd864ae..620cb3ce6 100644 --- a/dapps/pos-app/app/amount.tsx +++ b/dapps/pos-app/app/amount.tsx @@ -1,7 +1,6 @@ import { BigAmountInput } from "@/components/big-amount-input"; import { Button } from "@/components/button"; import { NumericKeyboard } from "@/components/numeric-keyboard"; -import { ThemedText } from "@/components/themed-text"; import { Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useSettingsStore } from "@/store/useSettingsStore"; @@ -129,11 +128,9 @@ export default function AmountScreen() { disabled={!isValid} style={styles.button} > - - {isValid - ? `Charge ${formatAmountWithSymbol(formatAmount(watchAmount), currency)}` - : "Enter amount"} - + {isValid + ? `Charge ${formatAmountWithSymbol(formatAmount(watchAmount), currency)}` + : "Enter amount"} ); diff --git a/dapps/pos-app/app/payment-failure.tsx b/dapps/pos-app/app/payment-failure.tsx index 1fb32f4c2..e19e63c5b 100644 --- a/dapps/pos-app/app/payment-failure.tsx +++ b/dapps/pos-app/app/payment-failure.tsx @@ -81,9 +81,7 @@ export default function PaymentFailureScreen() { ); diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index 7cb08875f..53c2137fe 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -257,28 +257,20 @@ export default function PaymentSuccessScreen() { variant="tertiary" onPress={handlePrintReceipt} disabled={isPrinting} - style={styles.button} + icon={ + + } > - - {isPrinting ? "Printing receipt…" : "Print receipt"} - - + {isPrinting ? "Printing receipt…" : "Print receipt"} )} - @@ -333,10 +325,6 @@ const styles = StyleSheet.create({ width: "100%", gap: Spacing["spacing-3"], }, - button: { - flexDirection: "row", - gap: Spacing["spacing-2"], - }, buttonIcon: { width: 16, height: 16, diff --git a/dapps/pos-app/app/scan.tsx b/dapps/pos-app/app/scan.tsx index 43f3be5fc..4d26ca521 100644 --- a/dapps/pos-app/app/scan.tsx +++ b/dapps/pos-app/app/scan.tsx @@ -343,9 +343,7 @@ export default function ScanScreen() { fullWidth={false} style={styles.cancelButton} > - - Cancel - + Cancel )} diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index 4cb5bba88..39e9c551d 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -393,14 +393,7 @@ export default function SettingsScreen() { onPress={handleMerchantIdSave} disabled={isMerchantIdConfirmDisabled} > - - Save - + Save @@ -442,14 +435,7 @@ export default function SettingsScreen() { onPress={handleCustomerApiKeySave} disabled={isCustomerApiKeyConfirmDisabled} > - - Save - + Save @@ -522,7 +508,4 @@ const styles = StyleSheet.create({ fontFamily: "KH Teka", height: 60, }, - saveButtonLabel: { - textAlign: "center", - }, }); diff --git a/dapps/pos-app/components/button.test.tsx b/dapps/pos-app/components/button.test.tsx index ffaa38529..52503e3f5 100644 --- a/dapps/pos-app/components/button.test.tsx +++ b/dapps/pos-app/components/button.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { render } from "@testing-library/react-native"; -import { StyleSheet, Text } from "react-native"; +import { StyleSheet } from "react-native"; import { Button } from "@/components/button"; import { Colors } from "@/constants/theme"; @@ -24,6 +24,8 @@ jest.mock("react-native", () => ({ jest.mock("@/hooks/use-theme-color", () => ({ useTheme: () => mockColors.light, + useThemeColor: (colorName: keyof typeof mockColors.light) => + mockColors.light[colorName], })); jest.mock("./pressable", () => { @@ -38,7 +40,7 @@ jest.mock("./pressable", () => { function getButtonStyle(testID: string) { const button = render( , ).getByTestId(testID); @@ -65,7 +67,7 @@ describe("Button", () => { testID="secondary-button" onPress={() => {}} > - Cancel + Cancel , ).getByTestId("secondary-button"); @@ -84,7 +86,7 @@ describe("Button", () => { testID="tertiary-button" onPress={() => {}} > - Print + Print , ).getByTestId("tertiary-button"); @@ -102,7 +104,7 @@ describe("Button", () => { testID="disabled-button" onPress={() => {}} > - Disabled + Disabled , ).getByTestId("disabled-button"); diff --git a/dapps/pos-app/components/button.tsx b/dapps/pos-app/components/button.tsx index 7efd535cf..1266c6393 100644 --- a/dapps/pos-app/components/button.tsx +++ b/dapps/pos-app/components/button.tsx @@ -1,11 +1,13 @@ -import { BorderRadius } from "@/constants/spacing"; +import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import React from "react"; -import { StyleProp, StyleSheet, ViewStyle } from "react-native"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; import { Pressable } from "./pressable"; +import { ThemedText } from "./themed-text"; interface ButtonBaseProps { - children: React.ReactNode; + children: string; + icon?: React.ReactNode; style?: StyleProp; onPress: () => void; disabled?: boolean; @@ -19,6 +21,7 @@ export type ButtonProps = export function Button({ children, + icon, style, onPress, disabled = false, @@ -43,6 +46,13 @@ export function Button({ backgroundColor: theme["bg-invert"], }; + const textColor = + type === "accent" + ? "text-white" + : variant === "secondary" + ? "text-primary" + : "text-invert"; + return ( - {children} + + + {children} + + {icon} + ); } @@ -68,6 +88,15 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, + content: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: Spacing["spacing-2"], + }, + label: { + textAlign: "center", + }, fullWidth: { width: "100%", }, diff --git a/dapps/pos-app/components/empty-state.tsx b/dapps/pos-app/components/empty-state.tsx index f5df56bb7..fcacb3c54 100644 --- a/dapps/pos-app/components/empty-state.tsx +++ b/dapps/pos-app/components/empty-state.tsx @@ -41,9 +41,7 @@ function EmptyStateBase({ title, subtitle, icon, cta }: EmptyStateProps) { onPress={cta.onPress} style={styles.cta} > - - {cta.label} - + {cta.label} )} From c54adeb67d3323b236b324ce91411af109459b50 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:04:39 -0300 Subject: [PATCH 13/42] feat(pos): show text titles in Settings and Transactions headers Override the global WPay logo headerTitle with themed text titles on the settings and activity screens. Co-Authored-By: Claude Opus 4.8 (1M context) --- dapps/pos-app/app/_layout.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index f916a97e5..b2d2ef275 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -12,6 +12,7 @@ import "react-native-reanimated"; import Toast from "react-native-toast-message"; import HeaderImage from "@/components/header-image"; +import { ThemedText } from "@/components/themed-text"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { useFonts } from "expo-font"; @@ -74,6 +75,12 @@ Sentry.init({ const queryClient = new QueryClient(); +const renderHeaderTitle = (title: string) => () => ( + + {title} + +); + export default Sentry.wrap(function RootLayout() { const colorScheme = useColorScheme(); @@ -254,8 +261,14 @@ export default Sentry.wrap(function RootLayout() { }, }} /> - - + + From 05528cd2d7e13116c3adc63e3a724b7494261162 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:05:53 -0300 Subject: [PATCH 14/42] feat(pos): show text title in Logs header Override the global WPay logo headerTitle with a themed text title on the logs screen, matching Settings and Transactions. Co-Authored-By: Claude Opus 4.8 (1M context) --- dapps/pos-app/app/_layout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index b2d2ef275..a1991489a 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -269,7 +269,10 @@ export default Sentry.wrap(function RootLayout() { name="activity" options={{ headerTitle: renderHeaderTitle("Transactions") }} /> - + Date: Thu, 20 Aug 2026 11:32:31 -0300 Subject: [PATCH 15/42] feat(pos): make API request log titles route-based and concise Log successful requests as " " instead of the identical "API request successful", and mask id-like path segments to ":id" so titles stay short and group by route. Extract maskPathIds into utils/api with tests; keep the full endpoint + method in the log data. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pos-app/__tests__/services/client.test.ts | 5 +-- dapps/pos-app/services/client.ts | 24 ++++++++---- dapps/pos-app/utils/api.test.ts | 39 +++++++++++++++++++ dapps/pos-app/utils/api.ts | 34 ++++++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 dapps/pos-app/utils/api.test.ts create mode 100644 dapps/pos-app/utils/api.ts diff --git a/dapps/pos-app/__tests__/services/client.test.ts b/dapps/pos-app/__tests__/services/client.test.ts index 8a05e0f69..474d825ed 100644 --- a/dapps/pos-app/__tests__/services/client.test.ts +++ b/dapps/pos-app/__tests__/services/client.test.ts @@ -325,12 +325,11 @@ describe("ApiClient", () => { await apiClient.get("/test"); const logs = useLogsStore.getState().logs; - const apiLog = logs.find( - (log) => log.message === "API request successful", - ); + const apiLog = logs.find((log) => log.message === "GET /test"); expect(apiLog).toBeDefined(); expect(apiLog?.level).toBe("info"); expect(apiLog?.view).toBe("api"); + expect(apiLog?.data?.method).toBe("GET"); }); it("should log failed API requests", async () => { diff --git a/dapps/pos-app/services/client.ts b/dapps/pos-app/services/client.ts index 0f02ee2c8..199f320c0 100644 --- a/dapps/pos-app/services/client.ts +++ b/dapps/pos-app/services/client.ts @@ -1,5 +1,6 @@ import { useLogsStore } from "@/store/useLogsStore"; import { useSettingsStore } from "@/store/useSettingsStore"; +import { maskPathIds } from "@/utils/api"; import { ApiError } from "@/utils/types"; const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL; @@ -33,6 +34,7 @@ class ApiClient { ? endpoint : `/${endpoint}`; const url = `${normalizedBaseUrl}${normalizedEndpoint}`; + const method = fetchOptions.method ?? "GET"; const requestHeaders: HeadersInit = { "Content-Type": "application/json", @@ -72,11 +74,18 @@ class ApiClient { const data = await response.json(); useLogsStore .getState() - .addLog("info", "API request successful", "api", "request", { - endpoint, - body, - response: data, - }); + .addLog( + "info", + `${method} ${maskPathIds(normalizedEndpoint)}`, + "api", + "request", + { + method, + endpoint, + body, + response: data, + }, + ); return data as T; } catch (error) { clearTimeout(timeoutId); @@ -90,6 +99,7 @@ class ApiClient { useLogsStore .getState() .addLog("error", timeoutError.message, "api", "request", { + method, endpoint, body, }); @@ -105,7 +115,7 @@ class ApiClient { apiError.message || "API request failed", "api", "request", - { endpoint, body, response: error }, + { method, endpoint, body, response: error }, ); throw error; } @@ -113,7 +123,7 @@ class ApiClient { error instanceof Error ? error.message : "An unexpected error occurred"; useLogsStore .getState() - .addLog("error", errorMessage, "api", "request", { endpoint }); + .addLog("error", errorMessage, "api", "request", { method, endpoint }); const apiError: ApiError = { message: errorMessage, }; diff --git a/dapps/pos-app/utils/api.test.ts b/dapps/pos-app/utils/api.test.ts new file mode 100644 index 000000000..65625eccf --- /dev/null +++ b/dapps/pos-app/utils/api.test.ts @@ -0,0 +1,39 @@ +import { maskPathIds } from "./api"; + +describe("maskPathIds", () => { + it("masks prefixed ids", () => { + expect( + maskPathIds("/payments/pay_57a2ecc101M0FRYW0FDAAQFQ7SEJT8S0CS/cancel"), + ).toBe("/payments/:id/cancel"); + }); + + it("masks ids in the middle of a path", () => { + expect( + maskPathIds("/merchant/payment/pay_57a2ecc101M0FRYW0FD/status"), + ).toBe("/merchant/payment/:id/status"); + }); + + it("masks UUIDs", () => { + expect(maskPathIds("/orders/2f3d8c1a-1b2c-4d5e-8f90-abcdef123456")).toBe( + "/orders/:id", + ); + }); + + it("masks long opaque tokens mixing letters and digits", () => { + expect(maskPathIds("/session/aB3xK9mQ2wL7pR4t")).toBe("/session/:id"); + }); + + it("leaves plain route names untouched", () => { + expect(maskPathIds("/merchant/payment")).toBe("/merchant/payment"); + expect(maskPathIds("/start")).toBe("/start"); + }); + + it("does not mask short or word-only segments", () => { + expect(maskPathIds("/paymentmethods")).toBe("/paymentmethods"); + expect(maskPathIds("/v1/status")).toBe("/v1/status"); + }); + + it("preserves leading slash and empty segments", () => { + expect(maskPathIds("/")).toBe("/"); + }); +}); diff --git a/dapps/pos-app/utils/api.ts b/dapps/pos-app/utils/api.ts new file mode 100644 index 000000000..414791749 --- /dev/null +++ b/dapps/pos-app/utils/api.ts @@ -0,0 +1,34 @@ +/** + * Replace ID-like segments of a URL path with `:id` so it stays short and + * paths to the same route group together (e.g. + * `/merchant/payment/pay_57a2.../status` -> `/merchant/payment/:id/status`). + * Masks prefixed ids (`pay_...`), UUIDs, and long opaque tokens. Keep the + * original path alongside (e.g. in a log's `data`) when the real id is needed. + */ +export function maskPathIds(path: string): string { + return path + .split("/") + .map((segment) => { + if (!segment) return segment; + // Prefixed ids: pay_..., cus_..., mer_..., etc. + if (/^[a-z]+_[A-Za-z0-9]+$/.test(segment)) return ":id"; + // UUIDs + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + segment, + ) + ) { + return ":id"; + } + // Long opaque tokens mixing letters and digits + if ( + segment.length >= 16 && + /[A-Za-z]/.test(segment) && + /\d/.test(segment) + ) { + return ":id"; + } + return segment; + }) + .join("/"); +} From 467e656af32ee745153f59c667fff964eb15a442 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:32:36 -0300 Subject: [PATCH 16/42] feat(pos): redesign logs screen with expandable cards and clear confirmation Add a centered empty state, redesign log cards with per-level badges and tap-to-expand JSON (data-bearing entries only), pin the Clear logs button to the bottom, and gate clearing behind a confirmation bottom sheet. Co-Authored-By: Claude Opus 4.8 (1M context) --- dapps/pos-app/app/logs.tsx | 281 ++++++++++++------ dapps/pos-app/assets/images/chevron-down.png | Bin 0 -> 445 bytes dapps/pos-app/assets/images/scroll.png | Bin 0 -> 1622 bytes dapps/pos-app/assets/images/trash.png | Bin 0 -> 1179 bytes dapps/pos-app/components/clear-logs-modal.tsx | 182 ++++++++++++ 5 files changed, 379 insertions(+), 84 deletions(-) create mode 100644 dapps/pos-app/assets/images/chevron-down.png create mode 100644 dapps/pos-app/assets/images/scroll.png create mode 100644 dapps/pos-app/assets/images/trash.png create mode 100644 dapps/pos-app/components/clear-logs-modal.tsx diff --git a/dapps/pos-app/app/logs.tsx b/dapps/pos-app/app/logs.tsx index bfd3a1a39..81e4219b2 100644 --- a/dapps/pos-app/app/logs.tsx +++ b/dapps/pos-app/app/logs.tsx @@ -1,10 +1,24 @@ -import { Card } from "@/components/card"; +import { Button } from "@/components/button"; +import { ClearLogsModal } from "@/components/clear-logs-modal"; +import { EmptyState } from "@/components/empty-state"; +import { Pressable } from "@/components/pressable"; import { ThemedText } from "@/components/themed-text"; -import { Spacing } from "@/constants/spacing"; +import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { LogEntry, useLogsStore } from "@/store/useLogsStore"; -import { useCallback } from "react"; +import { Image } from "expo-image"; +import { useCallback, useEffect, useState } from "react"; import { FlatList, StyleSheet, View } from "react-native"; +import Animated, { + Easing, + FadeIn, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +const ANIMATION_DURATION = 200; +const EASING = Easing.inOut(Easing.ease); const formatTimestamp = (timestamp: number): string => { const date = new Date(timestamp); @@ -17,74 +31,137 @@ const formatTimestamp = (timestamp: number): string => { }); }; -const getLevelColor = (level: LogEntry["level"]): string => { +interface LevelBadge { + bg: string; + text: string; + label: string; +} + +const getLevelBadge = ( + level: LogEntry["level"], + theme: ReturnType, +): LevelBadge => { switch (level) { case "error": - return "#DF4A34"; + return { + bg: "rgba(223, 74, 52, 0.12)", + text: theme["icon-error"], + label: "Error", + }; case "info": - return "#0988F0"; - case "log": default: - return "#0988F0"; + return { + bg: theme["bg-invert"], + text: theme["text-invert"], + label: "Info", + }; } }; function LogItem({ item }: { item: LogEntry }) { - const Theme = useTheme(); - const levelColor = getLevelColor(item.level); + const theme = useTheme(); + const [expanded, setExpanded] = useState(false); + const hasData = !!item.data; + const badge = getLevelBadge(item.level, theme); const context = item.view && item.functionName ? `${item.view}:${item.functionName}` : item.view || item.functionName || ""; - return ( - - - - - {item.level.toUpperCase()} + const rotation = useSharedValue(0); + + useEffect(() => { + rotation.value = withTiming(expanded ? 180 : 0, { + duration: ANIMATION_DURATION, + easing: EASING, + }); + }, [expanded, rotation]); + + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + const inner = ( + <> + + + + + + {badge.label} + + + + {formatTimestamp(item.timestamp)} + + + + {item.message} + {context ? ( + + {context} + + ) : null} - - {formatTimestamp(item.timestamp)} - + {hasData ? ( + + + + ) : null} - {context ? ( - - {context} - - ) : null} - - {item.message} - - {item.data ? ( - - {JSON.stringify(item.data, null, 2)} - + {expanded && hasData ? ( + + + {JSON.stringify(item.data, null, 2)} + + ) : null} - + + ); + + const cardStyle = [ + styles.logItem, + { backgroundColor: theme["foreground-primary-fix"] }, + ]; + + if (!hasData) { + return {inner}; + } + + return ( + setExpanded((v) => !v)} style={cardStyle}> + {inner} + ); } export default function LogsScreen() { + const theme = useTheme(); const logs = useLogsStore((state) => state.logs); const clearLogs = useLogsStore((state) => state.clearLogs); + const [confirmVisible, setConfirmVisible] = useState(false); + const reversedLogs = [...logs].reverse(); const renderItem = useCallback( @@ -94,29 +171,53 @@ export default function LogsScreen() { const keyExtractor = useCallback((item: LogEntry) => item.id, []); + const handleConfirmClear = useCallback(() => { + clearLogs(); + setConfirmVisible(false); + }, [clearLogs]); + return ( - - - Clear logs - - - {reversedLogs.length === 0 ? ( - - - No logs yet - - - ) : ( - + } /> + ) : ( + <> + + + )} + + setConfirmVisible(false)} + /> ); } @@ -128,42 +229,54 @@ const styles = StyleSheet.create({ paddingHorizontal: Spacing["spacing-5"], }, clearButton: { - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - height: 50, - marginBottom: Spacing["spacing-3"], + marginTop: Spacing["spacing-3"], + }, + list: { + flex: 1, }, listContent: { paddingBottom: Spacing["extra-spacing-2"], gap: Spacing["spacing-2"], }, logItem: { - padding: Spacing["spacing-3"], - borderRadius: 8, + padding: Spacing["spacing-4"], + borderRadius: BorderRadius["3"], + }, + logRow: { + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-3"], + }, + logContent: { + flex: 1, }, logHeader: { flexDirection: "row", alignItems: "center", gap: Spacing["spacing-2"], - marginBottom: Spacing["spacing-1"], + marginBottom: Spacing["spacing-2"], }, levelBadge: { - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, + padding: Spacing["spacing-1"] + Spacing["spacing-05"], + borderRadius: BorderRadius["2"], }, - context: { - marginBottom: Spacing["spacing-1"], - fontStyle: "italic", + levelText: { + fontWeight: "500", + }, + chevron: { + width: 20, + height: 20, + }, + message: { + fontWeight: "500", + marginBottom: Spacing["spacing-05"], }, data: { - marginTop: Spacing["spacing-2"], + marginTop: Spacing["spacing-3"], fontFamily: "monospace", }, - emptyState: { - flex: 1, - justifyContent: "center", - alignItems: "center", + emptyIcon: { + width: 64, + height: 64, }, }); diff --git a/dapps/pos-app/assets/images/chevron-down.png b/dapps/pos-app/assets/images/chevron-down.png new file mode 100644 index 0000000000000000000000000000000000000000..b41fe435cef816e422cee70582f2a49971efb65f GIT binary patch literal 445 zcmeAS@N?(olHy`uVBq!ia0vp^W+2SL1|)l2v+e>Z&H|6fVg?393lL^>oo1K-6l5$8 za(7}_cTVOdki(Mh=f<{mN-XnUBgp3oxQD0qw4%IW3`=_!J{7`01S`VVmIU2nC7qw(#LayO6r z%8{NrGQX!j;8${ifGgiFeO3&u-F$l8luye1N$EFkK0SPF=X05}2GtLj$L{<4@%pjd zdw=K%S6(@J?A~&|vbN;hv#YnfUH{I1-$fDgMgNvZzpgs8FK5cp&s%m_2Od4PaqG74 z=F9dq>MaZ2KW(bUo5-lwyARbUPxUNZ9C$(Yne-}^1`=$tLc zx-c=I`}6NF8XWx1UNTGE9la#(bVh4U5@F3u4CvIJB*D5eu|Rp9N0G>q_QvC}|KcoC f+y%kFw$VO=_1Kjy`U_V9LzltR)z4*}Q$iB}iDk2d literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/scroll.png b/dapps/pos-app/assets/images/scroll.png new file mode 100644 index 0000000000000000000000000000000000000000..decc5ae2f36e1b51028c4232ed7a387a55a46d34 GIT binary patch literal 1622 zcmV-c2C4apP)R#}Q*0p_?e#WKHbwq z0000000000000000PsXHrO@n+ns*KAvZz-=($U|gNuvLO`GpRIX-Fg=#iNVcgVvBr zlqlf(+fiGwIuw>o1R7hvCUW%uL90(Ch#m0i`>R6{=@F#D#Alv(M@3)JkpVwmwfsSd z)(Mt1R7$qK{Mi~%K|%vI;WG2T`oC?0Y%q-#%SKTl2L|kSr0V>M5N`H#$l}TGMKsqc z=o5Dhbw~&|AG(8TT1ImbQO;HteQ@pX(Yoi!F2ys{9f&@O_u0H7%zx-N9fBzB`{!hS z$34o?!X~=$Zfx$moro_Scp`hZ3%dX2-mTws^tMSS;TPQ!tcdQUVayze^%(j3pw*+C%-w^frgkE}rXMF<<3aZnb!TFW zAkgHNmYexb7E9=OS6{u`lz?f%^n7*P$4rnE@F?0X`&-vtk4^F z%}l^w$<#4JcqllyW+q_20~K`OO?TZfz4- zFK`^}Jr)UuL7yka#Dxn212#jZuPQiX6{i`8(uK}s`Q<*54f-);(*a}I`*P4aBe32| z)C(rnqnD<5Z{A!-E!5W?z@yueHgy^P z&JUqYs_~(%3kzKhxPS9kbAw7Y>42W-pa+vJKGt!vi8fa*S!B|R^vOa_23%&d9E3$a zl7*ZM*vdr*tbQX4IT`R|PmeXsADsUv3k$h<4_3URJ2oA%fC=}$u8zsl>YAIq=*jlW z#=CL9%zGcRdU40n)xADz*QvQY4mLW%+@X?)S+XuQ1j<-(XyF3bSRg&9M+Fg}zEvx0JAc2F*i59Puop%0aqLXt~dl-aR|8LT%XCR23?8z^{2V@l{*?_@vxgz=Le?|=PV|e zrRUpw*ZVJ)THJsp+G39neZ4xSB_LqiE&2Fn1ndBFC9l32ZBhyd*b!3Wf6^>fLcp#N zYNlDLgn%7lmMT#~l^X{$%a-T4`$$bz{2_3cg#Nf$if782B~;joj;S11b_{h- z{hSwb?p&I4SSE^hXlTa-}YQo28$^56qJw8ONP2kg^3?7thPtS{7@_X4q2i@hzPEwnmC=;w637lqm zbgwl5Pni`9x~9A7F`K$)o2ni7(EL*(|LHOOh06;S0000000000000000Khum0gQw= U!%tM{`Tzg`07*qoM6N<$f|WY!iU0rr literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/trash.png b/dapps/pos-app/assets/images/trash.png new file mode 100644 index 0000000000000000000000000000000000000000..9c81c722e3b9ca4bfd5e673f00c1e71d469c516f GIT binary patch literal 1179 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q{^b&H|6fVg?393lL^>oo1K-6l5$8 za(7}_cTVOdki(Mh=xsLnA@;f_K@(-Gk#Q+EaXE6h>QxjNO{z_RfP%R{zF z!Y#cWVNTC;jnt-1ow@N%=8L-8x=^uc@(v%DxBqNn>i~lna?LEJ{5i!|uD`GQZ}H{z zWl2ew*dKAQAeZ-W;r*V8GnO!|e|xiUtHrFdDr-2UzwPHam&LaCi6rwQiAET4P#c!sNW(`MJtszMWVSFv{lY*DUAdn_mU{btAe>^aI6x6NLc z{?Yg)eA7p2hW%_EW|orenQHNKo9v}eXMc3-S$|Z!W5Sz|jMI-Sr*~HuCz)+zxG9{m zVOO?3m(uz2Jv;V0r!0zbIel;OAzvl)qKyfu+at^7Pggy6K;xeG)*jJWn{MmuK7U&1 zYnsr{8ge=^VH_3Im-ZtnXQ<+A(lh92p@>YS$u-PaX^)*mqXW8dI(yPf_1 z)HPxnChOm3?-$H@blY@!@B8F8(iM@}o6olaS)w=A9^0L~Q0C;-;{Ht0<1?FO_13(< zp*P{2;a%0&cjwL0V|p-82k1IWwy?g&sbGgB`xp81m<&lmpL(N+F^ z=j#`yQWbM!%P$?@y||`4ac28_y)$;|i}!C!Z{IAb_rS3!Z)fwH?f=(RH+(7=chf#8 zIHz2yyZfQ>tvmmp&2^mk>ZPYAo1Rtg(dTzrUH?nJb~l;w&}&)|&l(wK?c)bd8E&8o zGiFlb-5TGW`%NmE)s58ZnRv{VErq6}JlM0d<=I2qd#(ApcF$}ppOn9!xFAIQ;_p1G zy}#`X64kE%VA;R2?%DN^21RD2@#36bpNy?sF4u}3f3p(VA!h0=bQLlU!G9a;=53^aPw^ssq2gIg)ltan^?`R z`b_w!(^9kM5bL@`j;bjW`a>^#76~hVyHv&?zp+TN`Qq8mn+^(&@y)956jubixEa&L zw$Ijd{!^usFEhlJnA@7Ab7UwkpLDf6CtR=3p?>GgTX}xI@{PK0VjRmKRg}GY8T~Ws sai!ErrJvn3UpL>p1mdKI;Vst0O_P1F8}}l literal 0 HcmV?d00001 diff --git a/dapps/pos-app/components/clear-logs-modal.tsx b/dapps/pos-app/components/clear-logs-modal.tsx new file mode 100644 index 000000000..fc3bbf2f6 --- /dev/null +++ b/dapps/pos-app/components/clear-logs-modal.tsx @@ -0,0 +1,182 @@ +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useTheme } from "@/hooks/use-theme-color"; +import { Image } from "expo-image"; +import { memo, useEffect } from "react"; +import { Platform, Pressable, StyleSheet, View } from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "./button"; +import { FramedModal } from "./framed-modal"; +import { Pressable as ScalePressable } from "./pressable"; +import { ThemedText } from "./themed-text"; + +const ANIMATION_DURATION = 200; +const EASING = Easing.inOut(Easing.ease); + +interface ClearLogsModalProps { + visible: boolean; + count: number; + onConfirm: () => void; + onClose: () => void; +} + +function ClearLogsModalBase({ + visible, + count, + onConfirm, + onClose, +}: ClearLogsModalProps) { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + + const translateY = useSharedValue(Platform.OS === "web" ? 300 : 0); + + useEffect(() => { + if (Platform.OS !== "web") return; + translateY.value = withTiming(visible ? 0 : 300, { + duration: ANIMATION_DURATION, + easing: EASING, + }); + }, [visible, translateY]); + + const sheetAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + return ( + + + + + + + + + + + + + + + {`You're about to clear ${count} log ${ + count === 1 ? "entry" : "entries" + }`} + + + { + "This can't be undone. We don't keep a copy, so copy them first if support needs them." + } + + + + + + + + + + + + ); +} + +export const ClearLogsModal = memo(ClearLogsModalBase); + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + container: { + borderTopLeftRadius: BorderRadius["8"], + borderTopRightRadius: BorderRadius["8"], + }, + containerInner: { + paddingTop: Spacing["spacing-4"], + paddingHorizontal: Spacing["spacing-5"], + }, + header: { + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + }, + closeButton: { + borderRadius: BorderRadius["3"], + borderWidth: StyleSheet.hairlineWidth, + alignItems: "center", + justifyContent: "center", + padding: Spacing["spacing-3"], + }, + closeIcon: { + width: 20, + height: 20, + }, + body: { + alignItems: "center", + paddingHorizontal: Spacing["spacing-4"], + marginTop: Spacing["spacing-2"], + marginBottom: Spacing["spacing-7"], + }, + trashIcon: { + width: 40, + height: 40, + marginBottom: Spacing["spacing-3"], + }, + title: { + textAlign: "center", + marginBottom: Spacing["spacing-2"], + }, + description: { + textAlign: "center", + }, + actions: { + gap: Spacing["spacing-3"], + }, +}); From a350f113fc561f47995036a8f9eb06d69939ebcf Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:36:06 -0300 Subject: [PATCH 17/42] fix(pos): guard Android-only Bluetooth permission request by platform BLUETOOTH_CONNECT is an Android 12+ runtime permission with no iOS/web handler, so requesting it unconditionally crashed the iOS Simulator with "No permission handler detected". Only request it on Android; iOS relies on the NSBluetoothAlwaysUsageDescription auto-prompt and web is stubbed. Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/utils/printer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dapps/pos-app/utils/printer.ts b/dapps/pos-app/utils/printer.ts index 8221348c9..7896d1921 100644 --- a/dapps/pos-app/utils/printer.ts +++ b/dapps/pos-app/utils/printer.ts @@ -1,5 +1,6 @@ import { DEFAULT_LOGO_BASE64 } from "@/constants/printer-logos"; import { useLogsStore } from "@/store/useLogsStore"; +import { Platform } from "react-native"; import { PERMISSIONS, request, RESULTS } from "react-native-permissions"; import { ReactNativePosPrinter, @@ -13,6 +14,9 @@ import { import { getDate } from "./misc"; export const requestBluetoothPermission = async () => { + // BLUETOOTH_CONNECT is an Android 12+ runtime permission. On iOS/web there is + // no such handler, so requesting it throws. Only ask for it on Android. + if (Platform.OS !== "android") return true; const result = await request(PERMISSIONS.ANDROID.BLUETOOTH_CONNECT); return result === RESULTS.GRANTED || result === RESULTS.LIMITED; }; From 6cc7dfb154f3c8c09e3c49c63142b55040833a78 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:10:44 -0300 Subject: [PATCH 18/42] feat(pos): redesign toast as inverted pill at top of screen Rework the toast to match the WalletConnect Library Figma design: - Content-hugging pill (radius 64) with the label on the left and a 28px circular status badge on the right; soft drop shadow, no border. - Inverted surface: bg-invert + text-invert, so the toast reads as a black pill in light mode and a white pill in dark mode. - Per-type badges (info/warning/error/success) exported from Figma, plus a loading variant backed by ActivityIndicator. - Fix toastConfig so each type renders its own visual (info/warning were previously mismapped to success/error). - Label shrinks and the badge holds its size so long, wrapping messages no longer push the icon out of view. - Move the toast host to the top with a safe-area-aware topOffset. - Drop image assets no longer referenced anywhere (check_circle, error, clock, scan, wallet). Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/_layout.tsx | 4 +- dapps/pos-app/assets/images/check_circle.png | Bin 948 -> 0 bytes dapps/pos-app/assets/images/clock.png | Bin 1840 -> 0 bytes dapps/pos-app/assets/images/error.png | Bin 746 -> 0 bytes dapps/pos-app/assets/images/scan.png | Bin 578 -> 0 bytes dapps/pos-app/assets/images/toast-error.png | Bin 0 -> 1401 bytes dapps/pos-app/assets/images/toast-info.png | Bin 0 -> 1326 bytes dapps/pos-app/assets/images/toast-success.png | Bin 0 -> 1377 bytes dapps/pos-app/assets/images/toast-warning.png | Bin 0 -> 1405 bytes dapps/pos-app/assets/images/wallet.png | Bin 737 -> 0 bytes dapps/pos-app/components/toast.tsx | 89 ++++++++++++------ dapps/pos-app/utils/toasts.tsx | 7 +- 12 files changed, 65 insertions(+), 35 deletions(-) delete mode 100644 dapps/pos-app/assets/images/check_circle.png delete mode 100644 dapps/pos-app/assets/images/clock.png delete mode 100644 dapps/pos-app/assets/images/error.png delete mode 100644 dapps/pos-app/assets/images/scan.png create mode 100644 dapps/pos-app/assets/images/toast-error.png create mode 100644 dapps/pos-app/assets/images/toast-info.png create mode 100644 dapps/pos-app/assets/images/toast-success.png create mode 100644 dapps/pos-app/assets/images/toast-warning.png delete mode 100644 dapps/pos-app/assets/images/wallet.png diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index a1991489a..45371361b 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -277,8 +277,8 @@ export default Sentry.wrap(function RootLayout() { diff --git a/dapps/pos-app/assets/images/check_circle.png b/dapps/pos-app/assets/images/check_circle.png deleted file mode 100644 index 2949157812c446969b3c7f45d08e25e5d76acc9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 948 zcmV;l155mgP)oq$*hlGz*6OoIxcUC`D(`>f7PdH1cbJLGHOKsKI~zPCPz9uD z=kb`C!p7dO4<7H;V1VZG!z^!^?L6WpPh1bxVFmHu!>mTB78@_VeN>T*A(?KmQAANq zGWwvSe!s@qysw1+e51+O7mqOcBBccbyGi ze4>2{*;a}xQ52(b9%6v}toRqZ-rWxp7tV-tDoRAEyBdqhYnU*+?Q6w3w^aOFbyqhO zLzv+y#XVSFb3gno1i+@fBf`4kFzePE-h3N5rkXE zZQaxK1)5wns)|Hv-%^E*6Sx3XgDNpuo2JLv9w_KI1gJnDTPOq)M2qjjjzdf{ z-aX!?OWu?8U;+=h2E~0bE-Q0@MaBOkN4)VWDfJQ3n`n@1JxI=@u3a5Ds;Q}|vEm=g Wvwb)3eus|$0000aeW< diff --git a/dapps/pos-app/assets/images/clock.png b/dapps/pos-app/assets/images/clock.png deleted file mode 100644 index 80e24895f94c81624b07e093289b9c3e4b096ab3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1840 zcmV-02haG4P)a)OW(1WvGam4V_3I00~iz!L?g%H9*eo*-}obAr|0fM@>h_p#%| z$UV}GG?FbvzbaLnku1p{J<~HiJtNZ6($dn>($do6z=F&htsSI*zr4g@sQjTEnqw4vl+U7r(x~^0-@ET)bIYS{gy@dzmpa6ODe{FAX52ZpX6ZGQZqJw`9aYRxr zF^=P^ge_gre!tHuJ&@{!OcF~4!j>i|+sMbqM+&*&@wJ!^2F~dYRrbyCz}dv(leph1 z%HEEI1B+ZAfcprcnFImWf!t*xya z2~2Q7A?6`|)KSJ|_qgU1BA$fq>KJkN@$s8vST008f~dz5kf4IX@vw?_QZ6Y?NR9LJ z^CMcVqzH@iClK{O0ue+|)2F;jt8S+4I;gtyO^d~|$HJ=c-Fm%VR{{`3P*^70OPlkd zbJ;ATz49Te+ZIaH!h)jB{0__9x11g=FE4M-%lO;0uRo+jQ5U5E#sR5fa+Crwzs^%I zV|RBq#yfrHyJ_dq+vxB&NdaYF9!aiF=XJjAp$?lHPV`w|GP*MN5Jiv)u2 z3PDH8smP4-yzv&)SWQPc6`MA5<}pcE_^#;gZQ`T$&Kn%@i6?n1)YfPp&yxdkg^r5d zI_;bZ>UiNLsIksy>L%!JR2_E=2_2Bd^GD`4JL7AinsvsE-bl?cZNFCay}D7(g4(Q; zf7v=#^(1Uh8D~&VMLun*v!J%_1-eK_(hwN5k%Ct0Bq;lxS{DW)UrU3qSt)-z13)hK za1sXa}hLB9yXHZGxZxSwe=mDfOi55z6rc+vyY^H94 zF2u}2bhHx87UYUmPTb=*E0|-_C6mR)1my@xE60Oo8S{)d_anvVAX>kQVe^UY0(f)= zeWuEp0G#HKb4BdYWvWdO*#<1I&}5vALbIM_V`<0cnz=oV;OS)oAo`H zkY0H!R|WyqZ=D6CPWDVOYo>gHc=@+}Te8e&Ca_M}W_Yz}5e+9nQ)U7y?;nx@tNWE%3#L0{vYLm_Xy{GtW#d7 zMoLz8jF`elal|*;q-EK0-Q{|BDHeyN)1&l$<0)4Ww+R!*z&2w3Mq-c+MPYdy{K=|js)&1Cg)7Fjz@pq3pM$;vLg zcs6Z`pZr?86w4f7`kGu$X-oP#MY~yOpb}(xcfa5Nk+N1kmI`;Jv0>Jx(lTvM^BV8C zDFtzvq|3g^($Z#1HHvTy@PodTxp|6>aTSM{*7z;Q>i7-wvjM>vH1v#0?WJ78Rcpi0 z`+R7OcP=x_&#hg#kc)juP(ewWqnd!iUUShyXp40fk`#478o>o6roCc+G|IYmyrmjb z%=3!N5gMmtIE&!8%}{CEc9bG0aoIdYA;vM{Oe-e`ndQf0GC7S|D@?RtZUCpV%?xZL z?p$+VQdW1aG(pGAaL>CWrEPvSN$G-)X*Uxr2@316rCM&-HglGt*XudW1qFMq%m~xQ z`Z$tI%szUzRqb4+zJtsGrmM3?2g95qt~@are%396&GFNLF(Ko(-ZI#gx@~IR($dn> e($dn>;>3S}A!zm}Cie{h0000yqWT2;0&xr{twC7^a>OdiWI`!C zhl4>|aN6n=kz)brJ&~GlJVZp(v3~rSXD8W~iHg*O<3fmVB7-U-{_EQP` zMGD+g#2id5v2eCajxf)YVps2r(*63{#K5&V)JAb9YCyX3bHShyMC9>go7Dc&jFiB5 zyBV=mEcUkMQe#}I_9||9p4KJJ?kSyKg@UVt*a%70IB!fU_Ea31WooZRBw@jzxZgZwSiy%OR$-rbACXY20aW_P;qUM)@!V29o|Y1>44cOtDMUN6AC>e5fY z(E?S&s5Z9ub cA8J|r1F0@O5Yw(?l>h($07*qoM6N<$f{OxE3IG5A diff --git a/dapps/pos-app/assets/images/scan.png b/dapps/pos-app/assets/images/scan.png deleted file mode 100644 index c1e5193624605fa17f04bf825722e07a26c66e7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 578 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ྮrGl2*$!5Nwy0hZ`?J0%sGzH31~)IgeGma=S|Z`3iMTMEqNTOhEF)ZL`3 zt)X>Hl8EDw+)cVNzHMZQq~Xq=d>9A|9fC0_Vh>*ot#86sio@g3yWeNspq~5;k0Aq^ zHN$F~JZ6s~ddfvB7jRzt(a)%zVZ36Z{ML9O~aih|qQpT|Ri-u^m9 zQE+PwDhoG}I4AGC*>507k`zZesmz>{&_fHO0R4M;D*VubHSEwrKuZf49TFcmKkCA_ zJ4QZ7&$Lw7!7qq8!K4S7J7l3m9{d_fJ3~CauuMJx3xg^crh7DT>@E*xeFMeeSqS98 zyl>Cm*_|)DqE}3NZr*dEtZ*gaE#6m8e(>9_iZhI+s^ z-Kyy5F9raoYRGisLI5n9t1IYHOquTl34j%LxeBxcGUIe>4FyZ%SACAx|L8OdG2(u= zk{2%PCNY%@s<srG`QL`O}GM&x7h2PPG~x+OQ`+>{HK_+vW^CQ00000 LNkvXXu0mjfEf9zQ literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/wallet.png b/dapps/pos-app/assets/images/wallet.png deleted file mode 100644 index 1aba49ba2585dd16d173de426c4b5f9819948f25..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 737 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9FJK(?X*8o|0J?9Fff&Qx;TbZFuuL}I`_7LNZZ59i=3?s+6ypK3v`3RLW_a6}4<5)@80h zvw`43cX?1_ao*DIY~93{DUp6r-|qd6&Rn3ukXy05+uq3J&!xMW9MLOvtQm5DYscDT z{nV(n$YhwmWYhOaitm2fn${UdGJ4GK{jb35!{<=`Pkz&$zHf6baJGN{FP-_I+2{LT zpSyFXRsT)*Z&I;jjOkso|7zQQai2+-*?U+wi2F>t&AE8eEJl-;OQz+0YMn8U^{;iX ztlstSC5K*Sv0vD{c-q`|HNQ3OT^PPr>SpsETAt&h9y9;)rxn&&m*z4tWgPZ=vqRkvP7XcgX1QG{ZfWf~H>=R1q(jjY44Dkv7>;oqkYJQhW=Lj9=wav) zY(N#fn&-Yh^y9^c`nuAR^HM4$*=D|dCUN)TS3v=3`<^fNPMvN@7LQlAzpi`ncXYsw z&&S)M+P*Ri%sGRd{U zvFrlpKVLVh*>cK$ZQuIq#WCz9^@?KBmC~o*><~@F;vO_dMr>txCUIz^izVZaiC@o% zPf=8F*suJ${&ir%;fV&m=SobqCEX@JWU9SYwy{=Sp7EDA|1Jv-JgBs?_hCc-zlEO$L~q#9}bwVvUT49#WU)%r^F@YbN7_6 kOP&*$xl0m~LV#q&H~D*d_1xJp-{L`Hp00i_>zopr03E_EyZ`_I diff --git a/dapps/pos-app/components/toast.tsx b/dapps/pos-app/components/toast.tsx index 1e26cc6fc..5a5ac9b0c 100644 --- a/dapps/pos-app/components/toast.tsx +++ b/dapps/pos-app/components/toast.tsx @@ -2,44 +2,53 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useAssets } from "expo-asset"; import { Image } from "expo-image"; -import { StyleSheet, View } from "react-native"; +import { ActivityIndicator, StyleSheet, View } from "react-native"; import { ThemedText } from "./themed-text"; +type ToastType = "error" | "info" | "success" | "warning" | "loading"; + interface ToastProps { message?: string; - type: "error" | "info" | "success" | "warning"; + type: ToastType; } +const LOADING_COLOR = "#0988F0"; + export function Toast({ message = "", type }: ToastProps) { const Theme = useTheme(); const [assets] = useAssets([ - require("@/assets/images/error.png"), - require("@/assets/images/check_circle.png"), + require("@/assets/images/toast-info.png"), + require("@/assets/images/toast-warning.png"), + require("@/assets/images/toast-error.png"), + require("@/assets/images/toast-success.png"), ]); - const image = type === "error" ? assets?.[0] : assets?.[1]; - const iconColor = - type === "error" ? Theme["icon-error"] : Theme["icon-success"]; + const icon = { + info: assets?.[0], + warning: assets?.[1], + error: assets?.[2], + success: assets?.[3], + loading: undefined, + }[type]; return ( - - - - + + {message} + + + {type === "loading" ? ( + + ) : ( + + )} + ); } @@ -47,14 +56,32 @@ export function Toast({ message = "", type }: ToastProps) { const styles = StyleSheet.create({ container: { flexDirection: "row", - width: "90%", - gap: Spacing["spacing-2"], - padding: Spacing["spacing-4"], - borderRadius: BorderRadius["3"], - borderWidth: 1, + alignItems: "center", + alignSelf: "center", + maxWidth: "90%", + gap: Spacing["spacing-9"], + paddingLeft: Spacing["spacing-6"], + paddingRight: 10, + paddingVertical: 10, + borderRadius: BorderRadius["13"], + shadowColor: "#000", + shadowOpacity: 0.15, + shadowRadius: 15, + shadowOffset: { width: 0, height: 6 }, + elevation: 6, + }, + label: { + flexShrink: 1, + }, + icon: { + height: 28, + width: 28, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", }, - image: { - height: 18, - width: 18, + iconImage: { + height: 28, + width: 28, }, }); diff --git a/dapps/pos-app/utils/toasts.tsx b/dapps/pos-app/utils/toasts.tsx index 3c53c2299..7eb11866a 100644 --- a/dapps/pos-app/utils/toasts.tsx +++ b/dapps/pos-app/utils/toasts.tsx @@ -3,11 +3,14 @@ import { BaseToastProps } from "react-native-toast-message"; export const toastConfig = { error: ({ text1 }: BaseToastProps) => , - info: ({ text1 }: BaseToastProps) => , + info: ({ text1 }: BaseToastProps) => , success: ({ text1 }: BaseToastProps) => ( ), warning: ({ text1 }: BaseToastProps) => ( - + + ), + loading: ({ text1 }: BaseToastProps) => ( + ), }; From 03154546693ec6fd7b54dc537a64fdd5d512efcc Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:39:46 -0300 Subject: [PATCH 19/42] feat(pos): add "Copy entry" button to log cards - Add a compact "Copy entry" pill to each log card header; tapping it copies the entry to the clipboard as structured JSON (type/date/location/message/ body, full date with year) and shows a confirmation toast. - Add an optional compact size to the shared Button (size="sm"); defaults to the existing size so other call sites are unaffected. - Right-align the copy button by lifting the card header to a full-width row. - Extract log helpers to utils/logs.ts (formatTimestamp, formatFullTimestamp, buildLogText) and the card into components/log-card.tsx (memoized), slimming app/logs.tsx to the screen concern. - Give the Logs header title component a display name (react/display-name). Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/_layout.tsx | 13 +- dapps/pos-app/app/logs.tsx | 191 +--------------------- dapps/pos-app/assets/images/copy.png | Bin 0 -> 378 bytes dapps/pos-app/components/button.tsx | 18 ++- dapps/pos-app/components/log-card.tsx | 223 ++++++++++++++++++++++++++ dapps/pos-app/utils/logs.ts | 45 ++++++ 6 files changed, 295 insertions(+), 195 deletions(-) create mode 100644 dapps/pos-app/assets/images/copy.png create mode 100644 dapps/pos-app/components/log-card.tsx create mode 100644 dapps/pos-app/utils/logs.ts diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index 45371361b..f41705e9c 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -75,11 +75,14 @@ Sentry.init({ const queryClient = new QueryClient(); -const renderHeaderTitle = (title: string) => () => ( - - {title} - -); +const renderHeaderTitle = (title: string) => { + const HeaderTitle = () => ( + + {title} + + ); + return HeaderTitle; +}; export default Sentry.wrap(function RootLayout() { const colorScheme = useColorScheme(); diff --git a/dapps/pos-app/app/logs.tsx b/dapps/pos-app/app/logs.tsx index 81e4219b2..01573d46f 100644 --- a/dapps/pos-app/app/logs.tsx +++ b/dapps/pos-app/app/logs.tsx @@ -1,159 +1,13 @@ import { Button } from "@/components/button"; import { ClearLogsModal } from "@/components/clear-logs-modal"; import { EmptyState } from "@/components/empty-state"; -import { Pressable } from "@/components/pressable"; -import { ThemedText } from "@/components/themed-text"; -import { BorderRadius, Spacing } from "@/constants/spacing"; +import { LogCard } from "@/components/log-card"; +import { Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { LogEntry, useLogsStore } from "@/store/useLogsStore"; import { Image } from "expo-image"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { FlatList, StyleSheet, View } from "react-native"; -import Animated, { - Easing, - FadeIn, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; - -const ANIMATION_DURATION = 200; -const EASING = Easing.inOut(Easing.ease); - -const formatTimestamp = (timestamp: number): string => { - const date = new Date(timestamp); - return date.toLocaleString(undefined, { - day: "2-digit", - month: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -}; - -interface LevelBadge { - bg: string; - text: string; - label: string; -} - -const getLevelBadge = ( - level: LogEntry["level"], - theme: ReturnType, -): LevelBadge => { - switch (level) { - case "error": - return { - bg: "rgba(223, 74, 52, 0.12)", - text: theme["icon-error"], - label: "Error", - }; - case "info": - default: - return { - bg: theme["bg-invert"], - text: theme["text-invert"], - label: "Info", - }; - } -}; - -function LogItem({ item }: { item: LogEntry }) { - const theme = useTheme(); - const [expanded, setExpanded] = useState(false); - const hasData = !!item.data; - const badge = getLevelBadge(item.level, theme); - const context = - item.view && item.functionName - ? `${item.view}:${item.functionName}` - : item.view || item.functionName || ""; - - const rotation = useSharedValue(0); - - useEffect(() => { - rotation.value = withTiming(expanded ? 180 : 0, { - duration: ANIMATION_DURATION, - easing: EASING, - }); - }, [expanded, rotation]); - - const chevronStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${rotation.value}deg` }], - })); - - const inner = ( - <> - - - - - - {badge.label} - - - - {formatTimestamp(item.timestamp)} - - - - {item.message} - - {context ? ( - - {context} - - ) : null} - - {hasData ? ( - - - - ) : null} - - {expanded && hasData ? ( - - - {JSON.stringify(item.data, null, 2)} - - - ) : null} - - ); - - const cardStyle = [ - styles.logItem, - { backgroundColor: theme["foreground-primary-fix"] }, - ]; - - if (!hasData) { - return {inner}; - } - - return ( - setExpanded((v) => !v)} style={cardStyle}> - {inner} - - ); -} export default function LogsScreen() { const theme = useTheme(); @@ -165,7 +19,7 @@ export default function LogsScreen() { const reversedLogs = [...logs].reverse(); const renderItem = useCallback( - ({ item }: { item: LogEntry }) => , + ({ item }: { item: LogEntry }) => , [], ); @@ -238,43 +92,6 @@ const styles = StyleSheet.create({ paddingBottom: Spacing["extra-spacing-2"], gap: Spacing["spacing-2"], }, - logItem: { - padding: Spacing["spacing-4"], - borderRadius: BorderRadius["3"], - }, - logRow: { - flexDirection: "row", - alignItems: "center", - gap: Spacing["spacing-3"], - }, - logContent: { - flex: 1, - }, - logHeader: { - flexDirection: "row", - alignItems: "center", - gap: Spacing["spacing-2"], - marginBottom: Spacing["spacing-2"], - }, - levelBadge: { - padding: Spacing["spacing-1"] + Spacing["spacing-05"], - borderRadius: BorderRadius["2"], - }, - levelText: { - fontWeight: "500", - }, - chevron: { - width: 20, - height: 20, - }, - message: { - fontWeight: "500", - marginBottom: Spacing["spacing-05"], - }, - data: { - marginTop: Spacing["spacing-3"], - fontFamily: "monospace", - }, emptyIcon: { width: 64, height: 64, diff --git a/dapps/pos-app/assets/images/copy.png b/dapps/pos-app/assets/images/copy.png new file mode 100644 index 0000000000000000000000000000000000000000..13c10a4488d037654253aab19b02c1d5662ff3f2 GIT binary patch literal 378 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBI14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fihn` zT^vIy7~f96&3njz$5p?h-m1yHfiuHAfyKR+b1;eei6AaadT|@ zoGiyVucWwkd@*@8KWLLmiO~`H2@mI9J5wyXXNT~iEjhgDmmNLgPs&X&{UB>3(|v#Q zvM`}L;YLm|9l7ef^XmC4MP_CG^0_3;l{Q^bSmY2(Ys8%!$D8&|dsU`mFje8@tc`M? z3jevTniP9u*1X^6VznH5OVsu9-t;f5f52#@!O3Z*Y}hUFTc~j5R)4j_7d|lkHq>90 TsIoyB7yt~Ou6{1-oD!M<-q4Mb literal 0 HcmV?d00001 diff --git a/dapps/pos-app/components/button.tsx b/dapps/pos-app/components/button.tsx index 1266c6393..1340abdaa 100644 --- a/dapps/pos-app/components/button.tsx +++ b/dapps/pos-app/components/button.tsx @@ -12,6 +12,7 @@ interface ButtonBaseProps { onPress: () => void; disabled?: boolean; fullWidth?: boolean; + size?: "md" | "sm"; testID?: string; } @@ -26,11 +27,13 @@ export function Button({ onPress, disabled = false, fullWidth = true, + size = "md", testID, type, variant, }: ButtonProps) { const theme = useTheme(); + const isSmall = size === "sm"; const variantStyle = type === "accent" @@ -60,17 +63,18 @@ export function Button({ testID={testID} style={[ styles.button, + isSmall && styles.buttonSmall, fullWidth && styles.fullWidth, variantStyle, disabled && styles.disabled, style, ]} > - + {children} @@ -88,12 +92,20 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, + buttonSmall: { + height: 28, + borderRadius: 10, + paddingHorizontal: Spacing["spacing-3"], + }, content: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: Spacing["spacing-2"], }, + contentSmall: { + gap: Spacing["spacing-1"], + }, label: { textAlign: "center", }, diff --git a/dapps/pos-app/components/log-card.tsx b/dapps/pos-app/components/log-card.tsx new file mode 100644 index 000000000..f7140b358 --- /dev/null +++ b/dapps/pos-app/components/log-card.tsx @@ -0,0 +1,223 @@ +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useTheme } from "@/hooks/use-theme-color"; +import { LogEntry } from "@/store/useLogsStore"; +import { buildLogText, formatTimestamp } from "@/utils/logs"; +import { showSuccessToast } from "@/utils/toast"; +import * as Clipboard from "expo-clipboard"; +import { Image } from "expo-image"; +import { memo, useCallback, useEffect, useState } from "react"; +import { StyleSheet, View } from "react-native"; +import Animated, { + Easing, + FadeIn, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Button } from "./button"; +import { Pressable } from "./pressable"; +import { ThemedText } from "./themed-text"; + +const ANIMATION_DURATION = 200; +const EASING = Easing.inOut(Easing.ease); + +interface LevelBadge { + bg: string; + text: string; + label: string; +} + +const getLevelBadge = ( + level: LogEntry["level"], + theme: ReturnType, +): LevelBadge => { + switch (level) { + case "error": + return { + bg: "rgba(223, 74, 52, 0.12)", + text: theme["icon-error"], + label: "Error", + }; + case "info": + default: + return { + bg: theme["bg-invert"], + text: theme["text-invert"], + label: "Info", + }; + } +}; + +function LogCardBase({ item }: { item: LogEntry }) { + const theme = useTheme(); + const [expanded, setExpanded] = useState(false); + const hasData = !!item.data; + const badge = getLevelBadge(item.level, theme); + const context = + item.view && item.functionName + ? `${item.view}:${item.functionName}` + : item.view || item.functionName || ""; + + const rotation = useSharedValue(0); + + useEffect(() => { + rotation.value = withTiming(expanded ? 180 : 0, { + duration: ANIMATION_DURATION, + easing: EASING, + }); + }, [expanded, rotation]); + + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + const handleCopy = useCallback(async () => { + await Clipboard.setStringAsync(buildLogText(item)); + showSuccessToast("Log entry copied to clipboard"); + }, [item]); + + const inner = ( + <> + + + + + {badge.label} + + + + {formatTimestamp(item.timestamp)} + + + + + + + + {item.message} + + {context ? ( + + {context} + + ) : null} + + {hasData ? ( + + + + ) : null} + + {expanded && hasData ? ( + + + {JSON.stringify(item.data, null, 2)} + + + ) : null} + + ); + + const cardStyle = [ + styles.logItem, + { backgroundColor: theme["foreground-primary-fix"] }, + ]; + + if (!hasData) { + return {inner}; + } + + return ( + setExpanded((v) => !v)} style={cardStyle}> + {inner} + + ); +} + +export const LogCard = memo(LogCardBase); + +const styles = StyleSheet.create({ + logItem: { + padding: Spacing["spacing-4"], + borderRadius: BorderRadius["3"], + }, + logRow: { + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-3"], + }, + logContent: { + flex: 1, + }, + logHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: Spacing["spacing-2"], + marginBottom: Spacing["spacing-2"], + }, + headerLeft: { + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-2"], + flexShrink: 1, + }, + copyIcon: { + width: 14, + height: 14, + }, + levelBadge: { + padding: Spacing["spacing-1"] + Spacing["spacing-05"], + borderRadius: BorderRadius["2"], + }, + levelText: { + fontWeight: "500", + }, + chevron: { + width: 20, + height: 20, + }, + message: { + fontWeight: "500", + marginBottom: Spacing["spacing-05"], + }, + data: { + marginTop: Spacing["spacing-3"], + fontFamily: "monospace", + }, +}); diff --git a/dapps/pos-app/utils/logs.ts b/dapps/pos-app/utils/logs.ts new file mode 100644 index 000000000..c6ee53d43 --- /dev/null +++ b/dapps/pos-app/utils/logs.ts @@ -0,0 +1,45 @@ +import { LogEntry } from "@/store/useLogsStore"; + +/** + * Short timestamp for the log card header (day/month + time, no year). + */ +export const formatTimestamp = (timestamp: number): string => { + const date = new Date(timestamp); + return date.toLocaleString(undefined, { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +/** + * Full timestamp (includes the year) used when copying/sharing a log entry. + */ +export const formatFullTimestamp = (timestamp: number): string => { + const date = new Date(timestamp); + return date.toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +/** + * Serialize a log entry to a shareable JSON string (copied to the clipboard). + */ +export const buildLogText = (item: LogEntry): string => { + const location = [item.view, item.functionName].filter(Boolean).join(":"); + const entry: Record = { + type: item.level, + date: formatFullTimestamp(item.timestamp), + ...(location ? { location } : {}), + message: item.message, + ...(item.data ? { body: item.data } : {}), + }; + return JSON.stringify(entry, null, 2); +}; From 19bf7ffa55c71e69b56bfe7c0b57c27f0beda305 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:06:40 -0300 Subject: [PATCH 20/42] feat(pos): add type and date filters to the Logs screen - Add a "Type" (All/Info/Error) and "Date" (all time/today/7 days/this week/ this month) filter bar with a divider above the logs list, reusing the Transactions filter building blocks (FilterButtons, SettingsBottomSheet, RadioList, getDateRange). - Generalize FilterButtons to a neutral buttons array so both screens share it; update the Activity call site. Lift DATE_RANGE_OPTIONS into utils/date-range. - Persist logLevelFilter/logDateRangeFilter in the logs store; clearLogs resets them to defaults. Filter logs client-side via filterLogs (info also matches the never-emitted "log" level, consistent with the card badge). - Hide the filter bar entirely when there are no logs; show a "No matching logs" empty state when filters exclude everything. Info radio dot uses bg-invert to match the Info badge. Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/activity.tsx | 23 ++-- dapps/pos-app/app/logs.tsx | 120 +++++++++++++++++++- dapps/pos-app/components/filter-buttons.tsx | 77 +++++-------- dapps/pos-app/store/useLogsStore.ts | 18 ++- dapps/pos-app/utils/date-range.ts | 14 +++ dapps/pos-app/utils/logs.ts | 25 ++++ dapps/pos-app/utils/types.ts | 3 + 7 files changed, 214 insertions(+), 66 deletions(-) diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index 059e079f4..bcf385db3 100644 --- a/dapps/pos-app/app/activity.tsx +++ b/dapps/pos-app/app/activity.tsx @@ -5,6 +5,7 @@ import { SettingsBottomSheet } from "@/components/settings-bottom-sheet"; import { TransactionCard } from "@/components/transaction-card"; import { TransactionDetailModal } from "@/components/transaction-detail-modal"; import { Spacing } from "@/constants/spacing"; +import { DATE_RANGE_OPTIONS } from "@/utils/date-range"; import { useTheme } from "@/hooks/use-theme-color"; import { useTransactions } from "@/services/hooks"; import { useSettingsStore } from "@/store/useSettingsStore"; @@ -27,14 +28,6 @@ import { type ActiveSheet = "status" | "dateRange" | null; -const DATE_RANGE_OPTIONS: { value: DateRangeFilterType; label: string }[] = [ - { value: "all_time", label: "All time" }, - { value: "today", label: "Today" }, - { value: "7_days", label: "7 days" }, - { value: "this_week", label: "This week" }, - { value: "this_month", label: "This month" }, -]; - const STATUS_LABELS: Record = { all: "Status", pending: "Pending", @@ -209,10 +202,16 @@ export default function ActivityScreen() { const listHeader = useMemo( () => ( setActiveSheet("status")} - onDateRangePress={() => setActiveSheet("dateRange")} + buttons={[ + { + label: STATUS_LABELS[transactionFilter], + onPress: () => setActiveSheet("status"), + }, + { + label: DATE_RANGE_LABELS[dateRangeFilter], + onPress: () => setActiveSheet("dateRange"), + }, + ]} /> ), [transactionFilter, dateRangeFilter], diff --git a/dapps/pos-app/app/logs.tsx b/dapps/pos-app/app/logs.tsx index 01573d46f..1ca5122c0 100644 --- a/dapps/pos-app/app/logs.tsx +++ b/dapps/pos-app/app/logs.tsx @@ -1,22 +1,61 @@ import { Button } from "@/components/button"; import { ClearLogsModal } from "@/components/clear-logs-modal"; import { EmptyState } from "@/components/empty-state"; +import { FilterButtons } from "@/components/filter-buttons"; import { LogCard } from "@/components/log-card"; +import { RadioList, RadioOption } from "@/components/radio-list"; +import { SettingsBottomSheet } from "@/components/settings-bottom-sheet"; import { Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { LogEntry, useLogsStore } from "@/store/useLogsStore"; +import { DATE_RANGE_OPTIONS } from "@/utils/date-range"; +import { filterLogs } from "@/utils/logs"; +import { DateRangeFilterType, LogLevelFilterType } from "@/utils/types"; import { Image } from "expo-image"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { FlatList, StyleSheet, View } from "react-native"; +type ActiveSheet = "type" | "date" | null; + +const TYPE_LABELS: Record = { + all: "Type", + info: "Info", + error: "Error", +}; + export default function LogsScreen() { const theme = useTheme(); const logs = useLogsStore((state) => state.logs); const clearLogs = useLogsStore((state) => state.clearLogs); + const logLevelFilter = useLogsStore((state) => state.logLevelFilter); + const setLogLevelFilter = useLogsStore((state) => state.setLogLevelFilter); + const logDateRangeFilter = useLogsStore((state) => state.logDateRangeFilter); + const setLogDateRangeFilter = useLogsStore( + (state) => state.setLogDateRangeFilter, + ); const [confirmVisible, setConfirmVisible] = useState(false); + const [activeSheet, setActiveSheet] = useState(null); - const reversedLogs = [...logs].reverse(); + const typeOptions: RadioOption[] = useMemo( + () => [ + { value: "all", label: "All", dotColor: theme["icon-accent-primary"] }, + { value: "info", label: "Info", dotColor: theme["bg-invert"] }, + { value: "error", label: "Error", dotColor: theme["icon-error"] }, + ], + [theme], + ); + + const dateLabel = + logDateRangeFilter === "all_time" + ? "Date" + : (DATE_RANGE_OPTIONS.find((o) => o.value === logDateRangeFilter) + ?.label ?? "Date"); + + const filtered = useMemo( + () => filterLogs([...logs].reverse(), logLevelFilter, logDateRangeFilter), + [logs, logLevelFilter, logDateRangeFilter], + ); const renderItem = useCallback( ({ item }: { item: LogEntry }) => , @@ -25,6 +64,24 @@ export default function LogsScreen() { const keyExtractor = useCallback((item: LogEntry) => item.id, []); + const closeSheet = useCallback(() => setActiveSheet(null), []); + + const handleTypeChange = useCallback( + (filter: LogLevelFilterType) => { + setLogLevelFilter(filter); + setActiveSheet(null); + }, + [setLogLevelFilter], + ); + + const handleDateChange = useCallback( + (filter: DateRangeFilterType) => { + setLogDateRangeFilter(filter); + setActiveSheet(null); + }, + [setLogDateRangeFilter], + ); + const handleConfirmClear = useCallback(() => { clearLogs(); setConfirmVisible(false); @@ -32,7 +89,7 @@ export default function LogsScreen() { return ( - {reversedLogs.length === 0 ? ( + {logs.length === 0 ? ( ) : ( <> + setActiveSheet("type"), + }, + { label: dateLabel, onPress: () => setActiveSheet("date") }, + ]} + /> + + } /> + + )} + {/* Theme Bottom Sheet */} x@gW4PgTHkfXO2-sn+EHjEn=U9INIw| zdLz?&xtlp9?LF-h46M;^!N3^p8VqdF?!mwm9TE&I(P6>B5FHu}+0o&_kQtp43|Y}> z!H^N18Vu3V>A?^gO%V)H(KkY39LL|T>vsC%vu?L1PTA=3PS4xP0yHapaK=jC?XSBp z5;fv6vm_R%g3nmpcUfpnA6lQfy1gv4mVZkov9N|O9%iw?{?*bn7ML#{%wvJ|^3Wv~ z7zY4uvA{Osag7D0A)b3Iu#9O#Vu4{09~KMQw^~ADA@gQhcr0YyE=`FAkB3@lW5N2N zmL^kdn>i&l7OV#VSlu>rEIk&igNK>dDPqBNYPp|0-pjPo2Q;zZ`RHMj|Ct-tqlcxG cQcB752j>3RoJ+J+egFUf07*qoM6N<$f}=X*WdHyG literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/printer.png b/dapps/pos-app/assets/images/printer.png new file mode 100644 index 0000000000000000000000000000000000000000..9f8c92e1c92c98de77b7e21a6ac53ae6908d49f6 GIT binary patch literal 665 zcmV;K0%rY*P)d!X87TrW3~qUwDRPjPo1QiSJ7tQ##i07wcX4Kyh%-PT!OV z*zxxW0J3g&+{kFU%`aRMnK$%eafNJ@_vJ{_#OR642p(Hx;rmEUvGn;6GBFFSN^M&` zE3Jf}fU0Sq%yRmxrags#5EOVtRoIPmOF4QJ;*3i6{y>+H$js?rvrG>njOt~<4jr&k zKvyPuuS(eD0kHxkJjZEqe0G7}^+rmpC4EIstVezj;We>-XDdxJu+W ({ + StyleSheet: { + create: (styles: Record) => styles, + flatten: (style: unknown) => + Array.isArray(style) ? Object.assign({}, ...style) : style, + }, + Platform: { + OS: "ios", + select: (options: Record) => + options.ios ?? options.default, + }, + Text: "Text", + View: "View", +})); + +jest.mock("expo-image", () => ({ + Image: "Image", +})); + +jest.mock("@/hooks/use-theme-color", () => ({ + useTheme: () => mockColors.light, + useThemeColor: (colorName: keyof typeof mockColors.light) => + mockColors.light[colorName], +})); + +jest.mock("./pressable", () => { + const mockReact = jest.requireActual("react"); + + return { + Pressable: ({ children, ...props }: React.PropsWithChildren) => + mockReact.createElement("View", props, children), + }; +}); + +describe("LogEntriesRow", () => { + it("renders a pluralized count", () => { + const { getByText } = render( + {}} />, + ); + + expect(getByText("42 Log entries")).toBeTruthy(); + }); + + it("renders the singular label for a single entry", () => { + const { getByText } = render( + {}} />, + ); + + expect(getByText("1 Log entry")).toBeTruthy(); + }); + + it("calls onPress when the row is pressed", () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId("settings-view-logs")); + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dapps/pos-app/components/log-entries-row.tsx b/dapps/pos-app/components/log-entries-row.tsx new file mode 100644 index 000000000..c3e023d49 --- /dev/null +++ b/dapps/pos-app/components/log-entries-row.tsx @@ -0,0 +1,74 @@ +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useTheme } from "@/hooks/use-theme-color"; +import { Image } from "expo-image"; +import { StyleSheet, View } from "react-native"; +import { Pressable } from "./pressable"; +import { ThemedText } from "./themed-text"; + +interface LogEntriesRowProps { + count: number; + onPress: () => void; + testID?: string; +} + +export function LogEntriesRow({ count, onPress, testID }: LogEntriesRowProps) { + const Theme = useTheme(); + + return ( + + + {`${count} Log ${count === 1 ? "entry" : "entries"}`} + + + + View Logs + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + height: 64, + paddingHorizontal: Spacing["spacing-5"], + borderRadius: BorderRadius["4"], + gap: Spacing["spacing-2"], + }, + title: { + fontWeight: "500", + }, + pill: { + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-1"], + height: 28, + paddingHorizontal: Spacing["spacing-3"], + borderRadius: BorderRadius["3"], + }, + chevron: { + width: 12, + height: 12, + }, +}); diff --git a/dapps/pos-app/components/settings-item.tsx b/dapps/pos-app/components/settings-item.tsx index 11c74b717..a5d090875 100644 --- a/dapps/pos-app/components/settings-item.tsx +++ b/dapps/pos-app/components/settings-item.tsx @@ -11,6 +11,11 @@ interface SettingsItemProps { value?: string; onPress: () => void; showCaret?: boolean; + /** + * Which caret to draw on the right. "up-down" (default) opens a picker sheet; + * "right" drills into an editor. + */ + caret?: "up-down" | "right"; disabled?: boolean; testID?: string; } @@ -20,12 +25,17 @@ export function SettingsItem({ value, onPress, showCaret, + caret = "up-down", disabled, testID, }: SettingsItemProps) { const Theme = useTheme(); - const [assets] = useAssets([require("@/assets/images/caret-up-down.png")]); + const [assets] = useAssets([ + require("@/assets/images/caret-up-down.png"), + require("@/assets/images/chevron-right.png"), + ]); const shouldShowCaret = showCaret ?? !!value; + const caretAsset = caret === "right" ? assets?.[1] : assets?.[0]; return ( - + {title} {value && ( @@ -54,9 +69,9 @@ export function SettingsItem({ )} - {shouldShowCaret && assets?.[0] && ( + {shouldShowCaret && caretAsset && ( + + {title} + + {children} + + ); +} + +const styles = StyleSheet.create({ + section: { + gap: Spacing["spacing-3"], + }, + title: { + fontWeight: "500", + }, + rows: { + gap: Spacing["spacing-2"], + }, +}); From a68b628a885816bac72883c1467e0f5a81daa5b7 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:31:50 -0300 Subject: [PATCH 23/42] fix(pos): refine logs screen empty state, footer and list perf - fix "Clear logs" button overflowing the right edge (wrap in a padded footer instead of horizontal margins on a full-width button) - add a "Clear filters" CTA to the no-results empty state and keep the filters visible so users can recover - hide the "Clear logs" button when the active filters match no logs, since it clears all logs and reads as confusing over an empty result - tune the FlatList for low-end hardware (initialNumToRender, windowSize, maxToRenderPerBatch) without getItemLayout, since log cards have variable heights and expand on tap; skip removeClippedSubviews to avoid blank cells on fast Android scrolling Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/logs.tsx | 44 +++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/dapps/pos-app/app/logs.tsx b/dapps/pos-app/app/logs.tsx index 1ca5122c0..063f7234b 100644 --- a/dapps/pos-app/app/logs.tsx +++ b/dapps/pos-app/app/logs.tsx @@ -87,12 +87,17 @@ export default function LogsScreen() { setConfirmVisible(false); }, [clearLogs]); + const handleClearFilters = useCallback(() => { + setLogLevelFilter("all"); + setLogDateRangeFilter("all_time"); + }, [setLogLevelFilter, setLogDateRangeFilter]); + return ( {logs.length === 0 ? ( } /> - + {filtered.length > 0 && ( + + + + )} )} @@ -189,9 +207,9 @@ const styles = StyleSheet.create({ marginTop: Spacing["spacing-1"], marginBottom: Spacing["spacing-3"], }, - clearButton: { - marginTop: Spacing["spacing-3"], - marginHorizontal: Spacing["spacing-5"], + footer: { + paddingTop: Spacing["spacing-3"], + paddingHorizontal: Spacing["spacing-5"], }, list: { flex: 1, From 4a3baf195fc74e6972a1f54baf3e4bb2edb92fe8 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:53:38 -0300 Subject: [PATCH 24/42] feat(pos): redesign transactions list, cards, filters and detail modal - Transaction card: leading status icon square, status label as colored title, date subtitle, trailing amount + chevron - Add per-status icons/colors (Pending, Confirmed, Cancelled, Failed, Expired) via shared getTransactionStatusMeta single source of truth - Filters: transparent + bordered chips (shared, also applies to Logs); divider under the filter row; filters pinned above the scrolling list - Empty states: filtered "No payments found" + Clear filters; empty view is centered and non-scrollable - Detail modal: status pill now shows the status icon; Payment ID and Transaction ID render in KH Teka Mono + copy icon and are shortened - Unify date format to "14 Oct 2025 - 14:45" across cards, modal and Logs - Add KH Teka Mono font, icon-warning theme token and status icon assets - Logs: use plain RN Pressable on expandable cards so scrolling an open entry no longer fights the press animation Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app.json | 12 ++- dapps/pos-app/app/_layout.tsx | 1 + dapps/pos-app/app/activity.tsx | 65 ++++++++---- dapps/pos-app/app/payment-failure.tsx | 4 +- .../assets/fonts/KHTekaMono-Regular.otf | Bin 0 -> 74304 bytes dapps/pos-app/assets/images/check-bold.png | Bin 0 -> 341 bytes dapps/pos-app/assets/images/check.png | Bin 0 -> 346 bytes dapps/pos-app/assets/images/clock.png | Bin 0 -> 994 bytes dapps/pos-app/assets/images/receipt-x.png | Bin 0 -> 683 bytes .../pos-app/assets/images/warning_circle.png | Bin 2381 -> 1015 bytes .../assets/images/warning_circle_fill.png | Bin 0 -> 2381 bytes dapps/pos-app/components/filter-buttons.tsx | 7 +- dapps/pos-app/components/log-card.tsx | 5 +- dapps/pos-app/components/status-badge.tsx | 67 +++++------- dapps/pos-app/components/transaction-card.tsx | 86 +++++++++++---- .../components/transaction-detail-modal.tsx | 98 ++++++++++++------ dapps/pos-app/constants/theme.ts | 2 + dapps/pos-app/utils/logs.ts | 14 ++- dapps/pos-app/utils/misc.ts | 29 ++---- dapps/pos-app/utils/transaction-status.ts | 78 ++++++++++++++ 20 files changed, 322 insertions(+), 146 deletions(-) create mode 100644 dapps/pos-app/assets/fonts/KHTekaMono-Regular.otf create mode 100644 dapps/pos-app/assets/images/check-bold.png create mode 100644 dapps/pos-app/assets/images/check.png create mode 100644 dapps/pos-app/assets/images/clock.png create mode 100644 dapps/pos-app/assets/images/receipt-x.png create mode 100644 dapps/pos-app/assets/images/warning_circle_fill.png create mode 100644 dapps/pos-app/utils/transaction-status.ts diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json index 8050c3ecc..734a0b221 100644 --- a/dapps/pos-app/app.json +++ b/dapps/pos-app/app.json @@ -91,6 +91,15 @@ "weight": 500 } ] + }, + { + "fontFamily": "KH Teka Mono", + "fontDefinitions": [ + { + "path": "./assets/fonts/KHTekaMono-Regular.otf", + "weight": 400 + } + ] } ] }, @@ -98,7 +107,8 @@ "fonts": [ "./assets/fonts/KHTeka-Light.otf", "./assets/fonts/KHTeka-Regular.otf", - "./assets/fonts/KHTeka-Medium.otf" + "./assets/fonts/KHTeka-Medium.otf", + "./assets/fonts/KHTekaMono-Regular.otf" ] } } diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index f41705e9c..1e12d7fd1 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -98,6 +98,7 @@ export default Sentry.wrap(function RootLayout() { "KH Teka": require("@/assets/fonts/KHTeka-Regular.otf"), "KH Teka Light": require("@/assets/fonts/KHTeka-Light.otf"), "KH Teka Medium": require("@/assets/fonts/KHTeka-Medium.otf"), + "KH Teka Mono": require("@/assets/fonts/KHTekaMono-Regular.otf"), }); // Register the expo-router navigation container with Sentry so route changes diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index bcf385db3..4646c2b23 100644 --- a/dapps/pos-app/app/activity.tsx +++ b/dapps/pos-app/app/activity.tsx @@ -31,7 +31,7 @@ type ActiveSheet = "status" | "dateRange" | null; const STATUS_LABELS: Record = { all: "Status", pending: "Pending", - completed: "Completed", + completed: "Confirmed", failed: "Failed", expired: "Expired", cancelled: "Cancelled", @@ -73,15 +73,15 @@ export default function ActivityScreen() { { value: "pending", label: "Pending", - dotColor: theme["icon-default"], + dotColor: theme["bg-invert"], }, { value: "completed", - label: "Completed", + label: "Confirmed", dotColor: theme["icon-success"], }, { value: "failed", label: "Failed", dotColor: theme["icon-error"] }, - { value: "expired", label: "Expired", dotColor: theme["icon-error"] }, + { value: "expired", label: "Expired", dotColor: theme["icon-warning"] }, { value: "cancelled", label: "Cancelled", @@ -146,6 +146,16 @@ export default function ActivityScreen() { setSelectedPayment(null); }, []); + const isEmpty = !transactions || transactions.length === 0; + + const filtersActive = + transactionFilter !== "all" || dateRangeFilter !== "all_time"; + + const handleClearFilters = useCallback(() => { + setTransactionFilter("all"); + setDateRangeFilter("all_time"); + }, [setTransactionFilter, setDateRangeFilter]); + const renderItem = useCallback( ({ item }: { item: PaymentRecord }) => ( + ); + } + return ( ); - }, [isLoading, theme]); + }, [isLoading, theme, filtersActive, handleClearFilters]); const handleEndReached = useCallback(() => { if (hasNextPage && !isFetchingNextPage) { @@ -199,8 +219,8 @@ export default function ActivityScreen() { ); }, [isFetchingNextPage, theme]); - const listHeader = useMemo( - () => ( + return ( + - ), - [transactionFilter, dateRangeFilter], - ); - - return ( - <> + - + ); } const styles = StyleSheet.create({ - listContent: { + container: { + flex: 1, paddingTop: Spacing["spacing-4"], + }, + list: { + flex: 1, + }, + listContent: { paddingBottom: Platform.OS === "web" ? 0 : Spacing["spacing-6"], gap: Spacing["spacing-2"], }, @@ -297,6 +320,12 @@ const styles = StyleSheet.create({ cardPadding: { marginHorizontal: Spacing["spacing-5"], }, + divider: { + height: StyleSheet.hairlineWidth, + marginHorizontal: Spacing["spacing-5"], + marginTop: Spacing["spacing-1"], + marginBottom: Spacing["spacing-3"], + }, footerLoader: { paddingVertical: Spacing["spacing-4"], alignItems: "center", diff --git a/dapps/pos-app/app/payment-failure.tsx b/dapps/pos-app/app/payment-failure.tsx index e19e63c5b..292e5b8c7 100644 --- a/dapps/pos-app/app/payment-failure.tsx +++ b/dapps/pos-app/app/payment-failure.tsx @@ -30,7 +30,9 @@ export default function PaymentFailureScreen() { const { top } = useSafeAreaInsets(); const params: Partial = useLocalSearchParams(); const currencyCode = useSettingsStore((state) => state.currency); - const [assets] = useAssets([require("@/assets/images/warning_circle.png")]); + const [assets] = useAssets([ + require("@/assets/images/warning_circle_fill.png"), + ]); const { title, subtitle } = getPaymentErrorMessage(params.errorCode, { minAmountCents: params.minAmount, diff --git a/dapps/pos-app/assets/fonts/KHTekaMono-Regular.otf b/dapps/pos-app/assets/fonts/KHTekaMono-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..efeffbb016c2ebdf10ed6e1b1b1af29389e39f40 GIT binary patch literal 74304 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

xg+A+!wv^0KNs>Fg$iEmRk-pPbES@^z!OCk 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 literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/check-bold.png b/dapps/pos-app/assets/images/check-bold.png new file mode 100644 index 0000000000000000000000000000000000000000..a04dd15202aeb24697b2532c739e12faea5b4a3b GIT binary patch literal 341 zcmeAS@N?(olHy`uVBq!ia0vp^S|H591|*LjJ{b+9I14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fif37 zT^vIy7~f8N%h~K8;4(dAf{>1)>XMcz37#(%beA+w0rC$Dsa3l;9uj>2<=kxf&j|t| z*N)#?K2yMn<02Eo+~+xGc06E?XFGpXBGTiW&Gh^JZVnT^xc~oLWum4umuJt)XDd~_ z6;B3vY!bPu;>_s({$-B3sp{dS2{X9*ZWKOS^sK;tZpXVx+s!H=Gx~|sMa2H$q{iMjvXOlEHnM~5&v|!Si2~RC||36U_YrrHJX?Idme0OEs hJnOYf1e~rsXO@f%F^kLlqX+aGgQu&X%Q~loCIH>BfV={8$T_)*MMWV|=oO3RWCKnQ&FF2j8E&3xFw6A+r~k6}{CvBs zJV1R646=sDE|;|J*fDdHX<5(ub;~l;%gW45J8vXpb*P3hY&^oHFfD@dh){H$M|x|h zpmn?7^L=qPXBMw%m=$NXhgYK5JzRKq-_9JKlVY)umdKI;Vst00=jP`~Uy| literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/clock.png b/dapps/pos-app/assets/images/clock.png new file mode 100644 index 0000000000000000000000000000000000000000..69265897554e0e8dd17023cc17d3c34069bbdf5c GIT binary patch literal 994 zcmV<810DQ{P)M42ECl^1s@k+92r!(Foa~&IC;-h&zG3 zfzu6=31m;;bOLDurxT#$k-pdzNlPL~Ggm!#IE4-c0wzfF1Hi<@#Kb3JhC~oiDINzo z@UEqu&1NkU5Y1uXU?m=RLge#SNO9&B>G+$FS0Nkm1-v15gp-AkCm}lurx7yOk&zci zj_!${M(PF=!68j5bk{f?nbyY6V3cMrwUr4i}b=SRe$l&&m}7;Br{&stnuqLA^}f zvAaefoDOS!HCr!RdiNHAL~z*b<#9h#q?UHa)L?+yVXd<^Y^_{%uRMD0jq-2g%d1hY zToru|-x=i%^5w-S-(e_px|wGQJ*~G%pm*=8cjb7w#OWE-ALP&W=8G}2(cC(vFN+- z@F}Q`1$l`~A+>MswbAj-(p#f^k9?_&@)kp(?mm-cdl&=i9v_d}w_6ld$PDW4+sDs0 z)IIplu9t;PYs1Dl#zN|3aGz^0lRDHt>0Jt)TrIsUl}5cs>S-7MF|9{&`a9aLT}!sH zYf)~_*pg(IXcl{(EcVR73E8hJzaufSO^Y3)uYPxJpm87V4;1o^Kx#N+r_puln6SiD?hw#AJgUAhjZEh@ZXIeeu;@2l~jQ zYbcyu3`b+856cjhhX(RuYe@RA+`MmMh3+L1#19Fj2U;Xd5TBTsnD|uu1M|1Mh6GV` Q#sB~S07*qoM6N<$f<9Tx&;S4c literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/receipt-x.png b/dapps/pos-app/assets/images/receipt-x.png new file mode 100644 index 0000000000000000000000000000000000000000..aff967cd7677186346dac46ccbc40140a6a57ecc GIT binary patch literal 683 zcmV;c0#yBpP)0gY>S(_h=+BAQy$+_uf57xWoJgat8nif*=TjAVebZ9n@MY+^o>D=?xxV%MBi9os!j;J|YfM!TCvw}PY4(|!{Yn*$@Pf(egk8+Nfolss&NtUWGK z#1b(Jj{>b~>965@fny_zV9x?Y&~xjayWlv%F8G4j6Tdz&M01yx3LL2iJShtp;Vvy* zc{Vpbv^h&zzB2S$xJyfA_5E3m9jiCREP)A5!Jk&UHT5_|2fd%_1KTByLUwp*l8`5~ zci+>GDNZUle&zV!%*pJfB?LYQm_MWdX5@{>)Dgi`M+8ruUV_c*R4M@vv_FQ}kP|n_ z8vSL<7x(hg0JvkdW6XU8Zyi5)aqynO4jsF-`m$>Tccnyo)nS7jGPcQ}f=lBQIs>1cA~%m#E#7%6m(8QelTzVw~Adr7tBdET6bUm?;w`#1%e<5f*=S&Fh6F0SQ$R# Re+K{n002ovPDHLkV1kM37R3Mn literal 0 HcmV?d00001 diff --git a/dapps/pos-app/assets/images/warning_circle.png b/dapps/pos-app/assets/images/warning_circle.png index d33527495142dec46b4d801191314026c16a9c42..98fa533b352a8b2e8abfa9d5d02b5c8d3e1d0f43 100644 GIT binary patch delta 979 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 diff --git a/dapps/pos-app/assets/images/warning_circle_fill.png b/dapps/pos-app/assets/images/warning_circle_fill.png new file mode 100644 index 0000000000000000000000000000000000000000..d33527495142dec46b4d801191314026c16a9c42 GIT binary patch literal 2381 zcmV-T39|NyP)i7Kgb+dqA%qY@2qAyS7bA%!c?yvVXjIj161^F#)q;aybCd4>=#YXE8IGj>=X6b2{^ITn}Yh~RhDz5?!uV`wb6!IEh*MEePloFte;uicCka7sI zn?GxnG70Uy2`eT>)!Y^-T>|UPU(R~qk=Fpx6h+ni_lNv8VQTp)g{MNgKjd`=-&+Ne zHw$;=AMhHmV$kD)eMmrD%6~say-L7iQ|2>3Im+sz9#dwpee;6n-#^~ci`-D&!}8aZ zd1=CmQ5Vvti;KO_eOjA8|J7B&0+d9?hpCrQnez%XdDt%pjAXOb{Pk@8u9xJ`Tc=2y zGda~UYPzmM?kkY)I3p$C-Tc>x4}jB(j~k3m1Ei}@tPlNt!h(D{;$GAZM#pnyZYvPx z>a#2yzemmjv>eqZ0=f#VIvYv4t3XtJ&~P~-a}uB;(SjyRk&titzN;$arUDJ?TeL_F zl@<6W-315S1c-@!Ah;y32@Y5XsOUVyDiB=c&CvWi`ysu31yZ}95|nr{+67B4ny?)p zC?#r@f$h%#3)Fler~^QjbBSLy+R!+4)UG3!7p<^f7r zU)KB#q9j(;`5yU3OPw+V(POF@m@yxPad8183fyAnS&L!3sO+R*;#MMRJuqZZff_v^ zajlMt{jSg;GwIJNra zBmmsJ1FcvENZ-ArW(+abx1Ck6pN)^L87tO09YG^)%FZQIU-Fx4^7{uicWi?s#2c_= zeO{P;qA@{wKG+TNVsu60!Ctf5`|Qm0Bj>+veNy$)8N(~pY;^z~HjsCno8W<$04e#4R$D#U02rwzSm;#- z#bR^)G^#*+urS+`0XhZ`yah{F-1zMz3del{1d#}9qfo8?Ah6V?v`zh?GO&SKRS6|2EkL?I#B1vmBA?{7mDl)7OLD#ZMktxS+sni$J<;lz zx_32B*UH|zsq{S(vQ05&@fVxw_=AsW}}AvfBr zVIH*?LF22n5W=GbQGsUV8?BD~&moXo)bzwRCubc9NN@pCinT=SH8p8dvdK~tqb1C3 zEh@;fGW3O#Z9Lk?gdE}^Ug;()!0;$RR-jpOsJ{!kNn5sR6mHd-6?}lEmZoo1ET@%K z=fTS|=d?_r36P?O`+|)-jENnHkXnAKRuE!3iv(4GreX^c#^6k$4A9i4ASGfG-E_#b zsp9D|#^6k$4$xHb%+|*(Ij^=K{4?vtrlfD7{@@rxA7`E@0yIlci_}JH55t{c!lXi@ zHY07wYSWGDthML@G;1=))|R&bHsxR1F0GlHKA6a4XzFPZu1J&Gt_00000NkvXXu0mjfNYO|X literal 0 HcmV?d00001 diff --git a/dapps/pos-app/components/filter-buttons.tsx b/dapps/pos-app/components/filter-buttons.tsx index 3f3698ad9..91a650c74 100644 --- a/dapps/pos-app/components/filter-buttons.tsx +++ b/dapps/pos-app/components/filter-buttons.tsx @@ -26,10 +26,7 @@ function FilterButtonsBase({ buttons }: FilterButtonsProps) { {button.label} @@ -63,8 +60,8 @@ const styles = StyleSheet.create({ justifyContent: "center", height: 48, paddingHorizontal: Spacing["spacing-5"], - paddingVertical: Spacing["spacing-4"], borderRadius: BorderRadius["4"], + borderWidth: 1, gap: Spacing["spacing-2"], }, caretIcon: { diff --git a/dapps/pos-app/components/log-card.tsx b/dapps/pos-app/components/log-card.tsx index f7140b358..fa3b8e74f 100644 --- a/dapps/pos-app/components/log-card.tsx +++ b/dapps/pos-app/components/log-card.tsx @@ -6,7 +6,7 @@ import { showSuccessToast } from "@/utils/toast"; import * as Clipboard from "expo-clipboard"; import { Image } from "expo-image"; import { memo, useCallback, useEffect, useState } from "react"; -import { StyleSheet, View } from "react-native"; +import { Pressable, StyleSheet, View } from "react-native"; import Animated, { Easing, FadeIn, @@ -15,7 +15,6 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { Button } from "./button"; -import { Pressable } from "./pressable"; import { ThemedText } from "./themed-text"; const ANIMATION_DURATION = 200; @@ -73,7 +72,7 @@ function LogCardBase({ item }: { item: LogEntry }) { const handleCopy = useCallback(async () => { await Clipboard.setStringAsync(buildLogText(item)); - showSuccessToast("Log entry copied to clipboard"); + showSuccessToast("Log entry copied"); }, [item]); const inner = ( diff --git a/dapps/pos-app/components/status-badge.tsx b/dapps/pos-app/components/status-badge.tsx index 94c301226..a80131c06 100644 --- a/dapps/pos-app/components/status-badge.tsx +++ b/dapps/pos-app/components/status-badge.tsx @@ -1,57 +1,38 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; +import { getTransactionStatusMeta } from "@/utils/transaction-status"; import { TransactionStatus } from "@/utils/types"; +import { Image } from "expo-image"; import { memo } from "react"; import { StyleSheet, View } from "react-native"; import { ThemedText } from "./themed-text"; -type DisplayStatus = "completed" | "pending" | "failed"; - interface StatusBadgeProps { status: TransactionStatus; } -const STATUS_THEME_KEYS: Record< - DisplayStatus, - "icon-success" | "foreground-tertiary" | "icon-error" -> = { - completed: "icon-success", - pending: "foreground-tertiary", - failed: "icon-error", -}; - -const STATUS_LABELS: Record = { - completed: "Completed", - pending: "Pending", - failed: "Failed", -}; - -function mapToDisplayStatus(status: TransactionStatus): DisplayStatus { - switch (status) { - case "succeeded": - return "completed"; - case "failed": - case "expired": - case "cancelled": - return "failed"; - case "requires_action": - case "processing": - default: - return "pending"; - } -} - function StatusBadgeBase({ status }: StatusBadgeProps) { const theme = useTheme(); - const displayStatus = mapToDisplayStatus(status); - const backgroundColor = theme[STATUS_THEME_KEYS[displayStatus]]; - const label = STATUS_LABELS[displayStatus]; - const textColor = displayStatus === "pending" ? "text-primary" : "text-white"; + const meta = getTransactionStatusMeta(status); + const tint = theme[meta.iconTintKey]; return ( - - - {label} + + + + {meta.label} ); @@ -61,13 +42,19 @@ export const StatusBadge = memo(StatusBadgeBase); const styles = StyleSheet.create({ container: { + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-1"], paddingHorizontal: Spacing["spacing-2"], paddingVertical: 6, borderRadius: BorderRadius["2"], alignSelf: "flex-start", - alignItems: "center", justifyContent: "center", }, + icon: { + width: 14, + height: 14, + }, text: { fontWeight: "500", }, diff --git a/dapps/pos-app/components/transaction-card.tsx b/dapps/pos-app/components/transaction-card.tsx index d3376430b..b720e3f8c 100644 --- a/dapps/pos-app/components/transaction-card.tsx +++ b/dapps/pos-app/components/transaction-card.tsx @@ -1,14 +1,17 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { formatFiatAmount } from "@/utils/currency"; -import { formatShortDate } from "@/utils/misc"; +import { formatDateTime } from "@/utils/misc"; +import { getTransactionStatusMeta } from "@/utils/transaction-status"; import { PaymentRecord } from "@/utils/types"; +import { Image } from "expo-image"; import { memo } from "react"; import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; import { Pressable } from "./pressable"; -import { StatusBadge } from "./status-badge"; import { ThemedText } from "./themed-text"; +const CHEVRON = require("@/assets/images/chevron-right.png"); + interface TransactionCardProps { payment: PaymentRecord; onPress: () => void; @@ -21,6 +24,7 @@ function TransactionCardBase({ style, }: TransactionCardProps) { const theme = useTheme(); + const meta = getTransactionStatusMeta(payment.status); return ( - - + + + + + + + {meta.label} + + + {formatDateTime(payment.createdAt)} + + + + + {formatFiatAmount( payment.fiatAmount?.value, payment.fiatAmount?.unit, )} - - {formatShortDate(payment.createdAt)} - + - ); } @@ -58,16 +86,36 @@ const styles = StyleSheet.create({ container: { flexDirection: "row", alignItems: "center", - justifyContent: "space-between", - padding: Spacing["spacing-6"], + gap: Spacing["spacing-3"], + padding: Spacing["spacing-3"], + borderRadius: BorderRadius["3"], + height: 70, + }, + iconSquare: { + width: 38, + height: 38, borderRadius: BorderRadius["3"], + alignItems: "center", + justifyContent: "center", + }, + icon: { + width: 20, + height: 20, }, - leftContent: { + middle: { + flex: 1, + gap: Spacing["spacing-05"], + }, + trailing: { flexDirection: "row", alignItems: "center", - gap: Spacing["spacing-2"], + gap: Spacing["spacing-1"], + }, + chevron: { + width: 20, + height: 20, }, - date: { - marginLeft: Spacing["spacing-1"], + label: { + fontWeight: "500", }, }); diff --git a/dapps/pos-app/components/transaction-detail-modal.tsx b/dapps/pos-app/components/transaction-detail-modal.tsx index 727beabac..971e0af97 100644 --- a/dapps/pos-app/components/transaction-detail-modal.tsx +++ b/dapps/pos-app/components/transaction-detail-modal.tsx @@ -11,7 +11,10 @@ import { StyleSheet, View, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + useSafeAreaInsets, + initialWindowMetrics, +} from "react-native-safe-area-context"; import Animated, { Easing, useAnimatedStyle, @@ -38,12 +41,13 @@ interface TransactionDetailModalProps { } /** - * Truncate hash for display (e.g., "0x23...22d3") + * Truncate a developer-facing id in the middle (e.g., "0x23...22d3"). Used for + * transaction hashes and payment ids so long values stay a single short line. */ -function truncateHash(hash?: string): string { - if (!hash) return "-"; - if (hash.length <= 12) return hash; - return `${hash.slice(0, 4)}...${hash.slice(-4)}`; +function truncateMiddle(value?: string, lead = 4, trail = 4): string { + if (!value) return "-"; + if (value.length <= lead + trail + 3) return value; + return `${value.slice(0, lead)}...${value.slice(-trail)}`; } function formatTokenAmountLabel( @@ -69,16 +73,9 @@ interface DetailRowProps { value?: string; children?: React.ReactNode; onPress?: () => void; - underline?: boolean; } -function DetailRow({ - label, - value, - children, - onPress, - underline, -}: DetailRowProps) { +function DetailRow({ label, value, children, onPress }: DetailRowProps) { const theme = useTheme(); const content = ( @@ -97,7 +94,7 @@ function DetailRow({ color="text-primary" numberOfLines={1} ellipsizeMode="middle" - style={[styles.valueText, underline && styles.underlineText]} + style={styles.valueText} > {value} @@ -112,6 +109,34 @@ function DetailRow({ return content; } +/** + * Developer-facing id value: monospaced (KH Teka Mono) + a copy affordance. + * The row's onPress performs the copy; this only renders the value + icon. + */ +function CopyableId({ value }: { value: string }) { + const theme = useTheme(); + + return ( + + + {value} + + + + ); +} + function TransactionDetailModalBase({ visible, payment, @@ -146,14 +171,14 @@ function TransactionDetailModalBase({ const handleCopyPaymentId = async () => { if (!payment?.paymentId) return; await Clipboard.setStringAsync(payment.paymentId); - showSuccessToast("Payment ID copied to clipboard"); + showSuccessToast("Payment ID copied"); }; const txHash = payment.transaction?.hash; const handleCopyHash = async () => { if (!txHash) return; await Clipboard.setStringAsync(txHash); - showSuccessToast("Transaction ID copied to clipboard"); + showSuccessToast("Transaction ID copied"); }; return ( @@ -233,20 +258,14 @@ function TransactionDetailModalBase({ )} - + + + {txHash && ( - + + + )} @@ -255,8 +274,8 @@ function TransactionDetailModalBase({ @@ -305,9 +324,6 @@ const styles = StyleSheet.create({ textAlign: "right", flex: 1, }, - underlineText: { - textDecorationLine: "underline", - }, closeButton: { borderRadius: BorderRadius["3"], borderWidth: StyleSheet.hairlineWidth, @@ -328,4 +344,18 @@ const styles = StyleSheet.create({ width: 18, height: 18, }, + copyValue: { + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-2"], + flexShrink: 1, + }, + monoValue: { + fontFamily: "KH Teka Mono", + textAlign: "right", + }, + copyIcon: { + width: 18, + height: 18, + }, }); diff --git a/dapps/pos-app/constants/theme.ts b/dapps/pos-app/constants/theme.ts index acd61a193..d775c18ac 100644 --- a/dapps/pos-app/constants/theme.ts +++ b/dapps/pos-app/constants/theme.ts @@ -19,6 +19,7 @@ export const Colors = { "icon-success": "#30A46B", "icon-accent-primary": "#0666FF", "icon-error": "#DF4A34", + "icon-warning": "#F3A13F", // Background colors "bg-primary": "#FFFFFF", @@ -58,6 +59,7 @@ export const Colors = { "icon-success": "#30A46B", "icon-accent-primary": "#0666FF", "icon-error": "#DF4A34", + "icon-warning": "#F3A13F", // Background colors "bg-primary": "#202020", diff --git a/dapps/pos-app/utils/logs.ts b/dapps/pos-app/utils/logs.ts index 8332229bc..344289bcd 100644 --- a/dapps/pos-app/utils/logs.ts +++ b/dapps/pos-app/utils/logs.ts @@ -3,17 +3,21 @@ import { getDateRange } from "./date-range"; import { DateRangeFilterType, LogLevelFilterType } from "./types"; /** - * Short timestamp for the log card header (day/month + time, no year). + * Timestamp for the log card header (e.g. "14 Oct 2025 - 14:45"). Matches the + * transaction card date format; no seconds. */ export const formatTimestamp = (timestamp: number): string => { const date = new Date(timestamp); - return date.toLocaleString(undefined, { - day: "2-digit", - month: "2-digit", + const datePart = date.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + const timePart = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", - second: "2-digit", }); + return `${datePart} - ${timePart}`; }; /** diff --git a/dapps/pos-app/utils/misc.ts b/dapps/pos-app/utils/misc.ts index 66d87ab6d..a5ea91316 100644 --- a/dapps/pos-app/utils/misc.ts +++ b/dapps/pos-app/utils/misc.ts @@ -18,30 +18,19 @@ export const getDeviceIdentifier = async () => { }; /** - * Format date to short display string (e.g., "Oct 14, 25") + * Format a date with time (e.g., "14 Oct 2025 - 14:45"). + * Accepts an ISO string or an epoch-ms timestamp. */ -export function formatShortDate(dateString?: string): string { - if (!dateString) return "-"; +export function formatDateTime(input?: string | number): string { + if (input === undefined || input === null || input === "") return "-"; - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "2-digit", - }); -} + const date = new Date(input); + if (Number.isNaN(date.getTime())) return "-"; -/** - * Format date with time (e.g., "Oct 14, 25 - 14:23") - */ -export function formatDateTime(dateString?: string): string { - if (!dateString) return "-"; - - const date = new Date(dateString); - const datePart = date.toLocaleDateString("en-US", { - month: "short", + const datePart = date.toLocaleDateString("en-GB", { day: "numeric", - year: "2-digit", + month: "short", + year: "numeric", }); const timePart = date.toLocaleTimeString("en-GB", { hour: "2-digit", diff --git a/dapps/pos-app/utils/transaction-status.ts b/dapps/pos-app/utils/transaction-status.ts new file mode 100644 index 000000000..0062e826b --- /dev/null +++ b/dapps/pos-app/utils/transaction-status.ts @@ -0,0 +1,78 @@ +import { Colors } from "@/constants/theme"; +import { ImageSourcePropType } from "react-native"; +import { TransactionStatus } from "./types"; + +type ColorKey = keyof typeof Colors.light; + +export interface TransactionStatusMeta { + /** Display label shown on the card title and status pill. */ + label: string; + /** Theme key for the title text color. */ + titleColorKey: ColorKey; + /** Theme key for the icon square background. */ + iconBgKey: ColorKey; + /** Theme key for the icon tint (rendered on top of `iconBgKey`). */ + iconTintKey: ColorKey; + /** PNG glyph rendered inside the icon square (tinted via `iconTintKey`). */ + icon: ImageSourcePropType; + /** + * Optional bolder glyph for the small (14px) status pill, where the regular + * icon reads too thin. Falls back to `icon` when unset. + */ + badgeIcon?: ImageSourcePropType; +} + +/** + * Single source of truth mapping a raw `PaymentStatus` to its display label, + * colors and icon. Consumed by both the transaction card and the status pill so + * the two never drift apart. + */ +export function getTransactionStatusMeta( + status: TransactionStatus, +): TransactionStatusMeta { + switch (status) { + case "succeeded": + return { + label: "Confirmed", + titleColorKey: "icon-success", + iconBgKey: "icon-success", + iconTintKey: "text-white", + icon: require("@/assets/images/check.png"), + badgeIcon: require("@/assets/images/check-bold.png"), + }; + case "cancelled": + return { + label: "Cancelled", + titleColorKey: "text-tertiary", + iconBgKey: "icon-default", + iconTintKey: "text-white", + icon: require("@/assets/images/close.png"), + }; + case "failed": + return { + label: "Failed", + titleColorKey: "icon-error", + iconBgKey: "icon-error", + iconTintKey: "text-white", + icon: require("@/assets/images/receipt-x.png"), + }; + case "expired": + return { + label: "Expired", + titleColorKey: "icon-warning", + iconBgKey: "icon-warning", + iconTintKey: "text-white", + icon: require("@/assets/images/warning_circle.png"), + }; + case "requires_action": + case "processing": + default: + return { + label: "Pending", + titleColorKey: "text-primary", + iconBgKey: "bg-invert", + iconTintKey: "text-invert", + icon: require("@/assets/images/clock.png"), + }; + } +} From 949c8bad0fb53deeee64be3df499413cf69c579a Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:05:18 -0300 Subject: [PATCH 25/42] chore: added accessibility labels to main 3 buttons --- dapps/pos-app/app/index.tsx | 9 +++++++++ dapps/pos-app/components/button.tsx | 8 ++++++++ dapps/pos-app/components/pressable.tsx | 12 +++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/dapps/pos-app/app/index.tsx b/dapps/pos-app/app/index.tsx index 7da9b699b..ca047de03 100644 --- a/dapps/pos-app/app/index.tsx +++ b/dapps/pos-app/app/index.tsx @@ -65,6 +65,9 @@ export default function HomeScreen() { > void; disabled?: boolean; testID?: string; + accessibilityRole?: AccessibilityRole; + accessibilityLabel?: string; + accessibilityHint?: string; } export const Pressable: React.FC = ({ @@ -16,6 +19,9 @@ export const Pressable: React.FC = ({ onPress, disabled, testID, + accessibilityRole, + accessibilityLabel, + accessibilityHint, }) => { return ( = ({ onPress={onPress} enabled={!disabled} testID={testID} + accessibilityRole={accessibilityRole} + accessibilityLabel={accessibilityLabel} + accessibilityHint={accessibilityHint} + accessibilityState={{ disabled: !!disabled }} > {children} From 0062532dce91b6226ae9b11eae20dfe2457ff3bb Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:21:28 -0300 Subject: [PATCH 26/42] fix(pos): hide back button/swipe during payment confirmation Hide the header back button and disable swipe-back while a payment is confirming on the scan screen, and disable swipe-back on the terminal payment-success/payment-failure screens. The hidden state is derived from the payment status rather than toggled at navigation time: a terminal status flips the loading state off and navigates away in the same tick, and reviving the native back-button config mid-detach crashes react-native-screens on Android ("ScreenStackFragment added into a non-stack container"). Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/_layout.tsx | 2 ++ dapps/pos-app/app/scan.tsx | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index 1e12d7fd1..245874b98 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -253,12 +253,14 @@ export default Sentry.wrap(function RootLayout() { name="payment-failure" options={{ headerBackVisible: false, + gestureEnabled: false, }} /> Date: Thu, 20 Aug 2026 16:54:16 -0300 Subject: [PATCH 32/42] fix(pos): address auto-review findings (header titles, timers, tintColor) - _layout: build header titles once at module scope so headerTitle keeps a stable identity and no longer remounts/flickers on RootLayout re-render. - payment-success: merge the two identical-delay reveal timers into one. - settings: drop the ignored tintColor from the printer image style array. Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/_layout.tsx | 13 ++++++++++--- dapps/pos-app/app/payment-success.tsx | 15 +++++---------- dapps/pos-app/app/settings.tsx | 5 +---- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index 245874b98..2fd2287b7 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -84,6 +84,13 @@ const renderHeaderTitle = (title: string) => { return HeaderTitle; }; +// Build once at module scope so each Stack.Screen gets a stable headerTitle +// reference — React Navigation compares by identity and would otherwise +// remount the header (visible flicker) on every RootLayout re-render. +const SettingsHeaderTitle = renderHeaderTitle("Settings"); +const TransactionsHeaderTitle = renderHeaderTitle("Transactions"); +const LogsHeaderTitle = renderHeaderTitle("Logs"); + export default Sentry.wrap(function RootLayout() { const colorScheme = useColorScheme(); @@ -269,15 +276,15 @@ export default Sentry.wrap(function RootLayout() { /> diff --git a/dapps/pos-app/app/payment-success.tsx b/dapps/pos-app/app/payment-success.tsx index 5f94b2bde..347bb6dc2 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -153,18 +153,13 @@ export default function PaymentSuccessScreen() { contentRevealDelay, withTiming(0, { duration: contentRevealDuration }), ); - const themeBackgroundTimeout = setTimeout( - () => setIsThemeBackgroundVisible(true), - contentRevealDelay, - ); - const successAnimationTimeout = setTimeout( - () => setIsSuccessAnimationVisible(true), - contentRevealDelay, - ); + const revealTimeout = setTimeout(() => { + setIsThemeBackgroundVisible(true); + setIsSuccessAnimationVisible(true); + }, contentRevealDelay); return () => { - clearTimeout(themeBackgroundTimeout); - clearTimeout(successAnimationTimeout); + clearTimeout(revealTimeout); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index 5be9dd489..81de4b7ad 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -366,10 +366,7 @@ export default function SettingsScreen() { icon={ From 3bfe33809ccd44a1df654031d56eb18fbffdfdd9 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:05:33 -0300 Subject: [PATCH 33/42] feat(pos): restructure settings into Terminal/Connection/Device - Move Face ID + NFC toggles into Connection (drop Security section) - Add reusable SettingsToggleItem with inline title/description - Add Device section: Logs as an icon item row + Print test receipt button - Redesign Logs row (terminal icon + entries count + chevron), remove LogEntriesRow - Support optional leading icon in SettingsItem; drop fixed footer Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/settings.tsx | 152 ++++++------------ dapps/pos-app/assets/images/terminal.png | Bin 0 -> 704 bytes .../components/log-entries-row.test.tsx | 69 -------- dapps/pos-app/components/log-entries-row.tsx | 74 --------- dapps/pos-app/components/settings-item.tsx | 17 +- .../components/settings-toggle-item.tsx | 87 ++++++++++ 6 files changed, 153 insertions(+), 246 deletions(-) create mode 100644 dapps/pos-app/assets/images/terminal.png delete mode 100644 dapps/pos-app/components/log-entries-row.test.tsx delete mode 100644 dapps/pos-app/components/log-entries-row.tsx create mode 100644 dapps/pos-app/components/settings-toggle-item.tsx diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index 81de4b7ad..75cf07aa8 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -1,12 +1,10 @@ import { Button } from "@/components/button"; -import { Card } from "@/components/card"; import { PinModal } from "@/components/pin-modal"; -import { LogEntriesRow } from "@/components/log-entries-row"; import { RadioList, RadioOption } from "@/components/radio-list"; import { SettingsBottomSheet } from "@/components/settings-bottom-sheet"; import { SettingsItem } from "@/components/settings-item"; import { SettingsSection } from "@/components/settings-section"; -import { Switch } from "@/components/switch"; +import { SettingsToggleItem } from "@/components/settings-toggle-item"; import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; import { useBiometricAuth } from "@/hooks/use-biometric-auth"; @@ -175,7 +173,6 @@ export default function SettingsScreen() { nfcCapabilities.isHceSupported; const showBiometricToggle = shouldShowBiometricOption && !!biometricStatus; - const showSecuritySection = showNfcToggle || showBiometricToggle; const handleTestPrinterPress = async () => { try { @@ -280,71 +277,64 @@ export default function SettingsScreen() { setActiveSheet("customerApiKey")} /> - + )} + + {/* Biometric toggle - only show if PIN is set and biometrics available */} + {showBiometricToggle && ( + + )} + + + + router.push("/logs")} /> - - {showSecuritySection && ( - - {showNfcToggle && ( - - - - - Tap-to-pay prompt - - - Show the tap-to-pay prompt on the payment screen. - - - - - - )} - - {/* Biometric toggle - only show if PIN is set and biometrics available */} - {showBiometricToggle && ( - - - - - {getBiometricLabel(biometricStatus.biometricType)} - - - Use instead of PIN. - - - - - - )} - - )} + {Platform.OS !== "web" && ( + + )} + - {Platform.OS !== "web" && ( - - - - )} - {/* Theme Bottom Sheet */} &5DH8!sbxmF}t4;bA^n=O;Vh>o-Vko^fAf?vqp7a zNk{7wBaPPbtF#Z;DLVk7?EUQfO@G&-$ za>XrDYLr9#!>kJzVqKt7SjcWw{WW|#KS4L-zv+Zh#WHc*R6M|&xEZ{t&V@H*$te~; m(7JdnF)$3nFbu;mHpL5pMcMena>7&q0000 ({ - StyleSheet: { - create: (styles: Record) => styles, - flatten: (style: unknown) => - Array.isArray(style) ? Object.assign({}, ...style) : style, - }, - Platform: { - OS: "ios", - select: (options: Record) => - options.ios ?? options.default, - }, - Text: "Text", - View: "View", -})); - -jest.mock("expo-image", () => ({ - Image: "Image", -})); - -jest.mock("@/hooks/use-theme-color", () => ({ - useTheme: () => mockColors.light, - useThemeColor: (colorName: keyof typeof mockColors.light) => - mockColors.light[colorName], -})); - -jest.mock("./pressable", () => { - const mockReact = jest.requireActual("react"); - - return { - Pressable: ({ children, ...props }: React.PropsWithChildren) => - mockReact.createElement("View", props, children), - }; -}); - -describe("LogEntriesRow", () => { - it("renders a pluralized count", () => { - const { getByText } = render( - {}} />, - ); - - expect(getByText("42 Log entries")).toBeTruthy(); - }); - - it("renders the singular label for a single entry", () => { - const { getByText } = render( - {}} />, - ); - - expect(getByText("1 Log entry")).toBeTruthy(); - }); - - it("calls onPress when the row is pressed", () => { - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId("settings-view-logs")); - expect(onPress).toHaveBeenCalledTimes(1); - }); -}); diff --git a/dapps/pos-app/components/log-entries-row.tsx b/dapps/pos-app/components/log-entries-row.tsx deleted file mode 100644 index c3e023d49..000000000 --- a/dapps/pos-app/components/log-entries-row.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { BorderRadius, Spacing } from "@/constants/spacing"; -import { useTheme } from "@/hooks/use-theme-color"; -import { Image } from "expo-image"; -import { StyleSheet, View } from "react-native"; -import { Pressable } from "./pressable"; -import { ThemedText } from "./themed-text"; - -interface LogEntriesRowProps { - count: number; - onPress: () => void; - testID?: string; -} - -export function LogEntriesRow({ count, onPress, testID }: LogEntriesRowProps) { - const Theme = useTheme(); - - return ( - - - {`${count} Log ${count === 1 ? "entry" : "entries"}`} - - - - View Logs - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - height: 64, - paddingHorizontal: Spacing["spacing-5"], - borderRadius: BorderRadius["4"], - gap: Spacing["spacing-2"], - }, - title: { - fontWeight: "500", - }, - pill: { - flexDirection: "row", - alignItems: "center", - gap: Spacing["spacing-1"], - height: 28, - paddingHorizontal: Spacing["spacing-3"], - borderRadius: BorderRadius["3"], - }, - chevron: { - width: 12, - height: 12, - }, -}); diff --git a/dapps/pos-app/components/settings-item.tsx b/dapps/pos-app/components/settings-item.tsx index a5d090875..d91a7e96f 100644 --- a/dapps/pos-app/components/settings-item.tsx +++ b/dapps/pos-app/components/settings-item.tsx @@ -1,7 +1,7 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useAssets } from "expo-asset"; -import { Image } from "expo-image"; +import { Image, ImageSource } from "expo-image"; import { StyleSheet, View } from "react-native"; import { Pressable } from "./pressable"; import { ThemedText } from "./themed-text"; @@ -16,6 +16,8 @@ interface SettingsItemProps { * "right" drills into an editor. */ caret?: "up-down" | "right"; + /** Optional leading icon rendered before the title (tinted to text-primary). */ + icon?: ImageSource; disabled?: boolean; testID?: string; } @@ -26,6 +28,7 @@ export function SettingsItem({ onPress, showCaret, caret = "up-down", + icon, disabled, testID, }: SettingsItemProps) { @@ -49,6 +52,14 @@ export function SettingsItem({ ]} > + {icon && ( + + )} void; + testID?: string; +} + +export function SettingsToggleItem({ + title, + description, + value, + onValueChange, + testID, +}: SettingsToggleItemProps) { + const Theme = useTheme(); + + return ( + + + + {title} + + {description && ( + + {description} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + height: 68, + paddingHorizontal: Spacing["spacing-5"], + borderRadius: BorderRadius["4"], + gap: Spacing["spacing-2"], + }, + labelRow: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-2"], + }, + title: { + fontWeight: "500", + }, + description: { + flex: 1, + }, + switch: { + alignSelf: "center", + }, +}); From 581f42bf71d4203d7804a58f50aa7049c2068a7b Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:38:31 -0300 Subject: [PATCH 34/42] feat(pos): setup-incomplete alert, Not-set badges, and manual-credential handling - Add amber "Finish setting up" banner with an "N Left" counter, shown while a required credential is missing - Flag unconfigured Merchant ID / Customer API key rows with an orange bullet and a "Not set" badge (new Badge + SetupBanner components; reuse icon-warning, add bg-warning token) - Support optional bullet + badge on SettingsItem - Clearing the merchant ID no longer resets to env defaults, and no longer clears the customer API key (independent credentials) - Allow saving an empty customer API key to clear it (enable Save when the masked field is emptied), tracked via isEditingCustomerApiKey - Persist a hasInitializedDefaults flag (store v19) so env defaults seed only on first run and are never re-applied after manual clear/change Co-Authored-By: Claude Opus 4.8 --- .../__tests__/store/useSettingsStore.test.ts | 13 +++- dapps/pos-app/app/settings.tsx | 54 +++++++++++---- dapps/pos-app/components/badge.test.tsx | 46 +++++++++++++ dapps/pos-app/components/badge.tsx | 44 ++++++++++++ dapps/pos-app/components/settings-item.tsx | 18 +++++ dapps/pos-app/components/setup-banner.tsx | 68 +++++++++++++++++++ dapps/pos-app/constants/theme.ts | 4 ++ dapps/pos-app/hooks/use-merchant-flow.ts | 53 ++++++++++----- dapps/pos-app/store/useSettingsStore.ts | 50 +++++--------- 9 files changed, 288 insertions(+), 62 deletions(-) create mode 100644 dapps/pos-app/components/badge.test.tsx create mode 100644 dapps/pos-app/components/badge.tsx create mode 100644 dapps/pos-app/components/setup-banner.tsx diff --git a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts index 44c9dbe3f..832cf79e5 100644 --- a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts +++ b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts @@ -539,7 +539,7 @@ describe("useSettingsStore", () => { // Check persist name and version are set (for storage key) expect(persistOptions?.name).toBe("settings"); - expect(persistOptions?.version).toBe(17); + expect(persistOptions?.version).toBe(19); // Verify storage is configured (MMKV in production, mock in tests) expect(persistOptions?.storage).toBeDefined(); @@ -562,5 +562,16 @@ describe("useSettingsStore", () => { expect(migrated.variant).toBe("default"); }); + + it("marks pre-existing installs as already initialized on migration", () => { + const migrate = useSettingsStore.persist?.getOptions?.().migrate; + expect(migrate).toBeDefined(); + + // A persisted install from before the flag existed should be treated as + // initialized so env defaults are not re-seeded on the next launch. + const migrated: any = migrate!({ variant: "default" }, 18); + + expect(migrated.hasInitializedDefaults).toBe(true); + }); }); }); diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index 75cf07aa8..939b415e7 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -1,3 +1,4 @@ +import { Badge } from "@/components/badge"; import { Button } from "@/components/button"; import { PinModal } from "@/components/pin-modal"; import { RadioList, RadioOption } from "@/components/radio-list"; @@ -5,6 +6,7 @@ import { SettingsBottomSheet } from "@/components/settings-bottom-sheet"; import { SettingsItem } from "@/components/settings-item"; import { SettingsSection } from "@/components/settings-section"; import { SettingsToggleItem } from "@/components/settings-toggle-item"; +import { SetupBanner } from "@/components/setup-banner"; import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; import { useBiometricAuth } from "@/hooks/use-biometric-auth"; @@ -80,7 +82,6 @@ export default function SettingsScreen() { const theme = useTheme(); const [activeSheet, setActiveSheet] = useState(null); - const [isEditingCustomerKey, setIsEditingCustomerKey] = useState(false); // Custom hooks for biometrics and merchant flow const { @@ -96,6 +97,8 @@ export default function SettingsScreen() { const { merchantIdInput, customerApiKeyInput, + isEditingCustomerApiKey, + storedMerchantId, activeModal, pinError, isMerchantIdConfirmDisabled, @@ -137,7 +140,6 @@ export default function SettingsScreen() { resetCustomerApiKeyInput(); } setActiveSheet(null); - setIsEditingCustomerKey(false); }; const handleThemeModeChange = (value: ThemeMode) => { @@ -160,13 +162,6 @@ export default function SettingsScreen() { handleCustomerApiKeyConfirm(); }; - const handleCustomerKeyChange = (value: string) => { - if (!isEditingCustomerKey) { - setIsEditingCustomerKey(true); - } - handleCustomerApiKeyInputChange(value); - }; - const showNfcToggle = isNfcHceEnabled && Platform.OS === "android" && @@ -174,6 +169,10 @@ export default function SettingsScreen() { const showBiometricToggle = shouldShowBiometricOption && !!biometricStatus; + const hasMerchantId = !!storedMerchantId?.trim(); + const setupRemaining = + (hasMerchantId ? 0 : 1) + (hasStoredCustomerApiKey ? 0 : 1); + const handleTestPrinterPress = async () => { try { const isBluetoothPermissionGranted = await requestBluetoothPermission(); @@ -250,6 +249,13 @@ export default function SettingsScreen() { contentContainerStyle={styles.content} showsVerticalScrollIndicator={false} > + {setupRemaining > 0 && ( + + )} + + ) + } caret="right" + showCaret onPress={() => setActiveSheet("merchantId")} /> + ) + } caret="right" + showCaret onPress={() => setActiveSheet("customerApiKey")} /> @@ -417,13 +445,13 @@ export default function SettingsScreen() { ({ + StyleSheet: { + create: (styles: Record) => styles, + flatten: (style: unknown) => + Array.isArray(style) ? Object.assign({}, ...style) : style, + }, + Platform: { + OS: "ios", + select: (options: Record) => + options.ios ?? options.default, + }, + Text: "Text", + View: "View", +})); + +jest.mock("@/hooks/use-theme-color", () => ({ + useTheme: () => mockColors.light, + useThemeColor: (colorName: keyof typeof mockColors.light) => + mockColors.light[colorName], +})); + +describe("Badge", () => { + it("renders the label and applies the background token", () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText("Not set")).toBeTruthy(); + expect(StyleSheet.flatten(getByTestId("badge").props.style)).toMatchObject({ + backgroundColor: Colors.light["bg-warning"], + }); + }); +}); diff --git a/dapps/pos-app/components/badge.tsx b/dapps/pos-app/components/badge.tsx new file mode 100644 index 000000000..be5a5f570 --- /dev/null +++ b/dapps/pos-app/components/badge.tsx @@ -0,0 +1,44 @@ +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { ColorKey } from "@/constants/theme"; +import { useTheme } from "@/hooks/use-theme-color"; +import { StyleSheet, View } from "react-native"; +import { ThemedText } from "./themed-text"; + +interface BadgeProps { + label: string; + backgroundColor: ColorKey; + color: ColorKey; + testID?: string; +} + +export function Badge({ label, backgroundColor, color, testID }: BadgeProps) { + const Theme = useTheme(); + + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + container: { + justifyContent: "center", + height: 22, + paddingHorizontal: Spacing["spacing-3"], + borderRadius: BorderRadius["3"], + }, + label: { + fontWeight: "500", + }, +}); diff --git a/dapps/pos-app/components/settings-item.tsx b/dapps/pos-app/components/settings-item.tsx index d91a7e96f..bd118a9b8 100644 --- a/dapps/pos-app/components/settings-item.tsx +++ b/dapps/pos-app/components/settings-item.tsx @@ -2,6 +2,7 @@ import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useAssets } from "expo-asset"; import { Image, ImageSource } from "expo-image"; +import { ReactNode } from "react"; import { StyleSheet, View } from "react-native"; import { Pressable } from "./pressable"; import { ThemedText } from "./themed-text"; @@ -18,6 +19,10 @@ interface SettingsItemProps { caret?: "up-down" | "right"; /** Optional leading icon rendered before the title (tinted to text-primary). */ icon?: ImageSource; + /** Renders an amber dot after the title to flag an unconfigured value. */ + bullet?: boolean; + /** Optional badge rendered on the right, before the caret. */ + badge?: ReactNode; disabled?: boolean; testID?: string; } @@ -29,6 +34,8 @@ export function SettingsItem({ showCaret, caret = "up-down", icon, + bullet, + badge, disabled, testID, }: SettingsItemProps) { @@ -68,6 +75,11 @@ export function SettingsItem({ > {title} + {bullet && ( + + )} {value && ( )} + {badge} {shouldShowCaret && caretAsset && ( + + + + Finish setting up + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + height: 54, + paddingHorizontal: Spacing["spacing-5"], + borderRadius: BorderRadius["4"], + gap: Spacing["spacing-2"], + }, + label: { + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-2"], + }, + icon: { + width: 18, + height: 18, + }, + title: { + fontWeight: "500", + }, +}); diff --git a/dapps/pos-app/constants/theme.ts b/dapps/pos-app/constants/theme.ts index d775c18ac..607e60d0c 100644 --- a/dapps/pos-app/constants/theme.ts +++ b/dapps/pos-app/constants/theme.ts @@ -26,6 +26,7 @@ export const Colors = { "bg-invert": "#202020", "bg-accent-primary": "#0666FF", "bg-payment-success": "#0666FF", + "bg-warning": "#F3A13F33", // 20% opacity // Text colors "text-primary": "#202020", @@ -66,6 +67,7 @@ export const Colors = { "bg-invert": "#FFFFFF", "bg-accent-primary": "#0666FF", "bg-payment-success": "#0666FF", + "bg-warning": "#F3A13F33", // 20% opacity // Text colors "text-primary": "#FFFFFF", @@ -83,6 +85,8 @@ export const Colors = { }, }; +export type ColorKey = keyof typeof Colors.light; + export const Fonts = Platform.select({ ios: { /** iOS `UIFontDescriptorSystemDesignDefault` */ diff --git a/dapps/pos-app/hooks/use-merchant-flow.ts b/dapps/pos-app/hooks/use-merchant-flow.ts index 82d1fd071..e1e9a9c24 100644 --- a/dapps/pos-app/hooks/use-merchant-flow.ts +++ b/dapps/pos-app/hooks/use-merchant-flow.ts @@ -10,6 +10,9 @@ type PendingAction = "merchant-id" | "customer-api-key" | null; interface MerchantFlowState { merchantIdInput: string; customerApiKeyInput: string; + // Whether the user has started editing the (masked) API key field. Used to + // distinguish "just opened, still showing ********" from "cleared the field". + isEditingCustomerApiKey: boolean; activeModal: ModalType; pinError: string | null; pendingValue: string | null; @@ -19,6 +22,7 @@ interface MerchantFlowState { const initialState: MerchantFlowState = { merchantIdInput: "", customerApiKeyInput: "", + isEditingCustomerApiKey: false, activeModal: "none", pinError: null, pendingValue: null, @@ -76,6 +80,7 @@ export function useMerchantFlow() { setState((prev) => ({ ...prev, customerApiKeyInput: value, + isEditingCustomerApiKey: true, })); }, []); @@ -83,6 +88,7 @@ export function useMerchantFlow() { setState((prev) => ({ ...prev, customerApiKeyInput: "", + isEditingCustomerApiKey: false, })); }, []); @@ -121,11 +127,16 @@ export function useMerchantFlow() { const handleCustomerApiKeyConfirm = useCallback(() => { const trimmedApiKey = state.customerApiKeyInput.trim(); if (!trimmedApiKey) { + // Empty means "clear the key". Only meaningful when one is stored. + if (!isCustomerApiKeySet) { + return; + } + initiateSave("", "customer-api-key"); return; } initiateSave(trimmedApiKey, "customer-api-key"); - }, [state.customerApiKeyInput, initiateSave]); + }, [state.customerApiKeyInput, isCustomerApiKeySet, initiateSave]); const completeSave = useCallback(async () => { if (state.pendingValue === null || !state.pendingAction) { @@ -135,20 +146,14 @@ export function useMerchantFlow() { try { if (state.pendingAction === "merchant-id") { if (state.pendingValue === "") { - // Clear merchant ID and API key (resets both to env defaults) - const newMerchantId = await clearMerchantId(); - // Sync local input with the new default value + // Clear only the merchant ID, leaving the terminal unconfigured. + await clearMerchantId(); setState((prev) => ({ ...prev, - merchantIdInput: newMerchantId ?? "", + merchantIdInput: "", })); - showSuccessToast("Merchant credentials reset to default"); - addLog( - "info", - "Merchant credentials reset to default", - "settings", - "completeSave", - ); + showSuccessToast("Merchant ID cleared"); + addLog("info", "Merchant ID cleared", "settings", "completeSave"); } else { setMerchantId(state.pendingValue); showSuccessToast("Merchant ID saved successfully"); @@ -160,13 +165,24 @@ export function useMerchantFlow() { ); } } else if (state.pendingAction === "customer-api-key") { + const isClearing = state.pendingValue === ""; await setCustomerApiKey(state.pendingValue); setState((prev) => ({ ...prev, customerApiKeyInput: "", // Clear input after saving + isEditingCustomerApiKey: false, })); - showSuccessToast("Customer API key saved successfully"); - addLog("info", "Customer API key updated", "settings", "completeSave"); + showSuccessToast( + isClearing + ? "Customer API key cleared" + : "Customer API key saved successfully", + ); + addLog( + "info", + isClearing ? "Customer API key cleared" : "Customer API key updated", + "settings", + "completeSave", + ); } setState((prev) => ({ @@ -254,15 +270,19 @@ export function useMerchantFlow() { pendingAction: null, merchantIdInput: storedMerchantId ?? "", customerApiKeyInput: "", // Clear input on cancel + isEditingCustomerApiKey: false, })); }, [storedMerchantId]); - // Enable save when value has changed (including clearing to reset to default) + // Enable save when the merchant ID has changed (including clearing it). const isMerchantIdConfirmDisabled = state.merchantIdInput.trim() === (storedMerchantId ?? ""); + // Enable save for a non-empty key, or when the user has emptied the field to + // clear an existing key. Stays disabled on open (masked, not yet edited). const isCustomerApiKeyConfirmDisabled = - state.customerApiKeyInput.trim().length === 0; + state.customerApiKeyInput.trim().length === 0 && + !(state.isEditingCustomerApiKey && isCustomerApiKeySet); const hasStoredCustomerApiKey = isCustomerApiKeySet; @@ -270,6 +290,7 @@ export function useMerchantFlow() { // State merchantIdInput: state.merchantIdInput, customerApiKeyInput: state.customerApiKeyInput, + isEditingCustomerApiKey: state.isEditingCustomerApiKey, activeModal: state.activeModal, pinError: state.pinError, storedMerchantId, diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index c97114931..9a4bfaec7 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -62,6 +62,7 @@ interface SettingsStore { _hasHydrated: boolean; merchantId: string | null; isCustomerApiKeySet: boolean; + hasInitializedDefaults: boolean; // Transaction filters transactionFilter: TransactionFilterType; @@ -114,6 +115,7 @@ export const useSettingsStore = create()( _hasHydrated: false, merchantId: null, isCustomerApiKeySet: false, + hasInitializedDefaults: false, transactionFilter: "all", dateRangeFilter: "all_time", isPinHashSet: false, @@ -135,41 +137,15 @@ export const useSettingsStore = create()( Variants[get().variant]?.printerLogo ?? DEFAULT_LOGO_BASE64, setCurrency: (currency: CurrencyCode) => set({ currency }), setMerchantId: (merchantId: string | null) => { - // If clearing, reset to env default (unless embedded — parent provides credentials) if (!merchantId || merchantId.trim() === "") { - set({ - merchantId: isEmbedded() - ? null - : MerchantConfig.getDefaultMerchantId(), - }); + set({ merchantId: null }); } else { set({ merchantId }); } }, clearMerchantId: async () => { - // When embedded, clear to null — parent provides credentials via postMessage. - // Otherwise, reset both merchant ID and API key to env defaults. - if (isEmbedded()) { - set({ merchantId: null }); - await secureStorage.removeItem(SECURE_STORAGE_KEYS.CUSTOMER_API_KEY); - set({ isCustomerApiKeySet: false }); - return null; - } - - const defaultMerchantId = MerchantConfig.getDefaultMerchantId(); - set({ merchantId: defaultMerchantId }); - const defaultApiKey = MerchantConfig.getDefaultCustomerApiKey(); - if (defaultApiKey) { - await secureStorage.setItem( - SECURE_STORAGE_KEYS.CUSTOMER_API_KEY, - defaultApiKey, - ); - set({ isCustomerApiKeySet: true }); - } else { - await secureStorage.removeItem(SECURE_STORAGE_KEYS.CUSTOMER_API_KEY); - set({ isCustomerApiKeySet: false }); - } - return defaultMerchantId; + set({ merchantId: null }); + return null; }, setCustomerApiKey: async (apiKey: string | null) => { try { @@ -279,7 +255,7 @@ export const useSettingsStore = create()( }), { name: "settings", - version: 18, + version: 19, storage, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { @@ -359,6 +335,13 @@ export const useSettingsStore = create()( persistedState.dateRangeFilter = "all_time"; } + if (version < 19) { + // Existing installs have already been through first-run, so treat + // them as initialized. This stops env defaults from being re-seeded + // after the user manually clears/changes their credentials. + persistedState.hasInitializedDefaults = true; + } + return persistedState; }, onRehydrateStorage: () => async (state, error) => { @@ -407,9 +390,10 @@ export const useSettingsStore = create()( ); state.isPinHashSet = pinHash !== null; - // Initialize merchant defaults from env if not set. + // Initialize merchant defaults from env on first run only, so we + // don't re-seed defaults after the user clears/changes credentials. // Skip when embedded in an iframe — parent provides credentials via postMessage. - if (!isEmbedded()) { + if (!isEmbedded() && !state.hasInitializedDefaults) { const defaultMerchantId = MerchantConfig.getDefaultMerchantId(); const defaultApiKey = MerchantConfig.getDefaultCustomerApiKey(); @@ -420,6 +404,8 @@ export const useSettingsStore = create()( if (!state.isCustomerApiKeySet && defaultApiKey) { await state.setCustomerApiKey(defaultApiKey); } + + state.hasInitializedDefaults = true; } } From 24bd23446a84cdb7c61cfac792a87e4449a14ff9 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:50:17 -0300 Subject: [PATCH 35/42] fix(pos): address Copilot PR review comments - transaction-status: correct doc comment (PaymentStatus -> TransactionStatus) - toast: use theme icon-accent-primary for the loading spinner instead of the stale hard-coded accent (#0988F0) - pressable.test: mock PressableScale to honor `enabled`, and assert a disabled press does not fire onPress (was testing the mock, not real behavior) Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/components/pressable.test.tsx | 47 +++++++++++++++++++-- dapps/pos-app/components/toast.tsx | 7 +-- dapps/pos-app/utils/transaction-status.ts | 2 +- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/dapps/pos-app/components/pressable.test.tsx b/dapps/pos-app/components/pressable.test.tsx index e9514ed67..f46cae362 100644 --- a/dapps/pos-app/components/pressable.test.tsx +++ b/dapps/pos-app/components/pressable.test.tsx @@ -16,14 +16,53 @@ jest.mock("react-native", () => ({ jest.mock("pressto", () => { const mockReact = jest.requireActual("react"); + // Mirror the real PressableScale: when `enabled` is false, a press is a + // no-op, so the provided onPress is never invoked. return { - PressableScale: ({ children, ...props }: React.PropsWithChildren) => - mockReact.createElement("View", props, children), + PressableScale: ({ + children, + onPress, + enabled, + ...props + }: React.PropsWithChildren) => + mockReact.createElement( + "View", + { + ...props, + enabled, + onPress: (...args: unknown[]) => { + if (enabled !== false) { + onPress?.(...args); + } + }, + }, + children, + ), }; }); describe("Pressable", () => { - it("delegates press behavior, disabled state, styles, and test IDs", () => { + it("delegates press behavior, styles, and test IDs when enabled", () => { + const onPress = jest.fn(); + const { getByTestId } = render( + + Press + , + ); + + const pressable = getByTestId("generic-pressable"); + expect(pressable.props.enabled).toBe(true); + expect(pressable.props.style).toEqual({ opacity: 1 }); + + fireEvent.press(pressable); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it("does not fire onPress when disabled", () => { const onPress = jest.fn(); const { getByTestId } = render( { expect(pressable.props.style).toEqual({ opacity: 0.4 }); fireEvent.press(pressable); - expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).not.toHaveBeenCalled(); }); }); diff --git a/dapps/pos-app/components/toast.tsx b/dapps/pos-app/components/toast.tsx index 5a5ac9b0c..6ee7ddf7c 100644 --- a/dapps/pos-app/components/toast.tsx +++ b/dapps/pos-app/components/toast.tsx @@ -12,8 +12,6 @@ interface ToastProps { type: ToastType; } -const LOADING_COLOR = "#0988F0"; - export function Toast({ message = "", type }: ToastProps) { const Theme = useTheme(); const [assets] = useAssets([ @@ -44,7 +42,10 @@ export function Toast({ message = "", type }: ToastProps) { {type === "loading" ? ( - + ) : ( )} diff --git a/dapps/pos-app/utils/transaction-status.ts b/dapps/pos-app/utils/transaction-status.ts index 0062e826b..1dd2dd9ee 100644 --- a/dapps/pos-app/utils/transaction-status.ts +++ b/dapps/pos-app/utils/transaction-status.ts @@ -23,7 +23,7 @@ export interface TransactionStatusMeta { } /** - * Single source of truth mapping a raw `PaymentStatus` to its display label, + * Single source of truth mapping a raw `TransactionStatus` to its display label, * colors and icon. Consumed by both the transaction card and the status pill so * the two never drift apart. */ From 144c0ad3d6a932685e1be6d75f1aa6e3d1685d16 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:54:15 -0300 Subject: [PATCH 36/42] fix(pos): restore Maestro web id matching via data-testid->id bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accessibility labels added in 949c8bad render as aria-label on web, which Maestro's `id:` selector resolves BEFORE data-testid — so every id-selected element (start-payment-button, charge-button, cancel-button, pos-qr-code, etc.) became unmatchable and the whole web E2E suite failed. Add a web-only MutationObserver that mirrors data-testid onto the DOM id (checked first by Maestro), restoring id matching without dropping the accessibility labels. Mirrors the existing rn_cli_wallet bridge. Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/index.web.tsx | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/dapps/pos-app/index.web.tsx b/dapps/pos-app/index.web.tsx index 9bfc1f381..37bf751e0 100644 --- a/dapps/pos-app/index.web.tsx +++ b/dapps/pos-app/index.web.tsx @@ -7,6 +7,66 @@ import { LoadSkiaWeb } from "@shopify/react-native-skia/lib/module/web"; import { DesktopFrameWrapper } from "@/components/desktop-frame-wrapper.web"; +// Maestro (web) test-id bridge. +// +// Maestro web locates elements (`id:` selector) via a "resource-id" it derives +// from each DOM node with this precedence: +// node.id || aria-label || name || title || htmlFor || data-testid +// react-native-web maps our `testID` to `data-testid` (the LAST fallback) and +// maps `accessibilityLabel` to `aria-label`. So any element that has BOTH a +// testID and an accessibilityLabel (e.g. the home actions and CTA buttons) +// resolves to the aria-label, and Maestro's `id: ` never matches — even +// though the element is plainly visible. +// +// Mirroring data-testid -> the DOM `id` (which Maestro checks FIRST) makes every +// `id: ` flow resolve to the testID regardless of any aria-label. We +// only set it when the element has no explicit id of its own, so intentional +// ids are preserved. Web-only; harmless in the browser (RNW styles via classes, +// not ids). +function installMaestroTestIdBridge() { + const mirror = (el: Element) => { + if (!el || !el.getAttribute) { + return; + } + const tid = el.getAttribute("data-testid"); + if (tid && !el.id) { + el.id = tid; + } + }; + const sync = (root: Document | Element) => { + if ("getAttribute" in root) { + mirror(root); + } + root.querySelectorAll("[data-testid]").forEach(mirror); + }; + const start = () => { + sync(document); + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.type === "attributes" && m.target instanceof Element) { + mirror(m.target); + } + m.addedNodes.forEach((node) => { + if (node instanceof Element) { + sync(node); + } + }); + } + }); + observer.observe(document.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-testid"], + }); + }; + if (document.body) { + start(); + } else { + document.addEventListener("DOMContentLoaded", start); + } +} + function WrappedApp() { return ( @@ -15,6 +75,8 @@ function WrappedApp() { ); } +installMaestroTestIdBridge(); + LoadSkiaWeb().then(() => { renderRootComponent(WrappedApp); }); From 9b4dfc833444f12d3d42cb5e94fbd2864581489a Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:48:49 -0300 Subject: [PATCH 37/42] test(pos): remove pressable wrapper test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test mocked `pressto`'s PressableScale, so it only exercised the mock, not the real press/disabled behavior. It added no meaningful coverage over the trivial prop-forwarding wrapper — remove it rather than assert against a mock. Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/components/pressable.test.tsx | 85 --------------------- 1 file changed, 85 deletions(-) delete mode 100644 dapps/pos-app/components/pressable.test.tsx diff --git a/dapps/pos-app/components/pressable.test.tsx b/dapps/pos-app/components/pressable.test.tsx deleted file mode 100644 index f46cae362..000000000 --- a/dapps/pos-app/components/pressable.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from "react"; -import { fireEvent, render } from "@testing-library/react-native"; -import { Text } from "react-native"; - -import { Pressable } from "@/components/pressable"; - -jest.mock("react-native", () => ({ - StyleSheet: { - flatten: (style: unknown) => - Array.isArray(style) ? Object.assign({}, ...style) : style, - }, - Text: "Text", - View: "View", -})); - -jest.mock("pressto", () => { - const mockReact = jest.requireActual("react"); - - // Mirror the real PressableScale: when `enabled` is false, a press is a - // no-op, so the provided onPress is never invoked. - return { - PressableScale: ({ - children, - onPress, - enabled, - ...props - }: React.PropsWithChildren) => - mockReact.createElement( - "View", - { - ...props, - enabled, - onPress: (...args: unknown[]) => { - if (enabled !== false) { - onPress?.(...args); - } - }, - }, - children, - ), - }; -}); - -describe("Pressable", () => { - it("delegates press behavior, styles, and test IDs when enabled", () => { - const onPress = jest.fn(); - const { getByTestId } = render( - - Press - , - ); - - const pressable = getByTestId("generic-pressable"); - expect(pressable.props.enabled).toBe(true); - expect(pressable.props.style).toEqual({ opacity: 1 }); - - fireEvent.press(pressable); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it("does not fire onPress when disabled", () => { - const onPress = jest.fn(); - const { getByTestId } = render( - - Press - , - ); - - const pressable = getByTestId("generic-pressable"); - expect(pressable.props.enabled).toBe(false); - expect(pressable.props.style).toEqual({ opacity: 0.4 }); - - fireEvent.press(pressable); - expect(onPress).not.toHaveBeenCalled(); - }); -}); From c5a827cc9850d0dbc45d1886447a1a45468dd2b8 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:17:41 -0300 Subject: [PATCH 38/42] fix(pos): make keypad controls tappable on web --- dapps/pos-app/components/numeric-keyboard.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/dapps/pos-app/components/numeric-keyboard.tsx b/dapps/pos-app/components/numeric-keyboard.tsx index d0e9ab93d..4caf055a9 100644 --- a/dapps/pos-app/components/numeric-keyboard.tsx +++ b/dapps/pos-app/components/numeric-keyboard.tsx @@ -34,6 +34,9 @@ function NumericKeyboardBase({ onKeyPress, style }: NumericKeyboardProps) { handlePress(key)} + testID={key === "." ? "key-decimal" : `key-${key}`} + accessibilityRole="button" + accessibilityLabel={key === "erase" ? "Backspace" : key} style={[ styles.key, { backgroundColor: Theme["foreground-primary-fix"] }, @@ -41,7 +44,6 @@ function NumericKeyboardBase({ onKeyPress, style }: NumericKeyboardProps) { > {key === "erase" ? ( ) : ( {key} From 48914507914cc7e931afec09311cb5d35462d602 Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:31:34 -0300 Subject: [PATCH 39/42] fix(pos): reset amount after cancelling payment --- dapps/pos-app/utils/navigation.test.ts | 33 ++++++++++++++++++++++++++ dapps/pos-app/utils/navigation.ts | 8 +++++-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 dapps/pos-app/utils/navigation.test.ts diff --git a/dapps/pos-app/utils/navigation.test.ts b/dapps/pos-app/utils/navigation.test.ts new file mode 100644 index 000000000..d819463bf --- /dev/null +++ b/dapps/pos-app/utils/navigation.test.ts @@ -0,0 +1,33 @@ +import { router } from "expo-router"; + +import { resetNavigation } from "./navigation"; + +jest.mock("expo-router", () => ({ + router: { + dismissAll: jest.fn(), + replace: jest.fn(), + navigate: jest.fn(), + }, +})); + +describe("resetNavigation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("clears the current stack before opening the requested route", () => { + resetNavigation("/amount"); + + expect(router.dismissAll).toHaveBeenCalledTimes(1); + expect(router.replace).toHaveBeenCalledWith("/"); + expect(router.navigate).toHaveBeenCalledWith("/amount"); + }); + + it("returns to the home route when no destination is provided", () => { + resetNavigation(); + + expect(router.dismissAll).toHaveBeenCalledTimes(1); + expect(router.replace).toHaveBeenCalledWith("/"); + expect(router.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/dapps/pos-app/utils/navigation.ts b/dapps/pos-app/utils/navigation.ts index 7d9907e6e..4141ffa9f 100644 --- a/dapps/pos-app/utils/navigation.ts +++ b/dapps/pos-app/utils/navigation.ts @@ -1,5 +1,9 @@ import { Href, router } from "expo-router"; -export const resetNavigation = (href: Href = "/") => { - router.dismissTo(href); +export const resetNavigation = (href?: Href) => { + router.dismissAll(); + router.replace("/"); + if (href) { + router.navigate(href); + } }; From c255dd5742f76cfa7c1186c4a925261cb26f973c Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:46:59 -0300 Subject: [PATCH 40/42] fix(pos): cap New payment button height to stay near-square Cap the primary action button's height at ~1.3x its width so tall screens no longer stretch it into a long rectangle. Bottom-anchor the home actions so the reclaimed space falls at the top, keeping the button visually centered. Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/index.tsx | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/dapps/pos-app/app/index.tsx b/dapps/pos-app/app/index.tsx index f59ce410d..20b699556 100644 --- a/dapps/pos-app/app/index.tsx +++ b/dapps/pos-app/app/index.tsx @@ -12,6 +12,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; const compactScreenHeight = 700; +// Keep the primary "New payment" button close to square on tall screens: cap +// its height relative to its width so it never stretches into a long rectangle. +// A little over 1 keeps it a rectangle that reads as almost-square. +const primaryMaxAspectRatio = 1.3; + export default function HomeScreen() { const [assets] = useAssets([ require("@/assets/images/plus-circle-fill.png"), @@ -20,7 +25,7 @@ export default function HomeScreen() { ]); const Theme = useTheme(); - const { height: windowHeight } = useWindowDimensions(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const { bottom } = useSafeAreaInsets(); const merchantId = useSettingsStore((state) => state.merchantId); const isCustomerApiKeySet = useSettingsStore( @@ -48,9 +53,14 @@ export default function HomeScreen() { const isCompact = windowHeight < compactScreenHeight; const secondaryActionHeight = isCompact ? 112 : 140; const primaryActionMinHeight = isCompact ? 200 : 320; - const topSpacing = isCompact - ? Spacing["spacing-6"] - : Spacing["extra-spacing-1"]; + // The button is full-width minus the horizontal padding; cap its height so + // the height/width gap stays small (almost square). Never below its min. + const primaryWidth = windowWidth - Spacing["spacing-5"] * 2; + const primaryActionMaxHeight = Math.max( + primaryActionMinHeight, + Math.round(primaryWidth * primaryMaxAspectRatio), + ); + const topSpacing = Spacing["spacing-6"]; const bottomSpacing = Math.max( bottom + Spacing["spacing-3"], Spacing["spacing-7"], @@ -72,7 +82,10 @@ export default function HomeScreen() { style={[ styles.baseActionButton, styles.primaryActionButton, - { minHeight: primaryActionMinHeight }, + { + minHeight: primaryActionMinHeight, + maxHeight: primaryActionMaxHeight, + }, { backgroundColor: Theme["foreground-primary-fix"] }, ]} > @@ -143,6 +156,7 @@ const styles = StyleSheet.create({ width: "100%", paddingHorizontal: Spacing["spacing-5"], alignItems: "center", + justifyContent: "flex-end", gap: Spacing["spacing-3"], }, baseActionButton: { From f7d23a366cbf8a2706b449eead81ec105516fd3b Mon Sep 17 00:00:00 2001 From: Ignacio Santise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:52:51 -0300 Subject: [PATCH 41/42] fix(pos): derive New payment height cap from measured width The near-square cap was computed from useWindowDimensions().width, which on the web build isn't the button's real width (the app renders inside a device frame), so the cap never applied and the button stretched full-height. Measure the container's content width via onLayout and cap the height from that instead, so the rule holds on web and native alike. Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/index.tsx | 38 ++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/dapps/pos-app/app/index.tsx b/dapps/pos-app/app/index.tsx index 20b699556..c786d2b71 100644 --- a/dapps/pos-app/app/index.tsx +++ b/dapps/pos-app/app/index.tsx @@ -7,7 +7,13 @@ import { showErrorToast } from "@/utils/toast"; import { useAssets } from "expo-asset"; import { Image } from "expo-image"; import { router } from "expo-router"; -import { StyleSheet, useWindowDimensions, View } from "react-native"; +import { useState } from "react"; +import { + LayoutChangeEvent, + StyleSheet, + useWindowDimensions, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; const compactScreenHeight = 700; @@ -25,8 +31,9 @@ export default function HomeScreen() { ]); const Theme = useTheme(); - const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const { height: windowHeight } = useWindowDimensions(); const { bottom } = useSafeAreaInsets(); + const [contentWidth, setContentWidth] = useState(0); const merchantId = useSettingsStore((state) => state.merchantId); const isCustomerApiKeySet = useSettingsStore( (state) => state.isCustomerApiKeySet, @@ -53,14 +60,26 @@ export default function HomeScreen() { const isCompact = windowHeight < compactScreenHeight; const secondaryActionHeight = isCompact ? 112 : 140; const primaryActionMinHeight = isCompact ? 200 : 320; - // The button is full-width minus the horizontal padding; cap its height so - // the height/width gap stays small (almost square). Never below its min. - const primaryWidth = windowWidth - Spacing["spacing-5"] * 2; - const primaryActionMaxHeight = Math.max( - primaryActionMinHeight, - Math.round(primaryWidth * primaryMaxAspectRatio), - ); + // Cap the button's height from its actual measured width (the container's + // content box, via onLayout) so the near-square rule holds regardless of the + // window size — on web the app renders inside a device-frame, so the window + // width isn't the button's width. Undefined until measured (button grows). + const primaryActionMaxHeight = contentWidth + ? Math.max( + primaryActionMinHeight, + Math.round(contentWidth * primaryMaxAspectRatio), + ) + : undefined; const topSpacing = Spacing["spacing-6"]; + + const handleContentLayout = (event: LayoutChangeEvent) => { + // Content box width = full width minus the container's horizontal padding, + // which matches the full-width button's width. + const measured = event.nativeEvent.layout.width - Spacing["spacing-5"] * 2; + if (measured > 0 && measured !== contentWidth) { + setContentWidth(measured); + } + }; const bottomSpacing = Math.max( bottom + Spacing["spacing-3"], Spacing["spacing-7"], @@ -68,6 +87,7 @@ export default function HomeScreen() { return ( Date: Fri, 21 Aug 2026 16:30:04 -0300 Subject: [PATCH 42/42] fix(pos): restore web press interactions --- dapps/pos-app/app.json | 2 +- dapps/pos-app/components/pressable.test.tsx | 53 +++++++++++++++ dapps/pos-app/components/pressable.tsx | 2 +- .../components/transaction-card.test.tsx | 68 +++++++++++++++++++ dapps/pos-app/components/transaction-card.tsx | 3 +- dapps/pos-app/e2e/README.md | 15 ++-- dapps/pos-app/e2e/settings-theme.yaml | 26 +++++++ 7 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 dapps/pos-app/components/pressable.test.tsx create mode 100644 dapps/pos-app/components/transaction-card.test.tsx create mode 100644 dapps/pos-app/e2e/settings-theme.yaml diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json index 734a0b221..a853b9452 100644 --- a/dapps/pos-app/app.json +++ b/dapps/pos-app/app.json @@ -36,7 +36,7 @@ "android.permission.BLUETOOTH_ADVERTISE", "android.permission.USB_PERMISSION" ], - "versionCode": 29 + "versionCode": 30 }, "web": { "output": "static", diff --git a/dapps/pos-app/components/pressable.test.tsx b/dapps/pos-app/components/pressable.test.tsx new file mode 100644 index 000000000..7be4239fd --- /dev/null +++ b/dapps/pos-app/components/pressable.test.tsx @@ -0,0 +1,53 @@ +import { render } from "@testing-library/react-native"; +import React from "react"; + +import { Pressable } from "./pressable"; + +jest.mock("react-native", () => ({ + StyleSheet: { + flatten: (style: unknown) => style, + }, + View: "View", +})); + +jest.mock("pressto", () => { + const mockReact = jest.requireActual("react"); + + return { + PressableScale: ({ children, ...props }: React.PropsWithChildren) => + mockReact.createElement("View", props, children), + }; +}); + +describe("Pressable", () => { + it("defaults to the button accessibility role", () => { + const pressable = render( + {}}> + Content + , + ).getByTestId("pressable"); + + expect(pressable.props.accessibilityRole).toBe("button"); + }); + + it("preserves an explicit accessibility role", () => { + const pressable = render( + {}}> + Content + , + ).getByTestId("pressable"); + + expect(pressable.props.accessibilityRole).toBe("link"); + }); + + it("maps disabled state to Pressto and accessibility props", () => { + const pressable = render( + {}}> + Content + , + ).getByTestId("pressable"); + + expect(pressable.props.enabled).toBe(false); + expect(pressable.props.accessibilityState).toEqual({ disabled: true }); + }); +}); diff --git a/dapps/pos-app/components/pressable.tsx b/dapps/pos-app/components/pressable.tsx index 8cb68687c..fe5a63ca5 100644 --- a/dapps/pos-app/components/pressable.tsx +++ b/dapps/pos-app/components/pressable.tsx @@ -19,7 +19,7 @@ export const Pressable: React.FC = ({ onPress, disabled, testID, - accessibilityRole, + accessibilityRole = "button", accessibilityLabel, accessibilityHint, }) => { diff --git a/dapps/pos-app/components/transaction-card.test.tsx b/dapps/pos-app/components/transaction-card.test.tsx new file mode 100644 index 000000000..fdad93f0e --- /dev/null +++ b/dapps/pos-app/components/transaction-card.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render } from "@testing-library/react-native"; +import React from "react"; + +import { TransactionCard } from "./transaction-card"; + +jest.mock("react-native", () => ({ + Platform: { + OS: "ios", + select: (options: Record) => + options.ios ?? options.default, + }, + StyleSheet: { + create: (styles: Record) => styles, + flatten: (style: unknown) => style, + }, + Text: "Text", + View: "View", +})); + +jest.mock("@/hooks/use-theme-color", () => ({ + useTheme: () => + new Proxy({}, { get: (_target, property) => String(property) }), + useThemeColor: (colorName: string) => colorName, +})); + +jest.mock("./pressable", () => { + const mockReact = jest.requireActual("react"); + + return { + Pressable: ({ children, ...props }: React.PropsWithChildren) => + mockReact.createElement( + "View", + { ...props, testID: "transaction-card-pressable" }, + children, + ), + }; +}); + +jest.mock("expo-image", () => { + const mockReact = jest.requireActual("react"); + + return { + Image: (props: Record) => + mockReact.createElement("View", props), + }; +}); + +describe("TransactionCard", () => { + it("invokes its detail callback through the shared Pressable", () => { + const onPress = jest.fn(); + const card = render( + , + ).getByTestId("transaction-card-pressable"); + + fireEvent.press(card); + + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dapps/pos-app/components/transaction-card.tsx b/dapps/pos-app/components/transaction-card.tsx index f6c6b0662..b720e3f8c 100644 --- a/dapps/pos-app/components/transaction-card.tsx +++ b/dapps/pos-app/components/transaction-card.tsx @@ -6,7 +6,8 @@ import { getTransactionStatusMeta } from "@/utils/transaction-status"; import { PaymentRecord } from "@/utils/types"; import { Image } from "expo-image"; import { memo } from "react"; -import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import { Pressable } from "./pressable"; import { ThemedText } from "./themed-text"; const CHEVRON = require("@/assets/images/chevron-right.png"); diff --git a/dapps/pos-app/e2e/README.md b/dapps/pos-app/e2e/README.md index 6ea1598b0..616a56d3d 100644 --- a/dapps/pos-app/e2e/README.md +++ b/dapps/pos-app/e2e/README.md @@ -64,7 +64,7 @@ maestro test --env APP_URL=http://localhost:8081 e2e/ # A single flow maestro test --env APP_URL=http://localhost:8081 e2e/keypad.yaml -# Filter by tag (payment | amount) +# Filter by tag (payment | amount | settings) maestro test --include-tags amount --env APP_URL=http://localhost:8081 e2e/ ``` @@ -72,12 +72,13 @@ Add `--headless` to run without a visible browser window (as CI does under xvfb) ## Test Files -| File | Tags | Description | -| --------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- | -| `payment-flow.yaml` | `payment` | Start payment → enter $0.01 → charge → wait for QR → tap QR copies the payment link. Needs valid merchant creds + network. | -| `payment-cancel.yaml` | `payment` | Same prelude as `payment-flow`, then cancel returns to a fresh amount screen (and cancels the payment at the gateway). | -| `invalid-amount.yaml` | `amount` | Charge button stays "Enter amount"/disabled at empty/`0`; enables once a non-zero amount is entered. | -| `keypad.yaml` | `amount` | Keypad decimal handling (single decimal, max 2 fractional digits) and backspace. | +| File | Tags | Description | +| --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- | +| `payment-flow.yaml` | `payment` | Start payment → enter $0.01 → charge → wait for QR → tap QR copies the payment link. Needs valid merchant creds + network. | +| `payment-cancel.yaml` | `payment` | Same prelude as `payment-flow`, then cancel returns to a fresh amount screen (and cancels the payment at the gateway). | +| `invalid-amount.yaml` | `amount` | Charge button stays "Enter amount"/disabled at empty/`0`; enables once a non-zero amount is entered. | +| `keypad.yaml` | `amount` | Keypad decimal handling (single decimal, max 2 fractional digits) and backspace. | +| `settings-theme.yaml` | `settings` | Opens the Theme bottom sheet, selects Dark, and verifies the updated setting. | Shared steps live in `common/*.yaml` (the `$0.01`→QR prelude, run via `runFlow`). Flows there are **not** picked up as standalone tests — neither by CI's diff --git a/dapps/pos-app/e2e/settings-theme.yaml b/dapps/pos-app/e2e/settings-theme.yaml new file mode 100644 index 000000000..293529267 --- /dev/null +++ b/dapps/pos-app/e2e/settings-theme.yaml @@ -0,0 +1,26 @@ +url: ${APP_URL} +name: POS Settings — open the theme modal and change theme +tags: + - settings +--- +- launchApp: + clearState: true + +- tapOn: + id: "settings-button" + +- tapOn: + id: "settings-theme" + +# The option only exists while the theme bottom sheet is open. +- assertVisible: + id: "option-dark" + +- tapOn: + id: "option-dark" + +# Selecting a theme closes the sheet and updates the Settings row. +- assertNotVisible: + id: "option-light" +- assertVisible: + text: "Dark"