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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Binary file added public/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 7 additions & 3 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
55 changes: 11 additions & 44 deletions src/app/api/listings/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,28 @@
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
// for a marketplace demo.

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 });
Expand Down
66 changes: 17 additions & 49 deletions src/app/api/listings/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down Expand Up @@ -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 });
Expand Down
Binary file added src/app/apple-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions src/app/components/ghost/AppChrome.tsx
Original file line number Diff line number Diff line change
@@ -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" }];

Expand All @@ -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 (
<div className="gd">
<div className="gdShell">
Expand All @@ -29,6 +38,7 @@ export default function AppChrome({ children }: { children: React.ReactNode }) {
</Link>
<ThemeToggle />
<div className="gdWallet">
<NetworkSelect />
<SelectWallet variant="nav" />
</div>
</header>
Expand Down
77 changes: 77 additions & 0 deletions src/app/components/ghost/NetworkSelect.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="gdNet" role="group" aria-label="Marketplace network">
{OPTIONS.map((option) => (
<button
key={option.id}
type="button"
className={current === option.id ? "gdNetBtn gdNetBtnOn" : "gdNetBtn"}
onClick={() => pick(option.id)}
disabled={isConnected}
aria-pressed={current === option.id}
title={
isConnected
? "Network follows the connected wallet"
: `Show the ${option.label} marketplace`
}
>
{option.label}
</button>
))}
</div>
);
}
2 changes: 1 addition & 1 deletion src/app/deals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading