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>
222 lines
4.9 KiB
TypeScript
222 lines
4.9 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import {
|
|
Pressable,
|
|
View,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { colors, radii, shadows, space } from "@/lib/theme";
|
|
|
|
export function Card({
|
|
children,
|
|
onPress,
|
|
padded = true,
|
|
tone = "surface",
|
|
elevated = true,
|
|
style,
|
|
}: {
|
|
children: ReactNode;
|
|
onPress?: () => void;
|
|
padded?: boolean;
|
|
tone?: "surface" | "wash" | "brand";
|
|
elevated?: boolean;
|
|
style?: StyleProp<ViewStyle>;
|
|
}) {
|
|
const bg =
|
|
tone === "wash" ? colors.surfaceAlt : tone === "brand" ? colors.brandWash : colors.surface;
|
|
|
|
const base: StyleProp<ViewStyle> = [
|
|
{
|
|
backgroundColor: bg,
|
|
borderRadius: radii.card,
|
|
borderWidth: 1,
|
|
borderColor: tone === "brand" ? colors.brandBorder : colors.line,
|
|
padding: padded ? space.lg : 0,
|
|
overflow: "hidden",
|
|
},
|
|
elevated && tone === "surface" ? shadows.xs : null,
|
|
style,
|
|
];
|
|
|
|
if (!onPress) return <View style={base}>{children}</View>;
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
style={({ pressed }) => [base, pressed && { opacity: 0.92 }]}
|
|
>
|
|
{children}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export function SectionHeader({
|
|
title,
|
|
action,
|
|
onAction,
|
|
style,
|
|
}: {
|
|
title: string;
|
|
action?: string;
|
|
onAction?: () => void;
|
|
style?: StyleProp<ViewStyle>;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={[
|
|
{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginBottom: space.md,
|
|
},
|
|
style,
|
|
]}
|
|
>
|
|
<Text variant="h2">{title}</Text>
|
|
{action ? (
|
|
<Pressable onPress={onAction} hitSlop={8}>
|
|
<Text variant="label" color={colors.brandText}>
|
|
{action}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function Section({
|
|
title,
|
|
action,
|
|
onAction,
|
|
children,
|
|
style,
|
|
}: {
|
|
title?: string;
|
|
action?: string;
|
|
onAction?: () => void;
|
|
children: ReactNode;
|
|
style?: StyleProp<ViewStyle>;
|
|
}) {
|
|
return (
|
|
<View style={[{ marginTop: space["2xl"] }, style]}>
|
|
{title ? <SectionHeader title={title} action={action} onAction={onAction} /> : null}
|
|
{children}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function Divider({ style }: { style?: StyleProp<ViewStyle> }) {
|
|
return <View style={[{ height: 1, backgroundColor: colors.line }, style]} />;
|
|
}
|
|
|
|
/** Key/value row used for drug facts and order summaries. */
|
|
export function InfoRow({
|
|
label,
|
|
value,
|
|
valueColor,
|
|
emphasis,
|
|
last,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
valueColor?: string;
|
|
emphasis?: boolean;
|
|
last?: boolean;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
justifyContent: "space-between",
|
|
gap: 16,
|
|
paddingVertical: 12,
|
|
borderBottomWidth: last ? 0 : 1,
|
|
borderBottomColor: colors.line,
|
|
}}
|
|
>
|
|
<Text variant="bodySm" color={colors.muted}>
|
|
{label}
|
|
</Text>
|
|
<Text
|
|
variant={emphasis ? "h3" : "label"}
|
|
color={valueColor ?? colors.ink}
|
|
style={{ flexShrink: 1, textAlign: "right" }}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/** Tappable settings/menu row with icon tile, title and chevron. */
|
|
export function MenuRow({
|
|
icon,
|
|
title,
|
|
subtitle,
|
|
onPress,
|
|
right,
|
|
tone = "brand",
|
|
last,
|
|
}: {
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
title: string;
|
|
subtitle?: string;
|
|
onPress?: () => void;
|
|
right?: ReactNode;
|
|
tone?: "brand" | "danger" | "neutral";
|
|
last?: boolean;
|
|
}) {
|
|
const iconBg =
|
|
tone === "danger"
|
|
? colors.dangerSoft
|
|
: tone === "neutral"
|
|
? colors.surfaceAlt
|
|
: colors.brandSoft;
|
|
const iconFg =
|
|
tone === "danger" ? colors.danger : tone === "neutral" ? colors.muted : colors.brandText;
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
style={({ pressed }) => ({
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: space.lg,
|
|
borderBottomWidth: last ? 0 : 1,
|
|
borderBottomColor: colors.line,
|
|
backgroundColor: pressed ? colors.surfaceAlt : "transparent",
|
|
})}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 12,
|
|
backgroundColor: iconBg,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Ionicons name={icon} size={19} color={iconFg} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="h3" color={tone === "danger" ? colors.danger : colors.ink}>
|
|
{title}
|
|
</Text>
|
|
{subtitle ? (
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
|
{subtitle}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
{right ?? <Ionicons name="chevron-forward" size={18} color={colors.subtle} />}
|
|
</Pressable>
|
|
);
|
|
}
|