diff --git a/dapps/pos-app/.env.example b/dapps/pos-app/.env.example index e0f6c552b..1b1ec5655 100644 --- a/dapps/pos-app/.env.example +++ b/dapps/pos-app/.env.example @@ -1,6 +1,7 @@ -EXPO_PUBLIC_PROJECT_ID="" EXPO_PUBLIC_SENTRY_DSN="" 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/AGENTS.md b/dapps/pos-app/AGENTS.md index 481c7b9be..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 sale" button to start payment - - "Activity" 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/__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/__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/__tests__/store/useSettingsStore.test.ts b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts index 1621fc853..832cf79e5 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(19); // Verify storage is configured (MMKV in production, mock in tests) expect(persistOptions?.storage).toBeDefined(); @@ -611,5 +552,26 @@ 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"); + }); + + 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.json b/dapps/pos-app/app.json index 1a13f24f5..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", @@ -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 + } } ], [ @@ -65,6 +69,7 @@ "./plugins/withAndroidVariants.js", "./plugins/withAndroidVariantIcons.js", "./plugins/withHceNfc.js", + "./plugins/withHceFeatureFlag.js", [ "expo-font", { @@ -86,6 +91,15 @@ "weight": 500 } ] + }, + { + "fontFamily": "KH Teka Mono", + "fontDefinitions": [ + { + "path": "./assets/fonts/KHTekaMono-Regular.otf", + "weight": 400 + } + ] } ] }, @@ -93,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" ] } } @@ -106,7 +121,6 @@ "./assets/images/tokens", "./assets/images/chains", "./assets/images/payment_methods", - "./assets/images/variants", "./assets/app_icons" ] } diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index a7095f68b..2fd2287b7 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -12,16 +12,12 @@ 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"; 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"; @@ -79,6 +75,22 @@ Sentry.init({ const queryClient = new QueryClient(); +const renderHeaderTitle = (title: string) => { + const HeaderTitle = () => ( + + {title} + + ); + 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(); @@ -93,6 +105,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 @@ -190,28 +203,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" && { @@ -234,30 +239,59 @@ export default Sentry.wrap(function RootLayout() { }; }} > - - + + - - - + + + diff --git a/dapps/pos-app/app/activity.tsx b/dapps/pos-app/app/activity.tsx index 059e079f4..4646c2b23 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,18 +28,10 @@ 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", - completed: "Completed", + completed: "Confirmed", failed: "Failed", expired: "Expired", cancelled: "Cancelled", @@ -80,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", @@ -153,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) { @@ -206,30 +219,33 @@ export default function ActivityScreen() { ); }, [isFetchingNextPage, theme]); - const listHeader = useMemo( - () => ( + return ( + setActiveSheet("status")} - onDateRangePress={() => setActiveSheet("dateRange")} + buttons={[ + { + label: STATUS_LABELS[transactionFilter], + onPress: () => setActiveSheet("status"), + }, + { + label: DATE_RANGE_LABELS[dateRangeFilter], + onPress: () => setActiveSheet("dateRange"), + }, + ]} + /> + - ), - [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"], }, @@ -298,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/amount.tsx b/dapps/pos-app/app/amount.tsx index 89eae986c..620cb3ce6 100644 --- a/dapps/pos-app/app/amount.tsx +++ b/dapps/pos-app/app/amount.tsx @@ -1,8 +1,7 @@ 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 { BorderRadius, Spacing } from "@/constants/spacing"; +import { Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useSettingsStore } from "@/store/useSettingsStore"; import { @@ -122,26 +121,16 @@ export default function AmountScreen() { )} /> ); @@ -165,11 +154,6 @@ const styles = StyleSheet.create({ paddingHorizontal: Spacing["spacing-5"], }, button: { - width: "100%", marginTop: Spacing["spacing-6"], - paddingVertical: Spacing["spacing-4"], - paddingHorizontal: Spacing["spacing-5"], - alignItems: "center", - borderRadius: BorderRadius["5"], }, }); diff --git a/dapps/pos-app/app/index.tsx b/dapps/pos-app/app/index.tsx index 8b5b544d6..c786d2b71 100644 --- a/dapps/pos-app/app/index.tsx +++ b/dapps/pos-app/app/index.tsx @@ -1,4 +1,4 @@ -import { Button } from "@/components/button"; +import { Pressable } from "@/components/pressable"; import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; @@ -7,16 +7,33 @@ import { showErrorToast } from "@/utils/toast"; import { useAssets } from "expo-asset"; import { Image } from "expo-image"; import { router } from "expo-router"; -import { Platform, StyleSheet, 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; + +// 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.png"), - require("@/assets/images/clock.png"), + require("@/assets/images/plus-circle-fill.png"), + require("@/assets/images/receipt.png"), require("@/assets/images/gear.png"), ]); const Theme = useTheme(); + const { height: windowHeight } = useWindowDimensions(); + const { bottom } = useSafeAreaInsets(); + const [contentWidth, setContentWidth] = useState(0); const merchantId = useSettingsStore((state) => state.merchantId); const isCustomerApiKeySet = useSettingsStore( (state) => state.isCustomerApiKeySet, @@ -40,59 +57,115 @@ export default function HomeScreen() { router.push("/settings"); }; + const isCompact = windowHeight < compactScreenHeight; + const secondaryActionHeight = isCompact ? 112 : 140; + const primaryActionMinHeight = isCompact ? 200 : 320; + // 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"], + ); + return ( - - - - + + + Transactions + + + + Settings + + ); } @@ -100,21 +173,30 @@ 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"], - justifyContent: "center", alignItems: "center", + justifyContent: "flex-end", gap: Spacing["spacing-3"], }, - actionButton: { - flex: 1, + baseActionButton: { justifyContent: "center", alignItems: "center", - width: "100%", borderRadius: BorderRadius["5"], gap: Spacing["spacing-4"], }, + actionButton: { + flex: 1, + }, + primaryActionButton: { + flex: 1, + width: "100%", + }, + secondaryActions: { + flexDirection: "row", + width: "100%", + gap: Spacing["spacing-3"], + }, actionButtonImage: { width: 32, height: 32, diff --git a/dapps/pos-app/app/logs.tsx b/dapps/pos-app/app/logs.tsx index 786b5e0bb..063f7234b 100644 --- a/dapps/pos-app/app/logs.tsx +++ b/dapps/pos-app/app/logs.tsx @@ -1,119 +1,197 @@ -import { Card } from "@/components/card"; -import { ThemedText } from "@/components/themed-text"; +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 { useCallback } from "react"; +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, useMemo, useState } from "react"; import { FlatList, StyleSheet, View } from "react-native"; -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", - }); -}; +type ActiveSheet = "type" | "date" | null; -const getLevelColor = (level: LogEntry["level"]): string => { - switch (level) { - case "error": - return "#DF4A34"; - case "info": - return "#0988F0"; - case "log": - default: - return "#0988F0"; - } +const TYPE_LABELS: Record = { + all: "Type", + info: "Info", + error: "Error", }; -function LogItem({ item }: { item: LogEntry }) { - const Theme = useTheme(); - const levelColor = getLevelColor(item.level); - const context = - item.view && item.functionName - ? `${item.view}:${item.functionName}` - : item.view || item.functionName || ""; - - return ( - - - - - {item.level.toUpperCase()} - - - - {formatTimestamp(item.timestamp)} - - - {context ? ( - - {context} - - ) : null} - - {item.message} - - {item.data ? ( - - {JSON.stringify(item.data, null, 2)} - - ) : null} - - ); -} - 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 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 reversedLogs = [...logs].reverse(); + const filtered = useMemo( + () => filterLogs([...logs].reverse(), logLevelFilter, logDateRangeFilter), + [logs, logLevelFilter, logDateRangeFilter], + ); const renderItem = useCallback( - ({ item }: { item: LogEntry }) => , + ({ item }: { item: LogEntry }) => , [], ); 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); + }, [clearLogs]); + + const handleClearFilters = useCallback(() => { + setLogLevelFilter("all"); + setLogDateRangeFilter("all_time"); + }, [setLogLevelFilter, setLogDateRangeFilter]); + return ( - - - Clear logs - - - - {reversedLogs.length === 0 ? ( - - - No logs yet - - - ) : ( - + } /> + ) : ( + <> + setActiveSheet("type"), + }, + { label: dateLabel, onPress: () => setActiveSheet("date") }, + ]} + /> + + + } + /> + {filtered.length > 0 && ( + + + + )} + )} + + + + + + + + + + setConfirmVisible(false)} + /> ); } @@ -122,45 +200,28 @@ const styles = StyleSheet.create({ container: { flex: 1, paddingTop: Spacing["spacing-5"], - paddingHorizontal: Spacing["spacing-5"], }, - clearButton: { - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - height: 50, + divider: { + height: StyleSheet.hairlineWidth, + marginHorizontal: Spacing["spacing-5"], + marginTop: Spacing["spacing-1"], marginBottom: Spacing["spacing-3"], }, - listContent: { - paddingBottom: Spacing["extra-spacing-2"], - gap: Spacing["spacing-2"], + footer: { + paddingTop: Spacing["spacing-3"], + paddingHorizontal: Spacing["spacing-5"], }, - logItem: { - padding: Spacing["spacing-3"], - borderRadius: 8, + list: { + flex: 1, }, - logHeader: { - flexDirection: "row", - alignItems: "center", + listContent: { + flexGrow: 1, + paddingHorizontal: Spacing["spacing-5"], + paddingBottom: Spacing["extra-spacing-2"], gap: Spacing["spacing-2"], - marginBottom: Spacing["spacing-1"], - }, - levelBadge: { - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - }, - context: { - marginBottom: Spacing["spacing-1"], - fontStyle: "italic", }, - data: { - marginTop: Spacing["spacing-2"], - fontFamily: "monospace", - }, - emptyState: { - flex: 1, - justifyContent: "center", - alignItems: "center", + emptyIcon: { + width: 64, + height: 64, }, }); diff --git a/dapps/pos-app/app/payment-failure.tsx b/dapps/pos-app/app/payment-failure.tsx index 278f58b4a..302ee0c82 100644 --- a/dapps/pos-app/app/payment-failure.tsx +++ b/dapps/pos-app/app/payment-failure.tsx @@ -6,10 +6,13 @@ 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 { 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 @@ -27,14 +30,27 @@ 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, 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"); }; @@ -66,30 +82,9 @@ export default function PaymentFailureScreen() { {subtitle} - - - + ); } @@ -117,21 +112,4 @@ 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 52b11a647..347bb6dc2 100644 --- a/dapps/pos-app/app/payment-success.tsx +++ b/dapps/pos-app/app/payment-success.tsx @@ -1,6 +1,6 @@ import { UnknownOutputParams, useLocalSearchParams } from "expo-router"; import React, { useEffect, useRef, useState } from "react"; -import { Dimensions, Platform, StyleSheet, View } from "react-native"; +import { Dimensions, StyleSheet, View } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, @@ -10,8 +10,10 @@ 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"; +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"; @@ -36,12 +38,14 @@ interface SuccessParams extends UnknownOutputParams { 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 finalScale = Math.ceil(diagonalLength / initialCircleSize) + 4; +const contentOffset = 16; +const contentRevealDelay = 700; +const contentRevealDuration = 200; export default function PaymentSuccessScreen() { useDisableBackButton(); - const Theme = useTheme("light"); - const DarkTheme = useTheme("dark"); + const Theme = useTheme(); const params = useLocalSearchParams(); const themeMode = useSettingsStore((state) => state.themeMode); const currencyCode = useSettingsStore((state) => state.currency); @@ -51,14 +55,24 @@ export default function PaymentSuccessScreen() { ); const currency = getCurrency(currencyCode); const addLog = useLogsStore((state) => state.addLog); - const { top } = useSafeAreaInsets(); + const { top, bottom } = useSafeAreaInsets(); 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 bottomSpacing = Math.max( + bottom + Spacing["spacing-3"], + Spacing["spacing-7"], + ); const circleScale = useSharedValue(1); + const backgroundOverlayOpacity = useSharedValue(0); const contentOpacity = useSharedValue(0); + const contentTranslateY = useSharedValue(contentOffset); const handleNewPayment = () => { resetNavigation("/amount"); @@ -126,10 +140,27 @@ 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 revealTimeout = setTimeout(() => { + setIsThemeBackgroundVisible(true); + setIsSuccessAnimationVisible(true); + }, contentRevealDelay); + + return () => { + clearTimeout(revealTimeout); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -139,16 +170,21 @@ export default function PaymentSuccessScreen() { const contentAnimatedStyle = useAnimatedStyle(() => ({ opacity: contentOpacity.value, + transform: [{ translateY: contentTranslateY.value }], + })); + + const backgroundOverlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: backgroundOverlayOpacity.value, })); return ( - + {/* Expanding circle background */} - {/* Content that fades in after circle expands */} - + + + {/* Content fades in after the blue-to-theme transition completes. The + safe-area padding lives here (not on the full-screen container) so the + expanding circle stays centered on the true screen center. */} + + + + + + {isSuccessAnimationVisible && ( + + )} + - Payment successful + {formatAmountWithSymbol(amount, currency)} - {formatAmountWithSymbol(amount, currency)} + Payment successful {isPrinterConnected && ( )} - - + ); } @@ -235,8 +288,7 @@ export default function PaymentSuccessScreen() { const styles = StyleSheet.create({ container: { flex: 1, - paddingHorizontal: Spacing["spacing-5"], - paddingBottom: Platform.OS === "web" ? 0 : Spacing["spacing-5"], + overflow: "hidden", }, circle: { position: "absolute", @@ -245,38 +297,32 @@ const styles = StyleSheet.create({ marginLeft: -initialCircleSize / 2, marginTop: -initialCircleSize / 2, }, + backgroundOverlay: { + ...StyleSheet.absoluteFill, + }, contentContainer: { flex: 1, width: "100%", + paddingHorizontal: Spacing["spacing-5"], + }, + header: { + alignItems: "center", + paddingBottom: Spacing["spacing-4"], + }, + successAnimationContainer: { + width: 200, + height: 175, }, amountDescription: { - fontSize: 18, - lineHeight: 20, textAlign: "center", - marginBottom: Spacing["spacing-3"], }, amountValue: { - fontSize: 38, - lineHeight: 38, textAlign: "center", }, 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"], - }, - 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..984cbe5d6 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"; @@ -15,26 +15,36 @@ import { formatAmountWithSymbol, getCurrency, } from "@/utils/currency"; -import { formatCountdown } from "@/utils/misc"; +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"; import * as Clipboard from "expo-clipboard"; import { Image } from "expo-image"; -import { router, UnknownOutputParams, useLocalSearchParams } from "expo-router"; +import { + router, + Stack, + 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([ - require("@/assets/images/wc_logo_dark.png"), + require("@/assets/images/wc-logo-dark.png"), require("@/assets/images/nfc.png"), ]); @@ -55,8 +65,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, @@ -102,7 +113,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,28 +216,72 @@ 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"; + const showNfc = isNfcHceEnabled && nfcEnabled && nfcMode === "hce"; + + // Hide the header back button (and swipe-back) once the payment leaves the + // interactive QR state. We derive this from the status rather than binding it + // to `isProcessing`: a terminal status flips `isProcessing` back to false + // *and* navigates away in the same tick, and reviving the header back-button + // config while the screen is detaching crashes react-native-screens on Android + // with "ScreenStackFragment added into a non-stack container". Keeping it + // hidden for every status past `requires_action` means the option never flips + // back during that transition. (Derived value only — a ref/effect latch trips + // the react-hooks lint rules.) + const backHidden = + !!paymentStatusData && paymentStatusData.status !== "requires_action"; return ( + {isProcessing ? ( - Waiting for confirmation + Waiting for confirmation... - This usually takes a few seconds. + This usually takes a few seconds. Keep this screen open. @@ -237,7 +292,7 @@ export default function ScanScreen() { )} - Payment expires in + Expires in - - Cancel - + Cancel )} @@ -317,7 +382,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 +429,10 @@ const styles = StyleSheet.create({ justifyContent: "center", gap: Spacing["spacing-1"], }, - closeButton: { - alignItems: "center", - justifyContent: "center", - borderRadius: BorderRadius["4"], + cancelButton: { marginHorizontal: Spacing["spacing-5"], - 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..939b415e7 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -1,13 +1,14 @@ +import { Badge } from "@/components/badge"; import { Button } from "@/components/button"; -import { Card } from "@/components/card"; import { PinModal } from "@/components/pin-modal"; import { RadioList, RadioOption } from "@/components/radio-list"; import { SettingsBottomSheet } from "@/components/settings-bottom-sheet"; import { SettingsItem } from "@/components/settings-item"; -import { Switch } from "@/components/switch"; +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 { 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"; @@ -18,6 +19,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, @@ -26,6 +28,7 @@ import { import { showErrorToast } from "@/utils/toast"; import * as Application from "expo-application"; import Constants from "expo-constants"; +import { Image } from "expo-image"; import { router } from "expo-router"; import { useCallback, useMemo, useState } from "react"; import { Platform, StyleSheet, TextInput, View } from "react-native"; @@ -33,7 +36,6 @@ import { ScrollView } from "react-native-gesture-handler"; type ActiveSheet = | "theme" - | "walletTheme" | "currency" | "merchantId" | "customerApiKey" @@ -67,7 +69,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, ); @@ -77,10 +78,10 @@ export default function SettingsScreen() { const setNfcEnabled = useSettingsStore((state) => state.setNfcEnabled); const nfcCapabilities = useNfcCapabilities(); const addLog = useLogsStore((state) => state.addLog); + const logsCount = useLogsStore((state) => state.logs.length); 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, @@ -113,15 +116,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,18 +133,13 @@ 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") { resetCustomerApiKeyInput(); } setActiveSheet(null); - setIsEditingCustomerKey(false); }; const handleThemeModeChange = (value: ThemeMode) => { @@ -158,11 +147,6 @@ export default function SettingsScreen() { closeSheet(); }; - const handleVariantChange = (value: VariantName) => { - setVariant(value); - closeSheet(); - }; - const handleCurrencyChange = (value: CurrencyCode) => { setCurrency(value); closeSheet(); @@ -178,15 +162,16 @@ export default function SettingsScreen() { handleCustomerApiKeyConfirm(); }; - const handleCustomerKeyChange = (value: string) => { - if (!isEditingCustomerKey) { - setIsEditingCustomerKey(true); - } - handleCustomerApiKeyInputChange(value); - }; - const showNfcToggle = - Platform.OS === "android" && nfcCapabilities.isHceSupported; + isNfcHceEnabled && + Platform.OS === "android" && + nfcCapabilities.isHceSupported; + + const showBiometricToggle = shouldShowBiometricOption && !!biometricStatus; + + const hasMerchantId = !!storedMerchantId?.trim(); + const setupRemaining = + (hasMerchantId ? 0 : 1) + (hasStoredCustomerApiKey ? 0 : 1); const handleTestPrinterPress = async () => { try { @@ -264,94 +249,120 @@ export default function SettingsScreen() { contentContainerStyle={styles.content} showsVerticalScrollIndicator={false} > - setActiveSheet("theme")} - disabled={isThemeLocked} - /> - - setActiveSheet("walletTheme")} - /> - - setActiveSheet("currency")} - /> - - setActiveSheet("merchantId")} - /> + {setupRemaining > 0 && ( + + )} - setActiveSheet("customerApiKey")} - /> + + setActiveSheet("theme")} + /> - {showNfcToggle && ( - - - - - Tap-to-pay prompt - - - Show the tap-to-pay prompt on the payment screen. - - - - - - )} + setActiveSheet("currency")} + /> + + + + + ) + } + caret="right" + showCaret + onPress={() => setActiveSheet("merchantId")} + /> - {/* Biometric toggle - only show if PIN is set and biometrics available */} - {shouldShowBiometricOption && biometricStatus && ( - - - - - {getBiometricLabel(biometricStatus.biometricType)} - - - Use instead of PIN. - - - - - - )} + + ) + } + caret="right" + showCaret + onPress={() => setActiveSheet("customerApiKey")} + /> - + {showNfcToggle && ( + + )} + + {/* Biometric toggle - only show if PIN is set and biometrics available */} + {showBiometricToggle && ( + + )} + + + + router.push("/logs")} + /> - router.push("/logs")} - /> + {Platform.OS !== "web" && ( + + )} + - {/* Wallet Theme Bottom Sheet */} - - - - {/* Currency Bottom Sheet */} @@ -426,26 +425,13 @@ export default function SettingsScreen() { ]} /> @@ -459,13 +445,13 @@ export default function SettingsScreen() { @@ -535,31 +508,17 @@ const styles = StyleSheet.create({ }, content: { paddingTop: Spacing["spacing-5"], - paddingBottom: Spacing["extra-spacing-2"], - gap: Spacing["spacing-2"], + paddingBottom: Spacing["spacing-6"], + gap: Spacing["spacing-7"], + }, + printerIcon: { + width: 16, + height: 16, }, versionText: { alignSelf: "flex-end", marginVertical: Spacing["spacing-2"], }, - switch: { - alignSelf: "center", - }, - biometricCard: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - height: 68, - }, - biometricRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - width: "100%", - }, - biometricLabel: { - gap: Spacing["spacing-1"], - }, inputContent: { gap: Spacing["spacing-3"], }, @@ -573,13 +532,4 @@ const styles = StyleSheet.create({ fontFamily: "KH Teka", height: 60, }, - saveButton: { - borderRadius: BorderRadius["4"], - paddingVertical: Spacing["spacing-4"], - justifyContent: "center", - alignItems: "center", - }, - saveButtonLabel: { - textAlign: "center", - }, }); 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 000000000..f51afda0d Binary files /dev/null and b/dapps/pos-app/assets/app_icons/splash_logo.png differ diff --git a/dapps/pos-app/assets/app_icons/splash_logo_android.png b/dapps/pos-app/assets/app_icons/splash_logo_android.png new file mode 100644 index 000000000..098a62862 Binary files /dev/null and b/dapps/pos-app/assets/app_icons/splash_logo_android.png differ 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 000000000..efeffbb01 Binary files /dev/null and b/dapps/pos-app/assets/fonts/KHTekaMono-Regular.otf differ 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 000000000..a04dd1520 Binary files /dev/null and b/dapps/pos-app/assets/images/check-bold.png differ diff --git a/dapps/pos-app/assets/images/check.png b/dapps/pos-app/assets/images/check.png new file mode 100644 index 000000000..9ee7c455b Binary files /dev/null and b/dapps/pos-app/assets/images/check.png differ 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 294915781..000000000 Binary files a/dapps/pos-app/assets/images/check_circle.png and /dev/null differ 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 000000000..b41fe435c Binary files /dev/null and b/dapps/pos-app/assets/images/chevron-down.png differ diff --git a/dapps/pos-app/assets/images/chevron-right.png b/dapps/pos-app/assets/images/chevron-right.png new file mode 100644 index 000000000..974e57283 Binary files /dev/null and b/dapps/pos-app/assets/images/chevron-right.png differ diff --git a/dapps/pos-app/assets/images/clock.png b/dapps/pos-app/assets/images/clock.png index 80e24895f..692658975 100644 Binary files a/dapps/pos-app/assets/images/clock.png and b/dapps/pos-app/assets/images/clock.png differ diff --git a/dapps/pos-app/assets/images/copy.png b/dapps/pos-app/assets/images/copy.png new file mode 100644 index 000000000..13c10a448 Binary files /dev/null and b/dapps/pos-app/assets/images/copy.png differ diff --git a/dapps/pos-app/assets/images/error.png b/dapps/pos-app/assets/images/error.png deleted file mode 100644 index 4bb9e38fc..000000000 Binary files a/dapps/pos-app/assets/images/error.png and /dev/null differ diff --git a/dapps/pos-app/assets/images/nfc.png b/dapps/pos-app/assets/images/nfc.png index ec15b936a..05ea24616 100644 Binary files a/dapps/pos-app/assets/images/nfc.png and b/dapps/pos-app/assets/images/nfc.png differ 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 000000000..2abb8fb48 Binary files /dev/null and b/dapps/pos-app/assets/images/plus-circle-fill.png differ diff --git a/dapps/pos-app/assets/images/printer.png b/dapps/pos-app/assets/images/printer.png new file mode 100644 index 000000000..9f8c92e1c Binary files /dev/null and b/dapps/pos-app/assets/images/printer.png differ 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 000000000..aff967cd7 Binary files /dev/null and b/dapps/pos-app/assets/images/receipt-x.png differ diff --git a/dapps/pos-app/assets/images/receipt.png b/dapps/pos-app/assets/images/receipt.png index 4c79a208f..c8a5b0d1b 100644 Binary files a/dapps/pos-app/assets/images/receipt.png and b/dapps/pos-app/assets/images/receipt.png differ diff --git a/dapps/pos-app/assets/images/scan.png b/dapps/pos-app/assets/images/scan.png deleted file mode 100644 index c1e519362..000000000 Binary files a/dapps/pos-app/assets/images/scan.png and /dev/null differ diff --git a/dapps/pos-app/assets/images/scroll.png b/dapps/pos-app/assets/images/scroll.png new file mode 100644 index 000000000..decc5ae2f Binary files /dev/null and b/dapps/pos-app/assets/images/scroll.png differ diff --git a/dapps/pos-app/assets/images/terminal.png b/dapps/pos-app/assets/images/terminal.png new file mode 100644 index 000000000..f4d55835d Binary files /dev/null and b/dapps/pos-app/assets/images/terminal.png differ diff --git a/dapps/pos-app/assets/images/toast-error.png b/dapps/pos-app/assets/images/toast-error.png new file mode 100644 index 000000000..a5c0f6507 Binary files /dev/null and b/dapps/pos-app/assets/images/toast-error.png differ diff --git a/dapps/pos-app/assets/images/toast-info.png b/dapps/pos-app/assets/images/toast-info.png new file mode 100644 index 000000000..de017bee4 Binary files /dev/null and b/dapps/pos-app/assets/images/toast-info.png differ diff --git a/dapps/pos-app/assets/images/toast-success.png b/dapps/pos-app/assets/images/toast-success.png new file mode 100644 index 000000000..443a82d89 Binary files /dev/null and b/dapps/pos-app/assets/images/toast-success.png differ diff --git a/dapps/pos-app/assets/images/toast-warning.png b/dapps/pos-app/assets/images/toast-warning.png new file mode 100644 index 000000000..014e30181 Binary files /dev/null and b/dapps/pos-app/assets/images/toast-warning.png differ diff --git a/dapps/pos-app/assets/images/trash.png b/dapps/pos-app/assets/images/trash.png new file mode 100644 index 000000000..9c81c722e Binary files /dev/null and b/dapps/pos-app/assets/images/trash.png differ diff --git a/dapps/pos-app/assets/images/variants/binance_brand.png b/dapps/pos-app/assets/images/variants/binance_brand.png deleted file mode 100644 index 8a4c07ba4..000000000 Binary files a/dapps/pos-app/assets/images/variants/binance_brand.png and /dev/null differ diff --git a/dapps/pos-app/assets/images/variants/imin_brand.png b/dapps/pos-app/assets/images/variants/imin_brand.png deleted file mode 100644 index feeac17e8..000000000 Binary files a/dapps/pos-app/assets/images/variants/imin_brand.png and /dev/null differ diff --git a/dapps/pos-app/assets/images/variants/ledger_brand.png b/dapps/pos-app/assets/images/variants/ledger_brand.png deleted file mode 100644 index 5987b560e..000000000 Binary files a/dapps/pos-app/assets/images/variants/ledger_brand.png and /dev/null differ diff --git a/dapps/pos-app/assets/images/variants/money2020_brand.png b/dapps/pos-app/assets/images/variants/money2020_brand.png deleted file mode 100644 index aa04dba84..000000000 Binary files a/dapps/pos-app/assets/images/variants/money2020_brand.png and /dev/null differ 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 0c1ef6be0..000000000 Binary files a/dapps/pos-app/assets/images/variants/phantom_brand.png and /dev/null differ 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 87275c08b..000000000 Binary files a/dapps/pos-app/assets/images/variants/solana_brand.png and /dev/null differ diff --git a/dapps/pos-app/assets/images/variants/solflare_brand.png b/dapps/pos-app/assets/images/variants/solflare_brand.png deleted file mode 100644 index 1bdf8d8a8..000000000 Binary files a/dapps/pos-app/assets/images/variants/solflare_brand.png and /dev/null differ diff --git a/dapps/pos-app/assets/images/variants/trezor_brand.png b/dapps/pos-app/assets/images/variants/trezor_brand.png deleted file mode 100644 index dab66e571..000000000 Binary files a/dapps/pos-app/assets/images/variants/trezor_brand.png and /dev/null differ 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 9701f4cfe..000000000 Binary files a/dapps/pos-app/assets/images/variants/xmoney_brand.png and /dev/null differ diff --git a/dapps/pos-app/assets/images/wallet.png b/dapps/pos-app/assets/images/wallet.png deleted file mode 100644 index 1aba49ba2..000000000 Binary files a/dapps/pos-app/assets/images/wallet.png and /dev/null differ 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 000000000..d33527495 Binary files /dev/null and b/dapps/pos-app/assets/images/warning-circle-fill.png differ diff --git a/dapps/pos-app/assets/images/warning_circle.png b/dapps/pos-app/assets/images/warning_circle.png index d33527495..98fa533b3 100644 Binary files a/dapps/pos-app/assets/images/warning_circle.png and b/dapps/pos-app/assets/images/warning_circle.png differ diff --git a/dapps/pos-app/assets/images/wc_logo_dark.png b/dapps/pos-app/assets/images/wc-logo-dark.png similarity index 100% rename from dapps/pos-app/assets/images/wc_logo_dark.png rename to dapps/pos-app/assets/images/wc-logo-dark.png 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/badge.test.tsx b/dapps/pos-app/components/badge.test.tsx new file mode 100644 index 000000000..f2e0857bf --- /dev/null +++ b/dapps/pos-app/components/badge.test.tsx @@ -0,0 +1,46 @@ +import { render } from "@testing-library/react-native"; +import { StyleSheet } from "react-native"; + +import { Badge } from "@/components/badge"; +import { Colors } from "@/constants/theme"; + +const mockColors = Colors; + +jest.mock("react-native", () => ({ + 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/button.test.tsx b/dapps/pos-app/components/button.test.tsx new file mode 100644 index 000000000..52503e3f5 --- /dev/null +++ b/dapps/pos-app/components/button.test.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import { render } from "@testing-library/react-native"; +import { StyleSheet } from "react-native"; + +import { Button } from "@/components/button"; +import { Colors } from "@/constants/theme"; + +const mockColors = Colors; + +jest.mock("react-native", () => ({ + 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], +})); + +jest.mock("./pressable", () => { + const mockReact = jest.requireActual("react"); + + return { + Pressable: ({ children, ...props }: React.PropsWithChildren) => + mockReact.createElement("View", props, children), + }; +}); + +function getButtonStyle(testID: string) { + const button = render( + , + ).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..fcded6e65 100644 --- a/dapps/pos-app/components/button.tsx +++ b/dapps/pos-app/components/button.tsx @@ -1,30 +1,126 @@ -import { PressableScale } from "pressto"; +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useTheme } from "@/hooks/use-theme-color"; import React from "react"; -import { StyleProp, ViewStyle } from "react-native"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import { Pressable } from "./pressable"; +import { ThemedText } from "./themed-text"; -interface Props { - children: React.ReactNode; +interface ButtonBaseProps { + children: string; + icon?: React.ReactNode; style?: StyleProp; onPress: () => void; disabled?: boolean; + fullWidth?: boolean; + size?: "md" | "sm"; testID?: string; + /** Overrides the accessible name (defaults to the button's text label). */ + accessibilityLabel?: string; + accessibilityHint?: string; } -export const Button: React.FC = ({ +export type ButtonProps = + | (ButtonBaseProps & { type: "accent"; variant: "primary" }) + | (ButtonBaseProps & { type: "neutral"; variant: "secondary" | "tertiary" }); + +export function Button({ children, + icon, style, onPress, - disabled, + disabled = false, + fullWidth = true, + size = "md", testID, -}) => { + accessibilityLabel, + accessibilityHint, + type, + variant, +}: ButtonProps) { + const theme = useTheme(); + const isSmall = size === "sm"; + + const variantStyle = + type === "accent" + ? { + backgroundColor: theme["bg-accent-primary"], + } + : variant === "secondary" + ? { + borderColor: theme["border-secondary"], + borderWidth: 1, + } + : { + backgroundColor: theme["bg-invert"], + }; + + const textColor = + type === "accent" + ? "text-white" + : variant === "secondary" + ? "text-primary" + : "text-invert"; + return ( - - {children} - + + + {children} + + {icon} + + ); -}; +} + +const styles = StyleSheet.create({ + button: { + height: 54, + borderRadius: BorderRadius["4"], + 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", + }, + fullWidth: { + width: "100%", + }, + disabled: { + opacity: 0.6, + }, +}); diff --git a/dapps/pos-app/components/card.tsx b/dapps/pos-app/components/card.tsx index 4a9319d19..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,21 +13,21 @@ export function Card({ children, onPress, style }: Props) { const Theme = useTheme(); return onPress ? ( - + ) : ( 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"], + }, +}); diff --git a/dapps/pos-app/components/empty-state.tsx b/dapps/pos-app/components/empty-state.tsx index 1c716a8a8..fcacb3c54 100644 --- a/dapps/pos-app/components/empty-state.tsx +++ b/dapps/pos-app/components/empty-state.tsx @@ -1,5 +1,4 @@ -import { BorderRadius, Spacing } from "@/constants/spacing"; -import { useTheme } from "@/hooks/use-theme-color"; +import { Spacing } from "@/constants/spacing"; import { memo, ReactNode } from "react"; import { StyleSheet, View } from "react-native"; import { Button } from "./button"; @@ -16,8 +15,6 @@ interface EmptyStateProps { } function EmptyStateBase({ title, subtitle, icon, cta }: EmptyStateProps) { - const theme = useTheme(); - return ( {icon && {icon}} @@ -39,12 +36,12 @@ function EmptyStateBase({ title, subtitle, icon, cta }: EmptyStateProps) { {cta && ( )} @@ -72,10 +69,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 b774c89b4..91a650c74 100644 --- a/dapps/pos-app/components/filter-buttons.tsx +++ b/dapps/pos-app/components/filter-buttons.tsx @@ -4,65 +4,43 @@ 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"; +export interface FilterButtonConfig { + label: string; + onPress: () => void; +} + interface FilterButtonsProps { - statusLabel: string; - dateRangeLabel: string; - onStatusPress: () => void; - onDateRangePress: () => void; + buttons: FilterButtonConfig[]; } -function FilterButtonsBase({ - statusLabel, - dateRangeLabel, - onStatusPress, - onDateRangePress, -}: FilterButtonsProps) { +function FilterButtonsBase({ buttons }: FilterButtonsProps) { const theme = useTheme(); const [assets] = useAssets([require("@/assets/images/caret-up-down.png")]); return ( - - + {buttons.map((button, index) => ( + + + {button.label} + + {assets?.[0] && ( + + )} + + ))} ); } @@ -82,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 new file mode 100644 index 000000000..fa3b8e74f --- /dev/null +++ b/dapps/pos-app/components/log-card.tsx @@ -0,0 +1,222 @@ +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 { Pressable, StyleSheet, View } from "react-native"; +import Animated, { + Easing, + FadeIn, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { Button } from "./button"; +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"); + }, [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/components/numeric-keyboard.tsx b/dapps/pos-app/components/numeric-keyboard.tsx index d4962a8bc..4caf055a9 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,17 +31,19 @@ function NumericKeyboardBase({ onKeyPress, style }: NumericKeyboardProps) { {keys.map((row, rowIndex) => ( {row.map((key) => ( - + ))} ))} diff --git a/dapps/pos-app/components/pin-modal.tsx b/dapps/pos-app/components/pin-modal.tsx index 795aeb3ab..4bfe8cf28 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,10 +238,10 @@ function PinModalBase({ activeOpacity={0.7} style={[ styles.cancelButton, - { backgroundColor: theme["foreground-secondary"] }, + { borderColor: theme["border-secondary"], borderWidth: 1 }, ]} > - + Cancel @@ -329,10 +329,11 @@ const styles = StyleSheet.create({ }, cancelButton: { marginTop: Spacing["spacing-5"], - paddingVertical: Spacing["spacing-3"], paddingHorizontal: Spacing["spacing-6"], borderRadius: BorderRadius["3"], width: "100%", + height: 48, alignItems: "center", + justifyContent: "center", }, }); 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 new file mode 100644 index 000000000..fe5a63ca5 --- /dev/null +++ b/dapps/pos-app/components/pressable.tsx @@ -0,0 +1,40 @@ +import { PressableScale } from "pressto"; +import React from "react"; +import { AccessibilityRole, StyleProp, ViewStyle } from "react-native"; + +interface Props { + children: React.ReactNode; + style?: StyleProp; + onPress: () => void; + disabled?: boolean; + testID?: string; + accessibilityRole?: AccessibilityRole; + accessibilityLabel?: string; + accessibilityHint?: string; +} + +export const Pressable: React.FC = ({ + children, + style, + onPress, + disabled, + testID, + accessibilityRole = "button", + accessibilityLabel, + accessibilityHint, +}) => { + return ( + + {children} + + ); +}; 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-bottom-sheet.tsx b/dapps/pos-app/components/settings-bottom-sheet.tsx index 1fdd96c92..e1e33d633 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"; @@ -30,6 +30,7 @@ const EASING = Easing.inOut(Easing.ease); interface SettingsBottomSheetProps { visible: boolean; title: string; + subtitle?: string; onClose: () => void; children: React.ReactNode; } @@ -37,6 +38,7 @@ interface SettingsBottomSheetProps { export function SettingsBottomSheet({ visible, title, + subtitle, onClose, children, }: SettingsBottomSheetProps) { @@ -85,30 +87,42 @@ export function SettingsBottomSheet({ }, ]} > - - - - {title} - - + + + + + {title} + + + + + + {subtitle ? ( + + {subtitle} + + ) : null} 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"; + /** 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; } @@ -20,28 +32,54 @@ export function SettingsItem({ value, onPress, showCaret, + caret = "up-down", + icon, + bullet, + badge, 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 ( - + ); } @@ -82,6 +121,9 @@ const styles = StyleSheet.create({ alignItems: "center", gap: Spacing["spacing-2"], }, + title: { + fontWeight: "500", + }, value: { flex: 1, }, @@ -89,4 +131,13 @@ const styles = StyleSheet.create({ width: 20, height: 20, }, + leadingIcon: { + width: 16, + height: 16, + }, + bullet: { + width: 8, + height: 8, + borderRadius: 4, + }, }); diff --git a/dapps/pos-app/components/settings-section.tsx b/dapps/pos-app/components/settings-section.tsx new file mode 100644 index 000000000..a9fdc6e86 --- /dev/null +++ b/dapps/pos-app/components/settings-section.tsx @@ -0,0 +1,36 @@ +import { Spacing } from "@/constants/spacing"; +import { StyleSheet, View } from "react-native"; +import { ThemedText } from "./themed-text"; + +interface SettingsSectionProps { + title: string; + children: React.ReactNode; +} + +export function SettingsSection({ title, children }: SettingsSectionProps) { + return ( + + + {title} + + {children} + + ); +} + +const styles = StyleSheet.create({ + section: { + gap: Spacing["spacing-3"], + }, + title: { + fontWeight: "500", + }, + rows: { + gap: Spacing["spacing-2"], + }, +}); diff --git a/dapps/pos-app/components/settings-toggle-item.tsx b/dapps/pos-app/components/settings-toggle-item.tsx new file mode 100644 index 000000000..b9cc6f538 --- /dev/null +++ b/dapps/pos-app/components/settings-toggle-item.tsx @@ -0,0 +1,87 @@ +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useTheme } from "@/hooks/use-theme-color"; +import { StyleSheet, View } from "react-native"; +import { Switch } from "./switch"; +import { ThemedText } from "./themed-text"; + +interface SettingsToggleItemProps { + title: string; + description?: string; + value: boolean; + onValueChange: (value: boolean) => 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", + }, +}); diff --git a/dapps/pos-app/components/setup-banner.tsx b/dapps/pos-app/components/setup-banner.tsx new file mode 100644 index 000000000..86db07e56 --- /dev/null +++ b/dapps/pos-app/components/setup-banner.tsx @@ -0,0 +1,68 @@ +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 { Badge } from "./badge"; +import { ThemedText } from "./themed-text"; + +interface SetupBannerProps { + remaining: number; + testID?: string; +} + +export function SetupBanner({ remaining, testID }: SetupBannerProps) { + const Theme = useTheme(); + + return ( + + + + + 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/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/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/components/toast.tsx b/dapps/pos-app/components/toast.tsx index 1e26cc6fc..6ee7ddf7c 100644 --- a/dapps/pos-app/components/toast.tsx +++ b/dapps/pos-app/components/toast.tsx @@ -2,44 +2,54 @@ 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; } 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 +57,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/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 45c8b169b..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 { Button } from "./button"; -import { StatusBadge } from "./status-badge"; +import { Pressable } from "./pressable"; import { ThemedText } from "./themed-text"; +const CHEVRON = require("@/assets/images/chevron-right.png"); + interface TransactionCardProps { payment: PaymentRecord; onPress: () => void; @@ -21,34 +24,59 @@ function TransactionCardBase({ style, }: TransactionCardProps) { const theme = useTheme(); + const meta = getTransactionStatusMeta(payment.status); return ( - + ); } @@ -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 0f43dd8dc..0defb286c 100644 --- a/dapps/pos-app/components/transaction-detail-modal.tsx +++ b/dapps/pos-app/components/transaction-detail-modal.tsx @@ -11,14 +11,16 @@ 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, useSharedValue, withTiming, } from "react-native-reanimated"; -import { Button } from "./button"; import { FramedModal } from "./framed-modal"; import { StatusBadge } from "./status-badge"; import { ThemedText } from "./themed-text"; @@ -38,12 +40,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 +72,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 +93,7 @@ function DetailRow({ color="text-primary" numberOfLines={1} ellipsizeMode="middle" - style={[styles.valueText, underline && styles.underlineText]} + style={styles.valueText} > {value} @@ -106,12 +102,40 @@ function DetailRow({ ); if (onPress) { - return ; + return {content}; } 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 +170,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 ( @@ -176,7 +200,7 @@ function TransactionDetailModalBase({ ]} > - + )} - + + + {txHash && ( - + + + )} @@ -255,8 +273,8 @@ function TransactionDetailModalBase({ @@ -305,9 +323,6 @@ const styles = StyleSheet.create({ textAlign: "right", flex: 1, }, - underlineText: { - textDecorationLine: "underline", - }, closeButton: { borderRadius: BorderRadius["3"], borderWidth: StyleSheet.hairlineWidth, @@ -328,4 +343,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/printer-logos.ts b/dapps/pos-app/constants/printer-logos.ts index 28f7b21a0..0c48938cf 100644 --- a/dapps/pos-app/constants/printer-logos.ts +++ b/dapps/pos-app/constants/printer-logos.ts @@ -1,5 +1,2 @@ -export const MONEY2020_LOGO_BASE64 = - "data:image/png;base64,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"; - export const DEFAULT_LOGO_BASE64 = "data:image/png;base64,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cAnK8vXZ+/btUzdv3uQb+HpnihkpAj0rQFDu2alj4AgggEAcAo1GQ/38+dNYzN69e1V/f3/yz9iYBggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhPgKAc35xSEQIIIIAAAggggEAAAYJyAES6QAABBBBAAAEEEIhP4G9ztJD5T5jPOwAAAABJRU5ErkJggg=="; diff --git a/dapps/pos-app/constants/theme.ts b/dapps/pos-app/constants/theme.ts index a81faea8d..607e60d0c 100644 --- a/dapps/pos-app/constants/theme.ts +++ b/dapps/pos-app/constants/theme.ts @@ -4,24 +4,29 @@ 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", + "icon-warning": "#F3A13F", // Background colors "bg-primary": "#FFFFFF", "bg-invert": "#202020", - "bg-accent-primary": "#0988F0", - "bg-payment-success": "#0988F0", + "bg-accent-primary": "#0666FF", + "bg-payment-success": "#0666FF", + "bg-warning": "#F3A13F33", // 20% opacity // Text colors "text-primary": "#202020", @@ -35,35 +40,40 @@ 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", + "icon-warning": "#F3A13F", // Background colors "bg-primary": "#202020", "bg-invert": "#FFFFFF", - "bg-accent-primary": "#0988F0", - "bg-payment-success": "#0988F0", + "bg-accent-primary": "#0666FF", + "bg-payment-success": "#0666FF", + "bg-warning": "#F3A13F33", // 20% opacity // 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,10 +81,12 @@ export const Colors = { "border-primary": "#363636", "border-secondary": "#4F4F4F", "border-payment-success": "#E9E9E9", - "border-accent-primary": "#0988F0", + "border-accent-primary": "#0666FF", }, }; +export type ColorKey = keyof typeof Colors.light; + export const Fonts = Platform.select({ ios: { /** iOS `UIFontDescriptorSystemDesignDefault` */ diff --git a/dapps/pos-app/constants/variants.ts b/dapps/pos-app/constants/variants.ts index 54a169f66..c775cb981 100644 --- a/dapps/pos-app/constants/variants.ts +++ b/dapps/pos-app/constants/variants.ts @@ -1,17 +1,13 @@ -import { MONEY2020_LOGO_BASE64 } from "./printer-logos"; import { Colors } from "./theme"; -export type VariantName = - | "default" - | "solflare" - | "binance" - | "phantom" - | "solana" - | "trezor" - | "ledger" - | "imin" - | "money2020" - | "xmoney"; +// Only "default" ships today. The variant machinery below is intentionally kept +// so a brand variant can be re-added later. To add one: +// 1. Add its key to the `VariantName` union. +// 2. Add a matching entry to `Variants` (brand colors, optional `variantLogo`, +// `printerLogo`, `defaultTheme`, `allowThemeToggle`). +// 3. Re-add the "Wallet theme" SettingsItem + bottom sheet in app/settings.tsx +// (it renders `VariantList` via RadioList and calls `setVariant`). +export type VariantName = "default"; type VariantColorOverrides = Partial; @@ -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/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" diff --git a/dapps/pos-app/hooks/use-merchant-flow.ts b/dapps/pos-app/hooks/use-merchant-flow.ts index 3941792cf..e1e9a9c24 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"; @@ -9,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; @@ -18,6 +22,7 @@ interface MerchantFlowState { const initialState: MerchantFlowState = { merchantIdInput: "", customerApiKeyInput: "", + isEditingCustomerApiKey: false, activeModal: "none", pinError: null, pendingValue: null, @@ -61,9 +66,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) => { @@ -77,6 +80,7 @@ export function useMerchantFlow() { setState((prev) => ({ ...prev, customerApiKeyInput: value, + isEditingCustomerApiKey: true, })); }, []); @@ -84,6 +88,7 @@ export function useMerchantFlow() { setState((prev) => ({ ...prev, customerApiKeyInput: "", + isEditingCustomerApiKey: false, })); }, []); @@ -122,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) { @@ -136,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"); @@ -161,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) => ({ @@ -255,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; @@ -271,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/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/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); }); 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(() => { 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/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/store/useLogsStore.ts b/dapps/pos-app/store/useLogsStore.ts index 4b9510a83..8e2757d16 100644 --- a/dapps/pos-app/store/useLogsStore.ts +++ b/dapps/pos-app/store/useLogsStore.ts @@ -1,4 +1,5 @@ import { storage } from "@/utils/storage"; +import { DateRangeFilterType, LogLevelFilterType } from "@/utils/types"; import { v4 as uuidv4 } from "uuid"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -18,6 +19,8 @@ export interface LogEntry { interface LogsStore { logs: LogEntry[]; + logLevelFilter: LogLevelFilterType; + logDateRangeFilter: DateRangeFilterType; _hasHydrated: boolean; // Actions @@ -29,6 +32,8 @@ interface LogsStore { data?: Record, ) => void; clearLogs: () => void; + setLogLevelFilter: (filter: LogLevelFilterType) => void; + setLogDateRangeFilter: (filter: DateRangeFilterType) => void; setHasHydrated: (state: boolean) => void; } @@ -40,6 +45,8 @@ export const useLogsStore = create()( persist( (set) => ({ logs: [], + logLevelFilter: "all", + logDateRangeFilter: "all_time", _hasHydrated: false, addLog: (level, message, view, functionName, data) => { @@ -72,7 +79,16 @@ export const useLogsStore = create()( }); }, - clearLogs: () => set({ logs: [] }), + clearLogs: () => + set({ + logs: [], + logLevelFilter: "all", + logDateRangeFilter: "all_time", + }), + + setLogLevelFilter: (filter) => set({ logLevelFilter: filter }), + + setLogDateRangeFilter: (filter) => set({ logDateRangeFilter: filter }), setHasHydrated: (state: boolean) => set({ _hasHydrated: state }), }), diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index ac321de69..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,8 +115,9 @@ export const useSettingsStore = create()( _hasHydrated: false, merchantId: null, isCustomerApiKeySet: false, + hasInitializedDefaults: false, transactionFilter: "all", - dateRangeFilter: "today", + dateRangeFilter: "all_time", isPinHashSet: false, pinFailedAttempts: 0, pinLockoutUntil: null, @@ -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: 16, + version: 19, storage, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { @@ -345,6 +321,27 @@ 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"; + } + + if (version < 18) { + // The date range filter previously defaulted to "today", which made a + // fresh terminal with no transactions show the "filters active" empty + // state ("No payments found") instead of the onboarding empty state + // ("No payments yet"). Reset to "all_time" so the default is unfiltered. + 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) => { @@ -393,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(); @@ -406,6 +404,8 @@ export const useSettingsStore = create()( if (!state.isCustomerApiKeySet && defaultApiKey) { await state.setCustomerApiKey(defaultApiKey); } + + state.hasInitializedDefaults = true; } } 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("/"); +} diff --git a/dapps/pos-app/utils/date-range.ts b/dapps/pos-app/utils/date-range.ts index 2bc376b05..ff1d9a5b0 100644 --- a/dapps/pos-app/utils/date-range.ts +++ b/dapps/pos-app/utils/date-range.ts @@ -5,6 +5,20 @@ interface DateRange { endTs?: string; } +/** + * Shared date-range options for filter UIs (Transactions + Logs). + */ +export 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" }, +]; + /** * Computes ISO date strings for a given date range filter. */ 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"; diff --git a/dapps/pos-app/utils/logs.ts b/dapps/pos-app/utils/logs.ts new file mode 100644 index 000000000..344289bcd --- /dev/null +++ b/dapps/pos-app/utils/logs.ts @@ -0,0 +1,74 @@ +import { LogEntry } from "@/store/useLogsStore"; +import { getDateRange } from "./date-range"; +import { DateRangeFilterType, LogLevelFilterType } from "./types"; + +/** + * 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); + const datePart = date.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + const timePart = date.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + }); + return `${datePart} - ${timePart}`; +}; + +/** + * 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", + }); +}; + +/** + * Filter logs by level and date range (client-side — logs live in memory). + * The "info" level filter also matches "log" entries, which render as the + * same Info badge on the card. + */ +export const filterLogs = ( + logs: LogEntry[], + level: LogLevelFilterType, + dateFilter: DateRangeFilterType, +): LogEntry[] => { + const { startTs } = getDateRange(dateFilter); + const startMs = startTs ? new Date(startTs).getTime() : undefined; + return logs.filter((log) => { + const levelOk = + level === "all" || + (level === "info" + ? log.level === "info" || log.level === "log" + : log.level === level); + const dateOk = startMs === undefined || log.timestamp >= startMs; + return levelOk && dateOk; + }); +}; + +/** + * 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); +}; 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..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", @@ -52,12 +41,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.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 362db5763..4141ffa9f 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.dismissAll(); router.replace("/"); 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..e290399af 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,23 +18,26 @@ 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. Try the payment again. If it keeps failing, contact support.", }, }; /** 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; @@ -68,8 +71,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.`, }; } 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; }; 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) => ( + ), }; diff --git a/dapps/pos-app/utils/transaction-status.ts b/dapps/pos-app/utils/transaction-status.ts new file mode 100644 index 000000000..1dd2dd9ee --- /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 `TransactionStatus` 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"), + }; + } +} diff --git a/dapps/pos-app/utils/types.ts b/dapps/pos-app/utils/types.ts index c6f6aa59f..b1a8afa42 100644 --- a/dapps/pos-app/utils/types.ts +++ b/dapps/pos-app/utils/types.ts @@ -55,6 +55,9 @@ export type DateRangeFilterType = | "this_week" | "this_month"; +// Logs filters +export type LogLevelFilterType = "all" | "info" | "error"; + export interface DisplayAmount { formatted?: string; assetSymbol?: string;