diff --git a/src/components/MortgageCalculator.tsx b/src/components/MortgageCalculator.tsx index 78e5c098..c1f1e3e5 100644 --- a/src/components/MortgageCalculator.tsx +++ b/src/components/MortgageCalculator.tsx @@ -82,11 +82,20 @@ export const MortgageCalculator: React.FC = ({ years: yearsLabel, }); + let currentUrl = ''; + try { + if (typeof window !== 'undefined') { + currentUrl = new URL(window.location.href).href; + } + } catch { + currentUrl = ''; + } + if (navigator.share) { navigator.share({ title: t('mortgageCalculator.shareTitle'), text, - url: window.location.href, + url: currentUrl, }).catch((err) => logger.error('Mortgage calculation error:', err)); } else { navigator.clipboard.writeText(text); diff --git a/src/components/PropertyCard.tsx b/src/components/PropertyCard.tsx index 555d41b6..5295ca42 100644 --- a/src/components/PropertyCard.tsx +++ b/src/components/PropertyCard.tsx @@ -36,19 +36,16 @@ export const PropertyCard: React.FC = ({ const { addFavorite, removeFavorite, isFavorite } = useFavoritesStore(); const handleAddToCart = (e: React.MouseEvent) => { - e.preventDefault(); e.stopPropagation(); addItem(property, 1); }; const handleComparisonToggle = (e: React.MouseEvent) => { - e.preventDefault(); e.stopPropagation(); toggleProperty(property); }; const handleCompareToggle = (e: React.MouseEvent) => { - e.preventDefault(); e.stopPropagation(); if (!compareLimitReached) { togglePropertyId(property.id); @@ -56,7 +53,6 @@ export const PropertyCard: React.FC = ({ }; const handleToggleFavorite = (e: React.MouseEvent) => { - e.preventDefault(); e.stopPropagation(); if (isFavorite(property.id)) { removeFavorite(property.id); @@ -66,12 +62,10 @@ export const PropertyCard: React.FC = ({ }; return ( - {/* Image */}
@@ -188,9 +182,12 @@ export const PropertyCard: React.FC = ({
{/* Title */} -

+ {property.name} -

+ {/* Location */}
@@ -280,12 +277,16 @@ export const PropertyCard: React.FC = ({
- + ); }; diff --git a/src/components/ViewToggle.tsx b/src/components/ViewToggle.tsx index 76d8ba2b..eca5eeff 100644 --- a/src/components/ViewToggle.tsx +++ b/src/components/ViewToggle.tsx @@ -95,16 +95,18 @@ export function ViewToggle({ mode, onChange }: ViewToggleProps) { onClick={() => safeChange("grid")} className={`px-3 py-1.5 flex items-center gap-1 ${mode === "grid" ? "bg-indigo-600 text-white" : "text-gray-600 hover:bg-gray-100"}`} aria-pressed={mode === "grid"} + aria-label="Grid view" > - Grid + Grid ); diff --git a/src/components/__tests__/PropertyCard.a11y.test.tsx b/src/components/__tests__/PropertyCard.a11y.test.tsx index a6b081ea..43a2cd1d 100644 --- a/src/components/__tests__/PropertyCard.a11y.test.tsx +++ b/src/components/__tests__/PropertyCard.a11y.test.tsx @@ -92,10 +92,12 @@ describe('PropertyCard Accessibility', () => { expect(results).toHaveNoViolations(); }); - it('should have accessible property link with proper aria-label', () => { + it('should have accessible property links with proper aria-labels', () => { render(); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('aria-label', 'View details for Sunset Villa'); + const links = screen.getAllByRole('link'); + expect(links.length).toBeGreaterThanOrEqual(2); + const viewLink = screen.getByLabelText('View details for Sunset Villa'); + expect(viewLink).toBeInTheDocument(); }); it('should have accessible image with descriptive alt text', () => { diff --git a/src/components/property/ShareButton.tsx b/src/components/property/ShareButton.tsx index d9eac2af..7235c81e 100644 --- a/src/components/property/ShareButton.tsx +++ b/src/components/property/ShareButton.tsx @@ -19,6 +19,13 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { toast } from 'sonner'; +import { + buildPropertyShareUrl, + buildShareText, + buildTwitterShareUrl, + buildLinkedInShareUrl, + buildEmailShareUrl, +} from '@/utils/security/shareUrl'; interface ShareButtonProps { property: { @@ -51,13 +58,11 @@ export const ShareButton: React.FC = ({ const [copied, setCopied] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); - const propertyUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/properties/${property.id}`; - - const shareText = `Check out this property: ${property.name} in ${property.location.city}, ${property.location.state}. ${property.metrics.roi}% ROI - ${property.price.total} ETH total value.`; - - const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(propertyUrl)}`; + const propertyUrl = buildPropertyShareUrl(property); + const shareText = buildShareText(property); - const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(propertyUrl)}`; + const twitterUrl = propertyUrl ? buildTwitterShareUrl(propertyUrl, shareText) : ''; + const linkedinUrl = propertyUrl ? buildLinkedInShareUrl(propertyUrl) : ''; const handleNativeShare = async () => { if (navigator.share) { @@ -106,9 +111,8 @@ export const ShareButton: React.FC = ({ }; const handleEmailShare = () => { - const subject = encodeURIComponent(`Check out this property: ${property.name}`); - const body = encodeURIComponent(`I found this interesting property and thought you might like it:\n\n${shareText}\n\nView it here: ${propertyUrl}`); - window.open(`mailto:?subject=${subject}&body=${body}`); + const mailtoUrl = buildEmailShareUrl(property.name, shareText, propertyUrl); + window.open(mailtoUrl); toast.success('Opening email client...'); }; diff --git a/src/hooks/usePerformanceMonitoring.ts b/src/hooks/usePerformanceMonitoring.ts new file mode 100644 index 00000000..0fd957c2 --- /dev/null +++ b/src/hooks/usePerformanceMonitoring.ts @@ -0,0 +1,34 @@ +'use client'; +import { useEffect, useRef } from 'react'; +import { setupPerformanceMonitoring, type PerformanceMetrics } from '@/lib/mobile-optimizer'; + +let globalCleanup: (() => void) | null = null; + +export function usePerformanceMonitoring(callback: (metrics: PerformanceMetrics) => void) { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (globalCleanup) { + globalCleanup(); + } + + globalCleanup = setupPerformanceMonitoring((metrics) => { + callbackRef.current(metrics); + }); + + return () => { + if (globalCleanup) { + globalCleanup(); + globalCleanup = null; + } + }; + }, []); +} + +export function resetPerformanceMonitoring() { + if (globalCleanup) { + globalCleanup(); + globalCleanup = null; + } +} diff --git a/src/utils/earlyErrorSuppression.ts b/src/utils/earlyErrorSuppression.ts index e4b758e6..5e412a11 100644 --- a/src/utils/earlyErrorSuppression.ts +++ b/src/utils/earlyErrorSuppression.ts @@ -1,6 +1,8 @@ const stringifyArgs = (args: readonly unknown[]): string => args.map((arg) => (typeof arg === 'string' ? arg : String(arg))).join(' '); +const earlySuppressionCleanups: Array<() => void> = []; + // This file should be imported as early as possible in the application // It immediately starts suppressing extension errors @@ -31,21 +33,34 @@ if (typeof window !== 'undefined') { if (shouldSuppress(...args)) return; originalConsoleWarn.apply(console, args); }; + + earlySuppressionCleanups.push(() => { + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + }); // Suppress global errors - window.addEventListener('error', (event) => { + const handleError = (event: ErrorEvent) => { if (shouldSuppress(event.error, event.filename, event.message)) { event.preventDefault(); event.stopPropagation(); } - }, true); + }; + window.addEventListener('error', handleError, true); + earlySuppressionCleanups.push(() => { + window.removeEventListener('error', handleError, true); + }); // Suppress unhandled promise rejections - window.addEventListener('unhandledrejection', (event) => { + const handleRejection = (event: PromiseRejectionEvent) => { if (shouldSuppress(event.reason)) { event.preventDefault(); event.stopPropagation(); } + }; + window.addEventListener('unhandledrejection', handleRejection); + earlySuppressionCleanups.push(() => { + window.removeEventListener('unhandledrejection', handleRejection); }); // Override window.onerror @@ -59,9 +74,19 @@ if (typeof window !== 'undefined') { } return false; }; + earlySuppressionCleanups.push(() => { + window.onerror = originalOnError; + }); } export const initializeEarlyErrorSuppression = () => { // This function can be called to ensure suppression is active // Note: Early error suppression is already active from module load }; + +export const cleanupEarlyErrorSuppression = () => { + let fn: (() => void) | undefined; + while ((fn = earlySuppressionCleanups.pop()) !== undefined) { + fn(); + } +}; diff --git a/src/utils/extensionDetection.ts b/src/utils/extensionDetection.ts index 7d159556..446f8bdc 100644 --- a/src/utils/extensionDetection.ts +++ b/src/utils/extensionDetection.ts @@ -82,8 +82,12 @@ export const sanitizeExtensionError = (error: unknown): string => { return 'Wallet extension error. Please check your extension settings and try again.'; }; +const extensionErrorListeners: Array<() => void> = []; + export const setupExtensionErrorHandling = () => { if (typeof window === 'undefined') return; + + cleanupExtensionErrorHandling(); // Override console.error to filter out extension errors const originalConsoleError = console.error; @@ -92,26 +96,46 @@ export const setupExtensionErrorHandling = () => { // Filter out known extension errors that don't affect functionality if (errorString.includes('chrome-extension://') || - errorString.includes('evmask.js') || errorString.includes('evmask.js')) { return; // Silently ignore these errors } originalConsoleError.apply(console, args); }; + + extensionErrorListeners.push(() => { + console.error = originalConsoleError; + }); // Add global error handler for unhandled extension errors - window.addEventListener('error', (event) => { + const handleError = (event: ErrorEvent) => { if (isExtensionError(event.error)) { event.preventDefault(); logger.warn('Extension error filtered:', sanitizeExtensionError(event.error)); } + }; + + window.addEventListener('error', handleError); + extensionErrorListeners.push(() => { + window.removeEventListener('error', handleError); }); - window.addEventListener('unhandledrejection', (event) => { + const handleRejection = (event: PromiseRejectionEvent) => { if (isExtensionError(event.reason)) { event.preventDefault(); logger.warn('Extension promise rejection filtered:', sanitizeExtensionError(event.reason)); } + }; + + window.addEventListener('unhandledrejection', handleRejection); + extensionErrorListeners.push(() => { + window.removeEventListener('unhandledrejection', handleRejection); }); }; + +export const cleanupExtensionErrorHandling = () => { + let fn: (() => void) | undefined; + while ((fn = extensionErrorListeners.pop()) !== undefined) { + fn(); + } +}; diff --git a/src/utils/security/shareUrl.ts b/src/utils/security/shareUrl.ts new file mode 100644 index 00000000..02e36889 --- /dev/null +++ b/src/utils/security/shareUrl.ts @@ -0,0 +1,70 @@ +const SAFE_STRING_REGEX = /[^a-zA-Z0-9\s\-'.,!?()%+]/g; +const MAX_STRING_LENGTH = 200; + +export function sanitizeDisplayString(value: string): string { + if (!value) return ''; + const truncated = value.slice(0, MAX_STRING_LENGTH); + return truncated.replace(SAFE_STRING_REGEX, '').trim(); +} + +export function buildPropertyShareUrl(property: { + id: string; + name: string; + location: { city: string; state: string }; + metrics: { roi: number }; + price: { total: number }; +}): string { + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + const urlStr = `${origin}/properties/${encodeURIComponent(property.id)}`; + + let url: URL; + try { + url = new URL(urlStr); + } catch { + return ''; + } + + if (origin && url.origin !== origin) { + return ''; + } + + return url.href; +} + +export function buildShareText(property: { + name: string; + location: { city: string; state: string }; + metrics: { roi: number }; + price: { total: number }; +}): string { + const safeName = sanitizeDisplayString(property.name); + const safeCity = sanitizeDisplayString(property.location.city); + const safeState = sanitizeDisplayString(property.location.state); + const roi = typeof property.metrics.roi === 'number' ? property.metrics.roi : 0; + const price = typeof property.price.total === 'number' ? property.price.total : 0; + + return `Check out this property: ${safeName} in ${safeCity}, ${safeState}. ${roi}% ROI - ${price} ETH total value.`; +} + +export function buildTwitterShareUrl(propertyUrl: string, shareText: string): string { + const url = new URL('https://twitter.com/intent/tweet'); + url.searchParams.set('text', shareText); + url.searchParams.set('url', propertyUrl); + return url.href; +} + +export function buildLinkedInShareUrl(propertyUrl: string): string { + const url = new URL('https://www.linkedin.com/sharing/share-offsite/'); + url.searchParams.set('url', propertyUrl); + return url.href; +} + +export function buildEmailShareUrl(propertyName: string, shareText: string, propertyUrl: string): string { + const safeName = sanitizeDisplayString(propertyName); + const subject = `Check out this property: ${safeName}`; + const body = `I found this interesting property and thought you might like it:\n\n${shareText}\n\nView it here: ${propertyUrl}`; + const mailto = new URL('mailto:'); + mailto.searchParams.set('subject', subject); + mailto.searchParams.set('body', body); + return mailto.href; +}