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.
2111 lines
77 KiB
Markdown
2111 lines
77 KiB
Markdown
# 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:
|
||
|
||
```css
|
||
@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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```ts
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```ts
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
git rm "app/src/app/(tabs)/explore.tsx"
|
||
```
|
||
|
||
- [ ] **Step 2: Create the Browse screen**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```bash
|
||
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 `AppHeader` back 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:
|
||
|
||
```bash
|
||
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 |
|