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:
parent
858972b5ca
commit
d40cb22ff4
59
app/(root)/(screens)/checkout-success.tsx
Normal file
59
app/(root)/(screens)/checkout-success.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,110 +173,101 @@ 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;
|
||||||
|
|
||||||
const order = addOrder({
|
setPaying(true);
|
||||||
items,
|
try {
|
||||||
subtotal,
|
if (!assisted && payMethod !== "pay_at_branch" && payMethod !== "cod") {
|
||||||
deliveryFee: fee,
|
const intent = await initiatePayment({
|
||||||
discount,
|
provider: payMethod,
|
||||||
voucherCode: discount ? voucher.trim().toUpperCase() : null,
|
amountEtb: afterDiscount + fee,
|
||||||
total: afterDiscount + fee,
|
orderId: `pending_${Date.now()}`,
|
||||||
fulfillment: effectiveFulfillment,
|
});
|
||||||
branchId: useAnyBranchPickup ? null : branchId,
|
if (intent.status === "requires_action" && intent.checkoutUrl) {
|
||||||
areaLabel: useAnyBranchPickup
|
await Linking.openURL(intent.checkoutUrl).catch(() => undefined);
|
||||||
? "Any Gishen branch"
|
}
|
||||||
: effectiveFulfillment === "pickup"
|
}
|
||||||
? branch?.name ?? areaLabel
|
|
||||||
: selectedAddress?.area ?? areaLabel,
|
|
||||||
addressId:
|
|
||||||
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
|
||||||
addressLine: useAnyBranchPickup ? undefined : addressLine,
|
|
||||||
paymentMethod: payment,
|
|
||||||
corporateCovered: split.covered,
|
|
||||||
selfPay: split.selfPay + fee,
|
|
||||||
customerName: name.trim(),
|
|
||||||
customerPhone: phone.trim(),
|
|
||||||
notes: orderNotes,
|
|
||||||
riderAssigned: false,
|
|
||||||
pickupCode,
|
|
||||||
anyBranch: useAnyBranchPickup || isGuest,
|
|
||||||
isGuestOrder: isGuest,
|
|
||||||
status: isGuest
|
|
||||||
? "requested"
|
|
||||||
: useAnyBranchPickup || payment === "pay_at_branch"
|
|
||||||
? "confirmed"
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (forMemberId) setActiveMember(forMemberId);
|
const order = addOrder({
|
||||||
|
items,
|
||||||
|
subtotal,
|
||||||
|
deliveryFee: fee,
|
||||||
|
discount,
|
||||||
|
voucherCode: discount ? voucher.trim().toUpperCase() : null,
|
||||||
|
total: afterDiscount + fee,
|
||||||
|
fulfillment: effectiveFulfillment,
|
||||||
|
branchId: useAnyBranchPickup ? null : branchId,
|
||||||
|
areaLabel: useAnyBranchPickup
|
||||||
|
? "Any Gishen branch"
|
||||||
|
: effectiveFulfillment === "pickup"
|
||||||
|
? branch?.name ?? areaLabel
|
||||||
|
: selectedAddress?.area ?? areaLabel,
|
||||||
|
addressId:
|
||||||
|
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
||||||
|
addressLine: useAnyBranchPickup ? undefined : addressLine,
|
||||||
|
paymentMethod: payMethod,
|
||||||
|
corporateCovered: split.covered,
|
||||||
|
selfPay: split.selfPay + fee,
|
||||||
|
customerName: name.trim(),
|
||||||
|
customerPhone: phone.trim(),
|
||||||
|
notes: orderNotes,
|
||||||
|
riderAssigned: false,
|
||||||
|
pickupCode,
|
||||||
|
anyBranch: useAnyBranchPickup || assisted,
|
||||||
|
isGuestOrder: assisted,
|
||||||
|
status: assisted
|
||||||
|
? "requested"
|
||||||
|
: payMethod === "pay_at_branch" || payMethod === "cod" || payMethod === "bank"
|
||||||
|
? "confirmed"
|
||||||
|
: "confirmed",
|
||||||
|
});
|
||||||
|
|
||||||
if (!isGuest && split.covered > 0) consumeAllowance(split.covered);
|
if (forMemberId) setActiveMember(forMemberId);
|
||||||
if (!isGuest) addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
|
if (!assisted && split.covered > 0) consumeAllowance(split.covered);
|
||||||
if (discount) {
|
if (!assisted && !isGuest) {
|
||||||
setActiveVoucher(null);
|
addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
|
||||||
setCartVoucher(null);
|
}
|
||||||
|
if (discount) {
|
||||||
|
setActiveVoucher(null);
|
||||||
|
setCartVoucher(null);
|
||||||
|
}
|
||||||
|
clear();
|
||||||
|
|
||||||
|
pushNotification({
|
||||||
|
kind: "order",
|
||||||
|
title: assisted
|
||||||
|
? `Pharmacist code ${pickupCode}`
|
||||||
|
: pickupCode
|
||||||
|
? `Pickup code ${pickupCode}`
|
||||||
|
: "Order placed",
|
||||||
|
body: assisted
|
||||||
|
? `Share ${pickupCode} so a pharmacist can create your order in admin.`
|
||||||
|
: pickupCode
|
||||||
|
? useAnyBranchPickup
|
||||||
|
? `Show ${pickupCode} at any Gishen branch counter.`
|
||||||
|
: `Show ${pickupCode} at ${branch?.name ?? "your branch"}.`
|
||||||
|
: `Your ${order.fulfillment} order is confirmed.`,
|
||||||
|
href: assisted && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (assisted && pickupCode) {
|
||||||
|
router.replace(routes.orderCode(order.id) as never);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(routes.checkoutSuccess(order.id) as never);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert("Payment failed", e instanceof Error ? e.message : "Try again.");
|
||||||
|
} finally {
|
||||||
|
setPaying(false);
|
||||||
}
|
}
|
||||||
clear();
|
|
||||||
|
|
||||||
pushNotification({
|
|
||||||
kind: "order",
|
|
||||||
title: isGuest
|
|
||||||
? `Pharmacist code ${pickupCode}`
|
|
||||||
: pickupCode
|
|
||||||
? `Pickup code ${pickupCode}`
|
|
||||||
: "Order placed",
|
|
||||||
body: isGuest
|
|
||||||
? `Share ${pickupCode} so a pharmacist can create your order in admin.`
|
|
||||||
: pickupCode
|
|
||||||
? useAnyBranchPickup
|
|
||||||
? `Show ${pickupCode} at any Gishen branch counter to collect your order.`
|
|
||||||
: `Show ${pickupCode} at ${branch?.name ?? "your branch"} to collect.`
|
|
||||||
: `Your ${order.fulfillment} order is confirmed.`,
|
|
||||||
href: isGuest && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isGuest && pickupCode) {
|
|
||||||
router.replace(routes.orderCode(order.id) as never);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
pickupCode ? "Quick pickup code ready" : "Order placed",
|
|
||||||
pickupCode
|
|
||||||
? `Your code is ${pickupCode}. ${
|
|
||||||
useAnyBranchPickup
|
|
||||||
? "Present it at any Gishen branch for fulfillment."
|
|
||||||
: `Present it at ${branch?.name ?? "the branch"}.`
|
|
||||||
}${
|
|
||||||
payment === "pay_at_branch"
|
|
||||||
? ` Pay ${formatETB(order.selfPay ?? order.total)} at the counter.`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
: payment === "pay_at_branch"
|
|
||||||
? `Pay ${formatETB(order.selfPay ?? order.total)} at ${branch?.name ?? "the branch"}.`
|
|
||||||
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
|
|
||||||
[
|
|
||||||
{ text: "View order", onPress: () => router.replace(routes.orderDetail(order.id)) },
|
|
||||||
{
|
|
||||||
text: pickupCode ? "Done" : "Track",
|
|
||||||
onPress: () =>
|
|
||||||
pickupCode
|
|
||||||
? router.replace(routes.orderDetail(order.id))
|
|
||||||
: router.replace(routes.tracking(order.id)),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const whatsapp = () => {
|
const 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 }}>
|
) : null}
|
||||||
<Chip label="GISHEN10" onPress={() => setVoucher("GISHEN10")} selected={voucher.toUpperCase() === "GISHEN10"} />
|
|
||||||
{user?.activeVoucher ? (
|
|
||||||
<Chip
|
|
||||||
label={`Loyalty: ${user.activeVoucher}`}
|
|
||||||
onPress={() => setVoucher(user.activeVoucher!)}
|
|
||||||
selected={voucher === user.activeVoucher}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{mode === "corporate" && membership ? (
|
{!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";
|
|
||||||
}
|
|
||||||
|
|
|
||||||
82
app/(root)/(screens)/notification-prefs.tsx
Normal file
82
app/(root)/(screens)/notification-prefs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
app/(root)/(screens)/payment-methods.tsx
Normal file
58
app/(root)/(screens)/payment-methods.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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={() => {
|
||||||
const match = products.find((p) =>
|
rx.medicines.forEach((m) => {
|
||||||
rx.medicineName.toLowerCase().includes(p.name.toLowerCase().split(" ")[0])
|
const match = products.find((p) =>
|
||||||
);
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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,41 +93,68 @@ 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
|
||||||
{draftImages.length ? (
|
contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
|
>
|
||||||
{draftImages.map((uri) => (
|
<Card elevated={false}>
|
||||||
<Image
|
<Text variant="label" color={colors.muted} style={{ marginBottom: 12 }}>
|
||||||
key={uri}
|
After submit
|
||||||
source={{ uri }}
|
</Text>
|
||||||
style={{ width: 96, height: 96, borderRadius: radii.md, backgroundColor: colors.surfaceAlt }}
|
<RxStatusStepper status="submitted" />
|
||||||
/>
|
</Card>
|
||||||
))}
|
|
||||||
</ScrollView>
|
<Card elevated={false}>
|
||||||
) : (
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||||
<Card tone="brand" elevated={false}>
|
Prescription pages ({draftImages.length})
|
||||||
<Text variant="h3">No images attached</Text>
|
</Text>
|
||||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
{draftImages.length ? (
|
||||||
Go back to scan or upload a prescription photo.
|
<RxImagePager
|
||||||
</Text>
|
uris={draftImages}
|
||||||
<Button
|
size={104}
|
||||||
title="Scan / upload"
|
onLongPress={(_, index) => {
|
||||||
size="md"
|
Alert.alert("Remove page?", `Page ${index + 1}`, [
|
||||||
style={{ marginTop: 12 }}
|
{ text: "Cancel", style: "cancel" },
|
||||||
onPress={() => router.replace(routes.rxScan)}
|
{
|
||||||
|
text: "Remove",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () =>
|
||||||
|
setDraftImages(draftImages.filter((_, i) => i !== index)),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
) : (
|
||||||
)}
|
<>
|
||||||
|
<Text variant="bodySm" color={colors.muted}>
|
||||||
|
Go back to scan or upload a prescription photo.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Scan / upload"
|
||||||
|
size="md"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
onPress={() => router.replace(routes.rxScan)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card elevated={false}>
|
<Card elevated={false}>
|
||||||
<Text variant="h3" style={{ marginBottom: 10 }}>
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||||
|
|
@ -186,28 +216,29 @@ 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
|
||||||
<View
|
: [{ name: medicineName || "—", status: "review" as const }]
|
||||||
key={m.name}
|
).map((m) => (
|
||||||
style={{
|
<View
|
||||||
flexDirection: "row",
|
key={m.name}
|
||||||
justifyContent: "space-between",
|
style={{
|
||||||
paddingVertical: 8,
|
flexDirection: "row",
|
||||||
borderBottomWidth: 1,
|
justifyContent: "space-between",
|
||||||
borderBottomColor: colors.line,
|
paddingVertical: 8,
|
||||||
}}
|
borderBottomWidth: 1,
|
||||||
>
|
borderBottomColor: colors.line,
|
||||||
<Text variant="bodyMedium">{m.name}</Text>
|
}}
|
||||||
<Badge
|
>
|
||||||
label={m.status}
|
<Text variant="bodyMedium">{m.name}</Text>
|
||||||
tone={
|
<Badge
|
||||||
m.status === "ready" ? "brand" : m.status === "review" ? "warn" : "danger"
|
label={m.status}
|
||||||
}
|
tone={
|
||||||
/>
|
m.status === "ready" ? "brand" : m.status === "review" ? "warn" : "danger"
|
||||||
</View>
|
}
|
||||||
)
|
/>
|
||||||
)}
|
</View>
|
||||||
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Button title="Submit for review" onPress={() => save(false)} />
|
<Button title="Submit for review" onPress={() => save(false)} />
|
||||||
|
|
|
||||||
|
|
@ -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}` },
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
36
components/care/EscalationBanner.tsx
Normal file
36
components/care/EscalationBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
components/care/FamilyInviteCard.tsx
Normal file
67
components/care/FamilyInviteCard.tsx
Normal 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
2
components/care/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { FamilyInviteCard } from "./FamilyInviteCard";
|
||||||
|
export { EscalationBanner } from "./EscalationBanner";
|
||||||
20
components/checkout/AssistedOrderExplainer.tsx
Normal file
20
components/checkout/AssistedOrderExplainer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/checkout/EntitlementSplitCard.tsx
Normal file
31
components/checkout/EntitlementSplitCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
components/checkout/LoyaltyRedeemRow.tsx
Normal file
84
components/checkout/LoyaltyRedeemRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
components/checkout/PaymentMethodList.tsx
Normal file
66
components/checkout/PaymentMethodList.tsx
Normal 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;
|
||||||
|
}
|
||||||
41
components/checkout/RxCartGate.tsx
Normal file
41
components/checkout/RxCartGate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/checkout/index.ts
Normal file
5
components/checkout/index.ts
Normal 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";
|
||||||
144
components/prescription/RxCareCard.tsx
Normal file
144
components/prescription/RxCareCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
components/prescription/RxImagePager.tsx
Normal file
60
components/prescription/RxImagePager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
components/prescription/RxMedicineLines.tsx
Normal file
57
components/prescription/RxMedicineLines.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/prescription/RxQueriedPanel.tsx
Normal file
64
components/prescription/RxQueriedPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
components/prescription/RxStatusStepper.tsx
Normal file
112
components/prescription/RxStatusStepper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/prescription/index.ts
Normal file
5
components/prescription/index.ts
Normal 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";
|
||||||
49
components/profile/BiometricRow.tsx
Normal file
49
components/profile/BiometricRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
components/profile/LoyaltyStrip.tsx
Normal file
66
components/profile/LoyaltyStrip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
components/profile/QuickActions.tsx
Normal file
60
components/profile/QuickActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
components/profile/index.ts
Normal file
3
components/profile/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { LoyaltyStrip } from "./LoyaltyStrip";
|
||||||
|
export { QuickActions } from "./QuickActions";
|
||||||
|
export { BiometricRow } from "./BiometricRow";
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[] = [
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,8 +38,18 @@ 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 (
|
||||||
next.paymentPreference = "chapa";
|
fulfillment === "delivery" &&
|
||||||
|
(get().paymentPreference === "pay_at_branch" ||
|
||||||
|
get().paymentPreference === "cod")
|
||||||
|
) {
|
||||||
|
// COD stays valid for delivery; only strip pay_at_branch
|
||||||
|
if (get().paymentPreference === "pay_at_branch") {
|
||||||
|
next.paymentPreference = "chapa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fulfillment === "pickup" && get().paymentPreference === "cod") {
|
||||||
|
next.paymentPreference = "pay_at_branch";
|
||||||
}
|
}
|
||||||
set(next);
|
set(next);
|
||||||
},
|
},
|
||||||
|
|
@ -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
40
lib/stores/prefsStore.ts
Normal 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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@
|
||||||
"addresses": "አድራሻዎች",
|
"addresses": "አድራሻዎች",
|
||||||
"rewards": "ሽልማቶች",
|
"rewards": "ሽልማቶች",
|
||||||
"referral": "የማጣቀሻ ኮድ",
|
"referral": "የማጣቀሻ ኮድ",
|
||||||
|
"arifpay": "ArifPay",
|
||||||
|
"bankTransfer": "ባንክ ማስተላለፍ",
|
||||||
|
"cashOnDelivery": "በአገልግሎት ክፍያ",
|
||||||
|
"paymentMethods": "የክፍያ ዘዴዎች",
|
||||||
|
"notificationPrefs": "የማሳወቂያ ምርጫዎች",
|
||||||
|
"assistedCode": "የፋርማሲስት ትዕዛዝ ኮድ",
|
||||||
"inviteFamily": "ቤተሰብ ጋብዝ",
|
"inviteFamily": "ቤተሰብ ጋብዝ",
|
||||||
"manageSubs": "ደንበኝነት አስተዳድር",
|
"manageSubs": "ደንበኝነት አስተዳድር",
|
||||||
"scanRx": "የሐኪም ትዕዛዝ ቅኝት",
|
"scanRx": "የሐኪም ትዕዛዝ ቅኝት",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user