From 4b62a1a7758f14fd04d9cc8fed5d43bb3ed87f16 Mon Sep 17 00:00:00 2001 From: timiturn3r Date: Thu, 30 Jul 2026 22:43:04 +0100 Subject: [PATCH 1/9] docs: add mock-mode development workflow documentation --- PRE_COMMIT_SETUP.md | 2 - README.md | 96 ++++++- docs/observability.md | 262 ++++++++++++++++++++ scripts/check-i18n.mjs | 31 ++- scripts/find-unused-i18n-keys.js | 44 ++-- src/__tests__/hooks/usePlatformFee.test.tsx | 6 +- src/components/CampaignMap.tsx | 4 +- src/components/CancelDonationBanner.tsx | 11 +- src/components/ContributorLeaderboard.tsx | 12 +- src/components/DonatorBadges.tsx | 21 +- src/components/NotificationSettings.tsx | 15 +- src/components/WalletContext.tsx | 20 +- src/context/DonationContext.tsx | 14 +- src/hooks/useDonationGracePeriod.ts | 8 +- src/lib/adminLog.ts | 24 +- src/lib/analytics.ts | 22 +- src/lib/contributorLeaderboard.ts | 43 ++-- src/lib/eventSubscriber.ts | 19 +- src/lib/gamification.ts | 61 ++--- src/lib/localStorageStore.ts | 78 ++++++ src/lib/notifications.ts | 29 +-- src/lib/preferences.ts | 49 +--- src/lib/transactionLog.ts | 31 +-- tests/e2e/withdrawal.spec.ts | 13 +- 24 files changed, 636 insertions(+), 279 deletions(-) create mode 100644 docs/observability.md create mode 100644 src/lib/localStorageStore.ts diff --git a/PRE_COMMIT_SETUP.md b/PRE_COMMIT_SETUP.md index 3204fa17..75d90959 100644 --- a/PRE_COMMIT_SETUP.md +++ b/PRE_COMMIT_SETUP.md @@ -36,8 +36,6 @@ Extended pre-commit hook to run typecheck and affected tests. - Performance tips - Troubleshooting - - ## How It Works ### 1. Get Staged Files diff --git a/README.md b/README.md index a0da6d7f..44b02ed8 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,95 @@ npm run dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## 🔧 Developing Without a Live Network + +The app includes a mock-data development mode that allows you to develop and test the UI without connecting to a live Stellar testnet. This is useful for frontend development, UI testing, and running the test suite locally. + +### Enabling Mock Mode + +Set the `NEXT_PUBLIC_USE_MOCKS` environment variable to `true` in your `.env.local` file: + +```env +NEXT_PUBLIC_USE_MOCKS=true +``` + +When enabled, the app uses mock campaign data instead of fetching from the blockchain. The mock data is defined in `src/lib/contractClient.ts` and is gated behind the `IS_MOCK_MODE` runtime check. + +**Important**: The build process will fail if you attempt to build a production bundle with `NEXT_PUBLIC_USE_MOCKS=true`. This is intentional to prevent accidental deployment of mock data to production. See [issue #343](https://github.com/Iris-IV/ProofOfHeart-frontend/issues/343) for details. + +### Using the DevMockPanel UI + +When mock mode is enabled in development, a **DevMockPanel** component appears as a floating button in the bottom-right corner of the screen (labeled "⚙️ Mock"). Click it to open the mock scenario panel. + +The panel allows you to: + +- **Switch campaign states** for campaigns 1-6 using dropdown selectors +- **Test different UI states** without changing mock data files +- **Persist scenarios** across page reloads (stored in sessionStorage) +- **Reset all scenarios** to default with one click + +#### Available Mock Scenarios + +Each campaign can be set to one of the following scenarios: + +- **Default**: Original mock data from `contractClient.ts` +- **Active**: Ongoing campaign, 50% funded, not verified +- **Verified**: Verified campaign, 33% funded, funds not yet withdrawn +- **Funded**: Successfully funded campaign, funds withdrawn, deadline passed +- **Cancelled**: Campaign cancelled by creator, 25% funded +- **Failed**: Deadline passed, goal not met (20% funded) +- **Empty**: No data (empty title, description, zero amounts) +- **Error**: Error state for testing error boundaries and loading states + +### Adding a New Mock Scenario + +To add a new mock scenario: + +1. **Add the scenario type** to the `MockScenario` type in `src/hooks/useDevMockScenario.ts`: + +```typescript +export type MockScenario = + | "default" + | "active" + // ... existing scenarios + | "your_new_scenario"; // Add here +``` + +2. **Implement the scenario logic** in `src/lib/devMockScenarios.ts` by adding a new case in the `applyMockScenario` function: + +```typescript +case "your_new_scenario": + return { + ...campaign, + // Set your desired campaign properties + is_active: true, + status: "active" as CampaignStatus, + }; +``` + +3. **Add the scenario to the UI** in `src/components/DevMockPanel.tsx` by adding an ` +``` + +4. **Add to the scenarios list** in `src/lib/devMockScenarios.ts` by updating `MOCK_SCENARIOS`: + +```typescript +export const MOCK_SCENARIOS = [ + // ... existing scenarios + { value: "your_new_scenario", label: "Your New Scenario", description: "Description" }, +] as const; +``` + +### Mock Data Files + +- **`src/lib/contractClient.ts`**: Contains the base mock campaign data +- **`src/lib/devMockScenarios.ts`**: Scenario transformation logic +- **`src/hooks/useDevMockScenario.ts`**: React hook for accessing current scenario +- **`src/components/DevMockPanel.tsx`**: Dev-only UI for switching scenarios +- **`src/lib/mockCauses.ts`**: Filter constants for listing pages + ## ⚙️ Configuration The project uses environment variables for configuration. Create a `.env.local` file in the root directory: @@ -174,6 +263,7 @@ To keep our translation files clean, you can run the unused keys script to find ```bash node scripts/find-unused-i18n-keys.js ``` + This script will output a report of keys present in `messages/en.json` but never referenced in `src/`. ## 🐳 Docker Support @@ -202,7 +292,11 @@ To run the production container: docker run -p 3000:3000 proofofheart-frontend ``` -## 🛡 Error Reporting +## � Observability + +The application includes an internal observability module for monitoring contract interactions, transaction flows, and RPC operations. See [docs/observability.md](docs/observability.md) for architecture details, event types, and instructions on adding new observability events. + +## �🛡 Error Reporting `src/components/ErrorBoundary.tsx` exposes an optional `onError` prop that receives a PII-safe error report (`name`, `message`, `stack`) whenever a React render error is caught. diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 00000000..ec8cd96b --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,262 @@ +# Observability Module + +The observability module (`src/lib/observability/`) provides internal telemetry for monitoring contract interactions, transaction flows, and RPC operations. It captures structured events, computes failure rates, and triggers alerts when thresholds are exceeded. + +## Architecture + +The module consists of four core files plus API routes: + +### Core Files + +- **`types.ts`** - Type definitions for events, categories, kinds, metrics snapshots, and alerts +- **`classify.ts`** - Error classification logic that converts raw errors into structured `ClassifiedFailure` objects +- **`record.ts`** - Event construction, logging, and transmission to backend/webhook +- **`metricsStore.ts`** - In-memory event storage, rate computation, and alert evaluation +- **`index.ts`** - Public API exports + +### API Routes + +- **`/api/observability/events`** (POST) - Ingests observability events from the client +- **`/api/observability/metrics`** (GET) - Returns a metrics snapshot with counters, rates, alerts, and recent events + +## Data Flow + +``` +App Error/Success + ↓ +classify.ts (classifySimulationFailure / classifyRpcFailure) + ↓ +ClassifiedFailure { category, kind, contractErrorCode?, message } + ↓ +record.ts (buildObservabilityEvent / buildSuccessEvent) + ↓ +ObservabilityEvent { id, timestamp, category, kind, operation, ... } + ↓ +record.ts (recordObservabilityEvent) + ├→ console.log (dev only) + ├→ POST /api/observability/events + └→ POST webhook (optional, via NEXT_PUBLIC_OBSERVABILITY_WEBHOOK_URL) + ↓ +metricsStore.ts (ingestObservabilityEvent) + ↓ +In-memory store (max 2000 events, FIFO eviction) + ↓ +GET /api/observability/metrics + ↓ +ObservabilityMetricsSnapshot { counters, rates, alerts, recentEvents } +``` + +## Event Categories and Kinds + +### Categories + +- **`contract`** - Soroban contract errors +- **`transaction`** - Transaction lifecycle events (simulation, submission, confirmation) +- **`rpc`** - RPC operation errors and timeouts + +### Kinds + +- **`contract_error`** - Contract execution error with `contractErrorCode` and `contractErrorKey` +- **`simulation_failure`** - Transaction simulation failed (non-contract error) +- **`submission_failure`** - Transaction submission to network failed +- **`confirmation_timeout`** - Transaction confirmation timed out +- **`confirmation_failed`** - Transaction confirmation failed +- **`rpc_error`** - Generic RPC error +- **`rpc_timeout`** - RPC operation timed out +- **`transaction_success`** - Transaction completed successfully + +## Metrics and Alerts + +### Counters + +The module tracks: + +- Total events +- Events by kind +- Events by contract error code +- Events by operation name + +### Rates + +Computed over a sliding time window (default 5 minutes): + +- `simulationFailureRate` - simulation failures / transaction attempts +- `submissionFailureRate` - submission failures / transaction attempts +- `rpcTimeoutRate` - RPC timeouts / RPC operations +- `contractErrorRate` - contract errors / transaction attempts + +### Alerts + +Alerts trigger when rates exceed configurable thresholds (environment variables): + +- `OBSERVABILITY_ALERT_SIMULATION_FAILURE_RATE` (default: 0.15) +- `OBSERVABILITY_ALERT_SUBMISSION_FAILURE_RATE` (default: 0.1) +- `OBSERVABILITY_ALERT_RPC_TIMEOUT_RATE` (default: 0.2) + +Alert severity is `warning` at threshold and `critical` at 2× threshold. Minimum sample size of 10 events is required before alerting. + +## Adding a New Event Type + +To add a new observability event type, follow these steps: + +### 1. Add the Kind to `types.ts` + +```typescript +// src/lib/observability/types.ts +export type ObservabilityKind = + | "contract_error" + | "simulation_failure" + // ... existing kinds + | "your_new_kind"; // Add here +``` + +### 2. Add Classification Logic (if needed) + +If your new kind requires error classification, add a classifier in `classify.ts`: + +```typescript +// src/lib/observability/classify.ts +export function classifyYourError(error: unknown, operation?: string): ClassifiedFailure { + // Your classification logic here + return { + category: "transaction", // or "contract" or "rpc" + kind: "your_new_kind", + message: error instanceof Error ? error.message : String(error), + }; +} +``` + +Export the new classifier in `index.ts`: + +```typescript +// src/lib/observability/index.ts +export { classifyYourError } from "./classify"; +``` + +### 3. Add Recording Helper (optional) + +For convenience, add a recording helper in `record.ts`: + +```typescript +// src/lib/observability/record.ts +export function recordYourNewKind( + message: string, + options?: { operation?: string; rpcStatus?: string; txHash?: string }, +): void { + recordObservabilityKind("your_new_kind", message, options); +} +``` + +Export it in `index.ts`: + +```typescript +// src/lib/observability/index.ts +export { + // ... existing exports + recordYourNewKind, +} from "./record"; +``` + +### 4. Update Metrics Computation (if needed) + +If your new kind should be included in rate calculations, update `metricsStore.ts`: + +```typescript +// src/lib/observability/metricsStore.ts +function computeRates( + windowEvents: ObservabilityEvent[], + windowMs: number, +): ObservabilityRatesSnapshot { + // Add your new kind to the relevant denominator or numerator + const yourNewKindCount = windowEvents.filter((event) => event.kind === "your_new_kind").length; + + // Update the returned snapshot if you want a new rate field + return { + // ... existing rates + // yourNewRate: yourNewKindCount / denom, + }; +} +``` + +### 5. Add Alert Threshold (if needed) + +If your new kind should trigger alerts, add threshold configuration and alert logic in `metricsStore.ts`: + +```typescript +// src/lib/observability/metricsStore.ts +export function evaluateAlerts(rates: ObservabilityRatesSnapshot): ObservabilityAlert[] { + const yourThreshold = Number(process.env.OBSERVABILITY_ALERT_YOUR_RATE) || 0.1; + + if (rates.sampleSize >= 10 && rates.yourNewRate >= yourThreshold) { + alerts.push({ + id: "elevated-your-kind", + severity: rates.yourNewRate >= yourThreshold * 2 ? "critical" : "warning", + message: "Your kind rate is above the configured threshold.", + metric: "yourNewRate", + currentRate: rates.yourNewRate, + threshold: yourThreshold, + triggeredAt: now, + }); + } + + return alerts; +} +``` + +### 6. Use the New Event Type + +Record events from your application code: + +```typescript +import { recordYourNewKind, classifyYourError } from "@/lib/observability"; + +// Using the classifier (if you added one) +try { + await someOperation(); +} catch (error) { + const failure = classifyYourError(error, "someOperation"); + recordObservabilityFailure(failure, { operation: "someOperation" }); +} + +// Or using the helper (if you added one) +recordYourNewKind("Something happened", { operation: "someOperation" }); + +// Or using the generic kind recorder +recordObservabilityKind("your_new_kind", "Something happened", { + operation: "someOperation", +}); +``` + +## Environment Variables + +- `NEXT_PUBLIC_STELLAR_NETWORK` - Network name (mainnet/testnet), auto-detected from `NEXT_PUBLIC_NETWORK_PASSPHRASE` if not set +- `NEXT_PUBLIC_OBSERVABILITY_WEBHOOK_URL` - Optional external webhook URL for event forwarding +- `OBSERVABILITY_ALERT_SIMULATION_FAILURE_RATE` - Alert threshold for simulation failures (default: 0.15) +- `OBSERVABILITY_ALERT_SUBMISSION_FAILURE_RATE` - Alert threshold for submission failures (default: 0.1) +- `OBSERVABILITY_ALERT_RPC_TIMEOUT_RATE` - Alert threshold for RPC timeouts (default: 0.2) + +## Testing + +The module exports `resetObservabilityMetricsForTests()` for clearing the in-memory store between tests: + +```typescript +import { resetObservabilityMetricsForTests } from "@/lib/observability"; + +beforeEach(() => { + resetObservabilityMetricsForTests(); +}); +``` + +## Storage + +Events are stored in-memory on the server with a maximum of 2,000 events (FIFO eviction). This is suitable for development and monitoring but not for long-term analytics. For production persistence, integrate an external error tracker (see issue #381). + +## API Endpoints + +### POST /api/observability/events + +Ingests an observability event. Accepts JSON matching `ObservabilityEvent` schema. Returns 202 on success. + +### GET /api/observability/metrics?windowMs=300000 + +Returns a metrics snapshot. Optional `windowMs` query parameter controls the time window for rate calculations (default: 5 minutes). diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index a9b39974..0b67799a 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -1,18 +1,18 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const messagesDir = path.join(__dirname, '../messages'); -const srcDir = path.join(__dirname, '../src'); +const messagesDir = path.join(__dirname, "../messages"); +const srcDir = path.join(__dirname, "../src"); -function getAllKeys(obj, prefix = '') { +function getAllKeys(obj, prefix = "") { return Object.keys(obj).reduce((acc, key) => { const value = obj[key]; const newKey = prefix ? `${prefix}.${key}` : key; - if (typeof value === 'object' && value !== null) { + if (typeof value === "object" && value !== null) { acc.push(...getAllKeys(value, newKey)); } else { acc.push(newKey); @@ -36,19 +36,18 @@ function getAllFiles(dir, files = []) { } function checkUnusedKeys() { - const enPath = path.join(messagesDir, 'en.json'); - const enObj = JSON.parse(fs.readFileSync(enPath, 'utf8')); + const enPath = path.join(messagesDir, "en.json"); + const enObj = JSON.parse(fs.readFileSync(enPath, "utf8")); const allKeys = getAllKeys(enObj); const files = getAllFiles(srcDir); - const fileContents = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n'); + const fileContents = files.map((f) => fs.readFileSync(f, "utf8")).join("\n"); const unusedKeys = []; for (const fullKey of allKeys) { - const parts = fullKey.split('.'); + const parts = fullKey.split("."); const key = parts[parts.length - 1]; - const namespace = parts.length > 1 ? parts[0] : ''; // Check if the key appears in the source code // It could be t('key') or t("key") or next-intl dynamic keys @@ -61,17 +60,17 @@ function checkUnusedKeys() { } // Filter out known dynamic keys to avoid false positives - const knownDynamicPrefixes = ['step_']; + const knownDynamicPrefixes = ["step_"]; const filteredUnused = unusedKeys.filter((k) => { - const key = k.split('.').pop(); + const key = k.split(".").pop(); return !knownDynamicPrefixes.some((prefix) => key.startsWith(prefix)); }); if (filteredUnused.length > 0) { - console.warn('⚠️ Potentially unused translation keys found:'); + console.warn("⚠️ Potentially unused translation keys found:"); filteredUnused.forEach((k) => console.warn(` - ${k}`)); } else { - console.log('✅ No unused translation keys detected.'); + console.log("✅ No unused translation keys detected."); } } diff --git a/scripts/find-unused-i18n-keys.js b/scripts/find-unused-i18n-keys.js index a1b6c6d0..3c96bacf 100644 --- a/scripts/find-unused-i18n-keys.js +++ b/scripts/find-unused-i18n-keys.js @@ -1,5 +1,5 @@ -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); function getFiles(dir, fileList = []) { const files = fs.readdirSync(dir); @@ -14,10 +14,10 @@ function getFiles(dir, fileList = []) { return fileList; } -function flattenKeys(obj, prefix = '') { +function flattenKeys(obj, prefix = "") { return Object.keys(obj).reduce((acc, k) => { - const pre = prefix.length ? prefix + '.' : ''; - if (typeof obj[k] === 'object' && obj[k] !== null) { + const pre = prefix.length ? prefix + "." : ""; + if (typeof obj[k] === "object" && obj[k] !== null) { Object.assign(acc, flattenKeys(obj[k], pre + k)); } else { acc[pre + k] = obj[k]; @@ -27,36 +27,36 @@ function flattenKeys(obj, prefix = '') { } function findUnusedKeys() { - const messagesPath = path.join(__dirname, '../messages/en.json'); - const srcPath = path.join(__dirname, '../src'); + const messagesPath = path.join(__dirname, "../messages/en.json"); + const srcPath = path.join(__dirname, "../src"); if (!fs.existsSync(messagesPath)) { - console.error('en.json not found at', messagesPath); + console.error("en.json not found at", messagesPath); process.exit(1); } - const enJson = JSON.parse(fs.readFileSync(messagesPath, 'utf8')); + const enJson = JSON.parse(fs.readFileSync(messagesPath, "utf8")); const flatKeys = flattenKeys(enJson); const keys = Object.keys(flatKeys); - + const files = getFiles(srcPath); - const fileContents = files.map(f => fs.readFileSync(f, 'utf8')); + const fileContents = files.map((f) => fs.readFileSync(f, "utf8")); const unusedKeys = []; for (const key of keys) { - const parts = key.split('.'); + const parts = key.split("."); const leaf = parts[parts.length - 1]; - + // Check if the leaf key or the full key is present in any file. - let isUsed = fileContents.some(content => content.includes(leaf) || content.includes(key)); + let isUsed = fileContents.some((content) => content.includes(leaf) || content.includes(key)); // Heuristic for dynamic keys (like step_connect_title or level_Bronze) // If the exact leaf is not found, check if its underscore-separated parts are all present in a single file - if (!isUsed && leaf.includes('_')) { - const leafParts = leaf.split('_'); - isUsed = fileContents.some(content => { - return leafParts.every(p => content.includes(p)); + if (!isUsed && leaf.includes("_")) { + const leafParts = leaf.split("_"); + isUsed = fileContents.some((content) => { + return leafParts.every((p) => content.includes(p)); }); } @@ -67,11 +67,13 @@ function findUnusedKeys() { if (unusedKeys.length > 0) { console.log(`Found ${unusedKeys.length} potentially unused i18n keys:\n`); - unusedKeys.forEach(k => console.log(`- ${k}`)); - console.log('\nNote: Some dynamic keys might be incorrectly flagged if they are constructed in complex ways.'); + unusedKeys.forEach((k) => console.log(`- ${k}`)); + console.log( + "\nNote: Some dynamic keys might be incorrectly flagged if they are constructed in complex ways.", + ); // Don't exit with error code so it doesn't fail CI if wired later } else { - console.log('No unused i18n keys found! 🎉'); + console.log("No unused i18n keys found! 🎉"); } } diff --git a/src/__tests__/hooks/usePlatformFee.test.tsx b/src/__tests__/hooks/usePlatformFee.test.tsx index 0fd932de..f5f31141 100644 --- a/src/__tests__/hooks/usePlatformFee.test.tsx +++ b/src/__tests__/hooks/usePlatformFee.test.tsx @@ -1,7 +1,11 @@ import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { usePlatformFee, DEFAULT_PLATFORM_FEE_BPS, PLATFORM_FEE_QUERY_KEY } from "@/hooks/usePlatformFee"; +import { + usePlatformFee, + DEFAULT_PLATFORM_FEE_BPS, + PLATFORM_FEE_QUERY_KEY, +} from "@/hooks/usePlatformFee"; jest.mock("@/lib/contractClient", () => ({ getPlatformFee: jest.fn(), diff --git a/src/components/CampaignMap.tsx b/src/components/CampaignMap.tsx index 3d6cc5bc..3e959ea3 100644 --- a/src/components/CampaignMap.tsx +++ b/src/components/CampaignMap.tsx @@ -83,9 +83,7 @@ export default function CampaignMap({ campaigns }: CampaignMapProps) {

{t("emptyTitle")}

-

- {t("emptyBody")} -

+

{t("emptyBody")}

); } diff --git a/src/components/CancelDonationBanner.tsx b/src/components/CancelDonationBanner.tsx index 900e9823..13b6d13c 100644 --- a/src/components/CancelDonationBanner.tsx +++ b/src/components/CancelDonationBanner.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import React, { useState, useEffect } from 'react'; -import { PendingDonation } from '../hooks/useDonationGracePeriod'; +import React, { useState, useEffect } from "react"; +import { PendingDonation } from "../hooks/useDonationGracePeriod"; interface CancelDonationBannerProps { pendingDonations: PendingDonation[]; @@ -31,10 +31,7 @@ export function CancelDonationBanner({ className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full px-4" > {pendingDonations.map((donation) => { - const remainingSeconds = Math.max( - 0, - Math.ceil((donation.expiresAt - Date.now()) / 1000) - ); + const remainingSeconds = Math.max(0, Math.ceil((donation.expiresAt - Date.now()) / 1000)); return (
-

- {t("emptyMessage")} -

+

{t("emptyMessage")}

) : (