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>
233 lines
5.6 KiB
TypeScript
233 lines
5.6 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import {
|
|
Pressable,
|
|
Switch,
|
|
TextInput,
|
|
View,
|
|
type TextInputProps,
|
|
} from "react-native";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { colors, fonts, radii } from "@/lib/theme";
|
|
|
|
type FieldProps = TextInputProps & {
|
|
label?: string;
|
|
icon?: keyof typeof Ionicons.glyphMap;
|
|
error?: string;
|
|
hint?: string;
|
|
secure?: boolean;
|
|
};
|
|
|
|
export function Field({
|
|
label,
|
|
icon,
|
|
error,
|
|
hint,
|
|
secure,
|
|
style,
|
|
...rest
|
|
}: FieldProps) {
|
|
const [focused, setFocused] = useState(false);
|
|
const [hidden, setHidden] = useState(!!secure);
|
|
|
|
const borderColor = error ? colors.danger : focused ? colors.brand : colors.line;
|
|
|
|
return (
|
|
<View>
|
|
{label ? (
|
|
<Text variant="label" color={colors.muted} style={{ marginBottom: 8 }}>
|
|
{label}
|
|
</Text>
|
|
) : null}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: radii.md,
|
|
borderWidth: 1.5,
|
|
borderColor,
|
|
paddingHorizontal: 14,
|
|
height: 52,
|
|
}}
|
|
>
|
|
{icon ? (
|
|
<Ionicons name={icon} size={18} color={focused ? colors.brandText : colors.subtle} />
|
|
) : null}
|
|
<TextInput
|
|
placeholderTextColor={colors.subtle}
|
|
secureTextEntry={hidden}
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={() => setFocused(false)}
|
|
style={[
|
|
{
|
|
flex: 1,
|
|
fontFamily: fonts.regular,
|
|
fontSize: 14,
|
|
color: colors.ink,
|
|
paddingVertical: 0,
|
|
},
|
|
style,
|
|
]}
|
|
{...rest}
|
|
/>
|
|
{secure ? (
|
|
<Pressable onPress={() => setHidden(!hidden)} hitSlop={8}>
|
|
<Ionicons
|
|
name={hidden ? "eye-off-outline" : "eye-outline"}
|
|
size={18}
|
|
color={colors.subtle}
|
|
/>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
{error ? (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 5, marginTop: 6 }}>
|
|
<Ionicons name="alert-circle" size={13} color={colors.danger} />
|
|
<Text variant="caption" color={colors.danger}>
|
|
{error}
|
|
</Text>
|
|
</View>
|
|
) : hint ? (
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 6 }}>
|
|
{hint}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function SwitchRow({
|
|
title,
|
|
subtitle,
|
|
value,
|
|
onValueChange,
|
|
icon,
|
|
last,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
value: boolean;
|
|
onValueChange: (v: boolean) => void;
|
|
icon?: keyof typeof Ionicons.glyphMap;
|
|
last?: boolean;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 16,
|
|
borderBottomWidth: last ? 0 : 1,
|
|
borderBottomColor: colors.line,
|
|
}}
|
|
>
|
|
{icon ? (
|
|
<View
|
|
style={{
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 12,
|
|
backgroundColor: colors.brandSoft,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Ionicons name={icon} size={19} color={colors.brandText} />
|
|
</View>
|
|
) : null}
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="h3">{title}</Text>
|
|
{subtitle ? (
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
|
{subtitle}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
<Switch
|
|
value={value}
|
|
onValueChange={onValueChange}
|
|
trackColor={{ false: colors.line, true: colors.brand }}
|
|
thumbColor={colors.surface}
|
|
ios_backgroundColor={colors.line}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/** Radio-style selectable row used for payment methods and addresses. */
|
|
export function OptionRow({
|
|
title,
|
|
subtitle,
|
|
selected,
|
|
onPress,
|
|
icon,
|
|
trailing,
|
|
last,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
selected: boolean;
|
|
onPress: () => void;
|
|
icon?: keyof typeof Ionicons.glyphMap;
|
|
trailing?: ReactNode;
|
|
last?: boolean;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 14,
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 16,
|
|
borderBottomWidth: last ? 0 : 1,
|
|
borderBottomColor: colors.line,
|
|
backgroundColor: selected ? colors.brandWash : "transparent",
|
|
}}
|
|
>
|
|
{icon ? (
|
|
<View
|
|
style={{
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 12,
|
|
backgroundColor: selected ? colors.brandSoft : colors.surfaceAlt,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={icon}
|
|
size={19}
|
|
color={selected ? colors.brandText : colors.muted}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="h3">{title}</Text>
|
|
{subtitle ? (
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
|
{subtitle}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
{trailing}
|
|
<View
|
|
style={{
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
borderWidth: selected ? 7 : 1.5,
|
|
borderColor: selected ? colors.brand : colors.line,
|
|
backgroundColor: colors.surface,
|
|
}}
|
|
/>
|
|
</Pressable>
|
|
);
|
|
}
|