diff --git a/.env.example b/.env.example index 5f2815b..fb18387 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,6 @@ NEXT_PUBLIC_WC_PROJECT_ID= # Shared marketplace storage (Upstash Redis REST, free tier: # https://upstash.com - create a Redis db, copy URL + token). Server-side -# only. Without these the app runs local-only plus the demo seeds. +# only. Without these the app runs local-only. Listings are stored per network. UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..2802126 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..ad5db45 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/sw.js b/public/sw.js index e3022b1..502d82a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,16 +1,20 @@ -const CACHE = "ghostdeal-v1"; +const CACHE = "ghostdeal-v3"; self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE).then((cache) => - cache.addAll(["/", "/tokens/strk.png", "/tokens/usdc.webp", "/tokens/strk20.png"]).catch(() => undefined) + cache + .addAll(["/", "/icon-192.png", "/icon-512.png", "/tokens/strk.png", "/tokens/usdc.webp"]) + .catch(() => undefined) ) ); self.skipWaiting(); }); self.addEventListener("activate", (event) => { - event.waitUntil(self.clients.claim()); + event.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))).then(() => self.clients.claim()) + ); }); self.addEventListener("fetch", (event) => { diff --git a/src/app/api/listings/[id]/route.ts b/src/app/api/listings/[id]/route.ts index 786976d..620153d 100644 --- a/src/app/api/listings/[id]/route.ts +++ b/src/app/api/listings/[id]/route.ts @@ -1,4 +1,9 @@ import { NextResponse } from "next/server"; +import { + deleteMarketplaceListing, + networkFromRequest, + redisConfigured, +} from "@/lib/marketplaceRedis"; // Demo-grade ownership check: the caller must present the owner address the // listing was published with. Not real authentication, but enough friction @@ -6,56 +11,18 @@ import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; -const IDS_KEY = "gd:ids"; -const itemKey = (id: string) => `gd:l:${id}`; - -type RedisCall = (body: unknown) => Promise<{ result: unknown }>; - -function redis(): RedisCall | null { - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - if (!url || !token) return null; - return async (body) => { - const commands = Array.isArray(body) ? body : []; - const pipeline = commands.length > 0 && Array.isArray(commands[0]); - const endpoint = pipeline ? `${url.replace(/\/$/, "")}/pipeline` : url; - const res = await fetch(endpoint, { - method: "POST", - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify(body), - cache: "no-store", - }); - if (!res.ok) throw new Error(`redis ${res.status}`); - const json = await res.json(); - if (pipeline) { - return { result: (json as { result: unknown }[]).map((row) => row.result) }; - } - return json as { result: unknown }; - }; -} - export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { - const call = redis(); - if (!call) return NextResponse.json({ error: "marketplace storage not configured" }, { status: 503 }); + if (!redisConfigured()) return NextResponse.json({ error: "marketplace storage not configured" }, { status: 503 }); + const network = networkFromRequest(request); + if (!network) return NextResponse.json({ error: "network required" }, { status: 400 }); const { id } = await params; if (!/^[a-z0-9-]{1,40}$/i.test(id)) return NextResponse.json({ error: "invalid id" }, { status: 400 }); const body = (await request.json().catch(() => null)) as { ownerAddress?: string } | null; if (!body?.ownerAddress) return NextResponse.json({ error: "ownerAddress required" }, { status: 400 }); try { - const res = await call([["get", itemKey(id)]]); - const stored = (res.result as (string | null)[])[0]; - if (!stored) return NextResponse.json({ error: "not found" }, { status: 404 }); - const listing = JSON.parse(stored) as { ownerAddress?: string }; - if ( - !listing.ownerAddress || - listing.ownerAddress.toLowerCase() !== body.ownerAddress.toLowerCase() - ) { - return NextResponse.json({ error: "not the owner" }, { status: 403 }); - } - await call([ - ["del", itemKey(id)], - ["lrem", IDS_KEY, "1", id], - ]); + const result = await deleteMarketplaceListing(network, id, body.ownerAddress); + if (result === "not_found") return NextResponse.json({ error: "not found" }, { status: 404 }); + if (result === "forbidden") return NextResponse.json({ error: "not the owner" }, { status: 403 }); return NextResponse.json({ ok: true }); } catch { return NextResponse.json({ error: "storage unavailable" }, { status: 502 }); diff --git a/src/app/api/listings/route.ts b/src/app/api/listings/route.ts index 56e5a07..37883dd 100644 --- a/src/app/api/listings/route.ts +++ b/src/app/api/listings/route.ts @@ -1,40 +1,17 @@ import { NextResponse } from "next/server"; +import { + listMarketplaceListings, + networkFromRequest, + redisConfigured, + saveMarketplaceListing, +} from "@/lib/marketplaceRedis"; // Shared marketplace storage on Upstash Redis (REST, free tier). When the env // vars are absent the API serves an empty list and rejects writes: the app -// falls back to local listings plus the shipped demo seeds. +// falls back to local listings. Listings are stored per network (mainnet / sepolia). export const dynamic = "force-dynamic"; -const IDS_KEY = "gd:ids"; -const itemKey = (id: string) => `gd:l:${id}`; -const MAX_LISTINGS = 200; - -type RedisCall = (body: unknown) => Promise<{ result: unknown }>; - -function redis(): RedisCall | null { - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - if (!url || !token) return null; - return async (body) => { - const commands = Array.isArray(body) ? body : []; - const pipeline = commands.length > 0 && Array.isArray(commands[0]); - const endpoint = pipeline ? `${url.replace(/\/$/, "")}/pipeline` : url; - const res = await fetch(endpoint, { - method: "POST", - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify(body), - cache: "no-store", - }); - if (!res.ok) throw new Error(`redis ${res.status}`); - const json = await res.json(); - if (pipeline) { - return { result: (json as { result: unknown }[]).map((row) => row.result) }; - } - return json as { result: unknown }; - }; -} - function isFelt(value: unknown): value is string { return typeof value === "string" && /^0x[0-9a-fA-F]{1,64}$/.test(value); } @@ -67,35 +44,26 @@ function sanitize(raw: unknown): { id: string; title: string; price: string; tok }; } -export async function GET() { - const call = redis(); - if (!call) return NextResponse.json({ listings: [] }); +export async function GET(request: Request) { + const network = networkFromRequest(request); + if (!network) return NextResponse.json({ listings: [] }); + if (!redisConfigured()) return NextResponse.json({ listings: [] }); try { - const idsRes = await call([["lrange", IDS_KEY, "0", "-1"]]); - const ids = (idsRes.result as string[][])[0] ?? []; - if (ids.length === 0) return NextResponse.json({ listings: [] }); - const itemsRes = await call([["mget", ...ids.map(itemKey)]]); - const rows = ((itemsRes.result as (string | null)[][])[0] ?? []) - .filter((v): v is string => Boolean(v)) - .map((v) => JSON.parse(v)) - .filter(Boolean); - return NextResponse.json({ listings: rows }); + const listings = await listMarketplaceListings(network); + return NextResponse.json({ listings }); } catch { return NextResponse.json({ listings: [] }); } } export async function POST(request: Request) { - const call = redis(); - if (!call) return NextResponse.json({ error: "marketplace storage not configured" }, { status: 503 }); + if (!redisConfigured()) return NextResponse.json({ error: "marketplace storage not configured" }, { status: 503 }); + const network = networkFromRequest(request); + if (!network) return NextResponse.json({ error: "network required" }, { status: 400 }); const listing = sanitize(await request.json().catch(() => null)); if (!listing) return NextResponse.json({ error: "invalid listing" }, { status: 400 }); try { - await call([ - ["set", itemKey(listing.id), JSON.stringify({ ...listing, status: "open" })], - ["lpush", IDS_KEY, listing.id], - ["ltrim", IDS_KEY, "0", String(MAX_LISTINGS - 1)], - ]); + await saveMarketplaceListing(network, listing); return NextResponse.json({ ok: true }); } catch { return NextResponse.json({ error: "storage unavailable" }, { status: 502 }); diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png new file mode 100644 index 0000000..fb1259f Binary files /dev/null and b/src/app/apple-icon.png differ diff --git a/src/app/components/ghost/AppChrome.tsx b/src/app/components/ghost/AppChrome.tsx index 1060e9a..60e2d5f 100644 --- a/src/app/components/ghost/AppChrome.tsx +++ b/src/app/components/ghost/AppChrome.tsx @@ -1,10 +1,14 @@ "use client"; import Link from "next/link"; +import { useEffect } from "react"; import { usePathname } from "next/navigation"; import SelectWallet from "../client/WalletHandle/SelectWallet"; import ThemeToggle from "./ThemeToggle"; +import NetworkSelect from "./NetworkSelect"; import { useStoreWallet } from "../Wallet/walletContext"; +import { useFrontendProvider } from "../client/provider/providerContext"; +import { refreshRemoteListings } from "@/data/listingStore"; const GUEST_TABS = [{ href: "/", label: "Home" }]; @@ -18,8 +22,13 @@ const CONNECTED_TABS = [ export default function AppChrome({ children }: { children: React.ReactNode }) { const path = usePathname(); const isConnected = useStoreWallet((s) => s.isConnected); + const providerIndex = useFrontendProvider((s) => s.currentFrontendProviderIndex); const tabs = isConnected ? CONNECTED_TABS : GUEST_TABS; + useEffect(() => { + refreshRemoteListings(); + }, [providerIndex]); + return (
@@ -29,6 +38,7 @@ export default function AppChrome({ children }: { children: React.ReactNode }) {
+
diff --git a/src/app/components/ghost/NetworkSelect.tsx b/src/app/components/ghost/NetworkSelect.tsx new file mode 100644 index 0000000..55a4374 --- /dev/null +++ b/src/app/components/ghost/NetworkSelect.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useEffect } from "react"; +import { useFrontendProvider } from "@/app/components/client/provider/providerContext"; +import { useStoreWallet } from "@/app/components/Wallet/walletContext"; +import { + isMarketplaceNetwork, + marketplaceIndexFromNetwork, + marketplaceNetworkFromIndex, + type MarketplaceNetwork, +} from "@/lib/marketplaceNetwork"; + +const STORAGE_KEY = "ghostdeal-browse-network"; +const OPTIONS: { id: MarketplaceNetwork; label: string }[] = [ + { id: "mainnet", label: "Mainnet" }, + { id: "sepolia", label: "Sepolia" }, +]; + +export default function NetworkSelect() { + const isConnected = useStoreWallet((s) => s.isConnected); + const providerIndex = useFrontendProvider((s) => s.currentFrontendProviderIndex); + const setIndex = useFrontendProvider((s) => s.setCurrentFrontendProviderIndex); + const current = marketplaceNetworkFromIndex(providerIndex); + + useEffect(() => { + if (isConnected) return; + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (isMarketplaceNetwork(saved)) { + setIndex(marketplaceIndexFromNetwork(saved)); + } + } catch { + /* ignore quota */ + } + }, [isConnected, setIndex]); + + useEffect(() => { + if (!isConnected) return; + try { + localStorage.setItem(STORAGE_KEY, marketplaceNetworkFromIndex(providerIndex)); + } catch { + /* ignore quota */ + } + }, [isConnected, providerIndex]); + + function pick(network: MarketplaceNetwork) { + if (isConnected) return; + setIndex(marketplaceIndexFromNetwork(network)); + try { + localStorage.setItem(STORAGE_KEY, network); + } catch { + /* ignore quota */ + } + } + + return ( +
+ {OPTIONS.map((option) => ( + + ))} +
+ ); +} diff --git a/src/app/deals/page.tsx b/src/app/deals/page.tsx index 606d969..4cc302a 100644 --- a/src/app/deals/page.tsx +++ b/src/app/deals/page.tsx @@ -58,7 +58,7 @@ export default function DealsPage() { useEffect(() => { refresh(); return onListingsChanged(refresh); - }, [refresh]); + }, [refresh, providerIndex]); // A close on a listing I own is not auto-marked released: from the seller // side, my claim and the buyer's cancel are indistinguishable. diff --git a/src/app/ghostdeal.css b/src/app/ghostdeal.css index e9feaf0..f0fb35f 100644 --- a/src/app/ghostdeal.css +++ b/src/app/ghostdeal.css @@ -68,6 +68,7 @@ background: color-mix(in srgb, var(--gd-bg) 88%, transparent); border-bottom: 1px solid var(--gd-line); overflow: visible; + flex-wrap: wrap; } .gdBrand { @@ -100,10 +101,51 @@ flex-shrink: 0; } +.gdWallet { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + .gdWallet > button { min-height: 44px; } +.gdNet { + display: flex; + align-items: stretch; + height: 44px; + border: 1px solid var(--gd-line); + border-radius: 999px; + overflow: hidden; + background: var(--gd-raised); + flex-shrink: 0; +} + +.gdNetBtn { + border: none; + background: transparent; + color: var(--gd-dim); + padding: 0 10px; + font: inherit; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + white-space: nowrap; +} + +.gdNetBtnOn { + background: var(--gd-cta); + color: var(--gd-cta-ink); +} + +.gdNetBtn:disabled { + cursor: default; + opacity: 1; +} + .gdMain { flex: 1; width: 100%; @@ -182,6 +224,29 @@ gap: 14px; } +.gdEmpty { + background: var(--gd-raised); + border: 1px dashed var(--gd-line); + border-radius: 22px; + padding: 28px 18px; + text-align: center; +} + +.gdEmptyTitle { + font-weight: 800; + font-size: 18px; + letter-spacing: -0.03em; + text-transform: uppercase; + margin-bottom: 8px; +} + +.gdEmptyLead { + color: var(--gd-dim); + font-size: 14px; + line-height: 1.5; + margin: 0; +} + .gdCard { display: block; background: var(--gd-raised); @@ -433,20 +498,35 @@ .gd { display: flex; justify-content: center; - align-items: stretch; - padding: 28px 16px; + align-items: center; + height: 100dvh; + max-height: 100dvh; + min-height: 0; + padding: 12px 16px; + overflow: hidden; + } + + .gd:has(.gdPickerOverlay) { + overflow: visible; } .gdShell { max-width: 430px; - min-height: calc(100dvh - 56px); - min-height: calc(100vh - 56px); + width: 100%; + height: 100%; + max-height: 100%; + min-height: 0; border: 1px solid var(--gd-line); border-radius: 28px; box-shadow: 0 40px 70px -30px rgba(0, 0, 0, 0.7); overflow: hidden; } + .gdMain { + min-height: 0; + overflow-y: auto; + } + .gdShell:has(.gdPickerOverlay) { overflow: visible; } diff --git a/src/app/icon.png b/src/app/icon.png index cc5e4e2..ad5db45 100644 Binary files a/src/app/icon.png and b/src/app/icon.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fe2888e..279eacf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -24,6 +24,10 @@ export const metadata: Metadata = { title: "GhostDeal", description: "Pay in person without showing your balance.", applicationName: "GhostDeal", + icons: { + icon: [{ url: "/icon.png", type: "image/png" }], + apple: [{ url: "/apple-icon.png", type: "image/png" }], + }, appleWebApp: { capable: true, title: "GhostDeal", diff --git a/src/app/listing/[id]/page.tsx b/src/app/listing/[id]/page.tsx index c0cf8fd..6905b99 100644 --- a/src/app/listing/[id]/page.tsx +++ b/src/app/listing/[id]/page.tsx @@ -8,7 +8,9 @@ import { allListings, isOwnedBy, onListingsChanged, refreshRemoteListings, remov import { TOKEN_ICON, type Listing } from "@/data/listings"; import PayModal from "@/app/components/ghost/PayModal"; import { deleteRemoteListing } from "@/lib/marketplace"; +import { marketplaceNetworkFromIndex } from "@/lib/marketplaceNetwork"; import { useStoreWallet } from "@/app/components/Wallet/walletContext"; +import { useFrontendProvider } from "@/app/components/client/provider/providerContext"; function ListingBody() { const router = useRouter(); @@ -16,6 +18,7 @@ function ListingBody() { const search = useSearchParams(); const isConnected = useStoreWallet((s) => s.isConnected); const address = useStoreWallet((s) => s.address); + const providerIndex = useFrontendProvider((s) => s.currentFrontendProviderIndex); const [listings, setListings] = useState([]); const listing = useMemo(() => listings.find((l) => l.id === params.id), [listings, params.id]); const [payOpen, setPayOpen] = useState(false); @@ -32,7 +35,7 @@ function ListingBody() { setOrigin(window.location.origin); refreshRemoteListings(); return onListingsChanged(refresh); - }, [search, params.id]); + }, [search, params.id, providerIndex]); useEffect(() => { if (buyer && search.get("pay") === "1") setPayOpen(true); @@ -55,7 +58,7 @@ function ListingBody() { if (!window.confirm("Remove this listing from this phone?")) return; // Also remove it from the shared marketplace when it went there. if (listing.ownerAddress && address) { - deleteRemoteListing(listing.id, address).catch(() => undefined); + deleteRemoteListing(listing.id, address, marketplaceNetworkFromIndex(providerIndex)).catch(() => undefined); } removeListing(listing.id); router.push("/"); diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 5c5596c..1c50e58 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -12,8 +12,8 @@ export default function manifest(): MetadataRoute.Manifest { background_color: "#0d0d0d", theme_color: "#0d0d0d", icons: [ - { src: "/tokens/strk20.png", sizes: "192x192", type: "image/png", purpose: "any" }, - { src: "/tokens/strk20.png", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, ], }; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 664d96c..49420dc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,11 @@ import { useEffect, useMemo, useState } from "react"; import ListingCard from "./components/ghost/ListingCard"; import { allListings, onListingsChanged, refreshRemoteListings } from "@/data/listingStore"; import { type ListingStatus } from "@/data/listings"; +import { useFrontendProvider } from "@/app/components/client/provider/providerContext"; +import { + marketplaceNetworkFromIndex, + marketplaceNetworkLabel, +} from "@/lib/marketplaceNetwork"; const FILTERS: { id: "all" | ListingStatus; label: string }[] = [ { id: "all", label: "All" }, @@ -14,12 +19,23 @@ const FILTERS: { id: "all" | ListingStatus; label: string }[] = [ export default function HomePage() { const [filter, setFilter] = useState<(typeof FILTERS)[number]["id"]>("all"); const [rows, setRows] = useState>([]); + const [loaded, setLoaded] = useState(false); + const providerIndex = useFrontendProvider((s) => s.currentFrontendProviderIndex); + const networkName = marketplaceNetworkLabel(marketplaceNetworkFromIndex(providerIndex)); useEffect(() => { + let cancelled = false; + setLoaded(false); const refresh = () => setRows(allListings()); refresh(); - refreshRemoteListings(); - return onListingsChanged(refresh); - }, []); + const off = onListingsChanged(refresh); + refreshRemoteListings().finally(() => { + if (!cancelled) setLoaded(true); + }); + return () => { + cancelled = true; + off(); + }; + }, [providerIndex]); const listings = useMemo(() => { if (filter === "all") return rows; return rows.filter((l) => l.status === filter); @@ -49,8 +65,17 @@ export default function HomePage() { ))}
- {listings.length === 0 ? ( -

No listings yet. Connect and publish from Sell.

+ {!loaded ? ( +

Loading listings…

+ ) : listings.length === 0 ? ( +
+

No listings yet

+

+ {rows.length === 0 + ? `Nothing for sale on ${networkName} yet. Connect and publish from Sell.` + : "Nothing in this filter. Try All or For sale."} +

+
) : ( listings.map((listing) => ( diff --git a/src/app/sell/page.tsx b/src/app/sell/page.tsx index 2d8aab8..d3e2d73 100644 --- a/src/app/sell/page.tsx +++ b/src/app/sell/page.tsx @@ -8,7 +8,9 @@ import { saveClaimSecret, getEscrowSecrets } from "@/data/escrowSecrets"; import { aliasFor } from "@/data/accountStore"; import { commitmentHashFromSecret, randomFeltSecret } from "@/lib/escrow"; import { publishRemoteListing } from "@/lib/marketplace"; +import { marketplaceNetworkFromIndex } from "@/lib/marketplaceNetwork"; import { useStoreWallet } from "@/app/components/Wallet/walletContext"; +import { useFrontendProvider } from "@/app/components/client/provider/providerContext"; import ConnectGate from "@/app/components/ghost/ConnectGate"; function compressImage(file: File): Promise { @@ -50,6 +52,7 @@ function SellForm() { const editId = search.get("edit"); const isConnected = useStoreWallet((s) => s.isConnected); const address = useStoreWallet((s) => s.address); + const providerIndex = useFrontendProvider((s) => s.currentFrontendProviderIndex); const [title, setTitle] = useState(""); const [price, setPrice] = useState(""); const [token, setToken] = useState("STRK"); @@ -77,7 +80,7 @@ function SellForm() { setSeller(row.seller); setBlurb(row.blurb); setImage(row.image.startsWith("data:") ? row.image : row.image); - }, [address, editId]); + }, [address, editId, providerIndex]); if (!isConnected) { return ; @@ -131,7 +134,7 @@ function SellForm() { } // New listings go to the shared marketplace too; edits stay local. Best // effort: a storage failure must not lose the local publish. - if (!existing) publishRemoteListing(listing).catch(() => undefined); + if (!existing) publishRemoteListing(listing, marketplaceNetworkFromIndex(providerIndex)).catch(() => undefined); if (existingSecret) { router.push(`/listing/${id}`); return; diff --git a/src/data/listingStore.ts b/src/data/listingStore.ts index d9cccb5..dd18495 100644 --- a/src/data/listingStore.ts +++ b/src/data/listingStore.ts @@ -1,14 +1,37 @@ "use client"; import { type Listing } from "@/data/listings"; -import { SEED_LISTINGS } from "@/data/seedListings"; import { fetchRemoteListings } from "@/lib/marketplace"; +import { marketplaceNetworkFromIndex, type MarketplaceNetwork } from "@/lib/marketplaceNetwork"; import { sameAddress } from "@/data/accountStore"; +import { useFrontendProvider } from "@/app/components/client/provider/providerContext"; const KEY = "ghostdeal-extra-listings"; const HIDDEN_KEY = "ghostdeal-hidden-listings"; const CHANGE_EVENT = "ghostdeal-listings"; +function currentNetwork(): MarketplaceNetwork { + return marketplaceNetworkFromIndex(useFrontendProvider.getState().currentFrontendProviderIndex); +} + +function extrasKey(network: MarketplaceNetwork) { + return `${KEY}:${network}`; +} + +function hiddenKey(network: MarketplaceNetwork) { + return `${HIDDEN_KEY}:${network}`; +} + +function readJson(key: string, fallback: T): T { + if (typeof window === "undefined") return fallback; + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + function notifyListingsChanged() { if (typeof window === "undefined") return; window.dispatchEvent(new Event(CHANGE_EVENT)); @@ -24,60 +47,60 @@ export function onListingsChanged(handler: () => void) { } export function loadExtraListings(): Listing[] { - if (typeof window === "undefined") return []; - try { - const raw = localStorage.getItem(KEY); - return raw ? (JSON.parse(raw) as Listing[]) : []; - } catch { - return []; - } + const network = currentNetwork(); + const scoped = readJson(extrasKey(network), []); + if (network !== "sepolia") return scoped; + const legacy = readJson(KEY, []); + if (legacy.length === 0) return scoped; + const seen = new Set(scoped.map((row) => row.id)); + return [...scoped, ...legacy.filter((row) => !seen.has(row.id))]; } function loadHiddenIds(): string[] { - if (typeof window === "undefined") return []; - try { - const raw = localStorage.getItem(HIDDEN_KEY); - return raw ? (JSON.parse(raw) as string[]) : []; - } catch { - return []; - } + const network = currentNetwork(); + const scoped = readJson(hiddenKey(network), []); + if (network !== "sepolia") return scoped; + const legacy = readJson(HIDDEN_KEY, []); + return [...new Set([...scoped, ...legacy])]; } export function saveExtraListing(listing: Listing) { + const network = currentNetwork(); const next = [listing, ...loadExtraListings().filter((row) => row.id !== listing.id)]; - localStorage.setItem(KEY, JSON.stringify(next)); + localStorage.setItem(extrasKey(network), JSON.stringify(next)); notifyListingsChanged(); } export function removeListing(id: string) { + const network = currentNetwork(); const extras = loadExtraListings().filter((row) => row.id !== id); - localStorage.setItem(KEY, JSON.stringify(extras)); + localStorage.setItem(extrasKey(network), JSON.stringify(extras)); const hidden = new Set(loadHiddenIds()); hidden.add(id); - localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden])); + localStorage.setItem(hiddenKey(network), JSON.stringify([...hidden])); notifyListingsChanged(); } export function allListings(): Listing[] { + const network = currentNetwork(); const hidden = new Set(loadHiddenIds()); - // Local listings first (they carry payment state), then the shared - // marketplace, then the shipped demo seeds; hidden ids filter all of them. const local = loadExtraListings().filter((row) => !hidden.has(row.id)); const seen = new Set(local.map((row) => row.id)); - const remote = remoteCache.filter((row) => !hidden.has(row.id) && !seen.has(row.id)); - remote.forEach((row) => seen.add(row.id)); - const seeds = SEED_LISTINGS.filter((row) => !hidden.has(row.id) && !seen.has(row.id)); - return [...local, ...remote, ...seeds]; + const remote = (remoteCache[network] ?? []).filter((row) => !hidden.has(row.id) && !seen.has(row.id)); + return [...local, ...remote]; } -let remoteCache: Listing[] = []; +const remoteCache: Partial> = {}; // Pulls the shared marketplace into the cache. Failures keep the previous // cache: a storage hiccup must not blank the marketplace view. export async function refreshRemoteListings() { - const rows = await fetchRemoteListings(); - if (rows.length > 0 || remoteCache.length === 0) { - remoteCache = rows; + const network = currentNetwork(); + notifyListingsChanged(); + const rows = await fetchRemoteListings(network); + const prev = remoteCache[network] ?? []; + if (rows.length > 0 || prev.length === 0) { + remoteCache[network] = rows; notifyListingsChanged(); } } @@ -125,6 +148,6 @@ export function claimOrphanListings(address: string) { return row; }); if (!changed) return; - localStorage.setItem(KEY, JSON.stringify(next)); + localStorage.setItem(extrasKey(currentNetwork()), JSON.stringify(next)); notifyListingsChanged(); } diff --git a/src/data/seedListings.ts b/src/data/seedListings.ts index 94d2c75..3ddba85 100644 --- a/src/data/seedListings.ts +++ b/src/data/seedListings.ts @@ -1,41 +1,5 @@ import type { Listing } from "@/data/listings"; -// Demo listings shipped with the repo so any visitor sees a live marketplace -// with no backend. Their claimHashes are real: a payment locks on-chain like -// any other, and the GhostDeal team holds the preimages (never in this repo). -// Paying one is indistinguishable from paying a stranger's listing. -export const SEED_LISTINGS: Listing[] = [ - { - id: "seed-keyboard", - title: "Mechanical keyboard, tactile", - price: "3", - token: "STRK", - seller: "@ghost", - image: "/demo/keyboard.svg", - blurb: "Hot-swappable switches, PBT caps, used for one hackathon season. Hand it over in person and get paid in private.", - status: "open", - claimHash: "0x1ef94103a8a2999ae6f189b3453e24b2522daa553ca40dd72c03c8d2bb32453", - }, - { - id: "seed-coffee", - title: "Specialty coffee, 1 kg", - price: "1", - token: "STRK", - seller: "@ghost", - image: "/demo/coffee.svg", - blurb: "Washed Ethiopian, roasted this week. The cheapest end-to-end demo on the marketplace.", - status: "open", - claimHash: "0x4faab32bf6bd50bf71fd7ecef6947d3281ffc89aea745723bf0186b5d968b79", - }, - { - id: "seed-bike", - title: "City bike, tuned", - price: "15", - token: "STRK", - seller: "@ghost", - image: "/demo/bike.svg", - blurb: "Single speed, new brake pads, locks to any rack. Meet at the park, scan, pay, ride away.", - status: "open", - claimHash: "0x2b7f05a6f1272ee370a220584e6c3b2c274d2f602a0d18a489eed19ec4e0636", - }, -]; +export const SEED_LISTINGS: Listing[] = []; + + diff --git a/src/lib/marketplace.ts b/src/lib/marketplace.ts index 196b0f7..052707d 100644 --- a/src/lib/marketplace.ts +++ b/src/lib/marketplace.ts @@ -1,10 +1,11 @@ import type { Listing } from "@/data/listings"; +import type { MarketplaceNetwork } from "@/lib/marketplaceNetwork"; // Shared marketplace on the app's own API. Best-effort by design: any failure // degrades to the local-only view and never blocks publishing or paying. -export async function fetchRemoteListings(): Promise { +export async function fetchRemoteListings(network: MarketplaceNetwork): Promise { try { - const res = await fetch("/api/listings", { cache: "no-store" }); + const res = await fetch(`/api/listings?network=${network}`, { cache: "no-store" }); if (!res.ok) return []; const data = (await res.json()) as { listings?: Listing[] }; return Array.isArray(data.listings) ? data.listings : []; @@ -13,9 +14,9 @@ export async function fetchRemoteListings(): Promise { } } -export async function publishRemoteListing(listing: Listing): Promise { +export async function publishRemoteListing(listing: Listing, network: MarketplaceNetwork): Promise { try { - const res = await fetch("/api/listings", { + const res = await fetch(`/api/listings?network=${network}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(listing), @@ -26,9 +27,13 @@ export async function publishRemoteListing(listing: Listing): Promise { } } -export async function deleteRemoteListing(id: string, ownerAddress: string): Promise { +export async function deleteRemoteListing( + id: string, + ownerAddress: string, + network: MarketplaceNetwork, +): Promise { try { - const res = await fetch(`/api/listings/${id}`, { + const res = await fetch(`/api/listings/${id}?network=${network}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ownerAddress }), diff --git a/src/lib/marketplaceNetwork.ts b/src/lib/marketplaceNetwork.ts new file mode 100644 index 0000000..0d9e4b6 --- /dev/null +++ b/src/lib/marketplaceNetwork.ts @@ -0,0 +1,21 @@ +export type MarketplaceNetwork = "mainnet" | "sepolia"; + +export function isMarketplaceNetwork(value: unknown): value is MarketplaceNetwork { + return value === "mainnet" || value === "sepolia"; +} + +export function parseMarketplaceNetwork(value: string | null): MarketplaceNetwork | null { + return isMarketplaceNetwork(value) ? value : null; +} + +export function marketplaceNetworkFromIndex(index: number): MarketplaceNetwork { + return index === 0 ? "mainnet" : "sepolia"; +} + +export function marketplaceIndexFromNetwork(network: MarketplaceNetwork): number { + return network === "mainnet" ? 0 : 2; +} + +export function marketplaceNetworkLabel(network: MarketplaceNetwork): string { + return network === "mainnet" ? "Mainnet" : "Sepolia"; +} diff --git a/src/lib/marketplaceRedis.ts b/src/lib/marketplaceRedis.ts new file mode 100644 index 0000000..e5aa93a --- /dev/null +++ b/src/lib/marketplaceRedis.ts @@ -0,0 +1,111 @@ +import { parseMarketplaceNetwork, type MarketplaceNetwork } from "@/lib/marketplaceNetwork"; + +const MAX_LISTINGS = 200; +const LEGACY_IDS_KEY = "gd:ids"; +const legacyItemKey = (id: string) => `gd:l:${id}`; +const idsKey = (network: MarketplaceNetwork) => `gd:ids:${network}`; +const itemKey = (network: MarketplaceNetwork, id: string) => `gd:l:${network}:${id}`; + +type RedisCall = (body: unknown) => Promise<{ result: unknown }>; + +function redis(): RedisCall | null { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) return null; + return async (body) => { + const commands = Array.isArray(body) ? body : []; + const pipeline = commands.length > 0 && Array.isArray(commands[0]); + const endpoint = pipeline ? `${url.replace(/\/$/, "")}/pipeline` : url; + const res = await fetch(endpoint, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + cache: "no-store", + }); + if (!res.ok) throw new Error(`redis ${res.status}`); + const json = await res.json(); + if (pipeline) { + return { result: (json as { result: unknown }[]).map((row) => row.result) }; + } + return json as { result: unknown }; + }; +} + +export function redisConfigured(): boolean { + return redis() !== null; +} + +export function networkFromRequest(request: Request): MarketplaceNetwork | null { + return parseMarketplaceNetwork(new URL(request.url).searchParams.get("network")); +} + +async function listIds(call: RedisCall, key: string): Promise { + const idsRes = await call([["lrange", key, "0", "-1"]]); + return (idsRes.result as string[][])[0] ?? []; +} + +async function mgetJson(call: RedisCall, keys: string[]): Promise { + if (keys.length === 0) return []; + const itemsRes = await call([["mget", ...keys]]); + return ((itemsRes.result as (string | null)[][])[0] ?? []) + .filter((v): v is string => Boolean(v)) + .map((v) => JSON.parse(v)) + .filter(Boolean); +} + +export async function listMarketplaceListings(network: MarketplaceNetwork): Promise { + const call = redis(); + if (!call) return []; + const scopedIds = await listIds(call, idsKey(network)); + const scoped = await mgetJson(call, scopedIds.map((id) => itemKey(network, id))); + if (network !== "sepolia") return scoped; + const legacyIds = await listIds(call, LEGACY_IDS_KEY); + const seen = new Set(scopedIds); + const leftover = legacyIds.filter((id) => !seen.has(id)); + const legacy = await mgetJson(call, leftover.map(legacyItemKey)); + return [...scoped, ...legacy]; +} + +export async function saveMarketplaceListing( + network: MarketplaceNetwork, + listing: Record, +): Promise { + const call = redis(); + if (!call) throw new Error("marketplace storage not configured"); + await call([ + ["set", itemKey(network, String(listing.id)), JSON.stringify({ ...listing, status: "open" })], + ["lpush", idsKey(network), listing.id], + ["ltrim", idsKey(network), "0", String(MAX_LISTINGS - 1)], + ]); +} + +export async function deleteMarketplaceListing( + network: MarketplaceNetwork, + id: string, + ownerAddress: string, +): Promise<"ok" | "not_found" | "forbidden"> { + const call = redis(); + if (!call) throw new Error("marketplace storage not configured"); + const scopedRes = await call([["get", itemKey(network, id)]]); + let stored = (scopedRes.result as (string | null)[])[0]; + let usedLegacy = false; + if (!stored && network === "sepolia") { + const legacyRes = await call([["get", legacyItemKey(id)]]); + stored = (legacyRes.result as (string | null)[])[0]; + usedLegacy = Boolean(stored); + } + if (!stored) return "not_found"; + const listing = JSON.parse(stored) as { ownerAddress?: string }; + if (!listing.ownerAddress || listing.ownerAddress.toLowerCase() !== ownerAddress.toLowerCase()) { + return "forbidden"; + } + const commands: unknown[] = [ + ["del", itemKey(network, id)], + ["lrem", idsKey(network), "1", id], + ]; + if (usedLegacy || network === "sepolia") { + commands.push(["del", legacyItemKey(id)], ["lrem", LEGACY_IDS_KEY, "1", id]); + } + await call(commands); + return "ok"; +}