Finalize profile, prescription, and checkout with shared UI modules.

Add payment/notification prefs, assisted pharmacist codes, Rx status journey, and mock payment intents so demo flows match the storefront brief.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kirukib 2026-08-10 14:53:34 +03:00
parent 858972b5ca
commit d40cb22ff4
36 changed files with 1884 additions and 544 deletions

View File

@ -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 (
<Screen>
<AppHeader title="Order confirmed" />
<View style={{ paddingHorizontal: gutter, gap: 14, paddingTop: 8 }}>
<Card tone="brand" elevated={false}>
<Text variant="h1">Thank you</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
{order
? `Order ${order.id.slice(-8).toUpperCase()} · ${formatETB(order.selfPay ?? order.total)}`
: "Your order was placed successfully."}
</Text>
{order?.pickupCode ? (
<Text variant="h2" style={{ marginTop: 14, letterSpacing: 1 }}>
Code {order.pickupCode}
</Text>
) : null}
</Card>
<Button
title="View order"
onPress={() =>
order ? router.replace(routes.orderDetail(order.id)) : router.replace(routes.orders)
}
/>
{order && !order.isGuestOrder && order.fulfillment === "delivery" ? (
<Button
title="Track delivery"
variant="soft"
onPress={() => router.replace(routes.tracking(order.id))}
/>
) : null}
{order?.pickupCode && order.isGuestOrder ? (
<Button
title="Open pharmacist code card"
variant="outline"
onPress={() => router.replace(routes.orderCode(order.id))}
/>
) : null}
<Button title="Continue shopping" variant="ghost" onPress={() => router.replace(routes.shop)} />
</View>
</Screen>
);
}

View File

