diff --git a/app/(root)/(screens)/checkout-success.tsx b/app/(root)/(screens)/checkout-success.tsx
new file mode 100644
index 0000000..6144fd4
--- /dev/null
+++ b/app/(root)/(screens)/checkout-success.tsx
@@ -0,0 +1,59 @@
+import { router, useLocalSearchParams } from "expo-router";
+import { View } from "react-native";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { AppHeader } from "@/components/ui/Header";
+import { Screen } from "@/components/ui/Screen";
+import { Text } from "@/components/ui/Text";
+import { routes } from "@/lib/routes";
+import { useOrdersStore } from "@/lib/stores/ordersStore";
+import { colors, gutter } from "@/lib/theme";
+import { formatETB } from "@/lib/utils";
+
+export default function CheckoutSuccessScreen() {
+ const params = useLocalSearchParams<{ id?: string }>();
+ const id = Array.isArray(params.id) ? params.id[0] : params.id;
+ const order = useOrdersStore((s) => s.orders.find((o) => o.id === id));
+
+ return (
+
+
+
+
+ Thank you
+
+ {order
+ ? `Order ${order.id.slice(-8).toUpperCase()} · ${formatETB(order.selfPay ?? order.total)}`
+ : "Your order was placed successfully."}
+
+ {order?.pickupCode ? (
+
+ Code {order.pickupCode}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/app/(root)/(screens)/checkout.tsx b/app/(root)/(screens)/checkout.tsx
index c7a9f93..705bf09 100644
--- a/app/(root)/(screens)/checkout.tsx
+++ b/app/(root)/(screens)/checkout.tsx
@@ -2,15 +2,29 @@ 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, OptionRow } from "@/components/ui/Field";
+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 { DELIVERY_FEE, generatePickupCode, orderSubtotalTotal, voucherDiscount } from "@/lib/commerce";
+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";
@@ -20,6 +34,7 @@ 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";
@@ -39,11 +54,13 @@ export default function CheckoutScreen() {
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);
@@ -53,23 +70,24 @@ export default function CheckoutScreen() {
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 [voucher, setVoucher] = useState(cartVoucher || user?.activeVoucher || "");
const [forMemberId, setForMemberId] = useState(activeMemberId);
- /** Guests (and optional pickup) can fulfill at any branch with a quick code */
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]);
@@ -78,16 +96,21 @@ export default function CheckoutScreen() {
[addresses, addressId, defaultAddress]
);
- const effectiveFulfillment = isGuest ? "pickup" : fulfillment;
+ const effectiveFulfillment = isGuest || assistedMode ? "pickup" : fulfillment;
const useAnyBranchPickup =
- effectiveFulfillment === "pickup" && (anyBranch || isGuest);
+ effectiveFulfillment === "pickup" && (anyBranch || isGuest || assistedMode);
const allowed = useMemo((): PaymentMethod[] => {
- const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"];
- return effectiveFulfillment === "pickup"
- ? [...base, "pay_at_branch"]
- : base;
- }, [effectiveFulfillment]);
+ 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;
@@ -97,23 +120,29 @@ export default function CheckoutScreen() {
mode === "corporate" && !isGuest
? quoteSplit(afterDiscount)
: { covered: 0, selfPay: afterDiscount };
- const total = orderSubtotalTotal({
+ const totalDue = orderSubtotalTotal({
subtotal: split.selfPay,
deliveryFee,
discount: 0,
});
- const pay = () => {
- if (!items.length) return;
+ 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 (!allowed.includes(payment) && !(useAnyBranchPickup && payment === "pay_at_branch")) {
+ 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;
@@ -127,13 +156,15 @@ export default function CheckoutScreen() {
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})` : "",
- isGuest
- ? "Pharmacist order code — enter in admin to create the order"
+ assisted
+ ? "Pharmacist assisted order code — enter in admin to fulfill"
: useAnyBranchPickup
? "Quick code — redeemable at any Gishen branch"
: "",
@@ -142,110 +173,101 @@ export default function CheckoutScreen() {
.join(" · ");
const pickupCode =
- isGuest || effectiveFulfillment === "pickup"
- ? generatePickupCode()
- : null;
+ assisted || 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?.name ?? 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 || isGuest,
- isGuestOrder: isGuest,
- status: isGuest
- ? "requested"
- : useAnyBranchPickup || payment === "pay_at_branch"
- ? "confirmed"
- : undefined,
- });
+ 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);
+ }
+ }
- if (forMemberId) setActiveMember(forMemberId);
+ 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 (!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);
+ 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);
}
- clear();
-
- pushNotification({
- kind: "order",
- title: isGuest
- ? `Pharmacist code ${pickupCode}`
- : pickupCode
- ? `Pickup code ${pickupCode}`
- : "Order placed",
- body: isGuest
- ? `Share ${pickupCode} so a pharmacist can create your order in admin.`
- : 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: isGuest && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id),
- });
-
- if (isGuest && pickupCode) {
- router.replace(routes.orderCode(order.id) as never);
- return;
- }
-
- 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}`;
+ 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}`);
};
@@ -273,10 +295,12 @@ export default function CheckoutScreen() {
+ i.name)} />
+
Contact
@@ -328,25 +352,41 @@ export default function CheckoutScreen() {
Fulfillment
- {isGuest ? (
-
- Pharmacist order code
-
- You’ll get a GSH-XXXX code to copy, share, or screenshot. A pharmacist
- enters it on the admin page to create your order — then you pay at the branch.
-
-
+ {isGuest || assistedMode ? (
+
+ ) : null}
+ {!isGuest ? (
+
+ {
+ const next = !assistedMode;
+ setAssistedMode(next);
+ if (next) {
+ setFulfillment("pickup");
+ setAnyBranch(true);
+ setPayment("pay_at_branch");
+ }
+ }}
+ />
+
) : null}
{
- if (isGuest && v === "delivery") {
+ if ((isGuest || assistedMode) && v === "delivery") {
Alert.alert(
- "Guest checkout",
- "Guests use quick-code pickup at any branch. Sign in for delivery & loyalty."
+ "Assisted checkout",
+ "Pharmacist codes are pickup-only. Turn off assisted mode for delivery."
);
- setFulfillment("pickup");
- setAnyBranch(true);
return;
}
setFulfillment(v);
@@ -422,7 +462,7 @@ export default function CheckoutScreen() {
>
Any branch · quick code
- Get a GSH-XXXX code and collect at Bole, CMC, Jemo, or any other branch
+ Collect at Bole, CMC, Jemo, or any other branch
{branches.map((b) => (
@@ -454,63 +494,50 @@ export default function CheckoutScreen() {
)}
-
-
- {t("common.payment")}
- {effectiveFulfillment === "delivery" ? (
-
- Pay at branch is only available for pickup
-
- ) : null}
-
- {allowed.map((m, i) => (
- setPayment(m)}
- icon={paymentIcon(m)}
- last={i === allowed.length - 1}
- />
- ))}
-
-
-
-
- Voucher
-
-
-
- setVoucher("GISHEN10")} selected={voucher.toUpperCase() === "GISHEN10"} />
- {user?.activeVoucher ? (
- setVoucher(user.activeVoucher!)}
- selected={voucher === user.activeVoucher}
- />
- ) : null}
-
-
+ ) : null}
- {mode === "corporate" && membership ? (
-
- Corporate split · {membership.orgName}
-
- Plan {membership.planName} · {formatETB(membership.remainingAllowance)} left this month
-
-
- Covered: {formatETB(split.covered)}
-
- You pay: {formatETB(split.selfPay + deliveryFee)}
-
+ {!isGuest ? (
+ 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 ? (
+
) : null}
@@ -527,7 +554,12 @@ export default function CheckoutScreen() {
label={effectiveFulfillment === "delivery" ? "Delivery" : "Pickup"}
value={deliveryFee ? formatETB(deliveryFee) : "Free"}
/>
-
+
@@ -537,39 +569,31 @@ export default function CheckoutScreen() {
- Total due
+ {assistedMode || isGuest ? "Assisted total" : "Total due"}
{formatETB(afterDiscount + deliveryFee)}
+ {!assistedMode && !isGuest ? (
+
+ {paymentLabel(payment)}
+
+ ) : null}
placeOrder({ assisted: isGuest || assistedMode })}
/>
);
}
-
-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";
-}
diff --git a/app/(root)/(screens)/notification-prefs.tsx b/app/(root)/(screens)/notification-prefs.tsx
new file mode 100644
index 0000000..30c0763
--- /dev/null
+++ b/app/(root)/(screens)/notification-prefs.tsx
@@ -0,0 +1,82 @@
+import { ScrollView, View } from "react-native";
+import { Card } from "@/components/ui/Card";
+import { SwitchRow } from "@/components/ui/Field";
+import { AppHeader } from "@/components/ui/Header";
+import { Screen } from "@/components/ui/Screen";
+import { Text } from "@/components/ui/Text";
+import { usePrefsStore } from "@/lib/stores/prefsStore";
+import { colors, gutter } from "@/lib/theme";
+
+export default function NotificationPrefsScreen() {
+ const prefs = usePrefsStore();
+
+ return (
+
+
+
+
+
+ Choose how Gishen reaches you for orders, refill reminders, and updates.
+
+
+
+
+
+ prefs.setPref("sms", v)}
+ />
+ prefs.setPref("email", v)}
+ />
+ prefs.setPref("push", v)}
+ last
+ />
+
+
+
+
+
+ prefs.setPref("orderUpdates", v)}
+ />
+ prefs.setPref("refillReminders", v)}
+ />
+ prefs.setPref("marketing", v)}
+ last
+ />
+
+
+
+
+ );
+}
diff --git a/app/(root)/(screens)/payment-methods.tsx b/app/(root)/(screens)/payment-methods.tsx
new file mode 100644
index 0000000..592f2c1
--- /dev/null
+++ b/app/(root)/(screens)/payment-methods.tsx
@@ -0,0 +1,58 @@
+import { router } from "expo-router";
+import { ScrollView, View } from "react-native";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { AppHeader } from "@/components/ui/Header";
+import { Screen } from "@/components/ui/Screen";
+import { Text } from "@/components/ui/Text";
+import { PaymentMethodList } from "@/components/checkout/PaymentMethodList";
+import { routes } from "@/lib/routes";
+import { useLocationStore, type PaymentMethod } from "@/lib/stores/locationStore";
+import { colors, gutter } from "@/lib/theme";
+
+const ALL: PaymentMethod[] = [
+ "chapa",
+ "arifpay",
+ "telebirr",
+ "mpesa",
+ "bank",
+ "cod",
+ "pay_at_branch",
+];
+
+export default function PaymentMethodsScreen() {
+ const payment = useLocationStore((s) => s.paymentPreference);
+ const setPayment = useLocationStore((s) => s.setPaymentPreference);
+
+ return (
+
+
+
+
+
+ Saved preference is pre-selected at checkout. Card/wallet tokens are not
+ stored in this demo — gateway handoff happens at pay time.
+
+
+
+ router.back()} />
+ router.push(routes.checkout)}
+ />
+
+
+ );
+}
diff --git a/app/(root)/(screens)/prescription/[id].tsx b/app/(root)/(screens)/prescription/[id].tsx
index eb70b62..8707e7c 100644
--- a/app/(root)/(screens)/prescription/[id].tsx
+++ b/app/(root)/(screens)/prescription/[id].tsx
@@ -2,6 +2,13 @@ import { Ionicons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Image, ScrollView, View } from "react-native";
+import {
+ RxImagePager,
+ RxMedicineLines,
+ RxQueriedPanel,
+ RxStatusStepper,
+} from "@/components/prescription";
+import { EscalationBanner } from "@/components/care";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card, InfoRow, Section } from "@/components/ui/Card";
@@ -11,11 +18,11 @@ import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { products } from "@/lib/data/products";
import { scheduleDoseReminder } from "@/lib/notifications";
+import { routes } from "@/lib/routes";
import { useCartStore } from "@/lib/stores/cartStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
import { colors, gutter } from "@/lib/theme";
-import { routes } from "@/lib/routes";
const statusTone = {
submitted: "neutral",
@@ -26,12 +33,6 @@ const statusTone = {
filled: "brand",
} as const;
-const lineTone = {
- ready: "brand",
- review: "warn",
- unavailable: "danger",
-} as const;
-
export default function PrescriptionDetailScreen() {
const params = useLocalSearchParams<{ id: string }>();
const id = Array.isArray(params.id) ? params.id[0] : params.id;
@@ -50,6 +51,7 @@ export default function PrescriptionDetailScreen() {
);
const markRefilled = usePrescriptionStore((s) => s.markRefilled);
const updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus);
+ const replyToQuery = usePrescriptionStore((s) => s.replyToQuery);
const confirmDose = usePrescriptionStore((s) => s.confirmDose);
const addProduct = useCartStore((s) => s.addProduct);
const pushNotification = useNotificationStore((s) => s.push);
@@ -76,6 +78,7 @@ export default function PrescriptionDetailScreen() {
})();
const pendingLog = recentLogs.find((l) => l.status === "pending");
+ const refillSoon = dueIn !== null && dueIn <= 5;
return (
@@ -91,6 +94,74 @@ export default function PrescriptionDetailScreen() {
gap: 14,
}}
>
+
+
+ Pharmacist review status
+
+
+
+
+ {rx.status === "queried" ? (
+ {
+ replyToQuery(rx.id, message);
+ pushNotification({
+ kind: "rx",
+ title: "Reply sent",
+ body: "Your clarification was sent back to the pharmacist.",
+ href: routes.rxDetail(rx.id),
+ });
+ Alert.alert("Sent", "Status moved back to under review.");
+ }}
+ />
+ ) : null}
+
+ {rx.status === "rejected" ? (
+
+
+ Prescription rejected
+
+
+ {rx.pharmacistMessage ||
+ "The pharmacist could not approve this script. Upload a clearer copy or visit a branch."}
+
+ router.push(routes.rxScan)}
+ />
+
+ ) : null}
+
+ {refillSoon ? (
+
+
+ {dueIn !== null && dueIn <= 0 ? "Refill overdue" : "Refill reminder"}
+
+
+ {dueIn !== null && dueIn <= 0
+ ? "Days of supply have run out — reorder or mark refilled after pickup."
+ : `About ${dueIn} day${dueIn === 1 ? "" : "s"} of supply left.`}
+
+ {
+ rx.medicines.forEach((m) => {
+ const match = products.find((p) =>
+ p.name.toLowerCase().includes(m.name.toLowerCase().split(" ")[0])
+ );
+ if (match) addProduct(match);
+ });
+ router.push(routes.cart);
+ }}
+ />
+
+ ) : null}
+
+ {rx.imageUris.length > 1 ? (
+
+
+ Pages
+
+
+
+ ) : null}
-
+
{dueIn !== null ? (
)}
{reminder?.enabled ? (
-
+
) : (
)}
@@ -144,6 +215,11 @@ export default function PrescriptionDetailScreen() {
+ {reminder?.escalateOnMiss ? (
+
+
+
+ ) : null}
{reminder?.enabled && !showSetup ? (
@@ -285,41 +361,7 @@ export default function PrescriptionDetailScreen() {
-
- {rx.medicines.map((m, i) => (
-
-
-
-
-
-
- {m.name}
-
-
-
-
- ))}
-
+
@@ -347,10 +389,12 @@ export default function PrescriptionDetailScreen() {
title="Reorder medicines"
icon="bag-add-outline"
onPress={() => {
- const match = products.find((p) =>
- rx.medicineName.toLowerCase().includes(p.name.toLowerCase().split(" ")[0])
- );
- if (match) addProduct(match);
+ rx.medicines.forEach((m) => {
+ const match = products.find((p) =>
+ p.name.toLowerCase().includes(m.name.toLowerCase().split(" ")[0])
+ );
+ if (match) addProduct(match);
+ });
router.push(routes.cart);
}}
/>
@@ -369,6 +413,19 @@ export default function PrescriptionDetailScreen() {
onPress={() => updateRxStatus(rx.id, "approved")}
/>
) : null}
+ {rx.status === "submitted" ? (
+
+ updateRxStatus(
+ rx.id,
+ "queried",
+ "Please confirm the prescribed strength on page 1."
+ )
+ }
+ />
+ ) : null}
diff --git a/app/(root)/(screens)/prescription/review.tsx b/app/(root)/(screens)/prescription/review.tsx
index 379771d..876ac87 100644
--- a/app/(root)/(screens)/prescription/review.tsx
+++ b/app/(root)/(screens)/prescription/review.tsx
@@ -1,6 +1,7 @@
import { router } from "expo-router";
import { useMemo, useState } from "react";
-import { Alert, Image, ScrollView, View } from "react-native";
+import { Alert, ScrollView, View } from "react-native";
+import { RxImagePager, RxStatusStepper } from "@/components/prescription";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -15,8 +16,9 @@ import { useAuthStore } from "@/lib/stores/authStore";
import { useCartStore } from "@/lib/stores/cartStore";
import { useFamilyStore } from "@/lib/stores/familyStore";
import { useLocationStore } from "@/lib/stores/locationStore";
+import { useNotificationStore } from "@/lib/stores/notificationStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
-import { colors, gutter, radii } from "@/lib/theme";
+import { colors, gutter } from "@/lib/theme";
function matchStatus(name: string): "ready" | "review" | "unavailable" {
const hit = products.find((p) =>
@@ -33,6 +35,7 @@ export default function RxReviewScreen() {
const addPrescription = usePrescriptionStore((s) => s.addPrescription);
const addProduct = useCartStore((s) => s.addProduct);
const setFulfillment = useLocationStore((s) => s.setFulfillment);
+ const pushNotification = useNotificationStore((s) => s.push);
const user = useAuthStore((s) => s.user);
const members = useFamilyStore((s) => s.members);
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
@@ -90,41 +93,68 @@ export default function RxReviewScreen() {
setFulfillment("pickup");
}
setDraftImages([]);
- Alert.alert("Saved", "Prescription submitted for pharmacist review", [
- { text: "Open details", onPress: () => router.replace(routes.rxDetail(rx.id)) },
+ pushNotification({
+ kind: "rx",
+ title: "Prescription submitted",
+ body: "A pharmacist will review your script shortly.",
+ href: routes.rxDetail(rx.id),
+ });
+ Alert.alert("Submitted", "Your prescription is under review.", [
+ { text: "Track status", onPress: () => router.replace(routes.rxDetail(rx.id)) },
{ text: "Reminders", onPress: () => router.replace(routes.rxCalendar(rx.id)) },
- { text: "List", onPress: () => router.replace(routes.rxList) },
+ ...(toCart
+ ? [{ text: "Cart", onPress: () => router.replace(routes.cart) }]
+ : [{ text: "List", onPress: () => router.replace(routes.rxList) }]),
]);
};
return (
-
- {draftImages.length ? (
-
- {draftImages.map((uri) => (
-
- ))}
-
- ) : (
-
- No images attached
-
- Go back to scan or upload a prescription photo.
-
- router.replace(routes.rxScan)}
+
+
+
+ After submit
+
+
+
+
+
+
+ Prescription pages ({draftImages.length})
+
+ {draftImages.length ? (
+ {
+ Alert.alert("Remove page?", `Page ${index + 1}`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Remove",
+ style: "destructive",
+ onPress: () =>
+ setDraftImages(draftImages.filter((_, i) => i !== index)),
+ },
+ ]);
+ }}
/>
-
- )}
+ ) : (
+ <>
+
+ Go back to scan or upload a prescription photo.
+
+ router.replace(routes.rxScan)}
+ />
+ >
+ )}
+
@@ -186,28 +216,29 @@ export default function RxReviewScreen() {
Availability preview
- {(medicines.length ? medicines : [{ name: medicineName || "—", status: "review" as const }]).map(
- (m) => (
-
- {m.name}
-
-
- )
- )}
+ {(medicines.length
+ ? medicines
+ : [{ name: medicineName || "—", status: "review" as const }]
+ ).map((m) => (
+
+ {m.name}
+
+
+ ))}
save(false)} />
diff --git a/app/(root)/(screens)/profile.tsx b/app/(root)/(screens)/profile.tsx
index b6def14..d907698 100644
--- a/app/(root)/(screens)/profile.tsx
+++ b/app/(root)/(screens)/profile.tsx
@@ -4,6 +4,7 @@ import { router } from "expo-router";
import { useState } from "react";
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
+import { BiometricRow, LoyaltyStrip, QuickActions } from "@/components/profile";
import { Avatar } from "@/components/ui/Avatar";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
@@ -13,10 +14,12 @@ import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { routes } from "@/lib/routes";
-import { useAuthStore } from "@/lib/stores/authStore";
-import type { AuthProvider } from "@/lib/stores/authStore";
+import { useAuthStore, type AuthProvider } from "@/lib/stores/authStore";
import { useLangStore } from "@/lib/stores/langStore";
+import { useLocationStore } from "@/lib/stores/locationStore";
+import { usePrefsStore } from "@/lib/stores/prefsStore";
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
+import { paymentLabel } from "@/components/checkout/PaymentMethodList";
import { colors, gutter } from "@/lib/theme";
import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils";
@@ -25,12 +28,15 @@ export default function ProfileScreen() {
const user = useAuthStore((s) => s.user);
const setAvatar = useAuthStore((s) => s.setAvatar);
const updateProfile = useAuthStore((s) => s.updateProfile);
+ const setBiometric = useAuthStore((s) => s.setBiometric);
const signOut = useAuthStore((s) => s.signOut);
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
const mode = useProfileModeStore((s) => s.mode);
const membership = useProfileModeStore((s) => s.activeMembership());
const language = useLangStore((s) => s.language);
const setLanguage = useLangStore((s) => s.setLanguage);
+ const payment = useLocationStore((s) => s.paymentPreference);
+ const prefs = usePrefsStore();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(user?.name ?? "");
@@ -46,14 +52,12 @@ export default function ProfileScreen() {
);
return;
}
-
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [1, 1],
quality: 0.85,
});
-
if (!result.canceled && result.assets[0]?.uri) {
setAvatar(result.assets[0].uri);
}
@@ -95,14 +99,31 @@ export default function ProfileScreen() {
{ label: t("common.switchProfile"), href: routes.profileSwitch, icon: "swap-horizontal", subtitle: mode },
{ label: t("common.joinOrg"), href: routes.profileJoin, icon: "business-outline" },
{ label: t("common.orders"), href: routes.orders, icon: "receipt-outline", subtitle: "Track delivery" },
- { label: t("common.notifications"), href: routes.notifications, icon: "notifications-outline", subtitle: "Alerts inbox" },
+ {
+ label: "Payment methods",
+ href: routes.paymentMethods,
+ icon: "card-outline",
+ subtitle: paymentLabel(payment),
+ },
+ {
+ label: "Notification preferences",
+ href: routes.notificationPrefs,
+ icon: "notifications-outline",
+ subtitle: [
+ prefs.push ? "Push" : null,
+ prefs.sms ? "SMS" : null,
+ prefs.email ? "Email" : null,
+ ]
+ .filter(Boolean)
+ .join(" · ") || "All off",
+ },
+ { label: t("common.notifications"), href: routes.notifications, icon: "mail-unread-outline", subtitle: "Alerts inbox" },
{ label: t("common.prescriptions"), href: routes.rxList, icon: "medkit-outline", subtitle: "Rx + family" },
{ label: t("common.loyalty"), href: routes.loyalty, icon: "star-outline", subtitle: `${user?.points ?? 0} pts` },
{ label: t("common.subscriptions"), href: routes.subscriptions, icon: "repeat-outline" },
{ label: t("common.emergencyContacts"), href: routes.emergencyContacts, icon: "alert-circle-outline" },
{ label: t("common.branches"), href: routes.branches, icon: "location-outline" },
{ label: t("common.addresses"), href: routes.addresses, icon: "home-outline" },
- { label: t("common.language"), href: routes.language, icon: "language-outline", subtitle: language.toUpperCase() },
{ label: t("common.about"), href: routes.about, icon: "information-circle-outline" },
{ label: t("common.contact"), href: routes.contact, icon: "mail-outline" },
];
@@ -153,10 +174,7 @@ export default function ProfileScreen() {
) : null}
{user?.authProvider && user.authProvider !== "guest" ? (
-
+
) : null}
-
+ {user?.isGuest ? : null}
@@ -210,6 +228,12 @@ export default function ProfileScreen() {
) : null}
+ {!user?.isGuest ? (
+
+ ) : null}
+
+
+
{editing && !user?.isGuest ? (
@@ -250,6 +274,15 @@ export default function ProfileScreen() {
) : null}
+ {!user?.isGuest ? (
+
+
+
+ ) : null}
+
{[
{ label: t("common.call"), icon: "call-outline" as const, href: `tel:${CALL_PHONE}` },
diff --git a/app/(root)/(tabs)/prescriptions.tsx b/app/(root)/(tabs)/prescriptions.tsx
index 1c11ef4..1803940 100644
--- a/app/(root)/(tabs)/prescriptions.tsx
+++ b/app/(root)/(tabs)/prescriptions.tsx
@@ -1,12 +1,13 @@
-import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
-import { Alert, Image, Pressable, Share, ScrollView, View } from "react-native";
+import { Alert, ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
+import { FamilyInviteCard } from "@/components/care";
+import { RxCareCard } from "@/components/prescription";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card, Section } from "@/components/ui/Card";
-import { DoseReminderPanel } from "@/components/ui/DoseReminderPanel";
+import { Chip } from "@/components/ui/Chip";
import { EmptyState } from "@/components/ui/EmptyState";
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
import { TabTopBar } from "@/components/ui/TabTopBar";
@@ -15,155 +16,12 @@ import { routes } from "@/lib/routes";
import { useFamilyStore } from "@/lib/stores/familyStore";
import {
usePrescriptionStore,
- type Prescription,
type Reminder,
+ type RxStatus,
} from "@/lib/stores/prescriptionStore";
import { colors, gutter } from "@/lib/theme";
-const statusTone = {
- submitted: "neutral",
- under_review: "warn",
- approved: "brand",
- queried: "info",
- rejected: "danger",
- filled: "brand",
-} as const;
-
-function refillDays(rx: Prescription) {
- if (!rx.lastFilledAt) return null;
- const due = new Date(rx.lastFilledAt);
- due.setDate(due.getDate() + rx.daysSupply);
- return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
-}
-
-function RxCareCard({
- rx,
- reminder,
- expanded,
- onToggleExpand,
-}: {
- rx: Prescription;
- reminder?: Reminder;
- expanded: boolean;
- onToggleExpand: () => void;
-}) {
- const dueIn = refillDays(rx);
- const thumb = rx.imageUris[0];
-
- return (
-
- router.push(routes.rxDetail(rx.id))}
- style={{ padding: 14 }}
- >
-
-
- {thumb ? (
-
- ) : (
-
- )}
-
-
-
-
- {rx.medicineName}
-
-
-
-
- {rx.patientName} · {rx.medicines.length} medicine
- {rx.medicines.length === 1 ? "" : "s"} · {rx.daysSupply}d supply
-
-
- {reminder?.enabled ? (
-
- ) : (
-
- )}
- {dueIn !== null ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
- {expanded ? "Hide setup" : reminder?.enabled ? "Edit reminder" : "Set reminder"}
-
-
-
- router.push(routes.rxCalendar(rx.id))}
- style={{
- flex: 1,
- paddingVertical: 12,
- alignItems: "center",
- flexDirection: "row",
- justifyContent: "center",
- gap: 6,
- }}
- >
-
-
- Calendar
-
-
-
-
- {expanded ? (
-
-
-
- ) : null}
-
- );
-}
+type Filter = "all" | RxStatus;
export default function PrescriptionsTabScreen() {
const { t } = useTranslation();
@@ -176,6 +34,7 @@ export default function PrescriptionsTabScreen() {
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
const [expandedId, setExpandedId] = useState(null);
+ const [filter, setFilter] = useState("all");
const reminderByRx = useMemo(() => {
const map = new Map();
@@ -185,6 +44,15 @@ export default function PrescriptionsTabScreen() {
const activeReminderCount = reminders.filter((r) => r.enabled).length;
const needingSetup = prescriptions.filter((p) => !reminderByRx.get(p.id)?.enabled).length;
+ const queriedCount = prescriptions.filter((p) => p.status === "queried").length;
+
+ const visible = useMemo(
+ () =>
+ filter === "all"
+ ? prescriptions
+ : prescriptions.filter((p) => p.status === filter),
+ [prescriptions, filter]
+ );
return (
@@ -221,6 +89,16 @@ export default function PrescriptionsTabScreen() {
{needingSetup}
+ {queriedCount ? (
+
+
+ Need reply
+
+
+ {queriedCount}
+
+
+ ) : null}
router.push(routes.rxScan)}
/>
+
+ {(
+ [
+ ["all", "All"],
+ ["queried", "Queried"],
+ ["under_review", "Review"],
+ ["approved", "Approved"],
+ ["filled", "Filled"],
+ ] as [Filter, string][]
+ ).map(([id, label]) => (
+ setFilter(id)}
+ size="sm"
+ />
+ ))}
+
+
- {prescriptions.length === 0 ? (
+ {visible.length === 0 ? (
router.push(routes.rxScan)}
/>
) : (
- {prescriptions.map((rx) => (
+ {visible.map((rx) => (
-
-
-
-
-
-
-
- Family invite code
-
-
- {inviteCode}
-
-
-
-
- Share care so family can help with dose reminders and refills.
-
-
-
- Share.share({
- message: `Join my Gishen family care group with code ${inviteCode}`,
- })
- }
- />
- router.push(routes.familyInvite)}
- />
-
-
+ router.push(routes.familyInvite)}
+ />
{members.length === 0 ? (
diff --git a/components/care/EscalationBanner.tsx b/components/care/EscalationBanner.tsx
new file mode 100644
index 0000000..bc24802
--- /dev/null
+++ b/components/care/EscalationBanner.tsx
@@ -0,0 +1,36 @@
+import { Ionicons } from "@expo/vector-icons";
+import { View } from "react-native";
+import { Text } from "@/components/ui/Text";
+import { colors, radii } from "@/lib/theme";
+
+export function EscalationBanner({
+ title = "Escalation active",
+ body = "If a dose isn’t confirmed in time, we’ll alert your emergency contact.",
+}: {
+ title?: string;
+ body?: string;
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+ {body}
+
+
+
+ );
+}
diff --git a/components/care/FamilyInviteCard.tsx b/components/care/FamilyInviteCard.tsx
new file mode 100644
index 0000000..25ef8bd
--- /dev/null
+++ b/components/care/FamilyInviteCard.tsx
@@ -0,0 +1,67 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Share, View } from "react-native";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { Text } from "@/components/ui/Text";
+import { colors } from "@/lib/theme";
+
+export function FamilyInviteCard({
+ inviteCode,
+ onInvite,
+}: {
+ inviteCode: string;
+ onInvite: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+ Family invite code
+
+
+ {inviteCode}
+
+
+
+
+ Share care so family can help with dose reminders and refills.
+
+
+
+ Share.share({
+ message: `Join my Gishen family care group with code ${inviteCode}`,
+ })
+ }
+ />
+
+
+
+ );
+}
diff --git a/components/care/index.ts b/components/care/index.ts
new file mode 100644
index 0000000..6162b1d
--- /dev/null
+++ b/components/care/index.ts
@@ -0,0 +1,2 @@
+export { FamilyInviteCard } from "./FamilyInviteCard";
+export { EscalationBanner } from "./EscalationBanner";
diff --git a/components/checkout/AssistedOrderExplainer.tsx b/components/checkout/AssistedOrderExplainer.tsx
new file mode 100644
index 0000000..1748d67
--- /dev/null
+++ b/components/checkout/AssistedOrderExplainer.tsx
@@ -0,0 +1,20 @@
+import { Card } from "@/components/ui/Card";
+import { Text } from "@/components/ui/Text";
+import { colors } from "@/lib/theme";
+
+export function AssistedOrderExplainer({
+ title = "Pharmacist order code",
+ body = "You’ll get a GSH-XXXX code to copy, share, or screenshot. A pharmacist enters it on the admin page to create your order — then you pay at the branch.",
+}: {
+ title?: string;
+ body?: string;
+}) {
+ return (
+
+ {title}
+
+ {body}
+
+
+ );
+}
diff --git a/components/checkout/EntitlementSplitCard.tsx b/components/checkout/EntitlementSplitCard.tsx
new file mode 100644
index 0000000..a1bcff8
--- /dev/null
+++ b/components/checkout/EntitlementSplitCard.tsx
@@ -0,0 +1,31 @@
+import { Card } from "@/components/ui/Card";
+import { Text } from "@/components/ui/Text";
+import { colors } from "@/lib/theme";
+import { formatETB } from "@/lib/utils";
+
+export function EntitlementSplitCard({
+ orgName,
+ planName,
+ remainingAllowance,
+ covered,
+ selfPay,
+}: {
+ orgName: string;
+ planName: string;
+ remainingAllowance: number;
+ covered: number;
+ selfPay: number;
+}) {
+ return (
+
+ Corporate split · {orgName}
+
+ Plan {planName} · {formatETB(remainingAllowance)} left this month
+
+
+ Covered: {formatETB(covered)}
+
+ You pay: {formatETB(selfPay)}
+
+ );
+}
diff --git a/components/checkout/LoyaltyRedeemRow.tsx b/components/checkout/LoyaltyRedeemRow.tsx
new file mode 100644
index 0000000..3cf6243
--- /dev/null
+++ b/components/checkout/LoyaltyRedeemRow.tsx
@@ -0,0 +1,84 @@
+import { View } from "react-native";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { Chip } from "@/components/ui/Chip";
+import { Field } from "@/components/ui/Field";
+import { Text } from "@/components/ui/Text";
+import { colors } from "@/lib/theme";
+import { formatETB } from "@/lib/utils";
+
+export function LoyaltyRedeemRow({
+ points,
+ activeVoucher,
+ voucher,
+ discount,
+ onChangeVoucher,
+ onApplyDemo,
+ onApplyLoyalty,
+ onRedeemPoints,
+ canRedeem,
+}: {
+ points: number;
+ activeVoucher?: string | null;
+ voucher: string;
+ discount: number;
+ onChangeVoucher: (v: string) => void;
+ onApplyDemo: () => void;
+ onApplyLoyalty: () => void;
+ onRedeemPoints: () => void;
+ canRedeem: boolean;
+}) {
+ return (
+
+
+ Voucher & points
+
+
+
+
+ {activeVoucher ? (
+
+ ) : null}
+
+ {!activeVoucher ? (
+
+
+ You have {points} pts. Redeem 100 pts for a GISHEN10 voucher at checkout.
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/checkout/PaymentMethodList.tsx b/components/checkout/PaymentMethodList.tsx
new file mode 100644
index 0000000..4a9aac5
--- /dev/null
+++ b/components/checkout/PaymentMethodList.tsx
@@ -0,0 +1,66 @@
+import type { Ionicons } from "@expo/vector-icons";
+import { Card } from "@/components/ui/Card";
+import { OptionRow } from "@/components/ui/Field";
+import { Text } from "@/components/ui/Text";
+import type { PaymentMethod } from "@/lib/stores/locationStore";
+import { colors } from "@/lib/theme";
+import { View } from "react-native";
+
+const META: Record<
+ PaymentMethod,
+ { title: string; hint: string; icon: keyof typeof Ionicons.glyphMap }
+> = {
+ chapa: { title: "Chapa", hint: "Card / mobile money via Chapa", icon: "card-outline" },
+ arifpay: { title: "ArifPay", hint: "Local digital checkout", icon: "wallet-outline" },
+ telebirr: { title: "Telebirr", hint: "Ethio telecom Telebirr", icon: "phone-portrait-outline" },
+ mpesa: { title: "M-Pesa", hint: "Safaricom M-Pesa", icon: "cash-outline" },
+ bank: { title: "Bank transfer", hint: "Transfer + upload receipt later", icon: "business-outline" },
+ pay_at_branch: {
+ title: "Pay at branch",
+ hint: "Cash or POS when you collect",
+ icon: "storefront-outline",
+ },
+ cod: { title: "Cash on delivery", hint: "Pay the rider on arrival", icon: "bicycle-outline" },
+};
+
+export function PaymentMethodList({
+ methods,
+ selected,
+ onSelect,
+ title = "Payment",
+ footnote,
+}: {
+ methods: PaymentMethod[];
+ selected: PaymentMethod;
+ onSelect: (m: PaymentMethod) => void;
+ title?: string;
+ footnote?: string;
+}) {
+ return (
+
+
+ {title}
+ {footnote ? (
+
+ {footnote}
+
+ ) : null}
+
+ {methods.map((m, i) => (
+ onSelect(m)}
+ icon={META[m].icon}
+ last={i === methods.length - 1}
+ />
+ ))}
+
+ );
+}
+
+export function paymentLabel(m: PaymentMethod) {
+ return META[m]?.title ?? m;
+}
diff --git a/components/checkout/RxCartGate.tsx b/components/checkout/RxCartGate.tsx
new file mode 100644
index 0000000..85936dc
--- /dev/null
+++ b/components/checkout/RxCartGate.tsx
@@ -0,0 +1,41 @@
+import { router } from "expo-router";
+import { View } from "react-native";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { Text } from "@/components/ui/Text";
+import { routes } from "@/lib/routes";
+import { colors } from "@/lib/theme";
+
+export function RxCartGate({
+ rxItemNames,
+}: {
+ rxItemNames: string[];
+}) {
+ if (!rxItemNames.length) return null;
+ return (
+
+ Prescription required
+
+ These items need an approved or submitted Rx before checkout:{" "}
+ {rxItemNames.slice(0, 3).join(", ")}
+ {rxItemNames.length > 3 ? ` +${rxItemNames.length - 3} more` : ""}.
+
+
+ router.push(routes.rxScan)}
+ />
+ router.push(routes.rxList)}
+ />
+
+
+ );
+}
diff --git a/components/checkout/index.ts b/components/checkout/index.ts
new file mode 100644
index 0000000..bc3d5c5
--- /dev/null
+++ b/components/checkout/index.ts
@@ -0,0 +1,5 @@
+export { PaymentMethodList, paymentLabel } from "./PaymentMethodList";
+export { LoyaltyRedeemRow } from "./LoyaltyRedeemRow";
+export { EntitlementSplitCard } from "./EntitlementSplitCard";
+export { RxCartGate } from "./RxCartGate";
+export { AssistedOrderExplainer } from "./AssistedOrderExplainer";
diff --git a/components/prescription/RxCareCard.tsx b/components/prescription/RxCareCard.tsx
new file mode 100644
index 0000000..792f69f
--- /dev/null
+++ b/components/prescription/RxCareCard.tsx
@@ -0,0 +1,144 @@
+import { Ionicons } from "@expo/vector-icons";
+import { router } from "expo-router";
+import { Image, Pressable, View } from "react-native";
+import { Badge } from "@/components/ui/Badge";
+import { Card } from "@/components/ui/Card";
+import { DoseReminderPanel } from "@/components/ui/DoseReminderPanel";
+import { Text } from "@/components/ui/Text";
+import { routes } from "@/lib/routes";
+import type { Prescription, Reminder } from "@/lib/stores/prescriptionStore";
+import { colors } from "@/lib/theme";
+
+const statusTone = {
+ submitted: "neutral",
+ under_review: "warn",
+ approved: "brand",
+ queried: "info",
+ rejected: "danger",
+ filled: "brand",
+} as const;
+
+function refillDays(rx: Prescription) {
+ if (!rx.lastFilledAt) return null;
+ const due = new Date(rx.lastFilledAt);
+ due.setDate(due.getDate() + rx.daysSupply);
+ return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+}
+
+export function RxCareCard({
+ rx,
+ reminder,
+ expanded,
+ onToggleExpand,
+}: {
+ rx: Prescription;
+ reminder?: Reminder;
+ expanded: boolean;
+ onToggleExpand: () => void;
+}) {
+ const dueIn = refillDays(rx);
+ const thumb = rx.imageUris[0];
+
+ return (
+
+ router.push(routes.rxDetail(rx.id))} style={{ padding: 14 }}>
+
+
+ {thumb ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {rx.medicineName}
+
+
+
+
+ {rx.patientName} · {rx.medicines.length} medicine
+ {rx.medicines.length === 1 ? "" : "s"} · {rx.daysSupply}d supply
+
+
+ {reminder?.enabled ? (
+
+ ) : (
+
+ )}
+ {dueIn !== null ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {expanded ? "Hide setup" : reminder?.enabled ? "Edit reminder" : "Set reminder"}
+
+
+
+ router.push(routes.rxCalendar(rx.id))}
+ style={{
+ flex: 1,
+ paddingVertical: 12,
+ alignItems: "center",
+ flexDirection: "row",
+ justifyContent: "center",
+ gap: 6,
+ }}
+ >
+
+
+ Calendar
+
+
+
+
+ {expanded ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/prescription/RxImagePager.tsx b/components/prescription/RxImagePager.tsx
new file mode 100644
index 0000000..fcb82b5
--- /dev/null
+++ b/components/prescription/RxImagePager.tsx
@@ -0,0 +1,60 @@
+import { Image, Pressable, ScrollView, View } from "react-native";
+import { Text } from "@/components/ui/Text";
+import { colors, radii } from "@/lib/theme";
+
+export function RxImagePager({
+ uris,
+ size = 96,
+ onLongPress,
+}: {
+ uris: string[];
+ size?: number;
+ onLongPress?: (uri: string, index: number) => void;
+}) {
+ if (!uris.length) {
+ return (
+
+
+ No pages attached
+
+
+ );
+ }
+
+ return (
+
+ {uris.map((uri, index) => (
+ onLongPress?.(uri, index)}>
+
+
+ {index + 1}/{uris.length}
+
+
+ ))}
+
+ );
+}
diff --git a/components/prescription/RxMedicineLines.tsx b/components/prescription/RxMedicineLines.tsx
new file mode 100644
index 0000000..a1fe98b
--- /dev/null
+++ b/components/prescription/RxMedicineLines.tsx
@@ -0,0 +1,57 @@
+import { Ionicons } from "@expo/vector-icons";
+import { View } from "react-native";
+import { Badge } from "@/components/ui/Badge";
+import { Card } from "@/components/ui/Card";
+import { Text } from "@/components/ui/Text";
+import type { LineStatus } from "@/lib/stores/prescriptionStore";
+import { colors } from "@/lib/theme";
+
+const lineTone = {
+ ready: "brand",
+ review: "warn",
+ unavailable: "danger",
+} as const;
+
+export function RxMedicineLines({
+ medicines,
+}: {
+ medicines: { name: string; status: LineStatus }[];
+}) {
+ return (
+
+ {medicines.map((m, i) => (
+
+
+
+
+
+
+ {m.name}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/prescription/RxQueriedPanel.tsx b/components/prescription/RxQueriedPanel.tsx
new file mode 100644
index 0000000..eee7970
--- /dev/null
+++ b/components/prescription/RxQueriedPanel.tsx
@@ -0,0 +1,64 @@
+import { useState } from "react";
+import { View } from "react-native";
+import { Button } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { Field } from "@/components/ui/Field";
+import { Text } from "@/components/ui/Text";
+import { colors } from "@/lib/theme";
+
+export function RxQueriedPanel({
+ pharmacistMessage,
+ customerReply,
+ onReply,
+}: {
+ pharmacistMessage?: string;
+ customerReply?: string;
+ onReply: (message: string) => void;
+}) {
+ const [reply, setReply] = useState(customerReply ?? "");
+
+ return (
+
+ Pharmacist needs clarification
+
+ {pharmacistMessage ||
+ "Please confirm dosage, patient name, or upload a clearer page."}
+
+ {customerReply ? (
+
+
+ Your reply
+
+
+ {customerReply}
+
+
+ ) : (
+
+
+ {
+ if (!reply.trim()) return;
+ onReply(reply.trim());
+ }}
+ />
+
+ )}
+
+ );
+}
diff --git a/components/prescription/RxStatusStepper.tsx b/components/prescription/RxStatusStepper.tsx
new file mode 100644
index 0000000..b2a4e70
--- /dev/null
+++ b/components/prescription/RxStatusStepper.tsx
@@ -0,0 +1,112 @@
+import { View } from "react-native";
+import { Text } from "@/components/ui/Text";
+import type { RxStatus } from "@/lib/stores/prescriptionStore";
+import { colors, radii } from "@/lib/theme";
+
+const STEPS: { key: RxStatus; label: string }[] = [
+ { key: "submitted", label: "Submitted" },
+ { key: "under_review", label: "Under review" },
+ { key: "approved", label: "Approved" },
+ { key: "filled", label: "Filled" },
+];
+
+const BRANCH: Partial> = {
+ queried: { label: "Queried", tone: "info" },
+ rejected: { label: "Rejected", tone: "danger" },
+};
+
+function stepIndex(status: RxStatus) {
+ if (status === "queried") return 1;
+ if (status === "rejected") return 1;
+ const i = STEPS.findIndex((s) => s.key === status);
+ return i < 0 ? 0 : i;
+}
+
+export function RxStatusStepper({ status }: { status: RxStatus }) {
+ const active = stepIndex(status);
+ const branch = BRANCH[status];
+
+ return (
+
+
+ {STEPS.map((step, i) => {
+ const done = i <= active && !branch;
+ const current = i === active;
+ const muted = i > active;
+ return (
+
+
+
+ {(done && !current) || status === "filled" && i <= active ? (
+
+ ) : null}
+
+
+ {step.label}
+
+
+ {i < STEPS.length - 1 ? (
+
+ ) : null}
+
+ );
+ })}
+
+ {branch ? (
+
+
+ Pharmacist: {branch.label}
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/prescription/index.ts b/components/prescription/index.ts
new file mode 100644
index 0000000..4c8a577
--- /dev/null
+++ b/components/prescription/index.ts
@@ -0,0 +1,5 @@
+export { RxCareCard } from "./RxCareCard";
+export { RxStatusStepper } from "./RxStatusStepper";
+export { RxMedicineLines } from "./RxMedicineLines";
+export { RxImagePager } from "./RxImagePager";
+export { RxQueriedPanel } from "./RxQueriedPanel";
diff --git a/components/profile/BiometricRow.tsx b/components/profile/BiometricRow.tsx
new file mode 100644
index 0000000..9acd546
--- /dev/null
+++ b/components/profile/BiometricRow.tsx
@@ -0,0 +1,49 @@
+import * as LocalAuthentication from "expo-local-authentication";
+import { Alert, View } from "react-native";
+import { SwitchRow } from "@/components/ui/Field";
+import { Text } from "@/components/ui/Text";
+import { colors } from "@/lib/theme";
+
+export function BiometricRow({
+ enabled,
+ onChange,
+}: {
+ enabled: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+ {
+ if (!v) {
+ onChange(false);
+ return;
+ }
+ const hardware = await LocalAuthentication.hasHardwareAsync();
+ const enrolled = await LocalAuthentication.isEnrolledAsync();
+ if (!hardware || !enrolled) {
+ Alert.alert(
+ "Biometrics unavailable",
+ "Set up Face ID or fingerprint on this device first."
+ );
+ return;
+ }
+ const result = await LocalAuthentication.authenticateAsync({
+ promptMessage: "Enable biometric unlock for Gishen",
+ });
+ if (result.success) {
+ onChange(true);
+ } else {
+ Alert.alert("Not enabled", "Biometric authentication was cancelled.");
+ }
+ }}
+ />
+
+ Requires a signed-in account (not guest).
+
+
+ );
+}
diff --git a/components/profile/LoyaltyStrip.tsx b/components/profile/LoyaltyStrip.tsx
new file mode 100644
index 0000000..2901091
--- /dev/null
+++ b/components/profile/LoyaltyStrip.tsx
@@ -0,0 +1,66 @@
+import { Pressable, View } from "react-native";
+import { router } from "expo-router";
+import { Badge } from "@/components/ui/Badge";
+import { Card } from "@/components/ui/Card";
+import { Text } from "@/components/ui/Text";
+import { routes } from "@/lib/routes";
+import { colors } from "@/lib/theme";
+
+const NEXT_TIER = {
+ bronze: { label: "Silver", at: 300 },
+ silver: { label: "Gold", at: 1000 },
+ gold: { label: "Gold", at: 1000 },
+} as const;
+
+export function LoyaltyStrip({
+ points,
+ tier,
+}: {
+ points: number;
+ tier: "bronze" | "silver" | "gold";
+}) {
+ const next = NEXT_TIER[tier];
+ const prev = tier === "gold" ? 1000 : tier === "silver" ? 300 : 0;
+ const progress =
+ tier === "gold" ? 1 : Math.min(1, Math.max(0, (points - prev) / (next.at - prev)));
+
+ return (
+ router.push(routes.loyalty)}>
+
+
+
+
+ Loyalty wallet
+
+
+ {points} pts
+
+
+
+
+
+
+
+
+ {tier === "gold"
+ ? "You’re at Gold — keep redeeming rewards"
+ : `${next.at - points} pts to ${next.label}`}
+
+
+
+ );
+}
diff --git a/components/profile/QuickActions.tsx b/components/profile/QuickActions.tsx
new file mode 100644
index 0000000..d995ac9
--- /dev/null
+++ b/components/profile/QuickActions.tsx
@@ -0,0 +1,60 @@
+import { Ionicons } from "@expo/vector-icons";
+import { router } from "expo-router";
+import { Pressable, ScrollView, View } from "react-native";
+import { Text } from "@/components/ui/Text";
+import { routes } from "@/lib/routes";
+import { colors, radii } from "@/lib/theme";
+
+const ACTIONS: {
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ href: string;
+}[] = [
+ { label: "Orders", icon: "receipt-outline", href: routes.orders },
+ { label: "Upload Rx", icon: "document-attach-outline", href: routes.rxScan },
+ { label: "Addresses", icon: "home-outline", href: routes.addresses },
+ { label: "Rewards", icon: "star-outline", href: routes.loyalty },
+];
+
+export function QuickActions() {
+ return (
+
+ {ACTIONS.map((a) => (
+ router.push(a.href as never)}
+ style={{
+ minWidth: 86,
+ paddingVertical: 12,
+ paddingHorizontal: 12,
+ borderRadius: radii.md,
+ backgroundColor: colors.surface,
+ borderWidth: 1,
+ borderColor: colors.line,
+ alignItems: "center",
+ gap: 6,
+ }}
+ >
+
+
+
+ {a.label}
+
+ ))}
+
+ );
+}
diff --git a/components/profile/index.ts b/components/profile/index.ts
new file mode 100644
index 0000000..c98f199
--- /dev/null
+++ b/components/profile/index.ts
@@ -0,0 +1,3 @@
+export { LoyaltyStrip } from "./LoyaltyStrip";
+export { QuickActions } from "./QuickActions";
+export { BiometricRow } from "./BiometricRow";
diff --git a/docs/BACKEND_SPEC.md b/docs/BACKEND_SPEC.md
index eadd6e5..7b87909 100644
--- a/docs/BACKEND_SPEC.md
+++ b/docs/BACKEND_SPEC.md
@@ -86,9 +86,13 @@ Guests (and optionally signed-in pickup) receive a short **pharmacist reference
## Payments
| Method | Path | Status | Notes |
|--------|------|--------|-------|
-| POST | `/payments/initiate` | Required | `chapa` \| `telebirr` \| `mpesa` only for delivery |
+| POST | `/payments/intent` | Required | `chapa` \| `arifpay` \| `telebirr` \| `mpesa` \| `bank` \| `cod` \| `pay_at_branch` |
| POST | `/payments/webhook/*` | Required | Gateway callbacks |
-| — | `pay_at_branch` | Required | Allowed **only** when `fulfillment=pickup` (required for guest pharmacist codes) |
+| GET | `/users/me/payment-preference` | Required | Saved preferred method |
+| PUT | `/users/me/payment-preference` | Required | |
+| — | `pay_at_branch` | Required | Allowed **only** when `fulfillment=pickup` (required for guest / assisted pharmacist codes) |
+| — | `cod` | Required | Delivery only |
+| — | `bank` | Required | May require receipt upload via signed URL |
## Prescriptions (Rx Care)
| Method | Path | Status | Notes |
@@ -160,6 +164,8 @@ Distinct from order list. Home bell → inbox.
| POST | `/notifications/read-all` | Required | |
| DELETE | `/notifications/:id` | Required | |
| POST | `/devices` | Required | push token registration |
+| GET | `/notifications/prefs` | Required | `{ sms, email, push, orderUpdates, refillReminders, marketing }` |
+| PUT | `/notifications/prefs` | Required | Same shape |
## Push / Tracking
| Method | Path | Status | Notes |
@@ -189,3 +195,4 @@ Distinct from order list. Home bell → inbox.
- 2026-08-07: Auth providers — Google, Email, Phone OTP, Telegram, and Guest session contracts; `authProvider` on `/users/me`.
- 2026-08-07: Guest pharmacist codes — `POST /orders/guest-codes`, `GET /orders/by-code/:code`, admin fulfill-by-code (Ecom parity); mobile copy/share/screenshot card.
- 2026-08-07: Notifications inbox — dedicated `/notifications/me` (separate from Orders); Favorites + Addresses endpoints documented; Rx Care dose reminder confirm/snooze.
+- 2026-08-10: Profile/Checkout/Rx finalize — payment preference screen (Chapa, ArifPay, Telebirr, M-Pesa, bank, COD, pay_at_branch); notification prefs (`GET/PUT /notifications/prefs`); checkout loyalty redeem + Rx cart gate + assisted code for signed-in; Rx status stepper + queried reply; mock `POST /payments/intent`.
diff --git a/lib/api/client.ts b/lib/api/client.ts
index 3331f1f..2f3a5a2 100644
--- a/lib/api/client.ts
+++ b/lib/api/client.ts
@@ -1,11 +1,47 @@
-/** API client stub — swap BASE_URL when shared backend is live. */
+/** API client — mock adapters when EXPO_PUBLIC_API_URL is unset. */
export const BASE_URLS = {
core: process.env.EXPO_PUBLIC_API_URL ?? "https://api.gishen.example/v1",
} as const;
+export const USE_MOCKS = !process.env.EXPO_PUBLIC_API_URL;
+
export type ApiError = { message: string; code?: string };
+export type PaymentIntent = {
+ id: string;
+ provider: string;
+ status: "requires_action" | "succeeded" | "pending";
+ amountEtb: number;
+ checkoutUrl?: string;
+};
+
+export async function initiatePayment(input: {
+ provider: string;
+ amountEtb: number;
+ orderId: string;
+}): Promise {
+ if (USE_MOCKS) {
+ await delay(450);
+ return {
+ id: `pay_${Date.now()}`,
+ provider: input.provider,
+ status: input.provider === "bank" || input.provider === "cod" || input.provider === "pay_at_branch"
+ ? "pending"
+ : "succeeded",
+ amountEtb: input.amountEtb,
+ checkoutUrl:
+ input.provider === "chapa" || input.provider === "arifpay"
+ ? `https://checkout.mock.gishen/${input.provider}`
+ : undefined,
+ };
+ }
+ return callEndpoint("/payments/intent", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
export async function callEndpoint(
path: string,
init?: RequestInit & { locale?: string }
@@ -17,9 +53,8 @@ export async function callEndpoint(
...(init?.headers as Record),
};
- // Mock mode until backend exists
- if (!process.env.EXPO_PUBLIC_API_URL) {
- throw new Error(`API not configured for ${path}`);
+ if (USE_MOCKS) {
+ throw new Error(`No mock for ${path} — pass EXPO_PUBLIC_API_URL or add an adapter`);
}
const res = await fetch(`${BASE_URLS.core}${path}`, {
@@ -32,3 +67,7 @@ export async function callEndpoint(
}
return res.json() as Promise;
}
+
+function delay(ms: number) {
+ return new Promise((r) => setTimeout(r, ms));
+}
diff --git a/lib/data/seed.ts b/lib/data/seed.ts
index b722566..024ad99 100644
--- a/lib/data/seed.ts
+++ b/lib/data/seed.ts
@@ -102,6 +102,26 @@ export const seedPrescriptions: Prescription[] = [
notes: "Pharmacist verifying dosage for pediatric use.",
createdAt: daysAgo(0),
},
+ {
+ id: "rx_demo_queried",
+ patientName: "Kirukib Abebe",
+ memberId: null,
+ medicineName: "Losartan 50mg",
+ medicines: [
+ { name: "Losartan 50mg", status: "review" },
+ { name: "Amlodipine 5mg", status: "ready" },
+ ],
+ imageUris: [
+ "https://images.unsplash.com/photo-1585435557343-3b092031a831?w=800&q=80",
+ ],
+ daysSupply: 30,
+ lastFilledAt: null,
+ status: "queried",
+ pharmacistMessage:
+ "Strength on page 2 is unclear — confirm Losartan 50mg vs 100mg, and whether Amlodipine is for the same patient.",
+ notes: "BP meds refill",
+ createdAt: daysAgo(1),
+ },
];
export const seedReminders: Reminder[] = [
diff --git a/lib/routes.ts b/lib/routes.ts
index 8c89c4f..1ac82ba 100644
--- a/lib/routes.ts
+++ b/lib/routes.ts
@@ -36,6 +36,10 @@ export const routes = {
about: "/(root)/(screens)/about",
contact: "/(root)/(screens)/contact",
addresses: "/(root)/(screens)/addresses",
+ paymentMethods: "/(root)/(screens)/payment-methods",
+ notificationPrefs: "/(root)/(screens)/notification-prefs",
+ checkoutSuccess: (id: string) =>
+ `/(root)/(screens)/checkout-success?id=${id}` as const,
} as const;
export type Category = {
diff --git a/lib/stores/locationStore.ts b/lib/stores/locationStore.ts
index e607e60..1481d98 100644
--- a/lib/stores/locationStore.ts
+++ b/lib/stores/locationStore.ts
@@ -5,8 +5,11 @@ import { createJSONStorage, persist } from "zustand/middleware";
export type Fulfillment = "delivery" | "pickup";
export type PaymentMethod =
| "chapa"
+ | "arifpay"
| "telebirr"
| "mpesa"
+ | "bank"
+ | "cod"
| "pay_at_branch";
type LocationState = {
@@ -35,8 +38,18 @@ export const useLocationStore = create()(
paymentPreference: "chapa",
setFulfillment: (fulfillment) => {
const next: Partial = { fulfillment };
- if (fulfillment === "delivery" && get().paymentPreference === "pay_at_branch") {
- next.paymentPreference = "chapa";
+ if (
+ fulfillment === "delivery" &&
+ (get().paymentPreference === "pay_at_branch" ||
+ get().paymentPreference === "cod")
+ ) {
+ // COD stays valid for delivery; only strip pay_at_branch
+ if (get().paymentPreference === "pay_at_branch") {
+ next.paymentPreference = "chapa";
+ }
+ }
+ if (fulfillment === "pickup" && get().paymentPreference === "cod") {
+ next.paymentPreference = "pay_at_branch";
}
set(next);
},
@@ -45,15 +58,22 @@ export const useLocationStore = create()(
setCoords: (lat, lng) => set({ lat, lng }),
setPaymentPreference: (paymentPreference) => set({ paymentPreference }),
allowedPayments: () => {
- const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"];
+ const online: PaymentMethod[] = [
+ "chapa",
+ "arifpay",
+ "telebirr",
+ "mpesa",
+ "bank",
+ ];
return get().fulfillment === "pickup"
- ? [...base, "pay_at_branch"]
- : base;
+ ? [...online, "pay_at_branch"]
+ : [...online, "cod"];
},
}),
{
name: "gishen-location",
storage: createJSONStorage(() => AsyncStorage),
+ version: 2,
}
)
);
diff --git a/lib/stores/prefsStore.ts b/lib/stores/prefsStore.ts
new file mode 100644
index 0000000..e654cb8
--- /dev/null
+++ b/lib/stores/prefsStore.ts
@@ -0,0 +1,40 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+export type NotificationPrefs = {
+ sms: boolean;
+ email: boolean;
+ push: boolean;
+ orderUpdates: boolean;
+ refillReminders: boolean;
+ marketing: boolean;
+};
+
+type PrefsState = NotificationPrefs & {
+ setPref: (key: K, value: NotificationPrefs[K]) => void;
+ setMany: (patch: Partial) => void;
+};
+
+const defaults: NotificationPrefs = {
+ sms: true,
+ email: true,
+ push: true,
+ orderUpdates: true,
+ refillReminders: true,
+ marketing: false,
+};
+
+export const usePrefsStore = create()(
+ persist(
+ (set) => ({
+ ...defaults,
+ setPref: (key, value) => set({ [key]: value } as Partial),
+ setMany: (patch) => set(patch),
+ }),
+ {
+ name: "gishen-prefs",
+ storage: createJSONStorage(() => AsyncStorage),
+ }
+ )
+);
diff --git a/lib/stores/prescriptionStore.ts b/lib/stores/prescriptionStore.ts
index 7d92534..5948897 100644
--- a/lib/stores/prescriptionStore.ts
+++ b/lib/stores/prescriptionStore.ts
@@ -28,6 +28,10 @@ export type Prescription = {
lastFilledAt: string | null;
status: RxStatus;
notes?: string;
+ /** Message from pharmacist when status is queried / rejected */
+ pharmacistMessage?: string;
+ /** Customer reply to a query */
+ customerReply?: string;
createdAt: string;
};
@@ -60,7 +64,8 @@ type RxState = {
addPrescription: (
p: Omit
) => Prescription;
- updateRxStatus: (id: string, status: RxStatus) => void;
+ updateRxStatus: (id: string, status: RxStatus, pharmacistMessage?: string) => void;
+ replyToQuery: (id: string, message: string) => void;
markRefilled: (id: string) => void;
removePrescription: (id: string) => void;
upsertReminder: (r: Omit & { id?: string }) => void;
@@ -88,10 +93,31 @@ export const usePrescriptionStore = create()(
set({ prescriptions: [rx, ...get().prescriptions] });
return rx;
},
- updateRxStatus: (id, status) =>
+ updateRxStatus: (id, status, pharmacistMessage) =>
set({
prescriptions: get().prescriptions.map((p) =>
- p.id === id ? { ...p, status } : p
+ p.id === id
+ ? {
+ ...p,
+ status,
+ ...(pharmacistMessage !== undefined
+ ? { pharmacistMessage }
+ : {}),
+ }
+ : p
+ ),
+ }),
+ replyToQuery: (id, message) =>
+ set({
+ prescriptions: get().prescriptions.map((p) =>
+ p.id === id
+ ? {
+ ...p,
+ customerReply: message,
+ status: "under_review",
+ pharmacistMessage: undefined,
+ }
+ : p
),
}),
markRefilled: (id) =>
diff --git a/locales/am/common.json b/locales/am/common.json
index 3fe3f90..522330c 100644
--- a/locales/am/common.json
+++ b/locales/am/common.json
@@ -71,6 +71,12 @@
"addresses": "አድራሻዎች",
"rewards": "ሽልማቶች",
"referral": "የማጣቀሻ ኮድ",
+ "arifpay": "ArifPay",
+ "bankTransfer": "ባንክ ማስተላለፍ",
+ "cashOnDelivery": "በአገልግሎት ክፍያ",
+ "paymentMethods": "የክፍያ ዘዴዎች",
+ "notificationPrefs": "የማሳወቂያ ምርጫዎች",
+ "assistedCode": "የፋርማሲስት ትዕዛዝ ኮድ",
"inviteFamily": "ቤተሰብ ጋብዝ",
"manageSubs": "ደንበኝነት አስተዳድር",
"scanRx": "የሐኪም ትዕዛዝ ቅኝት",
diff --git a/locales/en/common.json b/locales/en/common.json
index b468a6a..73b98c2 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -71,6 +71,12 @@
"addresses": "Addresses",
"rewards": "Rewards",
"referral": "Referral code",
+ "arifpay": "ArifPay",
+ "bankTransfer": "Bank transfer",
+ "cashOnDelivery": "Cash on delivery",
+ "paymentMethods": "Payment methods",
+ "notificationPrefs": "Notification preferences",
+ "assistedCode": "Pharmacist order code",
"inviteFamily": "Invite family",
"manageSubs": "Manage subscriptions",
"scanRx": "Scan prescription",