diff --git a/.env.example b/.env.example index 4122728f..8afeccb6 100644 --- a/.env.example +++ b/.env.example @@ -126,6 +126,9 @@ NEXT_PUBLIC_SENTRY_DSN= # Mock data mode (bypasses real API calls) NEXT_PUBLIC_USE_MOCK_DATA=false +# Enable demo/ simulated transaction mode for development (bypasses real blockchain reads) +NEXT_PUBLIC_DEMO_TX=false + # Skip authentication for development NEXT_PUBLIC_SKIP_AUTH=false @@ -142,6 +145,17 @@ RATE_LIMIT_MAX_REQUESTS=100 # Maximum requests per wallet address per time window (default: 50) RATE_LIMIT_MAX_REQUESTS_PER_WALLET=50 +# ----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- +# Chainalysis Security API Configuration +# ----------------------------------------------------------------------------- +# API key for Chainalysis address/transaction risk checks. +# IMPORTANT: This is read server-side only. Never expose it to the browser. +# CHAINALYSIS_API_KEY=your_chainalysis_api_key + +# Chainalysis API base URL (optional, uses default if not set) +# CHAINALYSIS_API_URL=https://api.chainalysis.com/api/v2 + # ----------------------------------------------------------------------------- # Secret Management (Production) # ----------------------------------------------------------------------------- diff --git a/src/app/api/security/address-check/route.ts b/src/app/api/security/address-check/route.ts new file mode 100644 index 00000000..cf9eea9f --- /dev/null +++ b/src/app/api/security/address-check/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const CHAINALYSIS_API_KEY = process.env.CHAINALYSIS_API_KEY || ''; +const CHAINALYSIS_API_URL = process.env.CHAINALYSIS_API_URL || 'https://api.chainalysis.com/api/v2'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const address = searchParams.get('address')?.trim(); + + if (!address) { + return NextResponse.json({ error: 'Address parameter required' }, { status: 400 }); + } + + if (address.length < 10) { + return NextResponse.json({ error: 'Invalid address' }, { status: 400 }); + } + + if (!CHAINALYSIS_API_KEY) { + return NextResponse.json({ + risk_score: 50, + risk_level: 'medium', + categories: ['unavailable'], + description: 'Chainalysis API key not configured on server', + }); + } + + try { + const response = await fetch(`${CHAINALYSIS_API_URL}/address/${address}`, { + headers: { + Authorization: `Bearer ${CHAINALYSIS_API_KEY}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new Error(`Chainalysis API returned ${response.status}`); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { error: 'Failed to check address risk', risk_score: 50, risk_level: 'medium' }, + { status: 502 } + ); + } +} 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/components/ui/chart.tsx b/src/components/ui/chart.tsx index e04abd79..c10618ce 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { ResponsiveContainer, Tooltip, Legend, type LegendProps } from "recharts" +import DOMPurify from "dompurify" import { cn } from "@/lib/utils" @@ -69,37 +70,38 @@ function ChartContainer({ ) } -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { +function buildChartCSS(id: string, config: ChartConfig): string { const colorConfig = Object.entries(config).filter( - ([, config]) => config.theme || config.color + ([, cfg]) => cfg.theme || cfg.color ) - if (!colorConfig.length) { - return null - } + if (!colorConfig.length) return '' - return ( -