@ -2,15 +2,29 @@ import { router } from "expo-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Alert, Linking, Pressable, ScrollView, View } from "react-native"; import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
AssistedOrderExplainer,
EntitlementSplitCard,
LoyaltyRedeemRow,
PaymentMethodList,
paymentLabel,
RxCartGate,
} from "@/components/checkout";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card, InfoRow } from "@/components/ui/Card"; import { Card, InfoRow } from "@/components/ui/Card";
import { Chip, Segmented } from "@/components/ui/Chip"; 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 { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen"; import { Screen } from "@/components/ui/Screen";
import { StickyBar } from "@/components/ui/StickyBar"; import { StickyBar } from "@/components/ui/StickyBar";
import { Text } from "@/components/ui/Text"; 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 { branches } from "@/lib/data/branches";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
import { useAddressStore } from "@/lib/stores/addressStore"; 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 { useLocationStore, type PaymentMethod } from "@/lib/stores/locationStore";
import { useNotificationStore } from "@/lib/stores/notificationStore"; import { useNotificationStore } from "@/lib/stores/notificationStore";
import { useOrdersStore } from "@/lib/stores/ordersStore"; import { useOrdersStore } from "@/lib/stores/ordersStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
import { useProfileModeStore } from "@/lib/stores/profileModeStore"; import { useProfileModeStore } from "@/lib/stores/profileModeStore";
import { colors, gutter } from "@/lib/theme"; import { colors, gutter } from "@/lib/theme";
import { formatETB, WHATSAPP_NUMBER } from "@/lib/utils"; import { formatETB, WHATSAPP_NUMBER } from "@/lib/utils";
@ -39,11 +54,13 @@ export default function CheckoutScreen() {
const setAreaLabel = useLocationStore((s) => s.setAreaLabel); const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
const payment = useLocationStore((s) => s.paymentPreference); const payment = useLocationStore((s) => s.paymentPreference);
const setPayment = useLocationStore((s) => s.setPaymentPreference); const setPayment = useLocationStore((s) => s.setPaymentPreference);
const allowedPayments = useLocationStore((s) => s.allowedPayments);
const addresses = useAddressStore((s) => s.addresses); const addresses = useAddressStore((s) => s.addresses);
const defaultAddress = useAddressStore((s) => s.defaultAddress()); const defaultAddress = useAddressStore((s) => s.defaultAddress());
const addOrder = useOrdersStore((s) => s.addOrder); const addOrder = useOrdersStore((s) => s.addOrder);
const pushNotification = useNotificationStore((s) => s.push); const pushNotification = useNotificationStore((s) => s.push);
const addPoints = useAuthStore((s) => s.addPoints); const addPoints = useAuthStore((s) => s.addPoints);
const redeemPoints = useAuthStore((s) => s.redeemPoints);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const setActiveVoucher = useAuthStore((s) => s.setActiveVoucher); const setActiveVoucher = useAuthStore((s) => s.setActiveVoucher);
const quoteSplit = useProfileModeStore((s) => s.quoteSplit); const quoteSplit = useProfileModeStore((s) => s.quoteSplit);
@ -53,23 +70,24 @@ export default function CheckoutScreen() {
const familyMembers = useFamilyStore((s) => s.members); const familyMembers = useFamilyStore((s) => s.members);
const activeMemberId = useFamilyStore((s) => s.activeMemberId); const activeMemberId = useFamilyStore((s) => s.activeMemberId);
const setActiveMember = useFamilyStore((s) => s.setActiveMember); const setActiveMember = useFamilyStore((s) => s.setActiveMember);
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
const isGuest = !!user?.isGuest; const isGuest = !!user?.isGuest;
const [name, setName] = useState(user?.name && !user.isGuest ? user.name : ""); const [name, setName] = useState(user?.name && !user.isGuest ? user.name : "");
const [phone, setPhone] = useState(user?.phone ?? ""); const [phone, setPhone] = useState(user?.phone ?? "");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [addressId, setAddressId] = useState(defaultAddress?.id ?? ""); const [addressId, setAddressId] = useState(defaultAddress?.id ?? "");
const [voucher, setVoucher] = useState( const [voucher, setVoucher] = useState(cartVoucher || user?.activeVoucher || "");
cartVoucher || user?.activeVoucher || ""
);
const [forMemberId, setForMemberId] = useState<string | null>(activeMemberId); const [forMemberId, setForMemberId] = useState<string | null>(activeMemberId);
/** Guests (and optional pickup) can fulfill at any branch with a quick code */
const [anyBranch, setAnyBranch] = useState(isGuest); const [anyBranch, setAnyBranch] = useState(isGuest);
const [paying, setPaying] = useState(false);
const [assistedMode, setAssistedMode] = useState(isGuest);
useEffect(() => { useEffect(() => {
if (!isGuest) return; if (!isGuest) return;
if (fulfillment !== "pickup") setFulfillment("pickup"); if (fulfillment !== "pickup") setFulfillment("pickup");
setAnyBranch(true); setAnyBranch(true);
setAssistedMode(true);
if (payment !== "pay_at_branch") setPayment("pay_at_branch"); if (payment !== "pay_at_branch") setPayment("pay_at_branch");
}, [isGuest, fulfillment, payment, setFulfillment, setPayment]); }, [isGuest, fulfillment, payment, setFulfillment, setPayment]);
@ -78,16 +96,21 @@ export default function CheckoutScreen() {
[addresses, addressId, defaultAddress] [addresses, addressId, defaultAddress]
); );
const effectiveFulfillment = isGuest ? "pickup" : fulfillment; const effectiveFulfillment = isGuest || assistedMode ? "pickup" : fulfillment;
const useAnyBranchPickup = const useAnyBranchPickup =
effectiveFulfillment === "pickup" && (anyBranch || isGuest); effectiveFulfillment === "pickup" && (anyBranch || isGuest || assistedMode);
const allowed = useMemo((): PaymentMethod[] => { const allowed = useMemo((): PaymentMethod[] => {
const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"]; if (assistedMode || isGuest) return ["pay_at_branch"];
return effectiveFulfillment === "pickup" return allowedPayments();
? [...base, "pay_at_branch"] }, [assistedMode, isGuest, allowedPayments, fulfillment]);
: base;
}, [effectiveFulfillment]); 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 = const deliveryFee =
effectiveFulfillment === "delivery" && items.length ? DELIVERY_FEE : 0; effectiveFulfillment === "delivery" && items.length ? DELIVERY_FEE : 0;
@ -97,23 +120,29 @@ export default function CheckoutScreen() {
mode === "corporate" && !isGuest mode === "corporate" && !isGuest
? quoteSplit(afterDiscount) ? quoteSplit(afterDiscount)
: { covered: 0, selfPay: afterDiscount }; : { covered: 0, selfPay: afterDiscount };
const total = orderSubtotalTotal({ const totalDue = orderSubtotalTotal({
subtotal: split.selfPay, subtotal: split.selfPay,
deliveryFee, deliveryFee,
discount: 0, discount: 0,
}); });
const pay = () => { const placeOrder = async (opts: { assisted: boolean }) => {
if (!items.length) return; if (!items.length || paying) return;
if (!name.trim() || phone.trim().length < 9) { if (!name.trim() || phone.trim().length < 9) {
Alert.alert("Contact details", "Enter your name and a valid phone number."); Alert.alert("Contact details", "Enter your name and a valid phone number.");
return; 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."); Alert.alert("Payment", "Select a valid payment method.");
return; return;
} }
if (effectiveFulfillment === "pickup" && !useAnyBranchPickup && !branchId) { if (effectiveFulfillment === "pickup" && !useAnyBranchPickup && !branchId) {
Alert.alert("Pickup", "Select a branch, or choose any-branch quick code."); Alert.alert("Pickup", "Select a branch, or choose any-branch quick code.");
return; return;
@ -127,13 +156,15 @@ export default function CheckoutScreen() {
const addressLine = selectedAddress const addressLine = selectedAddress
? `${selectedAddress.street}${selectedAddress.landmark ? ` · ${selectedAddress.landmark}` : ""}` ? `${selectedAddress.street}${selectedAddress.landmark ? ` · ${selectedAddress.landmark}` : ""}`
: undefined; : undefined;
const forMember = familyMembers.find((m) => m.id === forMemberId); const forMember = familyMembers.find((m) => m.id === forMemberId);
const assisted = opts.assisted || isGuest;
const payMethod: PaymentMethod = assisted ? "pay_at_branch" : payment;
const orderNotes = [ const orderNotes = [
notes.trim(), notes.trim(),
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "", forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
isGuest assisted
? "Pharmacist order code — enter in admin to create the order" ? "Pharmacist assisted order code — enter in admin to fulfill"
: useAnyBranchPickup : useAnyBranchPickup
? "Quick code — redeemable at any Gishen branch" ? "Quick code — redeemable at any Gishen branch"
: "", : "",
@ -142,11 +173,22 @@ export default function CheckoutScreen() {
.join(" · "); .join(" · ");
const pickupCode = const pickupCode =
isGuest || effectiveFulfillment === "pickup" assisted || effectiveFulfillment === "pickup" ? generatePickupCode() : null;
? generatePickupCode()
: null;
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee; const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
setPaying(true);
try {
if (!assisted && payMethod !== "pay_at_branch" && payMethod !== "cod") {
const intent = await initiatePayment({
provider: payMethod,
amountEtb: afterDiscount + fee,
orderId: `pending_${Date.now()}`,
});
if (intent.status === "requires_action" && intent.checkoutUrl) {
await Linking.openURL(intent.checkoutUrl).catch(() => undefined);
}
}
const order = addOrder({ const order = addOrder({
items, items,
subtotal, subtotal,
@ -164,7 +206,7 @@ export default function CheckoutScreen() {
addressId: addressId:
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null, effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
addressLine: useAnyBranchPickup ? undefined : addressLine, addressLine: useAnyBranchPickup ? undefined : addressLine,
paymentMethod: payment, paymentMethod: payMethod,
corporateCovered: split.covered, corporateCovered: split.covered,
selfPay: split.selfPay + fee, selfPay: split.selfPay + fee,
customerName: name.trim(), customerName: name.trim(),
@ -172,19 +214,20 @@ export default function CheckoutScreen() {
notes: orderNotes, notes: orderNotes,
riderAssigned: false, riderAssigned: false,
pickupCode, pickupCode,
anyBranch: useAnyBranchPickup || isGuest, anyBranch: useAnyBranchPickup || assisted,
isGuestOrder: isGuest, isGuestOrder: assisted,
status: isGuest status: assisted
? "requested" ? "requested"
: useAnyBranchPickup || payment === "pay_at_branch" : payMethod === "pay_at_branch" || payMethod === "cod" || payMethod === "bank"
? "confirmed" ? "confirmed"
: undefined, : "confirmed",
}); });
if (forMemberId) setActiveMember(forMemberId); if (forMemberId) setActiveMember(forMemberId);
if (!assisted && split.covered > 0) consumeAllowance(split.covered);
if (!isGuest && split.covered > 0) consumeAllowance(split.covered); if (!assisted && !isGuest) {
if (!isGuest) addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward"); addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
}
if (discount) { if (discount) {
setActiveVoucher(null); setActiveVoucher(null);
setCartVoucher(null); setCartVoucher(null);
@ -193,59 +236,38 @@ export default function CheckoutScreen() {
pushNotification({ pushNotification({
kind: "order", kind: "order",
title: isGuest title: assisted
? `Pharmacist code ${pickupCode}` ? `Pharmacist code ${pickupCode}`
: pickupCode : pickupCode
? `Pickup code ${pickupCode}` ? `Pickup code ${pickupCode}`
: "Order placed", : "Order placed",
body: isGuest body: assisted
? `Share ${pickupCode} so a pharmacist can create your order in admin.` ? `Share ${pickupCode} so a pharmacist can create your order in admin.`
: pickupCode : pickupCode
? useAnyBranchPickup ? useAnyBranchPickup
? `Show ${pickupCode} at any Gishen branch counter to collect your order.` ? `Show ${pickupCode} at any Gishen branch counter.`
: `Show ${pickupCode} at ${branch?.name ?? "your branch"} to collect.` : `Show ${pickupCode} at ${branch?.name ?? "your branch"}.`
: `Your ${order.fulfillment} order is confirmed.`, : `Your ${order.fulfillment} order is confirmed.`,
href: isGuest && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id), href: assisted && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id),
}); });
if (isGuest && pickupCode) { if (assisted && pickupCode) {
router.replace(routes.orderCode(order.id) as never); router.replace(routes.orderCode(order.id) as never);
return; return;
} }
router.replace(routes.checkoutSuccess(order.id) as never);
Alert.alert( } catch (e) {
pickupCode ? "Quick pickup code ready" : "Order placed", Alert.alert("Payment failed", e instanceof Error ? e.message : "Try again.");
pickupCode } finally {
? `Your code is ${pickupCode}. ${ setPaying(false);
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 whatsapp = () => {
const lines = items const lines = items
.map((i) => `• ${i.name} x${i.quantity} — ${i.price * i.quantity} ETB`) .map((i) => `• ${i.name} x${i.quantity} — ${i.price * i.quantity} ETB`)
.join("%0A"); .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}`); Linking.openURL(`https://wa.me/${WHATSAPP_NUMBER}?text=${msg}`);
}; };
@ -273,10 +295,12 @@ export default function CheckoutScreen() {
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: gutter, paddingHorizontal: gutter,
paddingBottom: 140, paddingBottom: 160,
gap: 14, gap: 14,
}} }}
> >
<RxCartGate rxItemNames={rxItems.map((i) => i.name)} />
<Card elevated={false}> <Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}> <Text variant="h3" style={{ marginBottom: 10 }}>
Contact Contact
@ -328,25 +352,41 @@ export default function CheckoutScreen() {
<Text variant="h3" style={{ marginBottom: 10 }}> <Text variant="h3" style={{ marginBottom: 10 }}>
Fulfillment Fulfillment
</Text> </Text>
{isGuest ? ( {isGuest || assistedMode ? (
<Card tone="brand" elevated={false} style={{ marginBottom: 12 }}> <AssistedOrderExplainer
<Text variant="h3">Pharmacist order code</Text> title={isGuest ? "Pharmacist order code" : "Assisted order code"}
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}> body={
You’ll get a GSH-XXXX code to copy, share, or screenshot. A pharmacist isGuest
enters it on the admin page to create your order — then you pay at the branch. ? "You’ll get a GSH-XXXX code. A pharmacist enters it in admin to create your order — pay at the branch."
</Text> : "Freeze this basket as a GSH-XXXX code for a pharmacist to place offline / at the counter."
</Card> }
/>
) : null}
{!isGuest ? (
<View style={{ marginBottom: 12 }}>
<Chip
label={assistedMode ? "Assisted code mode on" : "Get pharmacist code instead"}
selected={assistedMode}
onPress={() => {
const next = !assistedMode;
setAssistedMode(next);
if (next) {
setFulfillment("pickup");
setAnyBranch(true);
setPayment("pay_at_branch");
}
}}
/>
</View>
) : null} ) : null}
<Segmented <Segmented
value={effectiveFulfillment} value={effectiveFulfillment}
onChange={(v) => { onChange={(v) => {
if (isGuest && v === "delivery") { if ((isGuest || assistedMode) && v === "delivery") {
Alert.alert( Alert.alert(
"Guest checkout", "Assisted checkout",
"Guests use quick-code pickup at any branch. Sign in for delivery & loyalty." "Pharmacist codes are pickup-only. Turn off assisted mode for delivery."
); );
setFulfillment("pickup");
setAnyBranch(true);
return; return;
} }
setFulfillment(v); setFulfillment(v);
@ -422,7 +462,7 @@ export default function CheckoutScreen() {
> >
<Text variant="h3">Any branch · quick code</Text> <Text variant="h3">Any branch · quick code</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}> <Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
Get a GSH-XXXX code and collect at Bole, CMC, Jemo, or any other branch Collect at Bole, CMC, Jemo, or any other branch
</Text> </Text>
</Pressable> </Pressable>
{branches.map((b) => ( {branches.map((b) => (
@ -454,63 +494,50 @@ export default function CheckoutScreen() {
)} )}
</Card> </Card>
<Card elevated={false} padded={false}> {!assistedMode && !isGuest ? (
<View style={{ padding: 16, paddingBottom: 8 }}> <PaymentMethodList
<Text variant="h3">{t("common.payment")}</Text> methods={allowed}
{effectiveFulfillment === "delivery" ? ( selected={payment}
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}> onSelect={setPayment}
Pay at branch is only available for pickup title={t("common.payment")}
</Text> footnote={
) : null} effectiveFulfillment === "delivery"
</View> ? "Pay at branch is only available for pickup"
{allowed.map((m, i) => ( : undefined
<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} ) : null}
</View>
</Card>
{mode === "corporate" && membership ? ( {!isGuest ? (
<Card tone="brand" elevated={false}> <LoyaltyRedeemRow
<Text variant="h3">Corporate split · {membership.orgName}</Text> points={user?.points ?? 0}
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}> activeVoucher={user?.activeVoucher}
Plan {membership.planName} · {formatETB(membership.remainingAllowance)} left this month voucher={voucher}
</Text> discount={discount}
<Text variant="bodySm" style={{ marginTop: 8 }}> onChangeVoucher={setVoucher}
Covered: {formatETB(split.covered)} onApplyDemo={() => setVoucher("GISHEN10")}
</Text> onApplyLoyalty={() => user?.activeVoucher && setVoucher(user.activeVoucher!)}
<Text variant="bodySm">You pay: {formatETB(split.selfPay + deliveryFee)}</Text> canRedeem={(user?.points ?? 0) >= 100 && !user?.isGuest}
</Card> onRedeemPoints={() => {
if (redeemPoints(100)) {
setVoucher("GISHEN10");
setCartVoucher("GISHEN10");
Alert.alert("Redeemed", "GISHEN10 applied to this checkout.");
} else {
Alert.alert("Not enough points", "You need at least 100 points.");
}
}}
/>
) : null}
{mode === "corporate" && membership && !isGuest && !assistedMode ? (
<EntitlementSplitCard
orgName={membership.orgName}
planName={membership.planName}
remainingAllowance={membership.remainingAllowance}
covered={split.covered}
selfPay={split.selfPay + deliveryFee}
/>
) : null} ) : null}
<Card elevated={false}> <Card elevated={false}>
@ -527,7 +554,12 @@ export default function CheckoutScreen() {
label={effectiveFulfillment === "delivery" ? "Delivery" : "Pickup"} label={effectiveFulfillment === "delivery" ? "Delivery" : "Pickup"}
value={deliveryFee ? formatETB(deliveryFee) : "Free"} value={deliveryFee ? formatETB(deliveryFee) : "Free"}
/> />
<InfoRow label={t("common.total")} value={formatETB(afterDiscount + deliveryFee)} emphasis last /> <InfoRow
label={t("common.total")}
value={formatETB(afterDiscount + deliveryFee)}
emphasis
last
/>
</Card> </Card>
<Button title="Order via WhatsApp" variant="outline" icon="logo-whatsapp" onPress={whatsapp} /> <Button title="Order via WhatsApp" variant="outline" icon="logo-whatsapp" onPress={whatsapp} />
@ -537,39 +569,31 @@ export default function CheckoutScreen() {
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}> <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text variant="caption" color={colors.subtle}> <Text variant="caption" color={colors.subtle}>
Total due {assistedMode || isGuest ? "Assisted total" : "Total due"}
</Text> </Text>
<Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text> <Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text>
{!assistedMode && !isGuest ? (
<Text variant="micro" color={colors.subtle}>
{paymentLabel(payment)}
</Text>
) : null}
</View> </View>
<Button <Button
title={isGuest ? "Get pharmacist code" : t("common.payNow")} title={
paying
? "Processing…"
: isGuest || assistedMode
? "Get pharmacist code"
: t("common.payNow")
}
fullWidth={false} fullWidth={false}
style={{ minWidth: 140 }} style={{ minWidth: 148 }}
onPress={pay} loading={paying}
disabled={paying || (hasBlockingRx && !assistedMode && !isGuest)}
onPress={() => placeOrder({ assisted: isGuest || assistedMode })}
/> />
</View> </View>
</StickyBar> </StickyBar>
</Screen> </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";
}

View File

@ -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 (
<Screen>
<AppHeader title="Notification preferences" subtitle="SMS · Email · Push" />
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 14,
}}
>
<Card elevated={false}>
<Text variant="bodySm" color={colors.muted}>
Choose how Gishen reaches you for orders, refill reminders, and updates.
</Text>
</Card>
<Card padded={false} elevated={false}>
<View style={{ paddingHorizontal: 4 }}>
<SwitchRow
icon="chatbubble-outline"
title="SMS"
subtitle="Order and pickup alerts by text"
value={prefs.sms}
onValueChange={(v) => prefs.setPref("sms", v)}
/>
<SwitchRow
icon="mail-outline"
title="Email"
subtitle="Receipts and statements"
value={prefs.email}
onValueChange={(v) => prefs.setPref("email", v)}
/>
<SwitchRow
icon="notifications-outline"
title="Push"
subtitle="Live tracking and dose reminders"
value={prefs.push}
onValueChange={(v) => prefs.setPref("push", v)}
last
/>
</View>
</Card>
<Card padded={false} elevated={false}>
<View style={{ paddingHorizontal: 4 }}>
<SwitchRow
title="Order updates"
subtitle="Paid, packed, out for delivery"
value={prefs.orderUpdates}
onValueChange={(v) => prefs.setPref("orderUpdates", v)}
/>
<SwitchRow
title="Refill reminders"
subtitle="When days-of-supply is running out"
value={prefs.refillReminders}
onValueChange={(v) => prefs.setPref("refillReminders", v)}
/>
<SwitchRow
title="Offers & tips"
subtitle="Optional marketing messages"
value={prefs.marketing}
onValueChange={(v) => prefs.setPref("marketing", v)}
last
/>
</View>
</Card>
</ScrollView>
</Screen>
);
}

View File

@ -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 (
<Screen>
<AppHeader title="Payment methods" subtitle="Preferred checkout channel" />
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 14,
}}
>
<Card elevated={false}>
<Text variant="bodySm" color={colors.muted}>
Saved preference is pre-selected at checkout. Card/wallet tokens are not
stored in this demo — gateway handoff happens at pay time.
</Text>
</Card>
<PaymentMethodList
methods={ALL}
selected={payment}
onSelect={setPayment}
title="Preferred method"
/>
<Button title="Done" onPress={() => router.back()} />
<Button
title="Go to checkout"
variant="outline"
onPress={() => router.push(routes.checkout)}
/>
</ScrollView>
</Screen>
);
}

View File

@ -2,6 +2,13 @@ import { Ionicons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Alert, Image, ScrollView, View } from "react-native"; 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 { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card, InfoRow, Section } from "@/components/ui/Card"; 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 { Text } from "@/components/ui/Text";
import { products } from "@/lib/data/products"; import { products } from "@/lib/data/products";
import { scheduleDoseReminder } from "@/lib/notifications"; import { scheduleDoseReminder } from "@/lib/notifications";
import { routes } from "@/lib/routes";
import { useCartStore } from "@/lib/stores/cartStore"; import { useCartStore } from "@/lib/stores/cartStore";
import { useNotificationStore } from "@/lib/stores/notificationStore"; import { useNotificationStore } from "@/lib/stores/notificationStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore"; import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
import { colors, gutter } from "@/lib/theme"; import { colors, gutter } from "@/lib/theme";
import { routes } from "@/lib/routes";
const statusTone = { const statusTone = {
submitted: "neutral", submitted: "neutral",
@ -26,12 +33,6 @@ const statusTone = {
filled: "brand", filled: "brand",
} as const; } as const;
const lineTone = {
ready: "brand",
review: "warn",
unavailable: "danger",
} as const;
export default function PrescriptionDetailScreen() { export default function PrescriptionDetailScreen() {
const params = useLocalSearchParams<{ id: string }>(); const params = useLocalSearchParams<{ id: string }>();
const id = Array.isArray(params.id) ? params.id[0] : params.id; 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 markRefilled = usePrescriptionStore((s) => s.markRefilled);
const updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus); const updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus);
const replyToQuery = usePrescriptionStore((s) => s.replyToQuery);
const confirmDose = usePrescriptionStore((s) => s.confirmDose); const confirmDose = usePrescriptionStore((s) => s.confirmDose);
const addProduct = useCartStore((s) => s.addProduct); const addProduct = useCartStore((s) => s.addProduct);
const pushNotification = useNotificationStore((s) => s.push); const pushNotification = useNotificationStore((s) => s.push);
@ -76,6 +78,7 @@ export default function PrescriptionDetailScreen() {
})(); })();
const pendingLog = recentLogs.find((l) => l.status === "pending"); const pendingLog = recentLogs.find((l) => l.status === "pending");
const refillSoon = dueIn !== null && dueIn <= 5;
return ( return (
<Screen> <Screen>
@ -91,6 +94,74 @@ export default function PrescriptionDetailScreen() {
gap: 14, gap: 14,
}} }}
> >
<Card elevated={false}>
<Text variant="label" color={colors.muted} style={{ marginBottom: 12 }}>
Pharmacist review status
</Text>
<RxStatusStepper status={rx.status} />
</Card>
{rx.status === "queried" ? (
<RxQueriedPanel
pharmacistMessage={rx.pharmacistMessage}
customerReply={rx.customerReply}
onReply={(message) => {
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" ? (
<Card tone="wash" elevated={false} style={{ borderColor: colors.danger }}>
<Text variant="h3" color={colors.danger}>
Prescription rejected
</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
{rx.pharmacistMessage ||
"The pharmacist could not approve this script. Upload a clearer copy or visit a branch."}
</Text>
<Button
title="Scan new prescription"
style={{ marginTop: 12 }}
onPress={() => router.push(routes.rxScan)}
/>
</Card>
) : null}
{refillSoon ? (
<Card tone="brand" elevated={false}>
<Text variant="h3">
{dueIn !== null && dueIn <= 0 ? "Refill overdue" : "Refill reminder"}
</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
{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.`}
</Text>
<Button
title="One-tap refill to cart"
style={{ marginTop: 12 }}
icon="bag-add-outline"
onPress={() => {
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);
}}
/>
</Card>
) : null}
<Card elevated style={{ padding: 0, overflow: "hidden" }}> <Card elevated style={{ padding: 0, overflow: "hidden" }}>
<View <View
style={{ style={{
@ -116,12 +187,16 @@ export default function PrescriptionDetailScreen() {
For {rx.patientName} · {rx.medicines.length} item For {rx.patientName} · {rx.medicines.length} item
{rx.medicines.length === 1 ? "" : "s"} on this Rx {rx.medicines.length === 1 ? "" : "s"} on this Rx
</Text> </Text>
{rx.imageUris.length > 1 ? (
<View style={{ marginTop: 4 }}>
<Text variant="caption" color={colors.subtle} style={{ marginBottom: 8 }}>
Pages
</Text>
<RxImagePager uris={rx.imageUris} size={72} />
</View>
) : null}
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}> <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
<Badge <Badge label={`${rx.daysSupply} day supply`} tone="neutral" icon="time-outline" />
label={`${rx.daysSupply} day supply`}
tone="neutral"
icon="time-outline"
/>
{dueIn !== null ? ( {dueIn !== null ? (
<Badge <Badge
label={dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`} label={dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
@ -131,11 +206,7 @@ export default function PrescriptionDetailScreen() {
<Badge label="Awaiting first fill" tone="warn" /> <Badge label="Awaiting first fill" tone="warn" />
)} )}
{reminder?.enabled ? ( {reminder?.enabled ? (
<Badge <Badge label={reminder.times.join(" · ")} tone="brand" icon="alarm-outline" />
label={reminder.times.join(" · ")}
tone="brand"
icon="alarm-outline"
/>
) : ( ) : (
<Badge label="Reminders off" tone="neutral" icon="alarm-outline" /> <Badge label="Reminders off" tone="neutral" icon="alarm-outline" />
)} )}
@ -144,6 +215,11 @@ export default function PrescriptionDetailScreen() {
</Card> </Card>
<Section title="Dose calendar & push"> <Section title="Dose calendar & push">
{reminder?.escalateOnMiss ? (
<View style={{ marginBottom: 10 }}>
<EscalationBanner />
</View>
) : null}
<Card> <Card>
{reminder?.enabled && !showSetup ? ( {reminder?.enabled && !showSetup ? (
<View style={{ gap: 12 }}> <View style={{ gap: 12 }}>
@ -285,41 +361,7 @@ export default function PrescriptionDetailScreen() {
</Section> </Section>
<Section title="Medicines on this Rx"> <Section title="Medicines on this Rx">
<Card elevated={false} padded={false}> <RxMedicineLines medicines={rx.medicines} />
{rx.medicines.map((m, i) => (
<View
key={m.name}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: i === rx.medicines.length - 1 ? 0 : 1,
borderBottomColor: colors.line,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="medkit-outline" size={16} color={colors.brandText} />
</View>
<Text variant="h3" style={{ flex: 1 }}>
{m.name}
</Text>
</View>
<Badge label={m.status} tone={lineTone[m.status]} />
</View>
))}
</Card>
</Section> </Section>
<Section title="Details"> <Section title="Details">
@ -347,10 +389,12 @@ export default function PrescriptionDetailScreen() {
title="Reorder medicines" title="Reorder medicines"
icon="bag-add-outline" icon="bag-add-outline"
onPress={() => { onPress={() => {
rx.medicines.forEach((m) => {
const match = products.find((p) => const match = products.find((p) =>
rx.medicineName.toLowerCase().includes(p.name.toLowerCase().split(" ")[0]) p.name.toLowerCase().includes(m.name.toLowerCase().split(" ")[0])
); );
if (match) addProduct(match); if (match) addProduct(match);
});
router.push(routes.cart); router.push(routes.cart);
}} }}
/> />
@ -369,6 +413,19 @@ export default function PrescriptionDetailScreen() {
onPress={() => updateRxStatus(rx.id, "approved")} onPress={() => updateRxStatus(rx.id, "approved")}
/> />
) : null} ) : null}
{rx.status === "submitted" ? (
<Button
title="Simulate pharmacist query"
variant="ghost"
onPress={() =>
updateRxStatus(
rx.id,
"queried",
"Please confirm the prescribed strength on page 1."
)
}
/>
) : null}
</View> </View>
</ScrollView> </ScrollView>
</Screen> </Screen>

View File

@ -1,6 +1,7 @@
import { router } from "expo-router"; import { router } from "expo-router";
import { useMemo, useState } from "react"; 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 { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
@ -15,8 +16,9 @@ import { useAuthStore } from "@/lib/stores/authStore";
import { useCartStore } from "@/lib/stores/cartStore"; import { useCartStore } from "@/lib/stores/cartStore";
import { useFamilyStore } from "@/lib/stores/familyStore"; import { useFamilyStore } from "@/lib/stores/familyStore";
import { useLocationStore } from "@/lib/stores/locationStore"; import { useLocationStore } from "@/lib/stores/locationStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore"; 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" { function matchStatus(name: string): "ready" | "review" | "unavailable" {
const hit = products.find((p) => const hit = products.find((p) =>
@ -33,6 +35,7 @@ export default function RxReviewScreen() {
const addPrescription = usePrescriptionStore((s) => s.addPrescription); const addPrescription = usePrescriptionStore((s) => s.addPrescription);
const addProduct = useCartStore((s) => s.addProduct); const addProduct = useCartStore((s) => s.addProduct);
const setFulfillment = useLocationStore((s) => s.setFulfillment); const setFulfillment = useLocationStore((s) => s.setFulfillment);
const pushNotification = useNotificationStore((s) => s.push);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const members = useFamilyStore((s) => s.members); const members = useFamilyStore((s) => s.members);
const activeMemberId = useFamilyStore((s) => s.activeMemberId); const activeMemberId = useFamilyStore((s) => s.activeMemberId);
@ -90,31 +93,57 @@ export default function RxReviewScreen() {
setFulfillment("pickup"); setFulfillment("pickup");
} }
setDraftImages([]); setDraftImages([]);
Alert.alert("Saved", "Prescription submitted for pharmacist review", [ pushNotification({
{ text: "Open details", onPress: () => router.replace(routes.rxDetail(rx.id)) }, 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: "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 ( return (
<Screen> <Screen>
<AppHeader title="Review prescription" subtitle="Confirm patient and medicines" /> <AppHeader title="Review prescription" subtitle="Confirm patient and medicines" />
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}> <ScrollView
contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}
>
<Card elevated={false}>
<Text variant="label" color={colors.muted} style={{ marginBottom: 12 }}>
After submit
</Text>
<RxStatusStepper status="submitted" />
</Card>
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}>
Prescription pages ({draftImages.length})
</Text>
{draftImages.length ? ( {draftImages.length ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}> <RxImagePager
{draftImages.map((uri) => ( uris={draftImages}
<Image size={104}
key={uri} onLongPress={(_, index) => {
source={{ uri }} Alert.alert("Remove page?", `Page ${index + 1}`, [
style={{ width: 96, height: 96, borderRadius: radii.md, backgroundColor: colors.surfaceAlt }} { text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: () =>
setDraftImages(draftImages.filter((_, i) => i !== index)),
},
]);
}}
/> />
))}
</ScrollView>
) : ( ) : (
<Card tone="brand" elevated={false}> <>
<Text variant="h3">No images attached</Text> <Text variant="bodySm" color={colors.muted}>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
Go back to scan or upload a prescription photo. Go back to scan or upload a prescription photo.
</Text> </Text>
<Button <Button
@ -123,8 +152,9 @@ export default function RxReviewScreen() {
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
onPress={() => router.replace(routes.rxScan)} onPress={() => router.replace(routes.rxScan)}
/> />
</Card> </>
)} )}
</Card>
<Card elevated={false}> <Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}> <Text variant="h3" style={{ marginBottom: 10 }}>
@ -186,8 +216,10 @@ export default function RxReviewScreen() {
<Text variant="h3" style={{ marginBottom: 10 }}> <Text variant="h3" style={{ marginBottom: 10 }}>
Availability preview Availability preview
</Text> </Text>
{(medicines.length ? medicines : [{ name: medicineName || "—", status: "review" as const }]).map( {(medicines.length
(m) => ( ? medicines
: [{ name: medicineName || "—", status: "review" as const }]
).map((m) => (
<View <View
key={m.name} key={m.name}
style={{ style={{
@ -206,8 +238,7 @@ export default function RxReviewScreen() {
} }
/> />
</View> </View>
) ))}
)}
</Card> </Card>
<Button title="Submit for review" onPress={() => save(false)} /> <Button title="Submit for review" onPress={() => save(false)} />

View File

@ -4,6 +4,7 @@ import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { Alert, Linking, Pressable, ScrollView, View } from "react-native"; import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BiometricRow, LoyaltyStrip, QuickActions } from "@/components/profile";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@ -13,10 +14,12 @@ import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen"; import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text"; import { Text } from "@/components/ui/Text";
import { routes } from "@/lib/routes"; import { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore"; import { useAuthStore, type AuthProvider } from "@/lib/stores/authStore";
import type { AuthProvider } from "@/lib/stores/authStore";
import { useLangStore } from "@/lib/stores/langStore"; 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 { useProfileModeStore } from "@/lib/stores/profileModeStore";
import { paymentLabel } from "@/components/checkout/PaymentMethodList";
import { colors, gutter } from "@/lib/theme"; import { colors, gutter } from "@/lib/theme";
import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils"; 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 user = useAuthStore((s) => s.user);
const setAvatar = useAuthStore((s) => s.setAvatar); const setAvatar = useAuthStore((s) => s.setAvatar);
const updateProfile = useAuthStore((s) => s.updateProfile); const updateProfile = useAuthStore((s) => s.updateProfile);
const setBiometric = useAuthStore((s) => s.setBiometric);
const signOut = useAuthStore((s) => s.signOut); const signOut = useAuthStore((s) => s.signOut);
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded); const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
const mode = useProfileModeStore((s) => s.mode); const mode = useProfileModeStore((s) => s.mode);
const membership = useProfileModeStore((s) => s.activeMembership()); const membership = useProfileModeStore((s) => s.activeMembership());
const language = useLangStore((s) => s.language); const language = useLangStore((s) => s.language);
const setLanguage = useLangStore((s) => s.setLanguage); const setLanguage = useLangStore((s) => s.setLanguage);
const payment = useLocationStore((s) => s.paymentPreference);
const prefs = usePrefsStore();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [name, setName] = useState(user?.name ?? ""); const [name, setName] = useState(user?.name ?? "");
@ -46,14 +52,12 @@ export default function ProfileScreen() {
); );
return; return;
} }
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"], mediaTypes: ["images"],
allowsEditing: true, allowsEditing: true,
aspect: [1, 1], aspect: [1, 1],
quality: 0.85, quality: 0.85,
}); });
if (!result.canceled && result.assets[0]?.uri) { if (!result.canceled && result.assets[0]?.uri) {
setAvatar(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.switchProfile"), href: routes.profileSwitch, icon: "swap-horizontal", subtitle: mode },
{ label: t("common.joinOrg"), href: routes.profileJoin, icon: "business-outline" }, { 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.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.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.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.subscriptions"), href: routes.subscriptions, icon: "repeat-outline" },
{ label: t("common.emergencyContacts"), href: routes.emergencyContacts, icon: "alert-circle-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.branches"), href: routes.branches, icon: "location-outline" },
{ label: t("common.addresses"), href: routes.addresses, icon: "home-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.about"), href: routes.about, icon: "information-circle-outline" },
{ label: t("common.contact"), href: routes.contact, icon: "mail-outline" }, { label: t("common.contact"), href: routes.contact, icon: "mail-outline" },
]; ];
@ -153,10 +174,7 @@ export default function ProfileScreen() {
) : null} ) : null}
<View style={{ flexDirection: "row", gap: 8, marginTop: 8, flexWrap: "wrap" }}> <View style={{ flexDirection: "row", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
{user?.authProvider && user.authProvider !== "guest" ? ( {user?.authProvider && user.authProvider !== "guest" ? (
<Badge <Badge label={providerLabel(user.authProvider)} tone="info" />
label={providerLabel(user.authProvider)}
tone="info"
/>
) : null} ) : null}
<Badge <Badge
label={ label={
@ -166,7 +184,7 @@ export default function ProfileScreen() {
} }
tone="brand" tone="brand"
/> />
<Badge label={`${user?.tier ?? "bronze"} · ${user?.points ?? 0} pts`} tone="neutral" /> {user?.isGuest ? <Badge label="Guest" tone="neutral" /> : null}
</View> </View>
</View> </View>
</View> </View>
@ -210,6 +228,12 @@ export default function ProfileScreen() {
) : null} ) : null}
</Card> </Card>
{!user?.isGuest ? (
<LoyaltyStrip points={user?.points ?? 0} tier={user?.tier ?? "bronze"} />
) : null}
<QuickActions />
{editing && !user?.isGuest ? ( {editing && !user?.isGuest ? (
<Card elevated={false} style={{ marginTop: 12 }}> <Card elevated={false} style={{ marginTop: 12 }}>
<Text variant="h3" style={{ marginBottom: 12 }}> <Text variant="h3" style={{ marginBottom: 12 }}>
@ -250,6 +274,15 @@ export default function ProfileScreen() {
</Card> </Card>
) : null} ) : null}
{!user?.isGuest ? (
<Card padded={false} elevated={false} style={{ marginTop: 14, overflow: "hidden" }}>
<BiometricRow
enabled={!!user?.biometricEnabled}
onChange={setBiometric}
/>
</Card>
) : null}
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}> <View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
{[ {[
{ label: t("common.call"), icon: "call-outline" as const, href: `tel:${CALL_PHONE}` }, { label: t("common.call"), icon: "call-outline" as const, href: `tel:${CALL_PHONE}` },

View File

@ -1,12 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
import { useMemo, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { FamilyInviteCard } from "@/components/care";
import { RxCareCard } from "@/components/prescription";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card, Section } from "@/components/ui/Card"; 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 { EmptyState } from "@/components/ui/EmptyState";
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen"; import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
import { TabTopBar } from "@/components/ui/TabTopBar"; import { TabTopBar } from "@/components/ui/TabTopBar";
@ -15,155 +16,12 @@ import { routes } from "@/lib/routes";
import { useFamilyStore } from "@/lib/stores/familyStore"; import { useFamilyStore } from "@/lib/stores/familyStore";
import { import {
usePrescriptionStore, usePrescriptionStore,
type Prescription,
type Reminder, type Reminder,
type RxStatus,
} from "@/lib/stores/prescriptionStore"; } from "@/lib/stores/prescriptionStore";
import { colors, gutter } from "@/lib/theme"; import { colors, gutter } from "@/lib/theme";
const statusTone = { type Filter = "all" | RxStatus;
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 (
<Card elevated style={{ padding: 0, overflow: "hidden" }}>
<Pressable
onPress={() => router.push(routes.rxDetail(rx.id))}
style={{ padding: 14 }}
>
<View style={{ flexDirection: "row", gap: 12 }}>
<View
style={{
width: 64,
height: 64,
borderRadius: 16,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
{thumb ? (
<Image source={{ uri: thumb }} style={{ width: 64, height: 64 }} />
) : (
<Ionicons name="medkit" size={26} color={colors.brandText} />
)}
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
<Text variant="h3" style={{ flex: 1 }} numberOfLines={2}>
{rx.medicineName}
</Text>
<Badge label={rx.status.replace("_", " ")} tone={statusTone[rx.status]} />
</View>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{rx.patientName} · {rx.medicines.length} medicine
{rx.medicines.length === 1 ? "" : "s"} · {rx.daysSupply}d supply
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
{reminder?.enabled ? (
<Badge
label={`Reminders ${reminder.times.join(" · ")}`}
tone="brand"
icon="alarm-outline"
/>
) : (
<Badge label="No dose reminder" tone="warn" icon="alarm-outline" />
)}
{dueIn !== null ? (
<Badge
label={
dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn}d`
}
tone={dueIn <= 3 ? "warn" : "neutral"}
/>
) : (
<Badge label="Awaiting fill" tone="info" />
)}
</View>
</View>
</View>
</Pressable>
<View
style={{
flexDirection: "row",
borderTopWidth: 1,
borderTopColor: colors.line,
}}
>
<Pressable
onPress={onToggleExpand}
style={{
flex: 1,
paddingVertical: 12,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 6,
}}
>
<Ionicons
name={expanded ? "chevron-up" : "notifications-outline"}
size={16}
color={colors.brandText}
/>
<Text variant="label" color={colors.brandText}>
{expanded ? "Hide setup" : reminder?.enabled ? "Edit reminder" : "Set reminder"}
</Text>
</Pressable>
<View style={{ width: 1, backgroundColor: colors.line }} />
<Pressable
onPress={() => router.push(routes.rxCalendar(rx.id))}
style={{
flex: 1,
paddingVertical: 12,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 6,
}}
>
<Ionicons name="calendar-outline" size={16} color={colors.brandText} />
<Text variant="label" color={colors.brandText}>
Calendar
</Text>
</Pressable>
</View>
{expanded ? (
<View style={{ paddingHorizontal: 14, paddingBottom: 14 }}>
<DoseReminderPanel rx={rx} reminder={reminder} compact />
</View>
) : null}
</Card>
);
}
export default function PrescriptionsTabScreen() { export default function PrescriptionsTabScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -176,6 +34,7 @@ export default function PrescriptionsTabScreen() {
const setActiveMember = useFamilyStore((s) => s.setActiveMember); const setActiveMember = useFamilyStore((s) => s.setActiveMember);
const activeMemberId = useFamilyStore((s) => s.activeMemberId); const activeMemberId = useFamilyStore((s) => s.activeMemberId);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [filter, setFilter] = useState<Filter>("all");
const reminderByRx = useMemo(() => { const reminderByRx = useMemo(() => {
const map = new Map<string, Reminder>(); const map = new Map<string, Reminder>();
@ -185,6 +44,15 @@ export default function PrescriptionsTabScreen() {
const activeReminderCount = reminders.filter((r) => r.enabled).length; const activeReminderCount = reminders.filter((r) => r.enabled).length;
const needingSetup = prescriptions.filter((p) => !reminderByRx.get(p.id)?.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 ( return (
<Screen> <Screen>
@ -221,6 +89,16 @@ export default function PrescriptionsTabScreen() {
{needingSetup} {needingSetup}
</Text> </Text>
</Card> </Card>
{queriedCount ? (
<Card elevated={false} style={{ flex: 1 }}>
<Text variant="caption" color={colors.muted}>
Need reply
</Text>
<Text variant="h1" style={{ marginTop: 2 }}>
{queriedCount}
</Text>
</Card>
) : null}
</View> </View>
<Button <Button
@ -229,19 +107,43 @@ export default function PrescriptionsTabScreen() {
onPress={() => router.push(routes.rxScan)} onPress={() => router.push(routes.rxScan)}
/> />
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
{(
[
["all", "All"],
["queried", "Queried"],
["under_review", "Review"],
["approved", "Approved"],
["filled", "Filled"],
] as [Filter, string][]
).map(([id, label]) => (
<Chip
key={id}
label={label}
selected={filter === id}
onPress={() => setFilter(id)}
size="sm"
/>
))}
</ScrollView>
<Section title="Your prescriptions"> <Section title="Your prescriptions">
{prescriptions.length === 0 ? ( {visible.length === 0 ? (
<EmptyState <EmptyState
compact compact
icon="document-text-outline" icon="document-text-outline"
title="No prescriptions yet" title={filter === "all" ? "No prescriptions yet" : "Nothing in this filter"}
body="Scan or upload a prescription to start reminders." body={
filter === "all"
? "Scan or upload a prescription to start reminders."
: "Try another status filter."
}
actionTitle="Scan prescription" actionTitle="Scan prescription"
onAction={() => router.push(routes.rxScan)} onAction={() => router.push(routes.rxScan)}
/> />
) : ( ) : (
<View style={{ gap: 12 }}> <View style={{ gap: 12 }}>
{prescriptions.map((rx) => ( {visible.map((rx) => (
<RxCareCard <RxCareCard
key={rx.id} key={rx.id}
rx={rx} rx={rx}
@ -257,56 +159,10 @@ export default function PrescriptionsTabScreen() {
</Section> </Section>
<Section title={t("common.family")}> <Section title={t("common.family")}>
<Card tone="brand" elevated={false}> <FamilyInviteCard
<View style={{ flexDirection: "row", gap: 12, alignItems: "center" }}> inviteCode={inviteCode}
<View onInvite={() => router.push(routes.familyInvite)}
style={{
width: 48,
height: 48,
borderRadius: 14,
backgroundColor: colors.surface,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: colors.brandBorder,
}}
>
<Ionicons name="people" size={22} color={colors.brandText} />
</View>
<View style={{ flex: 1 }}>
<Text variant="caption" color={colors.muted}>
Family invite code
</Text>
<Text variant="h2" style={{ marginTop: 2, letterSpacing: 1 }}>
{inviteCode}
</Text>
</View>
</View>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 10 }}>
Share care so family can help with dose reminders and refills.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
<Button
title="Share code"
size="md"
fullWidth={false}
variant="outline"
icon="share-outline"
onPress={() =>
Share.share({
message: `Join my Gishen family care group with code ${inviteCode}`,
})
}
/> />
<Button
title={t("common.inviteFamily")}
size="md"
fullWidth={false}
icon="person-add-outline"
onPress={() => router.push(routes.familyInvite)}
/>
</View>
</Card>
{members.length === 0 ? ( {members.length === 0 ? (
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}> <Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>

View File

@ -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 (
<View
style={{
flexDirection: "row",
gap: 10,
padding: 14,
borderRadius: radii.md,
backgroundColor: colors.warnSoft,
borderWidth: 1,
borderColor: "#F0D7A8",
}}
>
<Ionicons name="alert-circle" size={20} color={colors.warn} />
<View style={{ flex: 1 }}>
<Text variant="label" color={colors.warn}>
{title}
</Text>
<Text variant="caption" color={colors.muted} style={{ marginTop: 4 }}>
{body}
</Text>
</View>
</View>
);
}

View File

@ -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 (
<Card tone="brand" elevated={false}>
<View style={{ flexDirection: "row", gap: 12, alignItems: "center" }}>
<View
style={{
width: 48,
height: 48,
borderRadius: 14,
backgroundColor: colors.surface,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: colors.brandBorder,
}}
>
<Ionicons name="people" size={22} color={colors.brandText} />
</View>
<View style={{ flex: 1 }}>
<Text variant="caption" color={colors.muted}>
Family invite code
</Text>
<Text variant="h2" style={{ marginTop: 2, letterSpacing: 1 }}>
{inviteCode}
</Text>
</View>
</View>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 10 }}>
Share care so family can help with dose reminders and refills.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
<Button
title="Share code"
size="md"
fullWidth={false}
variant="outline"
icon="share-outline"
onPress={() =>
Share.share({
message: `Join my Gishen family care group with code ${inviteCode}`,
})
}
/>
<Button
title="Invite family"
size="md"
fullWidth={false}
icon="person-add-outline"
onPress={onInvite}
/>
</View>
</Card>
);
}

2
components/care/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { FamilyInviteCard } from "./FamilyInviteCard";
export { EscalationBanner } from "./EscalationBanner";

View File

@ -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 (
<Card tone="brand" elevated={false} style={{ marginBottom: 12 }}>
<Text variant="h3">{title}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
{body}
</Text>
</Card>
);
}

View File

@ -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 (
<Card tone="brand" elevated={false}>
<Text variant="h3">Corporate split · {orgName}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
Plan {planName} · {formatETB(remainingAllowance)} left this month
</Text>
<Text variant="bodySm" style={{ marginTop: 8 }}>
Covered: {formatETB(covered)}
</Text>
<Text variant="bodySm">You pay: {formatETB(selfPay)}</Text>
</Card>
);
}

View File

@ -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 (
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}>
Voucher & points
</Text>
<Field
label="Code"
value={voucher}
onChangeText={onChangeVoucher}
placeholder="GISHEN10"
icon="pricetag-outline"
hint={discount ? `Saving ${formatETB(discount)}` : "Try GISHEN10 for 10% off"}
/>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
<Chip
label="GISHEN10"
onPress={onApplyDemo}
selected={voucher.toUpperCase() === "GISHEN10"}
/>
{activeVoucher ? (
<Chip
label={`Loyalty: ${activeVoucher}`}
onPress={onApplyLoyalty}
selected={voucher === activeVoucher}
/>
) : null}
</View>
{!activeVoucher ? (
<View
style={{
marginTop: 14,
padding: 12,
borderRadius: 14,
backgroundColor: colors.brandWash,
borderWidth: 1,
borderColor: colors.brandBorder,
gap: 10,
}}
>
<Text variant="bodySm" color={colors.muted}>
You have {points} pts. Redeem 100 pts for a GISHEN10 voucher at checkout.
</Text>
<Button
title="Redeem 100 pts"
size="md"
variant="soft"
disabled={!canRedeem}
onPress={onRedeemPoints}
/>
</View>
) : null}
</Card>
);
}

View File

@ -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 (
<Card elevated={false} padded={false}>
<View style={{ padding: 16, paddingBottom: 8 }}>
<Text variant="h3">{title}</Text>
{footnote ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{footnote}
</Text>
) : null}
</View>
{methods.map((m, i) => (
<OptionRow
key={m}
title={META[m].title}
subtitle={META[m].hint}
selected={selected === m}
onPress={() => onSelect(m)}
icon={META[m].icon}
last={i === methods.length - 1}
/>
))}
</Card>
);
}
export function paymentLabel(m: PaymentMethod) {
return META[m]?.title ?? m;
}

View File

@ -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 (
<Card tone="wash" elevated={false} style={{ borderColor: colors.warn }}>
<Text variant="h3">Prescription required</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
These items need an approved or submitted Rx before checkout:{" "}
{rxItemNames.slice(0, 3).join(", ")}
{rxItemNames.length > 3 ? ` +${rxItemNames.length - 3} more` : ""}.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
<Button
title="Upload Rx"
size="md"
fullWidth={false}
icon="document-attach-outline"
onPress={() => router.push(routes.rxScan)}
/>
<Button
title="My prescriptions"
size="md"
fullWidth={false}
variant="outline"
onPress={() => router.push(routes.rxList)}
/>
</View>
</Card>
);
}

View File

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

View File

@ -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 (
<Card elevated style={{ padding: 0, overflow: "hidden" }}>
<Pressable onPress={() => router.push(routes.rxDetail(rx.id))} style={{ padding: 14 }}>
<View style={{ flexDirection: "row", gap: 12 }}>
<View
style={{
width: 64,
height: 64,
borderRadius: 16,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
{thumb ? (
<Image source={{ uri: thumb }} style={{ width: 64, height: 64 }} />
) : (
<Ionicons name="medkit" size={26} color={colors.brandText} />
)}
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
<Text variant="h3" style={{ flex: 1 }} numberOfLines={2}>
{rx.medicineName}
</Text>
<Badge label={rx.status.replace("_", " ")} tone={statusTone[rx.status]} />
</View>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{rx.patientName} · {rx.medicines.length} medicine
{rx.medicines.length === 1 ? "" : "s"} · {rx.daysSupply}d supply
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
{reminder?.enabled ? (
<Badge
label={`Reminders ${reminder.times.join(" · ")}`}
tone="brand"
icon="alarm-outline"
/>
) : (
<Badge label="No dose reminder" tone="warn" icon="alarm-outline" />
)}
{dueIn !== null ? (
<Badge
label={dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn}d`}
tone={dueIn <= 3 ? "warn" : "neutral"}
/>
) : (
<Badge label="Awaiting fill" tone="info" />
)}
</View>
</View>
</View>
</Pressable>
<View style={{ flexDirection: "row", borderTopWidth: 1, borderTopColor: colors.line }}>
<Pressable
onPress={onToggleExpand}
style={{
flex: 1,
paddingVertical: 12,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 6,
}}
>
<Ionicons
name={expanded ? "chevron-up" : "notifications-outline"}
size={16}
color={colors.brandText}
/>
<Text variant="label" color={colors.brandText}>
{expanded ? "Hide setup" : reminder?.enabled ? "Edit reminder" : "Set reminder"}
</Text>
</Pressable>
<View style={{ width: 1, backgroundColor: colors.line }} />
<Pressable
onPress={() => router.push(routes.rxCalendar(rx.id))}
style={{
flex: 1,
paddingVertical: 12,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 6,
}}
>
<Ionicons name="calendar-outline" size={16} color={colors.brandText} />
<Text variant="label" color={colors.brandText}>
Calendar
</Text>
</Pressable>
</View>
{expanded ? (
<View style={{ paddingHorizontal: 14, paddingBottom: 14 }}>
<DoseReminderPanel rx={rx} reminder={reminder} compact />
</View>
) : null}
</Card>
);
}

View File

@ -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 (
<View
style={{
height: size,
borderRadius: radii.md,
backgroundColor: colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: colors.line,
}}
>
<Text variant="caption" color={colors.muted}>
No pages attached
</Text>
</View>
);
}
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
{uris.map((uri, index) => (
<Pressable key={`${uri}-${index}`} onLongPress={() => onLongPress?.(uri, index)}>
<Image
source={{ uri }}
style={{
width: size,
height: size,
borderRadius: radii.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.line,
}}
/>
<Text
variant="micro"
color={colors.subtle}
style={{ position: "absolute", left: 6, bottom: 6 }}
>
{index + 1}/{uris.length}
</Text>
</Pressable>
))}
</ScrollView>
);
}

View File

@ -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 (
<Card elevated={false} padded={false}>
{medicines.map((m, i) => (
<View
key={`${m.name}-${i}`}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: i === medicines.length - 1 ? 0 : 1,
borderBottomColor: colors.line,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="medkit-outline" size={16} color={colors.brandText} />
</View>
<Text variant="h3" style={{ flex: 1 }}>
{m.name}
</Text>
</View>
<Badge label={m.status} tone={lineTone[m.status]} />
</View>
))}
</Card>
);
}

View File

@ -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 (
<Card tone="brand" elevated={false}>
<Text variant="h3">Pharmacist needs clarification</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
{pharmacistMessage ||
"Please confirm dosage, patient name, or upload a clearer page."}
</Text>
{customerReply ? (
<View
style={{
marginTop: 12,
padding: 12,
borderRadius: 12,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.line,
}}
>
<Text variant="caption" color={colors.subtle}>
Your reply
</Text>
<Text variant="bodySm" style={{ marginTop: 4 }}>
{customerReply}
</Text>
</View>
) : (
<View style={{ marginTop: 12, gap: 10 }}>
<Field
label="Your reply"
value={reply}
onChangeText={setReply}
placeholder="Clarify dose, brand preference, or re-upload note…"
/>
<Button
title="Send reply"
onPress={() => {
if (!reply.trim()) return;
onReply(reply.trim());
}}
/>
</View>
)}
</Card>
);
}

View File

@ -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<Record<RxStatus, { label: string; tone: "warn" | "danger" | "info" }>> = {
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 (
<View style={{ gap: 12 }}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
{STEPS.map((step, i) => {
const done = i <= active && !branch;
const current = i === active;
const muted = i > active;
return (
<View key={step.key} style={{ flex: 1, flexDirection: "row", alignItems: "center" }}>
<View style={{ alignItems: "center", flex: 1 }}>
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: done || current ? colors.brand : colors.surfaceAlt,
borderWidth: 2,
borderColor:
branch && current
? colors.info
: done || current
? colors.brandDeep
: colors.line,
alignItems: "center",
justifyContent: "center",
}}
>
{(done && !current) || status === "filled" && i <= active ? (
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.ink,
}}
/>
) : null}
</View>
<Text
variant="micro"
color={muted ? colors.subtle : colors.ink}
style={{ marginTop: 6, textAlign: "center" }}
>
{step.label}
</Text>
</View>
{i < STEPS.length - 1 ? (
<View
style={{
position: "absolute",
right: -2,
top: 10,
width: "30%",
height: 2,
backgroundColor: i < active ? colors.brand : colors.line,
marginLeft: "70%",
}}
/>
) : null}
</View>
);
})}
</View>
{branch ? (
<View
style={{
alignSelf: "flex-start",
backgroundColor: branch.tone === "danger" ? colors.dangerSoft : colors.infoSoft,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: radii.sm,
}}
>
<Text
variant="label"
color={branch.tone === "danger" ? colors.danger : colors.info}
>
Pharmacist: {branch.label}
</Text>
</View>
) : null}
</View>
);
}

View File

@ -0,0 +1,5 @@
export { RxCareCard } from "./RxCareCard";
export { RxStatusStepper } from "./RxStatusStepper";
export { RxMedicineLines } from "./RxMedicineLines";
export { RxImagePager } from "./RxImagePager";
export { RxQueriedPanel } from "./RxQueriedPanel";

View File

@ -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 (
<View>
<SwitchRow
title="Biometric unlock"
subtitle="Use Face ID / fingerprint when opening Gishen"
value={enabled}
onValueChange={async (v) => {
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.");
}
}}
/>
<Text variant="caption" color={colors.subtle} style={{ marginTop: -4, marginBottom: 4 }}>
Requires a signed-in account (not guest).
</Text>
</View>
);
}

View File

@ -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 (
<Pressable onPress={() => router.push(routes.loyalty)}>
<Card tone="brand" elevated={false} style={{ marginTop: 12 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<View>
<Text variant="caption" color={colors.muted}>
Loyalty wallet
</Text>
<Text variant="h2" style={{ marginTop: 2 }}>
{points} pts
</Text>
</View>
<Badge label={tier} tone="brand" />
</View>
<View
style={{
marginTop: 12,
height: 8,
borderRadius: 999,
backgroundColor: colors.surface,
overflow: "hidden",
}}
>
<View
style={{
width: `${Math.round(progress * 100)}%`,
height: "100%",
backgroundColor: colors.brand,
}}
/>
</View>
<Text variant="caption" color={colors.muted} style={{ marginTop: 8 }}>
{tier === "gold"
? "You’re at Gold — keep redeeming rewards"
: `${next.at - points} pts to ${next.label}`}
</Text>
</Card>
</Pressable>
);
}

View File

@ -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 (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, paddingVertical: 2 }}
style={{ marginTop: 14 }}
>
{ACTIONS.map((a) => (
<Pressable
key={a.label}
onPress={() => 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,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name={a.icon} size={18} color={colors.brandText} />
</View>
<Text variant="micro">{a.label}</Text>
</Pressable>
))}
</ScrollView>
);
}

View File

@ -0,0 +1,3 @@
export { LoyaltyStrip } from "./LoyaltyStrip";
export { QuickActions } from "./QuickActions";
export { BiometricRow } from "./BiometricRow";

View File

@ -86,9 +86,13 @@ Guests (and optionally signed-in pickup) receive a short **pharmacist reference
## Payments ## Payments
| Method | Path | Status | Notes | | 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 | | 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) ## Prescriptions (Rx Care)
| Method | Path | Status | Notes | | Method | Path | Status | Notes |
@ -160,6 +164,8 @@ Distinct from order list. Home bell → inbox.
| POST | `/notifications/read-all` | Required | | | POST | `/notifications/read-all` | Required | |
| DELETE | `/notifications/:id` | Required | | | DELETE | `/notifications/:id` | Required | |
| POST | `/devices` | Required | push token registration | | POST | `/devices` | Required | push token registration |
| GET | `/notifications/prefs` | Required | `{ sms, email, push, orderUpdates, refillReminders, marketing }` |
| PUT | `/notifications/prefs` | Required | Same shape |
## Push / Tracking ## Push / Tracking
| Method | Path | Status | Notes | | 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: 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: 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-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`.

View File

@ -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 = { export const BASE_URLS = {
core: process.env.EXPO_PUBLIC_API_URL ?? "https://api.gishen.example/v1", core: process.env.EXPO_PUBLIC_API_URL ?? "https://api.gishen.example/v1",
} as const; } as const;
export const USE_MOCKS = !process.env.EXPO_PUBLIC_API_URL;
export type ApiError = { message: string; code?: string }; 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<PaymentIntent> {
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<PaymentIntent>("/payments/intent", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function callEndpoint<T>( export async function callEndpoint<T>(
path: string, path: string,
init?: RequestInit & { locale?: string } init?: RequestInit & { locale?: string }
@ -17,9 +53,8 @@ export async function callEndpoint<T>(
...(init?.headers as Record<string, string>), ...(init?.headers as Record<string, string>),
}; };
// Mock mode until backend exists if (USE_MOCKS) {
if (!process.env.EXPO_PUBLIC_API_URL) { throw new Error(`No mock for ${path} — pass EXPO_PUBLIC_API_URL or add an adapter`);
throw new Error(`API not configured for ${path}`);
} }
const res = await fetch(`${BASE_URLS.core}${path}`, { const res = await fetch(`${BASE_URLS.core}${path}`, {
@ -32,3 +67,7 @@ export async function callEndpoint<T>(
} }
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}

View File

@ -102,6 +102,26 @@ export const seedPrescriptions: Prescription[] = [
notes: "Pharmacist verifying dosage for pediatric use.", notes: "Pharmacist verifying dosage for pediatric use.",
createdAt: daysAgo(0), 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[] = [ export const seedReminders: Reminder[] = [

View File

@ -36,6 +36,10 @@ export const routes = {
about: "/(root)/(screens)/about", about: "/(root)/(screens)/about",
contact: "/(root)/(screens)/contact", contact: "/(root)/(screens)/contact",
addresses: "/(root)/(screens)/addresses", 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; } as const;
export type Category = { export type Category = {

View File

@ -5,8 +5,11 @@ import { createJSONStorage, persist } from "zustand/middleware";
export type Fulfillment = "delivery" | "pickup"; export type Fulfillment = "delivery" | "pickup";
export type PaymentMethod = export type PaymentMethod =
| "chapa" | "chapa"
| "arifpay"
| "telebirr" | "telebirr"
| "mpesa" | "mpesa"
| "bank"
| "cod"
| "pay_at_branch"; | "pay_at_branch";
type LocationState = { type LocationState = {
@ -35,9 +38,19 @@ export const useLocationStore = create<LocationState>()(
paymentPreference: "chapa", paymentPreference: "chapa",
setFulfillment: (fulfillment) => { setFulfillment: (fulfillment) => {
const next: Partial<LocationState> = { fulfillment }; const next: Partial<LocationState> = { fulfillment };
if (fulfillment === "delivery" && get().paymentPreference === "pay_at_branch") { 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"; next.paymentPreference = "chapa";
} }
}
if (fulfillment === "pickup" && get().paymentPreference === "cod") {
next.paymentPreference = "pay_at_branch";
}
set(next); set(next);
}, },
setAreaLabel: (areaLabel) => set({ areaLabel }), setAreaLabel: (areaLabel) => set({ areaLabel }),
@ -45,15 +58,22 @@ export const useLocationStore = create<LocationState>()(
setCoords: (lat, lng) => set({ lat, lng }), setCoords: (lat, lng) => set({ lat, lng }),
setPaymentPreference: (paymentPreference) => set({ paymentPreference }), setPaymentPreference: (paymentPreference) => set({ paymentPreference }),
allowedPayments: () => { allowedPayments: () => {
const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"]; const online: PaymentMethod[] = [
"chapa",
"arifpay",
"telebirr",
"mpesa",
"bank",
];
return get().fulfillment === "pickup" return get().fulfillment === "pickup"
? [...base, "pay_at_branch"] ? [...online, "pay_at_branch"]
: base; : [...online, "cod"];
}, },
}), }),
{ {
name: "gishen-location", name: "gishen-location",
storage: createJSONStorage(() => AsyncStorage), storage: createJSONStorage(() => AsyncStorage),
version: 2,
} }
) )
); );

40
lib/stores/prefsStore.ts Normal file
View File

@ -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: <K extends keyof NotificationPrefs>(key: K, value: NotificationPrefs[K]) => void;
setMany: (patch: Partial<NotificationPrefs>) => void;
};
const defaults: NotificationPrefs = {
sms: true,
email: true,
push: true,
orderUpdates: true,
refillReminders: true,
marketing: false,
};
export const usePrefsStore = create<PrefsState>()(
persist(
(set) => ({
...defaults,
setPref: (key, value) => set({ [key]: value } as Partial<PrefsState>),
setMany: (patch) => set(patch),
}),
{
name: "gishen-prefs",
storage: createJSONStorage(() => AsyncStorage),
}
)
);

View File

@ -28,6 +28,10 @@ export type Prescription = {
lastFilledAt: string | null; lastFilledAt: string | null;
status: RxStatus; status: RxStatus;
notes?: string; notes?: string;
/** Message from pharmacist when status is queried / rejected */
pharmacistMessage?: string;
/** Customer reply to a query */
customerReply?: string;
createdAt: string; createdAt: string;
}; };
@ -60,7 +64,8 @@ type RxState = {
addPrescription: ( addPrescription: (
p: Omit<Prescription, "id" | "createdAt" | "status"> p: Omit<Prescription, "id" | "createdAt" | "status">
) => Prescription; ) => 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; markRefilled: (id: string) => void;
removePrescription: (id: string) => void; removePrescription: (id: string) => void;
upsertReminder: (r: Omit<Reminder, "id"> & { id?: string }) => void; upsertReminder: (r: Omit<Reminder, "id"> & { id?: string }) => void;
@ -88,10 +93,31 @@ export const usePrescriptionStore = create<RxState>()(
set({ prescriptions: [rx, ...get().prescriptions] }); set({ prescriptions: [rx, ...get().prescriptions] });
return rx; return rx;
}, },
updateRxStatus: (id, status) => updateRxStatus: (id, status, pharmacistMessage) =>
set({ set({
prescriptions: get().prescriptions.map((p) => 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) => markRefilled: (id) =>

View File

@ -71,6 +71,12 @@
"addresses": "አድራሻዎች", "addresses": "አድራሻዎች",
"rewards": "ሽልማቶች", "rewards": "ሽልማቶች",
"referral": "የማጣቀሻ ኮድ", "referral": "የማጣቀሻ ኮድ",
"arifpay": "ArifPay",
"bankTransfer": "ባንክ ማስተላለፍ",
"cashOnDelivery": "በአገልግሎት ክፍያ",
"paymentMethods": "የክፍያ ዘዴዎች",
"notificationPrefs": "የማሳወቂያ ምርጫዎች",
"assistedCode": "የፋርማሲስት ትዕዛዝ ኮድ",
"inviteFamily": "ቤተሰብ ጋብዝ", "inviteFamily": "ቤተሰብ ጋብዝ",
"manageSubs": "ደንበኝነት አስተዳድር", "manageSubs": "ደንበኝነት አስተዳድር",
"scanRx": "የሐኪም ትዕዛዝ ቅኝት", "scanRx": "የሐኪም ትዕዛዝ ቅኝት",

View File

@ -71,6 +71,12 @@
"addresses": "Addresses", "addresses": "Addresses",
"rewards": "Rewards", "rewards": "Rewards",
"referral": "Referral code", "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", "inviteFamily": "Invite family",
"manageSubs": "Manage subscriptions", "manageSubs": "Manage subscriptions",
"scanRx": "Scan prescription", "scanRx": "Scan prescription",