Skip to content
Merged
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
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
FROM node:20-slim AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app

FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN pnpm next build

FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
1 change: 1 addition & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const csp = [
].join("; ");

const nextConfig: NextConfig = {
output: "standalone",
experimental: {
optimizePackageImports: [
"lucide-react",
Expand Down
86 changes: 59 additions & 27 deletions scripts/check-performance-budgets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ const nextDir = path.join(projectRoot, ".next");
const chunksDir = path.join(nextDir, "static", "chunks");
const buildManifestPath = path.join(nextDir, "build-manifest.json");

const totalBudgetKb = Number(process.env.TOTAL_JS_BUDGET_KB || 650);
const chunkBudgetKb = Number(process.env.MAX_CHUNK_BUDGET_KB || 250);
const INITIAL_JS_BUDGET_KB = 220;
const WEB3_VENDORS_BUDGET_KB = 180;
const CSS_BUDGET_KB = 60;

if (!fs.existsSync(chunksDir)) {
console.error("Missing .next/static/chunks. Run `next build` first.");
Expand All @@ -33,6 +34,8 @@ const manifest = fs.existsSync(buildManifestPath)
: null;

const initialFiles = new Set();
const web3VendorFiles = new Set();

if (manifest) {
for (const file of [...(manifest.polyfillFiles || []), ...(manifest.rootMainFiles || [])]) {
if (typeof file === "string" && file.endsWith(".js")) {
Expand All @@ -46,50 +49,79 @@ if (manifest) {
}
}
}
}

let allBytes = 0;
for (const filePath of allChunkFiles) {
const size = fs.statSync(filePath).size;
allBytes += size;
for (const file of allChunkFiles) {
const rel = path.relative(nextDir, file).replace(/\\/g, "/");
if (rel.includes("web3-vendors") || rel.includes("wagmi") || rel.includes("viem") || rel.includes("ethers")) {
web3VendorFiles.add(rel);
}
}
}

let initialBytes = 0;
const offenders = [];
const initialOffenders = [];
for (const relativePath of initialFiles) {
const filePath = path.join(nextDir, relativePath);
if (!fs.existsSync(filePath)) continue;
const size = fs.statSync(filePath).size;
initialBytes += size;
if (size > chunkBudgetKb * 1024) {
offenders.push({
file: relativePath,
kb: (size / 1024).toFixed(1),
});
if (size > 100 * 1024) {
initialOffenders.push({ file: relativePath, kb: (size / 1024).toFixed(1) });
}
}

const totalKb = initialBytes / 1024;
console.log(`Initial JS size (build manifest): ${totalKb.toFixed(1)} KB`);
console.log(`All chunk JS size: ${(allBytes / 1024).toFixed(1)} KB`);
console.log(`Budget: ${totalBudgetKb} KB`);
let web3Bytes = 0;
for (const rel of web3VendorFiles) {
const filePath = path.join(nextDir, rel);
if (!fs.existsSync(filePath)) continue;
web3Bytes += fs.statSync(filePath).size;
}

const cssDir = path.join(nextDir, "static", "css");
let cssBytes = 0;
if (fs.existsSync(cssDir)) {
const walk = (dir) => {
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, item.name);
if (item.isDirectory()) walk(full);
else if (item.isFile() && item.name.endsWith(".css")) {
cssBytes += fs.statSync(full).size;
}
}
};
walk(cssDir);
}

const initialKb = initialBytes / 1024;
const web3Kb = web3Bytes / 1024;
const cssKb = cssBytes / 1024;

console.log("Initial JS: " + initialKb.toFixed(1) + " KB (budget: " + INITIAL_JS_BUDGET_KB + " KB)");
console.log("Web3-vendor JS: " + web3Kb.toFixed(1) + " KB (budget: " + WEB3_VENDORS_BUDGET_KB + " KB)");
console.log("CSS: " + cssKb.toFixed(1) + " KB (budget: " + CSS_BUDGET_KB + " KB)");

let hasError = false;
if (totalKb > totalBudgetKb) {
console.error(`Total JS budget exceeded by ${(totalKb - totalBudgetKb).toFixed(1)} KB`);

if (initialKb > INITIAL_JS_BUDGET_KB) {
console.error("FAIL: Initial JS exceeded by " + (initialKb - INITIAL_JS_BUDGET_KB).toFixed(1) + " KB");
hasError = true;
}

if (offenders.length > 0) {
console.error(`Chunks above ${chunkBudgetKb} KB:`);
for (const item of offenders) {
console.error(`- ${item.file}: ${item.kb} KB`);
if (web3Kb > WEB3_VENDORS_BUDGET_KB) {
console.error("FAIL: Web3 vendor JS exceeded by " + (web3Kb - WEB3_VENDORS_BUDGET_KB).toFixed(1) + " KB");
hasError = true;
}
if (cssKb > CSS_BUDGET_KB) {
console.error("FAIL: CSS exceeded by " + (cssKb - CSS_BUDGET_KB).toFixed(1) + " KB");
hasError = true;
}
if (initialOffenders.length > 0) {
console.error("Large initial chunks:");
for (const item of initialOffenders) {
console.error(" - " + item.file + ": " + item.kb + " KB");
}
hasError = true;
}

if (hasError) {
process.exit(1);
}

console.log("Performance budgets passed.");
console.log("All performance budgets passed.");
26 changes: 10 additions & 16 deletions src/components/PropertyCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { ShoppingCart, Plus, CheckSquare, Square, Heart, Star } from 'lucide-react';
Expand All @@ -19,6 +19,12 @@ interface PropertyCardProps {
viewMode?: 'grid' | 'list';
}

const WALLET_OPTIONS = [
{ id: 'metamask', name: 'MetaMask', installUrl: 'https://metamask.io/download/' },
{ id: 'walletconnect', name: 'WalletConnect', description: 'Scan a QR code with any mobile wallet' },
{ id: 'coinbase', name: 'Coinbase Wallet', installUrl: 'https://www.coinbase.com/wallet' },
] as const;

const PropertyCardInner: React.FC<PropertyCardProps> = ({
property,
viewMode = 'grid'
Expand Down Expand Up @@ -110,7 +116,6 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
</div>
</div>

{/* Comparison Toggle */}
<button
onClick={handleComparisonToggle}
className="absolute top-2 right-12 sm:top-3 sm:right-16 bg-white/90 hover:bg-white text-gray-700 hover:text-gray-900 p-2 rounded-lg shadow-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
Expand All @@ -125,7 +130,6 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
)}
</button>

{/* Favorite Button */}
<button
onClick={handleToggleFavorite}
className="absolute top-2 right-2 sm:top-3 sm:right-3 p-2 bg-white/90 hover:bg-white rounded-full shadow-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
Expand All @@ -142,7 +146,6 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
/>
</button>

{/* Blockchain Badge */}
<div
className="absolute bottom-2 left-2 sm:bottom-3 sm:left-3"
role="status"
Expand All @@ -158,7 +161,6 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
</div>
</div>

{/* Compare Toggle */}
<div className="absolute top-12 sm:top-14 right-2 sm:right-3">
<label
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-xs font-semibold transition-colors ${
Expand All @@ -182,24 +184,20 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
</div>
</div>

{/* Content */}
<div className={`p-3 sm:p-5 flex flex-col ${isListView ? 'flex-1' : ''}`}>
{/* Property Type */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">
{PROPERTY_TYPE_LABELS[property.propertyType]}
</span>
</div>

{/* Title */}
<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"
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 rounded"
>
{property.name}
</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">
<svg className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
Expand All @@ -210,14 +208,12 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
</span>
</div>

{/* Description */}
{isListView && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
{property.description}
</p>
)}

{/* Details */}
{property.details.bedrooms && (
<div className="flex items-center gap-4 text-sm text-gray-700 dark:text-gray-300 mb-4">
{property.details.bedrooms && (
Expand All @@ -240,12 +236,11 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
<span>{formatNumber(property.details.squareFeet)} sqft</span>
<span>{formatNumber(property.details.squareFeet)} sqft</span>
</div>
</div>
)}

{/* Token Info */}
<div className="flex items-center justify-between mb-3 sm:mb-4 p-2 sm:p-3 bg-gray-50 dark:bg-gray-700 rounded-lg gap-2">
<div className="min-w-0">
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">Available Tokens</p>
Expand All @@ -261,7 +256,6 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
</div>
</div>

{/* Price and CTA */}
<div className="flex items-center justify-between mt-auto pt-3 sm:pt-4 border-t border-gray-200 dark:border-gray-700 gap-2">
<div className="min-w-0">
<p className="text-xs text-gray-500 dark:text-gray-400">Total Value</p>
Expand Down Expand Up @@ -289,7 +283,7 @@ const PropertyCardInner: React.FC<PropertyCardProps> = ({
</button>
<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"
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-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 inline-flex items-center justify-center"
aria-label={`View details for ${property.name}`}
>
View
Expand Down