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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@

# Deployed BaseBoard.sol address on Base Mainnet (8453).
NEXT_PUBLIC_BASEBOARD_CONTRACT_ADDRESS=
# Block Base contract was deployed at (fromBlock for log scans).
NEXT_PUBLIC_BASEBOARD_DEPLOY_BLOCK=

# ---- Celo Mainnet (42220) ----
# Deployed BaseBoard.sol address on Celo (placeholder until deployed).
NEXT_PUBLIC_CELO_CONTRACT_ADDRESS=
# Treasury that receives Celo mint fees (must match the on-chain TREASURY).
NEXT_PUBLIC_CELO_TREASURY_ADDRESS=
# Flat plot price on Celo, in CELO (≈ $0.001 equivalent).
NEXT_PUBLIC_CELO_PLOT_PRICE=
# Block the Celo contract was deployed at (fromBlock for log scans).
NEXT_PUBLIC_CELO_DEPLOY_BLOCK=

# Coinbase Developer Platform / OnchainKit client API key.
# https://portal.cdp.coinbase.com/
Expand Down
6 changes: 5 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ import { BaseBoardCanvas } from "@/components/BaseBoardCanvas";
import { HangingHeader } from "@/components/HangingHeader";
import { StatsDashboard } from "@/components/StatsDashboard";
import { WalletConnect } from "@/components/WalletConnect";
import { NetworkSwitcher } from "@/components/NetworkSwitcher";
import { NetworkGuard } from "@/components/NetworkGuard";
import { ProfileDrawer } from "@/components/ProfileDrawer";
import { PlotModal } from "@/components/PlotModal";
import { BuyModal } from "@/components/BuyModal";
import { Legend } from "@/components/Legend";
import { Toaster } from "@/components/Toaster";
import { useBoardStore } from "@/store/useBoardStore";
import { useActiveChainConfig } from "@/hooks/useActiveContract";

