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:
parent
5391780b9b
commit
858972b5ca
|
|
@ -132,13 +132,19 @@ export default function CheckoutScreen() {
|
||||||
const orderNotes = [
|
const orderNotes = [
|
||||||
notes.trim(),
|
notes.trim(),
|
||||||
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
|
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)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
const pickupCode =
|
const pickupCode =
|
||||||
effectiveFulfillment === "pickup" ? generatePickupCode() : null;
|
isGuest || effectiveFulfillment === "pickup"
|
||||||
|
? generatePickupCode()
|
||||||
|
: null;
|
||||||
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
|
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
|
||||||
|
|
||||||
const order = addOrder({
|
const order = addOrder({
|
||||||
|
|
@ -153,7 +159,7 @@ export default function CheckoutScreen() {
|
||||||
areaLabel: useAnyBranchPickup
|
areaLabel: useAnyBranchPickup
|
||||||
? "Any Gishen branch"
|
? "Any Gishen branch"
|
||||||
: effectiveFulfillment === "pickup"
|
: effectiveFulfillment === "pickup"
|
||||||
? branch?.area ?? areaLabel
|
? branch?.name ?? areaLabel
|
||||||
: selectedAddress?.area ?? areaLabel,
|
: selectedAddress?.area ?? areaLabel,
|
||||||
addressId:
|
addressId:
|
||||||
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
||||||
|
|
@ -166,10 +172,13 @@ export default function CheckoutScreen() {
|
||||||
notes: orderNotes,
|
notes: orderNotes,
|
||||||
riderAssigned: false,
|
riderAssigned: false,
|
||||||
pickupCode,
|
pickupCode,
|
||||||
anyBranch: useAnyBranchPickup,
|
anyBranch: useAnyBranchPickup || isGuest,
|
||||||
isGuestOrder: isGuest,
|
isGuestOrder: isGuest,
|
||||||
status:
|
status: isGuest
|
||||||
useAnyBranchPickup || payment === "pay_at_branch" ? "confirmed" : undefined,
|
? "requested"
|
||||||
|
: useAnyBranchPickup || payment === "pay_at_branch"
|
||||||
|
? "confirmed"
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (forMemberId) setActiveMember(forMemberId);
|
if (forMemberId) setActiveMember(forMemberId);
|
||||||
|
|
@ -184,15 +193,26 @@ export default function CheckoutScreen() {
|
||||||
|
|
||||||
pushNotification({
|
pushNotification({
|
||||||
kind: "order",
|
kind: "order",
|
||||||
title: pickupCode ? `Pickup code ${pickupCode}` : "Order placed",
|
title: isGuest
|
||||||
body: pickupCode
|
? `Pharmacist code ${pickupCode}`
|
||||||
|
: pickupCode
|
||||||
|
? `Pickup code ${pickupCode}`
|
||||||
|
: "Order placed",
|
||||||
|
body: isGuest
|
||||||
|
? `Share ${pickupCode} so a pharmacist can create your order in admin.`
|
||||||
|
: pickupCode
|
||||||
? useAnyBranchPickup
|
? useAnyBranchPickup
|
||||||
? `Show ${pickupCode} at any Gishen branch counter to collect your order.`
|
? `Show ${pickupCode} at any Gishen branch counter to collect your order.`
|
||||||
: `Show ${pickupCode} at ${branch?.name ?? "your branch"} to collect.`
|
: `Show ${pickupCode} at ${branch?.name ?? "your branch"} to collect.`
|
||||||
: `Your ${order.fulfillment} order is confirmed.`,
|
: `Your ${order.fulfillment} order is confirmed.`,
|
||||||
href: routes.orderDetail(order.id),
|
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(
|
Alert.alert(
|
||||||
pickupCode ? "Quick pickup code ready" : "Order placed",
|
pickupCode ? "Quick pickup code ready" : "Order placed",
|
||||||
pickupCode
|
pickupCode
|
||||||
|
|
@ -310,10 +330,10 @@ export default function CheckoutScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
{isGuest ? (
|
{isGuest ? (
|
||||||
<Card tone="brand" elevated={false} style={{ marginBottom: 12 }}>
|
<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 }}>
|
<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
|
You’ll get a GSH-XXXX code to copy, share, or screenshot. A pharmacist
|
||||||
Gishen branch counter — no account needed to fulfill.
|
enters it on the admin page to create your order — then you pay at the branch.
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -522,7 +542,7 @@ export default function CheckoutScreen() {
|
||||||
<Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text>
|
<Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
title={t("common.payNow")}
|
title={isGuest ? "Get pharmacist code" : t("common.payNow")}
|
||||||
fullWidth={false}
|
fullWidth={false}
|
||||||
style={{ minWidth: 140 }}
|
style={{ minWidth: 140 }}
|
||||||
onPress={pay}
|
onPress={pay}
|
||||||
|
|
|
||||||
|
|
@ -86,16 +86,27 @@ export default function OrderDetailScreen() {
|
||||||
{order.pickupCode ? (
|
{order.pickupCode ? (
|
||||||
<Card tone="brand" elevated={false}>
|
<Card tone="brand" elevated={false}>
|
||||||
<Text variant="caption" color={colors.muted}>
|
<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>
|
||||||
<Text variant="display" style={{ marginTop: 6, letterSpacing: 2 }}>
|
<Text variant="display" style={{ marginTop: 6, letterSpacing: 2 }}>
|
||||||
{order.pickupCode}
|
{order.pickupCode}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
|
||||||
{order.anyBranch
|
{order.isGuestOrder
|
||||||
|
? "Share or screenshot so a pharmacist can create your order in admin."
|
||||||
|
: order.anyBranch
|
||||||
? "Valid at any Gishen Pharmacy branch for fulfillment."
|
? "Valid at any Gishen Pharmacy branch for fulfillment."
|
||||||
: `Pickup at ${branch?.name ?? "selected branch"}.`}
|
: `Pickup at ${branch?.name ?? "selected branch"}.`}
|
||||||
</Text>
|
</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>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
275
app/(root)/(screens)/orders/code/[id].tsx
Normal file
275
app/(root)/(screens)/orders/code/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import { Screen } from "@/components/ui/Screen";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
import { useAuthStore } from "@/lib/stores/authStore";
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
import type { AuthProvider } from "@/lib/stores/authStore";
|
||||||
import { useLangStore } from "@/lib/stores/langStore";
|
import { useLangStore } from "@/lib/stores/langStore";
|
||||||
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
||||||
import { colors, gutter } from "@/lib/theme";
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
@ -151,6 +152,12 @@ export default function ProfileScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
|
<View style={{ flexDirection: "row", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
|
||||||
|
{user?.authProvider && user.authProvider !== "guest" ? (
|
||||||
|
<Badge
|
||||||
|
label={providerLabel(user.authProvider)}
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Badge
|
<Badge
|
||||||
label={
|
label={
|
||||||
mode === "corporate"
|
mode === "corporate"
|
||||||
|
|
@ -182,7 +189,19 @@ export default function ProfileScreen() {
|
||||||
{editing ? "Close editor" : "Edit profile"}
|
{editing ? "Close editor" : "Edit profile"}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</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>
|
</View>
|
||||||
{membership ? (
|
{membership ? (
|
||||||
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
|
||||||
|
|
@ -302,3 +321,11 @@ export default function ProfileScreen() {
|
||||||
</Screen>
|
</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";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { TabTopBar } from "@/components/ui/TabTopBar";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { DELIVERY_FEE, DEMO_VOUCHER, voucherDiscount } from "@/lib/commerce";
|
import { DELIVERY_FEE, DEMO_VOUCHER, voucherDiscount } from "@/lib/commerce";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
import { useCartStore } from "@/lib/stores/cartStore";
|
import { useCartStore } from "@/lib/stores/cartStore";
|
||||||
import { colors, fonts, gutter, radii } from "@/lib/theme";
|
import { colors, fonts, gutter, radii } from "@/lib/theme";
|
||||||
import { formatETB } from "@/lib/utils";
|
import { formatETB } from "@/lib/utils";
|
||||||
|
|
@ -31,6 +32,7 @@ export default function CartScreen() {
|
||||||
const subtotal = useCartStore((s) => s.subtotal());
|
const subtotal = useCartStore((s) => s.subtotal());
|
||||||
const voucher = useCartStore((s) => s.voucherCode);
|
const voucher = useCartStore((s) => s.voucherCode);
|
||||||
const setVoucher = useCartStore((s) => s.setVoucher);
|
const setVoucher = useCartStore((s) => s.setVoucher);
|
||||||
|
const isGuest = !!useAuthStore((s) => s.user?.isGuest);
|
||||||
const delivery = items.length ? DELIVERY_FEE : 0;
|
const delivery = items.length ? DELIVERY_FEE : 0;
|
||||||
const discount = voucherDiscount(subtotal, voucher);
|
const discount = voucherDiscount(subtotal, voucher);
|
||||||
const total = subtotal + delivery - discount;
|
const total = subtotal + delivery - discount;
|
||||||
|
|
@ -47,6 +49,7 @@ export default function CartScreen() {
|
||||||
<Text variant="h1">{t("common.cart")}</Text>
|
<Text variant="h1">{t("common.cart")}</Text>
|
||||||
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
|
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
{items.length} item{items.length === 1 ? "" : "s"} in your bag
|
{items.length} item{items.length === 1 ? "" : "s"} in your bag
|
||||||
|
{isGuest ? " · Guest checkout creates a pharmacist code" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
|
|
@ -171,7 +174,7 @@ export default function CartScreen() {
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<StickyBar>
|
<StickyBar>
|
||||||
<Button
|
<Button
|
||||||
title={t("common.payNow")}
|
title={isGuest ? "Get pharmacist code" : t("common.payNow")}
|
||||||
iconRight="arrow-forward"
|
iconRight="arrow-forward"
|
||||||
onPress={() => router.push(routes.checkout)}
|
onPress={() => router.push(routes.checkout)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export default function LanguageScreen() {
|
||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
<Button
|
<Button
|
||||||
title={t("common.continue")}
|
title={t("common.continue")}
|
||||||
onPress={() => router.push(routes.otp)}
|
onPress={() => router.push(routes.signIn as never)}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,85 @@
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Alert, View } from "react-native";
|
import { ActivityIndicator, Alert, View } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import * as LocalAuthentication from "expo-local-authentication";
|
import * as LocalAuthentication from "expo-local-authentication";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Field, SwitchRow } from "@/components/ui/Field";
|
import { Field, SwitchRow } from "@/components/ui/Field";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
import { Screen } from "@/components/ui/Screen";
|
import { Screen } from "@/components/ui/Screen";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { DEMO_OTP } from "@/lib/commerce";
|
import { DEMO_OTP } from "@/lib/commerce";
|
||||||
import { colors, gutter } from "@/lib/theme";
|
import { colors, gutter } from "@/lib/theme";
|
||||||
import { routes } from "@/lib/routes";
|
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() {
|
export default function OtpScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
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 signIn = useAuthStore((s) => s.signIn);
|
||||||
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
|
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
|
||||||
const setBiometric = useAuthStore((s) => s.setBiometric);
|
const setBiometric = useAuthStore((s) => s.setBiometric);
|
||||||
const [phone, setPhone] = useState("+251911000000");
|
|
||||||
const [name, setName] = useState("Kirukib Abebe");
|
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 [code, setCode] = useState("");
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
const [bio, setBio] = useState(false);
|
const [bio, setBio] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const sendCode = () => {
|
const finishSignIn = async (payload: {
|
||||||
if (!name.trim() || phone.replace(/\D/g, "").length < 9) {
|
name: string;
|
||||||
Alert.alert("Missing info", "Enter your name and a valid phone number.");
|
phone: string;
|
||||||
return;
|
email?: string;
|
||||||
}
|
}) => {
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (bio) {
|
if (bio) {
|
||||||
const hw = await LocalAuthentication.hasHardwareAsync();
|
const hw = await LocalAuthentication.hasHardwareAsync();
|
||||||
if (hw) {
|
if (hw) {
|
||||||
|
|
@ -57,32 +92,121 @@ export default function OtpScreen() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
signIn({
|
signIn({
|
||||||
name: name.trim(),
|
name: payload.name.trim(),
|
||||||
phone: phone.trim(),
|
phone: payload.phone.trim(),
|
||||||
email: email.trim() || undefined,
|
email: payload.email?.trim() || undefined,
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
|
authProvider: method,
|
||||||
});
|
});
|
||||||
setBiometric(bio);
|
setBiometric(bio);
|
||||||
setHasOnboarded(true);
|
setHasOnboarded(true);
|
||||||
router.replace(routes.home);
|
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 (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
|
<AppHeader title={meta.title} subtitle={meta.subtitle} />
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingTop: insets.top + 24,
|
|
||||||
paddingBottom: insets.bottom + 20,
|
paddingBottom: insets.bottom + 20,
|
||||||
paddingHorizontal: gutter,
|
paddingHorizontal: gutter,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text variant="h1">{t("common.signIn")}</Text>
|
{busy ? (
|
||||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 12 }}>
|
||||||
{t("common.otpHint")} Demo OTP is always {DEMO_OTP}.
|
<ActivityIndicator size="large" color={colors.brandText} />
|
||||||
|
<Text variant="body" color={colors.muted}>
|
||||||
|
Connecting to {method === "google" ? "Google" : "Telegram"}…
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ marginTop: 20, gap: 12 }}>
|
</View>
|
||||||
<Field label="Full name" value={name} onChangeText={setName} icon="person-outline" />
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={{ marginTop: 8, gap: 12 }}>
|
||||||
|
<Field
|
||||||
|
label="Full name"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
icon="person-outline"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{method === "phone" ? (
|
||||||
|
<>
|
||||||
<Field
|
<Field
|
||||||
label="Phone"
|
label="Phone"
|
||||||
value={phone}
|
value={phone}
|
||||||
|
|
@ -109,6 +233,79 @@ export default function OtpScreen() {
|
||||||
hint={`Demo code: ${DEMO_OTP}`}
|
hint={`Demo code: ${DEMO_OTP}`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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}>
|
<Card padded={false}>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
icon="finger-print-outline"
|
icon="finger-print-outline"
|
||||||
|
|
@ -120,14 +317,40 @@ export default function OtpScreen() {
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
{!sent ? (
|
|
||||||
<Button title={t("common.sendOtp")} onPress={sendCode} />
|
{method === "phone" ? (
|
||||||
|
!sent ? (
|
||||||
|
<Button title={t("common.sendOtp")} onPress={sendPhoneCode} />
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
<Button title={t("common.verify")} onPress={verify} />
|
<Button title={t("common.verify")} onPress={verifyPhone} />
|
||||||
<Button title="Resend code" variant="ghost" onPress={sendCode} />
|
<Button title="Resend code" variant="ghost" onPress={sendPhoneCode} />
|
||||||
</View>
|
</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>
|
</View>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|
|
||||||
146
app/(root)/auth/sign-in.tsx
Normal file
146
app/(root)/auth/sign-in.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ export default function WelcomeScreen() {
|
||||||
name: "Guest",
|
name: "Guest",
|
||||||
phone: "",
|
phone: "",
|
||||||
isGuest: true,
|
isGuest: true,
|
||||||
|
authProvider: "guest",
|
||||||
});
|
});
|
||||||
setHasOnboarded(true);
|
setHasOnboarded(true);
|
||||||
router.replace(routes.home);
|
router.replace(routes.home);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Gishen-Mob Backend Spec
|
# Gishen-Mob Backend Spec
|
||||||
|
|
||||||
**Base URL (TBD):** `https://api.gishen.example/v1`
|
**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`
|
**Headers:** `Authorization`, `Accept-Language` (`en` | `am`), `Content-Type: application/json`
|
||||||
|
|
||||||
Shared with Gishen-Ecom / Gishen-B2B where noted.
|
Shared with Gishen-Ecom / Gishen-B2B where noted.
|
||||||
|
|
@ -10,11 +10,19 @@ Patch this file whenever a feature is added or finalized.
|
||||||
## Auth
|
## Auth
|
||||||
| Method | Path | Status | Notes |
|
| 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/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 |
|
| POST | `/auth/biometric/bind` | Required | Device biometric public key |
|
||||||
| GET | `/users/me` | Required | Profile, locale, points, tier |
|
| GET | `/users/me` | Required | Profile, locale, points, tier, `authProvider` |
|
||||||
| PATCH | `/users/me` | Required | name, email, `locale`, biometric flag |
|
| 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
|
## Locale
|
||||||
| Method | Path | Status | Notes |
|
| 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/products/:idOrSlug` | Required | |
|
||||||
| GET | `/catalog/categories` | 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
|
## Branches / Stock
|
||||||
| Method | Path | Status | Notes |
|
| Method | Path | Status | Notes |
|
||||||
|--------|------|--------|-------|
|
|--------|------|--------|-------|
|
||||||
|
|
@ -39,34 +62,52 @@ Patch this file whenever a feature is added or finalized.
|
||||||
## Orders
|
## Orders
|
||||||
| Method | Path | Status | Notes |
|
| Method | Path | Status | Notes |
|
||||||
|--------|------|--------|-------|
|
|--------|------|--------|-------|
|
||||||
| POST | `/orders` | Required | items, fulfillment, branchId?, location, paymentMethod, prescriptionFileKeys?, notes, credit? |
|
| POST | `/orders` | Required | items, fulfillment, branchId?, location, paymentMethod, prescriptionFileKeys?, notes, credit?, `anyBranch?` |
|
||||||
| GET | `/orders/me` | Required | |
|
| GET | `/orders/me` | Required | Authenticated user’s orders |
|
||||||
| GET | `/orders/:id` | Required | |
|
| 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 |
|
| GET | `/orders/:id/tracking` | Required | Rider trail when assigned |
|
||||||
| POST | `/orders/:id/cancel` | Required | |
|
| 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
|
## Payments
|
||||||
| Method | Path | Status | Notes |
|
| Method | Path | Status | Notes |
|
||||||
|--------|------|--------|-------|
|
|--------|------|--------|-------|
|
||||||
| POST | `/payments/initiate` | Required | `chapa` \| `telebirr` \| `mpesa` only for delivery |
|
| POST | `/payments/initiate` | Required | `chapa` \| `telebirr` \| `mpesa` only for delivery |
|
||||||
| POST | `/payments/webhook/*` | Required | Gateway callbacks |
|
| 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 |
|
| Method | Path | Status | Notes |
|
||||||
|--------|------|--------|-------|
|
|--------|------|--------|-------|
|
||||||
| POST | `/uploads/signed-url` | Required | Rx images |
|
| POST | `/uploads/signed-url` | Required | Rx images |
|
||||||
| POST | `/prescriptions` | Required | Multi-page keys, medicines[], patient, memberId? |
|
| POST | `/prescriptions` | Required | Multi-page keys, medicines[], patient, memberId? |
|
||||||
| POST | `/prescriptions/check` | Required | Match catalog → ready/review/unavailable |
|
| POST | `/prescriptions/check` | Required | Match catalog → ready/review/unavailable |
|
||||||
| GET | `/prescriptions/me` | Required | |
|
| GET | `/prescriptions/me` | Required | |
|
||||||
|
| GET | `/prescriptions/:id` | Required | Detail + attached reminders |
|
||||||
| POST | `/prescriptions/:id/reorder` | Required | |
|
| POST | `/prescriptions/:id/reorder` | Required | |
|
||||||
|
|
||||||
## Reminders
|
## Reminders
|
||||||
| Method | Path | Status | Notes |
|
| 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/confirm` | Required | `dose.confirmed` |
|
||||||
|
| POST | `/health/reminders/:id/snooze` | Required | |
|
||||||
| GET | `/health/reminders/me` | Required | |
|
| GET | `/health/reminders/me` | Required | |
|
||||||
|
| — | Local + remote push | Required | Daily dose window; calendar UI on `/prescriptions/:id/calendar` |
|
||||||
|
|
||||||
## Emergency contacts
|
## Emergency contacts
|
||||||
| Method | Path | Status | Notes |
|
| Method | Path | Status | Notes |
|
||||||
|
|
@ -109,11 +150,22 @@ Patch this file whenever a feature is added or finalized.
|
||||||
| POST | `/loyalty/redeem` | Required | |
|
| POST | `/loyalty/redeem` | Required | |
|
||||||
| GET | `/referrals/me` | 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
|
## Push / Tracking
|
||||||
| Method | Path | Status | Notes |
|
| Method | Path | Status | Notes |
|
||||||
|--------|------|--------|-------|
|
|--------|------|--------|-------|
|
||||||
| POST | `/devices` | Required | push token |
|
| 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 | |
|
| GET | `/orders/:id/tracking` | Required | |
|
||||||
|
|
||||||
## Contact / Support
|
## Contact / Support
|
||||||
|
|
@ -121,6 +173,7 @@ Patch this file whenever a feature is added or finalized.
|
||||||
|--------|------|--------|-------|
|
|--------|------|--------|-------|
|
||||||
| POST | `/contact` | Required | |
|
| POST | `/contact` | Required | |
|
||||||
| — | Chatwoot | Deeplink | https://chat.yaltopia.com |
|
| — | Chatwoot | Deeplink | https://chat.yaltopia.com |
|
||||||
|
| — | WhatsApp / Call | Deeplink | Branch support numbers |
|
||||||
|
|
||||||
### Changelog
|
### Changelog
|
||||||
- 2026-08-06: Scaffold — skeleton created with all domains.
|
- 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: 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: 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-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.
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** Shared commerce numbers so cart, checkout and orders stay consistent. */
|
||||||
export const DELIVERY_FEE = 80;
|
export const DELIVERY_FEE = 80;
|
||||||
export const DEMO_VOUCHER = "GISHEN10";
|
export const DEMO_VOUCHER = "GISHEN10";
|
||||||
export const DEMO_VOUCHER_PCT = 0.1;
|
export const DEMO_VOUCHER_PCT = 0.1;
|
||||||
export const DEMO_OTP = "1234";
|
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() {
|
export function generatePickupCode() {
|
||||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
let body = "";
|
let body = "";
|
||||||
|
|
@ -14,6 +20,47 @@ export function generatePickupCode() {
|
||||||
return `GSH-${body}`;
|
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) {
|
export function voucherDiscount(subtotal: number, code: string | null | undefined) {
|
||||||
if (!code) return 0;
|
if (!code) return 0;
|
||||||
if (code.trim().toUpperCase() === DEMO_VOUCHER) {
|
if (code.trim().toUpperCase() === DEMO_VOUCHER) {
|
||||||
|
|
|
||||||
|
|
@ -203,11 +203,11 @@ export const seedOrders: Order[] = [
|
||||||
branchId: null,
|
branchId: null,
|
||||||
areaLabel: "Any Gishen branch",
|
areaLabel: "Any Gishen branch",
|
||||||
paymentMethod: "pay_at_branch",
|
paymentMethod: "pay_at_branch",
|
||||||
status: "confirmed",
|
status: "requested",
|
||||||
riderAssigned: false,
|
riderAssigned: false,
|
||||||
customerName: "Guest Shopper",
|
customerName: "Guest Shopper",
|
||||||
customerPhone: "+251900111222",
|
customerPhone: "+251900111222",
|
||||||
notes: "Guest quick-code order",
|
notes: "Guest pharmacist order code — create in admin",
|
||||||
pickupCode: "GSH-GUEST",
|
pickupCode: "GSH-GUEST",
|
||||||
anyBranch: true,
|
anyBranch: true,
|
||||||
isGuestOrder: true,
|
isGuestOrder: true,
|
||||||
|
|
@ -311,4 +311,5 @@ export const DEMO_USER = {
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
biometricEnabled: false,
|
biometricEnabled: false,
|
||||||
avatarUri: null as string | null,
|
avatarUri: null as string | null,
|
||||||
|
authProvider: "phone" as const,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
export const routes = {
|
export const routes = {
|
||||||
welcome: "/(root)/auth/welcome",
|
welcome: "/(root)/auth/welcome",
|
||||||
|
signIn: "/(root)/auth/sign-in",
|
||||||
otp: "/(root)/auth/otp",
|
otp: "/(root)/auth/otp",
|
||||||
language: "/(root)/auth/language",
|
language: "/(root)/auth/language",
|
||||||
home: "/(root)/(tabs)",
|
home: "/(root)/(tabs)",
|
||||||
|
|
@ -27,6 +28,8 @@ export const routes = {
|
||||||
emergencyContacts: "/(root)/(screens)/emergency-contacts",
|
emergencyContacts: "/(root)/(screens)/emergency-contacts",
|
||||||
orders: "/(root)/(screens)/orders",
|
orders: "/(root)/(screens)/orders",
|
||||||
orderDetail: (id: string) => `/(root)/(screens)/orders/${id}` as const,
|
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,
|
tracking: (id: string) => `/(root)/(screens)/tracking/${id}` as const,
|
||||||
notifications: "/(root)/(screens)/notifications",
|
notifications: "/(root)/(screens)/notifications",
|
||||||
loyalty: "/(root)/(screens)/loyalty",
|
loyalty: "/(root)/(screens)/loyalty",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ import { create } from "zustand";
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import { DEMO_USER } from "@/lib/data/seed";
|
import { DEMO_USER } from "@/lib/data/seed";
|
||||||
|
|
||||||
|
export type AuthProvider =
|
||||||
|
| "google"
|
||||||
|
| "email"
|
||||||
|
| "phone"
|
||||||
|
| "telegram"
|
||||||
|
| "guest";
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -15,6 +22,8 @@ export type AuthUser = {
|
||||||
avatarUri?: string | null;
|
avatarUri?: string | null;
|
||||||
referralCode?: string;
|
referralCode?: string;
|
||||||
activeVoucher?: string | null;
|
activeVoucher?: string | null;
|
||||||
|
/** How the user signed in (demo providers included). */
|
||||||
|
authProvider?: AuthProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PointsEntry = {
|
export type PointsEntry = {
|
||||||
|
|
@ -30,7 +39,13 @@ type AuthState = {
|
||||||
hasOnboarded: boolean;
|
hasOnboarded: boolean;
|
||||||
ledger: PointsEntry[];
|
ledger: PointsEntry[];
|
||||||
setHasOnboarded: (v: boolean) => void;
|
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;
|
signOut: () => void;
|
||||||
setBiometric: (enabled: boolean) => void;
|
setBiometric: (enabled: boolean) => void;
|
||||||
setAvatar: (uri: string | null) => void;
|
setAvatar: (uri: string | null) => void;
|
||||||
|
|
@ -92,7 +107,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
hasOnboarded: true,
|
hasOnboarded: true,
|
||||||
ledger: seedLedger,
|
ledger: seedLedger,
|
||||||
setHasOnboarded: (v) => set({ hasOnboarded: v }),
|
setHasOnboarded: (v) => set({ hasOnboarded: v }),
|
||||||
signIn: ({ phone, name, email, isGuest, avatarUri }) =>
|
signIn: ({ phone, name, email, isGuest, avatarUri, authProvider }) =>
|
||||||
set({
|
set({
|
||||||
user: {
|
user: {
|
||||||
id: `u_${Date.now()}`,
|
id: `u_${Date.now()}`,
|
||||||
|
|
@ -106,6 +121,8 @@ export const useAuthStore = create<AuthState>()(
|
||||||
avatarUri: avatarUri ?? null,
|
avatarUri: avatarUri ?? null,
|
||||||
referralCode: `GISHEN-${name.slice(0, 4).toUpperCase() || "USER"}`,
|
referralCode: `GISHEN-${name.slice(0, 4).toUpperCase() || "USER"}`,
|
||||||
activeVoucher: null,
|
activeVoucher: null,
|
||||||
|
authProvider:
|
||||||
|
authProvider ?? (isGuest ? "guest" : "phone"),
|
||||||
},
|
},
|
||||||
hasOnboarded: true,
|
hasOnboarded: true,
|
||||||
ledger: isGuest
|
ledger: isGuest
|
||||||
|
|
@ -196,7 +213,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
{
|
{
|
||||||
name: "gishen-auth",
|
name: "gishen-auth",
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
version: 3,
|
version: 4,
|
||||||
migrate: (persisted: unknown) => {
|
migrate: (persisted: unknown) => {
|
||||||
const state = (persisted ?? {}) as Partial<AuthState>;
|
const state = (persisted ?? {}) as Partial<AuthState>;
|
||||||
if (!state.user) {
|
if (!state.user) {
|
||||||
|
|
@ -206,6 +223,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
...DEMO_USER,
|
...DEMO_USER,
|
||||||
referralCode: "GISHEN-KIRU",
|
referralCode: "GISHEN-KIRU",
|
||||||
activeVoucher: null,
|
activeVoucher: null,
|
||||||
|
authProvider: "phone" as const,
|
||||||
},
|
},
|
||||||
hasOnboarded: true,
|
hasOnboarded: true,
|
||||||
ledger: seedLedger,
|
ledger: seedLedger,
|
||||||
|
|
@ -219,6 +237,9 @@ export const useAuthStore = create<AuthState>()(
|
||||||
avatarUri: state.user.avatarUri ?? null,
|
avatarUri: state.user.avatarUri ?? null,
|
||||||
referralCode: state.user.referralCode ?? "GISHEN-KIRU",
|
referralCode: state.user.referralCode ?? "GISHEN-KIRU",
|
||||||
activeVoucher: state.user.activeVoucher ?? null,
|
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,
|
ledger: state.ledger?.length ? state.ledger : seedLedger,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@
|
||||||
"contactNotified": "የአደጋ ጊዜ እውቂያ ተነግሯል",
|
"contactNotified": "የአደጋ ጊዜ እውቂያ ተነግሯል",
|
||||||
"guest": "እንደ እንግዳ ቀጥል",
|
"guest": "እንደ እንግዳ ቀጥል",
|
||||||
"signIn": "ግባ",
|
"signIn": "ግባ",
|
||||||
|
"signInWith": "በዚህ ግባ",
|
||||||
|
"continueGoogle": "በGoogle ቀጥል",
|
||||||
|
"continueEmail": "በኢሜይል ቀጥል",
|
||||||
|
"continuePhone": "በስልክ ቀጥል",
|
||||||
|
"continueTelegram": "በTelegram ቀጥል",
|
||||||
"otpHint": "ወደ ስልክዎ የተላከውን ኮድ ያስገቡ",
|
"otpHint": "ወደ ስልክዎ የተላከውን ኮድ ያስገቡ",
|
||||||
"sendOtp": "ኮድ ላክ",
|
"sendOtp": "ኮድ ላክ",
|
||||||
"verify": "አረጋግጥ",
|
"verify": "አረጋግጥ",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@
|
||||||
"contactNotified": "Emergency contact notified",
|
"contactNotified": "Emergency contact notified",
|
||||||
"guest": "Continue as guest",
|
"guest": "Continue as guest",
|
||||||
"signIn": "Sign in",
|
"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",
|
"otpHint": "Enter the code sent to your phone",
|
||||||
"sendOtp": "Send code",
|
"sendOtp": "Send code",
|
||||||
"verify": "Verify",
|
"verify": "Verify",
|
||||||
|
|
|
||||||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
||||||
"@react-native-async-storage/async-storage": "2.1.2",
|
"@react-native-async-storage/async-storage": "2.1.2",
|
||||||
"expo": "~53.0.0",
|
"expo": "~53.0.0",
|
||||||
"expo-camera": "~16.1.11",
|
"expo-camera": "~16.1.11",
|
||||||
|
"expo-clipboard": "~7.1.4",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
"expo-font": "~13.3.1",
|
"expo-font": "~13.3.1",
|
||||||
"expo-image-picker": "~16.1.4",
|
"expo-image-picker": "~16.1.4",
|
||||||
|
|
@ -2974,7 +2975,7 @@
|
||||||
"version": "19.0.14",
|
"version": "19.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz",
|
||||||
"integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==",
|
"integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
|
|
@ -4272,7 +4273,7 @@
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"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": {
|
"node_modules/expo-constants": {
|
||||||
"version": "17.1.8",
|
"version": "17.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.8.tgz",
|
||||||
|
|
@ -9759,7 +9771,7 @@
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"@react-native-async-storage/async-storage": "2.1.2",
|
"@react-native-async-storage/async-storage": "2.1.2",
|
||||||
"expo": "~53.0.0",
|
"expo": "~53.0.0",
|
||||||
"expo-camera": "~16.1.11",
|
"expo-camera": "~16.1.11",
|
||||||
|
"expo-clipboard": "~7.1.4",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
"expo-font": "~13.3.1",
|
"expo-font": "~13.3.1",
|
||||||
"expo-image-picker": "~16.1.4",
|
"expo-image-picker": "~16.1.4",
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user