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 d40cb22ff4 Finalize profile, prescription, and checkout with shared UI modules.
Add payment/notification prefs, assisted pharmacist codes, Rx status journey, and mock payment intents so demo flows match the storefront brief.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:53:34 +03:00

600 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
AssistedOrderExplainer,
EntitlementSplitCard,
LoyaltyRedeemRow,
PaymentMethodList,
paymentLabel,
RxCartGate,
} from "@/components/checkout";
import { Button } from "@/components/ui/Button";
import { Card, InfoRow } from "@/components/ui/Card";
import { Chip, Segmented } from "@/components/ui/Chip";
import { Field } 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 { initiatePayment } from "@/lib/api/client";
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 { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
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 allowedPayments = useLocationStore((s) => s.allowedPayments);
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 redeemPoints = useAuthStore((s) => s.redeemPoints);
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 prescriptions = usePrescriptionStore((s) => s.prescriptions);
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);
const [anyBranch, setAnyBranch] = useState(isGuest);
const [paying, setPaying] = useState(false);
const [assistedMode, setAssistedMode] = useState(isGuest);
useEffect(() => {
if (!isGuest) return;
if (fulfillment !== "pickup") setFulfillment("pickup");
setAnyBranch(true);
setAssistedMode(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 || assistedMode ? "pickup" : fulfillment;
const useAnyBranchPickup =
effectiveFulfillment === "pickup" && (anyBranch || isGuest || assistedMode);
const allowed = useMemo((): PaymentMethod[] => {
if (assistedMode || isGuest) return ["pay_at_branch"];
return allowedPayments();
}, [assistedMode, isGuest, allowedPayments, fulfillment]);
const rxItems = items.filter((i) => i.requiresPrescription);
const hasBlockingRx =
rxItems.length > 0 &&
!prescriptions.some((p) =>
["submitted", "under_review", "approved", "filled", "queried"].includes(p.status)
);
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 totalDue = orderSubtotalTotal({
subtotal: split.selfPay,
deliveryFee,
discount: 0,
});
const placeOrder = async (opts: { assisted: boolean }) => {
if (!items.length || paying) return;
if (!name.trim() || phone.trim().length < 9) {
Alert.alert("Contact details", "Enter your name and a valid phone number.");
return;
}
if (hasBlockingRx && !opts.assisted) {
Alert.alert(
"Prescription needed",
"Upload a prescription for Rx items before paying online."
);
return;
}
if (!opts.assisted && !allowed.includes(payment)) {
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 assisted = opts.assisted || isGuest;
const payMethod: PaymentMethod = assisted ? "pay_at_branch" : payment;
const orderNotes = [
notes.trim(),
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
assisted
? "Pharmacist assisted order code — enter in admin to fulfill"
: useAnyBranchPickup
? "Quick code — redeemable at any Gishen branch"
: "",
]
.filter(Boolean)
.join(" · ");
const pickupCode =
assisted || effectiveFulfillment === "pickup" ? generatePickupCode() : null;
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
setPaying(true);
try {
if (!assisted && payMethod !== "pay_at_branch" && payMethod !== "cod") {
const intent = await initiatePayment({
provider: payMethod,
amountEtb: afterDiscount + fee,
orderId: `pending_${Date.now()}`,
});
if (intent.status === "requires_action" && intent.checkoutUrl) {
await Linking.openURL(intent.checkoutUrl).catch(() => undefined);
}
}
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?.name ?? areaLabel
: selectedAddress?.area ?? areaLabel,
addressId:
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
addressLine: useAnyBranchPickup ? undefined : addressLine,
paymentMethod: payMethod,
corporateCovered: split.covered,
selfPay: split.selfPay + fee,
customerName: name.trim(),
customerPhone: phone.trim(),
notes: orderNotes,
riderAssigned: false,
pickupCode,
anyBranch: useAnyBranchPickup || assisted,
isGuestOrder: assisted,
status: assisted
? "requested"
: payMethod === "pay_at_branch" || payMethod === "cod" || payMethod === "bank"
? "confirmed"
: "confirmed",
});
if (forMemberId) setActiveMember(forMemberId);
if (!assisted && split.covered > 0) consumeAllowance(split.covered);
if (!assisted && !isGuest) {
addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
}
if (discount) {
setActiveVoucher(null);
setCartVoucher(null);
}
clear();
pushNotification({
kind: "order",
title: assisted
? `Pharmacist code ${pickupCode}`
: pickupCode
? `Pickup code ${pickupCode}`
: "Order placed",
body: assisted
? `Share ${pickupCode} so a pharmacist can create your order in admin.`
: pickupCode
? useAnyBranchPickup
? `Show ${pickupCode} at any Gishen branch counter.`
: `Show ${pickupCode} at ${branch?.name ?? "your branch"}.`
: `Your ${order.fulfillment} order is confirmed.`,
href: assisted && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id),
});
if (assisted && pickupCode) {
router.replace(routes.orderCode(order.id) as never);
return;
}
router.replace(routes.checkoutSuccess(order.id) as never);
} catch (e) {
Alert.alert("Payment failed", e instanceof Error ? e.message : "Try again.");
} finally {
setPaying(false);
}
};
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: ${totalDue} 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: 160,
gap: 14,
}}
>
<RxCartGate rxItemNames={rxItems.map((i) => i.name)} />
<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 || assistedMode ? (
<AssistedOrderExplainer
title={isGuest ? "Pharmacist order code" : "Assisted order code"}
body={
isGuest
? "You’ll get a GSH-XXXX code. A pharmacist enters it in admin to create your order — pay at the branch."
: "Freeze this basket as a GSH-XXXX code for a pharmacist to place offline / at the counter."
}
/>
) : null}
{!isGuest ? (
<View style={{ marginBottom: 12 }}>
<Chip
label={assistedMode ? "Assisted code mode on" : "Get pharmacist code instead"}
selected={assistedMode}
onPress={() => {
const next = !assistedMode;
setAssistedMode(next);
if (next) {
setFulfillment("pickup");
setAnyBranch(true);
setPayment("pay_at_branch");
}
}}
/>
</View>
) : null}
<Segmented
value={effectiveFulfillment}
onChange={(v) => {
if ((isGuest || assistedMode) && v === "delivery") {
Alert.alert(
"Assisted checkout",
"Pharmacist codes are pickup-only. Turn off assisted mode for delivery."
);
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 }}>
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>
{!assistedMode && !isGuest ? (
<PaymentMethodList
methods={allowed}
selected={payment}
onSelect={setPayment}
title={t("common.payment")}
footnote={
effectiveFulfillment === "delivery"
? "Pay at branch is only available for pickup"
: undefined
}
/>
) : null}
{!isGuest ? (
<LoyaltyRedeemRow
points={user?.points ?? 0}
activeVoucher={user?.activeVoucher}
voucher={voucher}
discount={discount}
onChangeVoucher={setVoucher}
onApplyDemo={() => setVoucher("GISHEN10")}
onApplyLoyalty={() => user?.activeVoucher && setVoucher(user.activeVoucher!)}
canRedeem={(user?.points ?? 0) >= 100 && !user?.isGuest}
onRedeemPoints={() => {
if (redeemPoints(100)) {
setVoucher("GISHEN10");
setCartVoucher("GISHEN10");
Alert.alert("Redeemed", "GISHEN10 applied to this checkout.");
} else {
Alert.alert("Not enough points", "You need at least 100 points.");
}
}}
/>
) : null}
{mode === "corporate" && membership && !isGuest && !assistedMode ? (
<EntitlementSplitCard
orgName={membership.orgName}
planName={membership.planName}
remainingAllowance={membership.remainingAllowance}
covered={split.covered}
selfPay={split.selfPay + deliveryFee}
/>
) : 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}>
{assistedMode || isGuest ? "Assisted total" : "Total due"}
</Text>
<Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text>
{!assistedMode && !isGuest ? (
<Text variant="micro" color={colors.subtle}>
{paymentLabel(payment)}
</Text>
) : null}
</View>
<Button
title={
paying
? "Processing…"
: isGuest || assistedMode
? "Get pharmacist code"
: t("common.payNow")
}
fullWidth={false}
style={{ minWidth: 148 }}
loading={paying}
disabled={paying || (hasBlockingRx && !assistedMode && !isGuest)}
onPress={() => placeOrder({ assisted: isGuest || assistedMode })}
/>
</View>
</StickyBar>
</Screen>
);
}