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 7310a349c5 Add Gishen mobile app with complete demo shopping and care flows.
Ship Expo Router screens, seeded stores, and polished UX so auth, shop, checkout, prescriptions, loyalty, family, and account processes work end-to-end offline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 01:21:32 +03:00

446 lines
16 KiB
TypeScript

import { router } from "expo-router";
import { 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, 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 { 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 allowed = useLocationStore((s) => s.allowedPayments());
const addresses = useAddressStore((s) => s.addresses);
const defaultAddress = useAddressStore((s) => s.defaultAddress());
const addOrder = useOrdersStore((s) => s.addOrder);
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 [name, setName] = useState(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 selectedAddress = useMemo(
() => addresses.find((a) => a.id === addressId) ?? defaultAddress,
[addresses, addressId, defaultAddress]
);
const deliveryFee = fulfillment === "delivery" && items.length ? DELIVERY_FEE : 0;
const discount = voucherDiscount(subtotal, voucher);
const afterDiscount = Math.max(0, subtotal - discount);
const split =
mode === "corporate"
? 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)) {
Alert.alert("Payment", "Select a valid payment method.");
return;
}
if (fulfillment === "pickup" && !branchId) {
Alert.alert("Pickup", "Select a branch for pickup.");
return;
}
if (fulfillment === "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})` : "",
]
.filter(Boolean)
.join(" · ");
const order = addOrder({
items,
subtotal,
deliveryFee,
discount,
voucherCode: discount ? voucher.trim().toUpperCase() : null,
total: afterDiscount + deliveryFee,
fulfillment,
branchId,
areaLabel:
fulfillment === "pickup"
? branch?.area ?? areaLabel
: selectedAddress?.area ?? areaLabel,
addressId: fulfillment === "delivery" ? selectedAddress?.id ?? null : null,
addressLine,
paymentMethod: payment,
corporateCovered: split.covered,
selfPay: split.selfPay + deliveryFee,
customerName: name.trim(),
customerPhone: phone.trim(),
notes: orderNotes,
riderAssigned: false,
});
if (forMemberId) setActiveMember(forMemberId);
if (split.covered > 0) consumeAllowance(split.covered);
addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
if (discount) {
setActiveVoucher(null);
setCartVoucher(null);
}
clear();
Alert.alert(
"Order placed",
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: "Track", onPress: () => 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>
<Segmented
value={fulfillment}
onChange={setFulfillment}
options={[
{ value: "delivery", label: t("common.delivery") },
{ value: "pickup", label: t("common.pickup") },
]}
/>
{fulfillment === "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 }}>
{branches.map((b) => (
<Pressable
key={b.id}
onPress={() => setBranchId(b.id)}
style={{
padding: 12,
borderRadius: 14,
borderWidth: 1.5,
borderColor: branchId === b.id ? colors.brand : colors.line,
backgroundColor: 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>
{fulfillment === "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={fulfillment === "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";
}