22 bite-sized tasks covering theming, mock data, wallet/modal state, shared components, and all five screens with responsive mobile/web layouts, per the approved design spec.
77 KiB
Volana Expo App UI Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Translate the HTML prototype (prototype/Volana.dc.html) into the Expo app (app/) — all pages, mock data, theming and responsive (mobile + web) layout — per the approved spec at docs/superpowers/specs/2026-07-02-expo-app-ui-design.md.
Architecture: Bottom-tab + stack navigation (Expo Router) on mobile, with a persistent top nav bar replacing the tab bar on wide/web viewports (md: breakpoint, ~768px). Two React Context providers (WalletProvider, ModalProvider) hold mock wallet state and the globally-triggered Wallet-Connect/Checkout BottomSheet overlays. All product/resolver/order data is static, taken verbatim from the prototype's embedded arrays. Styling uses HeroUI Native components with Tailwind/Uniwind className, with only color tokens overridden in global.css (radius is left at the HeroUI default, which already matches the prototype's 8–16px scale).
Tech Stack: Expo SDK 57, Expo Router 57 (typed routes), React 19, React Native 0.86, HeroUI Native 1.0.5, Uniwind (Tailwind v4 for RN), @expo/vector-icons (Ionicons).
Note on verification steps: This project has no test runner configured (package.json only has lint/typecheck/format scripts) and this feature is presentational/mock-data-only per the approved spec (no business logic to unit-test). So instead of TDD red/green steps, every task's verification step is yarn typecheck (and yarn lint at milestones), plus manual visual checks at the point a screen first becomes viewable. All commands below assume the working directory is app/ (i.e. /mnt/store/develop/projects/projd/volana/app).
File Overview
app/src/global.css — theme color overrides (Task 1)
app/src/data/mock.ts — Product/Resolver/Order types + mock data (Task 2)
app/src/lib/status.ts — status/availability → label+color helpers (Task 3)
app/src/state/wallet.tsx — WalletProvider/useWallet (Task 4)
app/src/state/modals.tsx — ModalProvider/useModals (Task 5)
app/src/app/_layout.tsx — providers, TopNav, global sheets (Tasks 6 & 15)
app/src/components/StatusChip.tsx — Task 7
app/src/components/ListingCard.tsx — Task 8
app/src/components/ResolverCard.tsx — Task 9
app/src/components/OrderRow.tsx — Task 10
app/src/components/WalletConnectSheet.tsx — Task 11
app/src/components/CheckoutSheet.tsx — Task 12
app/src/components/AppHeader.tsx — Task 13
app/src/components/TopNav.tsx — Task 14
app/src/app/(tabs)/index.tsx — Home screen (Task 16)
app/src/app/(tabs)/browse.tsx — Browse screen, replaces explore.tsx (Task 17)
app/src/app/(tabs)/orders.tsx — Orders list screen (Task 18)
app/src/app/(tabs)/_layout.tsx — tab bar, responsive hide (Task 19)
app/src/app/listing/[id].tsx — Listing detail screen (Task 20)
app/src/app/orders/[id].tsx — Order detail screen (Task 21)
Task 22 is a final typecheck/lint/manual-walkthrough pass.
Task 1: Theme color tokens
Files:
-
Modify:
app/src/global.css -
Step 1: Add prototype color overrides
Replace the full contents of app/src/global.css with:
@import "tailwindcss";
@import "uniwind";
@import "heroui-native/styles";
/* End-user path: heroui-native lives in the project's node_modules, one
level up from this src/global.css file. */
@source '../node_modules/heroui-native/lib';
@layer theme {
@variant light {
--background: #f7f6fb;
--surface: #ffffff;
--surface-secondary: #f0eef8;
--accent: #7a34d4;
--accent-foreground: #ffffff;
--success: #059669;
--warning: #d97706;
--danger: #dc2626;
}
@variant dark {
--background: #0e0e1c;
--surface: #171730;
--surface-secondary: #1e1e3c;
--accent: #b060ff;
--accent-foreground: #ffffff;
--success: #14f195;
--warning: #f5a623;
--danger: #ff4d4f;
}
}
These are the exact hex values from prototype/Volana.dc.html's :root/[data-theme="light"] CSS variables (--c-bg, --c-surf, --c-surf2, --c-a, --c-g, --c-y, --c-r). --radius is intentionally left untouched — HeroUI's default base radius is 8px, which already produces radius-lg=8px/radius-xl=12px/radius-2xl=16px, matching the prototype's 8–16px card/button radii closely enough that overriding it isn't needed.
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors (CSS changes don't affect TS, this just confirms the baseline is still healthy before further changes).
- Step 3: Commit
git add app/src/global.css
git commit -m "theme: apply Volana prototype accent/status/surface colors"
Task 2: Mock data
Files:
-
Create:
app/src/data/mock.ts -
Step 1: Create the mock data module
export type Currency = "SOL" | "USDC";
export interface Product {
id: number;
name: string;
desc: string;
price: string;
priceNum: number;
cur: Currency;
usd: string;
qty: number;
seller: string;
bg: string;
emoji: string;
alt: Currency[];
cat: string;
}
export interface Resolver {
id: number;
name: string;
desc: string;
fee: string;
type: "Human" | "DAO" | "Automated";
wins: number;
total: number;
}
export type OrderStatus = "AwaitingConfirm" | "Active" | "Complete" | "Cancelled" | "Disputed";
export interface Order {
id: number;
lid: number;
name: string;
amt: string;
status: OrderStatus;
date: string;
seller: string;
emoji: string;
}
export const PRODUCTS: Product[] = [
{ id: 0, name: 'MacBook Pro 14" M3 Pro', desc: "Factory sealed. 18GB Unified Memory, 512GB SSD, Space Black. International keyboard layout.", price: "2.4 SOL", priceNum: 2.4, cur: "SOL", usd: "$288", qty: 5, seller: "Gh9Z…mF8k", bg: "#0a1828", emoji: "💻", alt: ["USDC"], cat: "Electronics" },
{ id: 1, name: "Adobe Photoshop Lifetime Key", desc: "One-time activation code. Non-subscription. Valid for 2 devices on Windows and macOS.", price: "45 USDC", priceNum: 45, cur: "USDC", usd: "$45", qty: 12, seller: "Bx3K…7pRq", bg: "#001230", emoji: "🎨", alt: ["SOL"], cat: "Software" },
{ id: 2, name: 'Air Jordan 1 Retro "Shadow" US 10', desc: "Deadstock. Black/Particle Grey/White. Original box, tissue paper and receipt. Zero wears.", price: "180 USDC", priceNum: 180, cur: "USDC", usd: "$180", qty: 1, seller: "Qm4R…nL2w", bg: "#180800", emoji: "👟", alt: [], cat: "Fashion" },
{ id: 3, name: "Custom 65% Mechanical Keyboard", desc: "Lubed Gateron Yellow Pro V2 switches. POM plate, anodized aluminum case. USB-C cable included.", price: "0.9 SOL", priceNum: 0.9, cur: "SOL", usd: "$108", qty: 3, seller: "Kx7P…2mJc", bg: "#0a1810", emoji: "⌨️", alt: ["USDC"], cat: "Electronics" },
{ id: 4, name: "Midjourney Pro Plan (1 Year)", desc: "Full-year activation code. 30 fast GPU hours/month, unlimited relaxed, private and stealth mode.", price: "120 USDC", priceNum: 120, cur: "USDC", usd: "$120", qty: 8, seller: "Pp1L…sY9v", bg: "#120820", emoji: "🖼️", alt: [], cat: "Software" },
{ id: 5, name: "Sony WH-1000XM5 Headphones", desc: "Brand new, factory sealed. Midnight Black. 30-hour battery. Industry-leading noise cancellation.", price: "1.8 SOL", priceNum: 1.8, cur: "SOL", usd: "$216", qty: 0, seller: "Fx2T…oK5r", bg: "#0a0a14", emoji: "🎧", alt: [], cat: "Electronics" },
{ id: 6, name: 'iPad Pro 12.9" M2 (256GB Wi-Fi)', desc: "Silver. Opened for 2 hours of testing only. AppleCare+ until June 2026. Original box included.", price: "3.2 SOL", priceNum: 3.2, cur: "SOL", usd: "$384", qty: 2, seller: "Rv8W…eC1x", bg: "#081420", emoji: "📱", alt: ["USDC"], cat: "Electronics" },
{ id: 7, name: "Figma Professional (1 Year)", desc: "Team seat. Unlimited projects, 180-day version history, dev mode and advanced prototyping.", price: "60 USDC", priceNum: 60, cur: "USDC", usd: "$60", qty: 20, seller: "Mn6S…hB3z", bg: "#180a00", emoji: "✏️", alt: ["SOL"], cat: "Software" },
{ id: 8, name: "Supreme Box Logo Hoodie — M", desc: "FW23 White. Unworn, original tags attached. Comes in original Supreme bag. Firm price.", price: "220 USDC", priceNum: 220, cur: "USDC", usd: "$220", qty: 1, seller: "Yx5A…dN7u", bg: "#180008", emoji: "🧥", alt: [], cat: "Fashion" },
{ id: 9, name: "Nintendo Switch OLED (White)", desc: "Excellent condition. Original Joy-Con, dock, power adapter, HDMI cable. Screen is pristine.", price: "1.2 SOL", priceNum: 1.2, cur: "SOL", usd: "$144", qty: 4, seller: "Jc0M…pQ4y", bg: "#0e0d18", emoji: "🕹️", alt: ["USDC"], cat: "Electronics" },
{ id: 10, name: "myshop.sol Domain Name", desc: "Premium 3-year .sol domain registration. Currently parked. Instant transfer to your wallet on purchase.", price: "0.5 SOL", priceNum: 0.5, cur: "SOL", usd: "$60", qty: 1, seller: "Zg3F…wR6t", bg: "#0e0820", emoji: "🌐", alt: [], cat: "Crypto" },
{ id: 11, name: "Rolex Submariner 116610LN", desc: "Ref 116610LN, 2019. Full box and papers. Last serviced June 2024 by Rolex-authorized RSC.", price: "8.5 SOL", priceNum: 8.5, cur: "SOL", usd: "$1,020", qty: 1, seller: "Wh7N…iE2s", bg: "#081215", emoji: "⌚", alt: ["USDC"], cat: "Luxury" },
];
export const RESOLVERS: Resolver[] = [
{ id: 0, name: "ShieldArb", desc: "Human arbitrators with 24h response.", fee: "0.5%", type: "Human", wins: 94, total: 1247 },
{ id: 1, name: "FairDAO", desc: "Community-governed DAO for digital goods.", fee: "1.0%", type: "DAO", wins: 89, total: 823 },
{ id: 2, name: "QuickResolve", desc: "AI-assisted, resolves in under 2 hours.", fee: "1.5%", type: "Automated", wins: 96, total: 3102 },
];
export const ORDERS: Order[] = [
{ id: 0, lid: 0, name: 'MacBook Pro 14" M3 Pro', amt: "2.4 SOL", status: "AwaitingConfirm", date: "2 days ago", seller: "Gh9Z…mF8k", emoji: "💻" },
{ id: 1, lid: 1, name: "Adobe Photoshop Lifetime Key", amt: "45 USDC", status: "Active", date: "5 days ago", seller: "Bx3K…7pRq", emoji: "🎨" },
{ id: 2, lid: 7, name: "Figma Professional (1 Year)", amt: "60 USDC", status: "Complete", date: "12 days ago", seller: "Mn6S…hB3z", emoji: "✏️" },
];
This is a verbatim port of the _L, _R, _O arrays from prototype/Volana.dc.html (lines 835–859).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/data/mock.ts
git commit -m "feat: add mock product/resolver/order data from prototype"
Task 3: Status helpers
Files:
-
Create:
app/src/lib/status.ts -
Step 1: Create the status/availability helper module
import type { OrderStatus } from "@/data/mock";
export type StatusColor = "default" | "accent" | "success" | "warning" | "danger";
export interface StatusInfo {
label: string;
color: StatusColor;
}
const ORDER_STATUS_INFO: Record<OrderStatus, StatusInfo> = {
AwaitingConfirm: { label: "Awaiting Confirm", color: "warning" },
Active: { label: "Active", color: "accent" },
Complete: { label: "Complete", color: "success" },
Cancelled: { label: "Cancelled", color: "default" },
Disputed: { label: "Disputed", color: "danger" },
};
export function orderStatusInfo(status: OrderStatus): StatusInfo {
return ORDER_STATUS_INFO[status];
}
export function availabilityInfo(qty: number): StatusInfo {
if (qty === 0) return { label: "Out of Stock", color: "default" };
if (qty <= 3) return { label: `Only ${qty} left!`, color: "warning" };
return { label: `${qty} in stock`, color: "success" };
}
StatusColor intentionally matches HeroUI Native's Chip color prop union ('default'|'accent'|'success'|'warning'|'danger') exactly, so results can be passed straight through to Chip/StatusChip without conversion. This ports the _si/_av helper functions from prototype/Volana.dc.html (lines 869–884).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/lib/status.ts
git commit -m "feat: add order status and availability label/color helpers"
Task 4: WalletProvider
Files:
-
Create:
app/src/state/wallet.tsx -
Step 1: Create the mock wallet context
import type { JSX, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
export type WalletName = "phantom" | "solflare" | "backpack";
const WALLET_ADDRESSES: Record<WalletName, string> = {
phantom: "Gh9Z…k3mP",
solflare: "Bx3K…7pRq",
backpack: "Kx7P…2mJc",
};
interface WalletContextValue {
connected: boolean;
address: string | null;
connect: (wallet: WalletName) => void;
disconnect: () => void;
}
const WalletContext = createContext<WalletContextValue | null>(null);
export function WalletProvider({ children }: PropsWithChildren): JSX.Element {
const [address, setAddress] = useState<string | null>(null);
const connect = useCallback((wallet: WalletName) => {
setAddress(WALLET_ADDRESSES[wallet]);
}, []);
const disconnect = useCallback(() => {
setAddress(null);
}, []);
const value = useMemo<WalletContextValue>(
() => ({ connected: address !== null, address, connect, disconnect }),
[address, connect, disconnect],
);
return <WalletContext.Provider value={value}>{children}</WalletContext.Provider>;
}
export function useWallet(): WalletContextValue {
const ctx = useContext(WalletContext);
if (!ctx) throw new Error("useWallet must be used within a WalletProvider");
return ctx;
}
Mirrors the prototype's mock connectPhantom/connectSolflare/connectBackpack/disconnectWallet handlers (lines 1062–1082), minus the toast side effect (that's wired at the call site in Task 11).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/state/wallet.tsx
git commit -m "feat: add mock WalletProvider"
Task 5: ModalProvider
Files:
-
Create:
app/src/state/modals.tsx -
Step 1: Create the global modal/checkout context
import type { JSX, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { useWallet, type WalletName } from "@/state/wallet";
export type CheckoutStep = "review" | "pending" | "success";
interface ModalContextValue {
isWalletSheetOpen: boolean;
isCheckoutSheetOpen: boolean;
checkoutListingId: number | null;
checkoutStep: CheckoutStep;
requestBuy: (listingId: number) => void;
openWalletConnect: () => void;
connectWallet: (wallet: WalletName) => void;
closeWalletSheet: () => void;
closeCheckoutSheet: () => void;
confirmOrder: () => void;
}
const ModalContext = createContext<ModalContextValue | null>(null);
export function ModalProvider({ children }: PropsWithChildren): JSX.Element {
const { connected, connect } = useWallet();
const [isWalletSheetOpen, setWalletSheetOpen] = useState(false);
const [isCheckoutSheetOpen, setCheckoutSheetOpen] = useState(false);
const [checkoutListingId, setCheckoutListingId] = useState<number | null>(null);
const [checkoutStep, setCheckoutStep] = useState<CheckoutStep>("review");
const pendingListingId = useRef<number | null>(null);
const requestBuy = useCallback(
(listingId: number) => {
if (connected) {
setCheckoutListingId(listingId);
setCheckoutStep("review");
setCheckoutSheetOpen(true);
} else {
pendingListingId.current = listingId;
setWalletSheetOpen(true);
}
},
[connected],
);
const openWalletConnect = useCallback(() => {
pendingListingId.current = null;
setWalletSheetOpen(true);
}, []);
const connectWallet = useCallback(
(wallet: WalletName) => {
connect(wallet);
setWalletSheetOpen(false);
const listingId = pendingListingId.current;
pendingListingId.current = null;
if (listingId !== null) {
setCheckoutListingId(listingId);
setCheckoutStep("review");
setCheckoutSheetOpen(true);
}
},
[connect],
);
const closeWalletSheet = useCallback(() => {
setWalletSheetOpen(false);
pendingListingId.current = null;
}, []);
const closeCheckoutSheet = useCallback(() => {
setCheckoutSheetOpen(false);
}, []);
const confirmOrder = useCallback(() => {
setCheckoutStep("pending");
setTimeout(() => setCheckoutStep("success"), 2200);
}, []);
const value = useMemo<ModalContextValue>(
() => ({
isWalletSheetOpen,
isCheckoutSheetOpen,
checkoutListingId,
checkoutStep,
requestBuy,
openWalletConnect,
connectWallet,
closeWalletSheet,
closeCheckoutSheet,
confirmOrder,
}),
[
isWalletSheetOpen,
isCheckoutSheetOpen,
checkoutListingId,
checkoutStep,
requestBuy,
openWalletConnect,
connectWallet,
closeWalletSheet,
closeCheckoutSheet,
confirmOrder,
],
);
return <ModalContext.Provider value={value}>{children}</ModalContext.Provider>;
}
export function useModals(): ModalContextValue {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("useModals must be used within a ModalProvider");
return ctx;
}
requestBuy mirrors the prototype's clickBuy/onBuyNow logic (open wallet modal first if not connected, remembering the listing via pendingId, else go straight to checkout). confirmOrder mirrors confirmOrder (lines 1055–1058): flips to pending, then success after 2.2s.
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/state/modals.tsx
git commit -m "feat: add ModalProvider for wallet-connect and checkout sheets"
Task 6: Wire Wallet and Modal providers into the root layout
Files:
-
Modify:
app/src/app/_layout.tsx -
Step 1: Wrap the app in WalletProvider and ModalProvider
Replace the full contents of app/src/app/_layout.tsx with:
import type { JSX } from "react";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { HeroUINativeProvider } from "heroui-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "../global.css";
import { WalletProvider } from "@/state/wallet";
import { ModalProvider } from "@/state/modals";
export default function RootLayout(): JSX.Element {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<HeroUINativeProvider config={{ devInfo: { stylingPrinciples: false } }}>
<WalletProvider>
<ModalProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
</Stack>
</ModalProvider>
</WalletProvider>
<StatusBar style="auto" />
</HeroUINativeProvider>
</GestureHandlerRootView>
);
}
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
Run: yarn start (or yarn start --web), open the app.
Expected: app boots and shows the existing Home tab exactly as before (providers don't change behavior yet — nothing consumes them until later tasks). Stop the dev server after confirming.
- Step 3: Commit
git add app/src/app/_layout.tsx
git commit -m "feat: wire WalletProvider and ModalProvider into root layout"
Task 7: StatusChip component
Files:
-
Create:
app/src/components/StatusChip.tsx -
Step 1: Create the component
import type { JSX } from "react";
import { Chip } from "heroui-native";
import type { StatusColor } from "@/lib/status";
interface StatusChipProps {
label: string;
color: StatusColor;
}
export function StatusChip({ label, color }: StatusChipProps): JSX.Element {
return (
<Chip variant="soft" color={color} size="sm">
<Chip.Label>{label}</Chip.Label>
</Chip>
);
}
variant="soft" gives the tinted-background-plus-matching-text look the prototype uses for all its badges (e.g. background:var(--c-gd);border:1px solid var(--c-g);color:var(--c-g)).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/components/StatusChip.tsx
git commit -m "feat: add StatusChip component"
Task 8: ListingCard component
Files:
-
Create:
app/src/components/ListingCard.tsx -
Step 1: Create the component
import type { GestureResponderEvent } from "react-native";
import type { JSX } from "react";
import { Pressable, View } from "react-native";
import { useRouter } from "expo-router";
import { Button, Separator, Surface, Typography } from "heroui-native";
import type { Product } from "@/data/mock";
import { availabilityInfo } from "@/lib/status";
import { StatusChip } from "@/components/StatusChip";
import { useModals } from "@/state/modals";
interface ListingCardProps {
product: Product;
variant?: "list" | "grid";
}
export function ListingCard({ product, variant = "grid" }: ListingCardProps): JSX.Element {
const router = useRouter();
const { requestBuy } = useModals();
const availability = availabilityInfo(product.qty);
const isOut = product.qty === 0;
const openListing = () => router.push(`/listing/${product.id}`);
const handleBuyPress = (event: GestureResponderEvent) => {
event.stopPropagation();
requestBuy(product.id);
};
if (variant === "list") {
return (
<Pressable onPress={openListing}>
<Surface variant="default" className="flex-row items-stretch gap-0 overflow-hidden p-0">
<View
className="w-24 items-center justify-center md:w-36"
style={{ backgroundColor: product.bg }}
>
<Typography className="text-4xl">{product.emoji}</Typography>
</View>
<Separator orientation="vertical" />
<View className="w-24 justify-center gap-0.5 p-3 md:w-32">
<Typography type="h6" className="text-accent">
{product.priceNum}
</Typography>
<Typography type="body-xs" color="muted">
{product.cur}
</Typography>
</View>
<Separator orientation="vertical" />
<View className="flex-1 justify-center gap-1 p-3">
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
{product.name}
</Typography>
<Typography type="body-xs" color="muted" numberOfLines={2}>
{product.desc}
</Typography>
</View>
<Separator orientation="vertical" />
<View className="w-28 items-stretch justify-center gap-2 p-3">
<StatusChip label={availability.label} color={availability.color} />
<Button
size="sm"
variant={isOut ? "secondary" : "primary"}
isDisabled={isOut}
onPress={handleBuyPress}
>
<Button.Label>{isOut ? "Out of Stock" : "Buy Now"}</Button.Label>
</Button>
</View>
</Surface>
</Pressable>
);
}
return (
<Pressable onPress={openListing} className="w-1/2 p-1.5 md:w-1/3 lg:w-1/4">
<Surface variant="default" className="gap-0 overflow-hidden p-0">
<View
className="h-32 items-center justify-center"
style={{ backgroundColor: product.bg }}
>
<Typography className="text-4xl">{product.emoji}</Typography>
</View>
<View className="gap-1 p-2.5">
<Typography type="body-sm" weight="semibold" numberOfLines={2}>
{product.name}
</Typography>
<Typography type="body-xs" color="muted" numberOfLines={1}>
{product.seller}
</Typography>
<View className="mt-1 flex-row items-center justify-between gap-1">
<Typography type="body" weight="bold" className="text-accent">
{product.price}
</Typography>
<StatusChip label={availability.label} color={availability.color} />
</View>
<Button
size="sm"
variant={isOut ? "secondary" : "primary"}
isDisabled={isOut}
className="mt-2 w-full"
onPress={handleBuyPress}
>
<Button.Label>{isOut ? "Out of Stock" : "Buy Now"}</Button.Label>
</Button>
</View>
</Surface>
</Pressable>
);
}
grid variant carries its own responsive width (w-1/2 md:w-1/3 lg:w-1/4) so callers can just render a flex-row flex-wrap container and drop cards in — no CSS Grid needed (React Native doesn't support display: grid, even on the web build). list variant reproduces the prototype's image/price/name+desc/buy row (lines 353–392) using Separator for the vertical divider lines instead of manual border classes.
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/components/ListingCard.tsx
git commit -m "feat: add ListingCard component (list and grid variants)"
Task 9: ResolverCard component
Files:
-
Create:
app/src/components/ResolverCard.tsx -
Step 1: Create the component
import type { JSX } from "react";
import { Pressable, View } from "react-native";
import { Typography } from "heroui-native";
import type { Resolver } from "@/data/mock";
interface ResolverCardProps {
resolver: Resolver;
isSelected: boolean;
onSelect: () => void;
}
export function ResolverCard({ resolver, isSelected, onSelect }: ResolverCardProps): JSX.Element {
return (
<Pressable
onPress={onSelect}
className={`mb-1.5 rounded-xl border p-3 ${
isSelected ? "border-accent bg-accent/10" : "border-border bg-transparent"
}`}
>
<View className="flex-row items-center justify-between">
<Typography type="body-sm" weight="semibold" className={isSelected ? "text-accent" : undefined}>
{resolver.name}
</Typography>
<Typography type="body-sm" weight="semibold" className={isSelected ? "text-accent" : undefined}>
{resolver.fee}
</Typography>
</View>
<Typography type="body-xs" color="muted" className="mt-0.5">
{resolver.type} · {resolver.wins}% buyer-favorable
</Typography>
</Pressable>
);
}
Ports the prototype's selectable resolver rows (lines 514–522 / _fb selected-state styling).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/components/ResolverCard.tsx
git commit -m "feat: add ResolverCard component"
Task 10: OrderRow component
Files:
-
Create:
app/src/components/OrderRow.tsx -
Step 1: Create the component
import type { JSX } from "react";
import { Pressable, View } from "react-native";
import { useRouter } from "expo-router";
import { Typography } from "heroui-native";
import type { Order } from "@/data/mock";
import { orderStatusInfo } from "@/lib/status";
import { StatusChip } from "@/components/StatusChip";
interface OrderRowProps {
order: Order;
}
export function OrderRow({ order }: OrderRowProps): JSX.Element {
const router = useRouter();
const status = orderStatusInfo(order.status);
return (
<Pressable
onPress={() => router.push(`/orders/${order.id}`)}
className="flex-row items-center gap-3.5 rounded-xl border border-border bg-surface p-3.5"
>
<Typography className="text-2xl">{order.emoji}</Typography>
<View className="flex-1 gap-0.5">
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
{order.name}
</Typography>
<Typography type="body-xs" color="muted">
{order.seller}
</Typography>
</View>
<View className="items-end gap-0.5">
<Typography type="body-sm" weight="bold">
{order.amt}
</Typography>
<Typography type="body-xs" color="muted">
{order.date}
</Typography>
</View>
<StatusChip label={status.label} color={status.color} />
</Pressable>
);
}
Ports the prototype's order list row (lines 556–570).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/components/OrderRow.tsx
git commit -m "feat: add OrderRow component"
Task 11: WalletConnectSheet component
Files:
-
Create:
app/src/components/WalletConnectSheet.tsx -
Step 1: Create the component
import type { JSX } from "react";
import { View } from "react-native";
import { BottomSheet, Button, Typography, useToast } from "heroui-native";
import { useModals } from "@/state/modals";
import type { WalletName } from "@/state/wallet";
const WALLETS: { id: WalletName; label: string; description: string; emoji: string }[] = [
{ id: "phantom", label: "Phantom", description: "Most popular Solana wallet", emoji: "👻" },
{ id: "solflare", label: "Solflare", description: "Built for power users", emoji: "🌊" },
{ id: "backpack", label: "Backpack", description: "xNFT & multi-chain wallet", emoji: "🎒" },
];
export function WalletConnectSheet(): JSX.Element {
const { isWalletSheetOpen, closeWalletSheet, connectWallet } = useModals();
const { toast } = useToast();
const handleConnect = (wallet: WalletName, label: string) => {
connectWallet(wallet);
toast.show({ variant: "success", label: `${label} connected!` });
};
return (
<BottomSheet
isOpen={isWalletSheetOpen}
onOpenChange={(open) => {
if (!open) closeWalletSheet();
}}
>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<BottomSheet.Close />
<BottomSheet.Title>Connect Wallet</BottomSheet.Title>
<BottomSheet.Description>Choose your Solana wallet</BottomSheet.Description>
<View className="mt-5 gap-2">
{WALLETS.map((wallet) => (
<Button
key={wallet.id}
variant="secondary"
className="h-auto justify-start gap-3 px-4 py-3"
onPress={() => handleConnect(wallet.id, wallet.label)}
>
<Typography className="text-2xl">{wallet.emoji}</Typography>
<View className="flex-1 items-start">
<Button.Label>{wallet.label}</Button.Label>
<Typography type="body-xs" color="muted">
{wallet.description}
</Typography>
</View>
</Button>
))}
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
);
}
This is rendered once at the root (Task 15) and driven entirely by ModalProvider's isWalletSheetOpen/closeWalletSheet/connectWallet — no BottomSheet.Trigger needed, matching the documented fully-controlled isOpen/onOpenChange pattern. Ports the prototype's wallet modal (lines 656–698) and connectPhantom/connectSolflare/connectBackpack toasts (lines 1062–1082).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/components/WalletConnectSheet.tsx
git commit -m "feat: add WalletConnectSheet component"
Task 12: CheckoutSheet component
Files:
-
Create:
app/src/components/CheckoutSheet.tsx -
Step 1: Create the component
import type { JSX } from "react";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { useRouter } from "expo-router";
import { BottomSheet, Button, Spinner, Typography, useThemeColor } from "heroui-native";
import { PRODUCTS, RESOLVERS } from "@/data/mock";
import { useModals } from "@/state/modals";
import { ResolverCard } from "@/components/ResolverCard";
export function CheckoutSheet(): JSX.Element {
const router = useRouter();
const accentColor = useThemeColor("accent");
const { isCheckoutSheetOpen, checkoutListingId, checkoutStep, closeCheckoutSheet, confirmOrder } =
useModals();
const [resolverId, setResolverId] = useState(0);
const listing = useMemo(
() => PRODUCTS.find((product) => product.id === checkoutListingId) ?? PRODUCTS[0],
[checkoutListingId],
);
const handleViewOrders = () => {
closeCheckoutSheet();
router.push("/orders");
};
const handleContinueShopping = () => {
closeCheckoutSheet();
router.push("/browse");
};
return (
<BottomSheet
isOpen={isCheckoutSheetOpen}
onOpenChange={(open) => {
if (!open) closeCheckoutSheet();
}}
>
<BottomSheet.Portal>
<BottomSheet.Overlay isCloseOnPress={checkoutStep !== "pending"} />
<BottomSheet.Content>
{checkoutStep === "review" && (
<View className="gap-4">
<BottomSheet.Close />
<BottomSheet.Title>Place Order</BottomSheet.Title>
<View className="flex-row items-center gap-3 rounded-xl bg-surface-secondary p-3">
<View
className="size-11 items-center justify-center rounded-lg"
style={{ backgroundColor: listing.bg }}
>
<Typography className="text-xl">{listing.emoji}</Typography>
</View>
<View className="flex-1">
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
{listing.name}
</Typography>
<Typography type="body-xs" color="muted">
{listing.seller}
</Typography>
</View>
</View>
<View className="gap-1 rounded-xl bg-surface-secondary p-3.5">
<Typography type="body-xs" color="muted">
Payment
</Typography>
<View className="flex-row items-baseline gap-2">
<Typography type="h5">{listing.price}</Typography>
<Typography type="body-sm" color="muted">
≈ {listing.usd} USD
</Typography>
</View>
</View>
<View className="gap-2 rounded-xl bg-surface-secondary p-3.5">
<Typography type="body-xs" color="muted">
Dispute Protection
</Typography>
{RESOLVERS.map((item) => (
<ResolverCard
key={item.id}
resolver={item}
isSelected={item.id === resolverId}
onSelect={() => setResolverId(item.id)}
/>
))}
</View>
<View className="flex-row gap-2">
<Button variant="secondary" className="flex-1" onPress={closeCheckoutSheet}>
<Button.Label>Cancel</Button.Label>
</Button>
<Button className="flex-[2]" onPress={confirmOrder}>
<Button.Label>Confirm & Place Order →</Button.Label>
</Button>
</View>
</View>
)}
{checkoutStep === "pending" && (
<View className="items-center gap-4 py-10">
<Spinner size="lg" color={accentColor} />
<Typography type="h6">Confirming on Solana…</Typography>
<Typography type="body-sm" color="muted" align="center">
{"Approve the transaction in your wallet.\nThis takes a few seconds."}
</Typography>
</View>
)}
{checkoutStep === "success" && (
<View className="gap-4">
<View className="items-center gap-2">
<View className="size-14 items-center justify-center rounded-full bg-success/15">
<Typography className="text-2xl">✓</Typography>
</View>
<Typography type="h5">Order Placed!</Typography>
<Typography type="body-sm" color="muted" align="center">
{`${listing.price} is now held in secure escrow.\nThe seller has been notified.`}
</Typography>
</View>
<View className="flex-row gap-2">
<Button variant="secondary" className="flex-1" onPress={handleContinueShopping}>
<Button.Label>Continue Shopping</Button.Label>
</Button>
<Button className="flex-1" onPress={handleViewOrders}>
<Button.Label>View My Orders</Button.Label>
</Button>
</View>
</View>
)}
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
);
}
confirmOrder (from ModalProvider) drives the review → pending → success transition with the same 2.2s delay as the prototype (lines 1055–1058). The resolver picker reuses ResolverCard (Task 9). isCloseOnPress={checkoutStep !== "pending"} stops the user from dismissing the sheet mid-"transaction".
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/components/CheckoutSheet.tsx
git commit -m "feat: add CheckoutSheet component (review/pending/success)"
Task 13: AppHeader component
Files:
-
Create:
app/src/components/AppHeader.tsx -
Step 1: Create the component
import type { JSX } from "react";
import { Pressable, View } from "react-native";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { Typography, useThemeColor } from "heroui-native";
interface AppHeaderProps {
title: string;
}
export function AppHeader({ title }: AppHeaderProps): JSX.Element {
const router = useRouter();
const foreground = useThemeColor("foreground");
return (
<View className="flex-row items-center gap-2 px-4 py-3 md:hidden">
<Pressable onPress={() => router.back()} hitSlop={8} className="p-1">
<Ionicons name="chevron-back" size={22} color={foreground} />
</Pressable>
<Typography type="h6" numberOfLines={1} className="flex-1">
{title}
</Typography>
</View>
);
}
Used only on the mobile-pushed Listing Detail and Order Detail screens (Tasks 20–21), which set headerShown: false at the root Stack level and render this instead. It's hidden at md: because TopNav (Task 14) is visible there and provides navigation instead.
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/components/AppHeader.tsx
git commit -m "feat: add AppHeader component for mobile stack screens"
Task 14: TopNav component
Files:
-
Create:
app/src/components/TopNav.tsx -
Step 1: Create the component
import type { JSX } from "react";
import { useState } from "react";
import { Pressable, View } from "react-native";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { Button, SearchField, Typography, useThemeColor } from "heroui-native";
import { useWallet } from "@/state/wallet";
import { useModals } from "@/state/modals";
export function TopNav(): JSX.Element {
const router = useRouter();
const accentForeground = useThemeColor("accent-foreground");
const { connected, address, disconnect } = useWallet();
const { openWalletConnect } = useModals();
const [search, setSearch] = useState("");
return (
<View className="hidden h-16 flex-row items-center gap-4 border-b border-border bg-background px-6 md:flex">
<Pressable onPress={() => router.push("/")} className="flex-row items-center gap-2">
<View className="size-6 items-center justify-center rounded-md bg-accent">
<Ionicons name="list" size={14} color={accentForeground} />
</View>
<Typography type="h6">Volana</Typography>
</Pressable>
<View className="max-w-[320px] flex-1">
<SearchField value={search} onChange={setSearch}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input
placeholder="Search listings…"
onSubmitEditing={() => router.push("/browse")}
/>
</SearchField.Group>
</SearchField>
</View>
<View className="flex-1" />
<Button variant="ghost" size="sm" onPress={() => router.push("/browse")}>
<Button.Label>Browse</Button.Label>
</Button>
<Button variant="ghost" size="sm" onPress={() => router.push("/orders")}>
<Button.Label>My Orders</Button.Label>
</Button>
{connected ? (
<Button variant="secondary" size="sm" onPress={disconnect}>
<Button.Label>{address}</Button.Label>
</Button>
) : (
<Button size="sm" onPress={openWalletConnect}>
<Button.Label>Connect Wallet</Button.Label>
</Button>
)}
</View>
);
}
hidden md:flex keeps this invisible on mobile widths. The search field navigates to /browse on submit — it intentionally does not thread the query into Browse's own search state (Browse has its own independent search field, Task 17); wiring cross-screen search state isn't needed for a mock-data app and would add scope without a corresponding prototype requirement beyond "navigate to browse".
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add app/src/components/TopNav.tsx
git commit -m "feat: add TopNav component for wide/web layouts"
Task 15: Wire TopNav and global sheets into the root layout
Files:
-
Modify:
app/src/app/_layout.tsx -
Step 1: Render TopNav above the Stack, and the two sheets alongside it
Replace the full contents of app/src/app/_layout.tsx with:
import type { JSX } from "react";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { HeroUINativeProvider } from "heroui-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { View } from "react-native";
import "../global.css";
import { WalletProvider } from "@/state/wallet";
import { ModalProvider } from "@/state/modals";
import { TopNav } from "@/components/TopNav";
import { WalletConnectSheet } from "@/components/WalletConnectSheet";
import { CheckoutSheet } from "@/components/CheckoutSheet";
export default function RootLayout(): JSX.Element {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<HeroUINativeProvider config={{ devInfo: { stylingPrinciples: false } }}>
<WalletProvider>
<ModalProvider>
<View className="flex-1 bg-background">
<TopNav />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
</Stack>
</View>
<WalletConnectSheet />
<CheckoutSheet />
</ModalProvider>
</WalletProvider>
<StatusBar style="auto" />
</HeroUINativeProvider>
</GestureHandlerRootView>
);
}
TopNav sits above the Stack inside a flex-1 wrapper, so it persists across every route pushed onto that Stack (tab screens and, later, listing/[id]/orders/[id]). The two sheets are rendered once, outside the Stack, and are entirely state-driven by ModalProvider — any screen can call useModals().requestBuy(...) etc. to open them.
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
Run: yarn start --web, resize the browser window wide (≥768px) and narrow (<768px).
Expected: at narrow width, no top bar is visible (bottom tab bar still shows, from the existing (tabs) layout). At wide width, the Volana top bar (logo, search field, Browse/My Orders buttons, Connect Wallet button) appears above the content. Clicking "Connect Wallet" opens a bottom sheet listing Phantom/Solflare/Backpack; clicking one closes it and the nav button now shows the mock address. Stop the dev server after confirming.
- Step 3: Commit
git add app/src/app/_layout.tsx
git commit -m "feat: render TopNav and global wallet/checkout sheets in root layout"
Task 16: Home screen
Files:
-
Modify:
app/src/app/(tabs)/index.tsx -
Step 1: Replace the placeholder Home screen
Replace the full contents of app/src/app/(tabs)/index.tsx with:
import type { JSX } from "react";
import { ScrollView, View } from "react-native";
import { useRouter } from "expo-router";
import { Button, Typography } from "heroui-native";
import { PRODUCTS } from "@/data/mock";
import { ListingCard } from "@/components/ListingCard";
export default function HomeTab(): JSX.Element {
const router = useRouter();
const featured = PRODUCTS.filter((product) => product.qty > 0).slice(0, 4);
return (
<ScrollView className="flex-1 bg-background">
<View className="gap-4 px-5 pb-6 pt-8 md:items-center md:pt-14">
<Typography type="h2" className="md:text-center">
{"The marketplace\n"}
<Typography type="h2" className="text-accent">
nobody controls.
</Typography>
</Typography>
<Typography type="body-sm" color="muted" className="md:max-w-md md:text-center">
Buy and sell freely. Payments are final — no chargebacks, no frozen accounts.
</Typography>
<Button onPress={() => router.push("/browse")} className="self-start md:self-center">
<Button.Label>Browse Listings →</Button.Label>
</Button>
</View>
<View className="px-3.5 pb-10">
<Typography type="h5" className="px-1.5 pb-3">
Featured listings
</Typography>
<View className="flex-row flex-wrap">
{featured.map((product) => (
<ListingCard key={product.id} product={product} variant="grid" />
))}
</View>
</View>
</ScrollView>
);
}
This is the "Minimal" home-tab scope agreed in the spec: short hero + featured listings only (no stats/"why Volana"/"how it works" sections, no footer). Ports the prototype's hero headline (lines 125–126) and featured listings grid (lines 262–276), using the same "first 4 in-stock products" selection (prototype featuredProducts, lines 1099–1103).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
Run: yarn start --web, open the Home tab.
Expected: purple-accented headline "The marketplace / nobody controls.", subline, "Browse Listings →" button, and a 2-column (narrow) / 3–4-column (wide) grid of 4 featured product cards with emoji thumbnails, names, and prices. Tapping a card navigates to a (still-placeholder-behaving, since Task 20 isn't done yet — it will 404 or show a default Expo Router "unmatched route" screen) listing route — that's expected at this point. Stop the dev server after confirming the Home tab itself renders correctly.
- Step 3: Commit
git add "app/src/app/(tabs)/index.tsx"
git commit -m "feat: implement Home screen (hero + featured listings)"
Task 17: Browse screen
Files:
-
Delete:
app/src/app/(tabs)/explore.tsx -
Create:
app/src/app/(tabs)/browse.tsx -
Step 1: Remove the placeholder Explore screen
git rm "app/src/app/(tabs)/explore.tsx"
- Step 2: Create the Browse screen
import type { JSX } from "react";
import { useMemo, useState } from "react";
import { Pressable, ScrollView, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Chip, SearchField, Switch, Typography, useThemeColor } from "heroui-native";
import type { Currency, Product } from "@/data/mock";
import { PRODUCTS } from "@/data/mock";
import { ListingCard } from "@/components/ListingCard";
type CurrencyFilter = "all" | Currency;
type SortMode = "newest" | "priceLow" | "priceHigh";
type ViewMode = "list" | "grid";
export default function BrowseTab(): JSX.Element {
const [search, setSearch] = useState("");
const [currencyFilter, setCurrencyFilter] = useState<CurrencyFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("newest");
const [inStockOnly, setInStockOnly] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("list");
const mutedColor = useThemeColor("muted");
const accentForeground = useThemeColor("accent-foreground");
const products = useMemo<Product[]>(() => {
let result = [...PRODUCTS];
if (inStockOnly) result = result.filter((product) => product.qty > 0);
if (currencyFilter !== "all") result = result.filter((product) => product.cur === currencyFilter);
if (search.trim()) {
const query = search.trim().toLowerCase();
result = result.filter(
(product) =>
product.name.toLowerCase().includes(query) || product.desc.toLowerCase().includes(query),
);
}
if (sortMode === "priceLow") result.sort((a, b) => a.priceNum - b.priceNum);
else if (sortMode === "priceHigh") result.sort((a, b) => b.priceNum - a.priceNum);
return result;
}, [search, currencyFilter, sortMode, inStockOnly]);
return (
<View className="flex-1 bg-background">
<View className="gap-3 border-b border-border px-4 py-3">
<SearchField value={search} onChange={setSearch}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder="Search listings…" />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<View className="flex-row flex-wrap items-center gap-2">
<Typography type="body-xs" color="muted">
Currency
</Typography>
<Chip
size="sm"
variant={currencyFilter === "all" ? "primary" : "soft"}
color={currencyFilter === "all" ? "accent" : "default"}
onPress={() => setCurrencyFilter("all")}
>
<Chip.Label>All</Chip.Label>
</Chip>
<Chip
size="sm"
variant={currencyFilter === "SOL" ? "primary" : "soft"}
color={currencyFilter === "SOL" ? "accent" : "default"}
onPress={() => setCurrencyFilter("SOL")}
>
<Chip.Label>SOL</Chip.Label>
</Chip>
<Chip
size="sm"
variant={currencyFilter === "USDC" ? "primary" : "soft"}
color={currencyFilter === "USDC" ? "accent" : "default"}
onPress={() => setCurrencyFilter("USDC")}
>
<Chip.Label>USDC</Chip.Label>
</Chip>
<Typography type="body-xs" color="muted" className="ml-2">
Sort
</Typography>
<Chip
size="sm"
variant={sortMode === "newest" ? "primary" : "soft"}
color={sortMode === "newest" ? "accent" : "default"}
onPress={() => setSortMode("newest")}
>
<Chip.Label>Newest</Chip.Label>
</Chip>
<Chip
size="sm"
variant={sortMode === "priceLow" ? "primary" : "soft"}
color={sortMode === "priceLow" ? "accent" : "default"}
onPress={() => setSortMode("priceLow")}
>
<Chip.Label>Price ↑</Chip.Label>
</Chip>
<Chip
size="sm"
variant={sortMode === "priceHigh" ? "primary" : "soft"}
color={sortMode === "priceHigh" ? "accent" : "default"}
onPress={() => setSortMode("priceHigh")}
>
<Chip.Label>Price ↓</Chip.Label>
</Chip>
</View>
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-2">
<Switch isSelected={inStockOnly} onSelectedChange={setInStockOnly} />
<Typography type="body-xs" color="muted">
In stock only
</Typography>
</View>
<View className="flex-row gap-1 rounded-lg border border-border bg-surface p-1">
<Pressable
onPress={() => setViewMode("list")}
className={`h-7 w-8 items-center justify-center rounded-md ${
viewMode === "list" ? "bg-accent" : ""
}`}
>
<Ionicons
name="list"
size={14}
color={viewMode === "list" ? accentForeground : mutedColor}
/>
</Pressable>
<Pressable
onPress={() => setViewMode("grid")}
className={`h-7 w-8 items-center justify-center rounded-md ${
viewMode === "grid" ? "bg-accent" : ""
}`}
>
<Ionicons
name="grid"
size={14}
color={viewMode === "grid" ? accentForeground : mutedColor}
/>
</Pressable>
</View>
</View>
</View>
<ScrollView className="flex-1">
{viewMode === "list" ? (
<View className="gap-2.5 p-3.5">
{products.map((product) => (
<ListingCard key={product.id} product={product} variant="list" />
))}
</View>
) : (
<View className="flex-row flex-wrap p-2">
{products.map((product) => (
<ListingCard key={product.id} product={product} variant="grid" />
))}
</View>
)}
</ScrollView>
</View>
);
}
Ports the prototype's filter bar (lines 322–347: currency chips, sort chips, in-stock switch, list/grid toggle) and its filtering/sorting logic (renderVals, lines 898–914). Both view modes render via a plain ScrollView + .map() rather than FlatList, since ListingCard's grid variant already carries responsive width classes that are incompatible with FlatList's fixed numColumns — and at 12 mock items, FlatList's virtualization has no practical benefit anyway.
- Step 3: Verify
Run: yarn typecheck
Expected: passes with no errors (this also confirms the explore.tsx deletion didn't break any other import — nothing else references it).
Run: yarn start --web, navigate to the Browse tab (bottom tab bar narrow, or "Browse" link in TopNav wide).
Expected: search field, Currency (All/SOL/USDC) and Sort (Newest/Price↑/Price↓) chips that visibly highlight the active one, "In stock only" switch (defaults on — Sony headphones with qty 0 should be hidden), list/grid toggle that switches layout. Typing in the search field filters the list live. Stop the dev server after confirming.
- Step 4: Commit
git add "app/src/app/(tabs)/browse.tsx"
git commit -m "feat: implement Browse screen (search, filters, list/grid view)"
Task 18: Orders screen
Files:
-
Create:
app/src/app/(tabs)/orders.tsx -
Step 1: Create the Orders list screen
import type { JSX } from "react";
import { ScrollView, View } from "react-native";
import { Typography } from "heroui-native";
import { ORDERS } from "@/data/mock";
import { OrderRow } from "@/components/OrderRow";
export default function OrdersTab(): JSX.Element {
return (
<ScrollView className="flex-1 bg-background">
<View className="gap-2.5 p-4 md:mx-auto md:w-full md:max-w-2xl">
<Typography type="h4" className="mb-1">
My Orders
</Typography>
{ORDERS.map((order) => (
<OrderRow key={order.id} order={order} />
))}
</View>
</ScrollView>
);
}
Ports the prototype's My Orders page (lines 546–575), using the static ORDERS mock array (per the spec's "no dynamically added orders" decision — the prototype itself doesn't add purchased items to this list either).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
- Step 3: Commit
git add "app/src/app/(tabs)/orders.tsx"
git commit -m "feat: implement Orders list screen"
Task 19: Responsive tab bar with Browse and Orders tabs
Files:
-
Modify:
app/src/app/(tabs)/_layout.tsx -
Step 1: Register the Browse and Orders tabs, hide the tab bar at md: width
Replace the full contents of app/src/app/(tabs)/_layout.tsx with:
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
import type { ComponentProps, JSX } from "react";
import type { ColorValue } from "react-native";
import { useWindowDimensions } from "react-native";
type IoniconName = ComponentProps<typeof Ionicons>["name"];
function TabIcon({ name, color }: { name: IoniconName; color: ColorValue }): JSX.Element {
return <Ionicons name={name} size={24} color={color} />;
}
const WIDE_BREAKPOINT = 768;
export default function TabsLayout(): JSX.Element {
const { width } = useWindowDimensions();
const isWide = width >= WIDE_BREAKPOINT;
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: isWide ? { display: "none" } : undefined,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => <TabIcon name="home-outline" color={color} />,
}}
/>
<Tabs.Screen
name="browse"
options={{
title: "Browse",
tabBarIcon: ({ color }) => <TabIcon name="storefront-outline" color={color} />,
}}
/>
<Tabs.Screen
name="orders"
options={{
title: "Orders",
tabBarIcon: ({ color }) => <TabIcon name="receipt-outline" color={color} />,
}}
/>
</Tabs>
);
}
useWindowDimensions is used (rather than a Tailwind md: class) because tabBarStyle is a React Navigation option evaluated in JS, not a style on a component we can attach a className to directly. It re-renders on resize, so resizing a browser window across the breakpoint live-toggles the tab bar.
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
Run: yarn start --web.
Expected: bottom tab bar shows Home/Browse/Orders at narrow width; resizing the window to ≥768px hides the tab bar (TopNav's Browse/My Orders links take over). Stop the dev server after confirming.
- Step 3: Commit
git add "app/src/app/(tabs)/_layout.tsx"
git commit -m "feat: add Browse/Orders tabs, hide tab bar on wide viewports"
Task 20: Listing Detail screen
Files:
-
Create:
app/src/app/listing/[id].tsx -
Step 1: Create the Listing Detail screen
import type { JSX } from "react";
import type { ViewStyle } from "react-native";
import { useMemo, useState } from "react";
import { Platform, ScrollView, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { Button, Chip, Separator, Typography, useToast } from "heroui-native";
import type { Currency } from "@/data/mock";
import { PRODUCTS, RESOLVERS } from "@/data/mock";
import { availabilityInfo } from "@/lib/status";
import { AppHeader } from "@/components/AppHeader";
import { StatusChip } from "@/components/StatusChip";
import { ResolverCard } from "@/components/ResolverCard";
import { ListingCard } from "@/components/ListingCard";
import { useModals } from "@/state/modals";
// react-native-web supports CSS `position: sticky`, but React Native's own
// ViewStyle type doesn't include it — cast is required to use it web-only.
const stickyBoxStyle: ViewStyle | undefined =
Platform.OS === "web" ? ({ position: "sticky", top: 84 } as ViewStyle) : undefined;
export default function ListingDetailScreen(): JSX.Element {
const { id } = useLocalSearchParams<{ id: string }>();
const { toast } = useToast();
const { requestBuy } = useModals();
const [resolverId, setResolverId] = useState(0);
const listing = useMemo(
() => PRODUCTS.find((product) => product.id === Number(id)) ?? PRODUCTS[0],
[id],
);
const [currency, setCurrency] = useState<Currency>(listing.cur);
const availability = availabilityInfo(listing.qty);
const isOut = listing.qty === 0;
const relatedProducts = PRODUCTS.filter((p) => p.id !== listing.id && p.qty > 0).slice(0, 6);
return (
<ScrollView className="flex-1 bg-background">
<AppHeader title={listing.name} />
<View className="gap-6 p-4 md:flex-row md:items-start md:gap-8 md:p-8">
<View className="flex-1 gap-5">
<View
className="h-52 items-center justify-center rounded-2xl md:h-80"
style={{ backgroundColor: listing.bg }}
>
<Typography className="text-6xl">{listing.emoji}</Typography>
</View>
<View className="gap-2">
<Typography type="h4">{listing.name}</Typography>
<Typography type="body-sm" color="muted">
{listing.desc}
</Typography>
</View>
<View className="flex-row items-center gap-3 rounded-xl border border-border bg-surface p-3.5">
<View className="flex-1">
<Typography type="body-sm" weight="semibold">
Verified on Solana
</Typography>
<Typography type="body-xs" color="muted">
{listing.seller}
</Typography>
</View>
<Button size="sm" variant="ghost" onPress={() => toast.show("Address copied!")}>
<Button.Label>Copy</Button.Label>
</Button>
</View>
<View className="gap-3 rounded-xl border border-border bg-surface p-3.5">
<Typography type="body-xs" color="muted">
About this listing
</Typography>
<View className="flex-row flex-wrap gap-y-3">
<View className="w-1/2">
<Typography type="body-xs" color="muted">
Category
</Typography>
<Typography type="body-sm" weight="medium">
{listing.cat}
</Typography>
</View>
<View className="w-1/2">
<Typography type="body-xs" color="muted">
Available
</Typography>
<Typography type="body-sm" weight="medium">
{listing.qty} units
</Typography>
</View>
<View className="w-1/2">
<Typography type="body-xs" color="muted">
Currency
</Typography>
<Typography type="body-sm" weight="medium">
{listing.cur}
</Typography>
</View>
<View className="w-1/2">
<Typography type="body-xs" color="muted">
Listing ID
</Typography>
<Typography type="body-sm" color="muted">
{`#${listing.id}${listing.id}${listing.id}${listing.id}`}
</Typography>
</View>
</View>
</View>
{relatedProducts.length > 0 && (
<View className="gap-3">
<Typography type="h6">Customers also bought</Typography>
<View className="flex-row flex-wrap">
{relatedProducts.map((product) => (
<ListingCard key={product.id} product={product} variant="grid" />
))}
</View>
</View>
)}
</View>
<View className="gap-4 rounded-2xl border border-border bg-surface p-4 md:w-80" style={stickyBoxStyle}>
<View>
<Typography type="h3">{listing.price}</Typography>
<Typography type="body-sm" color="muted">
≈ {listing.usd} USD
</Typography>
<View className="mt-2 self-start">
<StatusChip label={availability.label} color={availability.color} />
</View>
</View>
<Separator />
{listing.alt.length > 0 && (
<>
<View className="gap-2">
<Typography type="body-xs" color="muted">
Pay with
</Typography>
<View className="flex-row gap-2">
<Chip
variant={currency === listing.cur ? "primary" : "soft"}
color={currency === listing.cur ? "accent" : "default"}
onPress={() => setCurrency(listing.cur)}
>
<Chip.Label>{listing.cur}</Chip.Label>
</Chip>
<Chip
variant={currency === listing.alt[0] ? "primary" : "soft"}
color={currency === listing.alt[0] ? "accent" : "default"}
onPress={() => setCurrency(listing.alt[0])}
>
<Chip.Label>{listing.alt[0]}</Chip.Label>
</Chip>
</View>
</View>
<Separator />
</>
)}
<View className="gap-2">
<Typography type="body-xs" color="muted">
Dispute Protection
</Typography>
{RESOLVERS.map((resolver) => (
<ResolverCard
key={resolver.id}
resolver={resolver}
isSelected={resolver.id === resolverId}
onSelect={() => setResolverId(resolver.id)}
/>
))}
<Typography type="body-xs" color="muted">
Fee only charged if dispute is adjudicated.
</Typography>
</View>
<Separator />
<Button
isDisabled={isOut}
variant={isOut ? "secondary" : "primary"}
onPress={() => requestBuy(listing.id)}
>
<Button.Label>{isOut ? "Out of Stock" : "Buy Now →"}</Button.Label>
</Button>
<View className="flex-row flex-wrap gap-1.5">
<Chip size="sm" variant="soft" color="success">
<Chip.Label>✓ Final Payment</Chip.Label>
</Chip>
<Chip size="sm" variant="soft" color="default">
<Chip.Label>🛡 Dispute Cover</Chip.Label>
</Chip>
<Chip size="sm" variant="soft" color="default">
<Chip.Label>⬡ On-chain</Chip.Label>
</Chip>
</View>
<Typography type="body-xs" color="muted" align="center">
Funds held in secure escrow until you confirm receipt.
</Typography>
</View>
</View>
</ScrollView>
);
}
Ports the prototype's listing detail page (lines 424–543): image, title/description, verified-seller row with copy button (shows a toast instead of touching a real clipboard — no on-chain/real wallet functionality exists to copy, per the spec's non-goals), info grid, related products, and the buy panel (price, currency switch, resolver picker, Buy Now, trust badges). requestBuy (from ModalProvider, Task 5) reproduces the prototype's "open wallet modal if disconnected, else open checkout" branch.
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
Run: yarn start --web, go to Browse, tap any product card.
Expected: listing detail renders with image placeholder, title/description, verified-seller row (Copy button shows a toast), info grid, buy panel with price/resolver picker/Buy Now button, trust badge chips. At narrow width the layout is a single stacked column with a back button (AppHeader) at the top; at ≥768px it's two columns with the buy panel on the right. Tapping "Buy Now →" while disconnected opens the Wallet Connect sheet; connecting a wallet then opens the Checkout sheet automatically with this listing pre-filled. Stop the dev server after confirming.
- Step 3: Commit
git add "app/src/app/listing/[id].tsx"
git commit -m "feat: implement Listing Detail screen"
Task 21: Order Detail screen
Files:
-
Create:
app/src/app/orders/[id].tsx -
Step 1: Create the Order Detail screen
import type { JSX } from "react";
import { useMemo, useState } from "react";
import { ScrollView, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Button, Typography, useToast } from "heroui-native";
import { ORDERS } from "@/data/mock";
import { orderStatusInfo } from "@/lib/status";
import { AppHeader } from "@/components/AppHeader";
import { StatusChip } from "@/components/StatusChip";
const STEP_LABELS = ["Payment\nHeld", "Seller\nConfirms", "You Confirm\nReceipt"] as const;
export default function OrderDetailScreen(): JSX.Element {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { toast } = useToast();
const order = useMemo(() => ORDERS.find((item) => item.id === Number(id)) ?? ORDERS[0], [id]);
const [status, setStatus] = useState(order.status);
const statusInfo = orderStatusInfo(status);
const step2Done = status !== "AwaitingConfirm";
const step3Done = status === "Complete";
const activeStepIndex = step3Done ? 2 : step2Done ? 1 : 0;
const canCancel = status === "AwaitingConfirm";
const canConfirm = status === "Active";
const handleCancel = () => {
setStatus("Cancelled");
toast.show({ variant: "success", label: "Order cancelled. Full refund initiated." });
};
const handleConfirmReceipt = () => {
setStatus("Complete");
toast.show({ variant: "success", label: "Receipt confirmed! Seller has been paid." });
};
return (
<ScrollView className="flex-1 bg-background">
<AppHeader title="Order Detail" />
<View className="gap-5 p-4 md:mx-auto md:w-full md:max-w-2xl md:p-8">
<View className="flex-row items-start justify-between gap-3 rounded-xl border border-border bg-surface p-4">
<View className="flex-1 gap-1">
<Typography type="body-xs" color="muted">
Order placed {order.date}
</Typography>
<Typography type="h6">{order.name}</Typography>
<Typography type="body-xs" color="muted">
{order.seller}
</Typography>
</View>
<View className="items-end gap-1.5">
<Typography type="h6">{order.amt}</Typography>
<StatusChip label={statusInfo.label} color={statusInfo.color} />
</View>
</View>
<View className="gap-4 rounded-xl border border-border bg-surface p-4">
<Typography type="body-xs" color="muted">
Order Progress
</Typography>
<View className="flex-row items-start">
{STEP_LABELS.map((label, index) => {
const done = index < activeStepIndex || (index === activeStepIndex && step3Done);
const active = index === activeStepIndex && !step3Done;
return (
<View key={label} className="flex-1 items-center gap-2">
<View
className={`size-7 items-center justify-center rounded-full ${
done ? "bg-success" : active ? "bg-accent" : "bg-surface-secondary"
}`}
>
<Typography type="body-xs" weight="bold" className={done ? "text-black" : "text-white"}>
{done ? "✓" : String(index + 1)}
</Typography>
</View>
<Typography
type="body-xs"
weight="semibold"
align="center"
color={done || active ? undefined : "muted"}
className={done ? "text-success" : active ? "text-accent" : undefined}
>
{label}
</Typography>
</View>
);
})}
</View>
</View>
<View className="gap-1 rounded-xl bg-surface-secondary p-4">
<Typography type="body-sm" color="muted">
<Typography type="body-sm" weight="semibold">
{order.amt}
</Typography>
{" is locked in a secure on-chain escrow. Funds are released to the seller only after you "}
confirm receipt — no third party can touch them.
</Typography>
</View>
<View className="flex-row flex-wrap gap-2.5">
{canCancel && (
<Button variant="danger-soft" onPress={handleCancel}>
<Button.Label>Cancel Order (Full Refund)</Button.Label>
</Button>
)}
{canConfirm && (
<Button onPress={handleConfirmReceipt}>
<Button.Label>Confirm Receipt — Release Payment</Button.Label>
</Button>
)}
<Button variant="secondary" onPress={() => router.push(`/listing/${order.lid}`)}>
<Button.Label>View Listing →</Button.Label>
</Button>
</View>
</View>
</ScrollView>
);
}
Ports the prototype's order detail page (lines 577–651): header, 3-step progress stepper with the same done/active color logic as _si/the inline od derivation (lines 918–943), escrow info box, and status-conditional actions (Cancel Order only when AwaitingConfirm, Confirm Receipt only when Active, View Listing always). Status changes are local useState (mirrors the prototype, which also only mutates its in-memory state and never persists — see spec's "orders stay static" decision for the list; here on the detail screen the single order's status is allowed to change locally so Cancel/Confirm visibly do something, exactly like the prototype's cancelOrder/confirmReceipt handlers).
- Step 2: Verify
Run: yarn typecheck
Expected: passes with no errors.
Run: yarn start --web, go to Orders, tap the "MacBook Pro" order (status AwaitingConfirm).
Expected: header with name/seller/amount/status chip, 3-step progress bar (step 1 green checkmark, step 2 highlighted, step 3 grey), escrow info text, "Cancel Order (Full Refund)" and "View Listing →" buttons (no "Confirm Receipt" button, since status is AwaitingConfirm not Active). Tapping "Cancel Order" shows a toast and flips the status chip/stepper to Cancelled. Stop the dev server after confirming.
- Step 3: Commit
git add "app/src/app/orders/[id].tsx"
git commit -m "feat: implement Order Detail screen"
Task 22: Final verification pass
Files: none (verification only)
- Step 1: Full typecheck and lint
Run: yarn typecheck
Expected: passes with no errors.
Run: yarn lint
Expected: passes with no errors (fix any issues found before proceeding — do not disable rules to silence them).
- Step 2: Manual walkthrough — mobile width
Run: yarn start --web, keep the browser window under 768px wide (or open the Expo Go / simulator build if available).
Walk through and confirm:
-
Bottom tab bar shows Home / Browse / Orders with icons.
-
Home: hero headline in the accent purple, "Browse Listings →" button navigates to Browse, featured grid shows 4 cards.
-
Browse: search filters live, currency/sort chips toggle correctly, "In stock only" switch hides the 0-qty Sony headphones when on, list/grid toggle switches layout.
-
Tapping a product card opens Listing Detail with a back button (top-left) that returns to Browse.
-
"Buy Now →" while disconnected opens Wallet Connect → picking a wallet closes it and opens Checkout automatically on the same listing.
-
Checkout: Confirm & Place Order → shows a spinner for ~2s → success screen → "View My Orders" navigates to the Orders tab.
-
Orders tab lists 3 mock orders with correct status chips; tapping one opens Order Detail with the correct progress stepper and status-appropriate action buttons.
-
Order Detail: Cancel/Confirm actions show a toast and update the status chip/stepper immediately.
-
Step 3: Manual walkthrough — wide/web width
Resize the browser window to ≥768px (or use a wide simulator/tablet).
Walk through and confirm:
-
Bottom tab bar is gone; a top nav bar (logo, search field, Browse, My Orders, wallet button) is visible and stays visible on every screen, including Listing Detail and Order Detail.
-
"Browse" / "My Orders" nav buttons navigate correctly; wallet button opens Wallet Connect / shows the connected mock address once connected.
-
Home hero is centered, featured grid shows more columns than at mobile width.
-
Browse grid shows more columns; list view rows are wider.
-
Listing Detail shows a two-column layout (content left, buy panel right); the buy panel visually stays in place while scrolling the left column (sticky).
-
No mobile-only
AppHeaderback bar is visible on Listing/Order Detail (TopNav is used for navigation instead). -
Step 4: Stop the dev server
No commit for this task — it's verification-only. If any issue was found and fixed during the walkthrough, amend the relevant earlier task's commit history is not required; just create one more commit with the fix:
git add -A
git commit -m "fix: address issues found during manual UI walkthrough"
(Only run this if fixes were actually needed — skip entirely if the walkthrough passed cleanly.)
Spec Coverage Check
| Spec section | Covered by |
|---|---|
| Theming (accent/status/surface colors, radius left default) | Task 1 |
| Mock data (products/resolvers/orders) | Task 2 |
| State (WalletProvider, ModalProvider incl. pending-listing-id flow, checkout step machine) | Tasks 4–5 |
| Navigation & Screens file layout | Tasks 6, 15–21 |
Responsive shell (TopNav vs bottom tabs, md: breakpoint) |
Tasks 14, 15, 19 |
| Grid layouts via flex-wrap, not CSS grid | Task 8 (ListingCard), used in Tasks 16, 17, 20 |
| Listing-detail two-column + sticky buy box on web | Task 20 |
| Home (minimal scope) | Task 16 |
| Browse (search/filter/sort/view toggle) | Task 17 |
| Listing Detail (info grid, related products, resolver/currency selection, trust badges) | Task 20 |
| Orders list | Task 18 |
| Order Detail (stepper, status actions) | Task 21 |
| Shared components (StatusChip, ListingCard, ResolverCard, OrderRow, WalletConnectSheet, CheckoutSheet, AppHeader, TopNav) | Tasks 7–14 |
| Verification approach (no test runner → typecheck/lint + manual walkthrough) | Every task's Verify step + Task 22 |