Ship Expo Router screens, seeded stores, and polished UX so auth, shop, checkout, prescriptions, loyalty, family, and account processes work end-to-end offline. Co-authored-by: Cursor <cursoragent@cursor.com>
263 lines
7.6 KiB
TypeScript
263 lines
7.6 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { router } from "expo-router";
|
|
import { Image, Pressable, View } from "react-native";
|
|
import { AddButton } from "@/components/ui/Button";
|
|
import { Badge, Price, StockLabel } from "@/components/ui/Badge";
|
|
import { MetaTag } from "@/components/ui/Chip";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { getDrugFacts } from "@/lib/data/clinical";
|
|
import type { Product } from "@/lib/data/products";
|
|
import { routes } from "@/lib/routes";
|
|
import { useCartStore } from "@/lib/stores/cartStore";
|
|
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
|
|
import { colors, radii, shadows, space } from "@/lib/theme";
|
|
import { formatETB } from "@/lib/utils";
|
|
|
|
export const GRID_CARD_GAP = 12;
|
|
|
|
function discount(product: Product) {
|
|
if (!product.compareAt || product.compareAt <= product.price) return null;
|
|
return `${Math.round(((product.compareAt - product.price) / product.compareAt) * 100)}% OFF`;
|
|
}
|
|
|
|
function useCardActions(product: Product) {
|
|
const addProduct = useCartStore((s) => s.addProduct);
|
|
return {
|
|
open: () => router.push(routes.product(product.id)),
|
|
add: () => addProduct(product, 1),
|
|
};
|
|
}
|
|
|
|
/** Grid tile used in shop, search results and category listings. */
|
|
export function ProductCard({
|
|
product,
|
|
width,
|
|
}: {
|
|
product: Product;
|
|
width?: number | `${number}%`;
|
|
}) {
|
|
const { open, add } = useCardActions(product);
|
|
const facts = getDrugFacts(product);
|
|
const off = discount(product);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={open}
|
|
style={({ pressed }) => [
|
|
{
|
|
width,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: radii.card,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
padding: space.md,
|
|
opacity: pressed ? 0.94 : 1,
|
|
},
|
|
shadows.xs,
|
|
]}
|
|
>
|
|
<View
|
|
style={{
|
|
height: 108,
|
|
borderRadius: radii.sm,
|
|
backgroundColor: colors.surfaceAlt,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Image
|
|
source={{ uri: product.image }}
|
|
style={{ width: "82%", height: "82%" }}
|
|
resizeMode="contain"
|
|
/>
|
|
{off ? (
|
|
<Badge label={off} solid style={{ position: "absolute", top: 8, left: 8 }} />
|
|
) : null}
|
|
{product.requiresPrescription ? (
|
|
<Badge
|
|
label="RX"
|
|
tone="info"
|
|
style={{ position: "absolute", top: 8, right: 8 }}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
|
|
<Text variant="micro" color={colors.subtle} style={{ marginTop: 10 }}>
|
|
{facts.useCase}
|
|
</Text>
|
|
<Text variant="h3" numberOfLines={2} style={{ marginTop: 2, minHeight: 40 }}>
|
|
{product.name}
|
|
</Text>
|
|
|
|
<View style={{ marginTop: 6 }}>
|
|
<Price value={product.price} compareAt={product.compareAt} format={formatETB} />
|
|
</View>
|
|
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, marginTop: 8 }}>
|
|
<MetaTag label={facts.form} icon="ellipse-outline" />
|
|
<MetaTag label={product.unit} />
|
|
</View>
|
|
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginTop: 10,
|
|
}}
|
|
>
|
|
<StockLabel inStock={product.inStock} count={facts.stockCount} />
|
|
<AddButton onPress={add} disabled={!product.inStock} />
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/** Horizontal row used in "Most popular", favorites and list-view results. */
|
|
export function ProductRow({ product }: { product: Product }) {
|
|
const { open, add } = useCardActions(product);
|
|
const facts = getDrugFacts(product);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={open}
|
|
style={({ pressed }) => [
|
|
{
|
|
flexDirection: "row",
|
|
gap: space.md,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: radii.card,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
padding: space.md,
|
|
alignItems: "center",
|
|
opacity: pressed ? 0.94 : 1,
|
|
},
|
|
shadows.xs,
|
|
]}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 66,
|
|
height: 66,
|
|
borderRadius: radii.sm,
|
|
backgroundColor: colors.surfaceAlt,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Image
|
|
source={{ uri: product.image }}
|
|
style={{ width: "80%", height: "80%" }}
|
|
resizeMode="contain"
|
|
/>
|
|
</View>
|
|
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="micro" color={colors.subtle}>
|
|
{facts.useCase}
|
|
</Text>
|
|
<Text variant="h3" numberOfLines={1} style={{ marginTop: 1 }}>
|
|
{product.name}
|
|
</Text>
|
|
<View style={{ marginTop: 4 }}>
|
|
<Price value={product.price} compareAt={product.compareAt} format={formatETB} />
|
|
</View>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, marginTop: 5 }}>
|
|
<MetaTag label={facts.form} icon="ellipse-outline" />
|
|
<StockLabel inStock={product.inStock} count={facts.stockCount} />
|
|
</View>
|
|
</View>
|
|
|
|
<AddButton onPress={add} disabled={!product.inStock} />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/** Compact tile for horizontal carousels on the home screen. */
|
|
export function ProductTile({ product }: { product: Product }) {
|
|
const { open, add } = useCardActions(product);
|
|
const liked = useFavoritesStore((s) => s.has(product.id));
|
|
const toggle = useFavoritesStore((s) => s.toggle);
|
|
const off = discount(product);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={open}
|
|
style={({ pressed }) => [
|
|
{
|
|
width: 158,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: radii.card,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
padding: space.md,
|
|
opacity: pressed ? 0.94 : 1,
|
|
},
|
|
shadows.xs,
|
|
]}
|
|
>
|
|
<View
|
|
style={{
|
|
height: 96,
|
|
borderRadius: radii.sm,
|
|
backgroundColor: colors.surfaceAlt,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Image
|
|
source={{ uri: product.image }}
|
|
style={{ width: "82%", height: "82%" }}
|
|
resizeMode="contain"
|
|
/>
|
|
{off ? (
|
|
<Badge label={off} solid style={{ position: "absolute", top: 6, left: 6 }} />
|
|
) : null}
|
|
<Pressable
|
|
onPress={() => toggle(product.id)}
|
|
hitSlop={8}
|
|
style={{
|
|
position: "absolute",
|
|
top: 6,
|
|
right: 6,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 13,
|
|
backgroundColor: colors.surface,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={liked ? "heart" : "heart-outline"}
|
|
size={14}
|
|
color={liked ? colors.danger : colors.muted}
|
|
/>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Text variant="h3" numberOfLines={2} style={{ marginTop: 10, minHeight: 40 }}>
|
|
{product.name}
|
|
</Text>
|
|
<View style={{ marginTop: 4 }}>
|
|
<Price value={product.price} compareAt={product.compareAt} size="sm" format={formatETB} />
|
|
</View>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginTop: 8,
|
|
}}
|
|
>
|
|
<StockLabel inStock={product.inStock} />
|
|
<AddButton onPress={add} disabled={!product.inStock} size={30} />
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|