export default function Home() {
const toggleProfile = useBoardStore((s) => s.toggleProfile);
const cfg = useActiveChainConfig();

return (
<div className="flex h-screen flex-col bg-white">
Expand All @@ -33,6 +36,7 @@ export default function Home() {
>
My Profile
</button>
<NetworkSwitcher />
<WalletConnect />
</div>
</div>
Expand All @@ -51,7 +55,7 @@ export default function Home() {
<div className="my-2 flex w-full max-w-7xl items-center justify-between">
<Legend />
<p className="text-xs text-slate-400">
BaseBoard · 3162 × 3162 grid · Base Mainnet (8453)
BaseBoard · 3162 × 3162 grid · {cfg.name} ({cfg.chainId})
</p>
</div>
</main>
Expand Down
49 changes: 33 additions & 16 deletions src/components/BaseBoardCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,10 @@ import {
useRef,
useState,
} from "react";
import { usePublicClient, useAccount } from "wagmi";
import { baseBoardAbi, baseBoardAddress } from "@/lib/contract";
import {
BASEBOARD_DEPLOY_BLOCK,
GRID_SIZE,
IS_CONTRACT_CONFIGURED,
ZERO_ADDRESS,
} from "@/lib/constants";
import { usePublicClient, useAccount, useChainId } from "wagmi";
import { baseBoardAbi } from "@/lib/contract";
import { GRID_SIZE, ZERO_ADDRESS } from "@/lib/constants";
import { useActiveChainConfig } from "@/hooks/useActiveContract";
import { clamp, plotIdFromXY, xyFromPlotId } from "@/lib/coords";
import { parseZone, stripZone } from "@/lib/image";
import type { Plot } from "@/lib/types";
Expand Down Expand Up @@ -46,6 +42,8 @@ export function BaseBoardCanvas() {
const containerRef = useRef<HTMLDivElement | null>(null);
const publicClient = usePublicClient();
const { address } = useAccount();
const chainId = useChainId();
const cfg = useActiveChainConfig();

const openPlot = useBoardStore((s) => s.openPlot);
const setBuySelection = useBoardStore((s) => s.setBuySelection);
Expand All @@ -59,6 +57,7 @@ export function BaseBoardCanvas() {
const clearBasket = useBoardStore((s) => s.clearBasket);
const setDirectBuyIds = useBoardStore((s) => s.setDirectBuyIds);
const optimisticPlots = useBoardStore((s) => s.optimisticPlots);
const clearOptimisticPlots = useBoardStore((s) => s.clearOptimisticPlots);

const [tool, setTool] = useState<Tool>("pan");
const [zoomLabel, setZoomLabel] = useState(1);
Expand Down Expand Up @@ -545,7 +544,7 @@ export function BaseBoardCanvas() {
// Data loading for the visible viewport (debounced on camera settle)
// -------------------------------------------------------------------
const loadViewport = useCallback(async () => {
if (!IS_CONTRACT_CONFIGURED || !publicClient) return;
if (!cfg.isConfigured || !publicClient) return;
const cam = cameraRef.current;
const { width, height } = sizeRef.current;
const startX = Math.max(0, Math.floor(cam.camX));
Expand All @@ -567,7 +566,7 @@ export function BaseBoardCanvas() {

try {
const result = (await publicClient.readContract({
address: baseBoardAddress,
address: cfg.contract,
abi: baseBoardAbi,
functionName: "getPlotsBatch",
args: [ids],
Expand All @@ -587,7 +586,7 @@ export function BaseBoardCanvas() {
} catch {
/* read failed (rpc / not deployed) — keep prior data */
}
}, [publicClient]);
}, [publicClient, cfg.isConfigured, cfg.contract]);

// Debounce viewport loads.
const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All @@ -608,15 +607,15 @@ export function BaseBoardCanvas() {
const globalLoadingRef = useRef(false);

const loadAllMinted = useCallback(async () => {
if (!IS_CONTRACT_CONFIGURED || !publicClient) return;
if (!cfg.isConfigured || !publicClient) return;
if (globalLoadingRef.current) return;
globalLoadingRef.current = true;
try {
const latest = Number(await publicClient.getBlockNumber());
const from =
lastScanBlockRef.current > 0
? lastScanBlockRef.current + 1
: BASEBOARD_DEPLOY_BLOCK;
: cfg.deployBlock;

// 1) Discover newly-minted plot ids from PlotsPurchased logs (chunked to
// respect RPC block-range limits). Base's public RPC caps eth_getLogs
Expand All @@ -627,7 +626,7 @@ export function BaseBoardCanvas() {
const end = Math.min(start + LOG_CHUNK, latest);
try {
const logs = await publicClient.getContractEvents({
address: baseBoardAddress,
address: cfg.contract,
abi: baseBoardAbi,
eventName: "PlotsPurchased",
fromBlock: BigInt(start),
Expand All @@ -651,7 +650,7 @@ export function BaseBoardCanvas() {
const slice = all.slice(i, i + READ_CHUNK);
try {
const res = (await publicClient.readContract({
address: baseBoardAddress,
address: cfg.contract,
abi: baseBoardAbi,
functionName: "getPlotsBatch",
args: [slice.map((n) => BigInt(n))],
Expand All @@ -676,7 +675,25 @@ export function BaseBoardCanvas() {
} finally {
globalLoadingRef.current = false;
}
}, [publicClient]);
}, [publicClient, cfg.isConfigured, cfg.contract, cfg.deployBlock]);

// State isolation: when the active chain changes (Base <-> Celo), wipe every
// cached plot, the minted-id set, the log-scan cursor, decoded images and any
// optimistic overrides so we never show one network's board on another. The
// load effects below re-run automatically (their callbacks are rebuilt from
// the new chain's contract) and repopulate from the active network.
const prevChainRef = useRef(chainId);
useEffect(() => {
if (prevChainRef.current === chainId) return;
prevChainRef.current = chainId;
plotMapRef.current.clear();
allMintedIdsRef.current.clear();
lastScanBlockRef.current = 0;
imageCacheRef.current.clear();
clearOptimisticPlots();
dirtyRef.current = true;
forceTick((t) => t + 1);
}, [chainId, clearOptimisticPlots]);

// Initial global load + light polling so other users' buys/listings appear
// without a manual refresh, at every zoom level.
Expand Down
28 changes: 13 additions & 15 deletions src/components/BuyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,15 @@ import { Spinner } from "./Spinner";
import { WalletConnect } from "./WalletConnect";
import { useBoardStore } from "@/store/useBoardStore";
import { useBaseBoardWrite } from "@/hooks/useBaseBoard";
import { baseBoardAbi, baseBoardAddress } from "@/lib/contract";
import {
IS_CONTRACT_CONFIGURED,
PLOT_PRICE_ETH,
ZERO_ADDRESS,
} from "@/lib/constants";
import { useActiveChainConfig } from "@/hooks/useActiveContract";
import { baseBoardAbi } from "@/lib/contract";
import { ZERO_ADDRESS } from "@/lib/constants";
import {
bboxToPlotIds,
normalizeBBox,
totalPriceEth,
totalPriceWei,
xyFromPlotId,
} from "@/lib/coords";
import { formatEther } from "viem";
import type { Plot } from "@/lib/types";

const MAX_BUY = 400;
Expand All @@ -34,6 +30,7 @@ export function BuyModal() {
const clearOptimisticPlots = useBoardStore((s) => s.clearOptimisticPlots);
const { address, isConnected } = useAccount();
const publicClient = usePublicClient();
const cfg = useActiveChainConfig();
const { writeContractAsync, status, error, reset } = useBaseBoardWrite();

const [buyableIds, setBuyableIds] = useState<number[] | null>(null);
Expand Down Expand Up @@ -66,7 +63,7 @@ export function BuyModal() {
if (!open || selectedIds.length === 0 || tooLarge) return;
const ids = selectedIds;

if (!IS_CONTRACT_CONFIGURED || !publicClient) {
if (!cfg.isConfigured || !publicClient) {
setBuyableIds(ids); // demo mode: treat all as buyable
return;
}
Expand All @@ -76,7 +73,7 @@ export function BuyModal() {
(async () => {
try {
const result = (await publicClient.readContract({
address: baseBoardAddress,
address: cfg.contract,
abi: baseBoardAbi,
functionName: "getPlotsBatch",
args: [ids.map((i) => BigInt(i))],
Expand Down Expand Up @@ -105,11 +102,11 @@ export function BuyModal() {
setTxError(null);
try {
await writeContractAsync({
address: baseBoardAddress,
address: cfg.contract,
abi: baseBoardAbi,
functionName: "buyPlots",
args: [buyableIds.map((i) => BigInt(i))],
value: totalPriceWei(buyableIds.length),
value: cfg.plotPriceWei * BigInt(buyableIds.length),
});
} catch (e) {
setTxError(e instanceof Error ? e.message : "Transaction failed");
Expand Down Expand Up @@ -169,8 +166,8 @@ export function BuyModal() {
</p>
)}
<p className="mt-1 text-slate-600">
{totalCount.toLocaleString()} plots selected · {PLOT_PRICE_ETH} ETH
each
{totalCount.toLocaleString()} plots selected ·{" "}
{cfg.plotPriceLabel} {cfg.nativeSymbol} each
</p>
</div>

Expand Down Expand Up @@ -203,7 +200,8 @@ export function BuyModal() {
Total
</p>
<p className="text-lg font-black text-base-blue">
{totalPriceEth(buyCount)} ETH
{formatEther(cfg.plotPriceWei * BigInt(buyCount))}{" "}
{cfg.nativeSymbol}
</p>
</div>
</div>
Expand Down
82 changes: 82 additions & 0 deletions src/components/ChainLogos.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import { BASE_CHAIN_ID, CELO_CHAIN_ID } from "@/lib/constants";

interface LogoProps {
size?: number;
className?: string;
}

/**
* Modern square "Basemark" — the white Base disc (flat right edge) on the
* #0052FF rounded square. The flat edge is produced with a clip so it renders
* crisply at any size.
*/
export function BaseLogo({ size = 20, className }: LogoProps) {
const clipId = "baseflat";
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
className={className}
aria-hidden="true"
>
<defs>
<clipPath id={clipId}>
<rect x="0" y="0" width="22.2" height="32" />
</clipPath>
</defs>
<rect width="32" height="32" rx="7" fill="#0052FF" />
<circle cx="16" cy="16" r="8.7" fill="#FFFFFF" clipPath={`url(#${clipId})`} />
</svg>
);
}

/**
* Circular Celo mark — Celo-yellow disc with the concentric-ring motif.
*/
export function CeloLogo({ size = 20, className }: LogoProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
className={className}
aria-hidden="true"
>
<circle cx="16" cy="16" r="16" fill="#FCFF52" />
<circle
cx="16"
cy="16"
r="9"
fill="none"
stroke="#000000"
strokeWidth="2.6"
/>
<circle
cx="16"
cy="16"
r="4.2"
fill="none"
stroke="#000000"
strokeWidth="2.6"
/>
</svg>
);
}

/** Pick the right logo for a chain id (defaults to the Base mark). */
export function ChainLogo({
chainId,
size,
className,
}: {
chainId: number;
} & LogoProps) {
if (chainId === CELO_CHAIN_ID)
return <CeloLogo size={size} className={className} />;
// Base + local default to the Base mark.
void BASE_CHAIN_ID;
return <BaseLogo size={size} className={className} />;
}
5 changes: 4 additions & 1 deletion src/components/HangingHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"use client";

import { useActiveChainConfig } from "@/hooks/useActiveContract";

/**
* The "BaseBoard" title plaque, styled to look like a framed sign hanging from
* a nail in the wall by two taut ropes. Pure SVG + CSS, no images.
*/
export function HangingHeader() {
const cfg = useActiveChainConfig();
return (
<div className="pointer-events-none relative z-20 flex flex-col items-center">
{/* Nail driven into the wall */}
Expand Down Expand Up @@ -52,7 +55,7 @@ export function HangingHeader() {
BaseBoard
</h1>
<p className="mt-0.5 text-center text-[11px] font-semibold uppercase tracking-[0.25em] text-base-light">
10,000,000 plots · Base Mainnet
10,000,000 plots · {cfg.name}
</p>
</div>
</div>
Expand Down
Loading