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>
139 lines
3.4 KiB
TypeScript
139 lines
3.4 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { colors, radii } from "@/lib/theme";
|
|
|
|
type Tone = "brand" | "danger" | "warn" | "info" | "neutral" | "dark";
|
|
|
|
const tones: Record<Tone, { bg: string; fg: string }> = {
|
|
brand: { bg: colors.brandSoft, fg: colors.brandText },
|
|
danger: { bg: colors.dangerSoft, fg: colors.danger },
|
|
warn: { bg: colors.warnSoft, fg: colors.warn },
|
|
info: { bg: colors.infoSoft, fg: colors.info },
|
|
neutral: { bg: colors.surfaceAlt, fg: colors.muted },
|
|
dark: { bg: colors.ink, fg: colors.surface },
|
|
};
|
|
|
|
export function Badge({
|
|
label,
|
|
tone = "brand",
|
|
icon,
|
|
solid,
|
|
style,
|
|
}: {
|
|
label: string;
|
|
tone?: Tone;
|
|
icon?: keyof typeof Ionicons.glyphMap;
|
|
/** Filled lime treatment used for discount flags on imagery. */
|
|
solid?: boolean;
|
|
style?: StyleProp<ViewStyle>;
|
|
}) {
|
|
const skin = tones[tone];
|
|
const bg = solid ? colors.brand : skin.bg;
|
|
const fg = solid ? colors.ink : skin.fg;
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
alignSelf: "flex-start",
|
|
backgroundColor: bg,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
borderRadius: radii.xs,
|
|
},
|
|
style,
|
|
]}
|
|
>
|
|
{icon ? <Ionicons name={icon} size={11} color={fg} /> : null}
|
|
<Text variant="micro" color={fg}>
|
|
{label}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function StockLabel({
|
|
inStock,
|
|
count,
|
|
}: {
|
|
inStock: boolean;
|
|
count?: number;
|
|
}) {
|
|
return (
|
|
<Text variant="micro" color={inStock ? colors.inStock : colors.outOfStock}>
|
|
{inStock ? `In stock${count ? ` (${count})` : ""}` : "Out of stock"}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
export function Rating({
|
|
value,
|
|
count,
|
|
showStars = true,
|
|
size = 13,
|
|
}: {
|
|
value: number;
|
|
count?: number;
|
|
showStars?: boolean;
|
|
size?: number;
|
|
}) {
|
|
return (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
{showStars ? (
|
|
<View style={{ flexDirection: "row", gap: 1 }}>
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<Ionicons
|
|
key={i}
|
|
name={
|
|
value >= i ? "star" : value >= i - 0.5 ? "star-half" : "star-outline"
|
|
}
|
|
size={size}
|
|
color={value >= i - 0.5 ? colors.star : colors.disabled}
|
|
/>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<Ionicons name="star" size={size} color={colors.star} />
|
|
)}
|
|
<Text variant="micro" color={colors.muted}>
|
|
{value.toFixed(1)}
|
|
{count !== undefined ? ` (${count})` : ""}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/** Compact price with optional struck-through original. */
|
|
export function Price({
|
|
value,
|
|
compareAt,
|
|
size = "md",
|
|
format,
|
|
}: {
|
|
value: number;
|
|
compareAt?: number;
|
|
size?: "sm" | "md" | "lg";
|
|
format: (n: number) => string;
|
|
}) {
|
|
return (
|
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
|
|
<Text variant={size === "lg" ? "priceLg" : size === "sm" ? "label" : "price"}>
|
|
{format(value)}
|
|
</Text>
|
|
{compareAt && compareAt > value ? (
|
|
<Text
|
|
variant="caption"
|
|
color={colors.subtle}
|
|
style={{ textDecorationLine: "line-through" }}
|
|
>
|
|
{format(compareAt)}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|