Ship Rx Care tab, notifications inbox, and guest pickup codes.
Replace the profile tab with prescription management (inline reminders and family invite), add a shared tab top bar, separate notifications from orders, and support any-branch guest checkout with GSH codes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7310a349c5
commit
5391780b9b
|
|
@ -1,5 +1,5 @@
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
|
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
@ -10,7 +10,7 @@ import { AppHeader } from "@/components/ui/Header";
|
||||||
import { Screen } from "@/components/ui/Screen";
|
import { Screen } from "@/components/ui/Screen";
|
||||||
import { StickyBar } from "@/components/ui/StickyBar";
|
import { StickyBar } from "@/components/ui/StickyBar";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { DELIVERY_FEE, orderSubtotalTotal, voucherDiscount } from "@/lib/commerce";
|
import { DELIVERY_FEE, generatePickupCode, orderSubtotalTotal, voucherDiscount } from "@/lib/commerce";
|
||||||
import { branches } from "@/lib/data/branches";
|
import { branches } from "@/lib/data/branches";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
import { useAddressStore } from "@/lib/stores/addressStore";
|
import { useAddressStore } from "@/lib/stores/addressStore";
|
||||||
|
|
@ -18,6 +18,7 @@ import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
import { useCartStore } from "@/lib/stores/cartStore";
|
import { useCartStore } from "@/lib/stores/cartStore";
|
||||||
import { useFamilyStore } from "@/lib/stores/familyStore";
|
import { useFamilyStore } from "@/lib/stores/familyStore";
|
||||||
import { useLocationStore, type PaymentMethod } from "@/lib/stores/locationStore";
|
import { useLocationStore, type PaymentMethod } from "@/lib/stores/locationStore";
|
||||||
|
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||||||
import { useOrdersStore } from "@/lib/stores/ordersStore";
|
import { useOrdersStore } from "@/lib/stores/ordersStore";
|
||||||
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
||||||
import { colors, gutter } from "@/lib/theme";
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
@ -38,10 +39,10 @@ export default function CheckoutScreen() {
|
||||||
const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
|
const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
|
||||||
const payment = useLocationStore((s) => s.paymentPreference);
|
const payment = useLocationStore((s) => s.paymentPreference);
|
||||||
const setPayment = useLocationStore((s) => s.setPaymentPreference);
|
const setPayment = useLocationStore((s) => s.setPaymentPreference);
|
||||||
const allowed = useLocationStore((s) => s.allowedPayments());
|
|
||||||
const addresses = useAddressStore((s) => s.addresses);
|
const addresses = useAddressStore((s) => s.addresses);
|
||||||
const defaultAddress = useAddressStore((s) => s.defaultAddress());
|
const defaultAddress = useAddressStore((s) => s.defaultAddress());
|
||||||
const addOrder = useOrdersStore((s) => s.addOrder);
|
const addOrder = useOrdersStore((s) => s.addOrder);
|
||||||
|
const pushNotification = useNotificationStore((s) => s.push);
|
||||||
const addPoints = useAuthStore((s) => s.addPoints);
|
const addPoints = useAuthStore((s) => s.addPoints);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setActiveVoucher = useAuthStore((s) => s.setActiveVoucher);
|
const setActiveVoucher = useAuthStore((s) => s.setActiveVoucher);
|
||||||
|
|
@ -52,8 +53,9 @@ export default function CheckoutScreen() {
|
||||||
const familyMembers = useFamilyStore((s) => s.members);
|
const familyMembers = useFamilyStore((s) => s.members);
|
||||||
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
||||||
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
||||||
|
const isGuest = !!user?.isGuest;
|
||||||
|
|
||||||
const [name, setName] = useState(user?.name ?? "");
|
const [name, setName] = useState(user?.name && !user.isGuest ? user.name : "");
|
||||||
const [phone, setPhone] = useState(user?.phone ?? "");
|
const [phone, setPhone] = useState(user?.phone ?? "");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [addressId, setAddressId] = useState(defaultAddress?.id ?? "");
|
const [addressId, setAddressId] = useState(defaultAddress?.id ?? "");
|
||||||
|
|
@ -61,17 +63,38 @@ export default function CheckoutScreen() {
|
||||||
cartVoucher || user?.activeVoucher || ""
|
cartVoucher || user?.activeVoucher || ""
|
||||||
);
|
);
|
||||||
const [forMemberId, setForMemberId] = useState<string | null>(activeMemberId);
|
const [forMemberId, setForMemberId] = useState<string | null>(activeMemberId);
|
||||||
|
/** Guests (and optional pickup) can fulfill at any branch with a quick code */
|
||||||
|
const [anyBranch, setAnyBranch] = useState(isGuest);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGuest) return;
|
||||||
|
if (fulfillment !== "pickup") setFulfillment("pickup");
|
||||||
|
setAnyBranch(true);
|
||||||
|
if (payment !== "pay_at_branch") setPayment("pay_at_branch");
|
||||||
|
}, [isGuest, fulfillment, payment, setFulfillment, setPayment]);
|
||||||
|
|
||||||
const selectedAddress = useMemo(
|
const selectedAddress = useMemo(
|
||||||
() => addresses.find((a) => a.id === addressId) ?? defaultAddress,
|
() => addresses.find((a) => a.id === addressId) ?? defaultAddress,
|
||||||
[addresses, addressId, defaultAddress]
|
[addresses, addressId, defaultAddress]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deliveryFee = fulfillment === "delivery" && items.length ? DELIVERY_FEE : 0;
|
const effectiveFulfillment = isGuest ? "pickup" : fulfillment;
|
||||||
|
const useAnyBranchPickup =
|
||||||
|
effectiveFulfillment === "pickup" && (anyBranch || isGuest);
|
||||||
|
|
||||||
|
const allowed = useMemo((): PaymentMethod[] => {
|
||||||
|
const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"];
|
||||||
|
return effectiveFulfillment === "pickup"
|
||||||
|
? [...base, "pay_at_branch"]
|
||||||
|
: base;
|
||||||
|
}, [effectiveFulfillment]);
|
||||||
|
|
||||||
|
const deliveryFee =
|
||||||
|
effectiveFulfillment === "delivery" && items.length ? DELIVERY_FEE : 0;
|
||||||
const discount = voucherDiscount(subtotal, voucher);
|
const discount = voucherDiscount(subtotal, voucher);
|
||||||
const afterDiscount = Math.max(0, subtotal - discount);
|
const afterDiscount = Math.max(0, subtotal - discount);
|
||||||
const split =
|
const split =
|
||||||
mode === "corporate"
|
mode === "corporate" && !isGuest
|
||||||
? quoteSplit(afterDiscount)
|
? quoteSplit(afterDiscount)
|
||||||
: { covered: 0, selfPay: afterDiscount };
|
: { covered: 0, selfPay: afterDiscount };
|
||||||
const total = orderSubtotalTotal({
|
const total = orderSubtotalTotal({
|
||||||
|
|
@ -86,15 +109,16 @@ export default function CheckoutScreen() {
|
||||||
Alert.alert("Contact details", "Enter your name and a valid phone number.");
|
Alert.alert("Contact details", "Enter your name and a valid phone number.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!allowed.includes(payment)) {
|
if (!allowed.includes(payment) && !(useAnyBranchPickup && payment === "pay_at_branch")) {
|
||||||
Alert.alert("Payment", "Select a valid payment method.");
|
Alert.alert("Payment", "Select a valid payment method.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (fulfillment === "pickup" && !branchId) {
|
|
||||||
Alert.alert("Pickup", "Select a branch for pickup.");
|
if (effectiveFulfillment === "pickup" && !useAnyBranchPickup && !branchId) {
|
||||||
|
Alert.alert("Pickup", "Select a branch, or choose any-branch quick code.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (fulfillment === "delivery" && !selectedAddress && !areaLabel.trim()) {
|
if (effectiveFulfillment === "delivery" && !selectedAddress && !areaLabel.trim()) {
|
||||||
Alert.alert("Delivery", "Choose a saved address or enter an area.");
|
Alert.alert("Delivery", "Choose a saved address or enter an area.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -108,52 +132,91 @@ export default function CheckoutScreen() {
|
||||||
const orderNotes = [
|
const orderNotes = [
|
||||||
notes.trim(),
|
notes.trim(),
|
||||||
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
|
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
|
||||||
|
useAnyBranchPickup ? "Quick code — redeemable at any Gishen branch" : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
|
const pickupCode =
|
||||||
|
effectiveFulfillment === "pickup" ? generatePickupCode() : null;
|
||||||
|
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
|
||||||
|
|
||||||
const order = addOrder({
|
const order = addOrder({
|
||||||
items,
|
items,
|
||||||
subtotal,
|
subtotal,
|
||||||
deliveryFee,
|
deliveryFee: fee,
|
||||||
discount,
|
discount,
|
||||||
voucherCode: discount ? voucher.trim().toUpperCase() : null,
|
voucherCode: discount ? voucher.trim().toUpperCase() : null,
|
||||||
total: afterDiscount + deliveryFee,
|
total: afterDiscount + fee,
|
||||||
fulfillment,
|
fulfillment: effectiveFulfillment,
|
||||||
branchId,
|
branchId: useAnyBranchPickup ? null : branchId,
|
||||||
areaLabel:
|
areaLabel: useAnyBranchPickup
|
||||||
fulfillment === "pickup"
|
? "Any Gishen branch"
|
||||||
|
: effectiveFulfillment === "pickup"
|
||||||
? branch?.area ?? areaLabel
|
? branch?.area ?? areaLabel
|
||||||
: selectedAddress?.area ?? areaLabel,
|
: selectedAddress?.area ?? areaLabel,
|
||||||
addressId: fulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
addressId:
|
||||||
addressLine,
|
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
||||||
|
addressLine: useAnyBranchPickup ? undefined : addressLine,
|
||||||
paymentMethod: payment,
|
paymentMethod: payment,
|
||||||
corporateCovered: split.covered,
|
corporateCovered: split.covered,
|
||||||
selfPay: split.selfPay + deliveryFee,
|
selfPay: split.selfPay + fee,
|
||||||
customerName: name.trim(),
|
customerName: name.trim(),
|
||||||
customerPhone: phone.trim(),
|
customerPhone: phone.trim(),
|
||||||
notes: orderNotes,
|
notes: orderNotes,
|
||||||
riderAssigned: false,
|
riderAssigned: false,
|
||||||
|
pickupCode,
|
||||||
|
anyBranch: useAnyBranchPickup,
|
||||||
|
isGuestOrder: isGuest,
|
||||||
|
status:
|
||||||
|
useAnyBranchPickup || payment === "pay_at_branch" ? "confirmed" : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (forMemberId) setActiveMember(forMemberId);
|
if (forMemberId) setActiveMember(forMemberId);
|
||||||
|
|
||||||
if (split.covered > 0) consumeAllowance(split.covered);
|
if (!isGuest && split.covered > 0) consumeAllowance(split.covered);
|
||||||
addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
|
if (!isGuest) addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
|
||||||
if (discount) {
|
if (discount) {
|
||||||
setActiveVoucher(null);
|
setActiveVoucher(null);
|
||||||
setCartVoucher(null);
|
setCartVoucher(null);
|
||||||
}
|
}
|
||||||
clear();
|
clear();
|
||||||
|
|
||||||
|
pushNotification({
|
||||||
|
kind: "order",
|
||||||
|
title: pickupCode ? `Pickup code ${pickupCode}` : "Order placed",
|
||||||
|
body: 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: routes.orderDetail(order.id),
|
||||||
|
});
|
||||||
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Order placed",
|
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"
|
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"}.`
|
? `Pay ${formatETB(order.selfPay ?? order.total)} at ${branch?.name ?? "the branch"}.`
|
||||||
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
|
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
|
||||||
[
|
[
|
||||||
{ text: "View order", onPress: () => router.replace(routes.orderDetail(order.id)) },
|
{ text: "View order", onPress: () => router.replace(routes.orderDetail(order.id)) },
|
||||||
{ text: "Track", onPress: () => router.replace(routes.tracking(order.id)) },
|
{
|
||||||
|
text: pickupCode ? "Done" : "Track",
|
||||||
|
onPress: () =>
|
||||||
|
pickupCode
|
||||||
|
? router.replace(routes.orderDetail(order.id))
|
||||||
|
: router.replace(routes.tracking(order.id)),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -245,16 +308,37 @@ export default function CheckoutScreen() {
|
||||||
<Text variant="h3" style={{ marginBottom: 10 }}>
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||||
Fulfillment
|
Fulfillment
|
||||||
</Text>
|
</Text>
|
||||||
|
{isGuest ? (
|
||||||
|
<Card tone="brand" elevated={false} style={{ marginBottom: 12 }}>
|
||||||
|
<Text variant="h3">Guest quick code</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||||
|
As a guest you get a short pickup code (e.g. GSH-AB12). Show it at any
|
||||||
|
Gishen branch counter — no account needed to fulfill.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
<Segmented
|
<Segmented
|
||||||
value={fulfillment}
|
value={effectiveFulfillment}
|
||||||
onChange={setFulfillment}
|
onChange={(v) => {
|
||||||
|
if (isGuest && v === "delivery") {
|
||||||
|
Alert.alert(
|
||||||
|
"Guest checkout",
|
||||||
|
"Guests use quick-code pickup at any branch. Sign in for delivery & loyalty."
|
||||||
|
);
|
||||||
|
setFulfillment("pickup");
|
||||||
|
setAnyBranch(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFulfillment(v);
|
||||||
|
if (v === "delivery") setAnyBranch(false);
|
||||||
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: "delivery", label: t("common.delivery") },
|
{ value: "delivery", label: t("common.delivery") },
|
||||||
{ value: "pickup", label: t("common.pickup") },
|
{ value: "pickup", label: t("common.pickup") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{fulfillment === "delivery" ? (
|
{effectiveFulfillment === "delivery" ? (
|
||||||
<View style={{ marginTop: 14, gap: 8 }}>
|
<View style={{ marginTop: 14, gap: 8 }}>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
<Text variant="label" color={colors.muted}>
|
<Text variant="label" color={colors.muted}>
|
||||||
|
|
@ -302,16 +386,42 @@ export default function CheckoutScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ marginTop: 14, gap: 8 }}>
|
<View style={{ marginTop: 14, gap: 8 }}>
|
||||||
{branches.map((b) => (
|
|
||||||
<Pressable
|
<Pressable
|
||||||
key={b.id}
|
onPress={() => {
|
||||||
onPress={() => setBranchId(b.id)}
|
setAnyBranch(true);
|
||||||
|
setBranchId(null);
|
||||||
|
setPayment("pay_at_branch");
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: 12,
|
padding: 12,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: branchId === b.id ? colors.brand : colors.line,
|
borderColor: anyBranch ? colors.brand : colors.line,
|
||||||
backgroundColor: branchId === b.id ? colors.brandWash : colors.surface,
|
backgroundColor: anyBranch ? colors.brandWash : colors.surface,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<Pressable
|
||||||
|
key={b.id}
|
||||||
|
onPress={() => {
|
||||||
|
setAnyBranch(false);
|
||||||
|
setBranchId(b.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor:
|
||||||
|
!anyBranch && branchId === b.id ? colors.brand : colors.line,
|
||||||
|
backgroundColor:
|
||||||
|
!anyBranch && branchId === b.id
|
||||||
|
? colors.brandWash
|
||||||
|
: colors.surface,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text variant="h3">{b.name}</Text>
|
<Text variant="h3">{b.name}</Text>
|
||||||
|
|
@ -327,7 +437,7 @@ export default function CheckoutScreen() {
|
||||||
<Card elevated={false} padded={false}>
|
<Card elevated={false} padded={false}>
|
||||||
<View style={{ padding: 16, paddingBottom: 8 }}>
|
<View style={{ padding: 16, paddingBottom: 8 }}>
|
||||||
<Text variant="h3">{t("common.payment")}</Text>
|
<Text variant="h3">{t("common.payment")}</Text>
|
||||||
{fulfillment === "delivery" ? (
|
{effectiveFulfillment === "delivery" ? (
|
||||||
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
Pay at branch is only available for pickup
|
Pay at branch is only available for pickup
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -394,7 +504,7 @@ export default function CheckoutScreen() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label={fulfillment === "delivery" ? "Delivery" : "Pickup"}
|
label={effectiveFulfillment === "delivery" ? "Delivery" : "Pickup"}
|
||||||
value={deliveryFee ? formatETB(deliveryFee) : "Free"}
|
value={deliveryFee ? formatETB(deliveryFee) : "Free"}
|
||||||
/>
|
/>
|
||||||
<InfoRow label={t("common.total")} value={formatETB(afterDiscount + deliveryFee)} emphasis last />
|
<InfoRow label={t("common.total")} value={formatETB(afterDiscount + deliveryFee)} emphasis last />
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,7 @@
|
||||||
import { router } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
|
||||||
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 { useFamilyStore } from "@/lib/stores/familyStore";
|
|
||||||
import { colors, gutter } from "@/lib/theme";
|
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
export default function FamilyScreen() {
|
/** Family is managed on the Prescriptions tab with invite. */
|
||||||
const { t } = useTranslation();
|
export default function FamilyRedirect() {
|
||||||
const members = useFamilyStore((s) => s.members);
|
return <Redirect href={routes.rxList as never} />;
|
||||||
const inviteCode = useFamilyStore((s) => s.inviteCode);
|
|
||||||
const acceptInvite = useFamilyStore((s) => s.acceptInvite);
|
|
||||||
const removeMember = useFamilyStore((s) => s.removeMember);
|
|
||||||
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
|
||||||
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen>
|
|
||||||
<AppHeader title={t("common.family")} subtitle="Manage members and shared care" />
|
|
||||||
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 12 }}>
|
|
||||||
<Card tone="brand" elevated={false}>
|
|
||||||
<Text variant="caption" color={colors.muted}>
|
|
||||||
Your invite code
|
|
||||||
</Text>
|
|
||||||
<Text variant="h1" style={{ marginTop: 4 }}>
|
|
||||||
{inviteCode}
|
|
||||||
</Text>
|
|
||||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
|
||||||
Share this code so family can join your care circle.
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
title={t("common.inviteFamily")}
|
|
||||||
icon="person-add-outline"
|
|
||||||
onPress={() => router.push(routes.familyInvite)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{members.map((m) => (
|
|
||||||
<Card
|
|
||||||
key={m.id}
|
|
||||||
onPress={() => setActiveMember(m.id === activeMemberId ? null : m.id)}
|
|
||||||
style={{
|
|
||||||
borderColor: activeMemberId === m.id ? colors.brand : colors.line,
|
|
||||||
borderWidth: activeMemberId === m.id ? 1.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text variant="h3">{m.name}</Text>
|
|
||||||
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
||||||
{m.relationship}
|
|
||||||
{m.phone ? ` · ${m.phone}` : ""}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Badge
|
|
||||||
label={m.status}
|
|
||||||
tone={m.status === "accepted" ? "brand" : "warn"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={{ flexDirection: "row", gap: 16, marginTop: 12 }}>
|
|
||||||
{m.status === "pending" ? (
|
|
||||||
<Button
|
|
||||||
title="Accept"
|
|
||||||
size="sm"
|
|
||||||
fullWidth={false}
|
|
||||||
onPress={() => acceptInvite(m.id)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
title="Remove"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
fullWidth={false}
|
|
||||||
onPress={() =>
|
|
||||||
Alert.alert("Remove member?", m.name, [
|
|
||||||
{ text: "Cancel", style: "cancel" },
|
|
||||||
{ text: "Remove", style: "destructive", onPress: () => removeMember(m.id) },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
159
app/(root)/(screens)/notifications.tsx
Normal file
159
app/(root)/(screens)/notifications.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import {
|
||||||
|
useNotificationStore,
|
||||||
|
type NotificationKind,
|
||||||
|
} from "@/lib/stores/notificationStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
const kindMeta: Record<
|
||||||
|
NotificationKind,
|
||||||
|
{ label: string; icon: keyof typeof Ionicons.glyphMap; tone: "brand" | "info" | "warn" | "neutral" | "danger" }
|
||||||
|
> = {
|
||||||
|
order: { label: "Order", icon: "cube-outline", tone: "info" },
|
||||||
|
rx: { label: "Rx", icon: "document-text-outline", tone: "warn" },
|
||||||
|
dose: { label: "Dose", icon: "alarm-outline", tone: "brand" },
|
||||||
|
loyalty: { label: "Loyalty", icon: "star-outline", tone: "brand" },
|
||||||
|
promo: { label: "Promo", icon: "pricetag-outline", tone: "neutral" },
|
||||||
|
system: { label: "Tips", icon: "information-circle-outline", tone: "neutral" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationsScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const items = useNotificationStore((s) => s.items);
|
||||||
|
const markRead = useNotificationStore((s) => s.markRead);
|
||||||
|
const markAllRead = useNotificationStore((s) => s.markAllRead);
|
||||||
|
const remove = useNotificationStore((s) => s.remove);
|
||||||
|
const unread = items.filter((n) => !n.read).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
title={t("common.notifications")}
|
||||||
|
subtitle={
|
||||||
|
unread
|
||||||
|
? `${unread} unread · separate from your order history`
|
||||||
|
: "Alerts for doses, Rx, loyalty and orders"
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
unread ? (
|
||||||
|
<Pressable onPress={markAllRead} hitSlop={8}>
|
||||||
|
<Text variant="label" color={colors.brandText}>
|
||||||
|
Read all
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card elevated={false} tone="brand">
|
||||||
|
<Text variant="bodySm" color={colors.muted}>
|
||||||
|
This inbox is not your Orders list. Order history lives under Profile → Orders.
|
||||||
|
Notifications include dose reminders, Rx updates, loyalty, promos, and status
|
||||||
|
alerts that may deep-link into an order.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Open order history"
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
fullWidth={false}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
onPress={() => router.push(routes.orders)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="notifications-outline"
|
||||||
|
title="No notifications"
|
||||||
|
body="Dose reminders, Rx status, and order alerts will show up here."
|
||||||
|
actionTitle="Browse shop"
|
||||||
|
onAction={() => router.push(routes.shop)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
items.map((n) => {
|
||||||
|
const meta = kindMeta[n.kind];
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={n.id}
|
||||||
|
onPress={() => {
|
||||||
|
markRead(n.id);
|
||||||
|
if (n.href) router.push(n.href as never);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: n.read ? colors.surface : colors.brandWash,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: n.read ? colors.line : colors.brandBorder,
|
||||||
|
padding: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", gap: 12, alignItems: "flex-start" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={meta.icon} size={18} color={colors.brandText} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge label={meta.label} tone={meta.tone} />
|
||||||
|
{!n.read ? <Badge label="New" solid /> : null}
|
||||||
|
</View>
|
||||||
|
<Text variant="h3" style={{ marginTop: 8 }}>
|
||||||
|
{n.title}
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
{n.body}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
|
||||||
|
{new Date(n.createdAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => remove(n.id)}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityLabel="Dismiss"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={18} color={colors.subtle} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -64,9 +64,14 @@ export default function OrderDetailScreen() {
|
||||||
<Card>
|
<Card>
|
||||||
<Text variant="caption" color={colors.subtle}>
|
<Text variant="caption" color={colors.subtle}>
|
||||||
{order.id}
|
{order.id}
|
||||||
|
{order.isGuestOrder ? " · Guest" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h2" style={{ marginTop: 4 }}>
|
<Text variant="h2" style={{ marginTop: 4 }}>
|
||||||
{order.fulfillment === "pickup" ? "Pickup order" : "Delivery order"}
|
{order.anyBranch
|
||||||
|
? "Any-branch pickup"
|
||||||
|
: order.fulfillment === "pickup"
|
||||||
|
? "Pickup order"
|
||||||
|
: "Delivery order"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||||
{order.customerName} · {order.customerPhone}
|
{order.customerName} · {order.customerPhone}
|
||||||
|
|
@ -78,6 +83,22 @@ export default function OrderDetailScreen() {
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{order.pickupCode ? (
|
||||||
|
<Card tone="brand" elevated={false}>
|
||||||
|
<Text variant="caption" color={colors.muted}>
|
||||||
|
Show this at the branch counter
|
||||||
|
</Text>
|
||||||
|
<Text variant="display" style={{ marginTop: 6, letterSpacing: 2 }}>
|
||||||
|
{order.pickupCode}
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
|
||||||
|
{order.anyBranch
|
||||||
|
? "Valid at any Gishen Pharmacy branch for fulfillment."
|
||||||
|
: `Pickup at ${branch?.name ?? "selected branch"}.`}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Section title="Items">
|
<Section title="Items">
|
||||||
<Card elevated={false}>
|
<Card elevated={false}>
|
||||||
{order.items.map((item, index) => (
|
{order.items.map((item, index) => (
|
||||||
|
|
@ -124,10 +145,17 @@ export default function OrderDetailScreen() {
|
||||||
{order.addressLine ? (
|
{order.addressLine ? (
|
||||||
<InfoRow label="Address" value={order.addressLine} />
|
<InfoRow label="Address" value={order.addressLine} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{order.pickupCode ? (
|
||||||
|
<InfoRow label="Pickup code" value={order.pickupCode} />
|
||||||
|
) : null}
|
||||||
{branch ? (
|
{branch ? (
|
||||||
<InfoRow label="Branch" value={`${branch.name} · ${branch.hours}`} last />
|
<InfoRow label="Branch" value={`${branch.name} · ${branch.hours}`} last />
|
||||||
) : (
|
) : (
|
||||||
<InfoRow label="Branch" value="—" last />
|
<InfoRow
|
||||||
|
label="Branch"
|
||||||
|
value={order.anyBranch ? "Any Gishen branch" : "—"}
|
||||||
|
last
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { Image, ScrollView, View } from "react-native";
|
import { useState } from "react";
|
||||||
|
import { Alert, Image, ScrollView, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
import { AppHeader } from "@/components/ui/Header";
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
import { Screen } from "@/components/ui/Screen";
|
import { Screen } from "@/components/ui/Screen";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
|
|
@ -25,11 +28,52 @@ const toneFor = {
|
||||||
export default function OrdersScreen() {
|
export default function OrdersScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const orders = useOrdersStore((s) => s.orders);
|
const orders = useOrdersStore((s) => s.orders);
|
||||||
|
const getByPickupCode = useOrdersStore((s) => s.getByPickupCode);
|
||||||
|
const [lookup, setLookup] = useState("");
|
||||||
|
|
||||||
|
const findByCode = () => {
|
||||||
|
const order = getByPickupCode(lookup);
|
||||||
|
if (!order) {
|
||||||
|
Alert.alert(
|
||||||
|
"Code not found",
|
||||||
|
"Check the GSH-XXXX code from checkout. Demo sample: GSH-GUEST"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(routes.orderDetail(order.id));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<AppHeader title={t("common.orders")} subtitle="Track delivery and pickup status" />
|
<AppHeader
|
||||||
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40 }}>
|
title={t("common.orders")}
|
||||||
|
subtitle="Order history — not the same as notifications"
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3">Find with pickup code</Text>
|
||||||
|
<Text variant="caption" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
Guests can open an order by entering the quick code from checkout.
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: 12, gap: 10 }}>
|
||||||
|
<Field
|
||||||
|
label="Pickup code"
|
||||||
|
value={lookup}
|
||||||
|
onChangeText={setLookup}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
placeholder="GSH-GUEST"
|
||||||
|
icon="key-outline"
|
||||||
|
/>
|
||||||
|
<Button title="Look up order" variant="outline" onPress={findByCode} />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{orders.length === 0 ? (
|
{orders.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="receipt-outline"
|
icon="receipt-outline"
|
||||||
|
|
@ -39,16 +83,21 @@ export default function OrdersScreen() {
|
||||||
onAction={() => router.push(routes.shop)}
|
onAction={() => router.push(routes.shop)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: 12 }}>
|
orders.map((o) => (
|
||||||
{orders.map((o) => (
|
|
||||||
<Card key={o.id} onPress={() => router.push(routes.orderDetail(o.id))}>
|
<Card key={o.id} onPress={() => router.push(routes.orderDetail(o.id))}>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text variant="h3">
|
<Text variant="h3">
|
||||||
{o.fulfillment === "pickup" ? "Pickup" : "Delivery"} · {o.areaLabel}
|
{o.anyBranch
|
||||||
|
? "Any-branch pickup"
|
||||||
|
: o.fulfillment === "pickup"
|
||||||
|
? "Pickup"
|
||||||
|
: "Delivery"}{" "}
|
||||||
|
· {o.areaLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
{new Date(o.createdAt).toLocaleString()} · {o.paymentMethod}
|
{new Date(o.createdAt).toLocaleString()} · {o.paymentMethod}
|
||||||
|
{o.pickupCode ? ` · ${o.pickupCode}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Badge label={o.status} tone={toneFor[o.status]} />
|
<Badge label={o.status} tone={toneFor[o.status]} />
|
||||||
|
|
@ -98,8 +147,7 @@ export default function OrdersScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { Image, ScrollView, View } from "react-native";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, Image, ScrollView, View } from "react-native";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, InfoRow, Section } from "@/components/ui/Card";
|
import { Card, InfoRow, Section } from "@/components/ui/Card";
|
||||||
|
import { DoseReminderPanel } from "@/components/ui/DoseReminderPanel";
|
||||||
import { AppHeader } from "@/components/ui/Header";
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
import { Screen } from "@/components/ui/Screen";
|
import { Screen } from "@/components/ui/Screen";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { products } from "@/lib/data/products";
|
import { products } from "@/lib/data/products";
|
||||||
|
import { scheduleDoseReminder } from "@/lib/notifications";
|
||||||
import { useCartStore } from "@/lib/stores/cartStore";
|
import { useCartStore } from "@/lib/stores/cartStore";
|
||||||
|
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||||||
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
import { colors, gutter, radii } from "@/lib/theme";
|
import { colors, gutter } from "@/lib/theme";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
const statusTone = {
|
const statusTone = {
|
||||||
|
|
@ -29,14 +33,27 @@ const lineTone = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function PrescriptionDetailScreen() {
|
export default function PrescriptionDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const params = useLocalSearchParams<{ id: string }>();
|
||||||
|
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||||
const rx = usePrescriptionStore((s) => s.prescriptions.find((p) => p.id === id));
|
const rx = usePrescriptionStore((s) => s.prescriptions.find((p) => p.id === id));
|
||||||
const reminders = usePrescriptionStore((s) =>
|
const allReminders = usePrescriptionStore((s) => s.reminders);
|
||||||
s.reminders.filter((r) => r.prescriptionId === id)
|
const doseLogs = usePrescriptionStore((s) => s.doseLogs);
|
||||||
|
const reminders = useMemo(
|
||||||
|
() => allReminders.filter((r) => r.prescriptionId === id),
|
||||||
|
[allReminders, id]
|
||||||
|
);
|
||||||
|
const reminder = reminders[0];
|
||||||
|
const reminderId = `rem_${id}`;
|
||||||
|
const recentLogs = useMemo(
|
||||||
|
() => doseLogs.filter((l) => l.reminderId === reminderId).slice(0, 5),
|
||||||
|
[doseLogs, reminderId]
|
||||||
);
|
);
|
||||||
const markRefilled = usePrescriptionStore((s) => s.markRefilled);
|
const markRefilled = usePrescriptionStore((s) => s.markRefilled);
|
||||||
const updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus);
|
const updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus);
|
||||||
|
const confirmDose = usePrescriptionStore((s) => s.confirmDose);
|
||||||
const addProduct = useCartStore((s) => s.addProduct);
|
const addProduct = useCartStore((s) => s.addProduct);
|
||||||
|
const pushNotification = useNotificationStore((s) => s.push);
|
||||||
|
const [showSetup, setShowSetup] = useState(!reminder?.enabled);
|
||||||
|
|
||||||
if (!rx) {
|
if (!rx) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -58,59 +75,217 @@ export default function PrescriptionDetailScreen() {
|
||||||
return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const pendingLog = recentLogs.find((l) => l.status === "pending");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<AppHeader title="Prescription detail" />
|
<AppHeader
|
||||||
|
title="Prescription"
|
||||||
|
subtitle={rx.patientName}
|
||||||
|
right={<Badge label={rx.status.replace("_", " ")} tone={statusTone[rx.status]} />}
|
||||||
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: gutter,
|
paddingHorizontal: gutter,
|
||||||
paddingBottom: 40,
|
paddingBottom: 40,
|
||||||
gap: 16,
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card elevated style={{ padding: 0, overflow: "hidden" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 160,
|
||||||
|
backgroundColor: colors.brandSoft,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rx.imageUris[0] ? (
|
{rx.imageUris[0] ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: rx.imageUris[0] }}
|
source={{ uri: rx.imageUris[0] }}
|
||||||
style={{
|
style={{ width: "100%", height: "100%" }}
|
||||||
width: "100%",
|
|
||||||
height: 180,
|
|
||||||
borderRadius: radii.card,
|
|
||||||
backgroundColor: colors.surfaceAlt,
|
|
||||||
}}
|
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<Ionicons name="document-text" size={48} color={colors.brandText} />
|
||||||
<Card>
|
)}
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ padding: 16, gap: 10 }}>
|
||||||
<Text variant="h1">{rx.medicineName}</Text>
|
<Text variant="h1">{rx.medicineName}</Text>
|
||||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
<Text variant="bodySm" color={colors.muted}>
|
||||||
For {rx.patientName}
|
For {rx.patientName} · {rx.medicines.length} item
|
||||||
|
{rx.medicines.length === 1 ? "" : "s"} on this Rx
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
<Badge
|
||||||
|
label={`${rx.daysSupply} day supply`}
|
||||||
|
tone="neutral"
|
||||||
|
icon="time-outline"
|
||||||
|
/>
|
||||||
|
{dueIn !== null ? (
|
||||||
|
<Badge
|
||||||
|
label={dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
|
||||||
|
tone={dueIn <= 3 ? "warn" : "brand"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Badge label="Awaiting first fill" tone="warn" />
|
||||||
|
)}
|
||||||
|
{reminder?.enabled ? (
|
||||||
|
<Badge
|
||||||
|
label={reminder.times.join(" · ")}
|
||||||
|
tone="brand"
|
||||||
|
icon="alarm-outline"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Badge label="Reminders off" tone="neutral" icon="alarm-outline" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Section title="Dose calendar & push">
|
||||||
|
<Card>
|
||||||
|
{reminder?.enabled && !showSetup ? (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.brandSoft,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="notifications" size={20} color={colors.brandText} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">Daily reminders on</Text>
|
||||||
|
<Text variant="caption" color={colors.muted} style={{ marginTop: 2 }}>
|
||||||
|
Push at {reminder.times.join(" · ")}
|
||||||
|
{reminder.escalateOnMiss ? " · escalate if missed" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Badge
|
</View>
|
||||||
label={rx.status.replace("_", " ")}
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||||||
tone={statusTone[rx.status]}
|
<Button
|
||||||
|
title="Edit times"
|
||||||
|
size="md"
|
||||||
|
fullWidth={false}
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => setShowSetup(true)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Open calendar"
|
||||||
|
size="md"
|
||||||
|
fullWidth={false}
|
||||||
|
icon="calendar-outline"
|
||||||
|
onPress={() => router.push(routes.rxCalendar(rx.id))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Test push now"
|
||||||
|
size="md"
|
||||||
|
fullWidth={false}
|
||||||
|
variant="soft"
|
||||||
|
icon="flash-outline"
|
||||||
|
onPress={async () => {
|
||||||
|
await scheduleDoseReminder({
|
||||||
|
title: `Time for ${rx.medicineName}`,
|
||||||
|
body: "Test dose reminder from Gishen",
|
||||||
|
secondsFromNow: 5,
|
||||||
|
data: { prescriptionId: rx.id, type: "dose" },
|
||||||
|
});
|
||||||
|
pushNotification({
|
||||||
|
kind: "dose",
|
||||||
|
title: "Test dose reminder",
|
||||||
|
body: `${rx.medicineName} push scheduled in ~5s`,
|
||||||
|
href: routes.rxDetail(rx.id),
|
||||||
|
});
|
||||||
|
Alert.alert("Scheduled", "Test push in about 5 seconds.");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{dueIn !== null ? (
|
</View>
|
||||||
<Text
|
|
||||||
variant="label"
|
|
||||||
color={dueIn <= 3 ? colors.warn : colors.brandText}
|
|
||||||
style={{ marginTop: 12 }}
|
|
||||||
>
|
|
||||||
{dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<Text variant="label" color={colors.warn} style={{ marginTop: 12 }}>
|
<DoseReminderPanel
|
||||||
Awaiting first fill
|
rx={rx}
|
||||||
</Text>
|
reminder={reminder}
|
||||||
|
onScheduled={() => setShowSetup(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{pendingLog ? (
|
||||||
|
<Card tone="brand" elevated={false} style={{ marginTop: 10 }}>
|
||||||
|
<Text variant="h3">Confirm today’s dose</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
A dose is pending confirmation.
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", gap: 10, marginTop: 12 }}>
|
||||||
|
<Button
|
||||||
|
title="Taken"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onPress={() => {
|
||||||
|
confirmDose(pendingLog.id);
|
||||||
|
Alert.alert("Logged", "Dose marked as taken.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Snooze 10m"
|
||||||
|
variant="outline"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onPress={() =>
|
||||||
|
scheduleDoseReminder({
|
||||||
|
title: `Snoozed: ${rx.medicineName}`,
|
||||||
|
body: "Take your dose when ready",
|
||||||
|
secondsFromNow: 600,
|
||||||
|
data: { prescriptionId: rx.id, type: "dose" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{recentLogs.length ? (
|
||||||
|
<View style={{ marginTop: 12, gap: 8 }}>
|
||||||
|
<Text variant="label" color={colors.muted}>
|
||||||
|
Recent dose logs
|
||||||
|
</Text>
|
||||||
|
{recentLogs.map((l) => (
|
||||||
|
<View
|
||||||
|
key={l.id}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
label={l.status.replace("_", " ")}
|
||||||
|
tone={
|
||||||
|
l.status === "taken"
|
||||||
|
? "brand"
|
||||||
|
: l.status === "missed_escalated"
|
||||||
|
? "danger"
|
||||||
|
: "warn"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ flex: 1 }}>
|
||||||
|
{new Date(l.scheduledAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Medicines on this Rx">
|
<Section title="Medicines on this Rx">
|
||||||
<Card elevated={false}>
|
<Card elevated={false} padded={false}>
|
||||||
{rx.medicines.map((m, i) => (
|
{rx.medicines.map((m, i) => (
|
||||||
<View
|
<View
|
||||||
key={m.name}
|
key={m.name}
|
||||||
|
|
@ -118,14 +293,28 @@ export default function PrescriptionDetailScreen() {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingVertical: 12,
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
borderBottomWidth: i === rx.medicines.length - 1 ? 0 : 1,
|
borderBottomWidth: i === rx.medicines.length - 1 ? 0 : 1,
|
||||||
borderBottomColor: colors.line,
|
borderBottomColor: colors.line,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}>
|
||||||
<Ionicons name="medkit-outline" size={18} color={colors.brandText} />
|
<View
|
||||||
<Text variant="h3">{m.name}</Text>
|
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>
|
</View>
|
||||||
<Badge label={m.status} tone={lineTone[m.status]} />
|
<Badge label={m.status} tone={lineTone[m.status]} />
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -153,34 +342,7 @@ export default function PrescriptionDetailScreen() {
|
||||||
</Card>
|
</Card>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Reminders">
|
<View style={{ gap: 10 }}>
|
||||||
{reminders.length === 0 ? (
|
|
||||||
<Card elevated={false}>
|
|
||||||
<Text variant="bodySm" color={colors.muted}>
|
|
||||||
No reminders yet. Set times so we can nudge you — and escalate if a dose is missed.
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
reminders.map((r) => (
|
|
||||||
<Card key={r.id} elevated={false} style={{ marginBottom: 8 }}>
|
|
||||||
<Text variant="h3">{r.medicineName}</Text>
|
|
||||||
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
|
||||||
{r.times.join(" · ")} · {r.graceMinutes} min grace
|
|
||||||
{r.escalateOnMiss ? " · escalate if missed" : ""}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
title="Manage calendar reminders"
|
|
||||||
variant="soft"
|
|
||||||
icon="calendar-outline"
|
|
||||||
onPress={() => router.push(routes.rxCalendar(rx.id))}
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<View style={{ gap: 10, marginTop: 8 }}>
|
|
||||||
<Button
|
<Button
|
||||||
title="Reorder medicines"
|
title="Reorder medicines"
|
||||||
icon="bag-add-outline"
|
icon="bag-add-outline"
|
||||||
|
|
@ -195,7 +357,10 @@ export default function PrescriptionDetailScreen() {
|
||||||
<Button
|
<Button
|
||||||
title="Mark as refilled"
|
title="Mark as refilled"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onPress={() => markRefilled(rx.id)}
|
onPress={() => {
|
||||||
|
markRefilled(rx.id);
|
||||||
|
Alert.alert("Updated", "Marked as refilled today.");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{rx.status === "under_review" || rx.status === "submitted" ? (
|
{rx.status === "under_review" || rx.status === "submitted" ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,21 @@ import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { Screen } from "@/components/ui/Screen";
|
import { Screen } from "@/components/ui/Screen";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import {
|
import {
|
||||||
|
scheduleDailyDoseReminders,
|
||||||
scheduleDoseReminder,
|
scheduleDoseReminder,
|
||||||
scheduleEscalationCheck,
|
scheduleEscalationCheck,
|
||||||
} from "@/lib/notifications";
|
} from "@/lib/notifications";
|
||||||
import { useEmergencyContactStore } from "@/lib/stores/emergencyContactStore";
|
import { useEmergencyContactStore } from "@/lib/stores/emergencyContactStore";
|
||||||
|
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||||||
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
import { colors, gutter } from "@/lib/theme";
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
const PRESETS = ["08:00", "12:00", "14:00", "18:00", "20:00"] as const;
|
const PRESETS = ["08:00", "12:00", "14:00", "18:00", "20:00"] as const;
|
||||||
|
|
||||||
export default function RxCalendarScreen() {
|
export default function RxCalendarScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const params = useLocalSearchParams<{ id: string }>();
|
||||||
|
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||||
const rx = usePrescriptionStore((s) => s.prescriptions.find((p) => p.id === id));
|
const rx = usePrescriptionStore((s) => s.prescriptions.find((p) => p.id === id));
|
||||||
const upsertReminder = usePrescriptionStore((s) => s.upsertReminder);
|
const upsertReminder = usePrescriptionStore((s) => s.upsertReminder);
|
||||||
const schedulePendingDose = usePrescriptionStore((s) => s.schedulePendingDose);
|
const schedulePendingDose = usePrescriptionStore((s) => s.schedulePendingDose);
|
||||||
|
|
@ -37,6 +41,7 @@ export default function RxCalendarScreen() {
|
||||||
const setEscalationEnabled = useEmergencyContactStore((s) => s.setEscalationEnabled);
|
const setEscalationEnabled = useEmergencyContactStore((s) => s.setEscalationEnabled);
|
||||||
const contacts = useEmergencyContactStore((s) => s.contacts);
|
const contacts = useEmergencyContactStore((s) => s.contacts);
|
||||||
const markEscalated = useEmergencyContactStore((s) => s.markEscalated);
|
const markEscalated = useEmergencyContactStore((s) => s.markEscalated);
|
||||||
|
const pushNotification = useNotificationStore((s) => s.push);
|
||||||
const [times, setTimes] = useState(reminder?.times.join(",") || "08:00,20:00");
|
const [times, setTimes] = useState(reminder?.times.join(",") || "08:00,20:00");
|
||||||
const [pendingLogId, setPendingLogId] = useState<string | null>(null);
|
const [pendingLogId, setPendingLogId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -85,15 +90,23 @@ export default function RxCalendarScreen() {
|
||||||
escalateOnMiss: escalationEnabled,
|
escalateOnMiss: escalationEnabled,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
await scheduleDoseReminder({
|
const result = await scheduleDailyDoseReminders({
|
||||||
title: `Time for ${rx.medicineName}`,
|
prescriptionId: rx.id,
|
||||||
body: "Tap to confirm you took your dose",
|
medicineName: rx.medicineName,
|
||||||
secondsFromNow: 15,
|
times: parsed,
|
||||||
data: { prescriptionId: rx.id, type: "dose" },
|
|
||||||
});
|
});
|
||||||
const log = schedulePendingDose(reminderId);
|
const log = schedulePendingDose(reminderId);
|
||||||
setPendingLogId(log.id);
|
setPendingLogId(log.id);
|
||||||
|
|
||||||
|
pushNotification({
|
||||||
|
kind: "dose",
|
||||||
|
title: `Calendar set · ${rx.medicineName}`,
|
||||||
|
body: result.granted
|
||||||
|
? `Daily push at ${result.times.join(", ")}`
|
||||||
|
: `Saved ${result.times.join(", ")} — enable notifications for push`,
|
||||||
|
href: routes.rxCalendar(rx.id),
|
||||||
|
});
|
||||||
|
|
||||||
if (escalationEnabled && contacts.length) {
|
if (escalationEnabled && contacts.length) {
|
||||||
await scheduleEscalationCheck({
|
await scheduleEscalationCheck({
|
||||||
title: "Dose not confirmed",
|
title: "Dose not confirmed",
|
||||||
|
|
@ -118,7 +131,12 @@ export default function RxCalendarScreen() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Alert.alert("Scheduled", `Reminders at ${parsed.join(", ")}. Demo push in ~15s.`);
|
Alert.alert(
|
||||||
|
result.granted ? "Scheduled" : "Saved",
|
||||||
|
result.granted
|
||||||
|
? `Daily calendar push at ${result.times.join(", ")}. Demo ping in ~12s.`
|
||||||
|
: `Reminders saved at ${result.times.join(", ")}. Allow notifications for daily push.`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,7 @@
|
||||||
import { router } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
import { ScrollView, View } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { Card } from "@/components/ui/Card";
|
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
|
||||||
import { AppHeader } from "@/components/ui/Header";
|
|
||||||
import { Screen } from "@/components/ui/Screen";
|
|
||||||
import { Text } from "@/components/ui/Text";
|
|
||||||
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
|
||||||
import { colors, gutter } from "@/lib/theme";
|
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
const statusTone = {
|
/** Legacy stack path — prescription management lives on the main tab. */
|
||||||
submitted: "neutral",
|
export default function RxListRedirect() {
|
||||||
under_review: "warn",
|
return <Redirect href={routes.rxList as never} />;
|
||||||
approved: "brand",
|
|
||||||
queried: "info",
|
|
||||||
rejected: "danger",
|
|
||||||
filled: "brand",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export default function RxListScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Screen>
|
|
||||||
<AppHeader
|
|
||||||
title={t("common.prescriptions")}
|
|
||||||
subtitle="Scan, track status, refill and remind"
|
|
||||||
/>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: gutter,
|
|
||||||
paddingBottom: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
title={t("common.scanRx")}
|
|
||||||
icon="scan-outline"
|
|
||||||
onPress={() => router.push(routes.rxScan)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{prescriptions.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon="document-text-outline"
|
|
||||||
title="No prescriptions yet"
|
|
||||||
body="Scan or upload a prescription to get started."
|
|
||||||
actionTitle="Scan prescription"
|
|
||||||
onAction={() => router.push(routes.rxScan)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={{ marginTop: 16, gap: 12 }}>
|
|
||||||
{prescriptions.map((rx) => {
|
|
||||||
const dueIn = (() => {
|
|
||||||
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));
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={rx.id} onPress={() => router.push(routes.rxDetail(rx.id))}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text variant="h3">{rx.medicineName}</Text>
|
|
||||||
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
||||||
{rx.patientName} · {rx.medicines.length} item
|
|
||||||
{rx.medicines.length === 1 ? "" : "s"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Badge
|
|
||||||
label={rx.status.replace("_", " ")}
|
|
||||||
tone={statusTone[rx.status]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{dueIn !== null ? (
|
|
||||||
<Text
|
|
||||||
variant="label"
|
|
||||||
color={dueIn <= 3 ? colors.warn : colors.brandText}
|
|
||||||
style={{ marginTop: 10 }}
|
|
||||||
>
|
|
||||||
{dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text variant="label" color={colors.warn} style={{ marginTop: 10 }}>
|
|
||||||
Waiting for first fill
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
|
|
||||||
Tap for details, reminders and reorder
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,16 @@ export default function ProductDetailScreen() {
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton icon="chevron-back" onPress={() => router.back()} />
|
<IconButton
|
||||||
|
icon="arrow-back"
|
||||||
|
accessibilityLabel="Go back"
|
||||||
|
onPress={() => router.back()}
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={liked ? "heart" : "heart-outline"}
|
icon={liked ? "heart" : "heart-outline"}
|
||||||
onPress={() => toggle(product.id)}
|
onPress={() => toggle(product.id)}
|
||||||
color={liked ? colors.danger : colors.ink}
|
color={liked ? colors.danger : colors.ink}
|
||||||
|
accessibilityLabel={liked ? "Remove favorite" : "Add favorite"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Image
|
<Image
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@ import * as ImagePicker from "expo-image-picker";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
|
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, MenuRow } from "@/components/ui/Card";
|
import { Card, MenuRow } from "@/components/ui/Card";
|
||||||
import { Field } from "@/components/ui/Field";
|
import { Field } from "@/components/ui/Field";
|
||||||
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
import { useAuthStore } from "@/lib/stores/authStore";
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
|
@ -21,7 +21,6 @@ import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils";
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setAvatar = useAuthStore((s) => s.setAvatar);
|
const setAvatar = useAuthStore((s) => s.setAvatar);
|
||||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||||
|
|
@ -95,9 +94,9 @@ export default function ProfileScreen() {
|
||||||
{ label: t("common.switchProfile"), href: routes.profileSwitch, icon: "swap-horizontal", subtitle: mode },
|
{ label: t("common.switchProfile"), href: routes.profileSwitch, icon: "swap-horizontal", subtitle: mode },
|
||||||
{ label: t("common.joinOrg"), href: routes.profileJoin, icon: "business-outline" },
|
{ label: t("common.joinOrg"), href: routes.profileJoin, icon: "business-outline" },
|
||||||
{ label: t("common.orders"), href: routes.orders, icon: "receipt-outline", subtitle: "Track delivery" },
|
{ label: t("common.orders"), href: routes.orders, icon: "receipt-outline", subtitle: "Track delivery" },
|
||||||
{ label: t("common.prescriptions"), href: routes.rxList, icon: "document-text-outline" },
|
{ label: t("common.notifications"), href: routes.notifications, icon: "notifications-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.loyalty"), href: routes.loyalty, icon: "star-outline", subtitle: `${user?.points ?? 0} pts` },
|
||||||
{ label: t("common.family"), href: routes.family, icon: "people-outline" },
|
|
||||||
{ label: t("common.subscriptions"), href: routes.subscriptions, icon: "repeat-outline" },
|
{ label: t("common.subscriptions"), href: routes.subscriptions, icon: "repeat-outline" },
|
||||||
{ label: t("common.emergencyContacts"), href: routes.emergencyContacts, icon: "alert-circle-outline" },
|
{ label: t("common.emergencyContacts"), href: routes.emergencyContacts, icon: "alert-circle-outline" },
|
||||||
{ label: t("common.branches"), href: routes.branches, icon: "location-outline" },
|
{ label: t("common.branches"), href: routes.branches, icon: "location-outline" },
|
||||||
|
|
@ -109,17 +108,15 @@ export default function ProfileScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
|
<AppHeader title={t("common.profile")} subtitle="Account and preferences" />
|
||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top + 16,
|
paddingBottom: 40,
|
||||||
paddingBottom: TAB_BAR_INSET + 24,
|
|
||||||
paddingHorizontal: gutter,
|
paddingHorizontal: gutter,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text variant="h1">{t("common.profile")}</Text>
|
<Card style={{ marginTop: 4 }}>
|
||||||
|
|
||||||
<Card style={{ marginTop: 16 }}>
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
|
||||||
<Pressable onPress={changePhoto}>
|
<Pressable onPress={changePhoto}>
|
||||||
<View>
|
<View>
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, InfoRow } from "@/components/ui/Card";
|
import { Card, InfoRow } from "@/components/ui/Card";
|
||||||
|
|
@ -16,6 +15,7 @@ import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { QtyStepper } from "@/components/ui/QtyStepper";
|
import { QtyStepper } from "@/components/ui/QtyStepper";
|
||||||
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
||||||
import { StickyBar } from "@/components/ui/StickyBar";
|
import { StickyBar } from "@/components/ui/StickyBar";
|
||||||
|
import { TabTopBar } from "@/components/ui/TabTopBar";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { DELIVERY_FEE, DEMO_VOUCHER, voucherDiscount } from "@/lib/commerce";
|
import { DELIVERY_FEE, DEMO_VOUCHER, voucherDiscount } from "@/lib/commerce";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
|
|
@ -25,7 +25,6 @@ import { formatETB } from "@/lib/utils";
|
||||||
|
|
||||||
export default function CartScreen() {
|
export default function CartScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
const setQuantity = useCartStore((s) => s.setQuantity);
|
const setQuantity = useCartStore((s) => s.setQuantity);
|
||||||
const removeItem = useCartStore((s) => s.removeItem);
|
const removeItem = useCartStore((s) => s.removeItem);
|
||||||
|
|
@ -38,9 +37,9 @@ export default function CartScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
|
<TabTopBar subtitle={t("common.cart")} />
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top + 16,
|
|
||||||
paddingBottom: TAB_BAR_INSET + 100,
|
paddingBottom: TAB_BAR_INSET + 100,
|
||||||
paddingHorizontal: gutter,
|
paddingHorizontal: gutter,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { ProductCard, GRID_CARD_GAP } from "@/components/ui/ProductCard";
|
import { ProductCard, GRID_CARD_GAP } from "@/components/ui/ProductCard";
|
||||||
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
||||||
|
import { TabTopBar } from "@/components/ui/TabTopBar";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { products } from "@/lib/data/products";
|
import { products } from "@/lib/data/products";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
|
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
|
||||||
import { gutter } from "@/lib/theme";
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
export default function FavoritesScreen() {
|
export default function FavoritesScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const ids = useFavoritesStore((s) => s.ids);
|
const ids = useFavoritesStore((s) => s.ids);
|
||||||
const items = products.filter((p) => ids.includes(p.id));
|
const items = products.filter((p) => ids.includes(p.id));
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
|
@ -21,15 +20,10 @@ export default function FavoritesScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<View
|
<TabTopBar subtitle={t("common.favorites")} />
|
||||||
style={{
|
<View style={{ paddingHorizontal: gutter, paddingBottom: 8 }}>
|
||||||
paddingTop: insets.top + 16,
|
|
||||||
paddingHorizontal: gutter,
|
|
||||||
paddingBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="h1">{t("common.favorites")}</Text>
|
<Text variant="h1">{t("common.favorites")}</Text>
|
||||||
<Text variant="bodySm" color="#8A9188" style={{ marginTop: 4 }}>
|
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
Saved products for quick reorder
|
Saved products for quick reorder
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,22 @@ import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { FlatList, Pressable, ScrollView, View } from "react-native";
|
import { FlatList, Pressable, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { ActionTile, CategoryTile } from "@/components/ui/CategoryTile";
|
import { ActionTile, CategoryTile } from "@/components/ui/CategoryTile";
|
||||||
import { Card, Section } from "@/components/ui/Card";
|
import { Card, Section } from "@/components/ui/Card";
|
||||||
import { ProductRow, ProductTile } from "@/components/ui/ProductCard";
|
import { ProductRow, ProductTile } from "@/components/ui/ProductCard";
|
||||||
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
||||||
import { SearchAction, SearchField } from "@/components/ui/SearchField";
|
import { SearchAction, SearchField } from "@/components/ui/SearchField";
|
||||||
|
import { TabTopBar } from "@/components/ui/TabTopBar";
|
||||||
import { Text } from "@/components/ui/Text";
|
import { Text } from "@/components/ui/Text";
|
||||||
import { products } from "@/lib/data/products";
|
import { products } from "@/lib/data/products";
|
||||||
import { categories, routes } from "@/lib/routes";
|
import { categories, routes } from "@/lib/routes";
|
||||||
import { useAuthStore } from "@/lib/stores/authStore";
|
|
||||||
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
import { colors, gutter, space } from "@/lib/theme";
|
import { colors, gutter, space } from "@/lib/theme";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const [category, setCategory] = useState("medicines");
|
const [category, setCategory] = useState("medicines");
|
||||||
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
|
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
|
||||||
|
|
||||||
|
|
@ -52,50 +48,14 @@ export default function HomeScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
|
<TabTopBar />
|
||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top + 12,
|
|
||||||
paddingBottom: TAB_BAR_INSET + 24,
|
paddingBottom: TAB_BAR_INSET + 24,
|
||||||
paddingHorizontal: gutter,
|
paddingHorizontal: gutter,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.push(routes.profile)}
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Avatar name={user?.name} uri={user?.avatarUri} size={46} />
|
|
||||||
<View>
|
|
||||||
<Text variant="caption" color={colors.subtle}>
|
|
||||||
Good day
|
|
||||||
</Text>
|
|
||||||
<Text variant="h3">{user?.name?.split(" ")[0] || "Guest"}</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.push(routes.orders)}
|
|
||||||
style={{
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.line,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="notifications-outline" size={20} color={colors.ink} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text variant="display" style={{ marginTop: 22 }}>
|
<Text variant="display" style={{ marginTop: 22 }}>
|
||||||
{t("common.trustedTitle")}
|
{t("common.trustedTitle")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
||||||
398
app/(root)/(tabs)/prescriptions.tsx
Normal file
398
app/(root)/(tabs)/prescriptions.tsx
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
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 { useTranslation } from "react-i18next";
|
||||||
|
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 { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
||||||
|
import { TabTopBar } from "@/components/ui/TabTopBar";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useFamilyStore } from "@/lib/stores/familyStore";
|
||||||
|
import {
|
||||||
|
usePrescriptionStore,
|
||||||
|
type Prescription,
|
||||||
|
type Reminder,
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrescriptionsTabScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
|
||||||
|
const reminders = usePrescriptionStore((s) => s.reminders);
|
||||||
|
const members = useFamilyStore((s) => s.members);
|
||||||
|
const inviteCode = useFamilyStore((s) => s.inviteCode);
|
||||||
|
const acceptInvite = useFamilyStore((s) => s.acceptInvite);
|
||||||
|
const removeMember = useFamilyStore((s) => s.removeMember);
|
||||||
|
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
||||||
|
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reminderByRx = useMemo(() => {
|
||||||
|
const map = new Map<string, Reminder>();
|
||||||
|
reminders.forEach((r) => map.set(r.prescriptionId, r));
|
||||||
|
return map;
|
||||||
|
}, [reminders]);
|
||||||
|
|
||||||
|
const activeReminderCount = reminders.filter((r) => r.enabled).length;
|
||||||
|
const needingSetup = prescriptions.filter((p) => !reminderByRx.get(p.id)?.enabled).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<TabTopBar subtitle={t("common.prescriptions")} />
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: TAB_BAR_INSET + 24,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text variant="h1">{t("common.prescriptions")}</Text>
|
||||||
|
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
|
Manage Rx, dose calendars, and family care shares
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", gap: 10 }}>
|
||||||
|
<Card elevated={false} tone="brand" style={{ flex: 1 }}>
|
||||||
|
<Text variant="caption" color={colors.muted}>
|
||||||
|
Active reminders
|
||||||
|
</Text>
|
||||||
|
<Text variant="h1" style={{ marginTop: 2 }}>
|
||||||
|
{activeReminderCount}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card elevated={false} style={{ flex: 1 }}>
|
||||||
|
<Text variant="caption" color={colors.muted}>
|
||||||
|
Need setup
|
||||||
|
</Text>
|
||||||
|
<Text variant="h1" style={{ marginTop: 2 }}>
|
||||||
|
{needingSetup}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={t("common.scanRx")}
|
||||||
|
icon="scan-outline"
|
||||||
|
onPress={() => router.push(routes.rxScan)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section title="Your prescriptions">
|
||||||
|
{prescriptions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
compact
|
||||||
|
icon="document-text-outline"
|
||||||
|
title="No prescriptions yet"
|
||||||
|
body="Scan or upload a prescription to start reminders."
|
||||||
|
actionTitle="Scan prescription"
|
||||||
|
onAction={() => router.push(routes.rxScan)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
{prescriptions.map((rx) => (
|
||||||
|
<RxCareCard
|
||||||
|
key={rx.id}
|
||||||
|
rx={rx}
|
||||||
|
reminder={reminderByRx.get(rx.id)}
|
||||||
|
expanded={expandedId === rx.id}
|
||||||
|
onToggleExpand={() =>
|
||||||
|
setExpandedId((cur) => (cur === rx.id ? null : rx.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
|
||||||
|
No family members yet. Invite someone to share Rx care.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: 10, marginTop: 4 }}>
|
||||||
|
{members.map((m) => {
|
||||||
|
const active = activeMemberId === m.id;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={m.id}
|
||||||
|
onPress={() => setActiveMember(active ? null : m.id)}
|
||||||
|
style={{
|
||||||
|
borderColor: active ? colors.brand : colors.line,
|
||||||
|
borderWidth: active ? 1.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", gap: 12, alignItems: "center" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
backgroundColor: colors.brandSoft,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="label" color={colors.brandText}>
|
||||||
|
{m.name.slice(0, 1).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{m.name}</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
||||||
|
{m.relationship}
|
||||||
|
{m.phone ? ` · ${m.phone}` : ""}
|
||||||
|
{active ? " · Active for Rx" : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge
|
||||||
|
label={m.status}
|
||||||
|
tone={m.status === "accepted" ? "brand" : "warn"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.status === "pending" ? (
|
||||||
|
<Button
|
||||||
|
title="Accept"
|
||||||
|
size="sm"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => acceptInvite(m.id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
title="Remove"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert("Remove member?", m.name, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Remove",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => removeMember(m.id),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -139,12 +139,15 @@ export function IconButton({
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: colors.surface;
|
: colors.surface;
|
||||||
const fg = color ?? (tone === "dark" ? colors.surface : colors.ink);
|
const fg = color ?? (tone === "dark" ? colors.surface : colors.ink);
|
||||||
|
// Ionicons arrows/chevrons sit optically left; nudge for visual centering.
|
||||||
|
const isBackIcon = icon === "arrow-back" || icon === "chevron-back";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
hitSlop={6}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
{
|
{
|
||||||
width: size,
|
width: size,
|
||||||
|
|
@ -158,7 +161,12 @@ export function IconButton({
|
||||||
tone === "surface" && { borderWidth: 1, borderColor: colors.line },
|
tone === "surface" && { borderWidth: 1, borderColor: colors.line },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name={icon} size={size * 0.46} color={fg} />
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size * 0.46}
|
||||||
|
color={fg}
|
||||||
|
style={isBackIcon ? { marginLeft: -1 } : undefined}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
202
components/ui/DoseReminderPanel.tsx
Normal file
202
components/ui/DoseReminderPanel.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, View } from "react-native";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Chip } from "@/components/ui/Chip";
|
||||||
|
import { SwitchRow } from "@/components/ui/Field";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { scheduleDailyDoseReminders } from "@/lib/notifications";
|
||||||
|
import { useEmergencyContactStore } from "@/lib/stores/emergencyContactStore";
|
||||||
|
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||||||
|
import {
|
||||||
|
usePrescriptionStore,
|
||||||
|
type Prescription,
|
||||||
|
type Reminder,
|
||||||
|
} from "@/lib/stores/prescriptionStore";
|
||||||
|
import { colors, radii } from "@/lib/theme";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
|
const PRESETS = ["08:00", "12:00", "14:00", "18:00", "20:00"] as const;
|
||||||
|
|
||||||
|
export function DoseReminderPanel({
|
||||||
|
rx,
|
||||||
|
reminder,
|
||||||
|
compact,
|
||||||
|
onScheduled,
|
||||||
|
}: {
|
||||||
|
rx: Prescription;
|
||||||
|
reminder?: Reminder | null;
|
||||||
|
compact?: boolean;
|
||||||
|
onScheduled?: () => void;
|
||||||
|
}) {
|
||||||
|
const upsertReminder = usePrescriptionStore((s) => s.upsertReminder);
|
||||||
|
const schedulePendingDose = usePrescriptionStore((s) => s.schedulePendingDose);
|
||||||
|
const grace = useEmergencyContactStore((s) => s.graceMinutesDefault);
|
||||||
|
const escalationEnabled = useEmergencyContactStore((s) => s.escalationEnabled);
|
||||||
|
const setEscalationEnabled = useEmergencyContactStore((s) => s.setEscalationEnabled);
|
||||||
|
const contacts = useEmergencyContactStore((s) => s.contacts);
|
||||||
|
const pushNotification = useNotificationStore((s) => s.push);
|
||||||
|
|
||||||
|
const [times, setTimes] = useState(
|
||||||
|
reminder?.times?.length ? reminder.times.join(",") : "08:00,20:00"
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() =>
|
||||||
|
times
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
[times]
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePreset = (t: string) => {
|
||||||
|
if (selected.includes(t)) {
|
||||||
|
setTimes(selected.filter((x) => x !== t).join(","));
|
||||||
|
} else {
|
||||||
|
setTimes([...selected, t].sort().join(","));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!selected.length) {
|
||||||
|
Alert.alert("Pick a time", "Choose at least one dose time.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const reminderId = `rem_${rx.id}`;
|
||||||
|
upsertReminder({
|
||||||
|
id: reminderId,
|
||||||
|
prescriptionId: rx.id,
|
||||||
|
medicineName: rx.medicineName,
|
||||||
|
times: selected,
|
||||||
|
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
graceMinutes: grace,
|
||||||
|
escalateOnMiss: escalationEnabled,
|
||||||
|
enabled: true,
|
||||||
|
memberId: rx.memberId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scheduleDailyDoseReminders({
|
||||||
|
prescriptionId: rx.id,
|
||||||
|
medicineName: rx.medicineName,
|
||||||
|
times: selected,
|
||||||
|
});
|
||||||
|
|
||||||
|
schedulePendingDose(reminderId);
|
||||||
|
|
||||||
|
pushNotification({
|
||||||
|
kind: "dose",
|
||||||
|
title: `Reminders on · ${rx.medicineName}`,
|
||||||
|
body: result.granted
|
||||||
|
? `Daily push at ${result.times.join(", ")}. Demo ping in ~12s.`
|
||||||
|
: `Saved ${result.times.join(", ")}. Enable notification permission for push.`,
|
||||||
|
href: routes.rxCalendar(rx.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
result.granted ? "Reminders scheduled" : "Reminders saved",
|
||||||
|
result.granted
|
||||||
|
? `Calendar push notifications set for ${result.times.join(", ")}. You'll get a demo notification shortly.`
|
||||||
|
: `Times saved (${result.times.join(", ")}). Allow notifications to receive daily push alerts.`
|
||||||
|
);
|
||||||
|
onScheduled?.();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={
|
||||||
|
compact
|
||||||
|
? {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.line,
|
||||||
|
gap: 10,
|
||||||
|
}
|
||||||
|
: { gap: 12 }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
<Text variant="h3" style={{ flex: 1 }}>
|
||||||
|
Dose times
|
||||||
|
</Text>
|
||||||
|
{reminder?.enabled ? <Badge label="Active" tone="brand" /> : <Badge label="Not set" tone="neutral" />}
|
||||||
|
</View>
|
||||||
|
{!compact ? (
|
||||||
|
<Text variant="caption" color={colors.muted}>
|
||||||
|
Pick daily times. We’ll schedule calendar push notifications for each slot.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<Chip
|
||||||
|
key={p}
|
||||||
|
label={p}
|
||||||
|
selected={selected.includes(p)}
|
||||||
|
onPress={() => togglePreset(p)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selected.length ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 6,
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
backgroundColor: colors.brandWash,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.brandBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="caption" color={colors.brandText}>
|
||||||
|
Schedule: {selected.join(" · ")} every day
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!compact ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: radii.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SwitchRow
|
||||||
|
icon="notifications-outline"
|
||||||
|
title="Escalate if missed"
|
||||||
|
subtitle={
|
||||||
|
contacts[0]
|
||||||
|
? `Notify ${contacts[0].name} after grace`
|
||||||
|
: "Add an emergency contact in profile"
|
||||||
|
}
|
||||||
|
value={escalationEnabled}
|
||||||
|
onValueChange={setEscalationEnabled}
|
||||||
|
last
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={busy ? "Scheduling…" : "Save & enable push"}
|
||||||
|
icon="alarm-outline"
|
||||||
|
onPress={save}
|
||||||
|
disabled={busy}
|
||||||
|
size={compact ? "md" : "lg"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -127,11 +127,14 @@ export function FloatingTabBar() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profile"
|
name="prescriptions"
|
||||||
options={{
|
options={{
|
||||||
title: t("common.profile"),
|
title: t("common.prescriptions"),
|
||||||
tabBarIcon: ({ focused }) => (
|
tabBarIcon: ({ focused }) => (
|
||||||
<TabIcon name={focused ? "person" : "person-outline"} focused={focused} />
|
<TabIcon
|
||||||
|
name={focused ? "medkit" : "medkit-outline"}
|
||||||
|
focused={focused}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ type Props = {
|
||||||
background?: string;
|
background?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared stack header. Back control and title share one row so they stay
|
||||||
|
* vertically centered with each other (subtitle stacks under the title).
|
||||||
|
*/
|
||||||
export function AppHeader({
|
export function AppHeader({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
|
@ -31,32 +35,74 @@ export function AppHeader({
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingTop: insets.top + 8,
|
paddingTop: insets.top + 6,
|
||||||
paddingHorizontal: gutter,
|
paddingHorizontal: gutter,
|
||||||
paddingBottom: large ? 4 : 12,
|
paddingBottom: large ? 4 : 10,
|
||||||
backgroundColor: background,
|
backgroundColor: background,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
minHeight: 44,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{back ? (
|
{back ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="chevron-back"
|
icon="arrow-back"
|
||||||
accessibilityLabel="Go back"
|
accessibilityLabel="Go back"
|
||||||
onPress={onBack ?? (() => router.back())}
|
onPress={onBack ?? (() => router.back())}
|
||||||
|
tone="surface"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!large && title ? (
|
|
||||||
<Text variant="h2" numberOfLines={1} style={{ flex: 1 }}>
|
{!large ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 40,
|
||||||
|
paddingRight: right ? 4 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<Text variant="h2" numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{subtitle ? (
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={colors.subtle}
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{ marginTop: title ? 2 : 0 }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{right ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "center",
|
||||||
|
maxWidth: "36%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{right}
|
{right}
|
||||||
</View>
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
{large && title ? (
|
{large && title ? (
|
||||||
<View style={{ marginTop: 16, marginBottom: 8 }}>
|
<View style={{ marginTop: back || right ? 14 : 8, marginBottom: 4 }}>
|
||||||
<Text variant="h1">{title}</Text>
|
<Text variant="h1">{title}</Text>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
|
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
|
|
@ -65,12 +111,6 @@ export function AppHeader({
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!large && subtitle ? (
|
|
||||||
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2, marginLeft: 52 }}>
|
|
||||||
{subtitle}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
components/ui/TabTopBar.tsx
Normal file
103
components/ui/TabTopBar.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
function greetingForNow() {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 12) return "Good morning";
|
||||||
|
if (hour < 17) return "Good afternoon";
|
||||||
|
return "Good evening";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared top bar for all bottom-tab screens (avatar → profile, bell → notifications). */
|
||||||
|
export function TabTopBar({
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
/** Optional line under the user name, e.g. the current tab context */
|
||||||
|
subtitle?: string;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const unread = useNotificationStore((s) => s.unreadCount());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 10,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 10,
|
||||||
|
backgroundColor: colors.page,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push(routes.profile)}
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 12, flex: 1 }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Open profile"
|
||||||
|
>
|
||||||
|
<Avatar name={user?.name} uri={user?.avatarUri} size={46} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="caption" color={colors.subtle}>
|
||||||
|
{subtitle || greetingForNow()}
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" numberOfLines={1}>
|
||||||
|
{user?.name?.split(" ")[0] || "Guest"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push(routes.notifications)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Notifications"
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="notifications-outline" size={20} color={colors.ink} />
|
||||||
|
{unread > 0 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 9,
|
||||||
|
minWidth: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: colors.danger,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="micro" color={colors.surface}>
|
||||||
|
{unread > 9 ? "9+" : unread}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,16 @@ export const DEMO_VOUCHER = "GISHEN10";
|
||||||
export const DEMO_VOUCHER_PCT = 0.1;
|
export const DEMO_VOUCHER_PCT = 0.1;
|
||||||
export const DEMO_OTP = "1234";
|
export const DEMO_OTP = "1234";
|
||||||
|
|
||||||
|
/** Short branch-counter code guests show to fulfill pickup at any location. */
|
||||||
|
export function generatePickupCode() {
|
||||||
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
let body = "";
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
body += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||||
|
}
|
||||||
|
return `GSH-${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function voucherDiscount(subtotal: number, code: string | null | undefined) {
|
export function voucherDiscount(subtotal: number, code: string | null | undefined) {
|
||||||
if (!code) return 0;
|
if (!code) return 0;
|
||||||
if (code.trim().toUpperCase() === DEMO_VOUCHER) {
|
if (code.trim().toUpperCase() === DEMO_VOUCHER) {
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,29 @@ export const seedOrders: Order[] = [
|
||||||
customerName: "Kirukib Abebe",
|
customerName: "Kirukib Abebe",
|
||||||
customerPhone: "+251911000000",
|
customerPhone: "+251911000000",
|
||||||
notes: "Prescription refill — patient ID on file",
|
notes: "Prescription refill — patient ID on file",
|
||||||
|
pickupCode: "GSH-CMC1",
|
||||||
|
anyBranch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ord_demo_guest_code",
|
||||||
|
createdAt: daysAgo(1),
|
||||||
|
items: [asCartItem(panadol, 1)],
|
||||||
|
subtotal: panadol.price,
|
||||||
|
deliveryFee: 0,
|
||||||
|
discount: 0,
|
||||||
|
total: panadol.price,
|
||||||
|
fulfillment: "pickup",
|
||||||
|
branchId: null,
|
||||||
|
areaLabel: "Any Gishen branch",
|
||||||
|
paymentMethod: "pay_at_branch",
|
||||||
|
status: "confirmed",
|
||||||
|
riderAssigned: false,
|
||||||
|
customerName: "Guest Shopper",
|
||||||
|
customerPhone: "+251900111222",
|
||||||
|
notes: "Guest quick-code order",
|
||||||
|
pickupCode: "GSH-GUEST",
|
||||||
|
anyBranch: true,
|
||||||
|
isGuestOrder: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ord_demo_done",
|
id: "ord_demo_done",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,39 @@ export async function ensureNotificationPermissions() {
|
||||||
return req.granted;
|
return req.granted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function androidChannel() {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
return Notifications.setNotificationChannelAsync("gishen-reminders", {
|
||||||
|
name: "Gishen reminders",
|
||||||
|
importance: Notifications.AndroidImportance.HIGH,
|
||||||
|
sound: "default",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimeHHmm(value: string): { hour: number; minute: number } | null {
|
||||||
|
const m = value.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const hour = Number(m[1]);
|
||||||
|
const minute = Number(m[2]);
|
||||||
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||||
|
return { hour, minute };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds until the next occurrence of HH:mm (today or tomorrow). */
|
||||||
|
export function secondsUntilNextHHmm(value: string): number | null {
|
||||||
|
const parsed = parseTimeHHmm(value);
|
||||||
|
if (!parsed) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setHours(parsed.hour, parsed.minute, 0, 0);
|
||||||
|
if (next.getTime() <= now.getTime()) {
|
||||||
|
next.setDate(next.getDate() + 1);
|
||||||
|
}
|
||||||
|
return Math.max(5, Math.round((next.getTime() - now.getTime()) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
export async function scheduleDoseReminder(opts: {
|
export async function scheduleDoseReminder(opts: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
|
@ -25,11 +58,13 @@ export async function scheduleDoseReminder(opts: {
|
||||||
data?: Record<string, string>;
|
data?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
await ensureNotificationPermissions();
|
await ensureNotificationPermissions();
|
||||||
|
await androidChannel();
|
||||||
return Notifications.scheduleNotificationAsync({
|
return Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
data: opts.data,
|
data: opts.data,
|
||||||
|
sound: "default",
|
||||||
categoryIdentifier: "dose",
|
categoryIdentifier: "dose",
|
||||||
},
|
},
|
||||||
trigger: {
|
trigger: {
|
||||||
|
|
@ -47,11 +82,13 @@ export async function scheduleEscalationCheck(opts: {
|
||||||
data?: Record<string, string>;
|
data?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
await ensureNotificationPermissions();
|
await ensureNotificationPermissions();
|
||||||
|
await androidChannel();
|
||||||
return Notifications.scheduleNotificationAsync({
|
return Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
data: { ...opts.data, type: "escalation" },
|
data: { ...opts.data, type: "escalation" },
|
||||||
|
sound: "default",
|
||||||
},
|
},
|
||||||
trigger: {
|
trigger: {
|
||||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||||
|
|
@ -61,12 +98,78 @@ export async function scheduleEscalationCheck(opts: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function androidChannel() {
|
/**
|
||||||
if (Platform.OS === "android") {
|
* Schedule repeating daily calendar notifications for each HH:mm time.
|
||||||
return Notifications.setNotificationChannelAsync("gishen-reminders", {
|
* Also queues a near-term demo notification for the soonest slot (~15s if within a few minutes).
|
||||||
name: "Gishen reminders",
|
*/
|
||||||
importance: Notifications.AndroidImportance.HIGH,
|
export async function scheduleDailyDoseReminders(opts: {
|
||||||
|
prescriptionId: string;
|
||||||
|
medicineName: string;
|
||||||
|
times: string[];
|
||||||
|
/** Optional prior notification ids to cancel */
|
||||||
|
previousIds?: string[];
|
||||||
|
}) {
|
||||||
|
const granted = await ensureNotificationPermissions();
|
||||||
|
await androidChannel();
|
||||||
|
|
||||||
|
if (opts.previousIds?.length) {
|
||||||
|
await Promise.all(
|
||||||
|
opts.previousIds.map((id) =>
|
||||||
|
Notifications.cancelScheduledNotificationAsync(id).catch(() => {})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationIds: string[] = [];
|
||||||
|
const validTimes: string[] = [];
|
||||||
|
|
||||||
|
for (const time of opts.times) {
|
||||||
|
const parsed = parseTimeHHmm(time);
|
||||||
|
if (!parsed) continue;
|
||||||
|
validTimes.push(
|
||||||
|
`${String(parsed.hour).padStart(2, "0")}:${String(parsed.minute).padStart(2, "0")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (granted) {
|
||||||
|
const id = await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: `Time for ${opts.medicineName}`,
|
||||||
|
body: `Take your ${time} dose. Open Gishen to confirm.`,
|
||||||
|
data: {
|
||||||
|
prescriptionId: opts.prescriptionId,
|
||||||
|
type: "dose",
|
||||||
|
time,
|
||||||
|
},
|
||||||
|
sound: "default",
|
||||||
|
categoryIdentifier: "dose",
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: Notifications.SchedulableTriggerInputTypes.DAILY,
|
||||||
|
hour: parsed.hour,
|
||||||
|
minute: parsed.minute,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
notificationIds.push(id);
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
}
|
||||||
|
|
||||||
|
// Demo: fire a near-term push so testers can verify without waiting until clock time
|
||||||
|
if (granted && validTimes.length) {
|
||||||
|
const demoId = await scheduleDoseReminder({
|
||||||
|
title: `Reminder set · ${opts.medicineName}`,
|
||||||
|
body: `Daily alerts at ${validTimes.join(", ")}. Demo ping.`,
|
||||||
|
secondsFromNow: 12,
|
||||||
|
data: {
|
||||||
|
prescriptionId: opts.prescriptionId,
|
||||||
|
type: "dose_demo",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
notificationIds.push(demoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
granted,
|
||||||
|
times: validTimes,
|
||||||
|
notificationIds,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export const routes = {
|
||||||
home: "/(root)/(tabs)",
|
home: "/(root)/(tabs)",
|
||||||
favorites: "/(root)/(tabs)/favorites",
|
favorites: "/(root)/(tabs)/favorites",
|
||||||
cart: "/(root)/(tabs)/cart",
|
cart: "/(root)/(tabs)/cart",
|
||||||
profile: "/(root)/(tabs)/profile",
|
profile: "/(root)/(screens)/profile",
|
||||||
shop: "/(root)/(screens)/shop",
|
shop: "/(root)/(screens)/shop",
|
||||||
search: "/(root)/(screens)/search",
|
search: "/(root)/(screens)/search",
|
||||||
product: (id: string) => `/(root)/(screens)/product/${id}` as const,
|
product: (id: string) => `/(root)/(screens)/product/${id}` as const,
|
||||||
|
|
@ -15,7 +15,7 @@ export const routes = {
|
||||||
checkout: "/(root)/(screens)/checkout",
|
checkout: "/(root)/(screens)/checkout",
|
||||||
rxScan: "/(root)/(screens)/prescription/scan",
|
rxScan: "/(root)/(screens)/prescription/scan",
|
||||||
rxReview: "/(root)/(screens)/prescription/review",
|
rxReview: "/(root)/(screens)/prescription/review",
|
||||||
rxList: "/(root)/(screens)/prescription/list",
|
rxList: "/(root)/(tabs)/prescriptions",
|
||||||
rxDetail: (id: string) => `/(root)/(screens)/prescription/${id}` as const,
|
rxDetail: (id: string) => `/(root)/(screens)/prescription/${id}` as const,
|
||||||
rxCalendar: (id: string) =>
|
rxCalendar: (id: string) =>
|
||||||
`/(root)/(screens)/prescription/calendar/${id}` as const,
|
`/(root)/(screens)/prescription/calendar/${id}` as const,
|
||||||
|
|
@ -28,6 +28,7 @@ export const routes = {
|
||||||
orders: "/(root)/(screens)/orders",
|
orders: "/(root)/(screens)/orders",
|
||||||
orderDetail: (id: string) => `/(root)/(screens)/orders/${id}` as const,
|
orderDetail: (id: string) => `/(root)/(screens)/orders/${id}` as const,
|
||||||
tracking: (id: string) => `/(root)/(screens)/tracking/${id}` as const,
|
tracking: (id: string) => `/(root)/(screens)/tracking/${id}` as const,
|
||||||
|
notifications: "/(root)/(screens)/notifications",
|
||||||
loyalty: "/(root)/(screens)/loyalty",
|
loyalty: "/(root)/(screens)/loyalty",
|
||||||
about: "/(root)/(screens)/about",
|
about: "/(root)/(screens)/about",
|
||||||
contact: "/(root)/(screens)/contact",
|
contact: "/(root)/(screens)/contact",
|
||||||
|
|
|
||||||
141
lib/stores/notificationStore.ts
Normal file
141
lib/stores/notificationStore.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export type NotificationKind =
|
||||||
|
| "order"
|
||||||
|
| "rx"
|
||||||
|
| "dose"
|
||||||
|
| "loyalty"
|
||||||
|
| "promo"
|
||||||
|
| "system";
|
||||||
|
|
||||||
|
export type AppNotification = {
|
||||||
|
id: string;
|
||||||
|
kind: NotificationKind;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
read: boolean;
|
||||||
|
/** Deep-link target inside the app */
|
||||||
|
href?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationState = {
|
||||||
|
items: AppNotification[];
|
||||||
|
unreadCount: () => number;
|
||||||
|
markRead: (id: string) => void;
|
||||||
|
markAllRead: () => void;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
push: (
|
||||||
|
n: Omit<AppNotification, "id" | "createdAt" | "read"> & {
|
||||||
|
id?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
read?: boolean;
|
||||||
|
}
|
||||||
|
) => AppNotification;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hoursAgo(n: number) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(d.getHours() - n);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seedNotifications: AppNotification[] = [
|
||||||
|
{
|
||||||
|
id: "ntf_dose_1",
|
||||||
|
kind: "dose",
|
||||||
|
title: "Time for Metformin 500mg",
|
||||||
|
body: "Confirm your 20:00 dose or snooze for 10 minutes.",
|
||||||
|
createdAt: hoursAgo(1),
|
||||||
|
read: false,
|
||||||
|
href: "/(root)/(screens)/prescription/calendar/rx_demo_metformin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ntf_rx_1",
|
||||||
|
kind: "rx",
|
||||||
|
title: "Prescription under review",
|
||||||
|
body: "A pharmacist is checking your Azithromycin Rx. We’ll notify you when it’s ready.",
|
||||||
|
createdAt: hoursAgo(5),
|
||||||
|
read: false,
|
||||||
|
href: "/(root)/(screens)/prescription/rx_demo_azitro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ntf_order_1",
|
||||||
|
kind: "order",
|
||||||
|
title: "Rider is on the way",
|
||||||
|
body: "Your Bole delivery is out for delivery. Track live progress anytime.",
|
||||||
|
createdAt: hoursAgo(8),
|
||||||
|
read: false,
|
||||||
|
href: "/(root)/(screens)/tracking/ord_demo_active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ntf_loyalty_1",
|
||||||
|
kind: "loyalty",
|
||||||
|
title: "120 points ready to redeem",
|
||||||
|
body: "Redeem 100 pts for voucher GISHEN10 at checkout.",
|
||||||
|
createdAt: hoursAgo(26),
|
||||||
|
read: true,
|
||||||
|
href: "/(root)/(screens)/loyalty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ntf_promo_1",
|
||||||
|
kind: "promo",
|
||||||
|
title: "Weekend wellness picks",
|
||||||
|
body: "Save on vitamins and personal care this week at any Gishen branch.",
|
||||||
|
createdAt: hoursAgo(40),
|
||||||
|
read: true,
|
||||||
|
href: "/(root)/(screens)/shop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ntf_system_1",
|
||||||
|
kind: "system",
|
||||||
|
title: "Guest pickup tip",
|
||||||
|
body: "Shopping as a guest? Use your quick pickup code at any branch counter.",
|
||||||
|
createdAt: hoursAgo(72),
|
||||||
|
read: true,
|
||||||
|
href: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useNotificationStore = create<NotificationState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
items: seedNotifications,
|
||||||
|
unreadCount: () => get().items.filter((n) => !n.read).length,
|
||||||
|
markRead: (id) =>
|
||||||
|
set({
|
||||||
|
items: get().items.map((n) => (n.id === id ? { ...n, read: true } : n)),
|
||||||
|
}),
|
||||||
|
markAllRead: () =>
|
||||||
|
set({ items: get().items.map((n) => ({ ...n, read: true })) }),
|
||||||
|
remove: (id) => set({ items: get().items.filter((n) => n.id !== id) }),
|
||||||
|
push: (payload) => {
|
||||||
|
const item: AppNotification = {
|
||||||
|
id: payload.id ?? `ntf_${Date.now()}`,
|
||||||
|
createdAt: payload.createdAt ?? new Date().toISOString(),
|
||||||
|
read: payload.read ?? false,
|
||||||
|
kind: payload.kind,
|
||||||
|
title: payload.title,
|
||||||
|
body: payload.body,
|
||||||
|
href: payload.href ?? null,
|
||||||
|
};
|
||||||
|
set({ items: [item, ...get().items] });
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-notifications",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 1,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<NotificationState>;
|
||||||
|
if (!state.items?.length) {
|
||||||
|
return { ...state, items: seedNotifications };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -36,6 +36,11 @@ export type Order = {
|
||||||
customerName?: string;
|
customerName?: string;
|
||||||
customerPhone?: string;
|
customerPhone?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
/** Guest / pickup QR-style code shown at any branch counter */
|
||||||
|
pickupCode?: string | null;
|
||||||
|
/** When true, order can be fulfilled at any Gishen branch */
|
||||||
|
anyBranch?: boolean;
|
||||||
|
isGuestOrder?: boolean;
|
||||||
timeline?: { at: string; label: string; status: OrderStatus | "created" }[];
|
timeline?: { at: string; label: string; status: OrderStatus | "created" }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -50,6 +55,7 @@ type OrdersState = {
|
||||||
updateStatus: (id: string, status: OrderStatus) => void;
|
updateStatus: (id: string, status: OrderStatus) => void;
|
||||||
cancelOrder: (id: string) => void;
|
cancelOrder: (id: string) => void;
|
||||||
getById: (id: string) => Order | undefined;
|
getById: (id: string) => Order | undefined;
|
||||||
|
getByPickupCode: (code: string) => Order | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
function timelineFor(status: OrderStatus, createdAt: string) {
|
function timelineFor(status: OrderStatus, createdAt: string) {
|
||||||
|
|
@ -141,11 +147,18 @@ export const useOrdersStore = create<OrdersState>()(
|
||||||
}),
|
}),
|
||||||
cancelOrder: (id) => get().updateStatus(id, "cancelled"),
|
cancelOrder: (id) => get().updateStatus(id, "cancelled"),
|
||||||
getById: (id) => get().orders.find((o) => o.id === id),
|
getById: (id) => get().orders.find((o) => o.id === id),
|
||||||
|
getByPickupCode: (code) => {
|
||||||
|
const normalized = code.trim().toUpperCase();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return get().orders.find(
|
||||||
|
(o) => (o.pickupCode ?? "").toUpperCase() === normalized
|
||||||
|
);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "gishen-orders",
|
name: "gishen-orders",
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
version: 3,
|
version: 4,
|
||||||
migrate: (persisted: unknown) => {
|
migrate: (persisted: unknown) => {
|
||||||
const state = (persisted ?? {}) as Partial<OrdersState>;
|
const state = (persisted ?? {}) as Partial<OrdersState>;
|
||||||
if (!state.orders?.length) {
|
if (!state.orders?.length) {
|
||||||
|
|
@ -153,9 +166,9 @@ export const useOrdersStore = create<OrdersState>()(
|
||||||
...state,
|
...state,
|
||||||
orders: seedOrders.map((o) => ({
|
orders: seedOrders.map((o) => ({
|
||||||
...o,
|
...o,
|
||||||
discount: 0,
|
discount: o.discount ?? 0,
|
||||||
customerName: "Kirukib Abebe",
|
customerName: o.customerName ?? "Kirukib Abebe",
|
||||||
customerPhone: "+251911000000",
|
customerPhone: o.customerPhone ?? "+251911000000",
|
||||||
timeline: timelineFor(o.status, o.createdAt),
|
timeline: timelineFor(o.status, o.createdAt),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,16 @@ export const usePrescriptionStore = create<RxState>()(
|
||||||
reminders: get().reminders.filter((r) => r.prescriptionId !== id),
|
reminders: get().reminders.filter((r) => r.prescriptionId !== id),
|
||||||
}),
|
}),
|
||||||
upsertReminder: (r) => {
|
upsertReminder: (r) => {
|
||||||
const id = r.id ?? `rem_${Date.now()}`;
|
const existing = get().reminders.find(
|
||||||
const next = { ...r, id } as Reminder;
|
(x) =>
|
||||||
const others = get().reminders.filter((x) => x.id !== id);
|
(r.id != null && x.id === r.id) ||
|
||||||
|
x.prescriptionId === r.prescriptionId
|
||||||
|
);
|
||||||
|
const id = r.id ?? existing?.id ?? `rem_${Date.now()}`;
|
||||||
|
const next = { ...existing, ...r, id } as Reminder;
|
||||||
|
const others = get().reminders.filter(
|
||||||
|
(x) => x.id !== id && x.prescriptionId !== r.prescriptionId
|
||||||
|
);
|
||||||
set({ reminders: [next, ...others] });
|
set({ reminders: [next, ...others] });
|
||||||
},
|
},
|
||||||
removeReminder: (id) =>
|
removeReminder: (id) =>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"shop": "ሱቅ",
|
"shop": "ሱቅ",
|
||||||
"branches": "ቅርንጫፎች",
|
"branches": "ቅርንጫፎች",
|
||||||
"orders": "ትዕዛዞች",
|
"orders": "ትዕዛዞች",
|
||||||
|
"notifications": "ማሳወቂያዎች",
|
||||||
"loyalty": "ነጥቦች",
|
"loyalty": "ነጥቦች",
|
||||||
"about": "ስለ እኛ",
|
"about": "ስለ እኛ",
|
||||||
"contact": "አግኙን",
|
"contact": "አግኙን",
|
||||||
|
|
@ -43,6 +44,7 @@
|
||||||
"family": "ቤተሰብ",
|
"family": "ቤተሰብ",
|
||||||
"subscriptions": "ደንበኝነት ምዝገባዎች",
|
"subscriptions": "ደንበኝነት ምዝገባዎች",
|
||||||
"prescriptions": "የሐኪም ትዕዛዞች",
|
"prescriptions": "የሐኪም ትዕዛዞች",
|
||||||
|
"rxManage": "የሐኪም እንክብካቤ",
|
||||||
"switchProfile": "መገለጫ ቀይር",
|
"switchProfile": "መገለጫ ቀይር",
|
||||||
"joinOrg": "ድርጅት ተቀላቀል",
|
"joinOrg": "ድርጅት ተቀላቀል",
|
||||||
"payAtBranch": "በቅርንጫፍ ክፈል",
|
"payAtBranch": "በቅርንጫፍ ክፈል",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"shop": "Shop",
|
"shop": "Shop",
|
||||||
"branches": "Branches",
|
"branches": "Branches",
|
||||||
"orders": "Orders",
|
"orders": "Orders",
|
||||||
|
"notifications": "Notifications",
|
||||||
"loyalty": "Loyalty",
|
"loyalty": "Loyalty",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
|
@ -43,6 +44,7 @@
|
||||||
"family": "Family",
|
"family": "Family",
|
||||||
"subscriptions": "Subscriptions",
|
"subscriptions": "Subscriptions",
|
||||||
"prescriptions": "Prescriptions",
|
"prescriptions": "Prescriptions",
|
||||||
|
"rxManage": "Rx Care",
|
||||||
"switchProfile": "Switch profile",
|
"switchProfile": "Switch profile",
|
||||||
"joinOrg": "Join organisation",
|
"joinOrg": "Join organisation",
|
||||||
"payAtBranch": "Pay at branch",
|
"payAtBranch": "Pay at branch",
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user