Compare commits
10 Commits
a08e4943fc
...
9080e6fbc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9080e6fbc6 | ||
|
|
45f612c990 | ||
|
|
7416e97560 | ||
|
|
1b2f1ec0b4 | ||
|
|
20f6418ab2 | ||
|
|
4e44bf8bbf | ||
|
|
21acab57c6 | ||
|
|
7d6c779711 | ||
|
|
a04555637d | ||
|
|
cf89864444 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
# heroui-agents-md
|
# heroui-agents-md
|
||||||
.heroui-docs/
|
.heroui-docs/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.yarn
|
.yarn
|
||||||
|
docs/superpowers
|
||||||
@@ -6,6 +6,8 @@ import { Chip, SearchField, Switch, Typography, useThemeColor } from "heroui-nat
|
|||||||
import type { Currency, Product } from "@/data/mock";
|
import type { Currency, Product } from "@/data/mock";
|
||||||
import { PRODUCTS } from "@/data/mock";
|
import { PRODUCTS } from "@/data/mock";
|
||||||
import { ListingCard } from "@/components/ListingCard";
|
import { ListingCard } from "@/components/ListingCard";
|
||||||
|
import { PageContainer } from "@/components/PageContainer";
|
||||||
|
import { MICRO_LABEL_CLASS } from "@/lib/typography";
|
||||||
|
|
||||||
type CurrencyFilter = "all" | Currency;
|
type CurrencyFilter = "all" | Currency;
|
||||||
type SortMode = "newest" | "priceLow" | "priceHigh";
|
type SortMode = "newest" | "priceLow" | "priceHigh";
|
||||||
@@ -23,12 +25,13 @@ export default function BrowseTab(): JSX.Element {
|
|||||||
const products = useMemo<Product[]>(() => {
|
const products = useMemo<Product[]>(() => {
|
||||||
let result = [...PRODUCTS];
|
let result = [...PRODUCTS];
|
||||||
if (inStockOnly) result = result.filter((product) => product.qty > 0);
|
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()) {
|
if (search.trim()) {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
(product) =>
|
(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);
|
if (sortMode === "priceLow") result.sort((a, b) => a.priceNum - b.priceNum);
|
||||||
@@ -38,123 +41,123 @@ export default function BrowseTab(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-background">
|
<View className="flex-1 bg-background">
|
||||||
<View className="gap-3 border-b border-border px-4 py-3">
|
<View className="border-b border-border">
|
||||||
<SearchField value={search} onChange={setSearch}>
|
<PageContainer className="gap-3 py-3">
|
||||||
<SearchField.Group>
|
<SearchField value={search} onChange={setSearch}>
|
||||||
<SearchField.SearchIcon />
|
<SearchField.Group>
|
||||||
<SearchField.Input placeholder="Search listings…" />
|
<SearchField.SearchIcon />
|
||||||
<SearchField.ClearButton />
|
<SearchField.Input placeholder="Search listings…" />
|
||||||
</SearchField.Group>
|
<SearchField.ClearButton />
|
||||||
</SearchField>
|
</SearchField.Group>
|
||||||
|
</SearchField>
|
||||||
|
|
||||||
<View className="flex-row flex-wrap items-center gap-2">
|
<View className="flex-row flex-wrap items-center gap-2">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>Currency</Typography>
|
||||||
Currency
|
<Chip
|
||||||
</Typography>
|
size="sm"
|
||||||
<Chip
|
variant={currencyFilter === "all" ? "primary" : "soft"}
|
||||||
size="sm"
|
color={currencyFilter === "all" ? "accent" : "default"}
|
||||||
variant={currencyFilter === "all" ? "primary" : "soft"}
|
onPress={() => setCurrencyFilter("all")}
|
||||||
color={currencyFilter === "all" ? "accent" : "default"}
|
|
||||||
onPress={() => setCurrencyFilter("all")}
|
|
||||||
>
|
|
||||||
<Chip.Label>All</Chip.Label>
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
size="sm"
|
|
||||||
variant={currencyFilter === "SOL" ? "primary" : "soft"}
|
|
||||||
color={currencyFilter === "SOL" ? "accent" : "default"}
|
|
||||||
onPress={() => setCurrencyFilter("SOL")}
|
|
||||||
>
|
|
||||||
<Chip.Label>SOL</Chip.Label>
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
size="sm"
|
|
||||||
variant={currencyFilter === "USDC" ? "primary" : "soft"}
|
|
||||||
color={currencyFilter === "USDC" ? "accent" : "default"}
|
|
||||||
onPress={() => setCurrencyFilter("USDC")}
|
|
||||||
>
|
|
||||||
<Chip.Label>USDC</Chip.Label>
|
|
||||||
</Chip>
|
|
||||||
|
|
||||||
<Typography type="body-xs" color="muted" className="ml-2">
|
|
||||||
Sort
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
size="sm"
|
|
||||||
variant={sortMode === "newest" ? "primary" : "soft"}
|
|
||||||
color={sortMode === "newest" ? "accent" : "default"}
|
|
||||||
onPress={() => setSortMode("newest")}
|
|
||||||
>
|
|
||||||
<Chip.Label>Newest</Chip.Label>
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
size="sm"
|
|
||||||
variant={sortMode === "priceLow" ? "primary" : "soft"}
|
|
||||||
color={sortMode === "priceLow" ? "accent" : "default"}
|
|
||||||
onPress={() => setSortMode("priceLow")}
|
|
||||||
>
|
|
||||||
<Chip.Label>Price ↑</Chip.Label>
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
size="sm"
|
|
||||||
variant={sortMode === "priceHigh" ? "primary" : "soft"}
|
|
||||||
color={sortMode === "priceHigh" ? "accent" : "default"}
|
|
||||||
onPress={() => setSortMode("priceHigh")}
|
|
||||||
>
|
|
||||||
<Chip.Label>Price ↓</Chip.Label>
|
|
||||||
</Chip>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<Switch isSelected={inStockOnly} onSelectedChange={setInStockOnly} />
|
|
||||||
<Typography type="body-xs" color="muted">
|
|
||||||
In stock only
|
|
||||||
</Typography>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row gap-1 rounded-lg border border-border bg-surface p-1">
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setViewMode("list")}
|
|
||||||
className={`h-7 w-8 items-center justify-center rounded-md ${
|
|
||||||
viewMode === "list" ? "bg-accent" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Chip.Label>All</Chip.Label>
|
||||||
name="list"
|
</Chip>
|
||||||
size={14}
|
<Chip
|
||||||
color={viewMode === "list" ? accentForeground : mutedColor}
|
size="sm"
|
||||||
/>
|
variant={currencyFilter === "SOL" ? "primary" : "soft"}
|
||||||
</Pressable>
|
color={currencyFilter === "SOL" ? "accent" : "default"}
|
||||||
<Pressable
|
onPress={() => setCurrencyFilter("SOL")}
|
||||||
onPress={() => setViewMode("grid")}
|
|
||||||
className={`h-7 w-8 items-center justify-center rounded-md ${
|
|
||||||
viewMode === "grid" ? "bg-accent" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Chip.Label>SOL</Chip.Label>
|
||||||
name="grid"
|
</Chip>
|
||||||
size={14}
|
<Chip
|
||||||
color={viewMode === "grid" ? accentForeground : mutedColor}
|
size="sm"
|
||||||
/>
|
variant={currencyFilter === "USDC" ? "primary" : "soft"}
|
||||||
</Pressable>
|
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>
|
|
||||||
|
<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>
|
</View>
|
||||||
|
|
||||||
<ScrollView className="flex-1">
|
<ScrollView className="flex-1">
|
||||||
{viewMode === "list" ? (
|
<PageContainer className="py-3.5">
|
||||||
<View className="gap-2.5 p-3.5">
|
{viewMode === "list" ? (
|
||||||
{products.map((product) => (
|
<View className="gap-2.5">
|
||||||
<ListingCard key={product.id} product={product} variant="list" />
|
{products.map((product) => (
|
||||||
))}
|
<ListingCard key={product.id} product={product} variant="list" />
|
||||||
</View>
|
))}
|
||||||
) : (
|
</View>
|
||||||
<View className="flex-row flex-wrap p-2">
|
) : (
|
||||||
{products.map((product) => (
|
<View className="flex-row flex-wrap gap-5 lg:gap-6">
|
||||||
<ListingCard key={product.id} product={product} variant="grid" />
|
{products.map((product) => (
|
||||||
))}
|
<ListingCard key={product.id} product={product} variant="grid" />
|
||||||
</View>
|
))}
|
||||||
)}
|
</View>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRouter } from "expo-router";
|
|||||||
import { Button, Typography } from "heroui-native";
|
import { Button, Typography } from "heroui-native";
|
||||||
import { PRODUCTS } from "@/data/mock";
|
import { PRODUCTS } from "@/data/mock";
|
||||||
import { ListingCard } from "@/components/ListingCard";
|
import { ListingCard } from "@/components/ListingCard";
|
||||||
|
import { PageContainer } from "@/components/PageContainer";
|
||||||
|
|
||||||
export default function HomeTab(): JSX.Element {
|
export default function HomeTab(): JSX.Element {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -11,31 +12,31 @@ export default function HomeTab(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background">
|
<ScrollView className="flex-1 bg-background">
|
||||||
<View className="gap-4 px-5 pb-6 pt-8 md:items-center md:pt-14">
|
<PageContainer className="gap-10 py-8 lg:py-14">
|
||||||
<Typography type="h2" className="md:text-center">
|
<View className="gap-4 md:items-center">
|
||||||
{"The marketplace\n"}
|
<Typography type="h2" className="md:text-center">
|
||||||
<Typography type="h2" className="text-accent">
|
{"The marketplace\n"}
|
||||||
nobody controls.
|
<Typography type="h2" className="text-accent">
|
||||||
|
nobody controls.
|
||||||
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
<Typography type="body-sm" color="muted" className="md:max-w-md md:text-center">
|
||||||
<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.
|
||||||
Buy and sell freely. Payments are final — no chargebacks, no frozen accounts.
|
</Typography>
|
||||||
</Typography>
|
<Button onPress={() => router.push("/browse")} className="self-start md:self-center">
|
||||||
<Button onPress={() => router.push("/browse")} className="self-start md:self-center">
|
<Button.Label>Browse Listings →</Button.Label>
|
||||||
<Button.Label>Browse Listings →</Button.Label>
|
</Button>
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="px-3.5 pb-10">
|
|
||||||
<Typography type="h5" className="px-1.5 pb-3">
|
|
||||||
Featured listings
|
|
||||||
</Typography>
|
|
||||||
<View className="flex-row flex-wrap">
|
|
||||||
{featured.map((product) => (
|
|
||||||
<ListingCard key={product.id} product={product} variant="grid" />
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { Typography } from "heroui-native";
|
import { Typography } from "heroui-native";
|
||||||
import { ORDERS } from "@/data/mock";
|
import { ORDERS } from "@/data/mock";
|
||||||
import { OrderRow } from "@/components/OrderRow";
|
import { OrderRow } from "@/components/OrderRow";
|
||||||
|
import { PageContainer } from "@/components/PageContainer";
|
||||||
|
|
||||||
export default function OrdersTab(): JSX.Element {
|
export default function OrdersTab(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background">
|
<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">
|
<Typography type="h4" className="mb-1">
|
||||||
My Orders
|
My Orders
|
||||||
</Typography>
|
</Typography>
|
||||||
{ORDERS.map((order) => (
|
{ORDERS.map((order) => (
|
||||||
<OrderRow key={order.id} order={order} />
|
<OrderRow key={order.id} order={order} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</PageContainer>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,60 @@
|
|||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
import type { ViewStyle } from "react-native";
|
import type { ViewStyle } from "react-native";
|
||||||
import { useMemo, useState } from "react";
|
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 { 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 type { Currency } from "@/data/mock";
|
||||||
import { PRODUCTS, RESOLVERS } from "@/data/mock";
|
import { PRODUCTS, RESOLVERS } from "@/data/mock";
|
||||||
import { availabilityInfo } from "@/lib/status";
|
import { availabilityInfo } from "@/lib/status";
|
||||||
|
import { MICRO_LABEL_CLASS } from "@/lib/typography";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
import { StatusChip } from "@/components/StatusChip";
|
import { StatusChip } from "@/components/StatusChip";
|
||||||
import { ResolverCard } from "@/components/ResolverCard";
|
import { ResolverCard } from "@/components/ResolverCard";
|
||||||
import { ListingCard } from "@/components/ListingCard";
|
import { ListingCard } from "@/components/ListingCard";
|
||||||
|
import { MonoText } from "@/components/MonoText";
|
||||||
|
import { PageContainer } from "@/components/PageContainer";
|
||||||
import { useModals } from "@/state/modals";
|
import { useModals } from "@/state/modals";
|
||||||
|
|
||||||
// react-native-web supports CSS `position: sticky`, but React Native's own
|
const STICKY_BREAKPOINT = 1024;
|
||||||
// ViewStyle type doesn't include it — cast is required to use it web-only.
|
|
||||||
const stickyBoxStyle: ViewStyle | undefined =
|
function HeroGradientOverlay({ gradientId }: { gradientId: string }): JSX.Element {
|
||||||
Platform.OS === "web" ? ({ position: "sticky", top: 84 } as ViewStyle) : undefined;
|
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 {
|
export default function ListingDetailScreen(): JSX.Element {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { requestBuy } = useModals();
|
const { requestBuy } = useModals();
|
||||||
const [resolverId, setResolverId] = useState(0);
|
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(
|
const listing = useMemo(
|
||||||
() => PRODUCTS.find((product) => product.id === Number(id)) ?? PRODUCTS[0],
|
() => PRODUCTS.find((product) => product.id === Number(id)) ?? PRODUCTS[0],
|
||||||
@@ -36,30 +68,36 @@ export default function ListingDetailScreen(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background">
|
<ScrollView className="flex-1 bg-background">
|
||||||
<AppHeader title={listing.name} />
|
<AppHeader title={listing.name} />
|
||||||
<View className="gap-6 p-4 md:flex-row md:items-start md:gap-8 md:p-8">
|
<PageContainer className="gap-6 py-4 lg:flex-row lg:items-start lg:gap-8 lg:py-10">
|
||||||
<View className="flex-1 gap-5">
|
<View className="flex-1 gap-6 lg:gap-10">
|
||||||
<View
|
<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 }}
|
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>
|
||||||
|
|
||||||
<View className="gap-2">
|
<View className="gap-2">
|
||||||
<Typography type="h4">{listing.name}</Typography>
|
<Typography type="h4" weight="bold" className="text-2xl leading-tight lg:text-[38px]">
|
||||||
<Typography type="body-sm" color="muted">
|
{listing.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography type="body-sm" color="muted" className="text-[15px] lg:text-[17px]">
|
||||||
{listing.desc}
|
{listing.desc}
|
||||||
</Typography>
|
</Typography>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-row items-center gap-3 rounded-xl border border-border bg-surface p-3.5">
|
<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">
|
<View className="flex-1">
|
||||||
<Typography type="body-sm" weight="semibold">
|
<Typography type="body-sm" weight="semibold">
|
||||||
Verified on Solana
|
Verified on Solana
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography type="body-xs" color="muted">
|
<MonoText type="body-xs" color="muted">
|
||||||
{listing.seller}
|
{listing.seller}
|
||||||
</Typography>
|
</MonoText>
|
||||||
</View>
|
</View>
|
||||||
<Button size="sm" variant="ghost" onPress={() => toast.show("Address copied!")}>
|
<Button size="sm" variant="ghost" onPress={() => toast.show("Address copied!")}>
|
||||||
<Button.Label>Copy</Button.Label>
|
<Button.Label>Copy</Button.Label>
|
||||||
@@ -67,41 +105,31 @@ export default function ListingDetailScreen(): JSX.Element {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="gap-3 rounded-xl border border-border bg-surface p-3.5">
|
<View className="gap-3 rounded-xl border border-border bg-surface p-3.5">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>About this listing</Typography>
|
||||||
About this listing
|
<View className="flex-row flex-wrap gap-y-4">
|
||||||
</Typography>
|
|
||||||
<View className="flex-row flex-wrap gap-y-3">
|
|
||||||
<View className="w-1/2">
|
<View className="w-1/2">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>Category</Typography>
|
||||||
Category
|
<Typography type="body-sm" weight="medium" className="mt-1">
|
||||||
</Typography>
|
|
||||||
<Typography type="body-sm" weight="medium">
|
|
||||||
{listing.cat}
|
{listing.cat}
|
||||||
</Typography>
|
</Typography>
|
||||||
</View>
|
</View>
|
||||||
<View className="w-1/2">
|
<View className="w-1/2">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>Available</Typography>
|
||||||
Available
|
<Typography type="body-sm" weight="medium" className="mt-1">
|
||||||
</Typography>
|
|
||||||
<Typography type="body-sm" weight="medium">
|
|
||||||
{listing.qty} units
|
{listing.qty} units
|
||||||
</Typography>
|
</Typography>
|
||||||
</View>
|
</View>
|
||||||
<View className="w-1/2">
|
<View className="w-1/2">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>Currency</Typography>
|
||||||
Currency
|
<Typography type="body-sm" weight="medium" className="mt-1">
|
||||||
</Typography>
|
|
||||||
<Typography type="body-sm" weight="medium">
|
|
||||||
{listing.cur}
|
{listing.cur}
|
||||||
</Typography>
|
</Typography>
|
||||||
</View>
|
</View>
|
||||||
<View className="w-1/2">
|
<View className="w-1/2">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>Listing ID</Typography>
|
||||||
Listing ID
|
<MonoText type="body-sm" color="muted" className="mt-1">
|
||||||
</Typography>
|
|
||||||
<Typography type="body-sm" color="muted">
|
|
||||||
{`#${listing.id}${listing.id}${listing.id}${listing.id}`}
|
{`#${listing.id}${listing.id}${listing.id}${listing.id}`}
|
||||||
</Typography>
|
</MonoText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -109,7 +137,7 @@ export default function ListingDetailScreen(): JSX.Element {
|
|||||||
{relatedProducts.length > 0 && (
|
{relatedProducts.length > 0 && (
|
||||||
<View className="gap-3">
|
<View className="gap-3">
|
||||||
<Typography type="h6">Customers also bought</Typography>
|
<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) => (
|
{relatedProducts.map((product) => (
|
||||||
<ListingCard key={product.id} product={product} variant="grid" />
|
<ListingCard key={product.id} product={product} variant="grid" />
|
||||||
))}
|
))}
|
||||||
@@ -118,14 +146,19 @@ export default function ListingDetailScreen(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</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>
|
<View>
|
||||||
<Typography type="h3">{listing.price}</Typography>
|
<Typography weight="bold" className="text-[30px] font-extrabold leading-none lg:text-[42px]">
|
||||||
<Typography type="body-sm" color="muted">
|
{listing.price}
|
||||||
|
</Typography>
|
||||||
|
<Typography className="mt-1 text-[15px] text-subtle lg:text-[18px]">
|
||||||
≈ {listing.usd} USD
|
≈ {listing.usd} USD
|
||||||
</Typography>
|
</Typography>
|
||||||
<View className="mt-2 self-start">
|
<View className="mt-2.5 self-start">
|
||||||
<StatusChip label={availability.label} color={availability.color} />
|
<StatusChip label={availability.label} color={availability.color} variant="outline" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -134,9 +167,7 @@ export default function ListingDetailScreen(): JSX.Element {
|
|||||||
{listing.alt.length > 0 && (
|
{listing.alt.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<View className="gap-2">
|
<View className="gap-2">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>Pay with</Typography>
|
||||||
Pay with
|
|
||||||
</Typography>
|
|
||||||
<View className="flex-row gap-2">
|
<View className="flex-row gap-2">
|
||||||
<Chip
|
<Chip
|
||||||
variant={currency === listing.cur ? "primary" : "soft"}
|
variant={currency === listing.cur ? "primary" : "soft"}
|
||||||
@@ -159,9 +190,7 @@ export default function ListingDetailScreen(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<View className="gap-2">
|
<View className="gap-2">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>Dispute Protection</Typography>
|
||||||
Dispute Protection
|
|
||||||
</Typography>
|
|
||||||
{RESOLVERS.map((resolver) => (
|
{RESOLVERS.map((resolver) => (
|
||||||
<ResolverCard
|
<ResolverCard
|
||||||
key={resolver.id}
|
key={resolver.id}
|
||||||
@@ -170,7 +199,7 @@ export default function ListingDetailScreen(): JSX.Element {
|
|||||||
onSelect={() => setResolverId(resolver.id)}
|
onSelect={() => setResolverId(resolver.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography type="body-xs" className="text-subtle">
|
||||||
Fee only charged if dispute is adjudicated.
|
Fee only charged if dispute is adjudicated.
|
||||||
</Typography>
|
</Typography>
|
||||||
</View>
|
</View>
|
||||||
@@ -179,29 +208,31 @@ export default function ListingDetailScreen(): JSX.Element {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isOut}
|
isDisabled={isOut}
|
||||||
|
size="lg"
|
||||||
variant={isOut ? "secondary" : "primary"}
|
variant={isOut ? "secondary" : "primary"}
|
||||||
|
className="w-full rounded-xl"
|
||||||
onPress={() => requestBuy(listing.id)}
|
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>
|
</Button>
|
||||||
|
|
||||||
<View className="flex-row flex-wrap gap-1.5">
|
<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.Label>✓ Final Payment</Chip.Label>
|
||||||
</Chip>
|
</Chip>
|
||||||
<Chip size="sm" variant="soft" color="default">
|
<Chip size="sm" variant="secondary">
|
||||||
<Chip.Label>🛡 Dispute Cover</Chip.Label>
|
<Chip.Label>🛡 Dispute Cover</Chip.Label>
|
||||||
</Chip>
|
</Chip>
|
||||||
<Chip size="sm" variant="soft" color="default">
|
<Chip size="sm" variant="secondary">
|
||||||
<Chip.Label>⬡ On-chain</Chip.Label>
|
<Chip.Label>⬡ On-chain</Chip.Label>
|
||||||
</Chip>
|
</Chip>
|
||||||
</View>
|
</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.
|
Funds held in secure escrow until you confirm receipt.
|
||||||
</Typography>
|
</Typography>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</PageContainer>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { ORDERS } from "@/data/mock";
|
|||||||
import { orderStatusInfo } from "@/lib/status";
|
import { orderStatusInfo } from "@/lib/status";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
import { StatusChip } from "@/components/StatusChip";
|
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;
|
const STEP_LABELS = ["Payment\nHeld", "Seller\nConfirms", "You Confirm\nReceipt"] as const;
|
||||||
|
|
||||||
@@ -39,16 +42,16 @@ export default function OrderDetailScreen(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-background">
|
<ScrollView className="flex-1 bg-background">
|
||||||
<AppHeader title="Order Detail" />
|
<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-row items-start justify-between gap-3 rounded-xl border border-border bg-surface p-4">
|
||||||
<View className="flex-1 gap-1">
|
<View className="flex-1 gap-1">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography type="body-xs" color="muted">
|
||||||
Order placed {order.date}
|
Order placed {order.date}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography type="h6">{order.name}</Typography>
|
<Typography type="h6">{order.name}</Typography>
|
||||||
<Typography type="body-xs" color="muted">
|
<MonoText type="body-xs" color="muted">
|
||||||
{order.seller}
|
{order.seller}
|
||||||
</Typography>
|
</MonoText>
|
||||||
</View>
|
</View>
|
||||||
<View className="items-end gap-1.5">
|
<View className="items-end gap-1.5">
|
||||||
<Typography type="h6">{order.amt}</Typography>
|
<Typography type="h6">{order.amt}</Typography>
|
||||||
@@ -57,9 +60,7 @@ export default function OrderDetailScreen(): JSX.Element {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="gap-4 rounded-xl border border-border bg-surface p-4">
|
<View className="gap-4 rounded-xl border border-border bg-surface p-4">
|
||||||
<Typography type="body-xs" color="muted">
|
<Typography className={MICRO_LABEL_CLASS}>Order Progress</Typography>
|
||||||
Order Progress
|
|
||||||
</Typography>
|
|
||||||
<View className="flex-row items-start">
|
<View className="flex-row items-start">
|
||||||
{STEP_LABELS.map((label, index) => {
|
{STEP_LABELS.map((label, index) => {
|
||||||
const done = index < activeStepIndex || (index === activeStepIndex && step3Done);
|
const done = index < activeStepIndex || (index === activeStepIndex && step3Done);
|
||||||
@@ -115,7 +116,7 @@ export default function OrderDetailScreen(): JSX.Element {
|
|||||||
<Button.Label>View Listing →</Button.Label>
|
<Button.Label>View Listing →</Button.Label>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</PageContainer>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { BottomSheet, Button, Spinner, Typography, useThemeColor } from "heroui-
|
|||||||
import { PRODUCTS, RESOLVERS } from "@/data/mock";
|
import { PRODUCTS, RESOLVERS } from "@/data/mock";
|
||||||
import { useModals } from "@/state/modals";
|
import { useModals } from "@/state/modals";
|
||||||
import { ResolverCard } from "@/components/ResolverCard";
|
import { ResolverCard } from "@/components/ResolverCard";
|
||||||
|
import { MonoText } from "@/components/MonoText";
|
||||||
|
|
||||||
export function CheckoutSheet(): JSX.Element {
|
export function CheckoutSheet(): JSX.Element {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -54,9 +55,9 @@ export function CheckoutSheet(): JSX.Element {
|
|||||||
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
|
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
|
||||||
{listing.name}
|
{listing.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography type="body-xs" color="muted">
|
<MonoText type="body-xs" color="muted">
|
||||||
{listing.seller}
|
{listing.seller}
|
||||||
</Typography>
|
</MonoText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -103,13 +103,16 @@ export function ListingCard({ product, variant = "grid" }: ListingCardProps): JS
|
|||||||
onPress={openListing}
|
onPress={openListing}
|
||||||
onHoverIn={() => setIsHovered(true)}
|
onHoverIn={() => setIsHovered(true)}
|
||||||
onHoverOut={() => setIsHovered(false)}
|
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
|
<Surface
|
||||||
variant="default"
|
variant="default"
|
||||||
className={`gap-0 overflow-hidden rounded-xl border p-0 ${isHovered ? "border-border-secondary" : "border-border"}`}
|
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}`} />
|
<CardHighlightOverlay gradientId={`card-highlight-${product.id}`} />
|
||||||
<Typography className="text-6xl lg:text-7xl">{product.emoji}</Typography>
|
<Typography className="text-6xl lg:text-7xl">{product.emoji}</Typography>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { Typography } from "heroui-native";
|
import { Typography } from "heroui-native";
|
||||||
import type { Order } from "@/data/mock";
|
import type { Order } from "@/data/mock";
|
||||||
import { orderStatusInfo } from "@/lib/status";
|
import { orderStatusInfo } from "@/lib/status";
|
||||||
import { StatusChip } from "@/components/StatusChip";
|
import { StatusChip } from "@/components/StatusChip";
|
||||||
|
import { MonoText } from "@/components/MonoText";
|
||||||
|
|
||||||
interface OrderRowProps {
|
interface OrderRowProps {
|
||||||
order: Order;
|
order: Order;
|
||||||
@@ -12,21 +14,26 @@ interface OrderRowProps {
|
|||||||
|
|
||||||
export function OrderRow({ order }: OrderRowProps): JSX.Element {
|
export function OrderRow({ order }: OrderRowProps): JSX.Element {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const status = orderStatusInfo(order.status);
|
const status = orderStatusInfo(order.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push(`/orders/${order.id}`)}
|
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>
|
<Typography className="text-2xl">{order.emoji}</Typography>
|
||||||
<View className="flex-1 gap-0.5">
|
<View className="flex-1 gap-0.5">
|
||||||
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
|
<Typography type="body-sm" weight="semibold" numberOfLines={1}>
|
||||||
{order.name}
|
{order.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography type="body-xs" color="muted">
|
<MonoText type="body-xs" color="muted">
|
||||||
{order.seller}
|
{order.seller}
|
||||||
</Typography>
|
</MonoText>
|
||||||
</View>
|
</View>
|
||||||
<View className="items-end gap-0.5">
|
<View className="items-end gap-0.5">
|
||||||
<Typography type="body-sm" weight="bold">
|
<Typography type="body-sm" weight="bold">
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ interface PageContainerProps {
|
|||||||
|
|
||||||
export function PageContainer({ children, className }: PageContainerProps): JSX.Element {
|
export function PageContainer({ children, className }: PageContainerProps): JSX.Element {
|
||||||
return (
|
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}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,27 +10,61 @@
|
|||||||
@layer theme {
|
@layer theme {
|
||||||
@variant light {
|
@variant light {
|
||||||
--background: #f7f6fb;
|
--background: #f7f6fb;
|
||||||
|
--foreground: #0b0b18;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
|
--surface-foreground: var(--foreground);
|
||||||
--surface-secondary: #f0eef8;
|
--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: #7a34d4;
|
||||||
--accent-foreground: #ffffff;
|
--accent-foreground: #ffffff;
|
||||||
|
--field-background: var(--surface);
|
||||||
|
--field-foreground: var(--foreground);
|
||||||
|
--field-border: var(--border);
|
||||||
--success: #059669;
|
--success: #059669;
|
||||||
--warning: #d97706;
|
--warning: #d97706;
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
|
--segment: var(--surface-secondary);
|
||||||
|
--segment-foreground: var(--foreground);
|
||||||
--border: #e2dff0;
|
--border: #e2dff0;
|
||||||
|
--separator: var(--border);
|
||||||
--subtle: #9991b8;
|
--subtle: #9991b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@variant dark {
|
@variant dark {
|
||||||
--background: #0e0e1c;
|
--background: #0e0e1c;
|
||||||
|
--foreground: #ede9ff;
|
||||||
--surface: #171730;
|
--surface: #171730;
|
||||||
|
--surface-foreground: var(--foreground);
|
||||||
--surface-secondary: #1e1e3c;
|
--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: #b060ff;
|
||||||
--accent-foreground: #ffffff;
|
--accent-foreground: #ffffff;
|
||||||
|
--field-background: var(--surface);
|
||||||
|
--field-foreground: var(--foreground);
|
||||||
|
--field-border: var(--border);
|
||||||
--success: #14f195;
|
--success: #14f195;
|
||||||
--warning: #f5a623;
|
--warning: #f5a623;
|
||||||
--danger: #ff4d4f;
|
--danger: #ff4d4f;
|
||||||
|
--segment: var(--surface-secondary);
|
||||||
|
--segment-foreground: var(--foreground);
|
||||||
--border: #252548;
|
--border: #252548;
|
||||||
|
--separator: var(--border);
|
||||||
--subtle: #605c88;
|
--subtle: #605c88;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-normal: 'DMSans_400Regular';
|
--font-normal: "DMSans_400Regular";
|
||||||
--font-medium: 'DMSans_500Medium';
|
--font-medium: "DMSans_500Medium";
|
||||||
--font-semibold: 'DMSans_600SemiBold';
|
--font-semibold: "DMSans_600SemiBold";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export const MICRO_LABEL_CLASS =
|
export const MICRO_LABEL_CLASS = "text-[11px] font-semibold uppercase tracking-wider text-subtle";
|
||||||
"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.
|
|
||||||
Reference in New Issue
Block a user