Redesign listing detail
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user