Compare commits

...

11 Commits

Author SHA1 Message Date
thesn10
0925649d25 readme 2026-07-08 19:30:08 +02:00
thesn10
9080e6fbc6 gitignore 2026-07-08 19:18:58 +02:00
thesn10
45f612c990 fix css 2026-07-08 19:16:01 +02:00
thesn10
7416e97560 Fix grid overflow to 3 columns 2026-07-03 13:36:36 +02:00
thesn10
1b2f1ec0b4 Fix prettier formatting drift 2026-07-03 13:23:59 +02:00
thesn10
20f6418ab2 Add monospace seller 2026-07-03 13:15:30 +02:00
thesn10
4e44bf8bbf Redesign Order Detail 2026-07-03 13:14:26 +02:00
thesn10
21acab57c6 Redesign Orders list 2026-07-03 13:12:56 +02:00
thesn10
7d6c779711 Wrap Browse in PageContainer 2026-07-03 13:11:37 +02:00
thesn10
a04555637d Wrap Home in PageContainer 2026-07-03 13:10:22 +02:00
thesn10
cf89864444 Redesign listing detail 2026-07-03 13:05:39 +02:00
18 changed files with 430 additions and 4357 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
# heroui-agents-md
.heroui-docs/
CLAUDE.md
.yarn
.yarn
docs/superpowers

133
README.md Normal file
View 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 1015% 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.52%, charged **only** if a dispute is actually adjudicated). Compare: eBay takes 1013%, PayPal another 34%.
- ⚖️ **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).

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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";
}

View File

@@ -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

View File

@@ -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 814px-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).

View File

@@ -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.

View File

@@ -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: