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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
# -----------------------------------------------------------------------------
Expand Down
48 changes: 48 additions & 0 deletions src/app/api/security/address-check/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
11 changes: 10 additions & 1 deletion src/components/MortgageCalculator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,20 @@ export const MortgageCalculator: React.FC<MortgageCalculatorProps> = ({
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);
Expand Down
27 changes: 14 additions & 13 deletions src/components/PropertyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,23 @@ export const PropertyCard: React.FC<PropertyCardProps> = ({
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<HTMLInputElement>) => {
e.preventDefault();
e.stopPropagation();
if (!compareLimitReached) {
togglePropertyId(property.id);
}
};

const handleToggleFavorite = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isFavorite(property.id)) {
removeFavorite(property.id);
Expand All @@ -66,12 +62,10 @@ export const PropertyCard: React.FC<PropertyCardProps> = ({
};

return (
<Link
href={`/properties/${property.id}`}
className={`group bg-white dark:bg-gray-800 rounded-xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden ${
<article
className={`bg-white dark:bg-gray-800 rounded-xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden ${
isListView ? 'flex flex-row' : 'flex flex-col'
}`}
aria-label={`View details for ${property.name}`}
>
{/* Image */}
<div className={`relative overflow-hidden ${isListView ? 'w-64 flex-shrink-0' : 'w-full h-56'}`}>
Expand Down Expand Up @@ -188,9 +182,12 @@ export const PropertyCard: React.FC<PropertyCardProps> = ({
</div>

{/* Title */}
<h3 className="text-base sm:text-lg font-bold text-gray-900 dark:text-white mb-1 sm:mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-2">
<Link
href={`/properties/${property.id}`}
className="text-base sm:text-lg font-bold text-gray-900 dark:text-white mb-1 sm:mb-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors line-clamp-2"
>
{property.name}
</h3>
</Link>

{/* Location */}
<div className="flex items-start gap-1 text-xs sm:text-sm text-gray-600 dark:text-gray-400 mb-2 sm:mb-3">
Expand Down Expand Up @@ -280,12 +277,16 @@ export const PropertyCard: React.FC<PropertyCardProps> = ({
<Plus className="w-2 h-2 sm:w-3 sm:h-3" aria-hidden="true" />
<span className="hidden sm:inline">Add to Cart</span>
</button>
<button className="px-2 sm:px-4 py-1.5 sm:py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs sm:text-sm font-medium rounded-lg transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" aria-label={`View details for ${property.name}`}>
<Link
href={`/properties/${property.id}`}
className="px-2 sm:px-4 py-1.5 sm:py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs sm:text-sm font-medium rounded-lg transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center justify-center"
aria-label={`View details for ${property.name}`}
>
View
</button>
</Link>
</div>
</div>
</div>
</Link>
</article>
);
};
6 changes: 4 additions & 2 deletions src/components/ViewToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<GridIcon /> Grid
<GridIcon /> <span>Grid</span>
</button>
<button
type="button"
onClick={() => safeChange("list")}
className={`px-3 py-1.5 flex items-center gap-1 ${mode === "list" ? "bg-indigo-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}
aria-pressed={mode === "list"}
aria-label="List view"
>
<ListIcon /> List
<ListIcon /> <span>List</span>
</button>
</div>
);
Expand Down
8 changes: 5 additions & 3 deletions src/components/__tests__/PropertyCard.a11y.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PropertyCard property={mockProperty} />);
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', () => {
Expand Down
22 changes: 13 additions & 9 deletions src/components/property/ShareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -51,13 +58,11 @@ export const ShareButton: React.FC<ShareButtonProps> = ({
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) {
Expand Down Expand Up @@ -106,9 +111,8 @@ export const ShareButton: React.FC<ShareButtonProps> = ({
};

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...');
};

Expand Down
38 changes: 20 additions & 18 deletions src/components/ui/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
return Object.entries(THEMES)
.map(([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
return color ? ` --color-${encodeURIComponent(key)}: ${color};` : null
})
.filter(Boolean)
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
`)
.join("\n")
}

const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const css = React.useMemo(() => buildChartCSS(id, config), [id, config])

if (!css) return null

const sanitizedCss = DOMPurify.sanitize(css)

return <style dangerouslySetInnerHTML={{ __html: sanitizedCss }} />
}

const ChartTooltip = Tooltip
Expand Down
34 changes: 34 additions & 0 deletions src/hooks/usePerformanceMonitoring.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading