Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<walletname>.ts`. Every connector must export two functions:

```typescript
// src/lib/walletConnectors/<walletname>.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<WalletNameConnectorResult> => {
// 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 | `"<WalletName> 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:
Expand Down
32 changes: 32 additions & 0 deletions docs/csp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
22 changes: 17 additions & 5 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<void>` | 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<void>` | 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

Expand Down
29 changes: 15 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions scripts/sort-package-json.mjs
Original file line number Diff line number Diff line change
@@ -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.');
}
19 changes: 18 additions & 1 deletion src/hooks/usePropertySearchQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down