diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e77e27b8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/next.config.ts b/next.config.ts index 1336e35d..b758257b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -34,6 +34,7 @@ const csp = [ ].join("; "); const nextConfig: NextConfig = { + output: "standalone", experimental: { optimizePackageImports: [ "lucide-react", diff --git a/scripts/check-performance-budgets.mjs b/scripts/check-performance-budgets.mjs index 0bc7482a..50d13bd4 100644 --- a/scripts/check-performance-budgets.mjs +++ b/scripts/check-performance-budgets.mjs @@ -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."); @@ -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")) { @@ -46,44 +49,74 @@ 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; } @@ -91,5 +124,4 @@ if (offenders.length > 0) { if (hasError) { process.exit(1); } - -console.log("Performance budgets passed."); +console.log("All performance budgets passed."); diff --git a/src/components/PropertyCard.tsx b/src/components/PropertyCard.tsx index 80bfcd46..f7ab030c 100644 --- a/src/components/PropertyCard.tsx +++ b/src/components/PropertyCard.tsx @@ -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'; @@ -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 = ({ property, viewMode = 'grid' @@ -110,7 +116,6 @@ const PropertyCardInner: React.FC = ({ - {/* Comparison Toggle */} - {/* Favorite Button */} - {/* Blockchain Badge */}
= ({
- {/* Compare Toggle */}
- {/* Content */}
- {/* Property Type */}
{PROPERTY_TYPE_LABELS[property.propertyType]}
- {/* Title */} {property.name} - {/* Location */}
- {/* Description */} {isListView && (

{property.description}

)} - {/* Details */} {property.details.bedrooms && (
{property.details.bedrooms && ( @@ -240,12 +236,11 @@ const PropertyCardInner: React.FC = ({ - {formatNumber(property.details.squareFeet)} sqft + {formatNumber(property.details.squareFeet)} sqft
)} - {/* Token Info */}

Available Tokens

@@ -261,7 +256,6 @@ const PropertyCardInner: React.FC = ({
- {/* Price and CTA */}

Total Value

@@ -289,7 +283,7 @@ const PropertyCardInner: React.FC = ({ View