Add multi-provider login and guest pharmacist order codes.

Guests can generate a shareable GSH code for admin fulfillment; sign-in now supports Google, email, phone, and Telegram demos, with the backend spec updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kirukib 2026-08-07 16:11:23 +03:00
parent 5391780b9b
commit 858972b5ca
18 changed files with 978 additions and 121 deletions

View File

@ -132,13 +132,19 @@ export default function CheckoutScreen() {
const orderNotes = [
notes.trim(),
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
useAnyBranchPickup ? "Quick code — redeemable at any Gishen branch" : "",
isGuest
? "Pharmacist order code — enter in admin to create the order"
: useAnyBranchPickup
? "Quick code — redeemable at any Gishen branch"
: "",
]
.filter(Boolean)
.join(" · ");
const pickupCode =
effectiveFulfillment === "pickup" ? generatePickupCode() : null;
isGuest || effectiveFulfillment === "pickup"
? generatePickupCode()
: null;
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
const order = addOrder({
@ -153,7 +159,7 @@ export default function CheckoutScreen() {
areaLabel: useAnyBranchPickup
? "Any Gishen branch"
: effectiveFulfillment === "pickup"
? branch?.area ?? areaLabel
? branch?.name ?? areaLabel
: selectedAddress?.area ?? areaLabel,
addressId:
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
@ -166,10 +172,13 @@ export default function CheckoutScreen() {
notes: orderNotes,
riderAssigned: false,
pickupCode,
anyBranch: useAnyBranchPickup,
anyBranch: useAnyBranchPickup || isGuest,
isGuestOrder: isGuest,
status:
useAnyBranchPickup || payment === "pay_at_branch" ? "confirmed" : undefined,
status: isGuest
? "requested"
: useAnyBranchPickup || payment === "pay_at_branch"
? "confirmed"
: undefined,
});
if (forMemberId) setActiveMember(forMemberId);
@ -184,15 +193,26 @@ export default function CheckoutScreen() {
pushNotification({
kind: "order",
title: pickupCode ? `Pickup code ${pickupCode}` : "Order placed",
body: pickupCode
? useAnyBranchPickup
? `Show ${pickupCode} at any Gishen branch counter to collect your order.`
: `Show ${pickupCode} at ${branch?.name ?? "your branch"} to collect.`
: `Your ${order.fulfillment} order is confirmed.`,
href: routes.orderDetail(order.id),
title: isGuest
? `Pharmacist code ${pickupCode}`
: pickupCode
? `Pickup code ${pickupCode}`
: "Order placed",
body: isGuest
? `Share ${pickupCode} so a pharmacist can create your order in admin.`
: pickupCode
? useAnyBranchPickup
? `Show ${pickupCode} at any Gishen branch counter to collect your order.`
: `Show ${pickupCode} at ${branch?.name ?? "your branch"} to collect.`
: `Your ${order.fulfillment} order is confirmed.`,
href: isGuest && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id),
});
if (isGuest && pickupCode) {
router.replace(routes.orderCode(order.id) as never);
return;
}
Alert.alert(
pickupCode ? "Quick pickup code ready" : "Order placed",
pickupCode
@ -310,10 +330,10 @@ export default function CheckoutScreen() {
</Text>
{isGuest ? (
<Card tone="brand" elevated={false} style={{ marginBottom: 12 }}>
<Text variant="h3">Guest quick code</Text>
<Text variant="h3">Pharmacist order code</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
As a guest you get a short pickup code (e.g. GSH-AB12). Show it at any
Gishen branch counter — no account needed to fulfill.
You’ll get a GSH-XXXX code to copy, share, or screenshot. A pharmacist
enters it on the admin page to create your order — then you pay at the branch.
</Text>
</Card>
) : null}
@ -522,7 +542,7 @@ export default function CheckoutScreen() {
<Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text>
</View>
<Button
title={t("common.payNow")}
title={isGuest ? "Get pharmacist code" : t("common.payNow")}
fullWidth={false}
style={{ minWidth: 140 }}
onPress={pay}

View File

@ -86,16 +86,27 @@ export default function OrderDetailScreen() {
{order.pickupCode ? (
<Card tone="brand" elevated={false}>
<Text variant="caption" color={colors.muted}>
Show this at the branch counter
{order.isGuestOrder
? "Pharmacist admin code"
: "Show this at the branch counter"}
</Text>
<Text variant="display" style={{ marginTop: 6, letterSpacing: 2 }}>
{order.pickupCode}
</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
{order.anyBranch
? "Valid at any Gishen Pharmacy branch for fulfillment."
: `Pickup at ${branch?.name ?? "selected branch"}.`}
{order.isGuestOrder
? "Share or screenshot so a pharmacist can create your order in admin."
: order.anyBranch
? "Valid at any Gishen Pharmacy branch for fulfillment."
: `Pickup at ${branch?.name ?? "selected branch"}.`}
</Text>
<Button
title="Open share / copy card"
variant="soft"
icon="share-outline"
style={{ marginTop: 14 }}
onPress={() => router.push(routes.orderCode(order.id) as never)}
/>
</Card>
) : null}

View File

@ -0,0 +1,275 @@
import { Ionicons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import { router, useLocalSearchParams } from "expo-router";
import { useState } from "react";
import {
Alert,
Image,
Linking,
Pressable,
ScrollView,
Share,
View,
} from "react-native";
import { Button } from "@/components/ui/Button";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { buildPharmacistOrderShareText } from "@/lib/commerce";
import { routes } from "@/lib/routes";
import { useOrdersStore } from "@/lib/stores/ordersStore";
import { colors, fonts, gutter, radii, shadows } from "@/lib/theme";
import { formatETB, WHATSAPP_NUMBER } from "@/lib/utils";
/**
* Screenshot-friendly pharmacist order code card.
* Guests copy / share / screenshot this so staff can create the order in admin.
*/
export default function PharmacistOrderCodeScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const order = useOrdersStore((s) => s.getById(id));
const [copied, setCopied] = useState(false);
if (!order?.pickupCode) {
return (
<Screen>
<AppHeader title="Order code" />
<View style={{ padding: gutter }}>
<Text variant="body" color={colors.muted}>
No pharmacist order code found for this order.
</Text>
<Button
title="Back to orders"
style={{ marginTop: 16 }}
onPress={() => router.replace(routes.orders)}
/>
</View>
</Screen>
);
}
const shareText = buildPharmacistOrderShareText(order);
const due = order.selfPay ?? order.total;
const copyCode = async () => {
await Clipboard.setStringAsync(order.pickupCode!);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const copyFull = async () => {
await Clipboard.setStringAsync(shareText);
Alert.alert("Copied", "Order details copied — paste into chat or admin notes.");
};
const share = async () => {
try {
await Share.share({ message: shareText, title: `Gishen code ${order.pickupCode}` });
} catch {
// user dismissed
}
};
const whatsapp = () => {
Linking.openURL(
`https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(shareText)}`
);
};
return (
<Screen>
<AppHeader
title="Pharmacist code"
subtitle="Copy, share, or screenshot"
/>
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 48,
gap: 16,
}}
>
<Text variant="bodySm" color={colors.muted}>
Share this code with a Gishen pharmacist. They enter it on the admin page
to create your order. Pay when you collect.
</Text>
{/* Clean card — ideal for device screenshots */}
<View
style={{
borderRadius: radii.lg,
overflow: "hidden",
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.brandBorder,
...shadows.md,
}}
>
<View
style={{
backgroundColor: colors.brand,
paddingVertical: 20,
paddingHorizontal: 20,
alignItems: "center",
gap: 10,
}}
>
<Image
source={require("@/assets/logo/gishen-logo.png")}
style={{ width: 48, height: 48, borderRadius: 12 }}
resizeMode="contain"
/>
<Text variant="h3" style={{ textAlign: "center" }}>
Gishen Pharmacy
</Text>
<Text variant="caption" style={{ textAlign: "center", opacity: 0.85 }}>
Pharmacist order code
</Text>
</View>
<View style={{ padding: 22, alignItems: "center" }}>
<Text variant="caption" color={colors.subtle} style={{ letterSpacing: 1 }}>
SHOW OR SHARE THIS CODE
</Text>
<Text
variant="display"
style={{
marginTop: 10,
letterSpacing: 3,
fontFamily: fonts.bold,
fontSize: 36,
lineHeight: 42,
}}
>
{order.pickupCode}
</Text>
<Text
variant="bodySm"
color={colors.muted}
style={{ marginTop: 12, textAlign: "center" }}
>
Enter on admin → create order
</Text>
</View>
<View
style={{
borderTopWidth: 1,
borderTopColor: colors.line,
padding: 18,
gap: 8,
}}
>
<Row label="Customer" value={order.customerName ?? "—"} />
<Row label="Phone" value={order.customerPhone ?? "—"} />
<Row label="Items" value={`${order.items.length}`} />
{order.items.slice(0, 6).map((item) => (
<Text
key={item.productId}
variant="caption"
color={colors.subtle}
style={{ paddingLeft: 2 }}
>
· {item.name} ×{item.quantity}
</Text>
))}
{order.items.length > 6 ? (
<Text variant="caption" color={colors.subtle}>
· +{order.items.length - 6} more
</Text>
) : null}
<View
style={{
marginTop: 8,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: colors.line,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Text variant="label" color={colors.muted}>
Total due
</Text>
<Text variant="priceLg">{formatETB(due)}</Text>
</View>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
Pay at branch when collecting
</Text>
</View>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 4,
}}
>
<Ionicons name="camera-outline" size={16} color={colors.subtle} />
<Text variant="caption" color={colors.subtle} style={{ flex: 1 }}>
Tip: screenshot the card above to show in person or send as an image.
</Text>
</View>
<View style={{ gap: 10 }}>
<Button
title={copied ? "Code copied" : "Copy code"}
icon={copied ? "checkmark-circle" : "copy-outline"}
onPress={copyCode}
/>
<View style={{ flexDirection: "row", gap: 10 }}>
<View style={{ flex: 1 }}>
<Button title="Share" icon="share-outline" variant="soft" onPress={share} />
</View>
<View style={{ flex: 1 }}>
<Button
title="WhatsApp"
icon="logo-whatsapp"
variant="outline"
onPress={whatsapp}
/>
</View>
</View>
<Pressable onPress={copyFull} hitSlop={8}>
<Text
variant="label"
color={colors.brandText}
style={{ textAlign: "center", marginTop: 4 }}
>
Copy full order details
</Text>
</Pressable>
</View>
<View style={{ gap: 10, marginTop: 4 }}>
<Button
title="View order"
variant="ghost"
onPress={() => router.replace(routes.orderDetail(order.id))}
/>
<Button
title="Done — back home"
variant="dark"
onPress={() => router.replace(routes.home)}
/>
</View>
</ScrollView>
</Screen>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
<Text variant="caption" color={colors.subtle}>
{label}
</Text>
<Text variant="label" style={{ flexShrink: 1, textAlign: "right" }}>
{value}
</Text>
</View>
);
}

View File

@ -14,6 +14,7 @@ import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
import type { AuthProvider } from "@/lib/stores/authStore";
import { useLangStore } from "@/lib/stores/langStore";
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
import { colors, gutter } from "@/lib/theme";
@ -151,6 +152,12 @@ export default function ProfileScreen() {
</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"
@ -182,7 +189,19 @@ export default function ProfileScreen() {
{editing ? "Close editor" : "Edit profile"}
</Text>
</Pressable>
) : null}
) : (
<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 }}>
@ -302,3 +321,11 @@ export default function ProfileScreen() {
</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";
}

View File

@ -19,6 +19,7 @@ import { TabTopBar } from "@/components/ui/TabTopBar";
import { Text } from "@/components/ui/Text";
import { DELIVERY_FEE, DEMO_VOUCHER, voucherDiscount } from "@/lib/commerce";
import { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
import { useCartStore } from "@/lib/stores/cartStore";
import { colors, fonts, gutter, radii } from "@/lib/theme";
import { formatETB } from "@/lib/utils";
@ -31,6 +32,7 @@ export default function CartScreen() {
const subtotal = useCartStore((s) => s.subtotal());
const voucher = useCartStore((s) => s.voucherCode);
const setVoucher = useCartStore((s) => s.setVoucher);
const isGuest = !!useAuthStore((s) => s.user?.isGuest);
const delivery = items.length ? DELIVERY_FEE : 0;
const discount = voucherDiscount(subtotal, voucher);
const total = subtotal + delivery - discount;
@ -47,6 +49,7 @@ export default function CartScreen() {
<Text variant="h1">{t("common.cart")}</Text>
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
{items.length} item{items.length === 1 ? "" : "s"} in your bag
{isGuest ? " · Guest checkout creates a pharmacist code" : ""}
</Text>
{items.length === 0 ? (
@ -171,7 +174,7 @@ export default function CartScreen() {
{items.length > 0 ? (
<StickyBar>
<Button
title={t("common.payNow")}
title={isGuest ? "Get pharmacist code" : t("common.payNow")}
iconRight="arrow-forward"
onPress={() => router.push(routes.checkout)}
/>

View File

@ -49,7 +49,7 @@ export default function LanguageScreen() {
<View style={{ flex: 1 }} />
<Button
title={t("common.continue")}
onPress={() => router.push(routes.otp)}
onPress={() => router.push(routes.signIn as never)}
style={{ marginBottom: 16 }}
/>
</View>

View File

@ -1,50 +1,85 @@
import { useState } from "react";
import { Alert, View } from "react-native";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import { ActivityIndicator, Alert, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import * as LocalAuthentication from "expo-local-authentication";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/ui/Button";
import { Field, SwitchRow } from "@/components/ui/Field";
import { Card } from "@/components/ui/Card";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { DEMO_OTP } from "@/lib/commerce";
import { colors, gutter } from "@/lib/theme";
import { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
import {
useAuthStore,
type AuthProvider,
} from "@/lib/stores/authStore";
const DEMO_EMAIL_PASSWORD = "gishen123";
const METHOD_META: Record<
AuthProvider,
{ title: string; subtitle: string }
> = {
google: {
title: "Google",
subtitle: "Connect with your Google account (demo).",
},
email: {
title: "Email",
subtitle: `Use any email. Demo password is ${DEMO_EMAIL_PASSWORD}.`,
},
phone: {
title: "Phone",
subtitle: `We'll send an OTP. Demo code is always ${DEMO_OTP}.`,
},
telegram: {
title: "Telegram",
subtitle: "Confirm with your Telegram username (demo).",
},
guest: {
title: "Guest",
subtitle: "Browse without an account.",
},
};
function normalizeMethod(raw: string | string[] | undefined): AuthProvider {
const v = Array.isArray(raw) ? raw[0] : raw;
if (v === "google" || v === "email" || v === "phone" || v === "telegram") {
return v;
}
return "phone";
}
export default function OtpScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const params = useLocalSearchParams<{ method?: string }>();
const method = useMemo(() => normalizeMethod(params.method), [params.method]);
const meta = METHOD_META[method];
const signIn = useAuthStore((s) => s.signIn);
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
const setBiometric = useAuthStore((s) => s.setBiometric);
const [phone, setPhone] = useState("+251911000000");
const [name, setName] = useState("Kirukib Abebe");
const [email, setEmail] = useState("kirukib@example.com");
const [phone, setPhone] = useState("+251911000000");
const [email, setEmail] = useState("kirukib@gmail.com");
const [password, setPassword] = useState("");
const [telegram, setTelegram] = useState("@kirukib");
const [code, setCode] = useState("");
const [sent, setSent] = useState(false);
const [bio, setBio] = useState(false);
const [busy, setBusy] = useState(false);
const sendCode = () => {
if (!name.trim() || phone.replace(/\D/g, "").length < 9) {
Alert.alert("Missing info", "Enter your name and a valid phone number.");
return;
}
setSent(true);
Alert.alert(
"Code sent (demo)",
`Use OTP ${DEMO_OTP} to verify. No real SMS is sent in this build.`
);
};
const verify = async () => {
if (!sent) return;
if (code.trim() !== DEMO_OTP) {
Alert.alert("Invalid OTP", `Enter the demo code ${DEMO_OTP}.`);
return;
}
const finishSignIn = async (payload: {
name: string;
phone: string;
email?: string;
}) => {
if (bio) {
const hw = await LocalAuthentication.hasHardwareAsync();
if (hw) {
@ -57,77 +92,265 @@ export default function OtpScreen() {
}
}
signIn({
name: name.trim(),
phone: phone.trim(),
email: email.trim() || undefined,
name: payload.name.trim(),
phone: payload.phone.trim(),
email: payload.email?.trim() || undefined,
isGuest: false,
authProvider: method,
});
setBiometric(bio);
setHasOnboarded(true);
router.replace(routes.home);
};
const sendPhoneCode = () => {
if (!name.trim() || phone.replace(/\D/g, "").length < 9) {
Alert.alert("Missing info", "Enter your name and a valid phone number.");
return;
}
setSent(true);
Alert.alert(
"Code sent (demo)",
`Use OTP ${DEMO_OTP} to verify. No real SMS is sent in this build.`
);
};
const verifyPhone = async () => {
if (!sent) return;
if (code.trim() !== DEMO_OTP) {
Alert.alert("Invalid OTP", `Enter the demo code ${DEMO_OTP}.`);
return;
}
await finishSignIn({
name,
phone,
email: email.trim() || undefined,
});
};
const signInEmail = async () => {
if (!name.trim()) {
Alert.alert("Missing info", "Enter your name.");
return;
}
if (!email.includes("@")) {
Alert.alert("Invalid email", "Enter a valid email address.");
return;
}
if (password.trim() !== DEMO_EMAIL_PASSWORD) {
Alert.alert(
"Wrong password",
`Demo password is ${DEMO_EMAIL_PASSWORD}.`
);
return;
}
await finishSignIn({
name,
phone: phone.trim() || "+251911000000",
email,
});
};
const signInSocial = async (provider: "google" | "telegram") => {
if (!name.trim()) {
Alert.alert("Missing info", "Enter the name to show on your profile.");
return;
}
if (provider === "telegram" && !telegram.trim()) {
Alert.alert("Telegram", "Enter your Telegram username.");
return;
}
setBusy(true);
// Simulated OAuth / Telegram Login Widget handshake
await new Promise((r) => setTimeout(r, 900));
setBusy(false);
const resolvedEmail =
provider === "google"
? email.trim() || `${name.split(" ")[0]?.toLowerCase() || "user"}@gmail.com`
: email.trim() || undefined;
const resolvedPhone =
phone.trim() ||
(provider === "telegram" ? "+251900000000" : "+251911000000");
await finishSignIn({
name: name.trim(),
phone: resolvedPhone,
email: resolvedEmail,
});
};
return (
<Screen>
<AppHeader title={meta.title} subtitle={meta.subtitle} />
<View
style={{
flex: 1,
paddingTop: insets.top + 24,
paddingBottom: insets.bottom + 20,
paddingHorizontal: gutter,
}}
>
<Text variant="h1">{t("common.signIn")}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
{t("common.otpHint")} Demo OTP is always {DEMO_OTP}.
</Text>
<View style={{ marginTop: 20, gap: 12 }}>
<Field label="Full name" value={name} onChangeText={setName} icon="person-outline" />
<Field
label="Phone"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
<Field
label="Email (optional)"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
icon="mail-outline"
/>
{sent ? (
<Field
label="OTP code"
value={code}
onChangeText={setCode}
keyboardType="number-pad"
placeholder={DEMO_OTP}
icon="key-outline"
hint={`Demo code: ${DEMO_OTP}`}
/>
) : null}
<Card padded={false}>
<SwitchRow
icon="finger-print-outline"
title={t("common.enableBiometric")}
subtitle="Unlock the app faster next time"
value={bio}
onValueChange={setBio}
last
/>
</Card>
</View>
<View style={{ flex: 1 }} />
{!sent ? (
<Button title={t("common.sendOtp")} onPress={sendCode} />
) : (
<View style={{ gap: 10 }}>
<Button title={t("common.verify")} onPress={verify} />
<Button title="Resend code" variant="ghost" onPress={sendCode} />
{busy ? (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 12 }}>
<ActivityIndicator size="large" color={colors.brandText} />
<Text variant="body" color={colors.muted}>
Connecting to {method === "google" ? "Google" : "Telegram"}…
</Text>
</View>
) : (
<>
<View style={{ marginTop: 8, gap: 12 }}>
<Field
label="Full name"
value={name}
onChangeText={setName}
icon="person-outline"
/>
{method === "phone" ? (
<>
<Field
label="Phone"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
<Field
label="Email (optional)"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
icon="mail-outline"
/>
{sent ? (
<Field
label="OTP code"
value={code}
onChangeText={setCode}
keyboardType="number-pad"
placeholder={DEMO_OTP}
icon="key-outline"
hint={`Demo code: ${DEMO_OTP}`}
/>
) : null}
</>
) : null}
{method === "email" ? (
<>
<Field
label="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
icon="mail-outline"
/>
<Field
label="Password"
value={password}
onChangeText={setPassword}
secure
autoCapitalize="none"
icon="lock-closed-outline"
hint={`Demo password: ${DEMO_EMAIL_PASSWORD}`}
/>
<Field
label="Phone (optional)"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
</>
) : null}
{method === "google" ? (
<>
<Field
label="Google email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
icon="logo-google"
hint="Demo — no real Google OAuth"
/>
<Field
label="Phone (optional)"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
</>
) : null}
{method === "telegram" ? (
<>
<Field
label="Telegram username"
value={telegram}
onChangeText={setTelegram}
autoCapitalize="none"
icon="paper-plane-outline"
placeholder="@username"
/>
<Field
label="Phone linked to Telegram"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
</>
) : null}
<Card padded={false}>
<SwitchRow
icon="finger-print-outline"
title={t("common.enableBiometric")}
subtitle="Unlock the app faster next time"
value={bio}
onValueChange={setBio}
last
/>
</Card>
</View>
<View style={{ flex: 1 }} />
{method === "phone" ? (
!sent ? (
<Button title={t("common.sendOtp")} onPress={sendPhoneCode} />
) : (
<View style={{ gap: 10 }}>
<Button title={t("common.verify")} onPress={verifyPhone} />
<Button title="Resend code" variant="ghost" onPress={sendPhoneCode} />
</View>
)
) : null}
{method === "email" ? (
<Button title="Sign in with email" icon="mail-outline" onPress={signInEmail} />
) : null}
{method === "google" ? (
<Button
title="Continue with Google"
icon="logo-google"
onPress={() => signInSocial("google")}
/>
) : null}
{method === "telegram" ? (
<Button
title="Continue with Telegram"
icon="paper-plane-outline"
onPress={() => signInSocial("telegram")}
/>
) : null}
</>
)}
</View>
</Screen>

146
app/(root)/auth/sign-in.tsx Normal file
View File

@ -0,0 +1,146 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Pressable, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/ui/Button";
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 } from "@/lib/stores/authStore";
import type { AuthProvider } from "@/lib/stores/authStore";
import { colors, gutter, radii, shadows } from "@/lib/theme";
type ProviderOption = {
id: AuthProvider;
title: string;
subtitle: string;
icon: keyof typeof Ionicons.glyphMap;
tint: string;
iconBg: string;
};
const PROVIDERS: ProviderOption[] = [
{
id: "google",
title: "Continue with Google",
subtitle: "Use your Google account",
icon: "logo-google",
tint: "#4285F4",
iconBg: "#EEF3FE",
},
{
id: "email",
title: "Continue with Email",
subtitle: "Sign in with email & password",
icon: "mail-outline",
tint: colors.brandText,
iconBg: colors.brandWash,
},
{
id: "phone",
title: "Continue with Phone",
subtitle: "SMS one-time code",
icon: "call-outline",
tint: colors.ink,
iconBg: colors.surfaceAlt,
},
{
id: "telegram",
title: "Continue with Telegram",
subtitle: "Log in via Telegram",
icon: "paper-plane-outline",
tint: "#2AABEE",
iconBg: "#E8F6FD",
},
];
export default function SignInScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
return (
<Screen>
<AppHeader title={t("common.signIn")} subtitle="Choose how you want to continue" />
<View
style={{
flex: 1,
paddingHorizontal: gutter,
paddingBottom: insets.bottom + 16,
gap: 12,
}}
>
<Text variant="bodySm" color={colors.muted}>
Demo build — each option signs you in locally. Real OAuth / Telegram Login
Widget can be wired later.
</Text>
<View style={{ gap: 10, marginTop: 8 }}>
{PROVIDERS.map((p) => (
<Pressable
key={p.id}
onPress={() =>
router.push({
pathname: routes.otp,
params: { method: p.id },
} as never)
}
style={({ pressed }) => ({
flexDirection: "row",
alignItems: "center",
gap: 14,
paddingVertical: 14,
paddingHorizontal: 14,
borderRadius: radii.lg,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.line,
opacity: pressed ? 0.9 : 1,
...shadows.xs,
})}
>
<View
style={{
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: p.iconBg,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name={p.icon} size={22} color={p.tint} />
</View>
<View style={{ flex: 1 }}>
<Text variant="h3">{p.title}</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{p.subtitle}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.subtle} />
</Pressable>
))}
</View>
<View style={{ flex: 1 }} />
<Button
title={t("common.guest")}
variant="outline"
onPress={() => {
useAuthStore.getState().signIn({
name: "Guest",
phone: "",
isGuest: true,
authProvider: "guest",
});
setHasOnboarded(true);
router.replace(routes.home);
}}
/>
</View>
</Screen>
);
}

View File

@ -56,6 +56,7 @@ export default function WelcomeScreen() {
name: "Guest",
phone: "",
isGuest: true,
authProvider: "guest",
});
setHasOnboarded(true);
router.replace(routes.home);

View File

@ -1,7 +1,7 @@
# Gishen-Mob Backend Spec
**Base URL (TBD):** `https://api.gishen.example/v1`
**Auth:** Bearer JWT after phone OTP
**Auth:** Bearer JWT after verified sign-in (phone OTP, email, Google, or Telegram)
**Headers:** `Authorization`, `Accept-Language` (`en` | `am`), `Content-Type: application/json`
Shared with Gishen-Ecom / Gishen-B2B where noted.
@ -10,11 +10,19 @@ Patch this file whenever a feature is added or finalized.
## Auth
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/auth/otp/request` | Required | `{ phone }` |
| POST | `/auth/otp/request` | Required | `{ phone }` — SMS OTP |
| POST | `/auth/otp/verify` | Required | `{ phone, code }` → session + user |
| POST | `/auth/email/login` | Required | `{ email, password }` → session + user |
| POST | `/auth/email/register` | Optional | `{ name, email, password, phone? }` |
| POST | `/auth/oauth/google` | Required | `{ idToken }` (or auth code) → session + user |
| POST | `/auth/oauth/telegram` | Required | Telegram Login Widget payload → session + user |
| POST | `/auth/guest` | Required | Optional device id → limited guest session (`isGuest: true`) |
| POST | `/auth/biometric/bind` | Required | Device biometric public key |
| GET | `/users/me` | Required | Profile, locale, points, tier |
| PATCH | `/users/me` | Required | name, email, `locale`, biometric flag |
| GET | `/users/me` | Required | Profile, locale, points, tier, `authProvider` |
| PATCH | `/users/me` | Required | name, email, phone, `locale`, biometric flag |
**Providers (mobile UI):** `google` \| `email` \| `phone` \| `telegram` \| `guest`
Guest sessions may browse catalog and generate **pharmacist order codes**; loyalty, corporate, and some care features require a non-guest account.
## Locale
| Method | Path | Status | Notes |
@ -29,6 +37,21 @@ Patch this file whenever a feature is added or finalized.
| GET | `/catalog/products/:idOrSlug` | Required | |
| GET | `/catalog/categories` | Required | |
## Favorites
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/favorites/me` | Required | Product ids |
| PUT | `/favorites/:productId` | Required | Add |
| DELETE | `/favorites/:productId` | Required | Remove |
## Addresses
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/users/me/addresses` | Required | Saved delivery addresses |
| POST | `/users/me/addresses` | Required | label, street, area, landmark?, isDefault? |
| PATCH | `/users/me/addresses/:id` | Required | |
| DELETE | `/users/me/addresses/:id` | Required | |
## Branches / Stock
| Method | Path | Status | Notes |
|--------|------|--------|-------|
@ -39,34 +62,52 @@ Patch this file whenever a feature is added or finalized.
## Orders
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/orders` | Required | items, fulfillment, branchId?, location, paymentMethod, prescriptionFileKeys?, notes, credit? |
| GET | `/orders/me` | Required | |
| POST | `/orders` | Required | items, fulfillment, branchId?, location, paymentMethod, prescriptionFileKeys?, notes, credit?, `anyBranch?` |
| GET | `/orders/me` | Required | Authenticated user’s orders |
| GET | `/orders/:id` | Required | |
| GET | `/orders/by-code/:code` | Required | Lookup by `GSH-XXXX` (guest + pickup). Mobile uses this for “find my code”. |
| GET | `/orders/:id/tracking` | Required | Rider trail when assigned |
| POST | `/orders/:id/cancel` | Required | |
### Guest / pharmacist order codes (parity with Gishen Ecom Web)
Guests (and optionally signed-in pickup) receive a short **pharmacist reference code** (`GSH-XXXX`).
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/orders/guest-codes` | Required | Basket snapshot + customer name/phone → `{ code, orderId, status: "requested" }`. Mobile shows copy / share / screenshot card. |
| GET | `/admin/orders/by-code/:code` | Required | **Admin** — pharmacist enters code to create / hydrate the POS order (shared with Ecom admin). |
| POST | `/admin/orders/by-code/:code/fulfill` | Required | Mark created / confirmed after admin processing |
**Mobile contract**
- Guest checkout forces pickup + `pay_at_branch` + any-branch code.
- Response includes `pickupCode`, `status: requested` until pharmacist creates the order in admin.
- Share payload should include code, customer, line items, total (plain text for WhatsApp / clipboard).
## Payments
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/payments/initiate` | Required | `chapa` \| `telebirr` \| `mpesa` only for delivery |
| POST | `/payments/webhook/*` | Required | Gateway callbacks |
| — | `pay_at_branch` | Required | Allowed **only** when `fulfillment=pickup` |
| — | `pay_at_branch` | Required | Allowed **only** when `fulfillment=pickup` (required for guest pharmacist codes) |
## Prescriptions
## Prescriptions (Rx Care)
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/uploads/signed-url` | Required | Rx images |
| POST | `/prescriptions` | Required | Multi-page keys, medicines[], patient, memberId? |
| POST | `/prescriptions/check` | Required | Match catalog → ready/review/unavailable |
| GET | `/prescriptions/me` | Required | |
| GET | `/prescriptions/:id` | Required | Detail + attached reminders |
| POST | `/prescriptions/:id/reorder` | Required | |
## Reminders
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/health/reminders` | Required | Dose/refill schedules |
| POST | `/health/reminders` | Required | Dose/refill schedules (times[], graceMinutes, escalate?) |
| POST | `/health/reminders/:id/confirm` | Required | `dose.confirmed` |
| POST | `/health/reminders/:id/snooze` | Required | |
| GET | `/health/reminders/me` | Required | |
| — | Local + remote push | Required | Daily dose window; calendar UI on `/prescriptions/:id/calendar` |
## Emergency contacts
| Method | Path | Status | Notes |
@ -109,11 +150,22 @@ Patch this file whenever a feature is added or finalized.
| POST | `/loyalty/redeem` | Required | |
| GET | `/referrals/me` | Required | |
## Notifications (in-app inbox)
Distinct from order list. Home bell → inbox.
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/notifications/me` | Required | kinds: `order` \| `rx` \| `dose` \| `loyalty` \| `promo` \| `system` |
| POST | `/notifications/:id/read` | Required | |
| POST | `/notifications/read-all` | Required | |
| DELETE | `/notifications/:id` | Required | |
| POST | `/devices` | Required | push token registration |
## Push / Tracking
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/devices` | Required | push token |
| — | Events | Required | order.*, dispatch.*, dose.*, refill.*, escalation.* |
| — | Events | Required | order.*, dispatch.*, dose.*, refill.*, escalation.*, guest_code.* |
| GET | `/orders/:id/tracking` | Required | |
## Contact / Support
@ -121,6 +173,7 @@ Patch this file whenever a feature is added or finalized.
|--------|------|--------|-------|
| POST | `/contact` | Required | |
| — | Chatwoot | Deeplink | https://chat.yaltopia.com |
| — | WhatsApp / Call | Deeplink | Branch support numbers |
### Changelog
- 2026-08-06: Scaffold — skeleton created with all domains.
@ -133,3 +186,6 @@ Patch this file whenever a feature is added or finalized.
- 2026-08-06: B2B/Account — contexts, join codes, profile switcher.
- 2026-08-06: Loyalty/Push/Tracking — wallet redeem/referral; order tracking map mock; device push channels.
- 2026-08-06: Contact/Support — contact form + Chatwoot/WhatsApp/call deeplinks.
- 2026-08-07: Auth providers — Google, Email, Phone OTP, Telegram, and Guest session contracts; `authProvider` on `/users/me`.
- 2026-08-07: Guest pharmacist codes — `POST /orders/guest-codes`, `GET /orders/by-code/:code`, admin fulfill-by-code (Ecom parity); mobile copy/share/screenshot card.
- 2026-08-07: Notifications inbox — dedicated `/notifications/me` (separate from Orders); Favorites + Addresses endpoints documented; Rx Care dose reminder confirm/snooze.

View File

@ -1,10 +1,16 @@
import type { Order } from "@/lib/stores/ordersStore";
import { formatETB } from "@/lib/utils";
/** Shared commerce numbers so cart, checkout and orders stay consistent. */
export const DELIVERY_FEE = 80;
export const DEMO_VOUCHER = "GISHEN10";
export const DEMO_VOUCHER_PCT = 0.1;
export const DEMO_OTP = "1234";
/** Short branch-counter code guests show to fulfill pickup at any location. */
/**
* Short GSH-XXXX reference guests share with a pharmacist.
* Pharmacist enters the code on the admin page to create / fulfill the order.
*/
export function generatePickupCode() {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let body = "";
@ -14,6 +20,47 @@ export function generatePickupCode() {
return `GSH-${body}`;
}
/** Alias used for guest → admin pharmacist order codes (same format as pickup). */
export const generatePharmacistOrderCode = generatePickupCode;
/** Plain-text payload for copy / share / WhatsApp (admin-friendly). */
export function buildPharmacistOrderShareText(order: Pick<
Order,
| "pickupCode"
| "customerName"
| "customerPhone"
| "items"
| "total"
| "selfPay"
| "paymentMethod"
| "notes"
>) {
const code = order.pickupCode ?? "—";
const lines = order.items
.map((i) => `• ${i.name} ×${i.quantity} — ${formatETB(i.price * i.quantity)}`)
.join("\n");
const due = order.selfPay ?? order.total;
return [
"Gishen Pharmacy — Pharmacist order code",
"",
`Code: ${code}`,
"",
`Customer: ${order.customerName ?? "—"}`,
`Phone: ${order.customerPhone ?? "—"}`,
"",
"Items:",
lines || "• (no items)",
"",
`Total: ${formatETB(due)}`,
`Payment: ${order.paymentMethod === "pay_at_branch" ? "Pay at branch" : order.paymentMethod}`,
order.notes ? `Notes: ${order.notes}` : "",
"",
"Enter this code on the Gishen admin page to create the order.",
]
.filter((line) => line !== undefined)
.join("\n");
}
export function voucherDiscount(subtotal: number, code: string | null | undefined) {
if (!code) return 0;
if (code.trim().toUpperCase() === DEMO_VOUCHER) {

View File

@ -203,11 +203,11 @@ export const seedOrders: Order[] = [
branchId: null,
areaLabel: "Any Gishen branch",
paymentMethod: "pay_at_branch",
status: "confirmed",
status: "requested",
riderAssigned: false,
customerName: "Guest Shopper",
customerPhone: "+251900111222",
notes: "Guest quick-code order",
notes: "Guest pharmacist order code — create in admin",
pickupCode: "GSH-GUEST",
anyBranch: true,
isGuestOrder: true,
@ -311,4 +311,5 @@ export const DEMO_USER = {
isGuest: false,
biometricEnabled: false,
avatarUri: null as string | null,
authProvider: "phone" as const,
};

View File

@ -2,6 +2,7 @@ import type { Ionicons } from "@expo/vector-icons";
export const routes = {
welcome: "/(root)/auth/welcome",
signIn: "/(root)/auth/sign-in",
otp: "/(root)/auth/otp",
language: "/(root)/auth/language",
home: "/(root)/(tabs)",
@ -27,6 +28,8 @@ export const routes = {
emergencyContacts: "/(root)/(screens)/emergency-contacts",
orders: "/(root)/(screens)/orders",
orderDetail: (id: string) => `/(root)/(screens)/orders/${id}` as const,
/** Screenshot / copy / share card for guest pharmacist order codes */
orderCode: (id: string) => `/(root)/(screens)/orders/code/${id}` as const,
tracking: (id: string) => `/(root)/(screens)/tracking/${id}` as const,
notifications: "/(root)/(screens)/notifications",
loyalty: "/(root)/(screens)/loyalty",

View File

@ -3,6 +3,13 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { DEMO_USER } from "@/lib/data/seed";
export type AuthProvider =
| "google"
| "email"
| "phone"
| "telegram"
| "guest";
export type AuthUser = {
id: string;
name: string;
@ -15,6 +22,8 @@ export type AuthUser = {
avatarUri?: string | null;
referralCode?: string;
activeVoucher?: string | null;
/** How the user signed in (demo providers included). */
authProvider?: AuthProvider;
};
export type PointsEntry = {
@ -30,7 +39,13 @@ type AuthState = {
hasOnboarded: boolean;
ledger: PointsEntry[];
setHasOnboarded: (v: boolean) => void;
signIn: (payload: Partial<AuthUser> & { phone: string; name: string }) => void;
signIn: (
payload: Partial<AuthUser> & {
phone: string;
name: string;
authProvider?: AuthProvider;
}
) => void;
signOut: () => void;
setBiometric: (enabled: boolean) => void;
setAvatar: (uri: string | null) => void;
@ -92,7 +107,7 @@ export const useAuthStore = create<AuthState>()(
hasOnboarded: true,
ledger: seedLedger,
setHasOnboarded: (v) => set({ hasOnboarded: v }),
signIn: ({ phone, name, email, isGuest, avatarUri }) =>
signIn: ({ phone, name, email, isGuest, avatarUri, authProvider }) =>
set({
user: {
id: `u_${Date.now()}`,
@ -106,6 +121,8 @@ export const useAuthStore = create<AuthState>()(
avatarUri: avatarUri ?? null,
referralCode: `GISHEN-${name.slice(0, 4).toUpperCase() || "USER"}`,
activeVoucher: null,
authProvider:
authProvider ?? (isGuest ? "guest" : "phone"),
},
hasOnboarded: true,
ledger: isGuest
@ -196,7 +213,7 @@ export const useAuthStore = create<AuthState>()(
{
name: "gishen-auth",
storage: createJSONStorage(() => AsyncStorage),
version: 3,
version: 4,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<AuthState>;
if (!state.user) {
@ -206,6 +223,7 @@ export const useAuthStore = create<AuthState>()(
...DEMO_USER,
referralCode: "GISHEN-KIRU",
activeVoucher: null,
authProvider: "phone" as const,
},
hasOnboarded: true,
ledger: seedLedger,
@ -219,6 +237,9 @@ export const useAuthStore = create<AuthState>()(
avatarUri: state.user.avatarUri ?? null,
referralCode: state.user.referralCode ?? "GISHEN-KIRU",
activeVoucher: state.user.activeVoucher ?? null,
authProvider:
state.user.authProvider ??
(state.user.isGuest ? ("guest" as const) : ("phone" as const)),
},
ledger: state.ledger?.length ? state.ledger : seedLedger,
};

View File

@ -57,6 +57,11 @@
"contactNotified": "የአደጋ ጊዜ እውቂያ ተነግሯል",
"guest": "እንደ እንግዳ ቀጥል",
"signIn": "ግባ",
"signInWith": "በዚህ ግባ",
"continueGoogle": "በGoogle ቀጥል",
"continueEmail": "በኢሜይል ቀጥል",
"continuePhone": "በስልክ ቀጥል",
"continueTelegram": "በTelegram ቀጥል",
"otpHint": "ወደ ስልክዎ የተላከውን ኮድ ያስገቡ",
"sendOtp": "ኮድ ላክ",
"verify": "አረጋግጥ",

View File

@ -57,6 +57,11 @@
"contactNotified": "Emergency contact notified",
"guest": "Continue as guest",
"signIn": "Sign in",
"signInWith": "Sign in with",
"continueGoogle": "Continue with Google",
"continueEmail": "Continue with Email",
"continuePhone": "Continue with Phone",
"continueTelegram": "Continue with Telegram",
"otpHint": "Enter the code sent to your phone",
"sendOtp": "Send code",
"verify": "Verify",

18
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@react-native-async-storage/async-storage": "2.1.2",
"expo": "~53.0.0",
"expo-camera": "~16.1.11",
"expo-clipboard": "~7.1.4",
"expo-constants": "~17.1.7",
"expo-font": "~13.3.1",
"expo-image-picker": "~16.1.4",
@ -2974,7 +2975,7 @@
"version": "19.0.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz",
"integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -4272,7 +4273,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@ -4765,6 +4766,17 @@
}
}
},
"node_modules/expo-clipboard": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.1.5.tgz",
"integrity": "sha512-TCANUGOxouoJXxKBW5ASJl2WlmQLGpuZGemDCL2fO5ZMl57DGTypUmagb0CVUFxDl0yAtFIcESd78UsF9o64aw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-constants": {
"version": "17.1.8",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.8.tgz",
@ -9759,7 +9771,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@ -15,6 +15,7 @@
"@react-native-async-storage/async-storage": "2.1.2",
"expo": "~53.0.0",
"expo-camera": "~16.1.11",
"expo-clipboard": "~7.1.4",
"expo-constants": "~17.1.7",
"expo-font": "~13.3.1",
"expo-image-picker": "~16.1.4",