From d297ac607b72181a9265712274ce7bf2275a7011 Mon Sep 17 00:00:00 2001 From: amossamuel851-tech Date: Mon, 29 Jun 2026 17:48:49 +0000 Subject: [PATCH] fix: resolve issues #525, #526, #527, #528 assigned to amossamuel851 - #525: Added comprehensive JSDoc to usePropertySearch hook and updated docs/hooks.md with edge cases, loadMore, and setResultsPerPage - #526: Documented CSP_ENFORCE env var in docs/csp.md with setup and extension guide - #527: Added step-by-step wallet connector contributor guide to CONTRIBUTING.md covering detection, validation, and error handling - #528: Sorted package.json keys and added CI sort-check script at scripts/sort-package-json.mjs (wired into lint pipeline) Closes #525, Closes #526, Closes #527, Closes #528 --- CONTRIBUTING.md | 95 +++++++++++++++++++++++++++++ docs/csp.md | 32 ++++++++++ docs/hooks.md | 22 +++++-- package.json | 29 ++++----- scripts/sort-package-json.mjs | 86 ++++++++++++++++++++++++++ src/hooks/usePropertySearchQuery.ts | 19 +++++- 6 files changed, 263 insertions(+), 20 deletions(-) create mode 100644 scripts/sort-package-json.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ed4fea9..2d98aa20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -346,6 +346,101 @@ We use a consistent design system based on: ## 🌐 Web3 Development +### Adding a New Wallet Connector + +PropChain uses lazy-loaded wallet connector modules to keep the initial bundle small (~178 KB savings). To add support for a new wallet (e.g. Trust Wallet, Rainbow, Phantom), follow this step-by-step guide. + +#### Step 1: Create the connector module + +Create `src/lib/walletConnectors/.ts`. Every connector must export two functions: + +```typescript +// src/lib/walletConnectors/.ts + +export interface WalletNameConnectorResult { + address: string; // 0x-prefixed hex address + chainId: number; // decimal chain ID +} + +/** Connect to the wallet. Must throw descriptive user-facing errors. */ +export const connectWalletNameWallet = async (): Promise => { + // 1. DETECTION — check if the wallet is available + // 2. CONNECTION — request accounts via the provider + // 3. VALIDATION — verify the returned address and chainId + // 4. RETURN the result +}; + +/** Synchronous check: is the wallet installed and ready? */ +export const isWalletNameAvailable = (): boolean => { + // Return true only if the provider object is present and the wallet flag is set +}; +``` + +#### Step 2: Detection + +Before attempting connection, verify the wallet provider is present: + +- **Injected wallets** (MetaMask, Coinbase, Rainbow): check `window.ethereum` and the wallet-specific flag (`isMetaMask`, `isCoinbaseWallet`, `isRainbow`). +- **Mobile / deep-link wallets** (Trust Wallet, Phantom): check `window.ethereum` or the wallet's own injected namespace. +- **QR-code wallets** (WalletConnect): check that the required environment variable (`NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID`) is configured. + +Throw a descriptive error if the wallet is not found: + +```typescript +if (!window.ethereum?.isWalletName) { + throw new Error( + 'WalletName is not installed. Please install the WalletName extension or app to continue.' + ); +} +``` + +#### Step 3: Validation + +Always validate the response from the wallet before returning: + +```typescript +const accounts = await provider.request({ method: 'eth_requestAccounts' }); + +// Validate the response shape +if (!Array.isArray(accounts) || accounts.length === 0) { + throw new Error('No accounts returned from WalletName'); +} + +const address = accounts[0]; +if (typeof address !== 'string' || !address.startsWith('0x')) { + throw new Error('Invalid account address received from WalletName'); +} + +const chainIdHex = await provider.request({ method: 'eth_chainId' }); +if (typeof chainIdHex !== 'string') { + throw new Error('Invalid chain ID received from WalletName'); +} +``` + +#### Step 4: Error messages + +All errors thrown by the connector MUST be user-friendly. Handle these standard cases: + +| Error code | Meaning | User-facing message | +|---|---|---| +| `4001` | User rejected the request | `"You rejected the connection request. Please try again."` | +| `-32002` | Request already pending | `"Connection request is already pending. Please check your wallet."` | +| Provider missing | Wallet not installed | `" is not installed. Please install the extension."` | +| Network error | RPC timeout or offline | `"Network error. Please check your connection and try again."` | + +Use `getErrorCode(error)` from `@/utils/typeGuards` to safely extract numeric error codes. + +#### Step 5: Register the connector + +1. **Add to `useWalletConnector.ts`**: Import the new connector and add a case for it in the `connectWallet` function. +2. **Add to `WalletModal.tsx`**: Add a button/option for the new wallet in the wallet selection UI. +3. **Add tests**: Create a test file under `src/lib/walletConnectors/__tests__/` that mocks the provider and covers connection, rejection, and missing-provider scenarios. +4. **Update this guide**: Add the new connector to the table in `src/lib/walletConnectors/README.md`. + +#### Template connector + +A minimal starting point is available at `src/lib/walletConnectors/README.md#adding-new-wallets`. + ### Wallet Integration When working with Web3 features: diff --git a/docs/csp.md b/docs/csp.md index e2563a9f..d7cd89a2 100644 --- a/docs/csp.md +++ b/docs/csp.md @@ -26,10 +26,42 @@ PropChain enforces a strict Content Security Policy to prevent XSS attacks. The CSP violations are reported to `POST /api/csp-report`. In development mode, reports are logged to the console. +## Environment Control: `CSP_ENFORCE` + +The middleware uses the environment variable `CSP_ENFORCE` to toggle between **enforcement** and **report-only** modes: + +| `CSP_ENFORCE` | Environment | Header Sent | Behaviour | +|---|---|---|---| +| `"true"` | Any | `Content-Security-Policy` | Violations are **blocked** by the browser | +| anything else (or unset) | Any | *No CSP header* | CSP is disabled entirely | + +> **Note**: In development (`NODE_ENV=development`), the `script-src` directive includes `'unsafe-eval'` to support hot reload. This is **never** included in production builds. + +### Adding `CSP_ENFORCE` to your environment + +```env +# .env.local (development — CSP disabled by default for easier debugging) +# CSP_ENFORCE=true # uncomment to test CSP enforcement locally + +# .env.production (production — CSP should be enforced) +CSP_ENFORCE=true +``` + +### How to extend the CSP + +To add new directives or allow additional origins: + +1. Edit `src/middleware.ts` → `buildCspHeader()`. +2. Add the new directive to the `directives` array. +3. Ensure nonce-based scripts are properly handled (the `x-nonce` request header is forwarded). +4. Test in report-only mode first by setting `CSP_ENFORCE=false` and checking the browser console for violation reports. +5. Violations are automatically posted to `POST /api/csp-report` for monitoring. + ## Exclusions The following paths are excluded from CSP: - `/api/*` - API routes +- `/sw.js` - Service Worker script - `/_next/static/*` - Next.js static assets - `/_next/image/*` - Next.js image optimization - `/favicon.ico`, `/sitemap.xml`, `/robots.txt` diff --git a/docs/hooks.md b/docs/hooks.md index a34a5a88..ac0fba38 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -122,9 +122,9 @@ function TransactionForm() { ## `usePropertySearch` -**File**: `src/hooks/usePropertySearch.ts` +**File**: `src/hooks/usePropertySearchQuery.ts` (React Query implementation) -Combines the search store, property API calls, and URL synchronization into a single hook. Automatically initializes filters from URL query parameters on mount and keeps the URL in sync as filters change. +Combines the Zustand search store, React Query caching, and URL synchronization into a single hook. Reads filters/sort/page from the store, delegates fetching to `usePropertySearchQuery` which uses `useQuery` under the hood for automatic caching, deduplication, and stale-while-revalidate behaviour. ### Returns @@ -137,15 +137,27 @@ Combines the search store, property API calls, and URL synchronization into a si | `properties` | `Property[]` | Current page of search results | | `totalResults` | `number` | Total matching properties across all pages | | `totalPages` | `number` | Computed total page count | -| `isLoading` | `boolean` | `true` while a fetch is in progress | +| `isLoading` | `boolean` | `true` while a React Query fetch is pending or refetching | | `error` | `string \| null` | Error message from the last failed fetch | -| `lastUpdated` | `number \| null` | Timestamp of the last successful fetch | +| `lastUpdated` | `Date \| undefined` | Date of the last successful React Query fetch | | `setFilters` | `(filters: SearchFilters) => void` | Replace all filters at once | | `setFilter` | `(key, value) => void` | Update a single filter key | | `clearFilters` | `() => void` | Reset all filters to defaults | | `setSortBy` | `(sort: SortOption) => void` | Change the sort order | | `setPage` | `(page: number) => void` | Navigate to a page (also scrolls to top) | -| `refetch` | `() => Promise` | Manually trigger a fresh fetch | +| `setResultsPerPage` | `(count: number) => void` | Change the number of results per page | +| `loadMore` | `() => void` | Append the next page without scrolling to top | +| `refetch` | `() => Promise` | Manually trigger a fresh React Query refetch | + +### Edge cases + +- **Empty results**: `properties` is `[]`, `totalResults` is `0`, `totalPages` is `0`. +- **Fetch error**: `error` receives the message; `properties` is cleared to `[]`. +- **Rapid filter changes**: React Query deduplicates concurrent requests for the same query key. +- **Stale data**: Cached results are served instantly for 5 minutes (`staleTime`); a background refetch updates silently. +- **Window refocus**: Does NOT trigger a refetch (`refetchOnWindowFocus: false`). +- **4xx errors**: Not retried. Network/5xx errors retried up to 3 times. +- **URL sync**: The parent component is responsible for URL parameter synchronization (see `src/hooks/usePropertySearch.ts`). ### Example diff --git a/package.json b/package.json index b0f4d199..27182833 100644 --- a/package.json +++ b/package.json @@ -3,31 +3,32 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "typecheck": "tsc --noEmit --incremental false", "build": "npm run typecheck && next build", + "build-storybook": "storybook build", "build:analyze": "node scripts/run-analyze-build.mjs", "bundle:measure": "node scripts/measure-bundle-size.mjs", - "start": "next start", - "lint": "eslint . --max-warnings=0", + "dev": "next dev", + "lint": "eslint . --max-warnings=0 && node scripts/sort-package-json.mjs", "perf:budgets": "node scripts/check-performance-budgets.mjs", "perf:ci": "npm run build && npm run perf:budgets", - "validate:env": "node scripts/validate-env.js", + "sort-package-json": "node scripts/sort-package-json.mjs", + "start": "next start", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build", "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", "test:ci": "jest --coverage --watchAll=false --ci", + "test:coverage": "jest --coverage", + "test:cy": "cypress run", + "test:cy:component": "cypress run --component", + "test:cy:component:ui": "cypress open --component", + "test:cy:ui": "cypress open", "test:e2e": "playwright test", - "test:e2e:mock": "SKIP_WEBSERVER=true playwright test tests/e2e/property-purchase-flow.spec.ts", - "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "test:e2e:install": "playwright install", - "test:cy": "cypress run", - "test:cy:ui": "cypress open", - "test:cy:component": "cypress run --component", - "test:cy:component:ui": "cypress open --component" + "test:e2e:mock": "SKIP_WEBSERVER=true playwright test tests/e2e/property-purchase-flow.spec.ts", + "test:e2e:ui": "playwright test --ui", + "test:watch": "jest --watch", + "typecheck": "tsc --noEmit --incremental false", + "validate:env": "node scripts/validate-env.js" }, "dependencies": { "@coinbase/wallet-sdk": "^4.3.7", diff --git a/scripts/sort-package-json.mjs b/scripts/sort-package-json.mjs new file mode 100644 index 00000000..2e96a3ca --- /dev/null +++ b/scripts/sort-package-json.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +/** + * CI check: ensures package.json top-level keys, scripts, dependencies, and + * devDependencies are alphabetically sorted. + * + * Usage: + * node scripts/sort-package-json.mjs # check only (exit 1 if unsorted) + * node scripts/sort-package-json.mjs --fix # sort in-place and exit 0 + * + * Run as a CI gate to prevent noisy diffs from unsorted keys. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgPath = resolve(__dirname, '..', 'package.json'); +const shouldFix = process.argv.includes('--fix'); + +const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + +/** + * Sort an object's keys alphabetically, preserving the original order for + * non-string keys (shouldn't exist in package.json, but be safe). + */ +function sortKeys(obj) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; + const sorted = {}; + const keys = Object.keys(obj).sort((a, b) => a.localeCompare(b, 'en')); + for (const key of keys) { + sorted[key] = obj[key]; + } + return sorted; +} + +// Fields to check for alphabetical sorting +const fieldsToCheck = ['scripts', 'dependencies', 'devDependencies']; +const fieldsToSkip = ['name', 'version', 'private', 'type']; // conventional top-level order + +/** Check if keys of an object are in alphabetical order */ +function isSorted(obj) { + const keys = Object.keys(obj); + for (let i = 1; i < keys.length; i++) { + if (keys[i].localeCompare(keys[i - 1], 'en') < 0) { + return { sorted: false, firstUnsorted: keys[i], previous: keys[i - 1] }; + } + } + return { sorted: true }; +} + +let hasErrors = false; + +for (const field of fieldsToCheck) { + if (!pkg[field]) continue; + const result = isSorted(pkg[field]); + if (!result.sorted) { + console.error( + `āŒ package.json → "${field}" keys are NOT sorted.\n` + + ` First unsorted key: "${result.firstUnsorted}" (comes after "${result.previous}")` + ); + hasErrors = true; + } +} + +if (hasErrors) { + if (shouldFix) { + console.log('\nšŸ”§ Auto-fixing package.json key order...'); + for (const field of fieldsToCheck) { + if (pkg[field]) { + pkg[field] = sortKeys(pkg[field]); + } + } + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8'); + console.log('āœ… package.json keys sorted successfully.'); + } else { + console.log( + '\nšŸ’” Run with --fix to auto-sort:\n' + + ' node scripts/sort-package-json.mjs --fix' + ); + process.exit(1); + } +} else { + console.log('āœ… package.json keys are properly sorted.'); +} diff --git a/src/hooks/usePropertySearchQuery.ts b/src/hooks/usePropertySearchQuery.ts index e1513dbf..1ecdd0e3 100644 --- a/src/hooks/usePropertySearchQuery.ts +++ b/src/hooks/usePropertySearchQuery.ts @@ -89,7 +89,24 @@ export function usePropertyQuery(id: string, enabled: boolean = true) { /** * Combined hook that maintains the same API as the original usePropertySearch - * but uses React Query under the hood + * but uses React Query under the hood for caching, deduplication, and stale-while-revalidate. + * + * ## What it does + * - Reads filters, sort, page, and resultsPerPage from the Zustand search store. + * - Passes them to `usePropertySearchQuery` which uses React Query (`useQuery`). + * - Automatically refetches whenever filters, sort, or page change (enabled after initialization). + * - Returns the same shape as the legacy hook so consumers don't need to change. + * + * ## Edge cases handled + * - **Empty results**: `properties` is an empty array, `totalResults` is 0, and `totalPages` is 0. + * - **Fetch error**: `error` is set to the error message, `properties` is cleared to `[]`. + * - **Loading state**: `isLoading` is true while the query is pending or refetching. + * - **Stale data**: React Query serves cached data for 5 minutes (staleTime) while refetching silently. + * - **Rapid filter changes**: React Query deduplicates concurrent requests for the same key. + * - **Window refocus**: Does NOT refetch on window focus (refetchOnWindowFocus: false). + * - **4xx errors**: Not retried; network/5xx errors retried up to 3 times. + * - **Pagination**: `setPage` scrolls to top by default; `loadMore` appends without scrolling. + * - **URL sync**: The parent component is responsible for URL synchronization (see `usePropertySearch.ts`). */ export function usePropertySearch() { const searchStore = useSearchStore();