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 { Alert, Linking, Pressable, ScrollView, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AssistedOrderExplainer,
|
||||
EntitlementSplitCard,
|
||||
LoyaltyRedeemRow,
|
||||
PaymentMethodList,
|
||||
paymentLabel,
|
||||
RxCartGate,
|
||||
} from "@/components/checkout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, InfoRow } from "@/components/ui/Card";
|
||||
import { Chip, Segmented } from "@/components/ui/Chip";
|
||||
import { Field, OptionRow } from "@/components/ui/Field";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { AppHeader } from "@/components/ui/Header";
|
||||
import { Screen } from "@/components/ui/Screen";
|
||||
import { StickyBar } from "@/components/ui/StickyBar";
|
||||
import { Text } from "@/components/ui/Text";
|
||||
import { DELIVERY_FEE, generatePickupCode, orderSubtotalTotal, voucherDiscount } from "@/lib/commerce";
|
||||
import { initiatePayment } from "@/lib/api/client";
|
||||
import {
|
||||
DELIVERY_FEE,
|
||||
generatePickupCode,
|
||||
orderSubtotalTotal,
|
||||
voucherDiscount,
|
||||
} from "@/lib/commerce";
|
||||
import { branches } from "@/lib/data/branches";
|
||||
import { routes } from "@/lib/routes";
|
||||
import { useAddressStore } from "@/lib/stores/addressStore";
|
||||
|
|
@ -20,6 +34,7 @@ import { useFamilyStore } from "@/lib/stores/familyStore";
|
|||
import { useLocationStore, type PaymentMethod } from "@/lib/stores/locationStore";
|
||||
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||||
import { useOrdersStore } from "@/lib/stores/ordersStore";
|
||||
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
||||
import { colors, gutter } from "@/lib/theme";
|
||||
import { formatETB, WHATSAPP_NUMBER } from "@/lib/utils";
|
||||
|
|
@ -39,11 +54,13 @@ export default function CheckoutScreen() {
|
|||
const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
|
||||
const payment = useLocationStore((s) => s.paymentPreference);
|
||||
const setPayment = useLocationStore((s) => s.setPaymentPreference);
|
||||
const allowedPayments = useLocationStore((s) => s.allowedPayments);
|
||||
const addresses = useAddressStore((s) => s.addresses);
|
||||
const defaultAddress = useAddressStore((s) => s.defaultAddress());
|
||||
const addOrder = useOrdersStore((s) => s.addOrder);
|
||||
const pushNotification = useNotificationStore((s) => s.push);
|
||||
const addPoints = useAuthStore((s) => s.addPoints);
|
||||
const redeemPoints = useAuthStore((s) => s.redeemPoints);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setActiveVoucher = useAuthStore((s) => s.setActiveVoucher);
|
||||
const quoteSplit = useProfileModeStore((s) => s.quoteSplit);
|
||||
|
|
@ -53,23 +70,24 @@ export default function CheckoutScreen() {
|
|||
const familyMembers = useFamilyStore((s) => s.members);
|
||||
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
||||
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
||||
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
|
||||
const isGuest = !!user?.isGuest;
|
||||
|
||||
const [name, setName] = useState(user?.name && !user.isGuest ? user.name : "");
|
||||
const [phone, setPhone] = useState(user?.phone ?? "");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [addressId, setAddressId] = useState(defaultAddress?.id ?? "");
|
||||
const [voucher, setVoucher] = useState(
|
||||
cartVoucher || user?.activeVoucher || ""
|
||||
);
|
||||
const [voucher, setVoucher] = useState(cartVoucher || user?.activeVoucher || "");
|
||||
const [forMemberId, setForMemberId] = useState<string | null>(activeMemberId);
|
||||
/** Guests (and optional pickup) can fulfill at any branch with a quick code */
|
||||
const [anyBranch, setAnyBranch] = useState(isGuest);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [assistedMode, setAssistedMode] = useState(isGuest);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGuest) return;
|
||||
if (fulfillment !== "pickup") setFulfillment("pickup");
|
||||
setAnyBranch(true);
|
||||
setAssistedMode(true);
|
||||
if (payment !== "pay_at_branch") setPayment("pay_at_branch");
|
||||
}, [isGuest, fulfillment, payment, setFulfillment, setPayment]);
|
||||
|
||||
|
|
@ -78,16 +96,21 @@ export default function CheckoutScreen() {
|
|||
[addresses, addressId, defaultAddress]
|
||||
);
|
||||
|
||||
const effectiveFulfillment = isGuest ? "pickup" : fulfillment;
|
||||
const effectiveFulfillment = isGuest || assistedMode ? "pickup" : fulfillment;
|
||||
const useAnyBranchPickup =
|
||||
effectiveFulfillment === "pickup" && (anyBranch || isGuest);
|
||||
effectiveFulfillment === "pickup" && (anyBranch || isGuest || assistedMode);
|
||||
|
||||
const allowed = useMemo((): PaymentMethod[] => {
|
||||
const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"];
|
||||
return effectiveFulfillment === "pickup"
|
||||
? [...base, "pay_at_branch"]
|
||||
: base;
|
||||
}, [effectiveFulfillment]);
|
||||
if (assistedMode || isGuest) return ["pay_at_branch"];
|
||||
return allowedPayments();
|
||||
}, [assistedMode, isGuest, allowedPayments, fulfillment]);
|
||||
|
||||
const rxItems = items.filter((i) => i.requiresPrescription);
|
||||
const hasBlockingRx =
|
||||
rxItems.length > 0 &&
|
||||
!prescriptions.some((p) =>
|
||||
["submitted", "under_review", "approved", "filled", "queried"].includes(p.status)
|
||||
);
|
||||
|
||||
const deliveryFee =
|
||||
effectiveFulfillment === "delivery" && items.length ? DELIVERY_FEE : 0;
|
||||
|
|
@ -97,23 +120,29 @@ export default function CheckoutScreen() {
|
|||
mode === "corporate" && !isGuest
|
||||
? quoteSplit(afterDiscount)
|
||||
: { covered: 0, selfPay: afterDiscount };
|
||||
const total = orderSubtotalTotal({
|
||||
const totalDue = orderSubtotalTotal({
|
||||
subtotal: split.selfPay,
|
||||
deliveryFee,
|
||||
discount: 0,
|
||||
});
|
||||
|
||||
const pay = () => {
|
||||
if (!items.length) return;
|
||||
const placeOrder = async (opts: { assisted: boolean }) => {
|
||||
if (!items.length || paying) return;
|
||||
if (!name.trim() || phone.trim().length < 9) {
|
||||
Alert.alert("Contact details", "Enter your name and a valid phone number.");
|
||||
return;
|
||||
}
|
||||
if (!allowed.includes(payment) && !(useAnyBranchPickup && payment === "pay_at_branch")) {
|
||||
if (hasBlockingRx && !opts.assisted) {
|
||||
Alert.alert(
|
||||
"Prescription needed",
|
||||
"Upload a prescription for Rx items before paying online."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!opts.assisted && !allowed.includes(payment)) {
|
||||
Alert.alert("Payment", "Select a valid payment method.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (effectiveFulfillment === "pickup" && !useAnyBranchPickup && !branchId) {
|
||||
Alert.alert("Pickup", "Select a branch, or choose any-branch quick code.");
|
||||
return;
|
||||
|
|
@ -127,13 +156,15 @@ export default function CheckoutScreen() {
|
|||
const addressLine = selectedAddress
|
||||
? `${selectedAddress.street}${selectedAddress.landmark ? ` · ${selectedAddress.landmark}` : ""}`
|
||||
: undefined;
|
||||
|
||||
const forMember = familyMembers.find((m) => m.id === forMemberId);
|
||||
const assisted = opts.assisted || isGuest;
|
||||
const payMethod: PaymentMethod = assisted ? "pay_at_branch" : payment;
|
||||
|
||||
const orderNotes = [
|
||||
notes.trim(),
|
||||
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
|
||||
isGuest
|
||||
? "Pharmacist order code — enter in admin to create the order"
|
||||
assisted
|
||||
? "Pharmacist assisted order code — enter in admin to fulfill"
|
||||
: useAnyBranchPickup
|
||||
? "Quick code — redeemable at any Gishen branch"
|
||||
: "",
|
||||
|
|
@ -142,110 +173,101 @@ export default function CheckoutScreen() {
|
|||
.join(" · ");
|
||||
|
||||
const pickupCode =
|
||||
isGuest || effectiveFulfillment === "pickup"
|
||||
? generatePickupCode()
|
||||
: null;
|
||||
assisted || effectiveFulfillment === "pickup" ? generatePickupCode() : null;
|
||||
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
|
||||
|
||||
const order = addOrder({
|
||||
items,
|
||||
subtotal,
|
||||
deliveryFee: fee,
|
||||
discount,
|
||||
voucherCode: discount ? voucher.trim().toUpperCase() : null,
|
||||
total: afterDiscount + fee,
|
||||
fulfillment: effectiveFulfillment,
|
||||
branchId: useAnyBranchPickup ? null : branchId,
|
||||
areaLabel: useAnyBranchPickup
|
||||
? "Any Gishen branch"
|
||||
: effectiveFulfillment === "pickup"
|
||||
? branch?.name ?? areaLabel
|
||||
: selectedAddress?.area ?? areaLabel,
|
||||
addressId:
|
||||
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
||||
addressLine: useAnyBranchPickup ? undefined : addressLine,
|
||||
paymentMethod: payment,
|
||||
corporateCovered: split.covered,
|
||||
selfPay: split.selfPay + fee,
|
||||
customerName: name.trim(),
|
||||
customerPhone: phone.trim(),
|
||||
notes: orderNotes,
|
||||
riderAssigned: false,
|
||||
pickupCode,
|
||||
anyBranch: useAnyBranchPickup || isGuest,
|
||||
isGuestOrder: isGuest,
|
||||
status: isGuest
|
||||
? "requested"
|
||||
: useAnyBranchPickup || payment === "pay_at_branch"
|
||||
? "confirmed"
|
||||
: undefined,
|
||||
});
|
||||
setPaying(true);
|
||||
try {
|
||||
if (!assisted && payMethod !== "pay_at_branch" && payMethod !== "cod") {
|
||||
const intent = await initiatePayment({
|
||||
provider: payMethod,
|
||||
amountEtb: afterDiscount + fee,
|
||||
orderId: `pending_${Date.now()}`,
|
||||
});
|
||||
if (intent.status === "requires_action" && intent.checkoutUrl) {
|
||||
await Linking.openURL(intent.checkoutUrl).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (forMemberId) setActiveMember(forMemberId);
|
||||
const order = addOrder({
|
||||
items,
|
||||
subtotal,
|
||||
deliveryFee: fee,
|
||||
discount,
|
||||
voucherCode: discount ? voucher.trim().toUpperCase() : null,
|
||||
total: afterDiscount + fee,
|
||||
fulfillment: effectiveFulfillment,
|
||||
branchId: useAnyBranchPickup ? null : branchId,
|
||||
areaLabel: useAnyBranchPickup
|
||||
? "Any Gishen branch"
|
||||
: effectiveFulfillment === "pickup"
|
||||
? branch?.name ?? areaLabel
|
||||
: selectedAddress?.area ?? areaLabel,
|
||||
addressId:
|
||||
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
||||
addressLine: useAnyBranchPickup ? undefined : addressLine,
|
||||
paymentMethod: payMethod,
|
||||
corporateCovered: split.covered,
|
||||
selfPay: split.selfPay + fee,
|
||||
customerName: name.trim(),
|
||||
customerPhone: phone.trim(),
|
||||
notes: orderNotes,
|
||||
riderAssigned: false,
|
||||
pickupCode,
|
||||
anyBranch: useAnyBranchPickup || assisted,
|
||||
isGuestOrder: assisted,
|
||||
status: assisted
|
||||
? "requested"
|
||||
: payMethod === "pay_at_branch" || payMethod === "cod" || payMethod === "bank"
|
||||
? "confirmed"
|
||||
: "confirmed",
|
||||
});
|
||||
|
||||
if (!isGuest && split.covered > 0) consumeAllowance(split.covered);
|
||||
if (!isGuest) addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
|
||||
if (discount) {
|
||||
setActiveVoucher(null);
|
||||
setCartVoucher(null);
|
||||
if (forMemberId) setActiveMember(forMemberId);
|
||||
if (!assisted && split.covered > 0) consumeAllowance(split.covered);
|
||||
if (!assisted && !isGuest) {
|
||||
addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
|
||||
}
|
||||
if (discount) {
|
||||
setActiveVoucher(null);
|
||||
setCartVoucher(null);
|
||||
}
|
||||
clear();
|
||||
|
||||
pushNotification({
|
||||
kind: "order",
|
||||
title: assisted
|
||||
? `Pharmacist code ${pickupCode}`
|
||||
: pickupCode
|
||||
? `Pickup code ${pickupCode}`
|
||||
: "Order placed",
|
||||
body: assisted
|
||||
? `Share ${pickupCode} so a pharmacist can create your order in admin.`
|
||||
: pickupCode
|
||||
? useAnyBranchPickup
|
||||
? `Show ${pickupCode} at any Gishen branch counter.`
|
||||
: `Show ${pickupCode} at ${branch?.name ?? "your branch"}.`
|
||||
: `Your ${order.fulfillment} order is confirmed.`,
|
||||
href: assisted && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id),
|
||||
});
|
||||
|
||||
if (assisted && pickupCode) {
|
||||
router.replace(routes.orderCode(order.id) as never);
|
||||
return;
|
||||
}
|
||||
router.replace(routes.checkoutSuccess(order.id) as never);
|
||||
} catch (e) {
|
||||
Alert.alert("Payment failed", e instanceof Error ? e.message : "Try again.");
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
clear();
|
||||
|
||||
pushNotification({
|
||||
kind: "order",
|
||||
title: isGuest
|
||||
? `Pharmacist code ${pickupCode}`
|
||||
: pickupCode
|
||||
? `Pickup code ${pickupCode}`
|
||||
: "Order placed",
|
||||
body: isGuest
|
||||
? `Share ${pickupCode} so a pharmacist can create your order in admin.`
|
||||
: pickupCode
|
||||
? useAnyBranchPickup
|
||||
? `Show ${pickupCode} at any Gishen branch counter to collect your order.`
|
||||
: `Show ${pickupCode} at ${branch?.name ?? "your branch"} to collect.`
|
||||
: `Your ${order.fulfillment} order is confirmed.`,
|
||||
href: isGuest && pickupCode ? routes.orderCode(order.id) : routes.orderDetail(order.id),
|
||||
});
|
||||
|
||||
if (isGuest && pickupCode) {
|
||||
router.replace(routes.orderCode(order.id) as never);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
pickupCode ? "Quick pickup code ready" : "Order placed",
|
||||
pickupCode
|
||||
? `Your code is ${pickupCode}. ${
|
||||
useAnyBranchPickup
|
||||
? "Present it at any Gishen branch for fulfillment."
|
||||
: `Present it at ${branch?.name ?? "the branch"}.`
|
||||
}${
|
||||
payment === "pay_at_branch"
|
||||
? ` Pay ${formatETB(order.selfPay ?? order.total)} at the counter.`
|
||||
: ""
|
||||
}`
|
||||
: payment === "pay_at_branch"
|
||||
? `Pay ${formatETB(order.selfPay ?? order.total)} at ${branch?.name ?? "the branch"}.`
|
||||
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
|
||||
[
|
||||
{ text: "View order", onPress: () => router.replace(routes.orderDetail(order.id)) },
|
||||
{
|
||||
text: pickupCode ? "Done" : "Track",
|
||||
onPress: () =>
|
||||
pickupCode
|
||||
? router.replace(routes.orderDetail(order.id))
|
||||
: router.replace(routes.tracking(order.id)),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const whatsapp = () => {
|
||||
const lines = items
|
||||
.map((i) => `• ${i.name} x${i.quantity} — ${i.price * i.quantity} ETB`)
|
||||
.join("%0A");
|
||||
const msg = `Gishen order%0AName: ${name}%0APhone: ${phone}%0A${lines}%0ATotal: ${total} ETB%0A${fulfillment}%0A${notes}`;
|
||||
const msg = `Gishen order%0AName: ${name}%0APhone: ${phone}%0A${lines}%0ATotal: ${totalDue} ETB%0A${fulfillment}%0A${notes}`;
|
||||
Linking.openURL(`https://wa.me/${WHATSAPP_NUMBER}?text=${msg}`);
|
||||
};
|
||||
|
||||
|
|
@ -273,10 +295,12 @@ export default function CheckoutScreen() {
|
|||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: gutter,
|
||||
paddingBottom: 140,
|
||||
paddingBottom: 160,
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<RxCartGate rxItemNames={rxItems.map((i) => i.name)} />
|
||||
|
||||
<Card elevated={false}>
|
||||
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||
Contact
|
||||
|
|
@ -328,25 +352,41 @@ export default function CheckoutScreen() {
|
|||
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||
Fulfillment
|
||||
</Text>
|
||||
{isGuest ? (
|
||||
<Card tone="brand" elevated={false} style={{ marginBottom: 12 }}>
|
||||
<Text variant="h3">Pharmacist order code</Text>
|
||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||
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.
|
||||
</Text>
|
||||
</Card>
|
||||
{isGuest || assistedMode ? (
|
||||
<AssistedOrderExplainer
|
||||
title={isGuest ? "Pharmacist order code" : "Assisted order code"}
|
||||
body={
|
||||
isGuest
|
||||
? "You’ll get a GSH-XXXX code. A pharmacist enters it in admin to create your order — pay at the branch."
|
||||
: "Freeze this basket as a GSH-XXXX code for a pharmacist to place offline / at the counter."
|
||||
}
|
||||
/>
|
||||
) : 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}
|
||||
<Segmented
|
||||
value={effectiveFulfillment}
|
||||
onChange={(v) => {
|
||||
if (isGuest && v === "delivery") {
|
||||
if ((isGuest || assistedMode) && v === "delivery") {
|
||||
Alert.alert(
|
||||
"Guest checkout",
|
||||
"Guests use quick-code pickup at any branch. Sign in for delivery & loyalty."
|
||||
"Assisted checkout",
|
||||
"Pharmacist codes are pickup-only. Turn off assisted mode for delivery."
|
||||
);
|
||||
setFulfillment("pickup");
|
||||
setAnyBranch(true);
|
||||
return;
|
||||
}
|
||||
setFulfillment(v);
|
||||
|
|
@ -422,7 +462,7 @@ export default function CheckoutScreen() {
|
|||
>
|
||||
<Text variant="h3">Any branch · quick code</Text>
|
||||
<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>
|
||||
</Pressable>
|
||||
{branches.map((b) => (
|
||||
|
|
@ -454,63 +494,50 @@ export default function CheckoutScreen() {
|
|||
)}
|
||||
</Card>
|
||||
|
||||
<Card elevated={false} padded={false}>
|
||||
<View style={{ padding: 16, paddingBottom: 8 }}>
|
||||
<Text variant="h3">{t("common.payment")}</Text>
|
||||
{effectiveFulfillment === "delivery" ? (
|
||||
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||
Pay at branch is only available for pickup
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{allowed.map((m, i) => (
|
||||
<OptionRow
|
||||
key={m}
|
||||
title={labelPayment(m, t)}
|
||||
subtitle={paymentHint(m)}
|
||||
selected={payment === m}
|
||||
onPress={() => setPayment(m)}
|
||||
icon={paymentIcon(m)}
|
||||
last={i === allowed.length - 1}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card elevated={false}>
|
||||
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||
Voucher
|
||||
</Text>
|
||||
<Field
|
||||
label="Code"
|
||||
value={voucher}
|
||||
onChangeText={setVoucher}
|
||||
placeholder="GISHEN10"
|
||||
icon="pricetag-outline"
|
||||
hint={discount ? `Saving ${formatETB(discount)}` : "Try GISHEN10 for 10% off"}
|
||||
{!assistedMode && !isGuest ? (
|
||||
<PaymentMethodList
|
||||
methods={allowed}
|
||||
selected={payment}
|
||||
onSelect={setPayment}
|
||||
title={t("common.payment")}
|
||||
footnote={
|
||||
effectiveFulfillment === "delivery"
|
||||
? "Pay at branch is only available for pickup"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 10 }}>
|
||||
<Chip label="GISHEN10" onPress={() => setVoucher("GISHEN10")} selected={voucher.toUpperCase() === "GISHEN10"} />
|
||||
{user?.activeVoucher ? (
|
||||
<Chip
|
||||
label={`Loyalty: ${user.activeVoucher}`}
|
||||
onPress={() => setVoucher(user.activeVoucher!)}
|
||||
selected={voucher === user.activeVoucher}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{mode === "corporate" && membership ? (
|
||||
<Card tone="brand" elevated={false}>
|
||||
<Text variant="h3">Corporate split · {membership.orgName}</Text>
|
||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||
Plan {membership.planName} · {formatETB(membership.remainingAllowance)} left this month
|
||||
</Text>
|
||||
<Text variant="bodySm" style={{ marginTop: 8 }}>
|
||||
Covered: {formatETB(split.covered)}
|
||||
</Text>
|
||||
<Text variant="bodySm">You pay: {formatETB(split.selfPay + deliveryFee)}</Text>
|
||||
</Card>
|
||||
{!isGuest ? (
|
||||
<LoyaltyRedeemRow
|
||||
points={user?.points ?? 0}
|
||||
activeVoucher={user?.activeVoucher}
|
||||
voucher={voucher}
|
||||
discount={discount}
|
||||
onChangeVoucher={setVoucher}
|
||||
onApplyDemo={() => setVoucher("GISHEN10")}
|
||||
onApplyLoyalty={() => user?.activeVoucher && setVoucher(user.activeVoucher!)}
|
||||
canRedeem={(user?.points ?? 0) >= 100 && !user?.isGuest}
|
||||
onRedeemPoints={() => {
|
||||
if (redeemPoints(100)) {
|
||||
setVoucher("GISHEN10");
|
||||
setCartVoucher("GISHEN10");
|
||||
Alert.alert("Redeemed", "GISHEN10 applied to this checkout.");
|
||||
} else {
|
||||
Alert.alert("Not enough points", "You need at least 100 points.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mode === "corporate" && membership && !isGuest && !assistedMode ? (
|
||||
<EntitlementSplitCard
|
||||
orgName={membership.orgName}
|
||||
planName={membership.planName}
|
||||
remainingAllowance={membership.remainingAllowance}
|
||||
covered={split.covered}
|
||||
selfPay={split.selfPay + deliveryFee}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card elevated={false}>
|
||||
|
|
@ -527,7 +554,12 @@ export default function CheckoutScreen() {
|
|||
label={effectiveFulfillment === "delivery" ? "Delivery" : "Pickup"}
|
||||
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>
|
||||
|
||||
<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={{ flex: 1 }}>
|
||||
<Text variant="caption" color={colors.subtle}>
|
||||
Total due
|
||||
{assistedMode || isGuest ? "Assisted total" : "Total due"}
|
||||
</Text>
|
||||
<Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text>
|
||||
{!assistedMode && !isGuest ? (
|
||||
<Text variant="micro" color={colors.subtle}>
|
||||
{paymentLabel(payment)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Button
|
||||
title={isGuest ? "Get pharmacist code" : t("common.payNow")}
|
||||
title={
|
||||
paying
|
||||
? "Processing…"
|
||||
: isGuest || assistedMode
|
||||
? "Get pharmacist code"
|
||||
: t("common.payNow")
|
||||
}
|
||||
fullWidth={false}
|
||||
style={{ minWidth: 140 }}
|
||||
onPress={pay}
|
||||
style={{ minWidth: 148 }}
|
||||
loading={paying}
|
||||
disabled={paying || (hasBlockingRx && !assistedMode && !isGuest)}
|
||||
onPress={() => placeOrder({ assisted: isGuest || assistedMode })}
|
||||
/>
|
||||
</View>
|
||||
</StickyBar>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
function labelPayment(m: PaymentMethod, t: (k: string) => string) {
|
||||
if (m === "chapa") return t("common.chapa");
|
||||
if (m === "telebirr") return t("common.telebirr");
|
||||
if (m === "mpesa") return t("common.mpesa");
|
||||
return t("common.payAtBranch");
|
||||
}
|
||||
|
||||
function paymentHint(m: PaymentMethod) {
|
||||
if (m === "chapa") return "Card / mobile money via Chapa";
|
||||
if (m === "telebirr") return "Ethio telecom Telebirr";
|
||||
if (m === "mpesa") return "Safaricom M-Pesa";
|
||||
return "Cash or POS when you collect";
|
||||
}
|
||||
|
||||
function paymentIcon(m: PaymentMethod): keyof typeof import("@expo/vector-icons").Ionicons.glyphMap {
|
||||
if (m === "pay_at_branch") return "storefront-outline";
|
||||
if (m === "telebirr") return "phone-portrait-outline";
|
||||
if (m === "mpesa") return "cash-outline";
|
||||
return "card-outline";
|
||||
}
|
||||
|
|
|
|||
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 { useMemo, useState } from "react";
|
||||
import { Alert, Image, ScrollView, View } from "react-native";
|
||||
import {
|
||||
RxImagePager,
|
||||
RxMedicineLines,
|
||||
RxQueriedPanel,
|
||||
RxStatusStepper,
|
||||
} from "@/components/prescription";
|
||||
import { EscalationBanner } from "@/components/care";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, InfoRow, Section } from "@/components/ui/Card";
|
||||
|
|
@ -11,11 +18,11 @@ import { Screen } from "@/components/ui/Screen";
|
|||
import { Text } from "@/components/ui/Text";
|
||||
import { products } from "@/lib/data/products";
|
||||
import { scheduleDoseReminder } from "@/lib/notifications";
|
||||
import { routes } from "@/lib/routes";
|
||||
import { useCartStore } from "@/lib/stores/cartStore";
|
||||
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||||
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||
import { colors, gutter } from "@/lib/theme";
|
||||
import { routes } from "@/lib/routes";
|
||||
|
||||
const statusTone = {
|
||||
submitted: "neutral",
|
||||
|
|
@ -26,12 +33,6 @@ const statusTone = {
|
|||
filled: "brand",
|
||||
} as const;
|
||||
|
||||
const lineTone = {
|
||||
ready: "brand",
|
||||
review: "warn",
|
||||
unavailable: "danger",
|
||||
} as const;
|
||||
|
||||
export default function PrescriptionDetailScreen() {
|
||||
const params = useLocalSearchParams<{ id: string }>();
|
||||
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||
|
|
@ -50,6 +51,7 @@ export default function PrescriptionDetailScreen() {
|
|||
);
|
||||
const markRefilled = usePrescriptionStore((s) => s.markRefilled);
|
||||
const updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus);
|
||||
const replyToQuery = usePrescriptionStore((s) => s.replyToQuery);
|
||||
const confirmDose = usePrescriptionStore((s) => s.confirmDose);
|
||||
const addProduct = useCartStore((s) => s.addProduct);
|
||||
const pushNotification = useNotificationStore((s) => s.push);
|
||||
|
|
@ -76,6 +78,7 @@ export default function PrescriptionDetailScreen() {
|
|||
})();
|
||||
|
||||
const pendingLog = recentLogs.find((l) => l.status === "pending");
|
||||
const refillSoon = dueIn !== null && dueIn <= 5;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
|
|
@ -91,6 +94,74 @@ export default function PrescriptionDetailScreen() {
|
|||
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" }}>
|
||||
<View
|
||||
style={{
|
||||
|
|
@ -116,12 +187,16 @@ export default function PrescriptionDetailScreen() {
|
|||
For {rx.patientName} · {rx.medicines.length} item
|
||||
{rx.medicines.length === 1 ? "" : "s"} on this Rx
|
||||
</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 }}>
|
||||
<Badge
|
||||
label={`${rx.daysSupply} day supply`}
|
||||
tone="neutral"
|
||||
icon="time-outline"
|
||||
/>
|
||||
<Badge label={`${rx.daysSupply} day supply`} tone="neutral" icon="time-outline" />
|
||||
{dueIn !== null ? (
|
||||
<Badge
|
||||
label={dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
|
||||
|
|
@ -131,11 +206,7 @@ export default function PrescriptionDetailScreen() {
|
|||
<Badge label="Awaiting first fill" tone="warn" />
|
||||
)}
|
||||
{reminder?.enabled ? (
|
||||
<Badge
|
||||
label={reminder.times.join(" · ")}
|
||||
tone="brand"
|
||||
icon="alarm-outline"
|
||||
/>
|
||||
<Badge label={reminder.times.join(" · ")} tone="brand" icon="alarm-outline" />
|
||||
) : (
|
||||
<Badge label="Reminders off" tone="neutral" icon="alarm-outline" />
|
||||
)}
|
||||
|
|
@ -144,6 +215,11 @@ export default function PrescriptionDetailScreen() {
|
|||
</Card>
|
||||
|
||||
<Section title="Dose calendar & push">
|
||||
{reminder?.escalateOnMiss ? (
|
||||
<View style={{ marginBottom: 10 }}>
|
||||
<EscalationBanner />
|
||||
</View>
|
||||
) : null}
|
||||
<Card>
|
||||
{reminder?.enabled && !showSetup ? (
|
||||
<View style={{ gap: 12 }}>
|
||||
|
|
@ -285,41 +361,7 @@ export default function PrescriptionDetailScreen() {
|
|||
</Section>
|
||||
|
||||
<Section title="Medicines on this Rx">
|
||||
<Card elevated={false} padded={false}>
|
||||
{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>
|
||||
<RxMedicineLines medicines={rx.medicines} />
|
||||
</Section>
|
||||
|
||||
<Section title="Details">
|
||||
|
|
@ -347,10 +389,12 @@ export default function PrescriptionDetailScreen() {
|
|||
title="Reorder medicines"
|
||||
icon="bag-add-outline"
|
||||
onPress={() => {
|
||||
const match = products.find((p) =>
|
||||
rx.medicineName.toLowerCase().includes(p.name.toLowerCase().split(" ")[0])
|
||||
);
|
||||
if (match) addProduct(match);
|
||||
rx.medicines.forEach((m) => {
|
||||
const match = products.find((p) =>
|
||||
p.name.toLowerCase().includes(m.name.toLowerCase().split(" ")[0])
|
||||
);
|
||||
if (match) addProduct(match);
|
||||
});
|
||||
router.push(routes.cart);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -369,6 +413,19 @@ export default function PrescriptionDetailScreen() {
|
|||
onPress={() => updateRxStatus(rx.id, "approved")}
|
||||
/>
|
||||
) : null}
|
||||
{rx.status === "submitted" ? (
|
||||
<Button
|
||||
title="Simulate pharmacist query"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
updateRxStatus(
|
||||
rx.id,
|
||||
"queried",
|
||||
"Please confirm the prescribed strength on page 1."
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { router } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Image, ScrollView, View } from "react-native";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import { RxImagePager, RxStatusStepper } from "@/components/prescription";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
|
@ -15,8 +16,9 @@ import { useAuthStore } from "@/lib/stores/authStore";
|
|||
import { useCartStore } from "@/lib/stores/cartStore";
|
||||
import { useFamilyStore } from "@/lib/stores/familyStore";
|
||||
import { useLocationStore } from "@/lib/stores/locationStore";
|
||||
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||||
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||
import { colors, gutter, radii } from "@/lib/theme";
|
||||
import { colors, gutter } from "@/lib/theme";
|
||||
|
||||
function matchStatus(name: string): "ready" | "review" | "unavailable" {
|
||||
const hit = products.find((p) =>
|
||||
|
|
@ -33,6 +35,7 @@ export default function RxReviewScreen() {
|
|||
const addPrescription = usePrescriptionStore((s) => s.addPrescription);
|
||||
const addProduct = useCartStore((s) => s.addProduct);
|
||||
const setFulfillment = useLocationStore((s) => s.setFulfillment);
|
||||
const pushNotification = useNotificationStore((s) => s.push);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const members = useFamilyStore((s) => s.members);
|
||||
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
||||
|
|
@ -90,41 +93,68 @@ export default function RxReviewScreen() {
|
|||
setFulfillment("pickup");
|
||||
}
|
||||
setDraftImages([]);
|
||||
Alert.alert("Saved", "Prescription submitted for pharmacist review", [
|
||||
{ text: "Open details", onPress: () => router.replace(routes.rxDetail(rx.id)) },
|
||||
pushNotification({
|
||||
kind: "rx",
|
||||
title: "Prescription submitted",
|
||||
body: "A pharmacist will review your script shortly.",
|
||||
href: routes.rxDetail(rx.id),
|
||||
});
|
||||
Alert.alert("Submitted", "Your prescription is under review.", [
|
||||
{ text: "Track status", onPress: () => router.replace(routes.rxDetail(rx.id)) },
|
||||
{ text: "Reminders", onPress: () => router.replace(routes.rxCalendar(rx.id)) },
|
||||
{ text: "List", onPress: () => router.replace(routes.rxList) },
|
||||
...(toCart
|
||||
? [{ text: "Cart", onPress: () => router.replace(routes.cart) }]
|
||||
: [{ text: "List", onPress: () => router.replace(routes.rxList) }]),
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<AppHeader title="Review prescription" subtitle="Confirm patient and medicines" />
|
||||
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}>
|
||||
{draftImages.length ? (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
|
||||
{draftImages.map((uri) => (
|
||||
<Image
|
||||
key={uri}
|
||||
source={{ uri }}
|
||||
style={{ width: 96, height: 96, borderRadius: radii.md, backgroundColor: colors.surfaceAlt }}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<Card tone="brand" elevated={false}>
|
||||
<Text variant="h3">No images attached</Text>
|
||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||||
Go back to scan or upload a prescription photo.
|
||||
</Text>
|
||||
<Button
|
||||
title="Scan / upload"
|
||||
size="md"
|
||||
style={{ marginTop: 12 }}
|
||||
onPress={() => router.replace(routes.rxScan)}
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}
|
||||
>
|
||||
<Card elevated={false}>
|
||||
<Text variant="label" color={colors.muted} style={{ marginBottom: 12 }}>
|
||||
After submit
|
||||
</Text>
|
||||
<RxStatusStepper status="submitted" />
|
||||
</Card>
|
||||
|
||||
<Card elevated={false}>
|
||||
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||
Prescription pages ({draftImages.length})
|
||||
</Text>
|
||||
{draftImages.length ? (
|
||||
<RxImagePager
|
||||
uris={draftImages}
|
||||
size={104}
|
||||
onLongPress={(_, index) => {
|
||||
Alert.alert("Remove page?", `Page ${index + 1}`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
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}>
|
||||
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||
|
|
@ -186,28 +216,29 @@ export default function RxReviewScreen() {
|
|||
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||
Availability preview
|
||||
</Text>
|
||||
{(medicines.length ? medicines : [{ name: medicineName || "—", status: "review" as const }]).map(
|
||||
(m) => (
|
||||
<View
|
||||
key={m.name}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
}}
|
||||
>
|
||||
<Text variant="bodyMedium">{m.name}</Text>
|
||||
<Badge
|
||||
label={m.status}
|
||||
tone={
|
||||
m.status === "ready" ? "brand" : m.status === "review" ? "warn" : "danger"
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
{(medicines.length
|
||||
? medicines
|
||||
: [{ name: medicineName || "—", status: "review" as const }]
|
||||
).map((m) => (
|
||||
<View
|
||||
key={m.name}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.line,
|
||||
}}
|
||||
>
|
||||
<Text variant="bodyMedium">{m.name}</Text>
|
||||
<Badge
|
||||
label={m.status}
|
||||
tone={
|
||||
m.status === "ready" ? "brand" : m.status === "review" ? "warn" : "danger"
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Button title="Submit for review" onPress={() => save(false)} />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { router } from "expo-router";
|
|||
import { useState } from "react";
|
||||
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BiometricRow, LoyaltyStrip, QuickActions } from "@/components/profile";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
|
@ -13,10 +14,12 @@ import { AppHeader } from "@/components/ui/Header";
|
|||
import { Screen } from "@/components/ui/Screen";
|
||||
import { Text } from "@/components/ui/Text";
|
||||
import { routes } from "@/lib/routes";
|
||||
import { useAuthStore } from "@/lib/stores/authStore";
|
||||
import type { AuthProvider } from "@/lib/stores/authStore";
|
||||
import { useAuthStore, type AuthProvider } from "@/lib/stores/authStore";
|
||||
import { useLangStore } from "@/lib/stores/langStore";
|
||||
import { useLocationStore } from "@/lib/stores/locationStore";
|
||||
import { usePrefsStore } from "@/lib/stores/prefsStore";
|
||||
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
||||
import { paymentLabel } from "@/components/checkout/PaymentMethodList";
|
||||
import { colors, gutter } from "@/lib/theme";
|
||||
import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils";
|
||||
|
||||
|
|
@ -25,12 +28,15 @@ export default function ProfileScreen() {
|
|||
const user = useAuthStore((s) => s.user);
|
||||
const setAvatar = useAuthStore((s) => s.setAvatar);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
const setBiometric = useAuthStore((s) => s.setBiometric);
|
||||
const signOut = useAuthStore((s) => s.signOut);
|
||||
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
|
||||
const mode = useProfileModeStore((s) => s.mode);
|
||||
const membership = useProfileModeStore((s) => s.activeMembership());
|
||||
const language = useLangStore((s) => s.language);
|
||||
const setLanguage = useLangStore((s) => s.setLanguage);
|
||||
const payment = useLocationStore((s) => s.paymentPreference);
|
||||
const prefs = usePrefsStore();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(user?.name ?? "");
|
||||
|
|
@ -46,14 +52,12 @@ export default function ProfileScreen() {
|
|||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.85,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]?.uri) {
|
||||
setAvatar(result.assets[0].uri);
|
||||
}
|
||||
|
|
@ -95,14 +99,31 @@ export default function ProfileScreen() {
|
|||
{ label: t("common.switchProfile"), href: routes.profileSwitch, icon: "swap-horizontal", subtitle: mode },
|
||||
{ label: t("common.joinOrg"), href: routes.profileJoin, icon: "business-outline" },
|
||||
{ label: t("common.orders"), href: routes.orders, icon: "receipt-outline", subtitle: "Track delivery" },
|
||||
{ label: t("common.notifications"), href: routes.notifications, icon: "notifications-outline", subtitle: "Alerts inbox" },
|
||||
{
|
||||
label: "Payment methods",
|
||||
href: routes.paymentMethods,
|
||||
icon: "card-outline",
|
||||
subtitle: paymentLabel(payment),
|
||||
},
|
||||
{
|
||||
label: "Notification preferences",
|
||||
href: routes.notificationPrefs,
|
||||
icon: "notifications-outline",
|
||||
subtitle: [
|
||||
prefs.push ? "Push" : null,
|
||||
prefs.sms ? "SMS" : null,
|
||||
prefs.email ? "Email" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "All off",
|
||||
},
|
||||
{ label: t("common.notifications"), href: routes.notifications, icon: "mail-unread-outline", subtitle: "Alerts inbox" },
|
||||
{ label: t("common.prescriptions"), href: routes.rxList, icon: "medkit-outline", subtitle: "Rx + family" },
|
||||
{ label: t("common.loyalty"), href: routes.loyalty, icon: "star-outline", subtitle: `${user?.points ?? 0} pts` },
|
||||
{ label: t("common.subscriptions"), href: routes.subscriptions, icon: "repeat-outline" },
|
||||
{ label: t("common.emergencyContacts"), href: routes.emergencyContacts, icon: "alert-circle-outline" },
|
||||
{ label: t("common.branches"), href: routes.branches, icon: "location-outline" },
|
||||
{ label: t("common.addresses"), href: routes.addresses, icon: "home-outline" },
|
||||
{ label: t("common.language"), href: routes.language, icon: "language-outline", subtitle: language.toUpperCase() },
|
||||
{ label: t("common.about"), href: routes.about, icon: "information-circle-outline" },
|
||||
{ label: t("common.contact"), href: routes.contact, icon: "mail-outline" },
|
||||
];
|
||||
|
|
@ -153,10 +174,7 @@ export default function ProfileScreen() {
|
|||
) : null}
|
||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
|
||||
{user?.authProvider && user.authProvider !== "guest" ? (
|
||||
<Badge
|
||||
label={providerLabel(user.authProvider)}
|
||||
tone="info"
|
||||
/>
|
||||
<Badge label={providerLabel(user.authProvider)} tone="info" />
|
||||
) : null}
|
||||
<Badge
|
||||
label={
|
||||
|
|
@ -166,7 +184,7 @@ export default function ProfileScreen() {
|
|||
}
|
||||
tone="brand"
|
||||
/>
|
||||
<Badge label={`${user?.tier ?? "bronze"} · ${user?.points ?? 0} pts`} tone="neutral" />
|
||||
{user?.isGuest ? <Badge label="Guest" tone="neutral" /> : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -210,6 +228,12 @@ export default function ProfileScreen() {
|
|||
) : null}
|
||||
</Card>
|
||||
|
||||
{!user?.isGuest ? (
|
||||
<LoyaltyStrip points={user?.points ?? 0} tier={user?.tier ?? "bronze"} />
|
||||
) : null}
|
||||
|
||||
<QuickActions />
|
||||
|
||||
{editing && !user?.isGuest ? (
|
||||
<Card elevated={false} style={{ marginTop: 12 }}>
|
||||
<Text variant="h3" style={{ marginBottom: 12 }}>
|
||||
|
|
@ -250,6 +274,15 @@ export default function ProfileScreen() {
|
|||
</Card>
|
||||
) : 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 }}>
|
||||
{[
|
||||
{ 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 { useMemo, useState } from "react";
|
||||
import { Alert, Image, Pressable, Share, ScrollView, View } from "react-native";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FamilyInviteCard } from "@/components/care";
|
||||
import { RxCareCard } from "@/components/prescription";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, Section } from "@/components/ui/Card";
|
||||
import { DoseReminderPanel } from "@/components/ui/DoseReminderPanel";
|
||||
import { Chip } from "@/components/ui/Chip";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
||||
import { TabTopBar } from "@/components/ui/TabTopBar";
|
||||
|
|
@ -15,155 +16,12 @@ import { routes } from "@/lib/routes";
|
|||
import { useFamilyStore } from "@/lib/stores/familyStore";
|
||||
import {
|
||||
usePrescriptionStore,
|
||||
type Prescription,
|
||||
type Reminder,
|
||||
type RxStatus,
|
||||
} from "@/lib/stores/prescriptionStore";
|
||||
import { colors, gutter } from "@/lib/theme";
|
||||
|
||||
const statusTone = {
|
||||
submitted: "neutral",
|
||||
under_review: "warn",
|
||||
approved: "brand",
|
||||
queried: "info",
|
||||
rejected: "danger",
|
||||
filled: "brand",
|
||||
} as const;
|
||||
|
||||
function refillDays(rx: Prescription) {
|
||||
if (!rx.lastFilledAt) return null;
|
||||
const due = new Date(rx.lastFilledAt);
|
||||
due.setDate(due.getDate() + rx.daysSupply);
|
||||
return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function RxCareCard({
|
||||
rx,
|
||||
reminder,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
rx: Prescription;
|
||||
reminder?: Reminder;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
}) {
|
||||
const dueIn = refillDays(rx);
|
||||
const thumb = rx.imageUris[0];
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
type Filter = "all" | RxStatus;
|
||||
|
||||
export default function PrescriptionsTabScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -176,6 +34,7 @@ export default function PrescriptionsTabScreen() {
|
|||
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
||||
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
|
||||
const reminderByRx = useMemo(() => {
|
||||
const map = new Map<string, Reminder>();
|
||||
|
|
@ -185,6 +44,15 @@ export default function PrescriptionsTabScreen() {
|
|||
|
||||
const activeReminderCount = reminders.filter((r) => r.enabled).length;
|
||||
const needingSetup = prescriptions.filter((p) => !reminderByRx.get(p.id)?.enabled).length;
|
||||
const queriedCount = prescriptions.filter((p) => p.status === "queried").length;
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
filter === "all"
|
||||
? prescriptions
|
||||
: prescriptions.filter((p) => p.status === filter),
|
||||
[prescriptions, filter]
|
||||
);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
|
|
@ -221,6 +89,16 @@ export default function PrescriptionsTabScreen() {
|
|||
{needingSetup}
|
||||
</Text>
|
||||
</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>
|
||||
|
||||
<Button
|
||||
|
|
@ -229,19 +107,43 @@ export default function PrescriptionsTabScreen() {
|
|||
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">
|
||||
{prescriptions.length === 0 ? (
|
||||
{visible.length === 0 ? (
|
||||
<EmptyState
|
||||
compact
|
||||
icon="document-text-outline"
|
||||
title="No prescriptions yet"
|
||||
body="Scan or upload a prescription to start reminders."
|
||||
title={filter === "all" ? "No prescriptions yet" : "Nothing in this filter"}
|
||||
body={
|
||||
filter === "all"
|
||||
? "Scan or upload a prescription to start reminders."
|
||||
: "Try another status filter."
|
||||
}
|
||||
actionTitle="Scan prescription"
|
||||
onAction={() => router.push(routes.rxScan)}
|
||||
/>
|
||||
) : (
|
||||
<View style={{ gap: 12 }}>
|
||||
{prescriptions.map((rx) => (
|
||||
{visible.map((rx) => (
|
||||
<RxCareCard
|
||||
key={rx.id}
|
||||
rx={rx}
|
||||
|
|
@ -257,56 +159,10 @@ export default function PrescriptionsTabScreen() {
|
|||
</Section>
|
||||
|
||||
<Section title={t("common.family")}>
|
||||
<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={t("common.inviteFamily")}
|
||||
size="md"
|
||||
fullWidth={false}
|
||||
icon="person-add-outline"
|
||||
onPress={() => router.push(routes.familyInvite)}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
<FamilyInviteCard
|
||||
inviteCode={inviteCode}
|
||||
onInvite={() => router.push(routes.familyInvite)}
|
||||
/>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<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
|
||||
| Method | Path | Status | Notes |
|
||||
|--------|------|--------|-------|
|
||||
| POST | `/payments/initiate` | Required | `chapa` \| `telebirr` \| `mpesa` only for delivery |
|
||||
| POST | `/payments/intent` | Required | `chapa` \| `arifpay` \| `telebirr` \| `mpesa` \| `bank` \| `cod` \| `pay_at_branch` |
|
||||
| POST | `/payments/webhook/*` | Required | Gateway callbacks |
|
||||
| — | `pay_at_branch` | Required | Allowed **only** when `fulfillment=pickup` (required for guest pharmacist codes) |
|
||||
| GET | `/users/me/payment-preference` | Required | Saved preferred method |
|
||||
| PUT | `/users/me/payment-preference` | Required | |
|
||||
| — | `pay_at_branch` | Required | Allowed **only** when `fulfillment=pickup` (required for guest / assisted pharmacist codes) |
|
||||
| — | `cod` | Required | Delivery only |
|
||||
| — | `bank` | Required | May require receipt upload via signed URL |
|
||||
|
||||
## Prescriptions (Rx Care)
|
||||
| Method | Path | Status | Notes |
|
||||
|
|
@ -160,6 +164,8 @@ Distinct from order list. Home bell → inbox.
|
|||
| POST | `/notifications/read-all` | Required | |
|
||||
| DELETE | `/notifications/:id` | Required | |
|
||||
| POST | `/devices` | Required | push token registration |
|
||||
| GET | `/notifications/prefs` | Required | `{ sms, email, push, orderUpdates, refillReminders, marketing }` |
|
||||
| PUT | `/notifications/prefs` | Required | Same shape |
|
||||
|
||||
## Push / Tracking
|
||||
| Method | Path | Status | Notes |
|
||||
|
|
@ -189,3 +195,4 @@ Distinct from order list. Home bell → inbox.
|
|||
- 2026-08-07: Auth providers — Google, Email, Phone OTP, Telegram, and Guest session contracts; `authProvider` on `/users/me`.
|
||||
- 2026-08-07: Guest pharmacist codes — `POST /orders/guest-codes`, `GET /orders/by-code/:code`, admin fulfill-by-code (Ecom parity); mobile copy/share/screenshot card.
|
||||
- 2026-08-07: Notifications inbox — dedicated `/notifications/me` (separate from Orders); Favorites + Addresses endpoints documented; Rx Care dose reminder confirm/snooze.
|
||||
- 2026-08-10: Profile/Checkout/Rx finalize — payment preference screen (Chapa, ArifPay, Telebirr, M-Pesa, bank, COD, pay_at_branch); notification prefs (`GET/PUT /notifications/prefs`); checkout loyalty redeem + Rx cart gate + assisted code for signed-in; Rx status stepper + queried reply; mock `POST /payments/intent`.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,47 @@
|
|||
/** API client stub — swap BASE_URL when shared backend is live. */
|
||||
/** API client — mock adapters when EXPO_PUBLIC_API_URL is unset. */
|
||||
|
||||
export const BASE_URLS = {
|
||||
core: process.env.EXPO_PUBLIC_API_URL ?? "https://api.gishen.example/v1",
|
||||
} as const;
|
||||
|
||||
export const USE_MOCKS = !process.env.EXPO_PUBLIC_API_URL;
|
||||
|
||||
export type ApiError = { message: string; code?: string };
|
||||
|
||||
export type PaymentIntent = {
|
||||
id: string;
|
||||
provider: string;
|
||||
status: "requires_action" | "succeeded" | "pending";
|
||||
amountEtb: number;
|
||||
checkoutUrl?: string;
|
||||
};
|
||||
|
||||
export async function initiatePayment(input: {
|
||||
provider: string;
|
||||
amountEtb: number;
|
||||
orderId: string;
|
||||
}): Promise<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>(
|
||||
path: string,
|
||||
init?: RequestInit & { locale?: string }
|
||||
|
|
@ -17,9 +53,8 @@ export async function callEndpoint<T>(
|
|||
...(init?.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// Mock mode until backend exists
|
||||
if (!process.env.EXPO_PUBLIC_API_URL) {
|
||||
throw new Error(`API not configured for ${path}`);
|
||||
if (USE_MOCKS) {
|
||||
throw new Error(`No mock for ${path} — pass EXPO_PUBLIC_API_URL or add an adapter`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URLS.core}${path}`, {
|
||||
|
|
@ -32,3 +67,7 @@ export async function callEndpoint<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.",
|
||||
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[] = [
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ export const routes = {
|
|||
about: "/(root)/(screens)/about",
|
||||
contact: "/(root)/(screens)/contact",
|
||||
addresses: "/(root)/(screens)/addresses",
|
||||
paymentMethods: "/(root)/(screens)/payment-methods",
|
||||
notificationPrefs: "/(root)/(screens)/notification-prefs",
|
||||
checkoutSuccess: (id: string) =>
|
||||
`/(root)/(screens)/checkout-success?id=${id}` as const,
|
||||
} as const;
|
||||
|
||||
export type Category = {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
|||
export type Fulfillment = "delivery" | "pickup";
|
||||
export type PaymentMethod =
|
||||
| "chapa"
|
||||
| "arifpay"
|
||||
| "telebirr"
|
||||
| "mpesa"
|
||||
| "bank"
|
||||
| "cod"
|
||||
| "pay_at_branch";
|
||||
|
||||
type LocationState = {
|
||||
|
|
@ -35,8 +38,18 @@ export const useLocationStore = create<LocationState>()(
|
|||
paymentPreference: "chapa",
|
||||
setFulfillment: (fulfillment) => {
|
||||
const next: Partial<LocationState> = { fulfillment };
|
||||
if (fulfillment === "delivery" && get().paymentPreference === "pay_at_branch") {
|
||||
next.paymentPreference = "chapa";
|
||||
if (
|
||||
fulfillment === "delivery" &&
|
||||
(get().paymentPreference === "pay_at_branch" ||
|
||||
get().paymentPreference === "cod")
|
||||
) {
|
||||
// COD stays valid for delivery; only strip pay_at_branch
|
||||
if (get().paymentPreference === "pay_at_branch") {
|
||||
next.paymentPreference = "chapa";
|
||||
}
|
||||
}
|
||||
if (fulfillment === "pickup" && get().paymentPreference === "cod") {
|
||||
next.paymentPreference = "pay_at_branch";
|
||||
}
|
||||
set(next);
|
||||
},
|
||||
|
|
@ -45,15 +58,22 @@ export const useLocationStore = create<LocationState>()(
|
|||
setCoords: (lat, lng) => set({ lat, lng }),
|
||||
setPaymentPreference: (paymentPreference) => set({ paymentPreference }),
|
||||
allowedPayments: () => {
|
||||
const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"];
|
||||
const online: PaymentMethod[] = [
|
||||
"chapa",
|
||||
"arifpay",
|
||||
"telebirr",
|
||||
"mpesa",
|
||||
"bank",
|
||||
];
|
||||
return get().fulfillment === "pickup"
|
||||
? [...base, "pay_at_branch"]
|
||||
: base;
|
||||
? [...online, "pay_at_branch"]
|
||||
: [...online, "cod"];
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "gishen-location",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
version: 2,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
|||
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;
|
||||
status: RxStatus;
|
||||
notes?: string;
|
||||
/** Message from pharmacist when status is queried / rejected */
|
||||
pharmacistMessage?: string;
|
||||
/** Customer reply to a query */
|
||||
customerReply?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
|
|
@ -60,7 +64,8 @@ type RxState = {
|
|||
addPrescription: (
|
||||
p: Omit<Prescription, "id" | "createdAt" | "status">
|
||||
) => Prescription;
|
||||
updateRxStatus: (id: string, status: RxStatus) => void;
|
||||
updateRxStatus: (id: string, status: RxStatus, pharmacistMessage?: string) => void;
|
||||
replyToQuery: (id: string, message: string) => void;
|
||||
markRefilled: (id: string) => void;
|
||||
removePrescription: (id: string) => void;
|
||||
upsertReminder: (r: Omit<Reminder, "id"> & { id?: string }) => void;
|
||||
|
|
@ -88,10 +93,31 @@ export const usePrescriptionStore = create<RxState>()(
|
|||
set({ prescriptions: [rx, ...get().prescriptions] });
|
||||
return rx;
|
||||
},
|
||||
updateRxStatus: (id, status) =>
|
||||
updateRxStatus: (id, status, pharmacistMessage) =>
|
||||
set({
|
||||
prescriptions: get().prescriptions.map((p) =>
|
||||
p.id === id ? { ...p, status } : p
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
status,
|
||||
...(pharmacistMessage !== undefined
|
||||
? { pharmacistMessage }
|
||||
: {}),
|
||||
}
|
||||
: p
|
||||
),
|
||||
}),
|
||||
replyToQuery: (id, message) =>
|
||||
set({
|
||||
prescriptions: get().prescriptions.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
customerReply: message,
|
||||
status: "under_review",
|
||||
pharmacistMessage: undefined,
|
||||
}
|
||||
: p
|
||||
),
|
||||
}),
|
||||
markRefilled: (id) =>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@
|
|||
"addresses": "አድራሻዎች",
|
||||
"rewards": "ሽልማቶች",
|
||||
"referral": "የማጣቀሻ ኮድ",
|
||||
"arifpay": "ArifPay",
|
||||
"bankTransfer": "ባንክ ማስተላለፍ",
|
||||
"cashOnDelivery": "በአገልግሎት ክፍያ",
|
||||
"paymentMethods": "የክፍያ ዘዴዎች",
|
||||
"notificationPrefs": "የማሳወቂያ ምርጫዎች",
|
||||
"assistedCode": "የፋርማሲስት ትዕዛዝ ኮድ",
|
||||
"inviteFamily": "ቤተሰብ ጋብዝ",
|
||||
"manageSubs": "ደንበኝነት አስተዳድር",
|
||||
"scanRx": "የሐኪም ትዕዛዝ ቅኝት",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@
|
|||
"addresses": "Addresses",
|
||||
"rewards": "Rewards",
|
||||
"referral": "Referral code",
|
||||
"arifpay": "ArifPay",
|
||||
"bankTransfer": "Bank transfer",
|
||||
"cashOnDelivery": "Cash on delivery",
|
||||
"paymentMethods": "Payment methods",
|
||||
"notificationPrefs": "Notification preferences",
|
||||
"assistedCode": "Pharmacist order code",
|
||||
"inviteFamily": "Invite family",
|
||||
"manageSubs": "Manage subscriptions",
|
||||
"scanRx": "Scan prescription",
|
||||
|
|
|
|||
Reference in New Issue
Block a user