Compare commits
11 Commits
a08e4943fc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0925649d25 | ||
|
|
9080e6fbc6 | ||
|
|
45f612c990 | ||
|
|
7416e97560 | ||
|
|
1b2f1ec0b4 | ||
|
|
20f6418ab2 | ||
|
|
4e44bf8bbf | ||
|
|
21acab57c6 | ||
|
|
7d6c779711 | ||
|
|
a04555637d | ||
|
|
cf89864444 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
# heroui-agents-md
|
||||
.heroui-docs/
|
||||
CLAUDE.md
|
||||
.yarn
|
||||
.yarn
|
||||
docs/superpowers
|
||||
133
README.md
Normal file
133
README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Volana
|
||||
|
||||
> **Trade freely. Pay finally.** A decentralized, censorship-resistant marketplace built on Solana.
|
||||
|
||||
Volana is what eBay would look like if it were rebuilt for the open web: a consumer marketplace where listings live on a public blockchain, payments settle through on-chain escrow, and no company, government, or payment processor can remove a listing, reverse a payment, or freeze funds. It feels like a modern shopping app. The blockchain stays invisible.
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Vision
|
||||
|
||||
Online commerce today runs on platforms that can change the rules at any moment: listings get delisted, payments get charged back months after a sale, and 10–15% of every transaction disappears into platform and processor fees. Volana's premise is that a marketplace can be **a protocol instead of a platform**:
|
||||
|
||||
- 🛡️ **Censorship-resistant by design.** Listings and orders are Solana accounts governed entirely by open-source smart contracts. Even if the Volana frontend went offline, anyone could spin up a new one that reads the same on-chain data and trading would continue.
|
||||
- ✅ **Final, predictable payments.** No chargebacks, no surprise reversals. Funds are locked in an on-chain escrow when the buyer orders and released when they confirm receipt. The entire payment flow is encoded in auditable contracts, so you can read exactly what will happen before you sign anything.
|
||||
- 🪙 **Near-zero fees.** No percentage cut. The only costs are Solana network fees (< $0.001 per transaction) and an optional dispute-mediator fee (typically 0.5–2%, charged **only** if a dispute is actually adjudicated). Compare: eBay takes 10–13%, PayPal another 3–4%.
|
||||
- ⚖️ **Permissionless dispute resolution.** If something goes wrong, a neutral resolver (chosen by the buyer at checkout from a list the seller accepts) rules on the case, and the ruling is enforced on-chain. Neither party can override it.
|
||||
- 🔑 **No accounts, no KYC.** Your Solana wallet *is* your account. Connect Phantom, Solflare, or Backpack and start trading.
|
||||
|
||||
**The target user is not a crypto native.** Volana is designed for regular online shoppers who are used to eBay and Amazon. The UX hides lamports, PDAs, and signatures behind plain language like "Confirm your purchase" and "Secure payment hold".
|
||||
|
||||
## 💡 How It Works
|
||||
|
||||
1. 🛒 **Browse & pick**: find something you want to buy.
|
||||
2. 🔒 **Place order**: connect your wallet and confirm. Your payment is locked in a secure escrow that nobody can tamper with.
|
||||
3. 📦 **Receive & confirm**: when the goods arrive, confirm receipt and the payment is released to the seller automatically.
|
||||
4. ⚖️ **Protected throughout**: if something goes wrong, your chosen dispute mediator steps in; their ruling is final and enforced on-chain.
|
||||
|
||||
Under the hood, every order drives an escrow state machine:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> AwaitingSellerConfirm : buyer locks funds
|
||||
AwaitingSellerConfirm --> Active : seller confirms
|
||||
AwaitingSellerConfirm --> Cancelled : buyer cancels (full refund)
|
||||
Active --> Complete : buyer confirms receipt → seller paid
|
||||
Active --> Disputed : buyer raises dispute
|
||||
Disputed --> Complete : resolver rules for seller
|
||||
Disputed --> Cancelled : resolver rules for buyer (refund)
|
||||
Complete --> [*]
|
||||
Cancelled --> [*]
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
Volana is split into an on-chain protocol layer and a consumer frontend:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Volana App (this repo) │
|
||||
│ Expo / React Native — iOS · Android · Web │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Wallet adapter · RPC data fetching │
|
||||
├──────────────────────────┬──────────────────────────┤
|
||||
│ solisting program │ descro program │
|
||||
│ listings & orders │ escrow state machine │
|
||||
│ (ListingAccount, │ (EscrowAccount, │
|
||||
│ OrderAccount, │ dispute resolution) │
|
||||
│ ResolverEntry) │ │
|
||||
├──────────────────────────┴──────────────────────────┤
|
||||
│ Solana · Pyth price oracle │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **`solisting`** is the marketplace program: listings (name, price, canonical + alternative currencies, stock, accepted resolvers), orders, and the resolver registry. Multi-currency prices are converted at order time via the Pyth oracle with buyer-set slippage protection.
|
||||
- **`descro`** is the escrow program: one escrow account per order, implementing the state machine above. Funds move from the buyer's wallet into an escrow vault atomically when the order is created.
|
||||
- **The frontend** is a universal Expo app (one codebase for iOS, Android, and web) that reads both programs and renders them as a familiar shopping experience.
|
||||
|
||||
### 📱 Frontend Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Framework | [Expo SDK 57](https://expo.dev) + React Native 0.86 + React 19 |
|
||||
| Routing | [Expo Router](https://docs.expo.dev/router/introduction/) (file-based, typed routes) |
|
||||
| UI kit | [HeroUI Native](https://heroui.com) |
|
||||
| Styling | [Uniwind](https://docs.uniwind.dev), Tailwind CSS for React Native |
|
||||
| Animation | React Native Reanimated 4 |
|
||||
| Language | TypeScript (strict mode) |
|
||||
|
||||
The app is **responsive by design**: a bottom tab bar on mobile that hands off to a persistent top navigation on desktop-class widths, and checkout that renders as a bottom sheet on mobile.
|
||||
|
||||
### 📂 Repository Layout
|
||||
|
||||
```
|
||||
├── app/ # The consumer app (Expo / React Native)
|
||||
│ └── src/
|
||||
│ ├── app/ # File-based routes: browse, listing/[id], orders/[id]
|
||||
│ ├── components/ # ListingCard, CheckoutSheet, WalletConnectSheet, …
|
||||
│ ├── state/ # Wallet & modal-flow contexts
|
||||
│ ├── data/ # Typed data layer (currently mocked)
|
||||
│ └── lib/ # Status → visual mappings, typography
|
||||
├── prototype/ # Static HTML/JS design prototype the app is built from
|
||||
└── docs/ # Product & design brief (data model, UX spec, copy rules)
|
||||
```
|
||||
|
||||
## 🎨 Design Principles
|
||||
|
||||
- 🫥 **Hide the blockchain.** Wallet addresses are abbreviated, prices are human-readable ("1.5 SOL", never lamports), and escrow states are explained in plain English. The only visible blockchain interaction is the wallet's confirm popup.
|
||||
- 🔍 **Transparency for those who want it.** Optional "view on explorer" links and technical detail sections expose the on-chain reality without foregrounding it.
|
||||
- ⚡ **Conversion-first UX.** One-step checkout, sticky Buy Now CTA, urgency signals ("Only 2 left!"), and skeleton loading states so RPC latency never shows a blank screen.
|
||||
- ♿ **Accessible.** Status indicators never rely on color alone, full keyboard navigability, WCAG AA contrast.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
```bash
|
||||
cd app
|
||||
yarn install
|
||||
npx expo start # then press i (iOS), a (Android), or w (web)
|
||||
```
|
||||
|
||||
Useful scripts (run from `app/`):
|
||||
|
||||
```bash
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint
|
||||
npm run format:check # prettier
|
||||
```
|
||||
|
||||
## 🗺️ Status & Roadmap
|
||||
|
||||
The consumer frontend is in active development, built screen-by-screen from the design prototype. On-chain data (listings, orders, resolvers, wallet connection) is currently **mocked** behind a typed data layer, so the real Solana integration can be swapped in at a single seam.
|
||||
|
||||
- [x] Browse / product grid with availability states
|
||||
- [x] Listing detail with resolver selection & trust signals
|
||||
- [x] Wallet-connect → checkout flow (connect resumes checkout automatically)
|
||||
- [x] My Orders + order detail with escrow progress
|
||||
- [ ] Real wallet adapter integration
|
||||
- [ ] Live RPC data via the on-chain programs
|
||||
- [ ] SPL token payments (USDC, …), which the contract layer already supports
|
||||
- [ ] Seller dashboard & dispute resolution UI
|
||||
|
||||
---
|
||||
|
||||
📖 For the full product specification (data model, page-by-page UX intent, and copy guidelines) see [docs/volana-design-brief.md](docs/volana-design-brief.md).
|
||||
@@ -6,6 +6,8 @@ import { Chip, SearchField, Switch, Typography, useThemeColor } from "heroui-nat
|
||||
import type { Currency, Product } from "@/data/mock";
|
||||
import { PRODUCTS } from "@/data/mock";
|
||||
import { ListingCard } from "@/components/ListingCard";
|
||||
import { PageContainer } from "@/components/PageContainer";
|
||||
import { MICRO_LABEL_CLASS } from "@/lib/typography";
|
||||
|
||||
type CurrencyFilter = "all" | Currency;
|
||||
type SortMode = "newest" | "priceLow" | "priceHigh";
|
||||
@@ -23,12 +25,13 @@ export default function BrowseTab(): JSX.Element {
|
||||
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 (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),
|
||||
product.name.toLowerCase().includes(query) || product.desc.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
if (sortMode === "priceLow") result.sort((a, b) => a.priceNum - b.priceNum);
|
||||
@@ -38,123 +41,123 @@ export default function BrowseTab(): JSX.Element {
|
||||
|
||||
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="border-b border-border">
|
||||
<PageContainer className="gap-3 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" : ""
|
||||
}`}
|
||||
<View className="flex-row flex-wrap items-center gap-2">
|
||||
<Typography className={MICRO_LABEL_CLASS}>Currency</Typography>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant={currencyFilter === "all" ? "primary" : "soft"}
|
||||
color={currencyFilter === "all" ? "accent" : "default"}
|
||||
onPress={() => setCurrencyFilter("all")}
|
||||
>
|
||||
<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" : ""
|
||||
}`}
|
||||
<Chip.Label>All</Chip.Label>
|
||||
</Chip>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant={currencyFilter === "SOL" ? "primary" : "soft"}
|
||||
color={currencyFilter === "SOL" ? "accent" : "default"}
|
||||
onPress={() => setCurrencyFilter("SOL")}
|
||||
>
|
||||
<Ionicons
|
||||
name="grid"
|
||||
size={14}
|
||||
color={viewMode === "grid" ? accentForeground : mutedColor}
|
||||
/>
|
||||
</Pressable>
|
||||
<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 className={`${MICRO_LABEL_CLASS} 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>
|
||||
|
||||
<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>
|
||||
</PageContainer>
|
||||
</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>
|
||||
)}
|
||||
<PageContainer className="py-3.5">
|
||||
{viewMode === "list" ? (
|
||||
<View className="gap-2.5">
|
||||
{products.map((product) => (
|
||||
<ListingCard key={product.id} product={product} variant="list" />
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row flex-wrap gap-5 lg:gap-6">
|
||||
{products.map((product) => (
|
||||
<ListingCard key={product.id} product={product} variant="grid" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</PageContainer>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "expo-router";
|
||||
import { Button, Typography } from "heroui-native";
|
||||
import { PRODUCTS } from "@/data/mock";
|
||||
import { ListingCard } from "@/components/ListingCard";
|
||||
import { PageContainer } from "@/components/PageContainer";
|
||||
|
||||
export default function HomeTab(): JSX.Element {
|
||||
const router = useRouter();
|
||||
@@ -11,31 +12,31 @@ export default function HomeTab(): JSX.Element {
|
||||
|
||||
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.
|
||||
<PageContainer className="gap-10 py-8 lg:py-14">
|
||||
<View className="gap-4 md:items-center">
|
||||
<Typography type="h2" className="md:text-center">
|
||||
{"The marketplace\n"}
|
||||
<Typography type="h2" className="text-accent">
|
||||
nobody controls.
|
||||
</Typography>
|
||||
</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" />
|
||||
))}
|
||||
<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>
|
||||
|
||||
<View className="gap-3">
|
||||
<Typography type="h5">Featured listings</Typography>
|
||||
<View className="flex-row flex-wrap gap-5 lg:gap-6">
|
||||
{featured.map((product) => (
|
||||
<ListingCard key={product.id} product={product} variant="grid" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</PageContainer>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { JSX } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { ScrollView } from "react-native";
|
||||
import { Typography } from "heroui-native";
|
||||
import { ORDERS } from "@/data/mock";
|
||||
import { OrderRow } from "@/components/OrderRow";
|
||||
import { PageContainer } from "@/components/PageContainer";
|
||||
|
||||
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">
|
||||
<PageContainer className="gap-2.5 py-4">
|
||||
<Typography type="h4" className="mb-1">
|
||||
My Orders
|
||||
</Typography>
|
||||
{ORDERS.map((order) => (
|
||||
<OrderRow key={order.id} order={order} />
|
||||
))}
|
||||
</View>
|
||||
</PageContainer>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,60 @@
|
||||
import type { JSX } from "react";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Platform, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { Button, Chip, Separator, Typography, useToast } from "heroui-native";
|
||||
import { Button, Chip, Separator, Typography, useThemeColor, useToast } from "heroui-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Svg, { Defs, LinearGradient, RadialGradient, Rect, Stop } from "react-native-svg";
|
||||
import type { Currency } from "@/data/mock";
|
||||
import { PRODUCTS, RESOLVERS } from "@/data/mock";
|
||||
import { availabilityInfo } from "@/lib/status";
|
||||
import { MICRO_LABEL_CLASS } from "@/lib/typography";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { StatusChip } from "@/components/StatusChip";
|
||||
import { ResolverCard } from "@/components/ResolverCard";
|
||||
import { ListingCard } from "@/components/ListingCard";
|
||||
import { MonoText } from "@/components/MonoText";
|
||||
import { PageContainer } from "@/components/PageContainer";
|
||||
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;
|
||||
const STICKY_BREAKPOINT = 1024;
|
||||
|
||||
function HeroGradientOverlay({ gradientId }: { gradientId: string }): JSX.Element {
|
||||
const highlightId = `${gradientId}-highlight`;
|
||||
const fadeId = `${gradientId}-fade`;
|
||||
return (
|
||||
<Svg style={StyleSheet.absoluteFill} width="100%" height="100%" pointerEvents="none">
|
||||
<Defs>
|
||||
<RadialGradient id={highlightId} cx="35%" cy="30%" r="60%">
|
||||
<Stop offset="0%" stopColor="#ffffff" stopOpacity={0.08} />
|
||||
<Stop offset="100%" stopColor="#ffffff" stopOpacity={0} />
|
||||
</RadialGradient>
|
||||
<LinearGradient id={fadeId} x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="78%" stopColor="#000000" stopOpacity={0} />
|
||||
<Stop offset="100%" stopColor="#000000" stopOpacity={0.3} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Rect width="100%" height="100%" fill={`url(#${highlightId})`} />
|
||||
<Rect width="100%" height="100%" fill={`url(#${fadeId})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ListingDetailScreen(): JSX.Element {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { toast } = useToast();
|
||||
const { requestBuy } = useModals();
|
||||
const [resolverId, setResolverId] = useState(0);
|
||||
const { width } = useWindowDimensions();
|
||||
const accentColor = useThemeColor("accent");
|
||||
|
||||
// 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" && width >= STICKY_BREAKPOINT
|
||||
? ({ position: "sticky", top: 92 } as ViewStyle)
|
||||
: undefined;
|
||||
|
||||
const listing = useMemo(
|
||||
() => PRODUCTS.find((product) => product.id === Number(id)) ?? PRODUCTS[0],
|
||||
@@ -36,30 +68,36 @@ export default function ListingDetailScreen(): JSX.Element {
|
||||
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">
|
||||
<PageContainer className="gap-6 py-4 lg:flex-row lg:items-start lg:gap-8 lg:py-10">
|
||||
<View className="flex-1 gap-6 lg:gap-10">
|
||||
<View
|
||||
className="h-52 items-center justify-center rounded-2xl md:h-80"
|
||||
className="aspect-[8/5] w-full items-center justify-center overflow-hidden rounded-2xl lg:aspect-[12/5]"
|
||||
style={{ backgroundColor: listing.bg }}
|
||||
>
|
||||
<Typography className="text-6xl">{listing.emoji}</Typography>
|
||||
<HeroGradientOverlay gradientId={`hero-${listing.id}`} />
|
||||
<Typography className="text-[96px] lg:text-[160px]">{listing.emoji}</Typography>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<Typography type="h4">{listing.name}</Typography>
|
||||
<Typography type="body-sm" color="muted">
|
||||
<Typography type="h4" weight="bold" className="text-2xl leading-tight lg:text-[38px]">
|
||||
{listing.name}
|
||||
</Typography>
|
||||
<Typography type="body-sm" color="muted" className="text-[15px] lg:text-[17px]">
|
||||
{listing.desc}
|
||||
</Typography>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-3 rounded-xl border border-border bg-surface p-3.5">
|
||||
<View className="size-9 items-center justify-center rounded-full bg-accent-soft">
|
||||
<Ionicons name="checkmark-circle" size={18} color={accentColor} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Typography type="body-sm" weight="semibold">
|
||||
Verified on Solana
|
||||
</Typography>
|
||||
<Typography type="body-xs" color="muted">
|
||||
<MonoText type="body-xs" color="muted">
|
||||
{listing.seller}
|
||||
</Typography>
|
||||
</MonoText>
|
||||
</View>
|
||||
<Button size="sm" variant="ghost" onPress={() => toast.show("Address copied!")}>
|
||||
<Button.Label>Copy</Button.Label>
|
||||
@@ -67,41 +105,31 @@ export default function ListingDetailScreen(): JSX.Element {
|
||||
</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">
|
||||
<Typography className={MICRO_LABEL_CLASS}>About this listing</Typography>
|
||||
<View className="flex-row flex-wrap gap-y-4">
|
||||
<View className="w-1/2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Category
|
||||
</Typography>
|
||||
<Typography type="body-sm" weight="medium">
|
||||
<Typography className={MICRO_LABEL_CLASS}>Category</Typography>
|
||||
<Typography type="body-sm" weight="medium" className="mt-1">
|
||||
{listing.cat}
|
||||
</Typography>
|
||||
</View>
|
||||
<View className="w-1/2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Available
|
||||
</Typography>
|
||||
<Typography type="body-sm" weight="medium">
|
||||
<Typography className={MICRO_LABEL_CLASS}>Available</Typography>
|
||||
<Typography type="body-sm" weight="medium" className="mt-1">
|
||||
{listing.qty} units
|
||||
</Typography>
|
||||
</View>
|
||||
<View className="w-1/2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Currency
|
||||
</Typography>
|
||||
<Typography type="body-sm" weight="medium">
|
||||
<Typography className={MICRO_LABEL_CLASS}>Currency</Typography>
|
||||
<Typography type="body-sm" weight="medium" className="mt-1">
|
||||
{listing.cur}
|
||||
</Typography>
|
||||
</View>
|
||||
<View className="w-1/2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Listing ID
|
||||
</Typography>
|
||||
<Typography type="body-sm" color="muted">
|
||||
<Typography className={MICRO_LABEL_CLASS}>Listing ID</Typography>
|
||||
<MonoText type="body-sm" color="muted" className="mt-1">
|
||||
{`#${listing.id}${listing.id}${listing.id}${listing.id}`}
|
||||
</Typography>
|
||||
</MonoText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -109,7 +137,7 @@ export default function ListingDetailScreen(): JSX.Element {
|
||||
{relatedProducts.length > 0 && (
|
||||
<View className="gap-3">
|
||||
<Typography type="h6">Customers also bought</Typography>
|
||||
<View className="flex-row flex-wrap">
|
||||
<View className="flex-row flex-wrap gap-5 lg:gap-6">
|
||||
{relatedProducts.map((product) => (
|
||||
<ListingCard key={product.id} product={product} variant="grid" />
|
||||
))}
|
||||
@@ -118,14 +146,19 @@ export default function ListingDetailScreen(): JSX.Element {
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="gap-4 rounded-2xl border border-border bg-surface p-4 md:w-80" style={stickyBoxStyle}>
|
||||
<View
|
||||
className="gap-5 rounded-2xl border border-border bg-surface p-6 lg:w-[340px] lg:gap-6 lg:p-8"
|
||||
style={stickyBoxStyle}
|
||||
>
|
||||
<View>
|
||||
<Typography type="h3">{listing.price}</Typography>
|
||||
<Typography type="body-sm" color="muted">
|
||||
<Typography weight="bold" className="text-[30px] font-extrabold leading-none lg:text-[42px]">
|
||||
{listing.price}
|
||||
</Typography>
|
||||
<Typography className="mt-1 text-[15px] text-subtle lg:text-[18px]">
|
||||
≈ {listing.usd} USD
|
||||
</Typography>
|
||||
<View className="mt-2 self-start">
|
||||
<StatusChip label={availability.label} color={availability.color} />
|
||||
<View className="mt-2.5 self-start">
|
||||
<StatusChip label={availability.label} color={availability.color} variant="outline" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -134,9 +167,7 @@ export default function ListingDetailScreen(): JSX.Element {
|
||||
{listing.alt.length > 0 && (
|
||||
<>
|
||||
<View className="gap-2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Pay with
|
||||
</Typography>
|
||||
<Typography className={MICRO_LABEL_CLASS}>Pay with</Typography>
|
||||
<View className="flex-row gap-2">
|
||||
<Chip
|
||||
variant={currency === listing.cur ? "primary" : "soft"}
|
||||
@@ -159,9 +190,7 @@ export default function ListingDetailScreen(): JSX.Element {
|
||||
)}
|
||||
|
||||
<View className="gap-2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Dispute Protection
|
||||
</Typography>
|
||||
<Typography className={MICRO_LABEL_CLASS}>Dispute Protection</Typography>
|
||||
{RESOLVERS.map((resolver) => (
|
||||
<ResolverCard
|
||||
key={resolver.id}
|
||||
@@ -170,7 +199,7 @@ export default function ListingDetailScreen(): JSX.Element {
|
||||
onSelect={() => setResolverId(resolver.id)}
|
||||
/>
|
||||
))}
|
||||
<Typography type="body-xs" color="muted">
|
||||
<Typography type="body-xs" className="text-subtle">
|
||||
Fee only charged if dispute is adjudicated.
|
||||
</Typography>
|
||||
</View>
|
||||
@@ -179,29 +208,31 @@ export default function ListingDetailScreen(): JSX.Element {
|
||||
|
||||
<Button
|
||||
isDisabled={isOut}
|
||||
size="lg"
|
||||
variant={isOut ? "secondary" : "primary"}
|
||||
className="w-full rounded-xl"
|
||||
onPress={() => requestBuy(listing.id)}
|
||||
>
|
||||
<Button.Label>{isOut ? "Out of Stock" : "Buy Now →"}</Button.Label>
|
||||
<Button.Label className="font-semibold">{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 size="sm" variant="secondary">
|
||||
<Chip.Label>✓ Final Payment</Chip.Label>
|
||||
</Chip>
|
||||
<Chip size="sm" variant="soft" color="default">
|
||||
<Chip size="sm" variant="secondary">
|
||||
<Chip.Label>🛡 Dispute Cover</Chip.Label>
|
||||
</Chip>
|
||||
<Chip size="sm" variant="soft" color="default">
|
||||
<Chip size="sm" variant="secondary">
|
||||
<Chip.Label>⬡ On-chain</Chip.Label>
|
||||
</Chip>
|
||||
</View>
|
||||
|
||||
<Typography type="body-xs" color="muted" align="center">
|
||||
<Typography type="body-xs" className="text-subtle" align="center">
|
||||
Funds held in secure escrow until you confirm receipt.
|
||||
</Typography>
|
||||
</View>
|
||||
</View>
|
||||
</PageContainer>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { ORDERS } from "@/data/mock";
|
||||
import { orderStatusInfo } from "@/lib/status";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { StatusChip } from "@/components/StatusChip";
|
||||
import { MonoText } from "@/components/MonoText";
|
||||
import { PageContainer } from "@/components/PageContainer";
|
||||
import { MICRO_LABEL_CLASS } from "@/lib/typography";
|
||||
|
||||
const STEP_LABELS = ["Payment\nHeld", "Seller\nConfirms", "You Confirm\nReceipt"] as const;
|
||||
|
||||
@@ -39,16 +42,16 @@ export default function OrderDetailScreen(): JSX.Element {
|
||||
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">
|
||||
<PageContainer className="gap-5 py-4 lg:py-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">
|
||||
<MonoText type="body-xs" color="muted">
|
||||
{order.seller}
|
||||
</Typography>
|
||||
</MonoText>
|
||||
</View>
|
||||
<View className="items-end gap-1.5">
|
||||
<Typography type="h6">{order.amt}</Typography>
|
||||
@@ -57,9 +60,7 @@ export default function OrderDetailScreen(): JSX.Element {
|
||||
</View>
|
||||
|
||||
<View className="gap-4 rounded-xl border border-border bg-surface p-4">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Order Progress
|
||||
</Typography>
|
||||
<Typography className={MICRO_LABEL_CLASS}>Order Progress</Typography>
|
||||
<View className="flex-row items-start">
|
||||
{STEP_LABELS.map((label, index) => {
|
||||
const done = index < activeStepIndex || (index === activeStepIndex && step3Done);
|
||||
@@ -115,7 +116,7 @@ export default function OrderDetailScreen(): JSX.Element {
|
||||
<Button.Label>View Listing →</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</PageContainer>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BottomSheet, Button, Spinner, Typography, useThemeColor } from "heroui-
|
||||
import { PRODUCTS, RESOLVERS } from "@/data/mock";
|
||||
import { useModals } from "@/state/modals";
|
||||
import { ResolverCard } from "@/components/ResolverCard";
|
||||
import { MonoText } from "@/components/MonoText";
|
||||
|
||||
export function CheckoutSheet(): JSX.Element {
|
||||
const router = useRouter();
|
||||
@@ -54,9 +55,9 @@ export function CheckoutSheet(): JSX.Element {
|
||||
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
|
||||
{listing.name}
|
||||
</Typography>
|
||||
<Typography type="body-xs" color="muted">
|
||||
<MonoText type="body-xs" color="muted">
|
||||
{listing.seller}
|
||||
</Typography>
|
||||
</MonoText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -103,13 +103,16 @@ export function ListingCard({ product, variant = "grid" }: ListingCardProps): JS
|
||||
onPress={openListing}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
className="w-full p-1.5 md:w-1/2 lg:w-1/4"
|
||||
className="w-full md:w-[calc(50%-10px)] lg:w-[calc(25%-18px)]"
|
||||
>
|
||||
<Surface
|
||||
variant="default"
|
||||
className={`gap-0 overflow-hidden rounded-xl border p-0 ${isHovered ? "border-border-secondary" : "border-border"}`}
|
||||
>
|
||||
<View className="aspect-[4/3] items-center justify-center" style={{ backgroundColor: product.bg }}>
|
||||
<View
|
||||
className="aspect-[4/3] items-center justify-center"
|
||||
style={{ backgroundColor: product.bg }}
|
||||
>
|
||||
<CardHighlightOverlay gradientId={`card-highlight-${product.id}`} />
|
||||
<Typography className="text-6xl lg:text-7xl">{product.emoji}</Typography>
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { JSX } from "react";
|
||||
import { useState } 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";
|
||||
import { MonoText } from "@/components/MonoText";
|
||||
|
||||
interface OrderRowProps {
|
||||
order: Order;
|
||||
@@ -12,21 +14,26 @@ interface OrderRowProps {
|
||||
|
||||
export function OrderRow({ order }: OrderRowProps): JSX.Element {
|
||||
const router = useRouter();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
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"
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
className={`flex-row items-center gap-3.5 rounded-xl border bg-surface p-3.5 ${
|
||||
isHovered ? "border-border-secondary" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<MonoText type="body-xs" color="muted">
|
||||
{order.seller}
|
||||
</Typography>
|
||||
</MonoText>
|
||||
</View>
|
||||
<View className="items-end gap-0.5">
|
||||
<Typography type="body-sm" weight="bold">
|
||||
|
||||
@@ -9,7 +9,9 @@ interface PageContainerProps {
|
||||
|
||||
export function PageContainer({ children, className }: PageContainerProps): JSX.Element {
|
||||
return (
|
||||
<View className={twMerge("w-full self-center px-4 md:px-8 lg:max-w-[1320px] lg:px-12", className)}>
|
||||
<View
|
||||
className={twMerge("w-full self-center px-4 md:px-8 lg:max-w-[1320px] lg:px-12", className)}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -10,27 +10,61 @@
|
||||
@layer theme {
|
||||
@variant light {
|
||||
--background: #f7f6fb;
|
||||
--foreground: #0b0b18;
|
||||
--surface: #ffffff;
|
||||
--surface-foreground: var(--foreground);
|
||||
--surface-secondary: #f0eef8;
|
||||
--surface-secondary-foreground: var(--foreground);
|
||||
--surface-tertiary: var(--border);
|
||||
--surface-tertiary-foreground: var(--foreground);
|
||||
--overlay: var(--surface);
|
||||
--overlay-foreground: var(--foreground);
|
||||
--backdrop: rgba(4, 4, 10, 0.78);
|
||||
--muted: #5c5880;
|
||||
--default: var(--surface-secondary);
|
||||
--default-foreground: var(--foreground);
|
||||
--accent: #7a34d4;
|
||||
--accent-foreground: #ffffff;
|
||||
--field-background: var(--surface);
|
||||
--field-foreground: var(--foreground);
|
||||
--field-border: var(--border);
|
||||
--success: #059669;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--segment: var(--surface-secondary);
|
||||
--segment-foreground: var(--foreground);
|
||||
--border: #e2dff0;
|
||||
--separator: var(--border);
|
||||
--subtle: #9991b8;
|
||||
}
|
||||
|
||||
@variant dark {
|
||||
--background: #0e0e1c;
|
||||
--foreground: #ede9ff;
|
||||
--surface: #171730;
|
||||
--surface-foreground: var(--foreground);
|
||||
--surface-secondary: #1e1e3c;
|
||||
--surface-secondary-foreground: var(--foreground);
|
||||
--surface-tertiary: var(--border);
|
||||
--surface-tertiary-foreground: var(--foreground);
|
||||
--overlay: var(--surface);
|
||||
--overlay-foreground: var(--foreground);
|
||||
--backdrop: rgba(4, 4, 10, 0.78);
|
||||
--muted: #9b96c0;
|
||||
--default: var(--surface-secondary);
|
||||
--default-foreground: var(--foreground);
|
||||
--accent: #b060ff;
|
||||
--accent-foreground: #ffffff;
|
||||
--field-background: var(--surface);
|
||||
--field-foreground: var(--foreground);
|
||||
--field-border: var(--border);
|
||||
--success: #14f195;
|
||||
--warning: #f5a623;
|
||||
--danger: #ff4d4f;
|
||||
--segment: var(--surface-secondary);
|
||||
--segment-foreground: var(--foreground);
|
||||
--border: #252548;
|
||||
--separator: var(--border);
|
||||
--subtle: #605c88;
|
||||
}
|
||||
}
|
||||
@@ -40,7 +74,7 @@
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-normal: 'DMSans_400Regular';
|
||||
--font-medium: 'DMSans_500Medium';
|
||||
--font-semibold: 'DMSans_600SemiBold';
|
||||
--font-normal: "DMSans_400Regular";
|
||||
--font-medium: "DMSans_500Medium";
|
||||
--font-semibold: "DMSans_600SemiBold";
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export const MICRO_LABEL_CLASS =
|
||||
"text-[11px] font-semibold uppercase tracking-wider text-subtle";
|
||||
export const MICRO_LABEL_CLASS = "text-[11px] font-semibold uppercase tracking-wider text-subtle";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
||||
# Volana Expo App — UI-Umsetzung des Prototyps
|
||||
|
||||
## Kontext
|
||||
|
||||
`prototype/Volana.dc.html` ist ein interaktiver HTML-Prototyp (DC-Format mit Template-Bindings à la `{{ x }}`, `sc-if`, `sc-for`) der Volana-Konsumenten-Oberfläche: ein dezentraler, zensurresistenter Marktplatz auf Solana (siehe `docs/volana-design-brief.md` für den vollständigen Produkt-Kontext). Der Prototyp enthält Homepage, Browse (Liste/Grid), Listing-Detail, My Orders, Order-Detail, Wallet-Connect-Modal, Checkout-Modal (Review/Pending/Success) und Toasts, inklusive vollständiger Mock-Daten (12 Produkte, 3 Dispute-Resolver, 3 Orders) und Farb-/Theming-Variablen für Light/Dark.
|
||||
|
||||
Die Expo-App (`app/`) ist aktuell ein leeres Grundgerüst: Expo Router mit `(tabs)`-Gruppe (Home/Explore), HeroUI Native, Uniwind (Tailwind v4 für React Native), React 19 / RN 0.86 / Expo SDK 57.
|
||||
|
||||
**Ziel dieser Iteration:** Das Design des Prototyps 1:1 in Seiten/Komponenten der Expo-App übertragen — mobil UND auf Web (`react-native-web`, in den Dependencies vorhanden) nahe am Prototyp. Keine echte Funktionalität (kein echtes Wallet, kein Backend) — Mock-Daten reichen. Styling bleibt so nah wie möglich an den HeroUI-Native-Defaults; nur Akzentfarbe, Statusfarben und Radius werden angepasst (Theming), komplexere Komponenten/Seiten bekommen zusätzlich Custom-Styles via `className` (Uniwind/Tailwind) bzw. `StyleSheet` wo nötig.
|
||||
|
||||
## Nicht-Ziele
|
||||
|
||||
- Keine echte Solana-Wallet-Integration (`@solana/connector` o.ä.) — Connect/Disconnect ist gemockt wie im Prototyp.
|
||||
- Kein Backend/On-Chain-Datenfetching — statische Mock-Daten aus dem Prototyp übernommen.
|
||||
- Keine Persistenz von Bestellungen (gekaufte Listings erscheinen nicht dynamisch in "My Orders" — der Prototyp macht das ebenfalls nicht).
|
||||
- Kein manueller Light/Dark-Toggle — die App folgt dem System-Theme (Uniwind unterstützt das nativ).
|
||||
- Kein Footer (im Prototyp vorhanden, in der App nicht nötig).
|
||||
- Keine Pixel-genaue 1:1-Übernahme der Inline-Styles des Prototyps — HeroUI-Native-Komponenten und ihre Standard-Abstände/Formen bleiben so weit wie möglich erhalten.
|
||||
|
||||
## Theming
|
||||
|
||||
Anpassung ausschließlich über CSS-Variablen-Overrides in `src/global.css` (`@layer theme { @variant light {...} @variant dark {...} }`), wie in der HeroUI-Native-Theming-Doku beschrieben. Kein Erstellen neuer Custom-Colors — bestehende semantische Slots werden mit den Prototyp-Werten belegt:
|
||||
|
||||
| Variable | Dark (Prototyp `--c-*`) | Light (Prototyp `--c-*`) | Verwendung |
|
||||
|---|---|---|---|
|
||||
| `--accent` / `--accent-foreground` | `#b060ff` / weiß | `#7a34d4` / weiß | Primär-CTA, Preis-Hervorhebung, aktive Filter |
|
||||
| `--success` | `#14f195` | `#059669` | "In Stock", "Verified", "Complete", "Final Payment" |
|
||||
| `--warning` | `#f5a623` | `#d97706` | "Low Stock", "Awaiting Confirm" |
|
||||
| `--danger` | `#ff4d4f` | `#dc2626` | "Out of Stock", "Disputed", "Cancel Order" |
|
||||
| `--background` / `--surface` / `--surface-secondary` | leicht lila-stichige Dunkeltöne (`#0e0e1c`/`#171730`/`#1e1e3c`) | helle lila-stichige Töne (`#f7f6fb`/`#ffffff`/`#f0eef8`) | Basis-/Karten-Hintergründe |
|
||||
| `--radius` | Basiswert so gewählt, dass abgeleitete `--radius-lg`/`--radius-xl` in etwa den 8–14px-Radii des Prototyps entsprechen | gleich | Cards, Buttons, Inputs |
|
||||
|
||||
Die im Prototyp verwendete "Solana-Mainnet"-Grün-Punkt/"Verified"-Optik nutzt direkt `success`, keine eigene Farbe nötig.
|
||||
|
||||
## Datenmodell & Mock-Daten
|
||||
|
||||
Neue Datei `src/data/mock.ts` mit TS-Typen und den Werten 1:1 aus dem Prototyp (`_L`, `_R`, `_O` Arrays in `Volana.dc.html`):
|
||||
|
||||
```ts
|
||||
type Currency = 'SOL' | 'USDC';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface Resolver {
|
||||
id: number; name: string; desc: string;
|
||||
fee: string; type: 'Human' | 'DAO' | 'Automated';
|
||||
wins: number; total: number;
|
||||
}
|
||||
|
||||
type OrderStatus = 'AwaitingConfirm' | 'Active' | 'Complete' | 'Cancelled' | 'Disputed';
|
||||
|
||||
interface Order {
|
||||
id: number; lid: number; name: string; amt: string;
|
||||
status: OrderStatus; date: string; seller: string; emoji: string;
|
||||
}
|
||||
```
|
||||
|
||||
Emojis dienen weiterhin als Platzhalter-"Produktbild" (wie im Prototyp) statt echter Bilder — kein Asset-Aufwand nötig. Helper-Funktionen analog zu `_si` (Status → Label/Farbe) und `_av` (Verfügbarkeit → Label/Farbe) werden als reine Funktionen in `src/data/mock.ts` oder `src/lib/status.ts` nachgebaut.
|
||||
|
||||
## State
|
||||
|
||||
- **`WalletProvider`** (`src/state/wallet.tsx`, React Context): `connected: boolean`, `address: string | null`, `connect(wallet: 'phantom' | 'solflare' | 'backpack')` (setzt eine fixe Mock-Adresse je Wallet, identisch zum Prototyp), `disconnect()`.
|
||||
- **`ModalProvider`** (`src/state/modals.tsx`, React Context): zentraler Zustand für die beiden globalen Overlays:
|
||||
- Wallet-Connect-Sheet: `open`, `pendingListingId` (falls "Buy Now" ohne verbundenes Wallet ausgelöst wurde → nach Connect automatisch Checkout öffnen, wie im Prototyp)
|
||||
- Checkout-Sheet: `open`, `listingId`, `step: 'review' | 'pending' | 'success'`, inkl. simuliertem `setTimeout`-Übergang `pending → success` wie im Prototyp (2.2s)
|
||||
- Bestellungen bleiben statische Mock-Daten (siehe Nicht-Ziele) — kein zusätzlicher Order-State nötig.
|
||||
- Toasts über HeroUI Natives eingebauten `useToast`/`ToastProvider` (Teil von `HeroUINativeProvider`), verwendet für: Wallet verbunden/getrennt, Adresse kopiert, Order storniert/bestätigt.
|
||||
|
||||
Beide Provider werden in `app/_layout.tsx` um den bestehenden `HeroUINativeProvider` ergänzt.
|
||||
|
||||
## Navigation & Screens
|
||||
|
||||
```
|
||||
app/_layout.tsx Root Stack + WalletProvider + ModalProvider; rendert zusätzlich
|
||||
<TopNav /> (nur ab `md:`, s.u.) + globale Sheets (WalletConnectSheet, CheckoutSheet)
|
||||
app/(tabs)/_layout.tsx Bottom Tabs: Home | Browse | Orders (Tab-Bar ab `md:` ausgeblendet)
|
||||
app/(tabs)/index.tsx Home
|
||||
app/(tabs)/browse.tsx Browse (ersetzt bisheriges explore.tsx)
|
||||
app/(tabs)/orders.tsx My Orders (Liste)
|
||||
app/listing/[id].tsx Listing-Detail — Stack-Push über den Tabs, eigener Header mit Zurück-Button
|
||||
app/orders/[id].tsx Order-Detail — Stack-Push über den Tabs, eigener Header mit Zurück-Button
|
||||
```
|
||||
|
||||
Wallet-Connect- und Checkout-Overlay sind bewusst **keine Routen**, sondern globale, über `ModalProvider` gesteuerte `BottomSheet`-Komponenten im Root-Layout — "Buy Now" ist von Home, Browse und Listing-Detail auslösbar und muss überall denselben zentralen State treffen (entspricht dem zentralen Component-State im Prototyp).
|
||||
|
||||
### Responsive Shell (Mobile vs. Web/Desktop)
|
||||
|
||||
Umschaltung über Uniwind-Responsive-Utilities (Breakpoint `md:`, ~768px) — funktioniert sowohl nativ (z. B. Tablet-Breite) als auch im Web-Build:
|
||||
|
||||
- **`TopNav`** (`src/components/TopNav.tsx`): Logo, Suchfeld, "Browse"-/"My Orders"-Links, Wallet-Button/-Pill — analog zur Prototyp-Nav. Klasse `hidden md:flex`, liegt im Root-Layout über allen Screens (auch Listing-/Order-Detail), damit Web-Nutzer durchgehend navigieren können.
|
||||
- **Bottom-Tab-Bar:** in `(tabs)/_layout.tsx` per `useWindowDimensions` (React-Navigation-`tabBarStyle` ist keine Tailwind-Klasse, daher JS-Bedingung statt CSS) ab `md:`-Breite auf `display: 'none'` gesetzt.
|
||||
- **Ergebnis:** Mobile = Bottom-Tabs + Stack-Pushes für Details. Web/Desktop = Top-Nav wie Prototyp, keine Tab-Bar, Navigation über `router.push`.
|
||||
|
||||
**Grid-Layouts:** React Native kennt kein CSS-Grid (auch nicht im Web-Build, da react-native-web View-Primitives nutzt) — alle "Grid"-Bereiche (Browse-Grid, Featured Listings, Customers-also-bought) werden über `flex-row flex-wrap` + Breiten-Utilities (`w-1/2 md:w-1/3 lg:w-1/4` o. ä.) gebaut, nicht über `display: grid`.
|
||||
|
||||
**Listing-Detail:** `flex-col md:flex-row` (mobil gestapelt, ab `md:` zweispaltig wie im Prototyp). Die rechte Kauf-Box nutzt auf Web `position: 'sticky'` (von react-native-web unterstützt); nativ scrollt sie regulär mit.
|
||||
|
||||
## Screens im Detail
|
||||
|
||||
### Home (`app/(tabs)/index.tsx`)
|
||||
Kurzer Hero (Headline + Subline + "Browse Listings"-Button, kein Stats-/"Warum Volana"-/"How it works"-Block), darunter Featured-Listings (4 Karten, `ListingCard` Grid-Variante).
|
||||
|
||||
### Browse (`app/(tabs)/browse.tsx`)
|
||||
`SearchField` (Name/Beschreibung), Filter-Reihe (`Chip`-Toggles für Currency All/SOL/USDC und Sort Newest/Price↑/Price↓), `Switch` "In stock only", List/Grid-View-Toggle (Icon-Buttons). Darunter Produktliste aus den gefilterten/sortierten Mock-Daten:
|
||||
- List-View: horizontale Card (Bild-Platzhalter · Preis · Name+Beschreibung · Buy-Button), analog Prototyp-Zeilen-Layout.
|
||||
- Grid-View: 2-spaltige (mobil) / mehr-spaltige (`md:`/`lg:`) vertikale Cards.
|
||||
|
||||
### Listing-Detail (`app/listing/[id].tsx`)
|
||||
Bild-Platzhalter (Emoji auf Farbfläche), Titel, Beschreibung, "Verified on Solana"-Zeile mit Copy-Button (Toast "Address copied!"), Info-Grid (Kategorie/Verfügbar/Währung/ID), "Customers also bought" (horizontale/Flex-Wrap-Liste verwandter Produkte). Kauf-Sektion (sticky ab `md:`): Preis, Currency-Umschalter (falls `alt.length > 0`), Resolver-Auswahl (3 selektierbare Cards aus Mock-Resolvern), "Buy Now"-Button (öffnet Wallet-Sheet falls nicht verbunden, sonst Checkout-Sheet), Trust-Badges (Final Payment / Dispute Protection / On-chain Verified).
|
||||
|
||||
### Orders (`app/(tabs)/orders.tsx`)
|
||||
Liste von `OrderRow` (Emoji, Name, Seller-Kürzel, Betrag, Datum, Status-`Chip`), Tap → `app/orders/[id].tsx`.
|
||||
|
||||
### Order-Detail (`app/orders/[id].tsx`)
|
||||
Header (Datum, Name, Seller, Betrag, Status), 3-Schritt-Fortschrittsanzeige (Payment Held → Seller Confirms → You Confirm Receipt) mit Status-abhängiger Einfärbung wie im Prototyp (`_si`-Logik), Escrow-Info-Box, Aktions-Buttons je Status: `AwaitingConfirm` → Cancel Order (mock, zeigt Toast + navigiert zurück), `Active` → Confirm Receipt (mock, zeigt Toast), immer sichtbar → "View Listing".
|
||||
|
||||
## Gemeinsame Komponenten (`src/components/`)
|
||||
|
||||
- `TopNav` — s. o.
|
||||
- `AppHeader` — einfacher mobiler Screen-Header für Stack-Screens (Titel + Zurück-Button), auf `md:` ggf. ausgeblendet da `TopNav` übernimmt
|
||||
- `ListingCard` — Varianten `list` und `grid`
|
||||
- `StatusChip` — mappt `OrderStatus`/Verfügbarkeit auf Label + Farbe (nutzt `Chip` von HeroUI)
|
||||
- `ResolverCard` — selektierbare Resolver-Option (Listing-Detail + Checkout-Review)
|
||||
- `OrderRow` — Zeile in der Orders-Liste
|
||||
- `WalletConnectSheet` — globales `BottomSheet` mit den drei Mock-Wallets (Phantom/Solflare/Backpack)
|
||||
- `CheckoutSheet` — globales `BottomSheet`, rendert intern je nach `step` Review-/Pending-(Spinner)-/Success-Inhalt
|
||||
|
||||
## Verifikation
|
||||
|
||||
Da keine echte Funktionalität besteht, erfolgt Verifikation visuell: App per `expo start` (Web + iOS/Android-Simulator soweit verfügbar) starten, jede Seite und beide Breakpoints (schmal/mobil, breit/`md:`+) durchklicken, Kernflows prüfen (Home → Browse → Listing → Buy Now ohne Wallet → Connect → Checkout Review → Pending → Success → My Orders; Order-Detail Cancel/Confirm-Aktionen; Such-/Filter-/Sortier-/List-Grid-Umschaltung in Browse).
|
||||
@@ -1,339 +0,0 @@
|
||||
# Desktop/web design pass — global foundations + listing detail page
|
||||
|
||||
## Problem
|
||||
|
||||
The app looks noticeably worse on web/desktop than the HTML design prototype
|
||||
(`prototype/Volana.dc.html`): content runs edge-to-edge with no max-width,
|
||||
typography doesn't scale up for desktop, secondary text has no visual hierarchy,
|
||||
addresses/fees aren't distinguished from regular text, and there's no shared,
|
||||
theme-token-driven system for any of this — every screen does its own ad-hoc
|
||||
centering and spacing.
|
||||
|
||||
The listing detail screen (`app/src/app/listing/[id].tsx`) is the worst offender
|
||||
(hero is an oversized flat-color box with a tiny emoji, sidebar isn't visually a
|
||||
distinct card) and gets a full page-specific redesign here. But the underlying
|
||||
problem — no shared container, no responsive typography/spacing scale, no
|
||||
consistent treatment of muted/subtle text and monospace data — is app-wide, so
|
||||
this spec has two layers:
|
||||
|
||||
1. **Foundations** (theme, fonts, `PageContainer`, typography/spacing
|
||||
conventions, hover pattern): built once, applied to every screen.
|
||||
2. **Listing detail page**: the one screen that also gets a full page-specific
|
||||
redesign (hero, purchase sidebar, verified/about sections) to close the gap
|
||||
with its prototype counterpart.
|
||||
|
||||
Other screens (Home, Browse, Orders, Order Detail) get layer 1 applied to their
|
||||
*existing* layout and content — this spec does not redesign their structure or
|
||||
add new sections to them, only replaces ad-hoc containers/spacing/text-color
|
||||
choices with the shared foundations.
|
||||
|
||||
## Scope
|
||||
|
||||
**Foundations (app-wide):**
|
||||
- `app/src/global.css` (theme tokens)
|
||||
- `app/src/app/_layout.tsx` (font loading)
|
||||
- A new `app/src/components/PageContainer.tsx`
|
||||
- `app/src/components/StatusChip.tsx`, `app/src/components/ResolverCard.tsx`,
|
||||
`app/src/components/TopNav.tsx`, `app/src/components/ListingCard.tsx` (shared
|
||||
components, used across screens)
|
||||
- `app/src/app/(tabs)/index.tsx` (Home) — wrap in `PageContainer`, adopt the
|
||||
spacing scale
|
||||
- `app/src/app/(tabs)/browse.tsx` (Browse) — wrap in `PageContainer`, filter
|
||||
captions ("Currency", "Sort") adopt the micro-label convention
|
||||
- `app/src/app/(tabs)/orders.tsx` (Orders list) — wrap in `PageContainer`
|
||||
(replacing its current `md:mx-auto md:w-full md:max-w-2xl` ad-hoc centering)
|
||||
- `app/src/app/orders/[id].tsx` (Order detail) — wrap in `PageContainer` (same
|
||||
ad-hoc centering to replace), "Order Progress" caption adopts the micro-label
|
||||
convention
|
||||
- `app/src/components/OrderRow.tsx` — seller address adopts the monospace
|
||||
convention
|
||||
- `app/src/components/CheckoutSheet.tsx` — `listing.seller` adopts the
|
||||
monospace convention (no `PageContainer`; it's a bottom sheet, not a page)
|
||||
|
||||
**Listing detail page (full redesign):** `app/src/app/listing/[id].tsx`, covered
|
||||
in detail below.
|
||||
|
||||
Out of scope: on-chain/wallet logic, redesigning the *content/structure* of
|
||||
Home/Browse/Orders/Order Detail (their current sections, copy, and interactions
|
||||
stay as they are — only containers/spacing/text treatment change),
|
||||
`WalletConnectSheet` (no addresses or long text needing the new conventions),
|
||||
the mobile/desktop nav breakpoint (768px) itself, nav blur/translucency.
|
||||
|
||||
The prototype (`prototype/Volana.dc.html`) is the visual source of truth; it has
|
||||
**zero responsive/mobile CSS** — it's a desktop-only mockup, no `@media` queries
|
||||
touch layout anywhere in it. All mobile-specific sizing in this spec (hero aspect
|
||||
ratio, typography minimums, the two-column breakpoint) is therefore this spec's own
|
||||
design judgment, not something derived from the prototype.
|
||||
|
||||
## Theme (`app/src/global.css`)
|
||||
|
||||
Theme colors are sourced from the prototype's own CSS variables (`--c-bg`,
|
||||
`--c-surf`, `--c-surf2`, `--c-a`, `--c-g`, `--c-y`, `--c-r`, defined in
|
||||
`prototype/Volana.dc.html`'s `<style>` block). `app/src/global.css`'s existing
|
||||
`--background`, `--surface`, `--surface-secondary`, `--accent`, `--success`,
|
||||
`--warning`, `--danger` already match these values for both light and dark themes
|
||||
— leave them as they are. Extend the existing `@layer theme` blocks with what's
|
||||
missing:
|
||||
|
||||
- Add `--border` overrides (currently unset, silently falling back to HeroUI's
|
||||
generic default gray instead of the prototype's `--c-bd` border color):
|
||||
- dark: `--border: #252548`
|
||||
- light: `--border: #e2dff0`
|
||||
- Add a second muted tier. The prototype uses two distinct de-emphasis levels —
|
||||
`--c-t2` for body/description text (already mapped to HeroUI's `--muted`) and a
|
||||
dimmer `--c-t3` for micro-labels, captions, wallet/listing IDs, fee fine-print
|
||||
(currently has no equivalent). Add a new semantic color following HeroUI's
|
||||
documented "Adding Custom Colors" pattern:
|
||||
```css
|
||||
@layer theme {
|
||||
@variant dark { --subtle: #605c88; }
|
||||
@variant light { --subtle: #9991b8; }
|
||||
}
|
||||
@theme inline {
|
||||
--color-subtle: var(--subtle);
|
||||
}
|
||||
```
|
||||
Usable as `className="text-subtle"` (Typography's `color` prop only accepts
|
||||
`default|muted`, so subtle text is applied via `className`, not the `color` prop).
|
||||
- No radius changes. Base `--radius` is already `8px` → `rounded-xl` (12px) and
|
||||
`rounded-2xl` (16px) already match the spec's button/card radius targets via
|
||||
existing Tailwind utility classes; just use those classes consistently instead of
|
||||
`<Surface>`'s default `rounded-3xl` (24px).
|
||||
|
||||
## Fonts
|
||||
|
||||
The prototype loads DM Sans (body) and DM Mono (addresses, fees, IDs) from Google
|
||||
Fonts; the app currently uses HeroUI's default system font throughout.
|
||||
|
||||
- Add `@expo-google-fonts/dm-sans` and `@expo-google-fonts/dm-mono` (both exist on
|
||||
npm, confirmed).
|
||||
- Load `DMSans_400Regular`, `DMSans_500Medium`, `DMSans_600SemiBold`,
|
||||
`DMSans_700Bold`, `DMMono_400Regular`, `DMMono_500Medium` via `useFonts` in
|
||||
`app/src/app/_layout.tsx`, following the existing app-level loading-gate pattern
|
||||
(render nothing / keep splash screen until `fontsLoaded`).
|
||||
- Override HeroUI's font CSS variables per its documented mechanism:
|
||||
```css
|
||||
@theme {
|
||||
--font-normal: 'DMSans_400Regular';
|
||||
--font-medium: 'DMSans_500Medium';
|
||||
--font-semibold: 'DMSans_600SemiBold';
|
||||
}
|
||||
```
|
||||
- Monospace elements (resolver fee %, wallet addresses, listing IDs) use
|
||||
`<Typography type="code" className="bg-transparent px-0 py-0 self-auto">`
|
||||
(HeroUI's monospace preset, stripped of its default chip-style
|
||||
background/padding) with an explicit `style={{ fontFamily: 'DMMono_500Medium' }}`
|
||||
override — HeroUI's `type="code"` font selection is hardcoded to generic
|
||||
`'monospace'`/`'Menlo'` in its source (`text.constants.ts`) and isn't
|
||||
theme-variable-driven, so the loaded DM Mono font must be applied directly via
|
||||
`style` (which takes precedence over `className` per this project's styling
|
||||
conventions).
|
||||
|
||||
## `PageContainer` (new component)
|
||||
|
||||
`app/src/components/PageContainer.tsx`:
|
||||
```tsx
|
||||
interface PageContainerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageContainer({ children, className }: PageContainerProps) {
|
||||
return (
|
||||
<View className={cn("w-full self-center px-4 md:px-8 lg:max-w-[1320px] lg:px-12", className)}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
(Exact class-merge helper — `cn`/`twMerge`/plain template string — decided at
|
||||
implementation time to match whatever convention the rest of the codebase uses.)
|
||||
|
||||
Used to wrap:
|
||||
- `TopNav`'s inner content (the nav bar itself stays edge-to-edge with its
|
||||
`border-b`; only the row of logo/search/links/wallet-button inside it is
|
||||
constrained)
|
||||
- The entire `ListingDetailScreen` body (hero, two-column detail layout, and
|
||||
"Customers also bought" section all inside one container, mirroring the
|
||||
prototype's single wrapping div)
|
||||
- `(tabs)/index.tsx`, `(tabs)/browse.tsx`, `(tabs)/orders.tsx`, and
|
||||
`orders/[id].tsx` — see "App-wide rollout" below
|
||||
|
||||
## App-wide rollout
|
||||
|
||||
The foundational pieces above (theme tokens, fonts, `PageContainer`, the
|
||||
typography/spacing conventions defined per-element in the Listing detail section
|
||||
below, the `onHoverIn`/`onHoverOut` web-hover pattern) apply to every screen once
|
||||
built — theme tokens and fonts are global by construction (`global.css` /
|
||||
root-layout font loading), and shared components (`StatusChip`, `ResolverCard`,
|
||||
`ListingCard`, `TopNav`) pick up the changes automatically wherever they're used.
|
||||
The remaining screens need explicit, mechanical updates to adopt the rest:
|
||||
|
||||
- **Home (`(tabs)/index.tsx`):** replace the manual `px-5` / `md:items-center`
|
||||
centering with `PageContainer`. Content and copy are unchanged.
|
||||
- **Browse (`(tabs)/browse.tsx`):** wrap the filter bar and list/grid in
|
||||
`PageContainer`. The "Currency" and "Sort" filter captions adopt the
|
||||
micro-label convention (`text-[11px] font-semibold uppercase tracking-wider
|
||||
text-subtle`) in place of their current `type="body-xs" color="muted"`.
|
||||
`ListingCard`'s grid-variant changes apply automatically. Add the
|
||||
`onHoverIn`/`onHoverOut` hover pattern to `ListingCard`'s list variant too
|
||||
(currently only specified for the grid variant).
|
||||
- **Orders list (`(tabs)/orders.tsx`):** replace `md:mx-auto md:w-full
|
||||
md:max-w-2xl` with `PageContainer`. `OrderRow`'s `order.seller` address adopts
|
||||
the monospace convention (`Typography type="code"` stripped of its background,
|
||||
`DMMono_500Medium` via `style`, same as the listing detail page's addresses).
|
||||
Add the hover pattern to `OrderRow` (it's a pressable row, currently no hover
|
||||
feedback).
|
||||
- **Order detail (`orders/[id].tsx`):** replace the same `md:mx-auto
|
||||
md:w-full md:max-w-2xl` pattern with `PageContainer`. `order.seller` adopts
|
||||
the monospace convention. The "Order Progress" caption adopts the micro-label
|
||||
convention.
|
||||
- **`CheckoutSheet`:** `listing.seller` adopts the monospace convention. No
|
||||
`PageContainer` — it's a bottom sheet, not a full-width page, so there's no
|
||||
max-width to enforce.
|
||||
|
||||
None of these screens get new sections, restructured layout, or copy changes —
|
||||
only their container, spacing, and text-treatment building blocks change to the
|
||||
shared ones.
|
||||
|
||||
## Listing detail layout (`app/src/app/listing/[id].tsx`)
|
||||
|
||||
**Two-column split:** change the breakpoint from `md:flex-row` (768px) to
|
||||
`lg:flex-row` (1024px) so there's room for the 340px sidebar before switching to
|
||||
two columns. Main content column `flex-1`, sidebar fixed `lg:w-[340px]`. Below
|
||||
`lg`: single column, sidebar stacks under the hero (today's existing behavior,
|
||||
just at a higher breakpoint).
|
||||
|
||||
**Sticky sidebar:** keep the existing web-only `position: sticky` mechanism, but
|
||||
gate it behind a `useWindowDimensions` width check (same pattern
|
||||
`app/src/app/(tabs)/_layout.tsx` already uses for its own breakpoint) so it only
|
||||
activates at `>=1024`. Update `top` offset to match the new desktop nav height
|
||||
(~76px) plus spacing (~92px total).
|
||||
|
||||
**Hero:**
|
||||
- `style={{ aspectRatio: isWide ? 2.4 : 1.6 }}` (RN's `aspectRatio` style works
|
||||
cross-platform, no separate mobile/desktop height branches needed) — `isWide`
|
||||
reuses the same width check as the sticky sidebar.
|
||||
- `rounded-2xl overflow-hidden`, background color from `listing.bg` (unchanged,
|
||||
per-listing data).
|
||||
- Layered on top via `react-native-svg` (already a project dependency, no new
|
||||
install needed): a `RadialGradient` white-8%-to-transparent highlight
|
||||
(top-left-biased) and a bottom linear fade to black-transparent — replicating the
|
||||
prototype's actual two-layer gradient technique exactly.
|
||||
- Emoji: `text-[96px] lg:text-[160px]`, centered.
|
||||
|
||||
**Typography scale** (Tailwind arbitrary values layered on HeroUI's `type` presets,
|
||||
which supply weight/line-height defaults; size is overridden responsively):
|
||||
- Title: `text-2xl lg:text-[38px] leading-tight font-bold`
|
||||
- Description: `text-[15px] lg:text-[17px]`, `color="muted"`
|
||||
- Sidebar price: `text-[30px] lg:text-[42px] font-extrabold`
|
||||
- USD subline: `text-[15px] lg:text-[18px] text-subtle`
|
||||
- Section micro-labels ("Pay with", "Dispute Protection", "About this listing"):
|
||||
`text-[11px] font-semibold uppercase tracking-wider text-subtle` — defined once
|
||||
as a local class-string constant in `[id].tsx` and reused at its ~4 call sites
|
||||
(not a new shared component; not reused outside this screen yet)
|
||||
|
||||
**Purchase sidebar card:**
|
||||
- `rounded-2xl border border-border bg-surface p-6 lg:p-8`, existing `<Separator />`
|
||||
usage between price / pay-with / dispute-protection / CTA blocks, re-padded to the
|
||||
new scale.
|
||||
- Stock badge uses `StatusChip`'s new `variant="outline"` (see below).
|
||||
- CTA: `<Button size="lg">`, full width, `variant="primary"` (unchanged) — verify at
|
||||
implementation time that HeroUI's `lg` button size lands in the ~52-56px height
|
||||
range; adjust with `className="h-14"` only if the built-in size doesn't already
|
||||
hit it.
|
||||
- Trust chips ("Final Payment", "Dispute Cover", "On-chain"):
|
||||
`<Chip size="sm" variant="secondary">` (renders `bg-default`) as the closest
|
||||
built-in equivalent to a small elevated pill.
|
||||
|
||||
**Verified box:** add an icon in a violet circle (`bg-accent-soft` circle,
|
||||
`text-accent` `Ionicons` checkmark) to the left of the existing two-line
|
||||
"Verified on Solana" / address text block; "Copy" button stays right-aligned;
|
||||
address rendered with the new monospace treatment.
|
||||
|
||||
**About this listing:** keep the existing 2-column grid structure; apply the new
|
||||
micro-label style to the small "Category"/"Available"/"Currency"/"Listing ID"
|
||||
captions; bump row-gap to match the new spacing scale; Listing ID value gets the
|
||||
monospace treatment.
|
||||
|
||||
## `StatusChip` (`app/src/components/StatusChip.tsx`)
|
||||
|
||||
Add an optional `variant?: "soft" | "outline"` prop, default `"soft"` (existing
|
||||
behavior, unchanged call sites in `ListingCard`/`OrderRow` keep working as-is).
|
||||
New `"outline"` value renders `<Chip variant="tertiary" color={color}>` plus an
|
||||
explicit border className looked up from a static `Record<StatusColor, string>`
|
||||
map (e.g. `{ success: "border border-success", warning: "border border-warning",
|
||||
default: "border border-default", accent: "border border-accent", danger: "border
|
||||
border-danger" }`) — dynamic template-literal class names
|
||||
(`` `border-${color}` ``) must not be used since Uniwind/Tailwind need statically
|
||||
analyzable class strings to include them in the build. Transparent background,
|
||||
colored border + text, built entirely from existing Chip primitives and theme
|
||||
color tokens (no new component, no hardcoded colors). Used for the sidebar's
|
||||
"5 in stock" badge only.
|
||||
|
||||
## `ResolverCard` (`app/src/components/ResolverCard.tsx`)
|
||||
|
||||
- Unselected background changes from `bg-transparent` to `bg-surface-secondary`
|
||||
(was: plain transparent; prototype implies an elevated unselected state).
|
||||
Selected state (`border-accent bg-accent/10`) is unchanged.
|
||||
- Add web-only hover feedback via local `onHoverIn`/`onHoverOut` state (these
|
||||
Pressable events only fire on web/mouse input and are no-ops on native touch —
|
||||
no `Platform.OS` branching required). Hover state lightens the border slightly
|
||||
using HeroUI's existing calculated `border-secondary`/`border-tertiary` tokens,
|
||||
not a new custom color.
|
||||
|
||||
## `TopNav` (`app/src/components/TopNav.tsx`)
|
||||
|
||||
- Height: `h-16 lg:h-[76px]` (was flat `h-16`).
|
||||
- Content wrapped in `PageContainer` (replacing the current manual `px-6`).
|
||||
- Search field width bumped: `lg:w-[480px]` (was capped at `max-w-[320px]` with no
|
||||
larger breakpoint).
|
||||
- No other structural changes. The `md:flex` visibility breakpoint (mobile tab bar
|
||||
vs. desktop nav swap) is unchanged — out of scope for this spec. No
|
||||
blur/translucency backdrop — the prototype's `backdrop-filter: blur(20px)` is a
|
||||
nice-to-have on top of this navbar's core requirements (height, border, logo,
|
||||
search, links, pill, connect button), has no native equivalent, and is
|
||||
deliberately excluded to keep this scoped.
|
||||
|
||||
## `ListingCard` grid variant (`app/src/components/ListingCard.tsx`)
|
||||
|
||||
Affects "Customers also bought" on the listing detail page, and (as a side effect
|
||||
of this being a shared component) the featured-listings grid on the home tab and
|
||||
the grid view on the browse tab:
|
||||
- Image area: `aspect-[4/3]` (was fixed `h-32`), same white-highlight radial-overlay
|
||||
technique as the hero (smaller), emoji `text-6xl lg:text-7xl` (was flat `text-4xl`).
|
||||
- Grid gap bumped: `gap-5 lg:gap-6` (was tighter implicit padding via `p-1.5`).
|
||||
- Title semibold (unchanged), address in the new monospace/subtle treatment, price
|
||||
in `text-accent font-bold` (unchanged), stock badge small, positioned top-right
|
||||
of the card body (was inline with price).
|
||||
- Buy Now button stays `size="sm"` — already more subdued than the sidebar's `lg`
|
||||
CTA; no variant change needed.
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
- Vertical spacing between major sections: `gap-6 lg:gap-10` in place of flat,
|
||||
non-responsive gaps — applied on the listing detail screen and, where a
|
||||
screen has analogous major-section breaks, on Home/Browse/Orders/Order Detail
|
||||
too (their existing gap values step up responsively rather than changing which
|
||||
sections exist).
|
||||
- Web-only hover states (resolver cards, related-product cards in both
|
||||
`ListingCard` variants, `OrderRow`) via the `onHoverIn`/`onHoverOut`
|
||||
local-state pattern described above — applied consistently wherever this spec
|
||||
calls for hover feedback, on every screen that uses these shared components.
|
||||
- Mobile regression check: since the two-column breakpoint moves from `md` (768px)
|
||||
to `lg` (1024px), mobile and tablet-portrait layouts stay single-column longer
|
||||
than today, which is a safe direction (less likely to cramp a mid-size viewport),
|
||||
not a regression risk in itself. Verify at implementation time on a phone-width
|
||||
viewport that spacing/typography changes don't look oversized before `lg` — on
|
||||
the listing detail page and on every other screen touched by the app-wide
|
||||
rollout.
|
||||
|
||||
## Testing
|
||||
|
||||
No test runner is configured in this repo (per `CLAUDE.md`). Verification is:
|
||||
`npm run typecheck`, `npm run lint`, and manual check in a running `expo start`
|
||||
web session at mobile (<768px), tablet (~900px), and desktop (>=1320px) widths,
|
||||
plus a native (iOS or Android simulator) pass to confirm nothing web-only leaked
|
||||
into native rendering. The manual pass covers every screen touched by this spec —
|
||||
Home, Browse, Orders list, Order Detail, and Listing Detail — not just the
|
||||
primary target.
|
||||
@@ -1,9 +1,5 @@
|
||||
# Volana — Consumer Frontend Design Brief
|
||||
|
||||
> This document is the sole source of truth for the Claude Design session. It contains everything needed to design the Volana consumer marketplace frontend: brand context, product vision, UX goals, data models, page inventory, interaction patterns, and content guidance. Visual design decisions (color, typography, spacing, motion) are left to the designer.
|
||||
|
||||
---
|
||||
|
||||
## 1. What is Volana?
|
||||
|
||||
**Volana is a decentralized, censorship-resistant marketplace** — think eBay, but on Solana. The primary inspiration is eBay's consumer UX (browse listings, view a product, buy it), with two fundamental differences:
|
||||
|
||||
Reference in New Issue
Block a user