Replace the profile tab with prescription management (inline reminders and family invite), add a shared tab top bar, separate notifications from orders, and support any-branch guest checkout with GSH codes. Co-authored-by: Cursor <cursoragent@cursor.com>
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { router, useLocalSearchParams } from "expo-router";
|
|
import { useMemo, useState } from "react";
|
|
import {
|
|
Image,
|
|
Pressable,
|
|
ScrollView,
|
|
View,
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Badge, Price, Rating, StockLabel } from "@/components/ui/Badge";
|
|
import { Button, IconButton } from "@/components/ui/Button";
|
|
import { Card, InfoRow, Section } from "@/components/ui/Card";
|
|
import { Chip } from "@/components/ui/Chip";
|
|
import { QtyStepper } from "@/components/ui/QtyStepper";
|
|
import { StickyBar } from "@/components/ui/StickyBar";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { branches } from "@/lib/data/branches";
|
|
import { getDrugFacts, getReviewBreakdown } from "@/lib/data/clinical";
|
|
import { products } from "@/lib/data/products";
|
|
import { useCartStore } from "@/lib/stores/cartStore";
|
|
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
|
|
import { colors, gutter, radii, space } from "@/lib/theme";
|
|
import { formatETB } from "@/lib/utils";
|
|
import { routes } from "@/lib/routes";
|
|
|
|
export default function ProductDetailScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const product = products.find((p) => p.id === id) ?? products[0];
|
|
const facts = useMemo(() => getDrugFacts(product), [product]);
|
|
const reviews = useMemo(() => getReviewBreakdown(product), [product]);
|
|
const [qty, setQty] = useState(1);
|
|
const [dose, setDose] = useState(facts.dosages[0] ?? "");
|
|
const [tab, setTab] = useState<"details" | "pharmacy">("details");
|
|
const [more, setMore] = useState(false);
|
|
const insets = useSafeAreaInsets();
|
|
const { t } = useTranslation();
|
|
const addProduct = useCartStore((s) => s.addProduct);
|
|
const toggle = useFavoritesStore((s) => s.toggle);
|
|
const liked = useFavoritesStore((s) => s.has(product.id));
|
|
|
|
const addToCart = () => {
|
|
addProduct(product, qty);
|
|
};
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.page }}>
|
|
<ScrollView
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={{ paddingBottom: 120 }}
|
|
>
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surfaceAlt,
|
|
paddingTop: insets.top + 8,
|
|
paddingBottom: 28,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
paddingHorizontal: gutter,
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<IconButton
|
|
icon="arrow-back"
|
|
accessibilityLabel="Go back"
|
|
onPress={() => router.back()}
|
|
/>
|
|
<IconButton
|
|
icon={liked ? "heart" : "heart-outline"}
|
|
onPress={() => toggle(product.id)}
|
|
color={liked ? colors.danger : colors.ink}
|
|
accessibilityLabel={liked ? "Remove favorite" : "Add favorite"}
|
|
/>
|
|
</View>
|
|
<Image
|
|
source={{ uri: product.image }}
|
|
style={{ width: "100%", height: 240 }}
|
|
resizeMode="contain"
|
|
/>
|
|
</View>
|
|
|
|
<View
|
|
style={{
|
|
marginTop: -24,
|
|
marginHorizontal: gutter,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: radii.xl,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
padding: space.xl,
|
|
gap: 14,
|
|
}}
|
|
>
|
|
{product.requiresPrescription ? (
|
|
<Badge label="RX needed" tone="info" icon="document-text-outline" />
|
|
) : (
|
|
<Badge label={facts.useCase} tone="neutral" />
|
|
)}
|
|
|
|
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="h1">{product.name}</Text>
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
{product.brand} · {product.unit}
|
|
</Text>
|
|
</View>
|
|
<Rating value={product.rating} count={product.reviews} showStars={false} />
|
|
</View>
|
|
|
|
<Price
|
|
value={product.price}
|
|
compareAt={product.compareAt}
|
|
size="lg"
|
|
format={formatETB}
|
|
/>
|
|
|
|
{facts.dosages.length > 0 ? (
|
|
<View>
|
|
<Text variant="label" color={colors.muted} style={{ marginBottom: 8 }}>
|
|
Strength
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
|
{facts.dosages.map((d) => (
|
|
<Chip
|
|
key={d}
|
|
label={d}
|
|
selected={dose === d}
|
|
onPress={() => setDose(d)}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<QtyStepper value={qty} onChange={setQty} />
|
|
<StockLabel inStock={product.inStock} count={facts.stockCount} />
|
|
</View>
|
|
|
|
<View style={{ gap: 8 }}>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
<Ionicons name="bicycle-outline" size={16} color={colors.info} />
|
|
<Text variant="bodySm" color={colors.muted}>
|
|
Same-day delivery in Addis Ababa
|
|
</Text>
|
|
</View>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
<Ionicons name="shield-checkmark-outline" size={16} color={colors.brandText} />
|
|
<Text variant="bodySm" color={colors.muted}>
|
|
Pharmacist-verified · 30-day return policy
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<Button
|
|
title={t("common.addProduct")}
|
|
icon="bag-add-outline"
|
|
onPress={() => {
|
|
addToCart();
|
|
router.push(routes.cart);
|
|
}}
|
|
disabled={!product.inStock}
|
|
/>
|
|
</View>
|
|
|
|
<View style={{ paddingHorizontal: gutter, marginTop: 18 }}>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
backgroundColor: colors.surfaceAlt,
|
|
borderRadius: radii.pill,
|
|
padding: 4,
|
|
}}
|
|
>
|
|
{(["details", "pharmacy"] as const).map((key) => (
|
|
<Pressable
|
|
key={key}
|
|
onPress={() => setTab(key)}
|
|
style={{
|
|
flex: 1,
|
|
alignItems: "center",
|
|
paddingVertical: 10,
|
|
borderRadius: radii.pill,
|
|
backgroundColor: tab === key ? colors.surface : "transparent",
|
|
}}
|
|
>
|
|
<Text variant="h3" color={tab === key ? colors.ink : colors.subtle}>
|
|
{key === "details" ? "Details" : "Pharmacy"}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
{tab === "details" ? (
|
|
<View style={{ paddingHorizontal: gutter, paddingBottom: 20 }}>
|
|
<Section title="Description">
|
|
<Text variant="body" color={colors.muted}>
|
|
{more ? product.description : `${product.description.slice(0, 140)}…`}
|
|
</Text>
|
|
<Pressable onPress={() => setMore(!more)} style={{ marginTop: 6 }}>
|
|
<Text variant="label" color={colors.brandText}>
|
|
{more ? "Show less" : "Read more"}
|
|
</Text>
|
|
</Pressable>
|
|
</Section>
|
|
|
|
<Section title="Drug facts">
|
|
<Card elevated={false}>
|
|
<InfoRow label="Generic name" value={facts.genericName} />
|
|
<InfoRow label="Brand names" value={facts.brandNames} />
|
|
<InfoRow label="Drug class" value={facts.drugClass} last />
|
|
</Card>
|
|
</Section>
|
|
|
|
<Section title="Side effects">
|
|
<Text variant="caption" color={colors.subtle} style={{ marginBottom: 8 }}>
|
|
Common (mild)
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
|
{facts.sideEffects.mild.map((s) => (
|
|
<Badge key={s} label={s} tone="neutral" icon="remove-outline" />
|
|
))}
|
|
</View>
|
|
{facts.sideEffects.severe.length > 0 ? (
|
|
<>
|
|
<Text
|
|
variant="caption"
|
|
color={colors.subtle}
|
|
style={{ marginTop: 14, marginBottom: 8 }}
|
|
>
|
|
Severe (rare)
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
|
{facts.sideEffects.severe.map((s) => (
|
|
<Badge key={s} label={s} tone="danger" icon="alert-outline" />
|
|
))}
|
|
</View>
|
|
</>
|
|
) : null}
|
|
</Section>
|
|
|
|
<Section title="Storage">
|
|
<View style={{ gap: 10 }}>
|
|
{facts.storage.map((note) => (
|
|
<Card key={note.title} elevated={false}>
|
|
<View style={{ flexDirection: "row", gap: 12, alignItems: "flex-start" }}>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 12,
|
|
backgroundColor: colors.brandSoft,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Ionicons name={note.icon} size={18} color={colors.brandText} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="h3">{note.title}</Text>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 2 }}>
|
|
{note.body}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
))}
|
|
</View>
|
|
</Section>
|
|
|
|
<Section title="Directions of use">
|
|
<View style={{ gap: 0, paddingLeft: 8 }}>
|
|
{facts.directions.map((step, index) => (
|
|
<View key={step.title} style={{ flexDirection: "row", gap: 14 }}>
|
|
<View style={{ alignItems: "center", width: 22 }}>
|
|
<View
|
|
style={{
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
backgroundColor: colors.brand,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Text variant="micro" color={colors.ink}>
|
|
{index + 1}
|
|
</Text>
|
|
</View>
|
|
{index < facts.directions.length - 1 ? (
|
|
<View
|
|
style={{
|
|
width: 2,
|
|
flex: 1,
|
|
minHeight: 28,
|
|
backgroundColor: colors.brandBorder,
|
|
}}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
<View style={{ flex: 1, paddingBottom: 16 }}>
|
|
<Text variant="h3">{step.title}</Text>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 2 }}>
|
|
{step.body}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</Section>
|
|
|
|
<Section title="Reviews">
|
|
<Card elevated={false}>
|
|
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 8 }}>
|
|
<Text variant="display">{product.rating.toFixed(1)}</Text>
|
|
<Text variant="bodySm" color={colors.subtle} style={{ marginBottom: 6 }}>
|
|
{product.reviews} reviews
|
|
</Text>
|
|
</View>
|
|
<View style={{ marginTop: 14, gap: 8 }}>
|
|
{reviews.map((row) => {
|
|
const max = reviews[0]?.count || 1;
|
|
return (
|
|
<View
|
|
key={row.stars}
|
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
|
>
|
|
<Text variant="caption" color={colors.muted} style={{ width: 14 }}>
|
|
{row.stars}
|
|
</Text>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: colors.surfaceAlt,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: `${Math.round((row.count / max) * 100)}%`,
|
|
height: "100%",
|
|
backgroundColor: colors.brand,
|
|
}}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</Card>
|
|
</Section>
|
|
|
|
<Card tone="brand" elevated={false} style={{ marginTop: 8 }}>
|
|
<Text variant="h3">Pharmacist tip</Text>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
|
{facts.advice}
|
|
</Text>
|
|
</Card>
|
|
</View>
|
|
) : (
|
|
<View style={{ paddingHorizontal: gutter, paddingBottom: 20 }}>
|
|
<Section title="Available at">
|
|
{branches.slice(0, 4).map((branch, index) => (
|
|
<Card
|
|
key={branch.id}
|
|
onPress={() => router.push(routes.branches)}
|
|
style={{ marginTop: index === 0 ? 0 : 10 }}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="h3">{branch.name}</Text>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
|
{branch.area}
|
|
</Text>
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
{branch.hours}
|
|
{branch.pickupAvailable ? " · Pickup available" : ""}
|
|
</Text>
|
|
</View>
|
|
{index === 0 ? (
|
|
<StockLabel inStock={product.inStock} count={facts.stockCount} />
|
|
) : (
|
|
<Badge
|
|
label={product.inStock ? "In stock" : "Call"}
|
|
tone={product.inStock ? "brand" : "warn"}
|
|
/>
|
|
)}
|
|
</View>
|
|
</Card>
|
|
))}
|
|
<Button
|
|
title="View all branches"
|
|
variant="outline"
|
|
onPress={() => router.push(routes.branches)}
|
|
style={{ marginTop: 14 }}
|
|
/>
|
|
</Section>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
|
|
<StickyBar floating>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="caption" color="rgba(255,255,255,0.7)" numberOfLines={1}>
|
|
{product.name}
|
|
</Text>
|
|
<Text variant="h3" color={colors.surface}>
|
|
{formatETB(product.price * qty)}
|
|
</Text>
|
|
</View>
|
|
<Button
|
|
title="Add"
|
|
size="md"
|
|
fullWidth={false}
|
|
icon="bag-add-outline"
|
|
onPress={addToCart}
|
|
disabled={!product.inStock}
|
|
style={{ minWidth: 110 }}
|
|
/>
|
|
</StickyBar>
|
|
</View>
|
|
);
|
|
}
|