Add payment/notification prefs, assisted pharmacist codes, Rx status journey, and mock payment intents so demo flows match the storefront brief. Co-authored-by: Cursor <cursoragent@cursor.com>
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import * as ImagePicker from "expo-image-picker";
|
|
import { router } from "expo-router";
|
|
import { useState } from "react";
|
|
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { BiometricRow, LoyaltyStrip, QuickActions } from "@/components/profile";
|
|
import { Avatar } from "@/components/ui/Avatar";
|
|
import { Badge } from "@/components/ui/Badge";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card, MenuRow } from "@/components/ui/Card";
|
|
import { Field } from "@/components/ui/Field";
|
|
import { AppHeader } from "@/components/ui/Header";
|
|
import { Screen } from "@/components/ui/Screen";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { routes } from "@/lib/routes";
|
|
import { useAuthStore, type AuthProvider } from "@/lib/stores/authStore";
|
|
import { useLangStore } from "@/lib/stores/langStore";
|
|
import { useLocationStore } from "@/lib/stores/locationStore";
|
|
import { usePrefsStore } from "@/lib/stores/prefsStore";
|
|
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
|
import { paymentLabel } from "@/components/checkout/PaymentMethodList";
|
|
import { colors, gutter } from "@/lib/theme";
|
|
import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils";
|
|
|
|
export default function ProfileScreen() {
|
|
const { t } = useTranslation();
|
|
const user = useAuthStore((s) => s.user);
|
|
const setAvatar = useAuthStore((s) => s.setAvatar);
|
|
const updateProfile = useAuthStore((s) => s.updateProfile);
|
|
const setBiometric = useAuthStore((s) => s.setBiometric);
|
|
const signOut = useAuthStore((s) => s.signOut);
|
|
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
|
|
const mode = useProfileModeStore((s) => s.mode);
|
|
const membership = useProfileModeStore((s) => s.activeMembership());
|
|
const language = useLangStore((s) => s.language);
|
|
const setLanguage = useLangStore((s) => s.setLanguage);
|
|
const payment = useLocationStore((s) => s.paymentPreference);
|
|
const prefs = usePrefsStore();
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
const [name, setName] = useState(user?.name ?? "");
|
|
const [phone, setPhone] = useState(user?.phone ?? "");
|
|
const [email, setEmail] = useState(user?.email ?? "");
|
|
|
|
const pickAvatar = async () => {
|
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (!permission.granted) {
|
|
Alert.alert(
|
|
"Photo access needed",
|
|
"Allow photo library access to set your profile picture."
|
|
);
|
|
return;
|
|
}
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ["images"],
|
|
allowsEditing: true,
|
|
aspect: [1, 1],
|
|
quality: 0.85,
|
|
});
|
|
if (!result.canceled && result.assets[0]?.uri) {
|
|
setAvatar(result.assets[0].uri);
|
|
}
|
|
};
|
|
|
|
const takePhoto = async () => {
|
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
|
if (!permission.granted) {
|
|
Alert.alert("Camera access needed", "Allow camera access to take a profile photo.");
|
|
return;
|
|
}
|
|
const result = await ImagePicker.launchCameraAsync({
|
|
allowsEditing: true,
|
|
aspect: [1, 1],
|
|
quality: 0.85,
|
|
});
|
|
if (!result.canceled && result.assets[0]?.uri) {
|
|
setAvatar(result.assets[0].uri);
|
|
}
|
|
};
|
|
|
|
const changePhoto = () => {
|
|
Alert.alert("Profile photo", "Choose how you'd like to update your photo.", [
|
|
{ text: "Choose from library", onPress: pickAvatar },
|
|
{ text: "Take photo", onPress: takePhoto },
|
|
...(user?.avatarUri
|
|
? [{ text: "Remove photo", style: "destructive" as const, onPress: () => setAvatar(null) }]
|
|
: []),
|
|
{ text: "Cancel", style: "cancel" },
|
|
]);
|
|
};
|
|
|
|
const links: {
|
|
label: string;
|
|
href: string;
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
subtitle?: string;
|
|
}[] = [
|
|
{ label: t("common.switchProfile"), href: routes.profileSwitch, icon: "swap-horizontal", subtitle: mode },
|
|
{ label: t("common.joinOrg"), href: routes.profileJoin, icon: "business-outline" },
|
|
{ label: t("common.orders"), href: routes.orders, icon: "receipt-outline", subtitle: "Track delivery" },
|
|
{
|
|
label: "Payment methods",
|
|
href: routes.paymentMethods,
|
|
icon: "card-outline",
|
|
subtitle: paymentLabel(payment),
|
|
},
|
|
{
|
|
label: "Notification preferences",
|
|
href: routes.notificationPrefs,
|
|
icon: "notifications-outline",
|
|
subtitle: [
|
|
prefs.push ? "Push" : null,
|
|
prefs.sms ? "SMS" : null,
|
|
prefs.email ? "Email" : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ") || "All off",
|
|
},
|
|
{ label: t("common.notifications"), href: routes.notifications, icon: "mail-unread-outline", subtitle: "Alerts inbox" },
|
|
{ label: t("common.prescriptions"), href: routes.rxList, icon: "medkit-outline", subtitle: "Rx + family" },
|
|
{ label: t("common.loyalty"), href: routes.loyalty, icon: "star-outline", subtitle: `${user?.points ?? 0} pts` },
|
|
{ label: t("common.subscriptions"), href: routes.subscriptions, icon: "repeat-outline" },
|
|
{ label: t("common.emergencyContacts"), href: routes.emergencyContacts, icon: "alert-circle-outline" },
|
|
{ label: t("common.branches"), href: routes.branches, icon: "location-outline" },
|
|
{ label: t("common.addresses"), href: routes.addresses, icon: "home-outline" },
|
|
{ label: t("common.about"), href: routes.about, icon: "information-circle-outline" },
|
|
{ label: t("common.contact"), href: routes.contact, icon: "mail-outline" },
|
|
];
|
|
|
|
return (
|
|
<Screen>
|
|
<AppHeader title={t("common.profile")} subtitle="Account and preferences" />
|
|
<ScrollView
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={{
|
|
paddingBottom: 40,
|
|
paddingHorizontal: gutter,
|
|
}}
|
|
>
|
|
<Card style={{ marginTop: 4 }}>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
|
|
<Pressable onPress={changePhoto}>
|
|
<View>
|
|
<Avatar name={user?.name} uri={user?.avatarUri} size={72} />
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
right: -2,
|
|
bottom: -2,
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: 13,
|
|
backgroundColor: colors.brand,
|
|
borderWidth: 2,
|
|
borderColor: colors.surface,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Ionicons name="camera" size={13} color={colors.ink} />
|
|
</View>
|
|
</View>
|
|
</Pressable>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="h2">{user?.name || "Guest"}</Text>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 2 }}>
|
|
{user?.phone || "—"}
|
|
</Text>
|
|
{user?.email ? (
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
|
{user.email}
|
|
</Text>
|
|
) : null}
|
|
<View style={{ flexDirection: "row", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
|
|
{user?.authProvider && user.authProvider !== "guest" ? (
|
|
<Badge label={providerLabel(user.authProvider)} tone="info" />
|
|
) : null}
|
|
<Badge
|
|
label={
|
|
mode === "corporate"
|
|
? membership?.orgName || t("common.corporate")
|
|
: t("common.personal")
|
|
}
|
|
tone="brand"
|
|
/>
|
|
{user?.isGuest ? <Badge label="Guest" tone="neutral" /> : null}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
<View style={{ flexDirection: "row", gap: 12, marginTop: 14 }}>
|
|
<Pressable onPress={changePhoto}>
|
|
<Text variant="label" color={colors.brandText}>
|
|
{user?.avatarUri ? "Change photo" : "Add photo"}
|
|
</Text>
|
|
</Pressable>
|
|
{!user?.isGuest ? (
|
|
<Pressable
|
|
onPress={() => {
|
|
setName(user?.name ?? "");
|
|
setPhone(user?.phone ?? "");
|
|
setEmail(user?.email ?? "");
|
|
setEditing((v) => !v);
|
|
}}
|
|
>
|
|
<Text variant="label" color={colors.brandText}>
|
|
{editing ? "Close editor" : "Edit profile"}
|
|
</Text>
|
|
</Pressable>
|
|
) : (
|
|
<Pressable
|
|
onPress={() => {
|
|
signOut();
|
|
setHasOnboarded(false);
|
|
router.replace(routes.signIn as never);
|
|
}}
|
|
>
|
|
<Text variant="label" color={colors.brandText}>
|
|
Sign in / create account
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
{membership ? (
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
|
|
{membership.planName} · {membership.remainingAllowance} ETB allowance left
|
|
</Text>
|
|
) : null}
|
|
</Card>
|
|
|
|
{!user?.isGuest ? (
|
|
<LoyaltyStrip points={user?.points ?? 0} tier={user?.tier ?? "bronze"} />
|
|
) : null}
|
|
|
|
<QuickActions />
|
|
|
|
{editing && !user?.isGuest ? (
|
|
<Card elevated={false} style={{ marginTop: 12 }}>
|
|
<Text variant="h3" style={{ marginBottom: 12 }}>
|
|
Edit profile
|
|
</Text>
|
|
<View style={{ gap: 12 }}>
|
|
<Field label="Full name" value={name} onChangeText={setName} />
|
|
<Field
|
|
label="Phone"
|
|
value={phone}
|
|
onChangeText={setPhone}
|
|
keyboardType="phone-pad"
|
|
/>
|
|
<Field
|
|
label="Email"
|
|
value={email}
|
|
onChangeText={setEmail}
|
|
keyboardType="email-address"
|
|
autoCapitalize="none"
|
|
/>
|
|
<Button
|
|
title="Save changes"
|
|
onPress={() => {
|
|
if (!name.trim()) {
|
|
Alert.alert("Name required");
|
|
return;
|
|
}
|
|
updateProfile({
|
|
name: name.trim(),
|
|
phone: phone.trim(),
|
|
email: email.trim() || undefined,
|
|
});
|
|
setEditing(false);
|
|
Alert.alert("Saved", "Your profile was updated.");
|
|
}}
|
|
/>
|
|
</View>
|
|
</Card>
|
|
) : null}
|
|
|
|
{!user?.isGuest ? (
|
|
<Card padded={false} elevated={false} style={{ marginTop: 14, overflow: "hidden" }}>
|
|
<BiometricRow
|
|
enabled={!!user?.biometricEnabled}
|
|
onChange={setBiometric}
|
|
/>
|
|
</Card>
|
|
) : null}
|
|
|
|
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
|
|
{[
|
|
{ label: t("common.call"), icon: "call-outline" as const, href: `tel:${CALL_PHONE}` },
|
|
{
|
|
label: t("common.whatsapp"),
|
|
icon: "logo-whatsapp" as const,
|
|
href: `https://wa.me/${WHATSAPP_NUMBER}`,
|
|
},
|
|
{ label: t("common.chat"), icon: "chatbubble-ellipses-outline" as const, href: CHATWOOT_BASE },
|
|
].map((item) => (
|
|
<Pressable
|
|
key={item.label}
|
|
onPress={() => Linking.openURL(item.href)}
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
paddingVertical: 12,
|
|
alignItems: "center",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<Ionicons name={item.icon} size={18} color={colors.brandText} />
|
|
<Text variant="micro">{item.label}</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
|
|
<Card padded={false} style={{ marginTop: 18, overflow: "hidden" }}>
|
|
<MenuRow
|
|
icon="language-outline"
|
|
title={t("common.language")}
|
|
subtitle={language === "en" ? "English" : "አማርኛ"}
|
|
onPress={() => setLanguage(language === "en" ? "am" : "en")}
|
|
right={
|
|
<Text variant="label" color={colors.brandText}>
|
|
{language.toUpperCase()}
|
|
</Text>
|
|
}
|
|
/>
|
|
{links.map((l, index) => (
|
|
<MenuRow
|
|
key={l.href}
|
|
icon={l.icon}
|
|
title={l.label}
|
|
subtitle={l.subtitle}
|
|
onPress={() => router.push(l.href as never)}
|
|
last={index === links.length - 1}
|
|
/>
|
|
))}
|
|
</Card>
|
|
|
|
<Card padded={false} style={{ marginTop: 12, overflow: "hidden" }}>
|
|
<MenuRow
|
|
icon="log-out-outline"
|
|
title="Sign out"
|
|
tone="danger"
|
|
last
|
|
onPress={() => {
|
|
signOut();
|
|
setHasOnboarded(false);
|
|
router.replace(routes.welcome);
|
|
}}
|
|
/>
|
|
</Card>
|
|
</ScrollView>
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
function providerLabel(provider: AuthProvider) {
|
|
if (provider === "google") return "Google";
|
|
if (provider === "email") return "Email";
|
|
if (provider === "telegram") return "Telegram";
|
|
if (provider === "phone") return "Phone";
|
|
return "Guest";
|
|
}
|