This repository has been archived on 2026-08-11. You can view files and clone it, but cannot push or open issues or pull requests.
Gishen-Mob/app/(root)/(screens)/checkout.tsx
kirukib 5391780b9b Ship Rx Care tab, notifications inbox, and guest pickup codes.
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>
2026-08-07 15:35:51 +03:00

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";
}