diff --git a/README.md b/README.md index f8ad357c..99db6e65 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,73 @@ PropChain-FrontEnd/ --- +## πŸ“ Logging + +All application code MUST log through the **canonical logger** at +`@/utils/logger`: + +```ts +import { logger } from '@/utils/logger'; + +logger.debug('…'); +logger.info('…'); +logger.warn('…'); +logger.error('…', errorObject); +``` + +### Why a single import path? + +- One canonical implementation owns redaction, correlation IDs, JSON output, + environment-aware levels, and singleton config (`configureLogger`). +- The legacy `@/utils/structuredLogger` module is kept as a thin + backwards-compat wrapper (re-exports + a domain-specific `StructuredLogger` + class with batching/remote delivery). It is marked **`@deprecated`** and an + ESLint `no-restricted-imports` rule blocks new imports outside the wrapper + itself. New code MUST NOT import from it. +- Direct `console.*` calls are blocked by ESLint for everything except + `src/utils/earlyErrorSuppression.ts`, which intentionally operates on the + raw global `console` because it runs **before** `logger` is initialised to + silence noisy browser-extension errors. + +### Backwards compatibility + +`@/utils/structuredLogger` re-exports `logger`, `createLogger`, `LogLevel`, +etc. from the canonical module so existing call sites continue to work +without changes. The wrapper itself (`StructuredLogger`, `logNetworkRequest`, +`logWeb3Activity`, `logTransaction`) is preserved for callers that rely on +its batching/remote-send semantics. + +--- + +## πŸ“Š Build stats plugin + +`next.config.ts` includes a small `BuildStatsPlugin` that writes a JSON +snapshot of webpack output to `.next/build-stats.json` for local inspection. + +To keep production builds lean and quiet, the plugin is gated by **two** +conditions: + +| Condition | Value | +|-------------------------|------------------------------------------------| +| `process.env.ANALYZE` | MUST be set to `'true'` | +| `process.env.NODE_ENV` | MUST NOT be `production` | +| Server-side build? | Plugin is client-only β€” skipped on `isServer` | + +In other words: + +```bash +# Quiet (default for `next build` in production) +npm run build + +# Opt-in to the JSON build-stats snapshot β€” local dev only +ANALYZE=true npm run dev # or: ANALYZE=true next build +``` + +Production CI MUST NOT pass `ANALYZE=true`; if it does the plugin is still +disabled by the `NODE_ENV === 'production'` guard. + +--- + ## πŸ“„ License This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for complete details. diff --git a/eslint.config.mjs b/eslint.config.mjs index 810d5cc1..d8b12ab1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,5 +24,46 @@ export default [{ "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/consistent-type-imports": "off", "@typescript-eslint/no-unnecessary-type-assertion": "off", + // Enforce the single canonical logger import path. + // structuredLogger is a backwards-compat wrapper; new code MUST import + // from '@/utils/logger' instead. See README Β§ "Logging". + "no-restricted-imports": ["error", { + patterns: [{ + group: [ + "@/utils/structuredLogger", + "./structuredLogger", + "../utils/structuredLogger", + "../../utils/structuredLogger", + ], + message: "Import from '@/utils/logger' instead. '@/utils/structuredLogger' is a thin backwards-compat wrapper and is deprecated.", + }], + }], + }, +}, { + // earlyErrorSuppression.ts runs BEFORE logger.ts is loaded and must + // intercept raw console output. Exempt it from no-console. + files: ["src/utils/earlyErrorSuppression.ts"], + rules: { + "no-console": "off", + }, +}, { + // Apply `no-console` to everything else so future direct console.* calls + // are caught at lint time. + files: ["src/**/*.{ts,tsx}"], + ignores: [ + // earlyErrorSuppression.ts intentionally uses raw console; logger.ts + // and the deprecated structuredLogger.ts wrap it. + "src/utils/earlyErrorSuppression.ts", + "src/utils/logger.ts", + "src/utils/structuredLogger.ts", + // Test files and stories legitimately use console.* for debug output + // and assertions. + "src/**/__tests__/**", + "src/**/*.test.{ts,tsx}", + "src/**/*.stories.{ts,tsx}", + ], + rules: { + // disallow all console.* (no `allow` options provided). + "no-console": "error", }, }, ...storybook.configs["flat/recommended"]]; diff --git a/next.config.ts b/next.config.ts index 17e30ec8..bd276598 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,16 @@ import type { NextConfig } from "next"; const isAnalyzeEnabled = process.env.ANALYZE === "true"; const isDev = process.env.NODE_ENV === "development"; +const isProd = process.env.NODE_ENV === "production"; + +// `BuildStatsPlugin` writes a JSON payload into `.next/` for on-demand +// inspection. It is ONLY meant for local development/debugging β€” production +// builds must never emit it. +// - Gate on the explicit `ANALYZE=true` opt-in flag. +// - Hard-disable on production builds even if `ANALYZE=true` is set +// (e.g. misconfigured CI). +// - Skip on server builds (this plugin is client-side only). +// See README Β§ "Build stats plugin" for details. const cspReportOnly = [ "default-src 'self'", @@ -140,7 +150,7 @@ const nextConfig: NextConfig = { }; } - if (isAnalyzeEnabled && !isServer) { + if (isAnalyzeEnabled && !isServer && !isProd) { class BuildStatsPlugin { apply(compiler: any) { compiler.hooks.done.tap("BuildStatsPlugin", (stats: any) => { diff --git a/src/app/page.tsx b/src/app/page.tsx index a036721a..99149a44 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next"; import { ChainAwareProvider } from "@/providers/ChainAwareProvider"; import { useWalletPersistence } from "@/utils/walletPersistence"; import { setupExtensionErrorHandling } from "@/utils/extensionDetection"; -import { structuredLogger } from "@/utils/structuredLogger"; import { errorMonitoring } from "@/utils/errorMonitoringService"; import { ErrorCategory, ErrorSeverity } from "@/types/errors"; import { logger } from "@/utils/logger"; @@ -36,10 +35,10 @@ function HomeContent() { setupExtensionErrorHandling(); // Initialize structured logging and error monitoring - structuredLogger.info('Application initialized', { + logger.info('Application initialized', { component: 'HomeContent', action: 'initialization', - metadata: { timestamp: new Date().toISOString() }, + timestamp: new Date().toISOString(), }); // Set up global error handling diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index efd0e3d5..f2d31e55 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -9,7 +9,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Globe } from 'lucide-react'; -import { structuredLogger } from '@/utils/structuredLogger'; +import { logger } from '@/utils/logger'; const languages = [ { code: 'en', name: 'English', flag: 'πŸ‡ΊπŸ‡Έ' }, @@ -40,7 +40,9 @@ export function LanguageSwitcher() { html.dir = 'ltr'; } - structuredLogger.component('LanguageSwitcher', 'changeLanguage', { + logger.info('Component: LanguageSwitcher - changeLanguage', { + component: 'LanguageSwitcher', + action: 'changeLanguage', metadata: { languageCode, rtl: ['ar', 'he'].includes(languageCode) }, }); }; diff --git a/src/components/TransactionDetailsModal.tsx b/src/components/TransactionDetailsModal.tsx index fb13a277..42c3be1c 100644 --- a/src/components/TransactionDetailsModal.tsx +++ b/src/components/TransactionDetailsModal.tsx @@ -18,6 +18,7 @@ import { format } from 'date-fns'; import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; import { toast } from 'sonner'; +import { logger } from '@/utils/logger'; interface TransactionDetailsModalProps { transaction: Transaction | null; @@ -105,7 +106,7 @@ export const TransactionDetailsModal: React.FC = ( doc.save(`transaction-receipt-${transaction.hash.slice(0, 8)}.pdf`); toast.success('Transaction receipt downloaded successfully'); } catch (error) { - console.error('Error downloading PDF:', error); + logger.error('Error downloading PDF', error); toast.error('Failed to download transaction receipt'); } }; diff --git a/src/components/ViewToggle.tsx b/src/components/ViewToggle.tsx index eca5eeff..747eb6a9 100644 --- a/src/components/ViewToggle.tsx +++ b/src/components/ViewToggle.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { logger } from '@/utils/logger'; /** * UI-only view mode for listing screens. @@ -31,8 +32,7 @@ export function useViewMode() { if (isValidViewMode(stored)) return stored; } catch (err) { // Swallow storage errors β€” fallback to default - // eslint-disable-next-line no-console - console.warn("useViewMode: localStorage unavailable, falling back to default view mode", err); + logger.warn("useViewMode: localStorage unavailable, falling back to default view mode", err); } return "grid"; @@ -41,8 +41,7 @@ export function useViewMode() { // Wrap setter to validate input before persisting const setMode = (v: ViewMode) => { if (!isValidViewMode(v)) { - // eslint-disable-next-line no-console - console.warn("useViewMode.setMode called with invalid mode:", v); + logger.warn("useViewMode.setMode called with invalid mode:", v); return; } setModeRaw(v); @@ -53,8 +52,7 @@ export function useViewMode() { localStorage.setItem(STORAGE_KEY, mode); } catch (err) { // Storage might be disabled; log and continue without throwing - // eslint-disable-next-line no-console - console.warn("useViewMode: failed to persist mode to localStorage", err); + logger.warn("useViewMode: failed to persist mode to localStorage", err); } }, [mode]); @@ -80,11 +78,10 @@ export function ViewToggle({ mode, onChange }: ViewToggleProps) { if (!isValidViewMode(v)) return; try { if (typeof onChange === "function") onChange(v); - else console.warn("ViewToggle: onChange is not a function", onChange); + else logger.warn("ViewToggle: onChange is not a function", onChange); } catch (err) { // Avoid bubbling UI errors β€” log instead - // eslint-disable-next-line no-console - console.error("ViewToggle: onChange handler threw an error", err); + logger.error("ViewToggle: onChange handler threw an error", err); } }; diff --git a/src/components/error/GlobalErrorBoundary.tsx b/src/components/error/GlobalErrorBoundary.tsx index 53933f9a..afa601fc 100644 --- a/src/components/error/GlobalErrorBoundary.tsx +++ b/src/components/error/GlobalErrorBoundary.tsx @@ -3,7 +3,7 @@ import React, { Component, type ReactNode, type ErrorInfo } from 'react'; import { ErrorCategory, ErrorSeverity, type AppError } from '@/types/errors'; import { ErrorFactory } from '@/utils/errorFactory'; -import { structuredLogger } from '@/utils/structuredLogger'; +import { logger } from '@/utils/logger'; import { errorMonitoring } from '@/utils/errorMonitoringService'; interface Props { @@ -58,13 +58,11 @@ export class GlobalErrorBoundary extends Component { this.setState({ error: appError }); // Log structured error - structuredLogger.error('Error caught by global boundary', appError, { + logger.errorWithStack('Error caught by global boundary', appError, { component: 'GlobalErrorBoundary', action: 'error_boundary_catch', - metadata: { - errorId: appError.id, - componentStack: errorInfo.componentStack, - }, + errorId: appError.id, + componentStack: errorInfo.componentStack, }); // Monitor error @@ -78,14 +76,12 @@ export class GlobalErrorBoundary extends Component { handleRetry = async (): Promise => { if (this.state.retryCount >= this.maxRetries) { - structuredLogger.warn('Max retry attempts reached', { + logger.warn('Max retry attempts reached', { component: 'GlobalErrorBoundary', action: 'retry_limit_reached', - metadata: { - errorId: this.state.errorId, - retryCount: this.state.retryCount, - maxRetries: this.maxRetries, - }, + errorId: this.state.errorId, + retryCount: this.state.retryCount, + maxRetries: this.maxRetries, }); return; } @@ -102,13 +98,11 @@ export class GlobalErrorBoundary extends Component { const recovered = await errorMonitoring.attemptRecovery(this.state.error); if (recovered) { - structuredLogger.info('Error recovery successful', { + logger.info('Error recovery successful', { component: 'GlobalErrorBoundary', action: 'recovery_success', - metadata: { - errorId: this.state.errorId, - retryCount: this.state.retryCount + 1, - }, + errorId: this.state.errorId, + retryCount: this.state.retryCount + 1, }); } } @@ -121,13 +115,11 @@ export class GlobalErrorBoundary extends Component { isRecovering: false, })); } catch (recoveryError) { - structuredLogger.error('Error recovery failed', recoveryError as Error, { + logger.errorWithStack('Error recovery failed', recoveryError as Error, { component: 'GlobalErrorBoundary', action: 'recovery_failed', - metadata: { - errorId: this.state.errorId, - retryCount: this.state.retryCount + 1, - }, + errorId: this.state.errorId, + retryCount: this.state.retryCount + 1, }); this.setState({ @@ -146,7 +138,7 @@ export class GlobalErrorBoundary extends Component { isRecovering: false, }); - structuredLogger.info('Error boundary reset', { + logger.info('Error boundary reset', { component: 'GlobalErrorBoundary', action: 'boundary_reset', }); diff --git a/src/store/referral/index.ts b/src/store/referral/index.ts new file mode 100644 index 00000000..cca3bfaa --- /dev/null +++ b/src/store/referral/index.ts @@ -0,0 +1,30 @@ +'use client'; + +/** + * @/store/referral β€” barrel re-export for the focused slice refactor of the + * monothilic referralStore. + * + * Consumers MAY import directly from this barrel (`@/store/referral`) or + * continue to import from `@/store/referralStore` (which re-exports the same + * hooks for backwards compatibility). + * + * Migration plan: + * 1. New code SHOULD import from `@/store/referral/`. + * 2. Existing consumer imports of `@/store/referralStore` keep working + * unchanged. + * 3. Once all consumers are migrated, the `@/store/referralStore` + * re-export layer can be removed. + */ + +export { useReferralLinks } from './referralLinks'; +export { useReferralStats, useRecentRewards } from './referralStats'; +export { useLeaderboard, useLeaderboardCache } from './leaderboard'; +export { + useReferralNotification, + useReferralLoading, + useReferralError, +} from './referralNotifications'; +export { + useCurrentReferralCampaign, + useReferralTermsAccepted, +} from './misc'; diff --git a/src/store/referral/leaderboard.ts b/src/store/referral/leaderboard.ts new file mode 100644 index 00000000..43de1455 --- /dev/null +++ b/src/store/referral/leaderboard.ts @@ -0,0 +1,27 @@ +'use client'; + +/** + * leaderboard β€” focused slice exposing the cached referral leaderboard. + * + * Part of the referralStore refactor that splits the monolith into focused + * selector hook slices. The exported name `useLeaderboard` is the new + * canonical hook; `useLeaderboardCache` is kept as an alias for backwards + * compatibility with existing call sites and the public re-export from + * `@/store/referralStore`. + */ + +import { useReferralStore } from './store'; +import type { LeaderboardEntry } from '@/types/referral'; + +/** + * Reactive selector for `state.leaderboardCache`. + * Canonical name introduced by the store-slice refactor. + */ +export const useLeaderboard = (): LeaderboardEntry[] => + useReferralStore((state) => state.leaderboardCache); + +/** + * Alias kept for backwards compatibility β€” existing call sites and tests + * import `useLeaderboardCache` from `@/store/referralStore`. + */ +export const useLeaderboardCache = useLeaderboard; diff --git a/src/store/referral/misc.ts b/src/store/referral/misc.ts new file mode 100644 index 00000000..cb47c2ce --- /dev/null +++ b/src/store/referral/misc.ts @@ -0,0 +1,26 @@ +'use client'; + +/** + * misc β€” remaining ReferralStore selectors that don't naturally fit into + * the four primary slices (referralLinks, referralStats, leaderboard, + * referralNotifications). Currently exposes the active campaign and the + * terms-accepted flag. + * + * Backwards-compatible: both hooks are still re-exported from + * `@/store/referralStore`. + */ + +import { useReferralStore } from './store'; +import type { ReferralCampaign } from '@/types/referral'; + +/** + * Reactive selector for `state.currentCampaign`. + */ +export const useCurrentReferralCampaign = (): ReferralCampaign | null => + useReferralStore((state) => state.currentCampaign); + +/** + * Reactive selector for `state.termsAccepted` (persisted across reloads). + */ +export const useReferralTermsAccepted = (): boolean => + useReferralStore((state) => state.termsAccepted); diff --git a/src/store/referral/referralLinks.ts b/src/store/referral/referralLinks.ts new file mode 100644 index 00000000..49bae38a --- /dev/null +++ b/src/store/referral/referralLinks.ts @@ -0,0 +1,19 @@ +'use client'; + +/** + * referralLinks β€” focused slice exposing the user's referral-link collection. + * + * Part of the referralStore refactor that splits the monolith into focused + * selector hook slices. Backwards-compatible: `useReferralLinks` is still + * re-exported from `@/store/referralStore` for callers that prefer the + * single-file import path. + */ + +import { useReferralStore } from './store'; +import type { ReferralLink } from '@/types/referral'; + +/** + * Reactive selector for `state.currentReferralLinks`. + */ +export const useReferralLinks = (): ReferralLink[] => + useReferralStore((state) => state.currentReferralLinks); diff --git a/src/store/referral/referralNotifications.ts b/src/store/referral/referralNotifications.ts new file mode 100644 index 00000000..507ab952 --- /dev/null +++ b/src/store/referral/referralNotifications.ts @@ -0,0 +1,48 @@ +'use client'; + +/** + * referralNotifications β€” focused slice exposing the surfacing layer for the + * referral feature: notifications, transient loading flags, and the latest + * top-level error string. + * + * Part of the referralStore refactor that splits the monolith into focused + * selector hook slices. Backwards-compatible: all hooks are still + * re-exported from `@/store/referralStore`. + */ + +import { useReferralStore } from './store'; + +type NotificationType = 'success' | 'error' | 'info' | 'warning' | null; + +/** + * Reactive selector for the transient toast-style notification (message + type). + */ +export const useReferralNotification = (): { + message: string | null; + type: NotificationType; +} => + useReferralStore((state) => ({ + message: state.notificationMessage, + type: state.notificationType, + })); + +/** + * Reactive selector bundling the loading flags for dashboard, leaderboard, + * and reward-claim flows. + */ +export const useReferralLoading = (): { + dashboardLoading: boolean; + leaderboardLoading: boolean; + isClaimingRewards: boolean; +} => + useReferralStore((state) => ({ + dashboardLoading: state.dashboardLoading, + leaderboardLoading: state.leaderboardLoading, + isClaimingRewards: state.isClaimingRewards, + })); + +/** + * Reactive selector for the top-level referral error string. + */ +export const useReferralError = (): string | null => + useReferralStore((state) => state.error); diff --git a/src/store/referral/referralStats.ts b/src/store/referral/referralStats.ts new file mode 100644 index 00000000..a177833b --- /dev/null +++ b/src/store/referral/referralStats.ts @@ -0,0 +1,26 @@ +'use client'; + +/** + * referralStats β€” focused slice exposing aggregate stats and the recent-rewards + * feed for the current user. + * + * Part of the referralStore refactor that splits the monolith into focused + * selector hook slices. Backwards-compatible: both hooks are still + * re-exported from `@/store/referralStore`. + */ + +import { useReferralStore } from './store'; +import type { ReferralReward, ReferralStats } from '@/types/referral'; + +/** + * Reactive selector for `state.currentStats`. + */ +export const useReferralStats = (): ReferralStats | null => + useReferralStore((state) => state.currentStats); + +/** + * Reactive selector for `state.recentRewards` (latest 10, capped by the + * `addReward` action). + */ +export const useRecentRewards = (): ReferralReward[] => + useReferralStore((state) => state.recentRewards); diff --git a/src/store/referral/store.ts b/src/store/referral/store.ts new file mode 100644 index 00000000..ef117412 --- /dev/null +++ b/src/store/referral/store.ts @@ -0,0 +1,360 @@ +'use client'; + +/** + * Referral store β€” Zustand state management source-of-truth. + * + * Owns the store actions (create + persist) and exposes the typed + * `useReferralStore` hook. Selector hooks that read from this store live + * under `@/store/referral/` for focused subscription, and are + * re-exported from the legacy `@/store/referralStore` path for backwards + * compatibility. + * + * NOTE on module layering: + * - Slice files (`./referralLinks.ts`, `./referralStats.ts`, …) import + * `useReferralStore` from THIS file (the source-of-truth) β€” never from + * the legacy `@/store/referralStore` barrel. This breaks a circular + * import path between the barrel and its slice files. + * - `src/store/referralStore.ts` re-exports `useReferralStore` from this + * file plus the slice hooks, so existing consumers of the legacy path + * continue to compile unchanged. + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { + ReferralLink, + ReferralStats, + ReferralReward, + ReferralDashboardData, + WalletAddress, + ReferralCode, + ReferralCampaign, + LeaderboardEntry, +} from '@/types/referral'; + +/** + * Program-level settings blob returned by the referral backend. Shape is + * currently loose; narrow on read until it stabilises into a typed + * `ReferralProgramSettings` interface. + */ +export type ReferralProgramSettings = Record | null; + +/** + * Referral store state interface + */ +export interface ReferralStoreState { + // State + isLoading: boolean; + error: string | null; + referrerId: WalletAddress | null; + + // Referral data + currentReferralLinks: ReferralLink[]; + currentStats: ReferralStats | null; + currentCampaign: ReferralCampaign | null; + recentRewards: ReferralReward[]; + leaderboardCache: LeaderboardEntry[]; + selectedReferralCode: ReferralCode | null; + + // UI state + dashboardLoading: boolean; + leaderboardLoading: boolean; + isClaimingRewards: boolean; + showClaimModal: boolean; + notificationMessage: string | null; + notificationType: 'success' | 'error' | 'info' | 'warning' | null; + + // Metadata + lastUpdated: number | null; + lastLeaderboardUpdate: number | null; + termsAccepted: boolean; + programSettings: ReferralProgramSettings; + + // Actions + initialize: (address: WalletAddress) => Promise; + setReferrerId: (address: WalletAddress | null) => void; + + // Referral link actions + addReferralLink: (link: ReferralLink) => void; + removeReferralLink: (code: ReferralCode) => void; + updateReferralLinks: (links: ReferralLink[]) => void; + setSelectedReferralCode: (code: ReferralCode | null) => void; + + // Stats actions + updateStats: (stats: ReferralStats) => void; + updateRecentRewards: (rewards: ReferralReward[]) => void; + addReward: (reward: ReferralReward) => void; + + // Campaign actions + setCurrentCampaign: (campaign: ReferralCampaign | null) => void; + updateCampaign: (campaign: ReferralCampaign) => void; + + // Leaderboard actions + updateLeaderboard: (entries: LeaderboardEntry[]) => void; + + // UI actions + setDashboardLoading: (loading: boolean) => void; + setLeaderboardLoading: (loading: boolean) => void; + setIsClaimingRewards: (claiming: boolean) => void; + setShowClaimModal: (show: boolean) => void; + setNotification: (message: string | null, type: 'success' | 'error' | 'info' | 'warning' | null) => void; + clearNotification: () => void; + + // Settings actions + updateTermsAccepted: (accepted: boolean) => void; + setProgramSettings: (settings: ReferralProgramSettings) => void; + + // Error actions + setError: (error: string | null) => void; + clearError: () => void; + + // Data refresh + setLastUpdated: (timestamp: number) => void; + setLastLeaderboardUpdate: (timestamp: number) => void; + + // Dashboard data actions + setDashboardData: (data: ReferralDashboardData) => void; + + // Reset + reset: () => void; +} + +/** + * Initial state β€” typed explicitly so empty arrays don't widen to `never[]`. + */ +type InitialState = Pick< + ReferralStoreState, + | 'isLoading' + | 'error' + | 'referrerId' + | 'currentReferralLinks' + | 'currentStats' + | 'currentCampaign' + | 'recentRewards' + | 'leaderboardCache' + | 'selectedReferralCode' + | 'dashboardLoading' + | 'leaderboardLoading' + | 'isClaimingRewards' + | 'showClaimModal' + | 'notificationMessage' + | 'notificationType' + | 'lastUpdated' + | 'lastLeaderboardUpdate' + | 'termsAccepted' + | 'programSettings' +>; + +const initialState: InitialState = { + isLoading: false, + error: null, + referrerId: null, + currentReferralLinks: [], + currentStats: null, + currentCampaign: null, + recentRewards: [], + leaderboardCache: [], + selectedReferralCode: null, + dashboardLoading: false, + leaderboardLoading: false, + isClaimingRewards: false, + showClaimModal: false, + notificationMessage: null, + notificationType: null, + lastUpdated: null, + lastLeaderboardUpdate: null, + termsAccepted: false, + programSettings: null, +}; + +/** + * Source-of-truth Zustand store with persistence. + * + * `partialize` is `(state) => ({...})` and returns a plain object whose + * `programSettings` is typed as `ReferralProgramSettings` (= `Record | null`); the persist middleware passes it through JSON.stringify + * unchanged. + */ +export const useReferralStore = create()( + persist( + (set) => ({ + ...initialState, + + initialize: async (address: WalletAddress) => { + set({ isLoading: true, error: null }); + try { + set({ + referrerId: address, + isLoading: false, + lastUpdated: Date.now(), + }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to initialize referral store', + isLoading: false, + }); + } + }, + + setReferrerId: (address) => { + set({ referrerId: address }); + }, + + // Referral link actions + addReferralLink: (link) => { + set((state) => ({ + currentReferralLinks: [...state.currentReferralLinks, link], + })); + }, + + removeReferralLink: (code) => { + set((state) => ({ + currentReferralLinks: state.currentReferralLinks.filter( + (link) => link.code !== code + ), + })); + }, + + updateReferralLinks: (links) => { + set({ currentReferralLinks: links }); + }, + + setSelectedReferralCode: (code) => { + set({ selectedReferralCode: code }); + }, + + // Stats actions + updateStats: (stats) => { + set({ + currentStats: stats, + lastUpdated: Date.now(), + }); + }, + + updateRecentRewards: (rewards) => { + set({ recentRewards: rewards }); + }, + + addReward: (reward) => { + set((state) => ({ + recentRewards: [reward, ...state.recentRewards].slice(0, 10), // Keep latest 10 + })); + }, + + // Campaign actions + setCurrentCampaign: (campaign) => { + set({ currentCampaign: campaign }); + }, + + updateCampaign: (campaign) => { + set({ currentCampaign: campaign }); + }, + + // Leaderboard actions + updateLeaderboard: (entries) => { + set({ + leaderboardCache: entries, + lastLeaderboardUpdate: Date.now(), + }); + }, + + // UI actions + setDashboardLoading: (loading) => { + set({ dashboardLoading: loading }); + }, + + setLeaderboardLoading: (loading) => { + set({ leaderboardLoading: loading }); + }, + + setIsClaimingRewards: (claiming) => { + set({ isClaimingRewards: claiming }); + }, + + setShowClaimModal: (show) => { + set({ showClaimModal: show }); + }, + + setNotification: (message, type) => { + set({ + notificationMessage: message, + notificationType: type, + }); + // Auto-clear after 5 seconds + if (message) { + setTimeout(() => { + set({ + notificationMessage: null, + notificationType: null, + }); + }, 5000); + } + }, + + clearNotification: () => { + set({ + notificationMessage: null, + notificationType: null, + }); + }, + + // Settings actions + updateTermsAccepted: (accepted) => { + set({ termsAccepted: accepted }); + }, + + setProgramSettings: (settings) => { + set({ programSettings: settings }); + }, + + // Error actions + setError: (error) => { + set({ error }); + }, + + clearError: () => { + set({ error: null }); + }, + + // Data refresh + setLastUpdated: (timestamp) => { + set({ lastUpdated: timestamp }); + }, + + setLastLeaderboardUpdate: (timestamp) => { + set({ lastLeaderboardUpdate: timestamp }); + }, + + // Dashboard data actions + setDashboardData: (data) => { + set({ + currentStats: data.stats, + currentCampaign: data.currentCampaign || null, + currentReferralLinks: data.referralLinks, + recentRewards: data.recentRewards, + leaderboardCache: data.leaderboardPosition + ? [data.leaderboardPosition] + : [], + lastUpdated: Date.now(), + }); + }, + + // Reset + reset: () => { + set({ ...initialState }); + }, + }), + { + name: 'propchain-referral', // Name of the storage + version: 1, + // Only persist specific fields to avoid storage bloat + partialize: (state) => ({ + termsAccepted: state.termsAccepted, + lastUpdated: state.lastUpdated, + lastLeaderboardUpdate: state.lastLeaderboardUpdate, + programSettings: state.programSettings, + leaderboardCache: state.leaderboardCache, + }), + } + ) +); diff --git a/src/store/referralStore.ts b/src/store/referralStore.ts index 694a3897..f046f83f 100644 --- a/src/store/referralStore.ts +++ b/src/store/referralStore.ts @@ -1,347 +1,47 @@ -/** - * Referral Store - Zustand state management - * Manages referral system state with persistence and offline support - */ - -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; -import { - ReferralLink, - ReferralStats, - ReferralReward, - ReferralDashboardData, - WalletAddress, - ReferralCode, - ReferralCampaign, - LeaderboardEntry, - ReferralTier, -} from '@/types/referral'; - -/** - * Referral store state interface - */ -export interface ReferralStoreState { - // State - isLoading: boolean; - error: string | null; - referrerId: WalletAddress | null; - - // Referral data - currentReferralLinks: ReferralLink[]; - currentStats: ReferralStats | null; - currentCampaign: ReferralCampaign | null; - recentRewards: ReferralReward[]; - leaderboardCache: LeaderboardEntry[]; - selectedReferralCode: ReferralCode | null; - - // UI state - dashboardLoading: boolean; - leaderboardLoading: boolean; - isClaimingRewards: boolean; - showClaimModal: boolean; - notificationMessage: string | null; - notificationType: 'success' | 'error' | 'info' | 'warning' | null; - - // Metadata - lastUpdated: number | null; - lastLeaderboardUpdate: number | null; - termsAccepted: boolean; - programSettings: any | null; - - // Actions - initialize: (address: WalletAddress) => Promise; - setReferrerId: (address: WalletAddress | null) => void; - - // Referral link actions - addReferralLink: (link: ReferralLink) => void; - removeReferralLink: (code: ReferralCode) => void; - updateReferralLinks: (links: ReferralLink[]) => void; - setSelectedReferralCode: (code: ReferralCode | null) => void; - - // Stats actions - updateStats: (stats: ReferralStats) => void; - updateRecentRewards: (rewards: ReferralReward[]) => void; - addReward: (reward: ReferralReward) => void; - - // Campaign actions - setCurrentCampaign: (campaign: ReferralCampaign | null) => void; - updateCampaign: (campaign: ReferralCampaign) => void; - - // Leaderboard actions - updateLeaderboard: (entries: LeaderboardEntry[]) => void; - - // UI actions - setDashboardLoading: (loading: boolean) => void; - setLeaderboardLoading: (loading: boolean) => void; - setIsClaimingRewards: (claiming: boolean) => void; - setShowClaimModal: (show: boolean) => void; - setNotification: (message: string | null, type: 'success' | 'error' | 'info' | 'warning' | null) => void; - clearNotification: () => void; - - // Settings actions - updateTermsAccepted: (accepted: boolean) => void; - setProgramSettings: (settings: any) => void; - - // Error actions - setError: (error: string | null) => void; - clearError: () => void; - - // Data refresh - setLastUpdated: (timestamp: number) => void; - setLastLeaderboardUpdate: (timestamp: number) => void; - - // Dashboard data actions - setDashboardData: (data: ReferralDashboardData) => void; - - // Reset - reset: () => void; -} - -/** - * Initial state - */ -const initialState = { - isLoading: false, - error: null, - referrerId: null, - currentReferralLinks: [], - currentStats: null, - currentCampaign: null, - recentRewards: [], - leaderboardCache: [], - selectedReferralCode: null, - dashboardLoading: false, - leaderboardLoading: false, - isClaimingRewards: false, - showClaimModal: false, - notificationMessage: null, - notificationType: null, - lastUpdated: null, - lastLeaderboardUpdate: null, - termsAccepted: false, - programSettings: null, -}; - -/** - * Create the referral store with persistence - */ -export const useReferralStore = create()( - persist( - (set, get) => ({ - ...initialState, - - initialize: async (address: WalletAddress) => { - set({ isLoading: true, error: null }); - try { - set({ - referrerId: address, - isLoading: false, - lastUpdated: Date.now(), - }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to initialize referral store', - isLoading: false, - }); - } - }, - - setReferrerId: (address) => { - set({ referrerId: address }); - }, - - // Referral link actions - addReferralLink: (link) => { - set((state) => ({ - currentReferralLinks: [...state.currentReferralLinks, link], - })); - }, - - removeReferralLink: (code) => { - set((state) => ({ - currentReferralLinks: state.currentReferralLinks.filter( - (link) => link.code !== code - ), - })); - }, - - updateReferralLinks: (links) => { - set({ currentReferralLinks: links }); - }, - - setSelectedReferralCode: (code) => { - set({ selectedReferralCode: code }); - }, - - // Stats actions - updateStats: (stats) => { - set({ - currentStats: stats, - lastUpdated: Date.now(), - }); - }, - - updateRecentRewards: (rewards) => { - set({ recentRewards: rewards }); - }, - - addReward: (reward) => { - set((state) => ({ - recentRewards: [reward, ...state.recentRewards].slice(0, 10), // Keep latest 10 - })); - }, - - // Campaign actions - setCurrentCampaign: (campaign) => { - set({ currentCampaign: campaign }); - }, - - updateCampaign: (campaign) => { - set({ currentCampaign: campaign }); - }, - - // Leaderboard actions - updateLeaderboard: (entries) => { - set({ - leaderboardCache: entries, - lastLeaderboardUpdate: Date.now(), - }); - }, - - // UI actions - setDashboardLoading: (loading) => { - set({ dashboardLoading: loading }); - }, - - setLeaderboardLoading: (loading) => { - set({ leaderboardLoading: loading }); - }, - - setIsClaimingRewards: (claiming) => { - set({ isClaimingRewards: claiming }); - }, - - setShowClaimModal: (show) => { - set({ showClaimModal: show }); - }, - - setNotification: (message, type) => { - set({ - notificationMessage: message, - notificationType: type, - }); - // Auto-clear after 5 seconds - if (message) { - setTimeout(() => { - set({ - notificationMessage: null, - notificationType: null, - }); - }, 5000); - } - }, - - clearNotification: () => { - set({ - notificationMessage: null, - notificationType: null, - }); - }, - - // Settings actions - updateTermsAccepted: (accepted) => { - set({ termsAccepted: accepted }); - }, - - setProgramSettings: (settings) => { - set({ programSettings: settings }); - }, - - // Error actions - setError: (error) => { - set({ error }); - }, - - clearError: () => { - set({ error: null }); - }, - - // Data refresh - setLastUpdated: (timestamp) => { - set({ lastUpdated: timestamp }); - }, - - setLastLeaderboardUpdate: (timestamp) => { - set({ lastLeaderboardUpdate: timestamp }); - }, - - // Dashboard data actions - setDashboardData: (data) => { - set({ - currentStats: data.stats, - currentCampaign: data.currentCampaign || null, - currentReferralLinks: data.referralLinks, - recentRewards: data.recentRewards, - leaderboardCache: data.leaderboardPosition - ? [data.leaderboardPosition] - : [], - lastUpdated: Date.now(), - }); - }, - - // Reset - reset: () => { - set(initialState); - }, - }), - { - name: 'propchain-referral', // Name of the storage - version: 1, - // Only persist specific fields to avoid storage bloat - partialize: (state) => ({ - termsAccepted: state.termsAccepted, - lastUpdated: state.lastUpdated, - lastLeaderboardUpdate: state.lastLeaderboardUpdate, - programSettings: state.programSettings, - leaderboardCache: state.leaderboardCache, - }), - } - ) -); +'use client'; /** - * Selectors for optimized component subscriptions + * @deprecated Backwards-compatibility re-export layer for the legacy + * `@/store/referralStore` import path. + * + * The source-of-truth Zustand store now lives at + * `@/store/referral/store` and the focused selector hooks live under + * `@/store/referral/`. This file re-exports both so existing + * callers (`useReferralStore`, `useReferralLinks`, `useReferralStats`, + * `useLeaderboardCache`, etc.) keep compiling unchanged. + * + * New code SHOULD import directly from: + * - `@/store/referral/store` β€” for `useReferralStore` and the + * `ReferralStoreState` type. + * - `@/store/referral/` β€” for the focused selector hooks. + * + * Migration plan: + * 1. New code imports from the new barrel (`@/store/referral`) or + * `@/store/referral/`. + * 2. Existing consumers keep working unchanged through this file. + * 3. Once all consumers are migrated, this re-export layer can be + * deleted and the legacy `@/store/referralStore` path removed. + * + * See README Β§ "Referral state". */ -export const useReferralStats = () => - useReferralStore((state) => state.currentStats); - -export const useReferralLinks = () => - useReferralStore((state) => state.currentReferralLinks); - -export const useRecentRewards = () => - useReferralStore((state) => state.recentRewards); - -export const useReferralLoading = () => - useReferralStore((state) => ({ - dashboardLoading: state.dashboardLoading, - leaderboardLoading: state.leaderboardLoading, - isClaimingRewards: state.isClaimingRewards, - })); - -export const useReferralNotification = () => - useReferralStore((state) => ({ - message: state.notificationMessage, - type: state.notificationType, - })); - -export const useReferralError = () => - useReferralStore((state) => state.error); - -export const useCurrentReferralCampaign = () => - useReferralStore((state) => state.currentCampaign); - -export const useLeaderboardCache = () => - useReferralStore((state) => state.leaderboardCache); -export const useReferralTermsAccepted = () => - useReferralStore((state) => state.termsAccepted); +// Source of truth β€” re-exported so legacy imports still work. +export { + useReferralStore, + type ReferralStoreState, + type ReferralProgramSettings, +} from './referral/store'; + +// Slice hooks β€” re-exported for backwards compatibility. +export { useReferralLinks } from './referral/referralLinks'; +export { useReferralStats, useRecentRewards } from './referral/referralStats'; +export { useLeaderboard, useLeaderboardCache } from './referral/leaderboard'; +export { + useReferralNotification, + useReferralLoading, + useReferralError, +} from './referral/referralNotifications'; +export { + useCurrentReferralCampaign, + useReferralTermsAccepted, +} from './referral/misc'; diff --git a/src/utils/earlyErrorSuppression.ts b/src/utils/earlyErrorSuppression.ts index 5e412a11..422cdadf 100644 --- a/src/utils/earlyErrorSuppression.ts +++ b/src/utils/earlyErrorSuppression.ts @@ -1,3 +1,21 @@ +/** + * earlyErrorSuppression β€” runs BEFORE the React/logger boot sequence to + * intercept and silence browser-extension noise (e.g. MetaMask) that would + * otherwise spam the console before `logger.ts` is initialised. + * + * NOTE on direct `console.*` usage: + * This module intentionally calls `console.error` / `console.warn` + * directly. It is loaded as a side-effect-only entry point at the very + * top of the bundle so it can intercept console output that precedes the + * canonical `logger` from `@/utils/logger`. Routing these calls through + * the structured logger would defeat the purpose because the logger + * itself eventually writes to `console.*` β€” and at that point noise from + * third-party wallets has already been emitted. + * + * ESLint exempts this file from the project-wide `no-console` rule via + * `eslint.config.mjs`. + */ + const stringifyArgs = (args: readonly unknown[]): string => args.map((arg) => (typeof arg === 'string' ? arg : String(arg))).join(' '); diff --git a/src/utils/errorHandlingTest.ts b/src/utils/errorHandlingTest.ts index ccf3ca45..84eccb93 100644 --- a/src/utils/errorHandlingTest.ts +++ b/src/utils/errorHandlingTest.ts @@ -1,24 +1,24 @@ 'use client'; // Test file to verify the new error handling implementation -import { structuredLogger } from './structuredLogger'; +import { logger } from './logger'; import { errorMonitoring } from './errorMonitoringService'; import { ErrorCategory, ErrorSeverity } from '@/types/errors'; // Test functions to verify the error handling system export const testErrorHandling = () => { - console.log('Testing new error handling system...'); + logger.info('Testing new error handling system...'); // Test 1: Structured logging - structuredLogger.info('Test info log', { + logger.info('Test info log', { component: 'TestComponent', action: 'test_logging', - metadata: { test: true }, + test: true, }); // Test 2: Error tracking const testError = new Error('Test error for verification'); - structuredLogger.error('Test error logging', testError, { + logger.errorWithStack('Test error logging', testError, { component: 'TestComponent', action: 'test_error_tracking', }); @@ -42,7 +42,7 @@ export const testErrorHandling = () => { // Test 4: Performance monitoring errorMonitoring.monitorPerformance('test-operation', 150); - console.log('Error handling tests completed successfully!'); + logger.info('Error handling tests completed successfully!'); return true; }; @@ -57,10 +57,10 @@ export const testConsoleOverridesRemoved = () => { }; // Test that console works normally - console.log('Console override removal test - this should appear normally'); - console.error('Console error test - this should appear normally'); - console.warn('Console warning test - this should appear normally'); - console.info('Console info test - this should appear normally'); + logger.info('Console override removal test - this should appear normally'); + logger.error('Console error test - this should appear normally'); + logger.warn('Console warning test - this should appear normally'); + logger.info('Console info test - this should appear normally'); return { consoleWorking: true, @@ -74,6 +74,6 @@ export const runAllTests = () => { consoleOverrides: testConsoleOverridesRemoved(), }; - console.log('All tests completed:', results); + logger.info('All tests completed:', results); return results; }; diff --git a/src/utils/errorMonitoringService.ts b/src/utils/errorMonitoringService.ts index 422ed056..56cc5836 100644 --- a/src/utils/errorMonitoringService.ts +++ b/src/utils/errorMonitoringService.ts @@ -1,6 +1,5 @@ 'use client'; -import { structuredLogger } from './structuredLogger'; import { errorReporting } from './errorReporting'; import { logger } from './logger'; import { ErrorCategory, ErrorSeverity, type AppError } from '@/types/errors'; @@ -81,7 +80,7 @@ class ErrorMonitoringService { // Error monitoring monitorError(error: AppError): void { // Log structured error - structuredLogger.trackError(error, { + logger.errorWithStack(error.message, error, { category: error.category, severity: error.severity, component: error.context?.component, @@ -131,8 +130,9 @@ class ErrorMonitoringService { this.activeAlerts.delete(alert.id); } - structuredLogger.info(`Error recovery successful for error`, { - metadata: { errorId: error.id, attempts: attempts + 1 }, + logger.info(`Error recovery successful for error`, { + errorId: error.id, + attempts: attempts + 1, }); return true; @@ -296,15 +296,13 @@ class ErrorMonitoringService { // Check if performance threshold exceeded if (duration > this.config.performanceThreshold) { - structuredLogger.warn(`Performance threshold exceeded: ${operation}`, { - metadata: { - performance: { - operation, - duration, - average: metrics.reduce((sum, d) => sum + d, 0) / metrics.length, - }, - threshold: this.config.performanceThreshold, + logger.warn(`Performance threshold exceeded: ${operation}`, { + performance: { + operation, + duration, + average: metrics.reduce((sum, d) => sum + d, 0) / metrics.length, }, + threshold: this.config.performanceThreshold, }); } } @@ -362,8 +360,10 @@ class ErrorMonitoringService { this.sendFeedbackToServer(fullFeedback); } - structuredLogger.info(`User feedback submitted for error`, { - metadata: { errorId: feedback.errorId, feedback: feedback.feedback, comment: feedback.comment }, + logger.info(`User feedback submitted for error`, { + errorId: feedback.errorId, + feedback: feedback.feedback, + comment: feedback.comment, }); } diff --git a/src/utils/structuredLogger.ts b/src/utils/structuredLogger.ts index 53957c42..72b3d6c5 100644 --- a/src/utils/structuredLogger.ts +++ b/src/utils/structuredLogger.ts @@ -1,7 +1,20 @@ 'use client'; /** - * structuredLogger β€” thin domain-specific layer on top of logger. + * structuredLogger β€” **deprecated** thin domain-specific layer on top of logger. + * + * @deprecated This module is kept as a backwards-compatibility wrapper only. + * The canonical logger lives in `@/utils/logger` β€” new code MUST import from + * there directly (see README Β§ "Logging"). Only `structuredLogger`, + * `createStructuredLogger`, `createPerformanceTracker`, `logNetworkRequest`, + * `logWeb3Activity`, and `logTransaction` plus the `StructuredLogger`, + * `StructuredLogEntry`, and `StructuredLoggerConfig` types remain unique to + * this module. + * + * All other re-exports (`logger`, `createLogger`, `LogLevel`, …) are kept so + * existing call sites compile, but they will be removed in a future release. + * See ESLint rule `no-restricted-imports` in `eslint.config.mjs` for the + * enforced single import path. * * All core logging (levels, JSON output, redaction, correlation IDs) lives in * logger.ts. This module adds domain helpers (performance, network, web3,