Replace the profile tab with prescription management (inline reminders and family invite), add a shared tab top bar, separate notifications from orders, and support any-branch guest checkout with GSH codes. Co-authored-by: Cursor <cursoragent@cursor.com>
556 lines
21 KiB
TypeScript
556 lines
21 KiB
TypeScript
import { router } from "expo-router";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card, InfoRow } from "@/components/ui/Card";
|
|
import { Chip, Segmented } from "@/components/ui/Chip";
|
|
import { Field, OptionRow } from "@/components/ui/Field";
|
|
import { AppHeader } from "@/components/ui/Header";
|
|
import { Screen } from "@/components/ui/Screen";
|
|
import { StickyBar } from "@/components/ui/StickyBar";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { DELIVERY_FEE, generatePickupCode, orderSubtotalTotal, voucherDiscount } from "@/lib/commerce";
|
|
import { branches } from "@/lib/data/branches";
|
|
import { routes } from "@/lib/routes";
|
|
import { useAddressStore } from "@/lib/stores/addressStore";
|
|
import { useAuthStore } from "@/lib/stores/authStore";
|
|
import { useCartStore } from "@/lib/stores/cartStore";
|
|
import { useFamilyStore } from "@/lib/stores/familyStore";
|
|
import { useLocationStore, type PaymentMethod } from "@/lib/stores/locationStore";
|
|
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
|
import { useOrdersStore } from "@/lib/stores/ordersStore";
|
|
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
|
import { colors, gutter } from "@/lib/theme";
|
|
import { formatETB, WHATSAPP_NUMBER } from "@/lib/utils";
|
|
|
|
export default function CheckoutScreen() {
|
|
const { t } = useTranslation();
|
|
const items = useCartStore((s) => s.items);
|
|
const clear = useCartStore((s) => s.clear);
|
|
const subtotal = useCartStore((s) => s.subtotal());
|
|
const cartVoucher = useCartStore((s) => s.voucherCode);
|
|
const setCartVoucher = useCartStore((s) => s.setVoucher);
|
|
const fulfillment = useLocationStore((s) => s.fulfillment);
|
|
const setFulfillment = useLocationStore((s) => s.setFulfillment);
|
|
const branchId = useLocationStore((s) => s.branchId);
|
|
const setBranchId = useLocationStore((s) => s.setBranchId);
|
|
const areaLabel = useLocationStore((s) => s.areaLabel);
|
|
const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
|
|
const payment = useLocationStore((s) => s.paymentPreference);
|
|
const setPayment = useLocationStore((s) => s.setPaymentPreference);
|
|
const addresses = useAddressStore((s) => s.addresses);
|
|
const defaultAddress = useAddressStore((s) => s.defaultAddress());
|
|
const addOrder = useOrdersStore((s) => s.addOrder);
|
|
const pushNotification = useNotificationStore((s) => s.push);
|
|
const addPoints = useAuthStore((s) => s.addPoints);
|
|
const user = useAuthStore((s) => s.user);
|
|
const setActiveVoucher = useAuthStore((s) => s.setActiveVoucher);
|
|
const quoteSplit = useProfileModeStore((s) => s.quoteSplit);
|
|
const consumeAllowance = useProfileModeStore((s) => s.consumeAllowance);
|
|
const mode = useProfileModeStore((s) => s.mode);
|
|
const membership = useProfileModeStore((s) => s.activeMembership());
|
|
const familyMembers = useFamilyStore((s) => s.members);
|
|
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
|
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
|
const isGuest = !!user?.isGuest;
|
|
|
|
const [name, setName] = useState(user?.name && !user.isGuest ? user.name : "");
|
|
const [phone, setPhone] = useState(user?.phone ?? "");
|
|
const [notes, setNotes] = useState("");
|
|
const [addressId, setAddressId] = useState(defaultAddress?.id ?? "");
|
|
const [voucher, setVoucher] = useState(
|
|
cartVoucher || user?.activeVoucher || ""
|
|
);
|
|
const [forMemberId, setForMemberId] = useState<string | null>(activeMemberId);
|
|
/** Guests (and optional pickup) can fulfill at any branch with a quick code */
|
|
const [anyBranch, setAnyBranch] = useState(isGuest);
|
|
|
|
useEffect(() => {
|
|
if (!isGuest) return;
|
|
if (fulfillment !== "pickup") setFulfillment("pickup");
|
|
setAnyBranch(true);
|
|
if (payment !== "pay_at_branch") setPayment("pay_at_branch");
|
|
}, [isGuest, fulfillment, payment, setFulfillment, setPayment]);
|
|
|
|
const selectedAddress = useMemo(
|
|
() => addresses.find((a) => a.id === addressId) ?? defaultAddress,
|
|
[addresses, addressId, defaultAddress]
|
|
);
|
|
|
|
const effectiveFulfillment = isGuest ? "pickup" : fulfillment;
|
|
const useAnyBranchPickup =
|
|
effectiveFulfillment === "pickup" && (anyBranch || isGuest);
|
|
|
|
const allowed = useMemo((): PaymentMethod[] => {
|
|
const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"];
|
|
return effectiveFulfillment === "pickup"
|
|
? [...base, "pay_at_branch"]
|
|
: base;
|
|
}, [effectiveFulfillment]);
|
|
|
|
const deliveryFee =
|
|
effectiveFulfillment === "delivery" && items.length ? DELIVERY_FEE : 0;
|
|
const discount = voucherDiscount(subtotal, voucher);
|
|
const afterDiscount = Math.max(0, subtotal - discount);
|
|
const split =
|
|
mode === "corporate" && !isGuest
|
|
? quoteSplit(afterDiscount)
|
|
: { covered: 0, selfPay: afterDiscount };
|
|
const total = orderSubtotalTotal({
|
|
subtotal: split.selfPay,
|
|
deliveryFee,
|
|
discount: 0,
|
|
});
|
|
|
|
const pay = () => {
|
|
if (!items.length) return;
|
|
if (!name.trim() || phone.trim().length < 9) {
|
|
Alert.alert("Contact details", "Enter your name and a valid phone number.");
|
|
return;
|
|
}
|
|
if (!allowed.includes(payment) && !(useAnyBranchPickup && payment === "pay_at_branch")) {
|
|
Alert.alert("Payment", "Select a valid payment method.");
|
|
return;
|
|
}
|
|
|
|
if (effectiveFulfillment === "pickup" && !useAnyBranchPickup && !branchId) {
|
|
Alert.alert("Pickup", "Select a branch, or choose any-branch quick code.");
|
|
return;
|
|
}
|
|
if (effectiveFulfillment === "delivery" && !selectedAddress && !areaLabel.trim()) {
|
|
Alert.alert("Delivery", "Choose a saved address or enter an area.");
|
|
return;
|
|
}
|
|
|
|
const branch = branches.find((b) => b.id === branchId);
|
|
const addressLine = selectedAddress
|
|
? `${selectedAddress.street}${selectedAddress.landmark ? ` · ${selectedAddress.landmark}` : ""}`
|
|
: undefined;
|
|
|
|
const forMember = familyMembers.find((m) => m.id === forMemberId);
|
|
const orderNotes = [
|
|
notes.trim(),
|
|
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
|
|
useAnyBranchPickup ? "Quick code — redeemable at any Gishen branch" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
|
|
const pickupCode =
|
|
effectiveFulfillment === "pickup" ? generatePickupCode() : null;
|
|
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
|
|
|
|
const order = addOrder({
|
|
items,
|
|
subtotal,
|
|
deliveryFee: fee,
|
|
discount,
|
|
voucherCode: discount ? voucher.trim().toUpperCase() : null,
|
|
total: afterDiscount + fee,
|
|
fulfillment: effectiveFulfillment,
|
|
branchId: useAnyBranchPickup ? null : branchId,
|
|
areaLabel: useAnyBranchPickup
|
|
? "Any Gishen branch"
|
|
: effectiveFulfillment === "pickup"
|
|
? branch?.area ?? areaLabel
|
|
: selectedAddress?.area ?? areaLabel,
|
|
addressId:
|
|
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
|
addressLine: useAnyBranchPickup ? undefined : addressLine,
|
|
paymentMethod: payment,
|
|
corporateCovered: split.covered,
|
|
selfPay: split.selfPay + fee,
|
|
customerName: name.trim(),
|
|
customerPhone: phone.trim(),
|
|
notes: orderNotes,
|
|
riderAssigned: false,
|
|
pickupCode,
|
|
anyBranch: useAnyBranchPickup,
|
|
isGuestOrder: isGuest,
|
|
status:
|
|
useAnyBranchPickup || payment === "pay_at_branch" ? "confirmed" : undefined,
|
|
});
|
|
|
|
if (forMemberId) setActiveMember(forMemberId);
|
|
|
|
if (!isGuest && split.covered > 0) consumeAllowance(split.covered);
|
|
if (!isGuest) addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
|
|
if (discount) {
|
|
setActiveVoucher(null);
|
|
setCartVoucher(null);
|
|
}
|
|
clear();
|
|
|
|
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),
|
|
});
|
|
|
|
Alert.alert(
|
|
pickupCode ? "Quick pickup code ready" : "Order placed",
|
|
pickupCode
|
|
? `Your code is ${pickupCode}. ${
|
|
useAnyBranchPickup
|
|
? "Present it at any Gishen branch for fulfillment."
|
|
: `Present it at ${branch?.name ?? "the branch"}.`
|
|
}${
|
|
payment === "pay_at_branch"
|
|
? ` Pay ${formatETB(order.selfPay ?? order.total)} at the counter.`
|
|
: ""
|
|
}`
|
|
: payment === "pay_at_branch"
|
|
? `Pay ${formatETB(order.selfPay ?? order.total)} at ${branch?.name ?? "the branch"}.`
|
|
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
|
|
[
|
|
{ text: "View order", onPress: () => router.replace(routes.orderDetail(order.id)) },
|
|
{
|
|
text: pickupCode ? "Done" : "Track",
|
|
onPress: () =>
|
|
pickupCode
|
|
? router.replace(routes.orderDetail(order.id))
|
|
: router.replace(routes.tracking(order.id)),
|
|
},
|
|
]
|
|
);
|
|
};
|
|
|
|
const whatsapp = () => {
|
|
const lines = items
|
|
.map((i) => `• ${i.name} x${i.quantity} — ${i.price * i.quantity} ETB`)
|
|
.join("%0A");
|
|
const msg = `Gishen order%0AName: ${name}%0APhone: ${phone}%0A${lines}%0ATotal: ${total} ETB%0A${fulfillment}%0A${notes}`;
|
|
Linking.openURL(`https://wa.me/${WHATSAPP_NUMBER}?text=${msg}`);
|
|
};
|
|
|
|
if (!items.length) {
|
|
return (
|
|
<Screen>
|
|
<AppHeader title={t("common.checkout")} />
|
|
<View style={{ padding: gutter }}>
|
|
<Text variant="body" color={colors.muted}>
|
|
Your cart is empty. Add products before checking out.
|
|
</Text>
|
|
<Button
|
|
title="Browse shop"
|
|
style={{ marginTop: 16 }}
|
|
onPress={() => router.replace(routes.shop)}
|
|
/>
|
|
</View>
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Screen>
|
|
<AppHeader title={t("common.checkout")} subtitle="Confirm delivery and payment" />
|
|
<ScrollView
|
|
contentContainerStyle={{
|
|
paddingHorizontal: gutter,
|
|
paddingBottom: 140,
|
|
gap: 14,
|
|
}}
|
|
>
|
|
<Card elevated={false}>
|
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
|
Contact
|
|
</Text>
|
|
{familyMembers.length ? (
|
|
<View style={{ marginBottom: 12 }}>
|
|
<Text variant="label" color={colors.muted} style={{ marginBottom: 8 }}>
|
|
Ordering for
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
|
<Chip
|
|
label="Myself"
|
|
selected={!forMemberId}
|
|
onPress={() => setForMemberId(null)}
|
|
size="sm"
|
|
/>
|
|
{familyMembers.map((m) => (
|
|
<Chip
|
|
key={m.id}
|
|
label={m.name}
|
|
selected={forMemberId === m.id}
|
|
onPress={() => setForMemberId(m.id)}
|
|
size="sm"
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
<View style={{ 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="Order notes"
|
|
value={notes}
|
|
onChangeText={setNotes}
|
|
icon="create-outline"
|
|
placeholder="Gate code, call on arrival…"
|
|
/>
|
|
</View>
|
|
</Card>
|
|
|
|
<Card elevated={false}>
|
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
|
Fulfillment
|
|
</Text>
|
|
{isGuest ? (
|
|
<Card tone="brand" elevated={false} style={{ marginBottom: 12 }}>
|
|
<Text variant="h3">Guest quick 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.
|
|
</Text>
|
|
</Card>
|
|
) : null}
|
|
<Segmented
|
|
value={effectiveFulfillment}
|
|
onChange={(v) => {
|
|
if (isGuest && v === "delivery") {
|
|
Alert.alert(
|
|
"Guest checkout",
|
|
"Guests use quick-code pickup at any branch. Sign in for delivery & loyalty."
|
|
);
|
|
setFulfillment("pickup");
|
|
setAnyBranch(true);
|
|
return;
|
|
}
|
|
setFulfillment(v);
|
|
if (v === "delivery") setAnyBranch(false);
|
|
}}
|
|
options={[
|
|
{ value: "delivery", label: t("common.delivery") },
|
|
{ value: "pickup", label: t("common.pickup") },
|
|
]}
|
|
/>
|
|
|
|
{effectiveFulfillment === "delivery" ? (
|
|
<View style={{ marginTop: 14, gap: 8 }}>
|
|
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
|
<Text variant="label" color={colors.muted}>
|
|
Saved addresses
|
|
</Text>
|
|
<Pressable onPress={() => router.push(routes.addresses)}>
|
|
<Text variant="label" color={colors.brandText}>
|
|
Manage
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
{addresses.map((a) => (
|
|
<Pressable
|
|
key={a.id}
|
|
onPress={() => {
|
|
setAddressId(a.id);
|
|
setAreaLabel(a.area);
|
|
}}
|
|
style={{
|
|
padding: 12,
|
|
borderRadius: 14,
|
|
borderWidth: 1.5,
|
|
borderColor: addressId === a.id ? colors.brand : colors.line,
|
|
backgroundColor: addressId === a.id ? colors.brandWash : colors.surface,
|
|
}}
|
|
>
|
|
<Text variant="h3">
|
|
{a.label}
|
|
{a.isDefault ? " · Default" : ""}
|
|
</Text>
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
{a.street}
|
|
</Text>
|
|
<Text variant="caption" color={colors.subtle}>
|
|
{a.area}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
<Field
|
|
label="Or type area"
|
|
value={areaLabel}
|
|
onChangeText={setAreaLabel}
|
|
icon="location-outline"
|
|
/>
|
|
</View>
|
|
) : (
|
|
<View style={{ marginTop: 14, gap: 8 }}>
|
|
<Pressable
|
|
onPress={() => {
|
|
setAnyBranch(true);
|
|
setBranchId(null);
|
|
setPayment("pay_at_branch");
|
|
}}
|
|
style={{
|
|
padding: 12,
|
|
borderRadius: 14,
|
|
borderWidth: 1.5,
|
|
borderColor: anyBranch ? colors.brand : colors.line,
|
|
backgroundColor: anyBranch ? colors.brandWash : colors.surface,
|
|
}}
|
|
>
|
|
<Text variant="h3">Any branch · quick code</Text>
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
Get a GSH-XXXX code and collect at Bole, CMC, Jemo, or any other branch
|
|
</Text>
|
|
</Pressable>
|
|
{branches.map((b) => (
|
|
<Pressable
|
|
key={b.id}
|
|
onPress={() => {
|
|
setAnyBranch(false);
|
|
setBranchId(b.id);
|
|
}}
|
|
style={{
|
|
padding: 12,
|
|
borderRadius: 14,
|
|
borderWidth: 1.5,
|
|
borderColor:
|
|
!anyBranch && branchId === b.id ? colors.brand : colors.line,
|
|
backgroundColor:
|
|
!anyBranch && branchId === b.id
|
|
? colors.brandWash
|
|
: colors.surface,
|
|
}}
|
|
>
|
|
<Text variant="h3">{b.name}</Text>
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
|
{b.area} · {b.hours}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
)}
|
|
</Card>
|
|
|
|
<Card elevated={false} padded={false}>
|
|
<View style={{ padding: 16, paddingBottom: 8 }}>
|
|
<Text variant="h3">{t("common.payment")}</Text>
|
|
{effectiveFulfillment === "delivery" ? (
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
Pay at branch is only available for pickup
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
{allowed.map((m, i) => (
|
|
<OptionRow
|
|
key={m}
|
|
title={labelPayment(m, t)}
|
|
subtitle={paymentHint(m)}
|
|
selected={payment === m}
|
|
onPress={() => setPayment(m)}
|
|
icon={paymentIcon(m)}
|
|
last={i === allowed.length - 1}
|
|
/>
|
|
))}
|
|
</Card>
|
|
|
|
<Card elevated={false}>
|
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
|
Voucher
|
|
</Text>
|
|
<Field
|
|
label="Code"
|
|
value={voucher}
|
|
onChangeText={setVoucher}
|
|
placeholder="GISHEN10"
|
|
icon="pricetag-outline"
|
|
hint={discount ? `Saving ${formatETB(discount)}` : "Try GISHEN10 for 10% off"}
|
|
/>
|
|
<View style={{ flexDirection: "row", gap: 8, marginTop: 10 }}>
|
|
<Chip label="GISHEN10" onPress={() => setVoucher("GISHEN10")} selected={voucher.toUpperCase() === "GISHEN10"} />
|
|
{user?.activeVoucher ? (
|
|
<Chip
|
|
label={`Loyalty: ${user.activeVoucher}`}
|
|
onPress={() => setVoucher(user.activeVoucher!)}
|
|
selected={voucher === user.activeVoucher}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
</Card>
|
|
|
|
{mode === "corporate" && membership ? (
|
|
<Card tone="brand" elevated={false}>
|
|
<Text variant="h3">Corporate split · {membership.orgName}</Text>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
|
Plan {membership.planName} · {formatETB(membership.remainingAllowance)} left this month
|
|
</Text>
|
|
<Text variant="bodySm" style={{ marginTop: 8 }}>
|
|
Covered: {formatETB(split.covered)}
|
|
</Text>
|
|
<Text variant="bodySm">You pay: {formatETB(split.selfPay + deliveryFee)}</Text>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Card elevated={false}>
|
|
<InfoRow label="Items" value={`${items.length}`} />
|
|
<InfoRow label="Subtotal" value={formatETB(subtotal)} />
|
|
{discount > 0 ? (
|
|
<InfoRow
|
|
label="Discount"
|
|
value={`- ${formatETB(discount)}`}
|
|
valueColor={colors.brandText}
|
|
/>
|
|
) : null}
|
|
<InfoRow
|
|
label={effectiveFulfillment === "delivery" ? "Delivery" : "Pickup"}
|
|
value={deliveryFee ? formatETB(deliveryFee) : "Free"}
|
|
/>
|
|
<InfoRow label={t("common.total")} value={formatETB(afterDiscount + deliveryFee)} emphasis last />
|
|
</Card>
|
|
|
|
<Button title="Order via WhatsApp" variant="outline" icon="logo-whatsapp" onPress={whatsapp} />
|
|
</ScrollView>
|
|
|
|
<StickyBar>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="caption" color={colors.subtle}>
|
|
Total due
|
|
</Text>
|
|
<Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text>
|
|
</View>
|
|
<Button
|
|
title={t("common.payNow")}
|
|
fullWidth={false}
|
|
style={{ minWidth: 140 }}
|
|
onPress={pay}
|
|
/>
|
|
</View>
|
|
</StickyBar>
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
function labelPayment(m: PaymentMethod, t: (k: string) => string) {
|
|
if (m === "chapa") return t("common.chapa");
|
|
if (m === "telebirr") return t("common.telebirr");
|
|
if (m === "mpesa") return t("common.mpesa");
|
|
return t("common.payAtBranch");
|
|
}
|
|
|
|
function paymentHint(m: PaymentMethod) {
|
|
if (m === "chapa") return "Card / mobile money via Chapa";
|
|
if (m === "telebirr") return "Ethio telecom Telebirr";
|
|
if (m === "mpesa") return "Safaricom M-Pesa";
|
|
return "Cash or POS when you collect";
|
|
}
|
|
|
|
function paymentIcon(m: PaymentMethod): keyof typeof import("@expo/vector-icons").Ionicons.glyphMap {
|
|
if (m === "pay_at_branch") return "storefront-outline";
|
|
if (m === "telebirr") return "phone-portrait-outline";
|
|
if (m === "mpesa") return "cash-outline";
|
|
return "card-outline";
|
|
}
|