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>
446 lines
16 KiB
TypeScript
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";
|
|
}
|