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:
kirukib 2026-08-07 15:35:51 +03:00
parent 7310a349c5
commit 5391780b9b
28 changed files with 1831 additions and 481 deletions

View File

@ -1,5 +1,5 @@
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 { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/Button";
@ -10,7 +10,7 @@ import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { StickyBar } from "@/components/ui/StickyBar";
import { Text } from "@/components/ui/Text";
import { DELIVERY_FEE, orderSubtotalTotal, voucherDiscount } from "@/lib/commerce";
import { DELIVERY_FEE, generatePickupCode, orderSubtotalTotal, voucherDiscount } from "@/lib/commerce";
import { branches } from "@/lib/data/branches";
import { routes } from "@/lib/routes";
import { useAddressStore } from "@/lib/stores/addressStore";
@ -18,6 +18,7 @@ import { useAuthStore } from "@/lib/stores/authStore";
import { useCartStore } from "@/lib/stores/cartStore";
import { useFamilyStore } from "@/lib/stores/familyStore";
import { useLocationStore, type PaymentMethod } from "@/lib/stores/locationStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { useOrdersStore } from "@/lib/stores/ordersStore";
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
import { colors, gutter } from "@/lib/theme";
@ -38,10 +39,10 @@ export default function CheckoutScreen() {
const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
const payment = useLocationStore((s) => s.paymentPreference);
const setPayment = useLocationStore((s) => s.setPaymentPreference);
const allowed = useLocationStore((s) => s.allowedPayments());
const addresses = useAddressStore((s) => s.addresses);
const defaultAddress = useAddressStore((s) => s.defaultAddress());
const addOrder = useOrdersStore((s) => s.addOrder);
const pushNotification = useNotificationStore((s) => s.push);
const addPoints = useAuthStore((s) => s.addPoints);
const user = useAuthStore((s) => s.user);
const setActiveVoucher = useAuthStore((s) => s.setActiveVoucher);
@ -52,8 +53,9 @@ export default function CheckoutScreen() {
const familyMembers = useFamilyStore((s) => s.members);
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
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 [notes, setNotes] = useState("");
const [addressId, setAddressId] = useState(defaultAddress?.id ?? "");
@ -61,17 +63,38 @@ export default function CheckoutScreen() {
cartVoucher || user?.activeVoucher || ""
);
const [forMemberId, setForMemberId] = useState<string | null>(activeMemberId);
/** Guests (and optional pickup) can fulfill at any branch with a quick code */
const [anyBranch, setAnyBranch] = useState(isGuest);
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(
() => addresses.find((a) => a.id === 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 afterDiscount = Math.max(0, subtotal - discount);
const split =
mode === "corporate"
mode === "corporate" && !isGuest
? quoteSplit(afterDiscount)
: { covered: 0, selfPay: afterDiscount };
const total = orderSubtotalTotal({
@ -86,15 +109,16 @@ export default function CheckoutScreen() {
Alert.alert("Contact details", "Enter your name and a valid phone number.");
return;
}
if (!allowed.includes(payment)) {
if (!allowed.includes(payment) && !(useAnyBranchPickup && payment === "pay_at_branch")) {
Alert.alert("Payment", "Select a valid payment method.");
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;
}
if (fulfillment === "delivery" && !selectedAddress && !areaLabel.trim()) {
if (effectiveFulfillment === "delivery" && !selectedAddress && !areaLabel.trim()) {
Alert.alert("Delivery", "Choose a saved address or enter an area.");
return;
}
@ -108,52 +132,91 @@ export default function CheckoutScreen() {
const orderNotes = [
notes.trim(),
forMember ? `For family: ${forMember.name} (${forMember.relationship})` : "",
useAnyBranchPickup ? "Quick code — redeemable at any Gishen branch" : "",
]
.filter(Boolean)
.join(" · ");
const pickupCode =
effectiveFulfillment === "pickup" ? generatePickupCode() : null;
const fee = effectiveFulfillment === "pickup" ? 0 : deliveryFee;
const order = addOrder({
items,
subtotal,
deliveryFee,
deliveryFee: fee,
discount,
voucherCode: discount ? voucher.trim().toUpperCase() : null,
total: afterDiscount + deliveryFee,
fulfillment,
branchId,
areaLabel:
fulfillment === "pickup"
total: afterDiscount + fee,
fulfillment: effectiveFulfillment,
branchId: useAnyBranchPickup ? null : branchId,
areaLabel: useAnyBranchPickup
? "Any Gishen branch"
: effectiveFulfillment === "pickup"
? branch?.area ?? areaLabel
: selectedAddress?.area ?? areaLabel,
addressId: fulfillment === "delivery" ? selectedAddress?.id ?? null : null,
addressLine,
addressId:
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
addressLine: useAnyBranchPickup ? undefined : addressLine,
paymentMethod: payment,
corporateCovered: split.covered,
selfPay: split.selfPay + deliveryFee,
selfPay: split.selfPay + fee,
customerName: name.trim(),
customerPhone: phone.trim(),
notes: orderNotes,
riderAssigned: false,
pickupCode,
anyBranch: useAnyBranchPickup,
isGuestOrder: isGuest,
status:
useAnyBranchPickup || payment === "pay_at_branch" ? "confirmed" : undefined,
});
if (forMemberId) setActiveMember(forMemberId);
if (split.covered > 0) consumeAllowance(split.covered);
addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
if (!isGuest && split.covered > 0) consumeAllowance(split.covered);
if (!isGuest) addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
if (discount) {
setActiveVoucher(null);
setCartVoucher(null);
}
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(
"Order placed",
payment === "pay_at_branch"
? `Pay ${formatETB(order.selfPay ?? order.total)} at ${branch?.name ?? "the branch"}.`
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
pickupCode ? "Quick pickup code ready" : "Order placed",
pickupCode
? `Your code is ${pickupCode}. ${
useAnyBranchPickup
? "Present it at any Gishen branch for fulfillment."
: `Present it at ${branch?.name ?? "the branch"}.`
}${
payment === "pay_at_branch"
? ` Pay ${formatETB(order.selfPay ?? order.total)} at the counter.`
: ""
}`
: payment === "pay_at_branch"
? `Pay ${formatETB(order.selfPay ?? order.total)} at ${branch?.name ?? "the branch"}.`
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
[
{ text: "View order", onPress: () => router.replace(routes.orderDetail(order.id)) },
{ text: "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 }}>
Fulfillment
</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
value={fulfillment}
onChange={setFulfillment}
value={effectiveFulfillment}
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={[
{ value: "delivery", label: t("common.delivery") },
{ value: "pickup", label: t("common.pickup") },
]}
/>
{fulfillment === "delivery" ? (
{effectiveFulfillment === "delivery" ? (
<View style={{ marginTop: 14, gap: 8 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<Text variant="label" color={colors.muted}>
@ -302,16 +386,42 @@ export default function CheckoutScreen() {
</View>
) : (
<View style={{ marginTop: 14, gap: 8 }}>
<Pressable
onPress={() => {
setAnyBranch(true);
setBranchId(null);
setPayment("pay_at_branch");
}}
style={{
padding: 12,
borderRadius: 14,
borderWidth: 1.5,
borderColor: anyBranch ? colors.brand : colors.line,
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={() => setBranchId(b.id)}
onPress={() => {
setAnyBranch(false);
setBranchId(b.id);
}}
style={{
padding: 12,
borderRadius: 14,
borderWidth: 1.5,
borderColor: branchId === b.id ? colors.brand : colors.line,
backgroundColor: branchId === b.id ? colors.brandWash : colors.surface,
borderColor:
!anyBranch && branchId === b.id ? colors.brand : colors.line,
backgroundColor:
!anyBranch && branchId === b.id
? colors.brandWash
: colors.surface,
}}
>
<Text variant="h3">{b.name}</Text>
@ -327,7 +437,7 @@ export default function CheckoutScreen() {
<Card elevated={false} padded={false}>
<View style={{ padding: 16, paddingBottom: 8 }}>
<Text variant="h3">{t("common.payment")}</Text>
{fulfillment === "delivery" ? (
{effectiveFulfillment === "delivery" ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
Pay at branch is only available for pickup
</Text>
@ -394,7 +504,7 @@ export default function CheckoutScreen() {
/>
) : null}
<InfoRow
label={fulfillment === "delivery" ? "Delivery" : "Pickup"}
label={effectiveFulfillment === "delivery" ? "Delivery" : "Pickup"}
value={deliveryFee ? formatETB(deliveryFee) : "Free"}
/>
<InfoRow label={t("common.total")} value={formatETB(afterDiscount + deliveryFee)} emphasis last />

View File

@ -1,94 +1,7 @@
import { router } 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 { Redirect } from "expo-router";
import { routes } from "@/lib/routes";
export default function FamilyScreen() {
const { t } = useTranslation();
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);
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>
);
/** Family is managed on the Prescriptions tab with invite. */
export default function FamilyRedirect() {
return <Redirect href={routes.rxList as never} />;
}

View 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>
);
}

View File

@ -64,9 +64,14 @@ export default function OrderDetailScreen() {
<Card>
<Text variant="caption" color={colors.subtle}>
{order.id}
{order.isGuestOrder ? " · Guest" : ""}
</Text>
<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 variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
{order.customerName} · {order.customerPhone}
@ -78,6 +83,22 @@ export default function OrderDetailScreen() {
) : null}
</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">
<Card elevated={false}>
{order.items.map((item, index) => (
@ -124,10 +145,17 @@ export default function OrderDetailScreen() {
{order.addressLine ? (
<InfoRow label="Address" value={order.addressLine} />
) : null}
{order.pickupCode ? (
<InfoRow label="Pickup code" value={order.pickupCode} />
) : null}
{branch ? (
<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>
</Section>

View File

@ -1,9 +1,12 @@
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 { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { Field } from "@/components/ui/Field";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
@ -25,11 +28,52 @@ const toneFor = {
export default function OrdersScreen() {
const { t } = useTranslation();
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 (
<Screen>
<AppHeader title={t("common.orders")} subtitle="Track delivery and pickup status" />
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40 }}>
<AppHeader
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 ? (
<EmptyState
icon="receipt-outline"
@ -39,67 +83,71 @@ export default function OrdersScreen() {
onAction={() => router.push(routes.shop)}
/>
) : (
<View style={{ gap: 12 }}>
{orders.map((o) => (
<Card key={o.id} onPress={() => router.push(routes.orderDetail(o.id))}>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text variant="h3">
{o.fulfillment === "pickup" ? "Pickup" : "Delivery"} · {o.areaLabel}
</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{new Date(o.createdAt).toLocaleString()} · {o.paymentMethod}
</Text>
</View>
<Badge label={o.status} tone={toneFor[o.status]} />
</View>
<View style={{ flexDirection: "row", gap: 8, marginTop: 12 }}>
{o.items.slice(0, 3).map((item) => (
<Image
key={item.productId}
source={{ uri: item.image }}
style={{
width: 44,
height: 44,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
}}
resizeMode="contain"
/>
))}
{o.items.length > 3 ? (
<View
style={{
width: 44,
height: 44,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
}}
>
<Text variant="micro">+{o.items.length - 3}</Text>
</View>
) : null}
</View>
<View
style={{
marginTop: 12,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text variant="price">{formatETB(o.total)}</Text>
<Text variant="label" color={colors.brandText}>
View details
orders.map((o) => (
<Card key={o.id} onPress={() => router.push(routes.orderDetail(o.id))}>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text variant="h3">
{o.anyBranch
? "Any-branch pickup"
: o.fulfillment === "pickup"
? "Pickup"
: "Delivery"}{" "}
· {o.areaLabel}
</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{new Date(o.createdAt).toLocaleString()} · {o.paymentMethod}
{o.pickupCode ? ` · ${o.pickupCode}` : ""}
</Text>
</View>
</Card>
))}
</View>
<Badge label={o.status} tone={toneFor[o.status]} />
</View>
<View style={{ flexDirection: "row", gap: 8, marginTop: 12 }}>
{o.items.slice(0, 3).map((item) => (
<Image
key={item.productId}
source={{ uri: item.image }}
style={{
width: 44,
height: 44,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
}}
resizeMode="contain"
/>
))}
{o.items.length > 3 ? (
<View
style={{
width: 44,
height: 44,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
}}
>
<Text variant="micro">+{o.items.length - 3}</Text>
</View>
) : null}
</View>
<View
style={{
marginTop: 12,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text variant="price">{formatETB(o.total)}</Text>
<Text variant="label" color={colors.brandText}>
View details
</Text>
</View>
</Card>
))
)}
</ScrollView>
</Screen>

View File

@ -1,16 +1,20 @@
import { Ionicons } from "@expo/vector-icons";
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 { Button } from "@/components/ui/Button";
import { Card, InfoRow, Section } from "@/components/ui/Card";
import { DoseReminderPanel } from "@/components/ui/DoseReminderPanel";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { products } from "@/lib/data/products";
import { scheduleDoseReminder } from "@/lib/notifications";
import { useCartStore } from "@/lib/stores/cartStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
import { colors, gutter, radii } from "@/lib/theme";
import { colors, gutter } from "@/lib/theme";
import { routes } from "@/lib/routes";
const statusTone = {
@ -29,14 +33,27 @@ const lineTone = {
} as const;
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 reminders = usePrescriptionStore((s) =>
s.reminders.filter((r) => r.prescriptionId === id)
const allReminders = usePrescriptionStore((s) => s.reminders);
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 updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus);
const confirmDose = usePrescriptionStore((s) => s.confirmDose);
const addProduct = useCartStore((s) => s.addProduct);
const pushNotification = useNotificationStore((s) => s.push);
const [showSetup, setShowSetup] = useState(!reminder?.enabled);
if (!rx) {
return (
@ -58,59 +75,217 @@ export default function PrescriptionDetailScreen() {
return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
})();
const pendingLog = recentLogs.find((l) => l.status === "pending");
return (
<Screen>
<AppHeader title="Prescription detail" />
<AppHeader
title="Prescription"
subtitle={rx.patientName}
right={<Badge label={rx.status.replace("_", " ")} tone={statusTone[rx.status]} />}
/>
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 16,
gap: 14,
}}
>
{rx.imageUris[0] ? (
<Image
source={{ uri: rx.imageUris[0] }}
<Card elevated style={{ padding: 0, overflow: "hidden" }}>
<View
style={{
width: "100%",
height: 180,
borderRadius: radii.card,
backgroundColor: colors.surfaceAlt,
height: 160,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
resizeMode="cover"
/>
) : null}
<Card>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text variant="h1">{rx.medicineName}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
For {rx.patientName}
</Text>
</View>
<Badge
label={rx.status.replace("_", " ")}
tone={statusTone[rx.status]}
/>
>
{rx.imageUris[0] ? (
<Image
source={{ uri: rx.imageUris[0] }}
style={{ width: "100%", height: "100%" }}
resizeMode="cover"
/>
) : (
<Ionicons name="document-text" size={48} color={colors.brandText} />
)}
</View>
{dueIn !== null ? (
<Text
variant="label"
color={dueIn <= 3 ? colors.warn : colors.brandText}
style={{ marginTop: 12 }}
>
{dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
<View style={{ padding: 16, gap: 10 }}>
<Text variant="h1">{rx.medicineName}</Text>
<Text variant="bodySm" color={colors.muted}>
For {rx.patientName} · {rx.medicines.length} item
{rx.medicines.length === 1 ? "" : "s"} on this Rx
</Text>
) : (
<Text variant="label" color={colors.warn} style={{ marginTop: 12 }}>
Awaiting first fill
</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>
</View>
</View>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
<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>
) : (
<DoseReminderPanel
rx={rx}
reminder={reminder}
onScheduled={() => setShowSetup(false)}
/>
)}
</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">
<Card elevated={false}>
<Card elevated={false} padded={false}>
{rx.medicines.map((m, i) => (
<View
key={m.name}
@ -118,14 +293,28 @@ export default function PrescriptionDetailScreen() {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 12,
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: i === rx.medicines.length - 1 ? 0 : 1,
borderBottomColor: colors.line,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<Ionicons name="medkit-outline" size={18} color={colors.brandText} />
<Text variant="h3">{m.name}</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="medkit-outline" size={16} color={colors.brandText} />
</View>
<Text variant="h3" style={{ flex: 1 }}>
{m.name}
</Text>
</View>
<Badge label={m.status} tone={lineTone[m.status]} />
</View>
@ -153,34 +342,7 @@ export default function PrescriptionDetailScreen() {
</Card>
</Section>
<Section title="Reminders">
{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 }}>
<View style={{ gap: 10 }}>
<Button
title="Reorder medicines"
icon="bag-add-outline"
@ -195,7 +357,10 @@ export default function PrescriptionDetailScreen() {
<Button
title="Mark as refilled"
variant="outline"
onPress={() => markRefilled(rx.id)}
onPress={() => {
markRefilled(rx.id);
Alert.alert("Updated", "Marked as refilled today.");
}}
/>
{rx.status === "under_review" || rx.status === "submitted" ? (
<Button

View File

@ -11,17 +11,21 @@ import { EmptyState } from "@/components/ui/EmptyState";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import {
scheduleDailyDoseReminders,
scheduleDoseReminder,
scheduleEscalationCheck,
} from "@/lib/notifications";
import { useEmergencyContactStore } from "@/lib/stores/emergencyContactStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
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;
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 upsertReminder = usePrescriptionStore((s) => s.upsertReminder);
const schedulePendingDose = usePrescriptionStore((s) => s.schedulePendingDose);
@ -37,6 +41,7 @@ export default function RxCalendarScreen() {
const setEscalationEnabled = useEmergencyContactStore((s) => s.setEscalationEnabled);
const contacts = useEmergencyContactStore((s) => s.contacts);
const markEscalated = useEmergencyContactStore((s) => s.markEscalated);
const pushNotification = useNotificationStore((s) => s.push);
const [times, setTimes] = useState(reminder?.times.join(",") || "08:00,20:00");
const [pendingLogId, setPendingLogId] = useState<string | null>(null);
@ -85,15 +90,23 @@ export default function RxCalendarScreen() {
escalateOnMiss: escalationEnabled,
enabled: true,
});
await scheduleDoseReminder({
title: `Time for ${rx.medicineName}`,
body: "Tap to confirm you took your dose",
secondsFromNow: 15,
data: { prescriptionId: rx.id, type: "dose" },
const result = await scheduleDailyDoseReminders({
prescriptionId: rx.id,
medicineName: rx.medicineName,
times: parsed,
});
const log = schedulePendingDose(reminderId);
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) {
await scheduleEscalationCheck({
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 (

View File

@ -1,109 +1,7 @@
import { router } 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 { Redirect } from "expo-router";
import { routes } from "@/lib/routes";
const statusTone = {
submitted: "neutral",
under_review: "warn",
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>
);
/** Legacy stack path — prescription management lives on the main tab. */
export default function RxListRedirect() {
return <Redirect href={routes.rxList as never} />;
}

View File

@ -65,11 +65,16 @@ export default function ProductDetailScreen() {
marginBottom: 8,
}}
>
<IconButton icon="chevron-back" onPress={() => router.back()} />
<IconButton
icon="arrow-back"
accessibilityLabel="Go back"
onPress={() => router.back()}
/>
<IconButton
icon={liked ? "heart" : "heart-outline"}
onPress={() => toggle(product.id)}
color={liked ? colors.danger : colors.ink}
accessibilityLabel={liked ? "Remove favorite" : "Add favorite"}
/>
</View>
<Image

View File

@ -3,14 +3,14 @@ import * as ImagePicker from "expo-image-picker";
import { router } from "expo-router";
import { useState } from "react";
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Avatar } from "@/components/ui/Avatar";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card, MenuRow } from "@/components/ui/Card";
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 { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
@ -21,7 +21,6 @@ import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils";
export default function ProfileScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const user = useAuthStore((s) => s.user);
const setAvatar = useAuthStore((s) => s.setAvatar);
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.joinOrg"), href: routes.profileJoin, icon: "business-outline" },
{ 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.family"), href: routes.family, icon: "people-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.branches"), href: routes.branches, icon: "location-outline" },
@ -109,17 +108,15 @@ export default function ProfileScreen() {
return (
<Screen>
<AppHeader title={t("common.profile")} subtitle="Account and preferences" />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: TAB_BAR_INSET + 24,
paddingBottom: 40,
paddingHorizontal: gutter,
}}
>
<Text variant="h1">{t("common.profile")}</Text>
<Card style={{ marginTop: 16 }}>
<Card style={{ marginTop: 4 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
<Pressable onPress={changePhoto}>
<View>

View File

@ -8,7 +8,6 @@ import {
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/Button";
import { Card, InfoRow } from "@/components/ui/Card";
@ -16,6 +15,7 @@ import { EmptyState } from "@/components/ui/EmptyState";
import { QtyStepper } from "@/components/ui/QtyStepper";
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
import { StickyBar } from "@/components/ui/StickyBar";
import { TabTopBar } from "@/components/ui/TabTopBar";
import { Text } from "@/components/ui/Text";
import { DELIVERY_FEE, DEMO_VOUCHER, voucherDiscount } from "@/lib/commerce";
import { routes } from "@/lib/routes";
@ -25,7 +25,6 @@ import { formatETB } from "@/lib/utils";
export default function CartScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const items = useCartStore((s) => s.items);
const setQuantity = useCartStore((s) => s.setQuantity);
const removeItem = useCartStore((s) => s.removeItem);
@ -38,9 +37,9 @@ export default function CartScreen() {
return (
<Screen>
<TabTopBar subtitle={t("common.cart")} />
<ScrollView
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: TAB_BAR_INSET + 100,
paddingHorizontal: gutter,
}}

View File

@ -1,19 +1,18 @@
import { router } from "expo-router";
import { FlatList, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { EmptyState } from "@/components/ui/EmptyState";
import { ProductCard, GRID_CARD_GAP } from "@/components/ui/ProductCard";
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
import { TabTopBar } from "@/components/ui/TabTopBar";
import { Text } from "@/components/ui/Text";
import { products } from "@/lib/data/products";
import { routes } from "@/lib/routes";
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
import { gutter } from "@/lib/theme";
import { colors, gutter } from "@/lib/theme";
export default function FavoritesScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const ids = useFavoritesStore((s) => s.ids);
const items = products.filter((p) => ids.includes(p.id));
const { width } = useWindowDimensions();
@ -21,15 +20,10 @@ export default function FavoritesScreen() {
return (
<Screen>
<View
style={{
paddingTop: insets.top + 16,
paddingHorizontal: gutter,
paddingBottom: 8,
}}
>
<TabTopBar subtitle={t("common.favorites")} />
<View style={{ paddingHorizontal: gutter, paddingBottom: 8 }}>
<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
</Text>
</View>

View File

@ -2,26 +2,22 @@ import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import { FlatList, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Avatar } from "@/components/ui/Avatar";
import { Badge } from "@/components/ui/Badge";
import { ActionTile, CategoryTile } from "@/components/ui/CategoryTile";
import { Card, Section } from "@/components/ui/Card";
import { ProductRow, ProductTile } from "@/components/ui/ProductCard";
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
import { SearchAction, SearchField } from "@/components/ui/SearchField";
import { TabTopBar } from "@/components/ui/TabTopBar";
import { Text } from "@/components/ui/Text";
import { products } from "@/lib/data/products";
import { categories, routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
import { colors, gutter, space } from "@/lib/theme";
export default function HomeScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const user = useAuthStore((s) => s.user);
const [category, setCategory] = useState("medicines");
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
@ -52,50 +48,14 @@ export default function HomeScreen() {
return (
<Screen>
<TabTopBar />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + 12,
paddingBottom: TAB_BAR_INSET + 24,
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 }}>
{t("common.trustedTitle")}
</Text>

View 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>
);
}

View File

@ -139,12 +139,15 @@ export function IconButton({
? "transparent"
: colors.surface;
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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={onPress}
hitSlop={6}
style={({ pressed }) => [
{
width: size,
@ -158,7 +161,12 @@ export function IconButton({
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>
);
}

View 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>
);
}

View File

@ -127,11 +127,14 @@ export function FloatingTabBar() {
}}
/>
<Tabs.Screen
name="profile"
name="prescriptions"
options={{
title: t("common.profile"),
title: t("common.prescriptions"),
tabBarIcon: ({ focused }) => (
<TabIcon name={focused ? "person" : "person-outline"} focused={focused} />
<TabIcon
name={focused ? "medkit" : "medkit-outline"}
focused={focused}
/>
),
}}
/>

View File

@ -17,6 +17,10 @@ type Props = {
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({
title,
subtitle,
@ -31,32 +35,74 @@ export function AppHeader({
return (
<View
style={{
paddingTop: insets.top + 8,
paddingTop: insets.top + 6,
paddingHorizontal: gutter,
paddingBottom: large ? 4 : 12,
paddingBottom: large ? 4 : 10,
backgroundColor: background,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 10,
minHeight: 44,
}}
>
{back ? (
<IconButton
icon="chevron-back"
icon="arrow-back"
accessibilityLabel="Go back"
onPress={onBack ?? (() => router.back())}
tone="surface"
/>
) : null}
{!large && title ? (
<Text variant="h2" numberOfLines={1} style={{ flex: 1 }}>
{title}
</Text>
{!large ? (
<View
style={{
flex: 1,
justifyContent: "center",
minHeight: 40,
paddingRight: right ? 4 : 0,
}}
>
{title ? (
<Text variant="h2" numberOfLines={1}>
{title}
</Text>
) : null}
{subtitle ? (
<Text
variant="caption"
color={colors.subtle}
numberOfLines={2}
style={{ marginTop: title ? 2 : 0 }}
>
{subtitle}
</Text>
) : null}
</View>
) : (
<View style={{ flex: 1 }} />
)}
{right}
{right ? (
<View
style={{
flexShrink: 0,
alignItems: "flex-end",
justifyContent: "center",
maxWidth: "36%",
}}
>
{right}
</View>
) : null}
</View>
{large && title ? (
<View style={{ marginTop: 16, marginBottom: 8 }}>
<View style={{ marginTop: back || right ? 14 : 8, marginBottom: 4 }}>
<Text variant="h1">{title}</Text>
{subtitle ? (
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
@ -65,12 +111,6 @@ export function AppHeader({
) : null}
</View>
) : null}
{!large && subtitle ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2, marginLeft: 52 }}>
{subtitle}
</Text>
) : null}
</View>
);
}

103
components/ui/TabTopBar.tsx Normal file
View 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>
);
}

View File

@ -4,6 +4,16 @@ export const DEMO_VOUCHER = "GISHEN10";
export const DEMO_VOUCHER_PCT = 0.1;
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) {
if (!code) return 0;
if (code.trim().toUpperCase() === DEMO_VOUCHER) {

View File

@ -188,6 +188,29 @@ export const seedOrders: Order[] = [
customerName: "Kirukib Abebe",
customerPhone: "+251911000000",
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",

View File

@ -18,6 +18,39 @@ export async function ensureNotificationPermissions() {
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: {
title: string;
body: string;
@ -25,11 +58,13 @@ export async function scheduleDoseReminder(opts: {
data?: Record<string, string>;
}) {
await ensureNotificationPermissions();
await androidChannel();
return Notifications.scheduleNotificationAsync({
content: {
title: opts.title,
body: opts.body,
data: opts.data,
sound: "default",
categoryIdentifier: "dose",
},
trigger: {
@ -47,11 +82,13 @@ export async function scheduleEscalationCheck(opts: {
data?: Record<string, string>;
}) {
await ensureNotificationPermissions();
await androidChannel();
return Notifications.scheduleNotificationAsync({
content: {
title: opts.title,
body: opts.body,
data: { ...opts.data, type: "escalation" },
sound: "default",
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
@ -61,12 +98,78 @@ export async function scheduleEscalationCheck(opts: {
});
}
export function androidChannel() {
if (Platform.OS === "android") {
return Notifications.setNotificationChannelAsync("gishen-reminders", {
name: "Gishen reminders",
importance: Notifications.AndroidImportance.HIGH,
});
/**
* Schedule repeating daily calendar notifications for each HH:mm time.
* Also queues a near-term demo notification for the soonest slot (~15s if within a few minutes).
*/
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(() => {})
)
);
}
return Promise.resolve();
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);
}
}
// 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,
};
}

View File

@ -7,7 +7,7 @@ export const routes = {
home: "/(root)/(tabs)",
favorites: "/(root)/(tabs)/favorites",
cart: "/(root)/(tabs)/cart",
profile: "/(root)/(tabs)/profile",
profile: "/(root)/(screens)/profile",
shop: "/(root)/(screens)/shop",
search: "/(root)/(screens)/search",
product: (id: string) => `/(root)/(screens)/product/${id}` as const,
@ -15,7 +15,7 @@ export const routes = {
checkout: "/(root)/(screens)/checkout",
rxScan: "/(root)/(screens)/prescription/scan",
rxReview: "/(root)/(screens)/prescription/review",
rxList: "/(root)/(screens)/prescription/list",
rxList: "/(root)/(tabs)/prescriptions",
rxDetail: (id: string) => `/(root)/(screens)/prescription/${id}` as const,
rxCalendar: (id: string) =>
`/(root)/(screens)/prescription/calendar/${id}` as const,
@ -28,6 +28,7 @@ export const routes = {
orders: "/(root)/(screens)/orders",
orderDetail: (id: string) => `/(root)/(screens)/orders/${id}` as const,
tracking: (id: string) => `/(root)/(screens)/tracking/${id}` as const,
notifications: "/(root)/(screens)/notifications",
loyalty: "/(root)/(screens)/loyalty",
about: "/(root)/(screens)/about",
contact: "/(root)/(screens)/contact",

View 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;
},
}
)
);

View File

@ -36,6 +36,11 @@ export type Order = {
customerName?: string;
customerPhone?: 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" }[];
};
@ -50,6 +55,7 @@ type OrdersState = {
updateStatus: (id: string, status: OrderStatus) => void;
cancelOrder: (id: string) => void;
getById: (id: string) => Order | undefined;
getByPickupCode: (code: string) => Order | undefined;
};
function timelineFor(status: OrderStatus, createdAt: string) {
@ -141,11 +147,18 @@ export const useOrdersStore = create<OrdersState>()(
}),
cancelOrder: (id) => get().updateStatus(id, "cancelled"),
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",
storage: createJSONStorage(() => AsyncStorage),
version: 3,
version: 4,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<OrdersState>;
if (!state.orders?.length) {
@ -153,9 +166,9 @@ export const useOrdersStore = create<OrdersState>()(
...state,
orders: seedOrders.map((o) => ({
...o,
discount: 0,
customerName: "Kirukib Abebe",
customerPhone: "+251911000000",
discount: o.discount ?? 0,
customerName: o.customerName ?? "Kirukib Abebe",
customerPhone: o.customerPhone ?? "+251911000000",
timeline: timelineFor(o.status, o.createdAt),
})),
};

View File

@ -108,9 +108,16 @@ export const usePrescriptionStore = create<RxState>()(
reminders: get().reminders.filter((r) => r.prescriptionId !== id),
}),
upsertReminder: (r) => {
const id = r.id ?? `rem_${Date.now()}`;
const next = { ...r, id } as Reminder;
const others = get().reminders.filter((x) => x.id !== id);
const existing = get().reminders.find(
(x) =>
(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] });
},
removeReminder: (id) =>

View File

@ -16,6 +16,7 @@
"shop": "ሱቅ",
"branches": "ቅርንጫፎች",
"orders": "ትዕዛዞች",
"notifications": "ማሳወቂያዎች",
"loyalty": "ነጥቦች",
"about": "ስለ እኛ",
"contact": "አግኙን",
@ -43,6 +44,7 @@
"family": "ቤተሰብ",
"subscriptions": "ደንበኝነት ምዝገባዎች",
"prescriptions": "የሐኪም ትዕዛዞች",
"rxManage": "የሐኪም እንክብካቤ",
"switchProfile": "መገለጫ ቀይር",
"joinOrg": "ድርጅት ተቀላቀል",
"payAtBranch": "በቅርንጫፍ ክፈል",

View File

@ -16,6 +16,7 @@
"shop": "Shop",
"branches": "Branches",
"orders": "Orders",
"notifications": "Notifications",
"loyalty": "Loyalty",
"about": "About",
"contact": "Contact",
@ -43,6 +44,7 @@
"family": "Family",
"subscriptions": "Subscriptions",
"prescriptions": "Prescriptions",
"rxManage": "Rx Care",
"switchProfile": "Switch profile",
"joinOrg": "Join organisation",
"payAtBranch": "Pay at branch",