Add listing detail screen
This commit is contained in:
207
app/src/app/listing/[id].tsx
Normal file
207
app/src/app/listing/[id].tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { JSX } from "react";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { Button, Chip, Separator, Typography, useToast } from "heroui-native";
|
||||
import type { Currency } from "@/data/mock";
|
||||
import { PRODUCTS, RESOLVERS } from "@/data/mock";
|
||||
import { availabilityInfo } from "@/lib/status";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { StatusChip } from "@/components/StatusChip";
|
||||
import { ResolverCard } from "@/components/ResolverCard";
|
||||
import { ListingCard } from "@/components/ListingCard";
|
||||
import { useModals } from "@/state/modals";
|
||||
|
||||
// react-native-web supports CSS `position: sticky`, but React Native's own
|
||||
// ViewStyle type doesn't include it — cast is required to use it web-only.
|
||||
const stickyBoxStyle: ViewStyle | undefined =
|
||||
Platform.OS === "web" ? ({ position: "sticky", top: 84 } as ViewStyle) : undefined;
|
||||
|
||||
export default function ListingDetailScreen(): JSX.Element {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { toast } = useToast();
|
||||
const { requestBuy } = useModals();
|
||||
const [resolverId, setResolverId] = useState(0);
|
||||
|
||||
const listing = useMemo(
|
||||
() => PRODUCTS.find((product) => product.id === Number(id)) ?? PRODUCTS[0],
|
||||
[id],
|
||||
);
|
||||
const [currency, setCurrency] = useState<Currency>(listing.cur);
|
||||
const availability = availabilityInfo(listing.qty);
|
||||
const isOut = listing.qty === 0;
|
||||
const relatedProducts = PRODUCTS.filter((p) => p.id !== listing.id && p.qty > 0).slice(0, 6);
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-background">
|
||||
<AppHeader title={listing.name} />
|
||||
<View className="gap-6 p-4 md:flex-row md:items-start md:gap-8 md:p-8">
|
||||
<View className="flex-1 gap-5">
|
||||
<View
|
||||
className="h-52 items-center justify-center rounded-2xl md:h-80"
|
||||
style={{ backgroundColor: listing.bg }}
|
||||
>
|
||||
<Typography className="text-6xl">{listing.emoji}</Typography>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<Typography type="h4">{listing.name}</Typography>
|
||||
<Typography type="body-sm" color="muted">
|
||||
{listing.desc}
|
||||
</Typography>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-3 rounded-xl border border-border bg-surface p-3.5">
|
||||
<View className="flex-1">
|
||||
<Typography type="body-sm" weight="semibold">
|
||||
Verified on Solana
|
||||
</Typography>
|
||||
<Typography type="body-xs" color="muted">
|
||||
{listing.seller}
|
||||
</Typography>
|
||||
</View>
|
||||
<Button size="sm" variant="ghost" onPress={() => toast.show("Address copied!")}>
|
||||
<Button.Label>Copy</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className="gap-3 rounded-xl border border-border bg-surface p-3.5">
|
||||
<Typography type="body-xs" color="muted">
|
||||
About this listing
|
||||
</Typography>
|
||||
<View className="flex-row flex-wrap gap-y-3">
|
||||
<View className="w-1/2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Category
|
||||
</Typography>
|
||||
<Typography type="body-sm" weight="medium">
|
||||
{listing.cat}
|
||||
</Typography>
|
||||
</View>
|
||||
<View className="w-1/2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Available
|
||||
</Typography>
|
||||
<Typography type="body-sm" weight="medium">
|
||||
{listing.qty} units
|
||||
</Typography>
|
||||
</View>
|
||||
<View className="w-1/2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Currency
|
||||
</Typography>
|
||||
<Typography type="body-sm" weight="medium">
|
||||
{listing.cur}
|
||||
</Typography>
|
||||
</View>
|
||||
<View className="w-1/2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Listing ID
|
||||
</Typography>
|
||||
<Typography type="body-sm" color="muted">
|
||||
{`#${listing.id}${listing.id}${listing.id}${listing.id}`}
|
||||
</Typography>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{relatedProducts.length > 0 && (
|
||||
<View className="gap-3">
|
||||
<Typography type="h6">Customers also bought</Typography>
|
||||
<View className="flex-row flex-wrap">
|
||||
{relatedProducts.map((product) => (
|
||||
<ListingCard key={product.id} product={product} variant="grid" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="gap-4 rounded-2xl border border-border bg-surface p-4 md:w-80" style={stickyBoxStyle}>
|
||||
<View>
|
||||
<Typography type="h3">{listing.price}</Typography>
|
||||
<Typography type="body-sm" color="muted">
|
||||
≈ {listing.usd} USD
|
||||
</Typography>
|
||||
<View className="mt-2 self-start">
|
||||
<StatusChip label={availability.label} color={availability.color} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Separator />
|
||||
|
||||
{listing.alt.length > 0 && (
|
||||
<>
|
||||
<View className="gap-2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Pay with
|
||||
</Typography>
|
||||
<View className="flex-row gap-2">
|
||||
<Chip
|
||||
variant={currency === listing.cur ? "primary" : "soft"}
|
||||
color={currency === listing.cur ? "accent" : "default"}
|
||||
onPress={() => setCurrency(listing.cur)}
|
||||
>
|
||||
<Chip.Label>{listing.cur}</Chip.Label>
|
||||
</Chip>
|
||||
<Chip
|
||||
variant={currency === listing.alt[0] ? "primary" : "soft"}
|
||||
color={currency === listing.alt[0] ? "accent" : "default"}
|
||||
onPress={() => setCurrency(listing.alt[0])}
|
||||
>
|
||||
<Chip.Label>{listing.alt[0]}</Chip.Label>
|
||||
</Chip>
|
||||
</View>
|
||||
</View>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<View className="gap-2">
|
||||
<Typography type="body-xs" color="muted">
|
||||
Dispute Protection
|
||||
</Typography>
|
||||
{RESOLVERS.map((resolver) => (
|
||||
<ResolverCard
|
||||
key={resolver.id}
|
||||
resolver={resolver}
|
||||
isSelected={resolver.id === resolverId}
|
||||
onSelect={() => setResolverId(resolver.id)}
|
||||
/>
|
||||
))}
|
||||
<Typography type="body-xs" color="muted">
|
||||
Fee only charged if dispute is adjudicated.
|
||||
</Typography>
|
||||
</View>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button
|
||||
isDisabled={isOut}
|
||||
variant={isOut ? "secondary" : "primary"}
|
||||
onPress={() => requestBuy(listing.id)}
|
||||
>
|
||||
<Button.Label>{isOut ? "Out of Stock" : "Buy Now →"}</Button.Label>
|
||||
</Button>
|
||||
|
||||
<View className="flex-row flex-wrap gap-1.5">
|
||||
<Chip size="sm" variant="soft" color="success">
|
||||
<Chip.Label>✓ Final Payment</Chip.Label>
|
||||
</Chip>
|
||||
<Chip size="sm" variant="soft" color="default">
|
||||
<Chip.Label>🛡 Dispute Cover</Chip.Label>
|
||||
</Chip>
|
||||
<Chip size="sm" variant="soft" color="default">
|
||||
<Chip.Label>⬡ On-chain</Chip.Label>
|
||||
</Chip>
|
||||
</View>
|
||||
|
||||
<Typography type="body-xs" color="muted" align="center">
|
||||
Funds held in secure escrow until you confirm receipt.
|
||||
</Typography>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user