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>
209 lines
5.6 KiB
TypeScript
209 lines
5.6 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import {
|
|
ActivityIndicator,
|
|
Pressable,
|
|
View,
|
|
type PressableProps,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { colors, radii, shadows } from "@/lib/theme";
|
|
|
|
type Variant = "primary" | "brand" | "dark" | "outline" | "ghost" | "soft" | "danger";
|
|
type Size = "sm" | "md" | "lg";
|
|
|
|
type Props = Omit<PressableProps, "style"> & {
|
|
title: string;
|
|
variant?: Variant;
|
|
size?: Size;
|
|
icon?: keyof typeof Ionicons.glyphMap;
|
|
iconRight?: keyof typeof Ionicons.glyphMap;
|
|
loading?: boolean;
|
|
fullWidth?: boolean;
|
|
elevated?: boolean;
|
|
style?: StyleProp<ViewStyle>;
|
|
};
|
|
|
|
const skins: Record<Variant, { bg: string; fg: string; border: string }> = {
|
|
// Lime fill with ink text: on-brand and far more legible than white-on-lime.
|
|
primary: { bg: colors.brand, fg: colors.ink, border: "transparent" },
|
|
brand: { bg: colors.brand, fg: colors.ink, border: "transparent" },
|
|
dark: { bg: colors.ink, fg: colors.surface, border: "transparent" },
|
|
outline: { bg: "transparent", fg: colors.ink, border: colors.line },
|
|
ghost: { bg: "transparent", fg: colors.ink, border: "transparent" },
|
|
soft: { bg: colors.brandSoft, fg: colors.brandText, border: "transparent" },
|
|
danger: { bg: colors.dangerSoft, fg: colors.danger, border: "transparent" },
|
|
};
|
|
|
|
const sizing: Record<Size, { py: number; px: number; gap: number; icon: number }> = {
|
|
sm: { py: 9, px: 14, gap: 6, icon: 15 },
|
|
md: { py: 13, px: 18, gap: 8, icon: 17 },
|
|
lg: { py: 16, px: 22, gap: 9, icon: 19 },
|
|
};
|
|
|
|
export function Button({
|
|
title,
|
|
variant = "primary",
|
|
size = "lg",
|
|
icon,
|
|
iconRight,
|
|
loading,
|
|
disabled,
|
|
fullWidth = true,
|
|
elevated,
|
|
style,
|
|
...rest
|
|
}: Props) {
|
|
const skin = skins[variant];
|
|
const dims = sizing[size];
|
|
const inactive = disabled || loading;
|
|
|
|
return (
|
|
<Pressable
|
|
disabled={inactive}
|
|
style={({ pressed }) => [
|
|
{
|
|
backgroundColor: inactive ? colors.surfaceAlt : skin.bg,
|
|
borderColor: skin.border,
|
|
borderWidth: variant === "outline" ? 1.5 : 0,
|
|
borderRadius: radii.pill,
|
|
paddingVertical: dims.py,
|
|
paddingHorizontal: dims.px,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: dims.gap,
|
|
alignSelf: fullWidth ? "stretch" : "flex-start",
|
|
opacity: pressed ? 0.88 : 1,
|
|
transform: [{ scale: pressed ? 0.99 : 1 }],
|
|
},
|
|
elevated && !inactive && shadows.sm,
|
|
style,
|
|
]}
|
|
{...rest}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator size="small" color={inactive ? colors.subtle : skin.fg} />
|
|
) : (
|
|
<>
|
|
{icon ? (
|
|
<Ionicons
|
|
name={icon}
|
|
size={dims.icon}
|
|
color={inactive ? colors.subtle : skin.fg}
|
|
/>
|
|
) : null}
|
|
<Text
|
|
variant={size === "sm" ? "label" : "h3"}
|
|
color={inactive ? colors.subtle : skin.fg}
|
|
>
|
|
{title}
|
|
</Text>
|
|
{iconRight ? (
|
|
<Ionicons
|
|
name={iconRight}
|
|
size={dims.icon}
|
|
color={inactive ? colors.subtle : skin.fg}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/** Circular icon button used in headers and cards. */
|
|
export function IconButton({
|
|
icon,
|
|
onPress,
|
|
size = 40,
|
|
tone = "surface",
|
|
color,
|
|
accessibilityLabel,
|
|
}: {
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
onPress?: () => void;
|
|
size?: number;
|
|
tone?: "surface" | "brand" | "dark" | "plain";
|
|
color?: string;
|
|
accessibilityLabel?: string;
|
|
}) {
|
|
const bg =
|
|
tone === "brand"
|
|
? colors.brand
|
|
: tone === "dark"
|
|
? colors.ink
|
|
: tone === "plain"
|
|
? "transparent"
|
|
: colors.surface;
|
|
const fg = color ?? (tone === "dark" ? colors.surface : colors.ink);
|
|
// Ionicons arrows/chevrons sit optically left; nudge for visual centering.
|
|
const isBackIcon = icon === "arrow-back" || icon === "chevron-back";
|
|
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={accessibilityLabel}
|
|
onPress={onPress}
|
|
hitSlop={6}
|
|
style={({ pressed }) => [
|
|
{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: size / 2,
|
|
backgroundColor: bg,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
opacity: pressed ? 0.7 : 1,
|
|
},
|
|
tone === "surface" && { borderWidth: 1, borderColor: colors.line },
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name={icon}
|
|
size={size * 0.46}
|
|
color={fg}
|
|
style={isBackIcon ? { marginLeft: -1 } : undefined}
|
|
/>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/** Small round "+" affordance on product cards. */
|
|
export function AddButton({
|
|
onPress,
|
|
disabled,
|
|
size = 34,
|
|
}: {
|
|
onPress?: () => void;
|
|
disabled?: boolean;
|
|
size?: number;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Add to cart"
|
|
disabled={disabled}
|
|
onPress={onPress}
|
|
hitSlop={8}
|
|
style={({ pressed }) => ({
|
|
width: size,
|
|
height: size,
|
|
borderRadius: size / 2,
|
|
backgroundColor: disabled ? colors.surfaceAlt : colors.brand,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
opacity: pressed ? 0.8 : 1,
|
|
})}
|
|
>
|
|
<Ionicons name="add" size={size * 0.55} color={disabled ? colors.subtle : colors.ink} />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export function ButtonRow({ children }: { children: ReactNode }) {
|
|
return <View style={{ flexDirection: "row", gap: 10 }}>{children}</View>;
|
|
}
|