Compare commits

...

2 Commits

Author SHA1 Message Date
5391780b9b 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>
2026-08-07 15:35:51 +03:00
7310a349c5 Add Gishen mobile app with complete demo shopping and care flows.
Ship Expo Router screens, seeded stores, and polished UX so auth, shop, checkout, prescriptions, loyalty, family, and account processes work end-to-end offline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 01:21:32 +03:00
101 changed files with 22905 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
.expo-export-check/
.expo-export-ios/
.DS_Store
*.log
.env
.env.local
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

View File

@ -0,0 +1,31 @@
# Gishen Pharmacy — Mobile
React Native (Expo) customer app for Gishen Pharmacy.
## Stack
- Expo SDK 53 + expo-router
- NativeWind / Tailwind
- Zustand + AsyncStorage
- i18next (`en` / `am`)
## Run
```bash
npm install --legacy-peer-deps
npx expo start
```
## Brand
Colors and logo from Gishen-Ecom (`#A8C73A` / `#141414`).
## Backend
Living contract: [`docs/BACKEND_SPEC.md`](docs/BACKEND_SPEC.md) — update whenever features change.
## Demo
- OTP: any 4+ digits (hint `1234`)
- Org join codes: `GISHEN-BANK`, `GISHEN-NGO`
- Payments: Chapa / Telebirr / M-Pesa; pay-at-branch only for pickup

73
app.json Normal file
View File

@ -0,0 +1,73 @@
{
"expo": {
"name": "Gishen Pharmacy",
"slug": "gishen-mob",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "gishen",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#F6F7F2"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.gishen.pharmacy",
"infoPlist": {
"NSCameraUsageDescription": "Gishen needs camera access to scan prescriptions.",
"NSPhotoLibraryUsageDescription": "Gishen needs photo access to upload prescriptions.",
"NSLocationWhenInUseUsageDescription": "Gishen uses your location to find the nearest branch.",
"NSFaceIDUsageDescription": "Gishen uses biometrics to unlock the app securely.",
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#F6F7F2"
},
"package": "com.gishen.pharmacy",
"permissions": [
"CAMERA",
"ACCESS_FINE_LOCATION",
"RECEIVE_BOOT_COMPLETED",
"VIBRATE"
]
},
"plugins": [
"expo-router",
"expo-font",
[
"expo-camera",
{
"cameraPermission": "Allow Gishen to scan prescriptions."
}
],
[
"expo-location",
{
"locationWhenInUsePermission": "Allow Gishen to find nearby branches."
}
],
[
"expo-notifications",
{
"icon": "./assets/icon.png",
"color": "#A8C73A"
}
],
"expo-local-authentication"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {
"origin": false
}
}
}
}

View File

@ -0,0 +1,13 @@
import { Stack } from "expo-router";
import { colors } from "@/lib/theme";
export default function ScreensLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.page },
}}
/>
);
}

View File

@ -0,0 +1,96 @@
import { Image, Linking, ScrollView, View } from "react-native";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card, InfoRow } from "@/components/ui/Card";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { brand } from "@/lib/data/brand";
import { colors, gutter } from "@/lib/theme";
export default function AboutScreen() {
return (
<Screen>
<AppHeader title="About" subtitle={brand.tagline} />
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 14,
}}
>
<Card>
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
<Image
source={require("@/assets/logo/gishen-logo.png")}
style={{ width: 64, height: 64, resizeMode: "contain" }}
/>
<View style={{ flex: 1 }}>
<Text variant="h2">{brand.name}</Text>
<Text variant="caption" color={colors.muted} style={{ marginTop: 4 }}>
Est. {brand.founded} · {brand.branchCount} branches
</Text>
</View>
</View>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 14 }}>
<Badge label={`${brand.institutions}+ institutions`} tone="brand" />
<Badge label={`${brand.googleRating}★ Google`} tone="neutral" />
<Badge label={`${brand.weeklyCustomers}/wk customers`} tone="info" />
</View>
</Card>
<Card elevated={false}>
<Text variant="h3">Our story</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8, lineHeight: 22 }}>
{brand.story}
</Text>
</Card>
<Card elevated={false}>
<Text variant="h3">Purpose</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8, lineHeight: 22 }}>
{brand.purpose}
</Text>
</Card>
<Card elevated={false}>
<Text variant="h3">Vision</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8, lineHeight: 22 }}>
{brand.vision}
</Text>
</Card>
<Card elevated={false}>
<Text variant="h3">Mission</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8, lineHeight: 22 }}>
{brand.mission}
</Text>
</Card>
<Card padded={false}>
<InfoRow label="Phone" value={brand.phoneDisplay} />
<InfoRow label="Email" value={brand.email} />
<InfoRow label="Orders" value={brand.orderEmail} />
<InfoRow label="Website" value={brand.website.replace("https://", "")} last />
</Card>
<Button
title="Call pharmacy"
icon="call-outline"
onPress={() => Linking.openURL(`tel:${brand.phone}`)}
/>
<Button
title="WhatsApp"
variant="outline"
icon="logo-whatsapp"
onPress={() => Linking.openURL(brand.socials[0].href)}
/>
<Button
title="Open website"
variant="ghost"
onPress={() => Linking.openURL(brand.website)}
/>
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,136 @@
import { router } from "expo-router";
import { useState } from "react";
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 { Field } from "@/components/ui/Field";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { useAddressStore } from "@/lib/stores/addressStore";
import { useLocationStore } from "@/lib/stores/locationStore";
import { colors, gutter } from "@/lib/theme";
export default function AddressesScreen() {
const { t } = useTranslation();
const addresses = useAddressStore((s) => s.addresses);
const add = useAddressStore((s) => s.add);
const remove = useAddressStore((s) => s.remove);
const setDefault = useAddressStore((s) => s.setDefault);
const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
const [label, setLabel] = useState("Home");
const [area, setArea] = useState("Bole, Addis Ababa");
const [street, setStreet] = useState("");
const [landmark, setLandmark] = useState("");
const [showForm, setShowForm] = useState(false);
return (
<Screen>
<AppHeader title={t("common.addresses")} subtitle="Saved delivery locations" />
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 12 }}>
{addresses.map((a) => (
<Card key={a.id}>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text variant="h3">{a.label}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
{a.street}
</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{a.area}
{a.landmark ? ` · ${a.landmark}` : ""}
</Text>
</View>
{a.isDefault ? <Badge label="Default" tone="brand" /> : null}
</View>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
{!a.isDefault ? (
<Button
title="Set default"
size="sm"
variant="outline"
fullWidth={false}
onPress={() => {
setDefault(a.id);
setAreaLabel(a.area);
}}
/>
) : (
<Button
title="Use for checkout"
size="sm"
fullWidth={false}
onPress={() => {
setAreaLabel(a.area);
router.push("/(root)/(tabs)/cart");
}}
/>
)}
<Button
title="Remove"
size="sm"
variant="ghost"
fullWidth={false}
onPress={() =>
Alert.alert("Remove address?", a.label, [
{ text: "Cancel", style: "cancel" },
{ text: "Remove", style: "destructive", onPress: () => remove(a.id) },
])
}
/>
</View>
</Card>
))}
{!showForm ? (
<Button title="Add address" icon="add" onPress={() => setShowForm(true)} />
) : (
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 12 }}>
New address
</Text>
<View style={{ gap: 12 }}>
<Field label="Label" value={label} onChangeText={setLabel} placeholder="Home, Office…" />
<Field label="Area" value={area} onChangeText={setArea} placeholder="Bole, Addis Ababa" />
<Field
label="Street / building"
value={street}
onChangeText={setStreet}
placeholder="Street, building, apt"
/>
<Field
label="Landmark (optional)"
value={landmark}
onChangeText={setLandmark}
placeholder="Near Edna Mall"
/>
<Button
title="Save address"
onPress={() => {
if (!street.trim() || !area.trim()) {
Alert.alert("Missing info", "Area and street are required.");
return;
}
add({
label: label.trim() || "Address",
area: area.trim(),
street: street.trim(),
landmark: landmark.trim() || undefined,
isDefault: addresses.length === 0,
});
setStreet("");
setLandmark("");
setShowForm(false);
}}
/>
<Button title="Cancel" variant="ghost" onPress={() => setShowForm(false)} />
</View>
</Card>
)}
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,146 @@
import * as Location from "expo-location";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Linking, 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 { branches } from "@/lib/data/branches";
import { routes } from "@/lib/routes";
import { useLocationStore } from "@/lib/stores/locationStore";
import { colors, gutter } from "@/lib/theme";
function dist(a: { lat: number; lng: number }, b: { lat: number; lng: number }) {
const toRad = (d: number) => (d * Math.PI) / 180;
const R = 6371;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(x));
}
export default function BranchesScreen() {
const { t } = useTranslation();
const setBranchId = useLocationStore((s) => s.setBranchId);
const setFulfillment = useLocationStore((s) => s.setFulfillment);
const setCoords = useLocationStore((s) => s.setCoords);
const branchId = useLocationStore((s) => s.branchId);
const [coords, setLocal] = useState<{ lat: number; lng: number } | null>(null);
const sorted = useMemo(() => {
if (!coords) return branches;
return [...branches].sort((a, b) => dist(coords, a) - dist(coords, b));
}, [coords]);
const useMyLocation = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
Alert.alert("Location", "Permission denied");
return;
}
const pos = await Location.getCurrentPositionAsync({});
const c = { lat: pos.coords.latitude, lng: pos.coords.longitude };
setLocal(c);
setCoords(c.lat, c.lng);
};
return (
<Screen>
<AppHeader
title={t("common.branches")}
subtitle={`${branches.length} locations across Addis Ababa`}
/>
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 12,
}}
>
<Button
title={t("common.useLocation")}
icon="navigate-outline"
onPress={useMyLocation}
/>
<Text variant="h3">{t("common.nearestBranch")}</Text>
{sorted.map((b, index) => {
const selected = branchId === b.id;
const km = coords ? dist(coords, b) : null;
return (
<Card
key={b.id}
style={{
borderWidth: selected ? 2 : 1,
borderColor: selected ? colors.brand : colors.line,
}}
>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 10 }}>
<View style={{ flex: 1 }}>
<Text variant="h3">{b.name}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
{b.area}
</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{b.hours}
{b.pickupAvailable ? " · Pickup" : ""}
</Text>
</View>
<View style={{ alignItems: "flex-end", gap: 6 }}>
{selected ? <Badge label="Selected" tone="brand" /> : null}
{index === 0 && coords ? <Badge label="Nearest" tone="info" /> : null}
{km != null ? (
<Text variant="label" color={colors.brandText}>
{km.toFixed(1)} km
</Text>
) : null}
</View>
</View>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 14 }}>
<Button
title="Call"
size="md"
fullWidth={false}
variant="outline"
icon="call-outline"
onPress={() => Linking.openURL(`tel:${b.phone}`)}
/>
<Button
title="Maps"
size="md"
fullWidth={false}
variant="outline"
icon="map-outline"
onPress={() => Linking.openURL(b.mapsUrl)}
/>
<Button
title="Set pickup"
size="md"
fullWidth={false}
onPress={() => {
setBranchId(b.id);
setFulfillment("pickup");
Alert.alert("Pickup set", `${b.name} selected for checkout.`);
}}
/>
</View>
</Card>
);
})}
<Button
title="Go to checkout"
variant="ghost"
onPress={() => router.push(routes.checkout)}
/>
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,555 @@
import { router } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/Button";
import { Card, InfoRow } from "@/components/ui/Card";
import { Chip, Segmented } from "@/components/ui/Chip";
import { Field, OptionRow } from "@/components/ui/Field";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { StickyBar } from "@/components/ui/StickyBar";
import { Text } from "@/components/ui/Text";
import { DELIVERY_FEE, generatePickupCode, orderSubtotalTotal, voucherDiscount } from "@/lib/commerce";
import { branches } from "@/lib/data/branches";
import { routes } from "@/lib/routes";
import { useAddressStore } from "@/lib/stores/addressStore";
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";
import { formatETB, WHATSAPP_NUMBER } from "@/lib/utils";
export default function CheckoutScreen() {
const { t } = useTranslation();
const items = useCartStore((s) => s.items);
const clear = useCartStore((s) => s.clear);
const subtotal = useCartStore((s) => s.subtotal());
const cartVoucher = useCartStore((s) => s.voucherCode);
const setCartVoucher = useCartStore((s) => s.setVoucher);
const fulfillment = useLocationStore((s) => s.fulfillment);
const setFulfillment = useLocationStore((s) => s.setFulfillment);
const branchId = useLocationStore((s) => s.branchId);
const setBranchId = useLocationStore((s) => s.setBranchId);
const areaLabel = useLocationStore((s) => s.areaLabel);
const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
const payment = useLocationStore((s) => s.paymentPreference);
const setPayment = useLocationStore((s) => s.setPaymentPreference);
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);
const quoteSplit = useProfileModeStore((s) => s.quoteSplit);
const consumeAllowance = useProfileModeStore((s) => s.consumeAllowance);
const mode = useProfileModeStore((s) => s.mode);
const membership = useProfileModeStore((s) => s.activeMembership());
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 && !user.isGuest ? user.name : "");
const [phone, setPhone] = useState(user?.phone ?? "");
const [notes, setNotes] = useState("");
const [addressId, setAddressId] = useState(defaultAddress?.id ?? "");
const [voucher, setVoucher] = useState(
cartVoucher || user?.activeVoucher || ""
);
const [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 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" && !isGuest
? quoteSplit(afterDiscount)
: { covered: 0, selfPay: afterDiscount };
const total = orderSubtotalTotal({
subtotal: split.selfPay,
deliveryFee,
discount: 0,
});
const pay = () => {
if (!items.length) return;
if (!name.trim() || phone.trim().length < 9) {
Alert.alert("Contact details", "Enter your name and a valid phone number.");
return;
}
if (!allowed.includes(payment) && !(useAnyBranchPickup && payment === "pay_at_branch")) {
Alert.alert("Payment", "Select a valid payment method.");
return;
}
if (effectiveFulfillment === "pickup" && !useAnyBranchPickup && !branchId) {
Alert.alert("Pickup", "Select a branch, or choose any-branch quick code.");
return;
}
if (effectiveFulfillment === "delivery" && !selectedAddress && !areaLabel.trim()) {
Alert.alert("Delivery", "Choose a saved address or enter an area.");
return;
}
const branch = branches.find((b) => b.id === branchId);
const addressLine = selectedAddress
? `${selectedAddress.street}${selectedAddress.landmark ? ` · ${selectedAddress.landmark}` : ""}`
: undefined;
const forMember = familyMembers.find((m) => m.id === forMemberId);
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: fee,
discount,
voucherCode: discount ? voucher.trim().toUpperCase() : null,
total: afterDiscount + fee,
fulfillment: effectiveFulfillment,
branchId: useAnyBranchPickup ? null : branchId,
areaLabel: useAnyBranchPickup
? "Any Gishen branch"
: effectiveFulfillment === "pickup"
? branch?.area ?? areaLabel
: selectedAddress?.area ?? areaLabel,
addressId:
effectiveFulfillment === "delivery" ? selectedAddress?.id ?? null : null,
addressLine: useAnyBranchPickup ? undefined : addressLine,
paymentMethod: payment,
corporateCovered: split.covered,
selfPay: split.selfPay + fee,
customerName: name.trim(),
customerPhone: phone.trim(),
notes: orderNotes,
riderAssigned: false,
pickupCode,
anyBranch: useAnyBranchPickup,
isGuestOrder: isGuest,
status:
useAnyBranchPickup || payment === "pay_at_branch" ? "confirmed" : undefined,
});
if (forMemberId) setActiveMember(forMemberId);
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(
pickupCode ? "Quick pickup code ready" : "Order placed",
pickupCode
? `Your code is ${pickupCode}. ${
useAnyBranchPickup
? "Present it at any Gishen branch for fulfillment."
: `Present it at ${branch?.name ?? "the branch"}.`
}${
payment === "pay_at_branch"
? ` Pay ${formatETB(order.selfPay ?? order.total)} at the counter.`
: ""
}`
: payment === "pay_at_branch"
? `Pay ${formatETB(order.selfPay ?? order.total)} at ${branch?.name ?? "the branch"}.`
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
[
{ text: "View order", onPress: () => router.replace(routes.orderDetail(order.id)) },
{
text: pickupCode ? "Done" : "Track",
onPress: () =>
pickupCode
? router.replace(routes.orderDetail(order.id))
: router.replace(routes.tracking(order.id)),
},
]
);
};
const whatsapp = () => {
const lines = items
.map((i) => `• ${i.name} x${i.quantity} — ${i.price * i.quantity} ETB`)
.join("%0A");
const msg = `Gishen order%0AName: ${name}%0APhone: ${phone}%0A${lines}%0ATotal: ${total} ETB%0A${fulfillment}%0A${notes}`;
Linking.openURL(`https://wa.me/${WHATSAPP_NUMBER}?text=${msg}`);
};
if (!items.length) {
return (
<Screen>
<AppHeader title={t("common.checkout")} />
<View style={{ padding: gutter }}>
<Text variant="body" color={colors.muted}>
Your cart is empty. Add products before checking out.
</Text>
<Button
title="Browse shop"
style={{ marginTop: 16 }}
onPress={() => router.replace(routes.shop)}
/>
</View>
</Screen>
);
}
return (
<Screen>
<AppHeader title={t("common.checkout")} subtitle="Confirm delivery and payment" />
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 140,
gap: 14,
}}
>
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}>
Contact
</Text>
{familyMembers.length ? (
<View style={{ marginBottom: 12 }}>
<Text variant="label" color={colors.muted} style={{ marginBottom: 8 }}>
Ordering for
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
<Chip
label="Myself"
selected={!forMemberId}
onPress={() => setForMemberId(null)}
size="sm"
/>
{familyMembers.map((m) => (
<Chip
key={m.id}
label={m.name}
selected={forMemberId === m.id}
onPress={() => setForMemberId(m.id)}
size="sm"
/>
))}
</View>
</View>
) : null}
<View style={{ gap: 12 }}>
<Field label="Full name" value={name} onChangeText={setName} icon="person-outline" />
<Field
label="Phone"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
<Field
label="Order notes"
value={notes}
onChangeText={setNotes}
icon="create-outline"
placeholder="Gate code, call on arrival…"
/>
</View>
</Card>
<Card elevated={false}>
<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={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") },
]}
/>
{effectiveFulfillment === "delivery" ? (
<View style={{ marginTop: 14, gap: 8 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<Text variant="label" color={colors.muted}>
Saved addresses
</Text>
<Pressable onPress={() => router.push(routes.addresses)}>
<Text variant="label" color={colors.brandText}>
Manage
</Text>
</Pressable>
</View>
{addresses.map((a) => (
<Pressable
key={a.id}
onPress={() => {
setAddressId(a.id);
setAreaLabel(a.area);
}}
style={{
padding: 12,
borderRadius: 14,
borderWidth: 1.5,
borderColor: addressId === a.id ? colors.brand : colors.line,
backgroundColor: addressId === a.id ? colors.brandWash : colors.surface,
}}
>
<Text variant="h3">
{a.label}
{a.isDefault ? " · Default" : ""}
</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{a.street}
</Text>
<Text variant="caption" color={colors.subtle}>
{a.area}
</Text>
</Pressable>
))}
<Field
label="Or type area"
value={areaLabel}
onChangeText={setAreaLabel}
icon="location-outline"
/>
</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={() => {
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="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{b.area} · {b.hours}
</Text>
</Pressable>
))}
</View>
)}
</Card>
<Card elevated={false} padded={false}>
<View style={{ padding: 16, paddingBottom: 8 }}>
<Text variant="h3">{t("common.payment")}</Text>
{effectiveFulfillment === "delivery" ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
Pay at branch is only available for pickup
</Text>
) : null}
</View>
{allowed.map((m, i) => (
<OptionRow
key={m}
title={labelPayment(m, t)}
subtitle={paymentHint(m)}
selected={payment === m}
onPress={() => setPayment(m)}
icon={paymentIcon(m)}
last={i === allowed.length - 1}
/>
))}
</Card>
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}>
Voucher
</Text>
<Field
label="Code"
value={voucher}
onChangeText={setVoucher}
placeholder="GISHEN10"
icon="pricetag-outline"
hint={discount ? `Saving ${formatETB(discount)}` : "Try GISHEN10 for 10% off"}
/>
<View style={{ flexDirection: "row", gap: 8, marginTop: 10 }}>
<Chip label="GISHEN10" onPress={() => setVoucher("GISHEN10")} selected={voucher.toUpperCase() === "GISHEN10"} />
{user?.activeVoucher ? (
<Chip
label={`Loyalty: ${user.activeVoucher}`}
onPress={() => setVoucher(user.activeVoucher!)}
selected={voucher === user.activeVoucher}
/>
) : null}
</View>
</Card>
{mode === "corporate" && membership ? (
<Card tone="brand" elevated={false}>
<Text variant="h3">Corporate split · {membership.orgName}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
Plan {membership.planName} · {formatETB(membership.remainingAllowance)} left this month
</Text>
<Text variant="bodySm" style={{ marginTop: 8 }}>
Covered: {formatETB(split.covered)}
</Text>
<Text variant="bodySm">You pay: {formatETB(split.selfPay + deliveryFee)}</Text>
</Card>
) : null}
<Card elevated={false}>
<InfoRow label="Items" value={`${items.length}`} />
<InfoRow label="Subtotal" value={formatETB(subtotal)} />
{discount > 0 ? (
<InfoRow
label="Discount"
value={`- ${formatETB(discount)}`}
valueColor={colors.brandText}
/>
) : null}
<InfoRow
label={effectiveFulfillment === "delivery" ? "Delivery" : "Pickup"}
value={deliveryFee ? formatETB(deliveryFee) : "Free"}
/>
<InfoRow label={t("common.total")} value={formatETB(afterDiscount + deliveryFee)} emphasis last />
</Card>
<Button title="Order via WhatsApp" variant="outline" icon="logo-whatsapp" onPress={whatsapp} />
</ScrollView>
<StickyBar>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text variant="caption" color={colors.subtle}>
Total due
</Text>
<Text variant="priceLg">{formatETB(afterDiscount + deliveryFee)}</Text>
</View>
<Button
title={t("common.payNow")}
fullWidth={false}
style={{ minWidth: 140 }}
onPress={pay}
/>
</View>
</StickyBar>
</Screen>
);
}
function labelPayment(m: PaymentMethod, t: (k: string) => string) {
if (m === "chapa") return t("common.chapa");
if (m === "telebirr") return t("common.telebirr");
if (m === "mpesa") return t("common.mpesa");
return t("common.payAtBranch");
}
function paymentHint(m: PaymentMethod) {
if (m === "chapa") return "Card / mobile money via Chapa";
if (m === "telebirr") return "Ethio telecom Telebirr";
if (m === "mpesa") return "Safaricom M-Pesa";
return "Cash or POS when you collect";
}
function paymentIcon(m: PaymentMethod): keyof typeof import("@expo/vector-icons").Ionicons.glyphMap {
if (m === "pay_at_branch") return "storefront-outline";
if (m === "telebirr") return "phone-portrait-outline";
if (m === "mpesa") return "cash-outline";
return "card-outline";
}

View File

@ -0,0 +1,143 @@
import { useState } from "react";
import { Alert, Linking, ScrollView, View } from "react-native";
import { Button } from "@/components/ui/Button";
import { Card, InfoRow } from "@/components/ui/Card";
import { Field } from "@/components/ui/Field";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { brand } from "@/lib/data/brand";
import { useAuthStore } from "@/lib/stores/authStore";
import { colors, gutter } from "@/lib/theme";
import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils";
type Ticket = {
id: string;
at: string;
subject: string;
message: string;
status: "received" | "in_progress" | "resolved";
};
export default function ContactScreen() {
const user = useAuthStore((s) => s.user);
const [name, setName] = useState(user?.name ?? "");
const [subject, setSubject] = useState("Order help");
const [message, setMessage] = useState("");
const [tickets, setTickets] = useState<Ticket[]>([
{
id: "tkt_demo_1",
at: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(),
subject: "Refill timing",
message: "When will my Metformin refill be ready at CMC?",
status: "resolved",
},
]);
return (
<Screen>
<AppHeader title="Contact" subtitle="Talk to Gishen support" />
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}>
<Card elevated={false}>
<InfoRow label="Phone" value={brand.phoneDisplay} />
<InfoRow label="Email" value={brand.email} />
<InfoRow label="Orders email" value={brand.orderEmail} last />
<View style={{ flexDirection: "row", gap: 8, marginTop: 12, flexWrap: "wrap" }}>
<Button
title="Call"
size="sm"
fullWidth={false}
icon="call-outline"
onPress={() => Linking.openURL(`tel:${CALL_PHONE}`)}
/>
<Button
title="WhatsApp"
size="sm"
variant="outline"
fullWidth={false}
icon="logo-whatsapp"
onPress={() => Linking.openURL(`https://wa.me/${WHATSAPP_NUMBER}`)}
/>
<Button
title="Live chat"
size="sm"
variant="soft"
fullWidth={false}
icon="chatbubble-ellipses-outline"
onPress={() => Linking.openURL(CHATWOOT_BASE)}
/>
</View>
</Card>
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 12 }}>
Send a message
</Text>
<View style={{ gap: 12 }}>
<Field label="Your name" value={name} onChangeText={setName} />
<Field label="Subject" value={subject} onChangeText={setSubject} />
<Field
label="Message"
value={message}
onChangeText={setMessage}
placeholder="How can we help?"
multiline
style={{ height: 100, textAlignVertical: "top", paddingTop: 12 }}
/>
<Button
title="Send message"
onPress={() => {
if (!message.trim()) {
Alert.alert("Message required", "Tell us how we can help.");
return;
}
const id = `tkt_${Date.now().toString().slice(-6)}`;
setTickets((prev) => [
{
id,
at: new Date().toISOString(),
subject: subject.trim() || "General",
message: message.trim(),
status: "received",
},
...prev,
]);
setMessage("");
Alert.alert(
"Ticket created",
`Reference ${id}. We'll reply by phone or WhatsApp (demo — not sent to a server).`
);
}}
/>
</View>
</Card>
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}>
Your tickets
</Text>
{tickets.map((t) => (
<View
key={t.id}
style={{
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.line,
}}
>
<Text variant="h3">
{t.subject} · {t.id}
</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{new Date(t.at).toLocaleString()} · {t.status}
</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
{t.message}
</Text>
</View>
))}
</Card>
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,143 @@
import { Linking, ScrollView, View } from "react-native";
import { useState } from "react";
import { Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/Badge";
import { Button, IconButton } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Chip } from "@/components/ui/Chip";
import { Field, SwitchRow } from "@/components/ui/Field";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { useEmergencyContactStore } from "@/lib/stores/emergencyContactStore";
import { colors, gutter } from "@/lib/theme";
const RELATIONSHIPS = ["spouse", "parent", "child", "sibling", "friend", "other"] as const;
export default function EmergencyContactsScreen() {
const { t } = useTranslation();
const contacts = useEmergencyContactStore((s) => s.contacts);
const add = useEmergencyContactStore((s) => s.add);
const remove = useEmergencyContactStore((s) => s.remove);
const grace = useEmergencyContactStore((s) => s.graceMinutesDefault);
const setGrace = useEmergencyContactStore((s) => s.setGrace);
const escalationEnabled = useEmergencyContactStore((s) => s.escalationEnabled);
const setEscalationEnabled = useEmergencyContactStore((s) => s.setEscalationEnabled);
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [relationship, setRelationship] =
useState<(typeof RELATIONSHIPS)[number]>("spouse");
return (
<Screen>
<AppHeader
title={t("common.emergencyContacts")}
subtitle="Notified if a dose isn’t confirmed in time"
/>
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 14,
}}
>
<Card padded={false}>
<SwitchRow
icon="notifications-outline"
title="Enable escalation"
subtitle={t("common.escalation")}
value={escalationEnabled}
onValueChange={setEscalationEnabled}
/>
<View style={{ padding: 16, paddingTop: 4 }}>
<Field
label={t("common.graceMinutes")}
value={String(grace)}
keyboardType="number-pad"
onChangeText={(v) => setGrace(Number(v) || 30)}
hint="Minutes to wait before notifying your contact"
/>
</View>
</Card>
<Card>
<Text variant="h3">Add contact</Text>
<View style={{ gap: 12, marginTop: 12 }}>
<Field label="Name" value={name} onChangeText={setName} icon="person-outline" />
<Field
label="Phone"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
<Text variant="label" color={colors.muted}>
Relationship
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{RELATIONSHIPS.map((r) => (
<Chip
key={r}
label={r}
selected={relationship === r}
onPress={() => setRelationship(r)}
size="sm"
/>
))}
</View>
<Button
title="Add contact"
onPress={() => {
if (!name.trim() || !phone.trim()) {
Alert.alert("Required", "Name and phone are required.");
return;
}
add({ name: name.trim(), phone: phone.trim(), relationship });
setName("");
setPhone("");
Alert.alert("Added", `${name.trim()} can receive missed-dose alerts.`);
}}
/>
</View>
</Card>
<Text variant="h3">{contacts.length ? "Your contacts" : "No contacts yet"}</Text>
{contacts.map((c) => (
<Card key={c.id}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text variant="h3">{c.name}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 2 }}>
{c.phone}
</Text>
<View style={{ marginTop: 8 }}>
<Badge label={c.relationship} tone="neutral" />
</View>
</View>
<IconButton
icon="call-outline"
onPress={() => Linking.openURL(`tel:${c.phone}`)}
accessibilityLabel="Call"
/>
<IconButton
icon="trash-outline"
onPress={() =>
Alert.alert("Remove contact?", c.name, [
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: () => remove(c.id),
},
])
}
accessibilityLabel="Remove"
/>
</View>
</Card>
))}
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,7 @@
import { Redirect } from "expo-router";
import { routes } from "@/lib/routes";
/** 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,85 @@
import { Share, View } from "react-native";
import { router } from "expo-router";
import { useState } from "react";
import { Alert } from "react-native";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Chip } from "@/components/ui/Chip";
import { Field } from "@/components/ui/Field";
import { 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";
const RELATIONS = ["spouse", "child", "parent", "other"] as const;
export default function FamilyInviteScreen() {
const addMember = useFamilyStore((s) => s.addMember);
const inviteCode = useFamilyStore((s) => s.inviteCode);
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [relationship, setRelationship] = useState<(typeof RELATIONS)[number]>("child");
return (
<Screen>
<AppHeader title="Invite family" subtitle="Share care, orders, and reminders" />
<View style={{ flex: 1, paddingHorizontal: gutter, gap: 14 }}>
<Card tone="brand" elevated={false}>
<Text variant="caption" color={colors.muted}>
Family invite code
</Text>
<Text variant="h2" style={{ marginTop: 4 }}>
{inviteCode}
</Text>
<Button
title="Share code"
variant="outline"
style={{ marginTop: 12 }}
onPress={() =>
Share.share({
message: `Join my Gishen family group with code ${inviteCode}`,
})
}
/>
</Card>
<Text variant="h3">Or add by phone</Text>
<Field label="Name" value={name} onChangeText={setName} icon="person-outline" />
<Field
label="Phone"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
<Text variant="label" color={colors.muted}>
Relationship
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{RELATIONS.map((r) => (
<Chip
key={r}
label={r}
selected={relationship === r}
onPress={() => setRelationship(r)}
size="sm"
/>
))}
</View>
<Button
title="Send invite"
onPress={() => {
if (!name.trim()) {
Alert.alert("Name required");
return;
}
addMember({ name: name.trim(), phone, relationship });
Alert.alert("Invited", `${name.trim()} is pending acceptance.`);
router.back();
}}
/>
</View>
</Screen>
);
}

View File

@ -0,0 +1,167 @@
import { 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, InfoRow, Section } from "@/components/ui/Card";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { DEMO_VOUCHER } from "@/lib/commerce";
import { useAuthStore } from "@/lib/stores/authStore";
import { useCartStore } from "@/lib/stores/cartStore";
import { colors, gutter } from "@/lib/theme";
import { router } from "expo-router";
import { routes } from "@/lib/routes";
import { Alert } from "react-native";
const tiers = [
{ id: "bronze", min: 0, perks: "1 pt per 10 ETB · Birthday surprise" },
{ id: "silver", min: 300, perks: "1.25x points · Free delivery over 800 ETB" },
{ id: "gold", min: 1000, perks: "1.5x points · Priority pharmacist consult" },
] as const;
export default function LoyaltyScreen() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const ledger = useAuthStore((s) => s.ledger);
const redeemPoints = useAuthStore((s) => s.redeemPoints);
const setVoucher = useCartStore((s) => s.setVoucher);
const points = user?.points ?? 0;
const next = tiers.find((tier) => points < tier.min) ?? null;
const progress = next
? Math.min(1, points / next.min)
: 1;
return (
<Screen>
<AppHeader title={t("common.loyalty")} subtitle="Earn points on every paid order" />
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}>
<Card tone="brand" elevated={false}>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<View>
<Text variant="caption" color={colors.muted}>
Available points
</Text>
<Text variant="display" style={{ marginTop: 4 }}>
{points}
</Text>
</View>
<Badge label={(user?.tier ?? "bronze").toUpperCase()} solid />
</View>
<View
style={{
marginTop: 16,
height: 8,
borderRadius: 4,
backgroundColor: "rgba(16,20,16,0.12)",
overflow: "hidden",
}}
>
<View
style={{
width: `${Math.round(progress * 100)}%`,
height: "100%",
backgroundColor: colors.ink,
}}
/>
</View>
<Text variant="caption" color={colors.muted} style={{ marginTop: 8 }}>
{next
? `${next.min - points} pts to ${next.id}`
: "You've reached Gold — enjoy premium perks"}
</Text>
</Card>
<Section title="How tiers work">
<Card elevated={false}>
{tiers.map((tier, i) => (
<InfoRow
key={tier.id}
label={`${tier.id} · ${tier.min}+ pts`}
value={tier.perks}
last={i === tiers.length - 1}
/>
))}
</Card>
</Section>
<Card elevated={false}>
<Text variant="h3">Redeem rewards</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
Redeem 100 points for a {DEMO_VOUCHER} voucher (10% off your next order).
</Text>
{user?.activeVoucher ? (
<Badge
label={`Active voucher: ${user.activeVoucher}`}
tone="brand"
style={{ marginTop: 12 }}
/>
) : null}
<Button
title="Redeem 100 pts"
style={{ marginTop: 14 }}
disabled={!user || user.isGuest || points < 100}
onPress={() => {
const ok = redeemPoints(100);
if (!ok) {
Alert.alert("Not enough points", "You need at least 100 points.");
return;
}
setVoucher(DEMO_VOUCHER);
Alert.alert(
"Voucher unlocked",
`${DEMO_VOUCHER} is ready in your cart / checkout.`,
[
{ text: "Go to cart", onPress: () => router.push(routes.cart) },
{ text: "OK" },
]
);
}}
/>
</Card>
<Card elevated={false}>
<Text variant="h3">{t("common.referral")}</Text>
<Text variant="priceLg" style={{ marginTop: 8 }}>
{user?.referralCode ?? "GISHEN-GUEST"}
</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
Friends get 50 welcome points. You get 100 when they place their first order.
</Text>
<Button
title="Share code"
variant="outline"
icon="share-outline"
style={{ marginTop: 14 }}
onPress={() =>
Share.share({
message: `Join Gishen Pharmacy with my code ${user?.referralCode ?? ""}. Healthcare delivered to your doorstep.`,
})
}
/>
</Card>
<Section title="Points history">
<Card elevated={false}>
{ledger.length === 0 ? (
<Text variant="bodySm" color={colors.muted}>
Points from orders will appear here.
</Text>
) : (
ledger.map((entry, i) => (
<InfoRow
key={entry.id}
label={`${entry.label} · ${new Date(entry.at).toLocaleDateString()}`}
value={`${entry.delta > 0 ? "+" : ""}${entry.delta}`}
valueColor={entry.delta >= 0 ? colors.brandText : colors.danger}
last={i === ledger.length - 1}
/>
))
)}
</Card>
</Section>
</ScrollView>
</Screen>
);
}

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

@ -0,0 +1,268 @@
import { Ionicons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
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 { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { getBranchById } from "@/lib/data/branches";
import { routes } from "@/lib/routes";
import { useOrdersStore } from "@/lib/stores/ordersStore";
import { colors, gutter, radii } from "@/lib/theme";
import { formatETB } from "@/lib/utils";
const toneFor = {
requested: "neutral",
confirmed: "info",
paid: "brand",
dispatched: "warn",
delivered: "brand",
completed: "brand",
cancelled: "danger",
} as const;
export default function OrderDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const order = useOrdersStore((s) => s.getById(id));
const cancelOrder = useOrdersStore((s) => s.cancelOrder);
const updateStatus = useOrdersStore((s) => s.updateStatus);
if (!order) {
return (
<Screen>
<AppHeader title="Order" />
<View style={{ padding: gutter }}>
<Text variant="body" color={colors.muted}>
Order not found.
</Text>
</View>
</Screen>
);
}
const branch = order.branchId ? getBranchById(order.branchId) : undefined;
const canCancel = !["delivered", "completed", "cancelled"].includes(order.status);
return (
<Screen>
<AppHeader
title="Order details"
subtitle={new Date(order.createdAt).toLocaleString()}
right={
<Badge label={order.status} tone={toneFor[order.status]} />
}
/>
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 14,
}}
>
<Card>
<Text variant="caption" color={colors.subtle}>
{order.id}
{order.isGuestOrder ? " · Guest" : ""}
</Text>
<Text variant="h2" style={{ marginTop: 4 }}>
{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}
</Text>
{order.notes ? (
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
Notes: {order.notes}
</Text>
) : 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) => (
<View
key={item.productId}
style={{
flexDirection: "row",
gap: 12,
paddingVertical: 12,
borderBottomWidth: index === order.items.length - 1 ? 0 : 1,
borderBottomColor: colors.line,
}}
>
<Image
source={{ uri: item.image }}
style={{
width: 52,
height: 52,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
}}
resizeMode="contain"
/>
<View style={{ flex: 1 }}>
<Text variant="h3">{item.name}</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{item.unit} · Qty {item.quantity}
{item.requiresPrescription ? " · RX" : ""}
</Text>
</View>
<Text variant="label">{formatETB(item.price * item.quantity)}</Text>
</View>
))}
</Card>
</Section>
<Section title="Fulfillment">
<Card elevated={false}>
<InfoRow
label="Method"
value={order.fulfillment === "pickup" ? "Pickup" : "Delivery"}
/>
<InfoRow label="Area" value={order.areaLabel || "—"} />
{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={order.anyBranch ? "Any Gishen branch" : "—"}
last
/>
)}
</Card>
</Section>
<Section title="Payment summary">
<Card elevated={false}>
<InfoRow label="Subtotal" value={formatETB(order.subtotal)} />
{(order.discount ?? 0) > 0 ? (
<InfoRow
label={`Discount${order.voucherCode ? ` (${order.voucherCode})` : ""}`}
value={`- ${formatETB(order.discount)}`}
valueColor={colors.brandText}
/>
) : null}
<InfoRow
label="Delivery fee"
value={order.deliveryFee ? formatETB(order.deliveryFee) : "Free"}
/>
{(order.corporateCovered ?? 0) > 0 ? (
<InfoRow
label="Corporate covered"
value={formatETB(order.corporateCovered!)}
valueColor={colors.brandText}
/>
) : null}
<InfoRow label="Payment method" value={order.paymentMethod} />
<InfoRow
label="Total"
value={formatETB(order.total)}
emphasis
last
/>
</Card>
</Section>
<Section title="Status timeline">
<Card elevated={false}>
{(order.timeline ?? []).map((step, index, arr) => (
<View
key={`${step.at}-${step.label}`}
style={{ flexDirection: "row", gap: 12, paddingVertical: 8 }}
>
<View style={{ alignItems: "center", width: 18 }}>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.brand,
marginTop: 4,
}}
/>
{index < arr.length - 1 ? (
<View
style={{
width: 2,
flex: 1,
backgroundColor: colors.brandBorder,
marginTop: 2,
}}
/>
) : null}
</View>
<View style={{ flex: 1, paddingBottom: 8 }}>
<Text variant="h3">{step.label}</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{new Date(step.at).toLocaleString()}
</Text>
</View>
</View>
))}
</Card>
</Section>
<View style={{ gap: 10 }}>
{order.status !== "cancelled" && order.status !== "delivered" ? (
<Button
title="Track order"
icon="navigate-outline"
onPress={() => router.push(routes.tracking(order.id))}
/>
) : null}
{order.status === "confirmed" || order.status === "paid" ? (
<Button
title="Simulate dispatch"
variant="soft"
onPress={() => updateStatus(order.id, "dispatched")}
/>
) : null}
{canCancel ? (
<Button
title="Cancel order"
variant="danger"
onPress={() =>
Alert.alert("Cancel order?", "This cannot be undone in the demo.", [
{ text: "Keep", style: "cancel" },
{
text: "Cancel order",
style: "destructive",
onPress: () => cancelOrder(order.id),
},
])
}
/>
) : null}
</View>
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,155 @@
import { router } from "expo-router";
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";
import { useOrdersStore } from "@/lib/stores/ordersStore";
import { colors, gutter, radii } from "@/lib/theme";
import { formatETB } from "@/lib/utils";
import { routes } from "@/lib/routes";
const toneFor = {
requested: "neutral",
confirmed: "info",
paid: "brand",
dispatched: "warn",
delivered: "brand",
completed: "brand",
cancelled: "danger",
} as const;
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="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"
title="No orders yet"
body="Your purchases will show up here."
actionTitle="Start shopping"
onAction={() => router.push(routes.shop)}
/>
) : (
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>
<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

@ -0,0 +1,376 @@
import { Ionicons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Image, ScrollView, View } from "react-native";
import { 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 } from "@/lib/theme";
import { routes } from "@/lib/routes";
const statusTone = {
submitted: "neutral",
under_review: "warn",
approved: "brand",
queried: "info",
rejected: "danger",
filled: "brand",
} as const;
const lineTone = {
ready: "brand",
review: "warn",
unavailable: "danger",
} as const;
export default function PrescriptionDetailScreen() {
const params = useLocalSearchParams<{ id: string }>();
const id = Array.isArray(params.id) ? params.id[0] : params.id;
const rx = usePrescriptionStore((s) => s.prescriptions.find((p) => p.id === 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 (
<Screen>
<AppHeader title="Prescription" />
<View style={{ padding: gutter }}>
<Text variant="body" color={colors.muted}>
Prescription not found.
</Text>
</View>
</Screen>
);
}
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));
})();
const pendingLog = recentLogs.find((l) => l.status === "pending");
return (
<Screen>
<AppHeader
title="Prescription"
subtitle={rx.patientName}
right={<Badge label={rx.status.replace("_", " ")} tone={statusTone[rx.status]} />}
/>
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 14,
}}
>
<Card elevated style={{ padding: 0, overflow: "hidden" }}>
<View
style={{
height: 160,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
{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>
<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>
<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} padded={false}>
{rx.medicines.map((m, i) => (
<View
key={m.name}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: i === rx.medicines.length - 1 ? 0 : 1,
borderBottomColor: colors.line,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="medkit-outline" size={16} color={colors.brandText} />
</View>
<Text variant="h3" style={{ flex: 1 }}>
{m.name}
</Text>
</View>
<Badge label={m.status} tone={lineTone[m.status]} />
</View>
))}
</Card>
</Section>
<Section title="Details">
<Card elevated={false}>
<InfoRow label="Days supply" value={`${rx.daysSupply} days`} />
<InfoRow
label="Last filled"
value={
rx.lastFilledAt
? new Date(rx.lastFilledAt).toLocaleDateString()
: "Not filled yet"
}
/>
<InfoRow
label="Submitted"
value={new Date(rx.createdAt).toLocaleDateString()}
last={!rx.notes}
/>
{rx.notes ? <InfoRow label="Notes" value={rx.notes} last /> : null}
</Card>
</Section>
<View style={{ gap: 10 }}>
<Button
title="Reorder medicines"
icon="bag-add-outline"
onPress={() => {
const match = products.find((p) =>
rx.medicineName.toLowerCase().includes(p.name.toLowerCase().split(" ")[0])
);
if (match) addProduct(match);
router.push(routes.cart);
}}
/>
<Button
title="Mark as refilled"
variant="outline"
onPress={() => {
markRefilled(rx.id);
Alert.alert("Updated", "Marked as refilled today.");
}}
/>
{rx.status === "under_review" || rx.status === "submitted" ? (
<Button
title="Simulate pharmacist approval"
variant="soft"
onPress={() => updateRxStatus(rx.id, "approved")}
/>
) : null}
</View>
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,283 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, ScrollView, View } from "react-native";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Chip } from "@/components/ui/Chip";
import { Field, SwitchRow } from "@/components/ui/Field";
import { AppHeader } from "@/components/ui/Header";
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 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);
const confirmDose = usePrescriptionStore((s) => s.confirmDose);
const escalateDose = usePrescriptionStore((s) => s.escalateDose);
const doseLogs = usePrescriptionStore((s) => s.doseLogs);
const reminder = usePrescriptionStore((s) =>
s.reminders.find((r) => r.prescriptionId === id)
);
const grace = useEmergencyContactStore((s) => s.graceMinutesDefault);
const setGrace = useEmergencyContactStore((s) => s.setGrace);
const escalationEnabled = useEmergencyContactStore((s) => s.escalationEnabled);
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);
const reminderId = `rem_${id}`;
const relatedLogs = useMemo(
() => doseLogs.filter((l) => l.reminderId === reminderId).slice(0, 8),
[doseLogs, reminderId]
);
if (!rx) {
return (
<Screen>
<EmptyState icon="calendar-outline" title="Prescription not found" />
</Screen>
);
}
const togglePreset = (t: string) => {
const list = times
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (list.includes(t)) {
setTimes(list.filter((x) => x !== t).join(","));
} else {
setTimes([...list, t].sort().join(","));
}
};
const saveAndSchedule = async () => {
const parsed = times
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!parsed.length) {
Alert.alert("Times required", "Pick at least one reminder time.");
return;
}
upsertReminder({
id: reminderId,
prescriptionId: rx.id,
medicineName: rx.medicineName,
times: parsed,
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
graceMinutes: grace,
escalateOnMiss: escalationEnabled,
enabled: true,
});
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",
body: `Notifying ${contacts[0].name} if still unconfirmed`,
graceSeconds: Math.max(20, grace * 60),
data: { logId: log.id, type: "escalation" },
});
setTimeout(() => {
const current = usePrescriptionStore
.getState()
.doseLogs.find((l) => l.id === log.id);
if (current?.status === "pending") {
escalateDose(log.id);
markEscalated();
Alert.alert("Emergency", `${contacts[0].name} would be notified (mock SMS/push)`);
}
}, Math.max(25000, grace * 1000));
} else if (escalationEnabled && !contacts.length) {
Alert.alert(
"No emergency contact",
"Add a contact under Profile → Emergency contacts for escalation."
);
}
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 (
<Screen>
<AppHeader title="Reminders" subtitle={rx.medicineName} />
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 40,
gap: 14,
}}
>
<Card>
<Text variant="caption" color={colors.muted}>
Patient
</Text>
<Text variant="h3" style={{ marginTop: 4 }}>
{rx.patientName || "Self"}
</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
{rx.status} · {rx.daysSupply} day supply
</Text>
</Card>
<Card>
<Text variant="h3">Dose times</Text>
<Text variant="caption" color={colors.muted} style={{ marginTop: 4 }}>
Tap presets or edit the comma-separated list
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
{PRESETS.map((p) => {
const selected = times
.split(",")
.map((s) => s.trim())
.includes(p);
return (
<Chip
key={p}
label={p}
selected={selected}
onPress={() => togglePreset(p)}
size="sm"
/>
);
})}
</View>
<View style={{ marginTop: 12 }}>
<Field
label="Times (HH:mm)"
value={times}
onChangeText={setTimes}
hint="Example: 08:00,14:00,20:00"
/>
</View>
</Card>
<Card padded={false}>
<SwitchRow
icon="alert-circle-outline"
title="Escalate if missed"
subtitle={
contacts[0]
? `Notify ${contacts[0].name} after grace period`
: "Add an emergency contact first"
}
value={escalationEnabled}
onValueChange={setEscalationEnabled}
/>
<View style={{ padding: 16, paddingTop: 4 }}>
<Field
label="Grace minutes"
value={String(grace)}
keyboardType="number-pad"
onChangeText={(v) => setGrace(Number(v) || 30)}
/>
</View>
</Card>
{pendingLogId ? (
<Card tone="brand" elevated={false}>
<Text variant="h3">Confirm this dose</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
Demo reminder is active. Mark taken or snooze.
</Text>
<View style={{ flexDirection: "row", gap: 10, marginTop: 14 }}>
<Button
title="Taken"
style={{ flex: 1 }}
onPress={() => {
confirmDose(pendingLogId);
setPendingLogId(null);
Alert.alert("Logged", "Dose confirmed");
}}
/>
<Button
title="Snooze 10m"
variant="outline"
style={{ flex: 1 }}
onPress={() =>
scheduleDoseReminder({
title: `Snoozed: ${rx.medicineName}`,
body: "Reminder",
secondsFromNow: 600,
})
}
/>
</View>
</Card>
) : null}
<Button title="Save & schedule push" onPress={saveAndSchedule} />
<Text variant="h3">Recent dose logs</Text>
{relatedLogs.length === 0 && doseLogs.length === 0 ? (
<Text variant="bodySm" color={colors.muted}>
No logs yet. Schedule a reminder to start tracking.
</Text>
) : (
(relatedLogs.length ? relatedLogs : doseLogs.slice(0, 8)).map((l) => (
<Card key={l.id} elevated={false}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<Badge
label={l.status.replace("_", " ")}
tone={
l.status === "taken"
? "brand"
: l.status === "missed_escalated"
? "danger"
: l.status === "pending"
? "warn"
: "neutral"
}
/>
<Text variant="bodySm" color={colors.muted} style={{ flex: 1 }}>
{new Date(l.scheduledAt).toLocaleString()}
</Text>
</View>
</Card>
))
)}
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,7 @@
import { Redirect } from "expo-router";
import { routes } from "@/lib/routes";
/** Legacy stack path — prescription management lives on the main tab. */
export default function RxListRedirect() {
return <Redirect href={routes.rxList as never} />;
}

View File

@ -0,0 +1,222 @@
import { router } from "expo-router";
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 } from "@/components/ui/Card";
import { Chip } from "@/components/ui/Chip";
import { Field } from "@/components/ui/Field";
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 { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
import { useCartStore } from "@/lib/stores/cartStore";
import { useFamilyStore } from "@/lib/stores/familyStore";
import { useLocationStore } from "@/lib/stores/locationStore";
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
import { colors, gutter, radii } from "@/lib/theme";
function matchStatus(name: string): "ready" | "review" | "unavailable" {
const hit = products.find((p) =>
p.name.toLowerCase().includes(name.trim().toLowerCase())
);
if (!hit) return "review";
if (!hit.inStock) return "unavailable";
return "ready";
}
export default function RxReviewScreen() {
const draftImages = usePrescriptionStore((s) => s.draftImages);
const setDraftImages = usePrescriptionStore((s) => s.setDraftImages);
const addPrescription = usePrescriptionStore((s) => s.addPrescription);
const addProduct = useCartStore((s) => s.addProduct);
const setFulfillment = useLocationStore((s) => s.setFulfillment);
const user = useAuthStore((s) => s.user);
const members = useFamilyStore((s) => s.members);
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
const activeMember = members.find((m) => m.id === activeMemberId);
const [patientName, setPatientName] = useState(
activeMember?.name || user?.name || ""
);
const [memberId, setMemberId] = useState<string | null>(activeMemberId);
const [medicineName, setMedicineName] = useState("Metformin 500mg");
const [daysSupply, setDaysSupply] = useState("30");
const [list, setList] = useState("Metformin 500mg, Vitamin B12");
const [notes, setNotes] = useState("");
const medicines = useMemo(() => {
return list
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((name) => ({ name, status: matchStatus(name) }));
}, [list]);
const save = (toCart: boolean) => {
if (!draftImages.length) {
Alert.alert("Image required", "Scan or upload at least one prescription photo first.");
return;
}
if (!patientName.trim() || !medicineName.trim()) {
Alert.alert("Missing", "Patient and primary medicine are required.");
return;
}
const meds =
medicines.length > 0
? medicines
: [{ name: medicineName, status: matchStatus(medicineName) }];
const rx = addPrescription({
patientName: patientName.trim(),
memberId,
medicineName: medicineName.trim(),
medicines: meds,
imageUris: draftImages,
daysSupply: Number(daysSupply) || 30,
lastFilledAt: null,
notes: notes.trim(),
});
if (toCart) {
meds
.filter((m) => m.status === "ready")
.forEach((m) => {
const p = products.find((x) =>
x.name.toLowerCase().includes(m.name.toLowerCase().split(" ")[0])
);
if (p) addProduct(p);
});
setFulfillment("pickup");
}
setDraftImages([]);
Alert.alert("Saved", "Prescription submitted for pharmacist review", [
{ text: "Open details", onPress: () => router.replace(routes.rxDetail(rx.id)) },
{ text: "Reminders", onPress: () => router.replace(routes.rxCalendar(rx.id)) },
{ text: "List", onPress: () => router.replace(routes.rxList) },
]);
};
return (
<Screen>
<AppHeader title="Review prescription" subtitle="Confirm patient and medicines" />
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}>
{draftImages.length ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
{draftImages.map((uri) => (
<Image
key={uri}
source={{ uri }}
style={{ width: 96, height: 96, borderRadius: radii.md, backgroundColor: colors.surfaceAlt }}
/>
))}
</ScrollView>
) : (
<Card tone="brand" elevated={false}>
<Text variant="h3">No images attached</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
Go back to scan or upload a prescription photo.
</Text>
<Button
title="Scan / upload"
size="md"
style={{ marginTop: 12 }}
onPress={() => router.replace(routes.rxScan)}
/>
</Card>
)}
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}>
Patient
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
<Chip
label={user?.name || "Me"}
selected={!memberId}
onPress={() => {
setMemberId(null);
setPatientName(user?.name || "");
}}
/>
{members
.filter((m) => m.status === "accepted")
.map((m) => (
<Chip
key={m.id}
label={m.name}
selected={memberId === m.id}
onPress={() => {
setMemberId(m.id);
setPatientName(m.name);
}}
/>
))}
</View>
<View style={{ gap: 12 }}>
<Field label="Patient name" value={patientName} onChangeText={setPatientName} />
<Field
label="Primary medicine"
value={medicineName}
onChangeText={setMedicineName}
hint="Used as the prescription title"
/>
<Field
label="All medicines (comma-separated)"
value={list}
onChangeText={setList}
placeholder="Metformin 500mg, Vitamin B12"
/>
<Field
label="Days supply"
value={daysSupply}
onChangeText={setDaysSupply}
keyboardType="number-pad"
/>
<Field
label="Notes for pharmacist"
value={notes}
onChangeText={setNotes}
placeholder="Allergies, instructions, preferred brand…"
/>
</View>
</Card>
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 10 }}>
Availability preview
</Text>
{(medicines.length ? medicines : [{ name: medicineName || "—", status: "review" as const }]).map(
(m) => (
<View
key={m.name}
style={{
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: colors.line,
}}
>
<Text variant="bodyMedium">{m.name}</Text>
<Badge
label={m.status}
tone={
m.status === "ready" ? "brand" : m.status === "review" ? "warn" : "danger"
}
/>
</View>
)
)}
</Card>
<Button title="Submit for review" onPress={() => save(false)} />
<Button
title="Submit & add ready items to cart"
variant="soft"
onPress={() => save(true)}
/>
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,196 @@
import { CameraView, useCameraPermissions } from "expo-camera";
import * as ImagePicker from "expo-image-picker";
import { router } from "expo-router";
import { useRef, useState } from "react";
import { Alert, Image, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
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";
export default function RxScanScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
const draftImages = usePrescriptionStore((s) => s.draftImages);
const setDraftImages = usePrescriptionStore((s) => s.setDraftImages);
const [ready, setReady] = useState(false);
const capture = async () => {
const photo = await cameraRef.current?.takePictureAsync({ quality: 0.7 });
if (photo?.uri) setDraftImages([...draftImages, photo.uri]);
};
const pick = async () => {
const res = await ImagePicker.launchImageLibraryAsync({
allowsMultipleSelection: true,
quality: 0.7,
});
if (!res.canceled) {
setDraftImages([...draftImages, ...res.assets.map((a) => a.uri)]);
}
};
const removeAt = (uri: string) => {
setDraftImages(draftImages.filter((u) => u !== uri));
};
if (!permission?.granted) {
return (
<Screen>
<View
style={{
flex: 1,
paddingTop: insets.top + 24,
paddingHorizontal: gutter,
justifyContent: "center",
gap: 14,
}}
>
<Text variant="h1">Scan prescription</Text>
<Text variant="body" color={colors.muted}>
Allow camera access to photograph your prescription, or upload existing photos.
</Text>
<Card elevated={false}>
<Text variant="bodySm" color={colors.muted}>
Tips: place the Rx on a flat surface, include the full page, and avoid glare.
</Text>
</Card>
<Button title="Grant camera permission" onPress={requestPermission} />
<Button title="Upload from photos" variant="outline" onPress={pick} />
{draftImages.length ? (
<Button
title={`Continue with ${draftImages.length} photo${draftImages.length === 1 ? "" : "s"}`}
variant="soft"
onPress={() => router.push(routes.rxReview)}
/>
) : null}
<Button title="Cancel" variant="ghost" onPress={() => router.back()} />
</View>
</Screen>
);
}
return (
<View style={{ flex: 1, backgroundColor: "#000" }}>
<CameraView
ref={cameraRef}
style={{ flex: 1 }}
facing="back"
onCameraReady={() => setReady(true)}
>
<View
style={{
position: "absolute",
top: insets.top + 8,
left: 16,
right: 16,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Pressable onPress={() => router.back()} hitSlop={8}>
<Ionicons name="close" size={28} color="#fff" />
</Pressable>
<Text variant="label" color="#fff">
{t("common.scanRx")}
</Text>
<Text variant="caption" color="rgba(255,255,255,0.75)">
{draftImages.length} saved
</Text>
</View>
<View
style={{
position: "absolute",
top: "20%",
left: "8%",
right: "8%",
bottom: "30%",
borderWidth: 2,
borderColor: colors.brand,
borderRadius: 16,
}}
/>
<Text
variant="caption"
color="rgba(255,255,255,0.85)"
center
style={{ position: "absolute", top: "16%", alignSelf: "center", width: "80%" }}
>
Align the full prescription inside the frame
</Text>
<View
style={{
position: "absolute",
bottom: 36,
left: 0,
right: 0,
alignItems: "center",
gap: 12,
}}
>
<ScrollView horizontal contentContainerStyle={{ gap: 8, paddingHorizontal: 16 }}>
{draftImages.map((uri) => (
<Pressable key={uri} onLongPress={() => removeAt(uri)}>
<Image
source={{ uri }}
style={{ width: 56, height: 56, borderRadius: 8 }}
/>
</Pressable>
))}
</ScrollView>
{draftImages.length ? (
<Text variant="micro" color="rgba(255,255,255,0.7)">
Long-press a thumbnail to remove
</Text>
) : null}
<View style={{ flexDirection: "row", gap: 24, alignItems: "center" }}>
<Pressable onPress={pick} accessibilityLabel="Upload photos">
<Ionicons name="images" size={28} color="#fff" />
</Pressable>
<Pressable
disabled={!ready}
onPress={capture}
accessibilityLabel="Capture"
style={{
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.brand,
borderWidth: 4,
borderColor: "#fff",
opacity: ready ? 1 : 0.5,
}}
/>
<Pressable
onPress={() => {
if (!draftImages.length) {
Alert.alert("Add a photo", "Capture or upload at least one image first.");
return;
}
router.push(routes.rxReview);
}}
accessibilityLabel="Continue to review"
>
<Ionicons
name="checkmark-circle"
size={34}
color={draftImages.length ? colors.brand : "#666"}
/>
</Pressable>
</View>
</View>
</CameraView>
</View>
);
}

View File

@ -0,0 +1,444 @@
import { Ionicons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import {
Image,
Pressable,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Badge, Price, Rating, StockLabel } from "@/components/ui/Badge";
import { Button, IconButton } from "@/components/ui/Button";
import { Card, InfoRow, Section } from "@/components/ui/Card";
import { Chip } from "@/components/ui/Chip";
import { QtyStepper } from "@/components/ui/QtyStepper";
import { StickyBar } from "@/components/ui/StickyBar";
import { Text } from "@/components/ui/Text";
import { branches } from "@/lib/data/branches";
import { getDrugFacts, getReviewBreakdown } from "@/lib/data/clinical";
import { products } from "@/lib/data/products";
import { useCartStore } from "@/lib/stores/cartStore";
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
import { colors, gutter, radii, space } from "@/lib/theme";
import { formatETB } from "@/lib/utils";
import { routes } from "@/lib/routes";
export default function ProductDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const product = products.find((p) => p.id === id) ?? products[0];
const facts = useMemo(() => getDrugFacts(product), [product]);
const reviews = useMemo(() => getReviewBreakdown(product), [product]);
const [qty, setQty] = useState(1);
const [dose, setDose] = useState(facts.dosages[0] ?? "");
const [tab, setTab] = useState<"details" | "pharmacy">("details");
const [more, setMore] = useState(false);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const addProduct = useCartStore((s) => s.addProduct);
const toggle = useFavoritesStore((s) => s.toggle);
const liked = useFavoritesStore((s) => s.has(product.id));
const addToCart = () => {
addProduct(product, qty);
};
return (
<View style={{ flex: 1, backgroundColor: colors.page }}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 120 }}
>
<View
style={{
backgroundColor: colors.surfaceAlt,
paddingTop: insets.top + 8,
paddingBottom: 28,
}}
>
<View
style={{
paddingHorizontal: gutter,
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 8,
}}
>
<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
source={{ uri: product.image }}
style={{ width: "100%", height: 240 }}
resizeMode="contain"
/>
</View>
<View
style={{
marginTop: -24,
marginHorizontal: gutter,
backgroundColor: colors.surface,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.line,
padding: space.xl,
gap: 14,
}}
>
{product.requiresPrescription ? (
<Badge label="RX needed" tone="info" icon="document-text-outline" />
) : (
<Badge label={facts.useCase} tone="neutral" />
)}
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text variant="h1">{product.name}</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{product.brand} · {product.unit}
</Text>
</View>
<Rating value={product.rating} count={product.reviews} showStars={false} />
</View>
<Price
value={product.price}
compareAt={product.compareAt}
size="lg"
format={formatETB}
/>
{facts.dosages.length > 0 ? (
<View>
<Text variant="label" color={colors.muted} style={{ marginBottom: 8 }}>
Strength
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{facts.dosages.map((d) => (
<Chip
key={d}
label={d}
selected={dose === d}
onPress={() => setDose(d)}
/>
))}
</View>
</View>
) : null}
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<QtyStepper value={qty} onChange={setQty} />
<StockLabel inStock={product.inStock} count={facts.stockCount} />
</View>
<View style={{ gap: 8 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Ionicons name="bicycle-outline" size={16} color={colors.info} />
<Text variant="bodySm" color={colors.muted}>
Same-day delivery in Addis Ababa
</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Ionicons name="shield-checkmark-outline" size={16} color={colors.brandText} />
<Text variant="bodySm" color={colors.muted}>
Pharmacist-verified · 30-day return policy
</Text>
</View>
</View>
<Button
title={t("common.addProduct")}
icon="bag-add-outline"
onPress={() => {
addToCart();
router.push(routes.cart);
}}
disabled={!product.inStock}
/>
</View>
<View style={{ paddingHorizontal: gutter, marginTop: 18 }}>
<View
style={{
flexDirection: "row",
backgroundColor: colors.surfaceAlt,
borderRadius: radii.pill,
padding: 4,
}}
>
{(["details", "pharmacy"] as const).map((key) => (
<Pressable
key={key}
onPress={() => setTab(key)}
style={{
flex: 1,
alignItems: "center",
paddingVertical: 10,
borderRadius: radii.pill,
backgroundColor: tab === key ? colors.surface : "transparent",
}}
>
<Text variant="h3" color={tab === key ? colors.ink : colors.subtle}>
{key === "details" ? "Details" : "Pharmacy"}
</Text>
</Pressable>
))}
</View>
</View>
{tab === "details" ? (
<View style={{ paddingHorizontal: gutter, paddingBottom: 20 }}>
<Section title="Description">
<Text variant="body" color={colors.muted}>
{more ? product.description : `${product.description.slice(0, 140)}…`}
</Text>
<Pressable onPress={() => setMore(!more)} style={{ marginTop: 6 }}>
<Text variant="label" color={colors.brandText}>
{more ? "Show less" : "Read more"}
</Text>
</Pressable>
</Section>
<Section title="Drug facts">
<Card elevated={false}>
<InfoRow label="Generic name" value={facts.genericName} />
<InfoRow label="Brand names" value={facts.brandNames} />
<InfoRow label="Drug class" value={facts.drugClass} last />
</Card>
</Section>
<Section title="Side effects">
<Text variant="caption" color={colors.subtle} style={{ marginBottom: 8 }}>
Common (mild)
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{facts.sideEffects.mild.map((s) => (
<Badge key={s} label={s} tone="neutral" icon="remove-outline" />
))}
</View>
{facts.sideEffects.severe.length > 0 ? (
<>
<Text
variant="caption"
color={colors.subtle}
style={{ marginTop: 14, marginBottom: 8 }}
>
Severe (rare)
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{facts.sideEffects.severe.map((s) => (
<Badge key={s} label={s} tone="danger" icon="alert-outline" />
))}
</View>
</>
) : null}
</Section>
<Section title="Storage">
<View style={{ gap: 10 }}>
{facts.storage.map((note) => (
<Card key={note.title} elevated={false}>
<View style={{ flexDirection: "row", gap: 12, alignItems: "flex-start" }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name={note.icon} size={18} color={colors.brandText} />
</View>
<View style={{ flex: 1 }}>
<Text variant="h3">{note.title}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 2 }}>
{note.body}
</Text>
</View>
</View>
</Card>
))}
</View>
</Section>
<Section title="Directions of use">
<View style={{ gap: 0, paddingLeft: 8 }}>
{facts.directions.map((step, index) => (
<View key={step.title} style={{ flexDirection: "row", gap: 14 }}>
<View style={{ alignItems: "center", width: 22 }}>
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.brand,
alignItems: "center",
justifyContent: "center",
}}
>
<Text variant="micro" color={colors.ink}>
{index + 1}
</Text>
</View>
{index < facts.directions.length - 1 ? (
<View
style={{
width: 2,
flex: 1,
minHeight: 28,
backgroundColor: colors.brandBorder,
}}
/>
) : null}
</View>
<View style={{ flex: 1, paddingBottom: 16 }}>
<Text variant="h3">{step.title}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 2 }}>
{step.body}
</Text>
</View>
</View>
))}
</View>
</Section>
<Section title="Reviews">
<Card elevated={false}>
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 8 }}>
<Text variant="display">{product.rating.toFixed(1)}</Text>
<Text variant="bodySm" color={colors.subtle} style={{ marginBottom: 6 }}>
{product.reviews} reviews
</Text>
</View>
<View style={{ marginTop: 14, gap: 8 }}>
{reviews.map((row) => {
const max = reviews[0]?.count || 1;
return (
<View
key={row.stars}
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Text variant="caption" color={colors.muted} style={{ width: 14 }}>
{row.stars}
</Text>
<View
style={{
flex: 1,
height: 6,
borderRadius: 3,
backgroundColor: colors.surfaceAlt,
overflow: "hidden",
}}
>
<View
style={{
width: `${Math.round((row.count / max) * 100)}%`,
height: "100%",
backgroundColor: colors.brand,
}}
/>
</View>
</View>
);
})}
</View>
</Card>
</Section>
<Card tone="brand" elevated={false} style={{ marginTop: 8 }}>
<Text variant="h3">Pharmacist tip</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
{facts.advice}
</Text>
</Card>
</View>
) : (
<View style={{ paddingHorizontal: gutter, paddingBottom: 20 }}>
<Section title="Available at">
{branches.slice(0, 4).map((branch, index) => (
<Card
key={branch.id}
onPress={() => router.push(routes.branches)}
style={{ marginTop: index === 0 ? 0 : 10 }}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
}}
>
<View style={{ flex: 1 }}>
<Text variant="h3">{branch.name}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
{branch.area}
</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{branch.hours}
{branch.pickupAvailable ? " · Pickup available" : ""}
</Text>
</View>
{index === 0 ? (
<StockLabel inStock={product.inStock} count={facts.stockCount} />
) : (
<Badge
label={product.inStock ? "In stock" : "Call"}
tone={product.inStock ? "brand" : "warn"}
/>
)}
</View>
</Card>
))}
<Button
title="View all branches"
variant="outline"
onPress={() => router.push(routes.branches)}
style={{ marginTop: 14 }}
/>
</Section>
</View>
)}
</ScrollView>
<StickyBar floating>
<View style={{ flex: 1 }}>
<Text variant="caption" color="rgba(255,255,255,0.7)" numberOfLines={1}>
{product.name}
</Text>
<Text variant="h3" color={colors.surface}>
{formatETB(product.price * qty)}
</Text>
</View>
<Button
title="Add"
size="md"
fullWidth={false}
icon="bag-add-outline"
onPress={addToCart}
disabled={!product.inStock}
style={{ minWidth: 110 }}
/>
</StickyBar>
</View>
);
}

View File

@ -0,0 +1,304 @@
import { Ionicons } from "@expo/vector-icons";
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 { 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 { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
import { useLangStore } from "@/lib/stores/langStore";
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
import { colors, gutter } from "@/lib/theme";
import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils";
export default function ProfileScreen() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const setAvatar = useAuthStore((s) => s.setAvatar);
const updateProfile = useAuthStore((s) => s.updateProfile);
const signOut = useAuthStore((s) => s.signOut);
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
const mode = useProfileModeStore((s) => s.mode);
const membership = useProfileModeStore((s) => s.activeMembership());
const language = useLangStore((s) => s.language);
const setLanguage = useLangStore((s) => s.setLanguage);
const [editing, setEditing] = useState(false);
const [name, setName] = useState(user?.name ?? "");
const [phone, setPhone] = useState(user?.phone ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const pickAvatar = async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert(
"Photo access needed",
"Allow photo library access to set your profile picture."
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [1, 1],
quality: 0.85,
});
if (!result.canceled && result.assets[0]?.uri) {
setAvatar(result.assets[0].uri);
}
};
const takePhoto = async () => {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
Alert.alert("Camera access needed", "Allow camera access to take a profile photo.");
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
aspect: [1, 1],
quality: 0.85,
});
if (!result.canceled && result.assets[0]?.uri) {
setAvatar(result.assets[0].uri);
}
};
const changePhoto = () => {
Alert.alert("Profile photo", "Choose how you'd like to update your photo.", [
{ text: "Choose from library", onPress: pickAvatar },
{ text: "Take photo", onPress: takePhoto },
...(user?.avatarUri
? [{ text: "Remove photo", style: "destructive" as const, onPress: () => setAvatar(null) }]
: []),
{ text: "Cancel", style: "cancel" },
]);
};
const links: {
label: string;
href: string;
icon: keyof typeof Ionicons.glyphMap;
subtitle?: string;
}[] = [
{ label: t("common.switchProfile"), href: routes.profileSwitch, icon: "swap-horizontal", subtitle: mode },
{ label: t("common.joinOrg"), href: routes.profileJoin, icon: "business-outline" },
{ label: t("common.orders"), href: routes.orders, icon: "receipt-outline", subtitle: "Track delivery" },
{ label: t("common.notifications"), href: routes.notifications, icon: "notifications-outline", subtitle: "Alerts inbox" },
{ label: t("common.prescriptions"), href: routes.rxList, icon: "medkit-outline", subtitle: "Rx + family" },
{ label: t("common.loyalty"), href: routes.loyalty, icon: "star-outline", subtitle: `${user?.points ?? 0} pts` },
{ label: t("common.subscriptions"), href: routes.subscriptions, icon: "repeat-outline" },
{ label: t("common.emergencyContacts"), href: routes.emergencyContacts, icon: "alert-circle-outline" },
{ label: t("common.branches"), href: routes.branches, icon: "location-outline" },
{ label: t("common.addresses"), href: routes.addresses, icon: "home-outline" },
{ label: t("common.language"), href: routes.language, icon: "language-outline", subtitle: language.toUpperCase() },
{ label: t("common.about"), href: routes.about, icon: "information-circle-outline" },
{ label: t("common.contact"), href: routes.contact, icon: "mail-outline" },
];
return (
<Screen>
<AppHeader title={t("common.profile")} subtitle="Account and preferences" />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingBottom: 40,
paddingHorizontal: gutter,
}}
>
<Card style={{ marginTop: 4 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
<Pressable onPress={changePhoto}>
<View>
<Avatar name={user?.name} uri={user?.avatarUri} size={72} />
<View
style={{
position: "absolute",
right: -2,
bottom: -2,
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: colors.brand,
borderWidth: 2,
borderColor: colors.surface,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="camera" size={13} color={colors.ink} />
</View>
</View>
</Pressable>
<View style={{ flex: 1 }}>
<Text variant="h2">{user?.name || "Guest"}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 2 }}>
{user?.phone || "—"}
</Text>
{user?.email ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{user.email}
</Text>
) : null}
<View style={{ flexDirection: "row", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
<Badge
label={
mode === "corporate"
? membership?.orgName || t("common.corporate")
: t("common.personal")
}
tone="brand"
/>
<Badge label={`${user?.tier ?? "bronze"} · ${user?.points ?? 0} pts`} tone="neutral" />
</View>
</View>
</View>
<View style={{ flexDirection: "row", gap: 12, marginTop: 14 }}>
<Pressable onPress={changePhoto}>
<Text variant="label" color={colors.brandText}>
{user?.avatarUri ? "Change photo" : "Add photo"}
</Text>
</Pressable>
{!user?.isGuest ? (
<Pressable
onPress={() => {
setName(user?.name ?? "");
setPhone(user?.phone ?? "");
setEmail(user?.email ?? "");
setEditing((v) => !v);
}}
>
<Text variant="label" color={colors.brandText}>
{editing ? "Close editor" : "Edit profile"}
</Text>
</Pressable>
) : null}
</View>
{membership ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
{membership.planName} · {membership.remainingAllowance} ETB allowance left
</Text>
) : null}
</Card>
{editing && !user?.isGuest ? (
<Card elevated={false} style={{ marginTop: 12 }}>
<Text variant="h3" style={{ marginBottom: 12 }}>
Edit profile
</Text>
<View style={{ gap: 12 }}>
<Field label="Full name" value={name} onChangeText={setName} />
<Field
label="Phone"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
/>
<Field
label="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<Button
title="Save changes"
onPress={() => {
if (!name.trim()) {
Alert.alert("Name required");
return;
}
updateProfile({
name: name.trim(),
phone: phone.trim(),
email: email.trim() || undefined,
});
setEditing(false);
Alert.alert("Saved", "Your profile was updated.");
}}
/>
</View>
</Card>
) : null}
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
{[
{ label: t("common.call"), icon: "call-outline" as const, href: `tel:${CALL_PHONE}` },
{
label: t("common.whatsapp"),
icon: "logo-whatsapp" as const,
href: `https://wa.me/${WHATSAPP_NUMBER}`,
},
{ label: t("common.chat"), icon: "chatbubble-ellipses-outline" as const, href: CHATWOOT_BASE },
].map((item) => (
<Pressable
key={item.label}
onPress={() => Linking.openURL(item.href)}
style={{
flex: 1,
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.line,
paddingVertical: 12,
alignItems: "center",
gap: 4,
}}
>
<Ionicons name={item.icon} size={18} color={colors.brandText} />
<Text variant="micro">{item.label}</Text>
</Pressable>
))}
</View>
<Card padded={false} style={{ marginTop: 18, overflow: "hidden" }}>
<MenuRow
icon="language-outline"
title={t("common.language")}
subtitle={language === "en" ? "English" : "አማርኛ"}
onPress={() => setLanguage(language === "en" ? "am" : "en")}
right={
<Text variant="label" color={colors.brandText}>
{language.toUpperCase()}
</Text>
}
/>
{links.map((l, index) => (
<MenuRow
key={l.href}
icon={l.icon}
title={l.label}
subtitle={l.subtitle}
onPress={() => router.push(l.href as never)}
last={index === links.length - 1}
/>
))}
</Card>
<Card padded={false} style={{ marginTop: 12, overflow: "hidden" }}>
<MenuRow
icon="log-out-outline"
title="Sign out"
tone="danger"
last
onPress={() => {
signOut();
setHasOnboarded(false);
router.replace(routes.welcome);
}}
/>
</Card>
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,60 @@
import { router } from "expo-router";
import { useState } from "react";
import { Alert, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Chip } from "@/components/ui/Chip";
import { Field } from "@/components/ui/Field";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
import { colors, gutter } from "@/lib/theme";
const DEMO_CODES = ["GISHEN-BANK", "GISHEN-NGO"] as const;
export default function JoinOrgScreen() {
const { t } = useTranslation();
const joinOrg = useProfileModeStore((s) => s.joinOrg);
const [code, setCode] = useState("");
return (
<Screen>
<AppHeader
title={t("common.joinOrg")}
subtitle="Unlock corporate allowance and benefits"
/>
<View style={{ flex: 1, paddingHorizontal: gutter, gap: 14 }}>
<Card elevated={false}>
<Text variant="bodySm" color={colors.muted}>
Enter the join code from your HR or organisation admin. Demo codes work offline.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
{DEMO_CODES.map((c) => (
<Chip key={c} label={c} selected={code === c} onPress={() => setCode(c)} size="sm" />
))}
</View>
</Card>
<Field
label="Join code"
value={code}
onChangeText={setCode}
autoCapitalize="characters"
icon="key-outline"
placeholder="GISHEN-BANK"
/>
<Button
title="Join organisation"
onPress={() => {
const res = joinOrg(code);
Alert.alert(res.ok ? "Joined" : "Failed", res.message);
if (res.ok) router.back();
}}
/>
</View>
</Screen>
);
}

View File

@ -0,0 +1,134 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useTranslation } from "react-i18next";
import { Pressable, ScrollView, View } from "react-native";
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 { routes } from "@/lib/routes";
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
import { colors, gutter } from "@/lib/theme";
import { formatETB } from "@/lib/utils";
export default function ProfileSwitchScreen() {
const { t } = useTranslation();
const mode = useProfileModeStore((s) => s.mode);
const setMode = useProfileModeStore((s) => s.setMode);
const memberships = useProfileModeStore((s) => s.memberships);
const setActiveOrg = useProfileModeStore((s) => s.setActiveOrg);
const activeOrgId = useProfileModeStore((s) => s.activeOrgId);
return (
<Screen>
<AppHeader
title={t("common.switchProfile")}
subtitle="Personal shopping or organisation benefits"
/>
<ScrollView
contentContainerStyle={{
paddingHorizontal: gutter,
paddingBottom: 32,
gap: 12,
}}
>
<Pressable
onPress={() => {
setMode("personal");
setActiveOrg(null);
}}
style={cardStyle(mode === "personal")}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View
style={{
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="person" size={20} color={colors.brandText} />
</View>
<View style={{ flex: 1 }}>
<Text variant="h3">{t("common.personal")}</Text>
<Text variant="caption" color={colors.muted} style={{ marginTop: 2 }}>
Retail shopping, loyalty points, family
</Text>
</View>
{mode === "personal" ? <Badge label="Active" tone="brand" /> : null}
</View>
</Pressable>
{memberships.map((m) => {
const active = mode === "corporate" && activeOrgId === m.orgId;
return (
<Pressable
key={m.orgId}
onPress={() => setActiveOrg(m.orgId)}
style={cardStyle(active)}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View
style={{
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="business" size={20} color={colors.brandText} />
</View>
<View style={{ flex: 1 }}>
<Text variant="h3">{m.orgName}</Text>
<Text variant="caption" color={colors.muted} style={{ marginTop: 2 }}>
{m.planName}
</Text>
<Text variant="label" color={colors.brandText} style={{ marginTop: 6 }}>
{formatETB(m.remainingAllowance)} allowance left
</Text>
</View>
{active ? <Badge label="Active" tone="brand" /> : null}
</View>
</Pressable>
);
})}
{!memberships.length ? (
<Card elevated={false}>
<Text variant="body" color={colors.muted}>
No organisations yet. Join with a demo code from your HR team.
</Text>
<Button
title={t("common.joinOrg")}
onPress={() => router.push(routes.profileJoin)}
style={{ marginTop: 12 }}
/>
</Card>
) : (
<Button
title={t("common.joinOrg")}
variant="outline"
onPress={() => router.push(routes.profileJoin)}
/>
)}
</ScrollView>
</Screen>
);
}
function cardStyle(selected: boolean) {
return {
backgroundColor: colors.surface,
borderRadius: 20,
padding: 16,
borderWidth: selected ? 2 : 1,
borderColor: selected ? colors.brand : colors.line,
};
}

View File

@ -0,0 +1,111 @@
import { router, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { FlatList, Pressable, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Chip, ChipRow } from "@/components/ui/Chip";
import { EmptyState } from "@/components/ui/EmptyState";
import { AppHeader } from "@/components/ui/Header";
import { ProductRow } from "@/components/ui/ProductCard";
import { Screen } from "@/components/ui/Screen";
import { SearchField } from "@/components/ui/SearchField";
import { Text } from "@/components/ui/Text";
import { products, searchProducts } from "@/lib/data/products";
import { categories, routes } from "@/lib/routes";
import { colors, gutter } from "@/lib/theme";
const recent = ["Panadol", "Metformin", "Vitamin C", "Cough syrup", "Glucose meter"];
export default function SearchScreen() {
const { t } = useTranslation();
const params = useLocalSearchParams<{ q?: string }>();
const [q, setQ] = useState(params.q ?? "");
const results = useMemo(() => (q.trim() ? searchProducts(q) : []), [q]);
return (
<Screen>
<AppHeader title="Search" />
<View style={{ paddingHorizontal: gutter }}>
<SearchField
value={q}
onChangeText={setQ}
onClear={() => setQ("")}
autoFocus
placeholder={t("common.search")}
/>
</View>
{!q.trim() ? (
<View style={{ paddingHorizontal: gutter, paddingTop: 20 }}>
<Text variant="h3">Recent searches</Text>
<View style={{ marginTop: 12, gap: 10 }}>
{recent.map((term) => (
<Pressable
key={term}
onPress={() => setQ(term)}
style={{ flexDirection: "row", alignItems: "center", gap: 10 }}
>
<Text variant="body" color={colors.muted}>
{term}
</Text>
</Pressable>
))}
</View>
<Text variant="h3" style={{ marginTop: 28, marginBottom: 12 }}>
Browse by category
</Text>
<ChipRow gutters={0}>
{categories.map((c) => (
<Chip
key={c.id}
label={t(c.labelKey)}
icon={c.icon}
onPress={() =>
router.push({ pathname: routes.shop, params: { category: c.id } })
}
/>
))}
</ChipRow>
<Text variant="h3" style={{ marginTop: 28, marginBottom: 12 }}>
Popular right now
</Text>
<View style={{ gap: 10 }}>
{products
.filter((p) => p.badge === "bestseller")
.slice(0, 4)
.map((p) => (
<ProductRow key={p.id} product={p} />
))}
</View>
</View>
) : (
<FlatList
data={results}
keyExtractor={(i) => i.id}
contentContainerStyle={{
paddingHorizontal: gutter,
paddingTop: 16,
paddingBottom: 40,
gap: 10,
}}
ListEmptyComponent={
<EmptyState
icon="search-outline"
title="Nothing found"
body="Try a brand name, symptom or category."
compact
/>
}
ListHeaderComponent={
<Text variant="caption" color={colors.subtle} style={{ marginBottom: 8 }}>
{results.length} result{results.length === 1 ? "" : "s"}
</Text>
}
renderItem={({ item }) => <ProductRow product={item} />}
/>
)}
</Screen>
);
}

View File

@ -0,0 +1,91 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { FlatList, useWindowDimensions, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Chip, ChipRow } from "@/components/ui/Chip";
import { EmptyState } from "@/components/ui/EmptyState";
import { AppHeader } from "@/components/ui/Header";
import { ProductCard, GRID_CARD_GAP } from "@/components/ui/ProductCard";
import { Screen } from "@/components/ui/Screen";
import { SearchField } from "@/components/ui/SearchField";
import { products } from "@/lib/data/products";
import { categories } from "@/lib/routes";
import { gutter } from "@/lib/theme";
export default function ShopScreen() {
const { t } = useTranslation();
const params = useLocalSearchParams<{ q?: string; category?: string }>();
const [q, setQ] = useState(params.q ?? "");
const [category, setCategory] = useState(params.category ?? "");
const { width } = useWindowDimensions();
const cardWidth = (width - gutter * 2 - GRID_CARD_GAP) / 2;
const data = useMemo(() => {
return products.filter((p) => {
const catOk = !category || p.category === category;
const qOk =
!q ||
p.name.toLowerCase().includes(q.toLowerCase()) ||
p.brand.toLowerCase().includes(q.toLowerCase()) ||
p.description.toLowerCase().includes(q.toLowerCase());
return catOk && qOk;
});
}, [q, category]);
return (
<Screen>
<AppHeader
large
back
title="Explore products"
subtitle="Trusted medicines, vitamins and care essentials"
/>
<View style={{ paddingHorizontal: gutter, marginBottom: 12 }}>
<SearchField
value={q}
onChangeText={setQ}
onClear={() => setQ("")}
placeholder={t("common.search")}
/>
</View>
<ChipRow gutters={gutter}>
<Chip
label="All"
selected={!category}
onPress={() => setCategory("")}
/>
{categories.map((c) => (
<Chip
key={c.id}
label={t(c.labelKey)}
selected={category === c.id}
onPress={() => setCategory(c.id)}
icon={c.icon}
/>
))}
</ChipRow>
<FlatList
data={data}
numColumns={2}
keyExtractor={(i) => i.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: gutter,
paddingTop: 16,
paddingBottom: 40,
gap: GRID_CARD_GAP,
}}
columnWrapperStyle={{ gap: GRID_CARD_GAP }}
ListEmptyComponent={
<EmptyState
icon="search-outline"
title="No matches"
body="Try another medicine name, brand or category."
compact
/>
}
renderItem={({ item }) => <ProductCard product={item} width={cardWidth} />}
/>
</Screen>
);
}

View File

@ -0,0 +1,255 @@
import { useMemo, useState } from "react";
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 { Chip, Segmented } from "@/components/ui/Chip";
import { Field } from "@/components/ui/Field";
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 { useFamilyStore } from "@/lib/stores/familyStore";
import { useLocationStore, type Fulfillment, type PaymentMethod } from "@/lib/stores/locationStore";
import { useSubscriptionStore } from "@/lib/stores/subscriptionStore";
import { colors, gutter } from "@/lib/theme";
const FREQUENCIES = [
{ days: 7, label: "Weekly" },
{ days: 14, label: "Every 2 weeks" },
{ days: 30, label: "Monthly" },
{ days: 60, label: "Every 2 months" },
{ days: 90, label: "Every 3 months" },
];
export default function SubscriptionsScreen() {
const { t } = useTranslation();
const items = useSubscriptionStore((s) => s.items);
const add = useSubscriptionStore((s) => s.add);
const pause = useSubscriptionStore((s) => s.pause);
const resume = useSubscriptionStore((s) => s.resume);
const cancel = useSubscriptionStore((s) => s.cancel);
const paymentPref = useLocationStore((s) => s.paymentPreference);
const fulfillmentPref = useLocationStore((s) => s.fulfillment);
const members = useFamilyStore((s) => s.members);
const refillCandidates = useMemo(
() =>
products.filter(
(p) =>
p.requiresPrescription ||
p.category === "diabetic" ||
p.category === "vitamins" ||
p.badge === "bestseller"
),
[]
);
const [showCreate, setShowCreate] = useState(false);
const [productId, setProductId] = useState(refillCandidates[0]?.id ?? products[0].id);
const [qty, setQty] = useState("1");
const [frequencyDays, setFrequencyDays] = useState(30);
const [fulfillment, setFulfillment] = useState<Fulfillment>(fulfillmentPref);
const [payment, setPayment] = useState<PaymentMethod>(
paymentPref === "pay_at_branch" && fulfillmentPref === "delivery"
? "chapa"
: paymentPref
);
const [memberId, setMemberId] = useState<string | null>(null);
const [query, setQuery] = useState("");
const filtered = refillCandidates.filter(
(p) =>
!query ||
p.name.toLowerCase().includes(query.toLowerCase()) ||
p.brand.toLowerCase().includes(query.toLowerCase())
);
const selected = products.find((p) => p.id === productId) ?? products[0];
return (
<Screen>
<AppHeader
title={t("common.subscriptions")}
subtitle="Auto-refill chronic medicines on a schedule"
/>
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 12 }}>
{!showCreate ? (
<Button
title="Create refill subscription"
icon="add-circle-outline"
onPress={() => setShowCreate(true)}
/>
) : (
<Card elevated={false}>
<Text variant="h3" style={{ marginBottom: 12 }}>
New subscription
</Text>
<Field
label="Search product"
value={query}
onChangeText={setQuery}
placeholder="Metformin, Vitamin C…"
icon="search-outline"
/>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginTop: 12 }}
contentContainerStyle={{ gap: 8 }}
>
{filtered.slice(0, 12).map((p) => (
<Chip
key={p.id}
label={p.name}
selected={productId === p.id}
onPress={() => setProductId(p.id)}
/>
))}
</ScrollView>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
Selected: {selected.name} · {selected.unit}
</Text>
<View style={{ marginTop: 14, gap: 12 }}>
<Field
label="Quantity"
value={qty}
onChangeText={setQty}
keyboardType="number-pad"
/>
<Text variant="label" color={colors.muted}>
Frequency
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{FREQUENCIES.map((f) => (
<Chip
key={f.days}
label={f.label}
selected={frequencyDays === f.days}
onPress={() => setFrequencyDays(f.days)}
/>
))}
</View>
<Text variant="label" color={colors.muted}>
Fulfillment
</Text>
<Segmented
value={fulfillment}
onChange={(v) => {
setFulfillment(v);
if (v === "delivery" && payment === "pay_at_branch") setPayment("chapa");
}}
options={[
{ value: "delivery", label: "Delivery" },
{ value: "pickup", label: "Pickup" },
]}
/>
<Text variant="label" color={colors.muted} style={{ marginTop: 4 }}>
Payment
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{(
[
"chapa",
"telebirr",
"mpesa",
...(fulfillment === "pickup" ? (["pay_at_branch"] as const) : []),
] as PaymentMethod[]
).map((m) => (
<Chip
key={m}
label={m}
selected={payment === m}
onPress={() => setPayment(m)}
/>
))}
</View>
<Text variant="label" color={colors.muted}>
For family member (optional)
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
<Chip label="Me" selected={!memberId} onPress={() => setMemberId(null)} />
{members
.filter((m) => m.status === "accepted")
.map((m) => (
<Chip
key={m.id}
label={m.name}
selected={memberId === m.id}
onPress={() => setMemberId(m.id)}
/>
))}
</View>
<Button
title="Start subscription"
onPress={() => {
add({
productId: selected.id,
productName: selected.name,
quantity: Math.max(1, Number(qty) || 1),
frequencyDays,
fulfillment,
paymentMethod: payment,
memberId,
});
setShowCreate(false);
Alert.alert(
"Subscription active",
`${selected.name} every ${frequencyDays} days via ${fulfillment}.`
);
}}
/>
<Button title="Cancel" variant="ghost" onPress={() => setShowCreate(false)} />
</View>
</Card>
)}
{items.map((s) => (
<Card key={s.id}>
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text variant="h3">{s.productName}</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
Every {s.frequencyDays} days · Qty {s.quantity} · {s.fulfillment}
</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
Next: {new Date(s.nextFulfillmentAt).toLocaleDateString()} · {s.paymentMethod}
{s.memberId
? ` · ${members.find((m) => m.id === s.memberId)?.name ?? "Family"}`
: ""}
</Text>
</View>
<Badge
label={s.status}
tone={
s.status === "active" ? "brand" : s.status === "paused" ? "warn" : "danger"
}
/>
</View>
<View style={{ flexDirection: "row", gap: 10, marginTop: 14 }}>
{s.status === "active" ? (
<Button title="Pause" size="sm" variant="outline" fullWidth={false} onPress={() => pause(s.id)} />
) : s.status === "paused" ? (
<Button title="Resume" size="sm" fullWidth={false} onPress={() => resume(s.id)} />
) : null}
{s.status !== "cancelled" ? (
<Button
title="Cancel"
size="sm"
variant="ghost"
fullWidth={false}
onPress={() => cancel(s.id)}
/>
) : null}
</View>
</Card>
))}
</ScrollView>
</Screen>
);
}

View File

@ -0,0 +1,122 @@
import { useLocalSearchParams, router } from "expo-router";
import { useEffect } from "react";
import { View } from "react-native";
import MapView, { Marker } from "@/components/maps/AppMap";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card, InfoRow } from "@/components/ui/Card";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { getBranchById } from "@/lib/data/branches";
import { routes } from "@/lib/routes";
import { useOrdersStore } from "@/lib/stores/ordersStore";
import { colors, gutter } from "@/lib/theme";
import { formatETB } from "@/lib/utils";
export default function TrackingScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const order = useOrdersStore((s) => s.orders.find((o) => o.id === id));
const updateStatus = useOrdersStore((s) => s.updateStatus);
const branch = order?.branchId ? getBranchById(order.branchId) : undefined;
useEffect(() => {
if (!order) return;
if (order.fulfillment === "pickup") return;
if (order.riderAssigned || order.status === "cancelled" || order.status === "delivered") {
return;
}
if (order.status === "paid" || order.status === "confirmed") {
const t = setTimeout(() => updateStatus(order.id, "dispatched"), 2500);
return () => clearTimeout(t);
}
}, [order?.id, order?.status, order?.fulfillment]);
if (!order) {
return (
<Screen>
<AppHeader title="Tracking" />
<View style={{ padding: gutter }}>
<Text variant="body" color={colors.muted}>
Order not found.
</Text>
</View>
</Screen>
);
}
const lat = branch?.lat ?? 9.03;
const lng = branch?.lng ?? 38.74;
const isPickup = order.fulfillment === "pickup";
return (
<Screen>
<AppHeader
title={isPickup ? "Pickup status" : "Live tracking"}
subtitle={order.id}
right={<Badge label={order.status} tone="brand" />}
/>
<MapView
style={{ flex: 1, marginHorizontal: gutter, borderRadius: 20, overflow: "hidden" }}
initialRegion={{
latitude: lat,
longitude: lng,
latitudeDelta: 0.06,
longitudeDelta: 0.06,
}}
>
<Marker
coordinate={{ latitude: lat, longitude: lng }}
title={isPickup ? branch?.name ?? "Branch" : "Delivery area"}
pinColor={colors.brand}
/>
{!isPickup && order.riderAssigned ? (
<Marker
coordinate={{ latitude: lat - 0.01, longitude: lng + 0.012 }}
title="Rider"
description={order.status}
/>
) : null}
</MapView>
<View style={{ padding: gutter, gap: 12 }}>
<Card elevated={false}>
<Text variant="h3">
{isPickup
? order.status === "delivered" || order.status === "completed"
? "Collected"
: "Ready for pickup soon"
: order.riderAssigned
? "Rider on the way"
: "Preparing your order"}
</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
{isPickup
? `${branch?.name ?? "Branch"} · ${branch?.hours ?? ""} · ${branch?.area ?? order.areaLabel}`
: `${order.areaLabel ?? "Addis Ababa"} · ETA 25–40 min (mock)`}
</Text>
<InfoRow label="Total" value={formatETB(order.total)} />
<InfoRow label="Payment" value={order.paymentMethod} last />
</Card>
<Button
title="View full order"
variant="outline"
onPress={() => router.push(routes.orderDetail(order.id))}
/>
{!isPickup && order.status !== "delivered" && order.status !== "cancelled" ? (
<Button
title="Mark delivered (demo)"
onPress={() => updateStatus(order.id, "delivered")}
/>
) : null}
{isPickup && order.status !== "delivered" && order.status !== "cancelled" ? (
<Button
title="Mark collected (demo)"
onPress={() => updateStatus(order.id, "delivered")}
/>
) : null}
</View>
</Screen>
);
}

View File

@ -0,0 +1,5 @@
import { FloatingTabBar } from "@/components/ui/FloatingTabBar";
export default function TabsLayout() {
return <FloatingTabBar />;
}

182
app/(root)/(tabs)/cart.tsx Normal file
View File

@ -0,0 +1,182 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import {
Alert,
Image,
Pressable,
ScrollView,
TextInput,
View,
} from "react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/Button";
import { Card, InfoRow } from "@/components/ui/Card";
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";
import { useCartStore } from "@/lib/stores/cartStore";
import { colors, fonts, gutter, radii } from "@/lib/theme";
import { formatETB } from "@/lib/utils";
export default function CartScreen() {
const { t } = useTranslation();
const items = useCartStore((s) => s.items);
const setQuantity = useCartStore((s) => s.setQuantity);
const removeItem = useCartStore((s) => s.removeItem);
const subtotal = useCartStore((s) => s.subtotal());
const voucher = useCartStore((s) => s.voucherCode);
const setVoucher = useCartStore((s) => s.setVoucher);
const delivery = items.length ? DELIVERY_FEE : 0;
const discount = voucherDiscount(subtotal, voucher);
const total = subtotal + delivery - discount;
return (
<Screen>
<TabTopBar subtitle={t("common.cart")} />
<ScrollView
contentContainerStyle={{
paddingBottom: TAB_BAR_INSET + 100,
paddingHorizontal: gutter,
}}
>
<Text variant="h1">{t("common.cart")}</Text>
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
{items.length} item{items.length === 1 ? "" : "s"} in your bag
</Text>
{items.length === 0 ? (
<EmptyState
icon="bag-handle-outline"
title={t("common.emptyCart")}
body="Browse the shop and add medicines or wellness products."
actionTitle="Explore products"
onAction={() => router.push(routes.shop)}
/>
) : (
<View style={{ marginTop: 16, gap: 12 }}>
{items.map((item) => (
<Card key={item.productId} elevated>
<View style={{ flexDirection: "row", gap: 12 }}>
<Image
source={{ uri: item.image }}
style={{
width: 72,
height: 72,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
}}
resizeMode="contain"
/>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
<Text variant="h3" style={{ flex: 1 }} numberOfLines={2}>
{item.name}
</Text>
<Pressable onPress={() => removeItem(item.productId)} hitSlop={8}>
<Ionicons name="trash-outline" size={18} color={colors.subtle} />
</Pressable>
</View>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{item.unit}
{item.requiresPrescription ? " · RX" : ""}
</Text>
<View
style={{
marginTop: 10,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text variant="price">{formatETB(item.price)}</Text>
<QtyStepper
size="sm"
value={item.quantity}
onChange={(n) => setQuantity(item.productId, n)}
min={0}
/>
</View>
</View>
</View>
</Card>
))}
<Pressable onPress={() => router.push(routes.shop)}>
<Text variant="label" color={colors.brandText}>
+ Add product
</Text>
</Pressable>
<Card elevated={false} style={{ marginTop: 8 }}>
<Text variant="h3" style={{ marginBottom: 8 }}>
Voucher
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderColor: colors.line,
borderRadius: radii.pill,
paddingLeft: 14,
paddingRight: 6,
height: 48,
}}
>
<TextInput
value={voucher ?? ""}
onChangeText={(v) => setVoucher(v || null)}
placeholder="Try GISHEN10"
placeholderTextColor={colors.subtle}
style={{
flex: 1,
fontFamily: fonts.regular,
fontSize: 14,
color: colors.ink,
}}
/>
<Button
title="Apply"
size="sm"
fullWidth={false}
onPress={() => {
if ((voucher ?? "").toUpperCase() === DEMO_VOUCHER) {
setVoucher(DEMO_VOUCHER);
Alert.alert("Applied", "10% off your basket.");
} else {
Alert.alert("Invalid code", `Use ${DEMO_VOUCHER} for a demo discount.`);
}
}}
/>
</View>
</Card>
<Card elevated={false}>
<InfoRow label="Payment" value={formatETB(subtotal)} />
<InfoRow label="Delivery" value={formatETB(delivery)} />
{discount > 0 ? (
<InfoRow label="Discount" value={`- ${formatETB(discount)}`} valueColor={colors.brandText} />
) : null}
<InfoRow label={t("common.total")} value={formatETB(total)} emphasis last />
</Card>
</View>
)}
</ScrollView>
{items.length > 0 ? (
<StickyBar>
<Button
title={t("common.payNow")}
iconRight="arrow-forward"
onPress={() => router.push(routes.checkout)}
/>
</StickyBar>
) : null}
</Screen>
);
}

View File

@ -0,0 +1,55 @@
import { router } from "expo-router";
import { FlatList, useWindowDimensions, View } from "react-native";
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 { colors, gutter } from "@/lib/theme";
export default function FavoritesScreen() {
const { t } = useTranslation();
const ids = useFavoritesStore((s) => s.ids);
const items = products.filter((p) => ids.includes(p.id));
const { width } = useWindowDimensions();
const cardWidth = (width - gutter * 2 - GRID_CARD_GAP) / 2;
return (
<Screen>
<TabTopBar subtitle={t("common.favorites")} />
<View style={{ paddingHorizontal: gutter, paddingBottom: 8 }}>
<Text variant="h1">{t("common.favorites")}</Text>
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
Saved products for quick reorder
</Text>
</View>
{items.length === 0 ? (
<EmptyState
icon="heart-outline"
title={t("common.emptyFavorites")}
body="Tap the heart on any product to save it here."
actionTitle="Browse shop"
onAction={() => router.push(routes.shop)}
/>
) : (
<FlatList
data={items}
numColumns={2}
keyExtractor={(i) => i.id}
columnWrapperStyle={{ gap: GRID_CARD_GAP }}
contentContainerStyle={{
paddingHorizontal: gutter,
paddingTop: 12,
paddingBottom: TAB_BAR_INSET + 24,
gap: GRID_CARD_GAP,
}}
renderItem={({ item }) => <ProductCard product={item} width={cardWidth} />}
/>
)}
</Screen>
);
}

207
app/(root)/(tabs)/index.tsx Normal file
View File

@ -0,0 +1,207 @@
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 { useTranslation } from "react-i18next";
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 { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
import { colors, gutter, space } from "@/lib/theme";
export default function HomeScreen() {
const { t } = useTranslation();
const [category, setCategory] = useState("medicines");
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
const filtered = useMemo(
() => products.filter((p) => p.category === category),
[category]
);
const popular = useMemo(
() =>
[...products]
.filter((p) => p.badge === "bestseller" || p.rating >= 4.6)
.slice(0, 6),
[]
);
const refillSoon = prescriptions.find((p) => {
if (!p.lastFilledAt || !p.daysSupply) return false;
const due = new Date(p.lastFilledAt);
due.setDate(due.getDate() + p.daysSupply);
const days = (due.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
return days <= 5;
});
const pendingRx = prescriptions.find(
(p) => p.status === "under_review" || p.status === "submitted"
);
return (
<Screen>
<TabTopBar />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingBottom: TAB_BAR_INSET + 24,
paddingHorizontal: gutter,
}}
>
<Text variant="display" style={{ marginTop: 22 }}>
{t("common.trustedTitle")}
</Text>
<Text variant="display" color={colors.brandText} style={{ marginTop: -2 }}>
{t("common.trustedSubtitle")}
</Text>
<View style={{ marginTop: 18 }}>
<SearchField
readOnlyPress={() => router.push(routes.search)}
trailing={
<SearchAction
icon="scan-outline"
label={t("common.scan")}
onPress={() => router.push(routes.rxScan)}
/>
}
/>
</View>
{(refillSoon || pendingRx) && (
<Pressable
onPress={() => router.push(routes.rxList)}
style={{ marginTop: 14 }}
>
<Card tone="brand" elevated={false}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View
style={{
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="medical-outline" size={20} color={colors.brandText} />
</View>
<View style={{ flex: 1 }}>
<Text variant="h3">
{refillSoon
? `${t("common.refillDue")}: ${refillSoon.medicineName}`
: `Rx under review: ${pendingRx?.medicineName}`}
</Text>
<Text variant="caption" color={colors.muted} style={{ marginTop: 2 }}>
Tap to manage prescriptions
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.brandText} />
</View>
</Card>
</Pressable>
)}
<View style={{ flexDirection: "row", gap: 12, marginTop: 18 }}>
<ActionTile
icon="cloud-upload-outline"
title="Upload Rx"
subtitle="Photo or PDF"
tone="brand"
onPress={() => router.push(routes.rxScan)}
/>
<ActionTile
icon="camera-outline"
title="Scan Rx"
subtitle="Camera scan"
tone="surface"
onPress={() => router.push(routes.rxScan)}
/>
</View>
<Section title={t("common.categories")} action={t("common.seeAll")} onAction={() => router.push(routes.shop)}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{categories.map((c) => (
<View key={c.id} style={{ marginRight: 10 }}>
<CategoryTile
icon={c.icon}
label={t(c.labelKey)}
selected={category === c.id}
onPress={() => setCategory(c.id)}
/>
</View>
))}
</ScrollView>
</Section>
<Section title="Today's specials" action={t("common.seeAll")} onAction={() => router.push(routes.shop)}>
<FlatList
horizontal
data={filtered.filter((p) => p.badge === "sale" || p.compareAt).slice(0, 8)}
keyExtractor={(item) => item.id}
showsHorizontalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={{ width: space.md }} />}
renderItem={({ item }) => <ProductTile product={item} />}
ListEmptyComponent={
<FlatList
horizontal
data={filtered.slice(0, 6)}
keyExtractor={(item) => item.id}
showsHorizontalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={{ width: space.md }} />}
renderItem={({ item }) => <ProductTile product={item} />}
/>
}
/>
</Section>
<Section title="Most popular" action={t("common.seeAll")} onAction={() => router.push(routes.shop)}>
<View style={{ gap: 10 }}>
{popular.map((p) => (
<ProductRow key={p.id} product={p} />
))}
</View>
</Section>
<Section title={t("common.prescriptions")} action="View all" onAction={() => router.push(routes.rxList)}>
<View style={{ gap: 10 }}>
{prescriptions.slice(0, 2).map((rx) => (
<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.daysSupply} day supply
</Text>
</View>
<Badge
label={rx.status.replace("_", " ")}
tone={
rx.status === "filled" || rx.status === "approved"
? "brand"
: rx.status === "under_review"
? "warn"
: "neutral"
}
/>
</View>
</Card>
))}
</View>
</Section>
</ScrollView>
</Screen>
);
}

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

13
app/(root)/_layout.tsx Normal file
View File

@ -0,0 +1,13 @@
import { Stack } from "expo-router";
import { colors } from "@/lib/theme";
export default function RootGroupLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.page },
}}
/>
);
}

View File

@ -0,0 +1,5 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@ -0,0 +1,58 @@
import { router } from "expo-router";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Button } from "@/components/ui/Button";
import { AppHeader } from "@/components/ui/Header";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { routes } from "@/lib/routes";
import { useLangStore, type Language } from "@/lib/stores/langStore";
import { colors, gutter } from "@/lib/theme";
const OPTIONS: { code: Language; label: string; hint: string }[] = [
{ code: "en", label: "English", hint: "Continue in English" },
{ code: "am", label: "አማርኛ", hint: "በአማርኛ ይቀጥሉ" },
];
export default function LanguageScreen() {
const { t } = useTranslation();
const language = useLangStore((s) => s.language);
const setLanguage = useLangStore((s) => s.setLanguage);
return (
<Screen>
<AppHeader title={t("common.language")} subtitle="Choose your preferred language" />
<View style={{ flex: 1, paddingHorizontal: gutter, paddingTop: 8 }}>
<View style={{ gap: 12 }}>
{OPTIONS.map((opt) => {
const selected = language === opt.code;
return (
<Pressable
key={opt.code}
onPress={() => setLanguage(opt.code)}
style={{
padding: 18,
borderRadius: 20,
backgroundColor: colors.surface,
borderWidth: selected ? 2 : 1,
borderColor: selected ? colors.brand : colors.line,
}}
>
<Text variant="h3">{opt.label}</Text>
<Text variant="caption" color={colors.muted} style={{ marginTop: 4 }}>
{opt.hint}
</Text>
</Pressable>
);
})}
</View>
<View style={{ flex: 1 }} />
<Button
title={t("common.continue")}
onPress={() => router.push(routes.otp)}
style={{ marginBottom: 16 }}
/>
</View>
</Screen>
);
}

135
app/(root)/auth/otp.tsx Normal file
View File

@ -0,0 +1,135 @@
import { useState } from "react";
import { Alert, View } from "react-native";
import { router } from "expo-router";
import * as LocalAuthentication from "expo-local-authentication";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/ui/Button";
import { Field, SwitchRow } from "@/components/ui/Field";
import { Card } from "@/components/ui/Card";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { DEMO_OTP } from "@/lib/commerce";
import { colors, gutter } from "@/lib/theme";
import { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
export default function OtpScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const signIn = useAuthStore((s) => s.signIn);
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
const setBiometric = useAuthStore((s) => s.setBiometric);
const [phone, setPhone] = useState("+251911000000");
const [name, setName] = useState("Kirukib Abebe");
const [email, setEmail] = useState("kirukib@example.com");
const [code, setCode] = useState("");
const [sent, setSent] = useState(false);
const [bio, setBio] = useState(false);
const sendCode = () => {
if (!name.trim() || phone.replace(/\D/g, "").length < 9) {
Alert.alert("Missing info", "Enter your name and a valid phone number.");
return;
}
setSent(true);
Alert.alert(
"Code sent (demo)",
`Use OTP ${DEMO_OTP} to verify. No real SMS is sent in this build.`
);
};
const verify = async () => {
if (!sent) return;
if (code.trim() !== DEMO_OTP) {
Alert.alert("Invalid OTP", `Enter the demo code ${DEMO_OTP}.`);
return;
}
if (bio) {
const hw = await LocalAuthentication.hasHardwareAsync();
if (hw) {
const res = await LocalAuthentication.authenticateAsync({
promptMessage: "Enable biometric unlock",
});
if (!res.success) {
Alert.alert("Biometric", "Skipped — you can enable later in profile.");
}
}
}
signIn({
name: name.trim(),
phone: phone.trim(),
email: email.trim() || undefined,
isGuest: false,
});
setBiometric(bio);
setHasOnboarded(true);
router.replace(routes.home);
};
return (
<Screen>
<View
style={{
flex: 1,
paddingTop: insets.top + 24,
paddingBottom: insets.bottom + 20,
paddingHorizontal: gutter,
}}
>
<Text variant="h1">{t("common.signIn")}</Text>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
{t("common.otpHint")} Demo OTP is always {DEMO_OTP}.
</Text>
<View style={{ marginTop: 20, gap: 12 }}>
<Field label="Full name" value={name} onChangeText={setName} icon="person-outline" />
<Field
label="Phone"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
icon="call-outline"
/>
<Field
label="Email (optional)"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
icon="mail-outline"
/>
{sent ? (
<Field
label="OTP code"
value={code}
onChangeText={setCode}
keyboardType="number-pad"
placeholder={DEMO_OTP}
icon="key-outline"
hint={`Demo code: ${DEMO_OTP}`}
/>
) : null}
<Card padded={false}>
<SwitchRow
icon="finger-print-outline"
title={t("common.enableBiometric")}
subtitle="Unlock the app faster next time"
value={bio}
onValueChange={setBio}
last
/>
</Card>
</View>
<View style={{ flex: 1 }} />
{!sent ? (
<Button title={t("common.sendOtp")} onPress={sendCode} />
) : (
<View style={{ gap: 10 }}>
<Button title={t("common.verify")} onPress={verify} />
<Button title="Resend code" variant="ghost" onPress={sendCode} />
</View>
)}
</View>
</Screen>
);
}

View File

@ -0,0 +1,71 @@
import { Image, View } from "react-native";
import { router } from "expo-router";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/Button";
import { Screen } from "@/components/ui/Screen";
import { Text } from "@/components/ui/Text";
import { brand } from "@/lib/data/brand";
import { colors, gutter } from "@/lib/theme";
import { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function WelcomeScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
return (
<Screen>
<View
style={{
flex: 1,
paddingTop: insets.top + 24,
paddingBottom: insets.bottom + 24,
paddingHorizontal: gutter + 4,
}}
>
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Image
source={require("@/assets/logo/gishen-logo.png")}
style={{ width: 160, height: 160, resizeMode: "contain" }}
/>
<Text variant="display" center style={{ marginTop: 24 }}>
{t("common.appName")}
</Text>
<Text variant="body" color={colors.muted} center style={{ marginTop: 8, maxWidth: 300 }}>
{t("common.tagline")}
</Text>
<Text
variant="caption"
color={colors.subtle}
center
style={{ marginTop: 16, maxWidth: 320 }}
>
Est. {brand.founded} · {brand.branchCount} branches · Serving {brand.institutions}+
institutions across Addis Ababa.
</Text>
</View>
<View style={{ gap: 12 }}>
<Button title={t("common.getStarted")} onPress={() => router.push(routes.language)} />
<Button
title={t("common.guest")}
variant="outline"
onPress={() => {
useAuthStore.getState().signIn({
name: "Guest",
phone: "",
isGuest: true,
});
setHasOnboarded(true);
router.replace(routes.home);
}}
/>
<Text variant="caption" color={colors.subtle} center>
Guests can browse. Loyalty, corporate and some care features need a signed-in account.
</Text>
</View>
</View>
</Screen>
);
}

49
app/_layout.tsx Normal file
View File

@ -0,0 +1,49 @@
import "./global.css";
import "@/lib/i18n";
import { useEffect } from "react";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { PlusJakartaSans_400Regular } from "@expo-google-fonts/plus-jakarta-sans/400Regular";
import { PlusJakartaSans_500Medium } from "@expo-google-fonts/plus-jakarta-sans/500Medium";
import { PlusJakartaSans_600SemiBold } from "@expo-google-fonts/plus-jakarta-sans/600SemiBold";
import { PlusJakartaSans_700Bold } from "@expo-google-fonts/plus-jakarta-sans/700Bold";
import { PlusJakartaSans_800ExtraBold } from "@expo-google-fonts/plus-jakarta-sans/800ExtraBold";
import { colors } from "@/lib/theme";
SplashScreen.preventAutoHideAsync().catch(() => {});
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
Jakarta_400Regular: PlusJakartaSans_400Regular,
Jakarta_500Medium: PlusJakartaSans_500Medium,
Jakarta_600SemiBold: PlusJakartaSans_600SemiBold,
Jakarta_700Bold: PlusJakartaSans_700Bold,
Jakarta_800ExtraBold: PlusJakartaSans_800ExtraBold,
});
const ready = fontsLoaded || !!fontError;
useEffect(() => {
if (ready) SplashScreen.hideAsync().catch(() => {});
}, [ready]);
if (!ready) return null;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.page },
}}
/>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}

3
app/global.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
app/index.tsx Normal file
View File

@ -0,0 +1,10 @@
import { Redirect } from "expo-router";
import { useAuthStore } from "@/lib/stores/authStore";
export default function Index() {
const hasOnboarded = useAuthStore((s) => s.hasOnboarded);
if (!hasOnboarded) {
return <Redirect href="/(root)/auth/welcome" />;
}
return <Redirect href="/(root)/(tabs)" />;
}

BIN
assets/adaptive-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
assets/logo/gishen-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

16
babel.config.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
[
"babel-preset-expo",
{
jsxImportSource: "nativewind",
// Zustand ESM uses import.meta; required for Expo web (SDK 53).
unstable_transformImportMeta: true,
},
],
"nativewind/babel",
],
};
};

View File

@ -0,0 +1,2 @@
export { default } from "react-native-maps";
export { Marker } from "react-native-maps";

View File

@ -0,0 +1,62 @@
import type { ReactNode } from "react";
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { colors } from "@/lib/theme";
type Region = {
latitude: number;
longitude: number;
latitudeDelta: number;
longitudeDelta: number;
};
type MapProps = {
style?: StyleProp<ViewStyle>;
initialRegion?: Region;
children?: ReactNode;
};
export default function MapView({ style, children }: MapProps) {
return (
<View style={[styles.wrap, style]}>
<Text style={styles.title}>Map preview</Text>
<Text style={styles.sub}>Live maps are available on iOS and Android.</Text>
{children}
</View>
);
}
type MarkerProps = {
coordinate: { latitude: number; longitude: number };
title?: string;
description?: string;
pinColor?: string;
};
export function Marker({ title, description }: MarkerProps) {
return (
<View style={styles.marker}>
<Text style={styles.markerTitle}>{title ?? "Pin"}</Text>
{description ? <Text style={styles.sub}>{description}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
backgroundColor: "#E8EDF2",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 24,
},
title: { fontSize: 16, fontWeight: "800", color: colors.ink },
sub: { fontSize: 13, color: colors.muted, textAlign: "center" },
marker: {
marginTop: 12,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: "#fff",
borderRadius: 8,
},
markerTitle: { fontWeight: "700", color: colors.ink },
});

60
components/ui/Avatar.tsx Normal file
View File

@ -0,0 +1,60 @@
import { Image, View } from "react-native";
import { Text } from "@/components/ui/Text";
import { colors } from "@/lib/theme";
function initials(name?: string) {
if (!name) return "G";
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
export function Avatar({
name,
uri,
size = 44,
tone = "brand",
}: {
name?: string;
uri?: string | null;
size?: number;
tone?: "brand" | "soft";
}) {
if (uri) {
return (
<Image
source={{ uri }}
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: colors.surfaceAlt,
}}
/>
);
}
return (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: tone === "brand" ? colors.brand : colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
variant="h3"
color={tone === "brand" ? colors.ink : colors.brandText}
style={{ fontSize: size * 0.36 }}
>
{initials(name)}
</Text>
</View>
);
}

138
components/ui/Badge.tsx Normal file
View File

@ -0,0 +1,138 @@
import { Ionicons } from "@expo/vector-icons";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { Text } from "@/components/ui/Text";
import { colors, radii } from "@/lib/theme";
type Tone = "brand" | "danger" | "warn" | "info" | "neutral" | "dark";
const tones: Record<Tone, { bg: string; fg: string }> = {
brand: { bg: colors.brandSoft, fg: colors.brandText },
danger: { bg: colors.dangerSoft, fg: colors.danger },
warn: { bg: colors.warnSoft, fg: colors.warn },
info: { bg: colors.infoSoft, fg: colors.info },
neutral: { bg: colors.surfaceAlt, fg: colors.muted },
dark: { bg: colors.ink, fg: colors.surface },
};
export function Badge({
label,
tone = "brand",
icon,
solid,
style,
}: {
label: string;
tone?: Tone;
icon?: keyof typeof Ionicons.glyphMap;
/** Filled lime treatment used for discount flags on imagery. */
solid?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const skin = tones[tone];
const bg = solid ? colors.brand : skin.bg;
const fg = solid ? colors.ink : skin.fg;
return (
<View
style={[
{
flexDirection: "row",
alignItems: "center",
gap: 4,
alignSelf: "flex-start",
backgroundColor: bg,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: radii.xs,
},
style,
]}
>
{icon ? <Ionicons name={icon} size={11} color={fg} /> : null}
<Text variant="micro" color={fg}>
{label}
</Text>
</View>
);
}
export function StockLabel({
inStock,
count,
}: {
inStock: boolean;
count?: number;
}) {
return (
<Text variant="micro" color={inStock ? colors.inStock : colors.outOfStock}>
{inStock ? `In stock${count ? ` (${count})` : ""}` : "Out of stock"}
</Text>
);
}
export function Rating({
value,
count,
showStars = true,
size = 13,
}: {
value: number;
count?: number;
showStars?: boolean;
size?: number;
}) {
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
{showStars ? (
<View style={{ flexDirection: "row", gap: 1 }}>
{[1, 2, 3, 4, 5].map((i) => (
<Ionicons
key={i}
name={
value >= i ? "star" : value >= i - 0.5 ? "star-half" : "star-outline"
}
size={size}
color={value >= i - 0.5 ? colors.star : colors.disabled}
/>
))}
</View>
) : (
<Ionicons name="star" size={size} color={colors.star} />
)}
<Text variant="micro" color={colors.muted}>
{value.toFixed(1)}
{count !== undefined ? ` (${count})` : ""}
</Text>
</View>
);
}
/** Compact price with optional struck-through original. */
export function Price({
value,
compareAt,
size = "md",
format,
}: {
value: number;
compareAt?: number;
size?: "sm" | "md" | "lg";
format: (n: number) => string;
}) {
return (
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
<Text variant={size === "lg" ? "priceLg" : size === "sm" ? "label" : "price"}>
{format(value)}
</Text>
{compareAt && compareAt > value ? (
<Text
variant="caption"
color={colors.subtle}
style={{ textDecorationLine: "line-through" }}
>
{format(compareAt)}
</Text>
) : null}
</View>
);
}

208
components/ui/Button.tsx Normal file
View File

@ -0,0 +1,208 @@
import type { ReactNode } from "react";
import { Ionicons } from "@expo/vector-icons";
import {
ActivityIndicator,
Pressable,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Text } from "@/components/ui/Text";
import { colors, radii, shadows } from "@/lib/theme";
type Variant = "primary" | "brand" | "dark" | "outline" | "ghost" | "soft" | "danger";
type Size = "sm" | "md" | "lg";
type Props = Omit<PressableProps, "style"> & {
title: string;
variant?: Variant;
size?: Size;
icon?: keyof typeof Ionicons.glyphMap;
iconRight?: keyof typeof Ionicons.glyphMap;
loading?: boolean;
fullWidth?: boolean;
elevated?: boolean;
style?: StyleProp<ViewStyle>;
};
const skins: Record<Variant, { bg: string; fg: string; border: string }> = {
// Lime fill with ink text: on-brand and far more legible than white-on-lime.
primary: { bg: colors.brand, fg: colors.ink, border: "transparent" },
brand: { bg: colors.brand, fg: colors.ink, border: "transparent" },
dark: { bg: colors.ink, fg: colors.surface, border: "transparent" },
outline: { bg: "transparent", fg: colors.ink, border: colors.line },
ghost: { bg: "transparent", fg: colors.ink, border: "transparent" },
soft: { bg: colors.brandSoft, fg: colors.brandText, border: "transparent" },
danger: { bg: colors.dangerSoft, fg: colors.danger, border: "transparent" },
};
const sizing: Record<Size, { py: number; px: number; gap: number; icon: number }> = {
sm: { py: 9, px: 14, gap: 6, icon: 15 },
md: { py: 13, px: 18, gap: 8, icon: 17 },
lg: { py: 16, px: 22, gap: 9, icon: 19 },
};
export function Button({
title,
variant = "primary",
size = "lg",
icon,
iconRight,
loading,
disabled,
fullWidth = true,
elevated,
style,
...rest
}: Props) {
const skin = skins[variant];
const dims = sizing[size];
const inactive = disabled || loading;
return (
<Pressable
disabled={inactive}
style={({ pressed }) => [
{
backgroundColor: inactive ? colors.surfaceAlt : skin.bg,
borderColor: skin.border,
borderWidth: variant === "outline" ? 1.5 : 0,
borderRadius: radii.pill,
paddingVertical: dims.py,
paddingHorizontal: dims.px,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: dims.gap,
alignSelf: fullWidth ? "stretch" : "flex-start",
opacity: pressed ? 0.88 : 1,
transform: [{ scale: pressed ? 0.99 : 1 }],
},
elevated && !inactive && shadows.sm,
style,
]}
{...rest}
>
{loading ? (
<ActivityIndicator size="small" color={inactive ? colors.subtle : skin.fg} />
) : (
<>
{icon ? (
<Ionicons
name={icon}
size={dims.icon}
color={inactive ? colors.subtle : skin.fg}
/>
) : null}
<Text
variant={size === "sm" ? "label" : "h3"}
color={inactive ? colors.subtle : skin.fg}
>
{title}
</Text>
{iconRight ? (
<Ionicons
name={iconRight}
size={dims.icon}
color={inactive ? colors.subtle : skin.fg}
/>
) : null}
</>
)}
</Pressable>
);
}
/** Circular icon button used in headers and cards. */
export function IconButton({
icon,
onPress,
size = 40,
tone = "surface",
color,
accessibilityLabel,
}: {
icon: keyof typeof Ionicons.glyphMap;
onPress?: () => void;
size?: number;
tone?: "surface" | "brand" | "dark" | "plain";
color?: string;
accessibilityLabel?: string;
}) {
const bg =
tone === "brand"
? colors.brand
: tone === "dark"
? colors.ink
: tone === "plain"
? "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,
height: size,
borderRadius: size / 2,
backgroundColor: bg,
alignItems: "center",
justifyContent: "center",
opacity: pressed ? 0.7 : 1,
},
tone === "surface" && { borderWidth: 1, borderColor: colors.line },
]}
>
<Ionicons
name={icon}
size={size * 0.46}
color={fg}
style={isBackIcon ? { marginLeft: -1 } : undefined}
/>
</Pressable>
);
}
/** Small round "+" affordance on product cards. */
export function AddButton({
onPress,
disabled,
size = 34,
}: {
onPress?: () => void;
disabled?: boolean;
size?: number;
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel="Add to cart"
disabled={disabled}
onPress={onPress}
hitSlop={8}
style={({ pressed }) => ({
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: disabled ? colors.surfaceAlt : colors.brand,
alignItems: "center",
justifyContent: "center",
opacity: pressed ? 0.8 : 1,
})}
>
<Ionicons name="add" size={size * 0.55} color={disabled ? colors.subtle : colors.ink} />
</Pressable>
);
}
export function ButtonRow({ children }: { children: ReactNode }) {
return <View style={{ flexDirection: "row", gap: 10 }}>{children}</View>;
}

221
components/ui/Card.tsx Normal file
View File

@ -0,0 +1,221 @@
import type { ReactNode } from "react";
import {
Pressable,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/Text";
import { colors, radii, shadows, space } from "@/lib/theme";
export function Card({
children,
onPress,
padded = true,
tone = "surface",
elevated = true,
style,
}: {
children: ReactNode;
onPress?: () => void;
padded?: boolean;
tone?: "surface" | "wash" | "brand";
elevated?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const bg =
tone === "wash" ? colors.surfaceAlt : tone === "brand" ? colors.brandWash : colors.surface;
const base: StyleProp<ViewStyle> = [
{
backgroundColor: bg,
borderRadius: radii.card,
borderWidth: 1,
borderColor: tone === "brand" ? colors.brandBorder : colors.line,
padding: padded ? space.lg : 0,
overflow: "hidden",
},
elevated && tone === "surface" ? shadows.xs : null,
style,
];
if (!onPress) return <View style={base}>{children}</View>;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [base, pressed && { opacity: 0.92 }]}
>
{children}
</Pressable>
);
}
export function SectionHeader({
title,
action,
onAction,
style,
}: {
title: string;
action?: string;
onAction?: () => void;
style?: StyleProp<ViewStyle>;
}) {
return (
<View
style={[
{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: space.md,
},
style,
]}
>
<Text variant="h2">{title}</Text>
{action ? (
<Pressable onPress={onAction} hitSlop={8}>
<Text variant="label" color={colors.brandText}>
{action}
</Text>
</Pressable>
) : null}
</View>
);
}
export function Section({
title,
action,
onAction,
children,
style,
}: {
title?: string;
action?: string;
onAction?: () => void;
children: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
return (
<View style={[{ marginTop: space["2xl"] }, style]}>
{title ? <SectionHeader title={title} action={action} onAction={onAction} /> : null}
{children}
</View>
);
}
export function Divider({ style }: { style?: StyleProp<ViewStyle> }) {
return <View style={[{ height: 1, backgroundColor: colors.line }, style]} />;
}
/** Key/value row used for drug facts and order summaries. */
export function InfoRow({
label,
value,
valueColor,
emphasis,
last,
}: {
label: string;
value: string;
valueColor?: string;
emphasis?: boolean;
last?: boolean;
}) {
return (
<View
style={{
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 16,
paddingVertical: 12,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: colors.line,
}}
>
<Text variant="bodySm" color={colors.muted}>
{label}
</Text>
<Text
variant={emphasis ? "h3" : "label"}
color={valueColor ?? colors.ink}
style={{ flexShrink: 1, textAlign: "right" }}
>
{value}
</Text>
</View>
);
}
/** Tappable settings/menu row with icon tile, title and chevron. */
export function MenuRow({
icon,
title,
subtitle,
onPress,
right,
tone = "brand",
last,
}: {
icon: keyof typeof Ionicons.glyphMap;
title: string;
subtitle?: string;
onPress?: () => void;
right?: ReactNode;
tone?: "brand" | "danger" | "neutral";
last?: boolean;
}) {
const iconBg =
tone === "danger"
? colors.dangerSoft
: tone === "neutral"
? colors.surfaceAlt
: colors.brandSoft;
const iconFg =
tone === "danger" ? colors.danger : tone === "neutral" ? colors.muted : colors.brandText;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
flexDirection: "row",
alignItems: "center",
gap: 14,
paddingVertical: 14,
paddingHorizontal: space.lg,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: colors.line,
backgroundColor: pressed ? colors.surfaceAlt : "transparent",
})}
>
<View
style={{
width: 38,
height: 38,
borderRadius: 12,
backgroundColor: iconBg,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name={icon} size={19} color={iconFg} />
</View>
<View style={{ flex: 1 }}>
<Text variant="h3" color={tone === "danger" ? colors.danger : colors.ink}>
{title}
</Text>
{subtitle ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{subtitle}
</Text>
) : null}
</View>
{right ?? <Ionicons name="chevron-forward" size={18} color={colors.subtle} />}
</Pressable>
);
}

View File

@ -0,0 +1,109 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, View } from "react-native";
import { Text } from "@/components/ui/Text";
import { colors, radii } from "@/lib/theme";
export function CategoryTile({
icon,
label,
selected,
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap;
label: string;
selected?: boolean;
onPress?: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
width: 72,
alignItems: "center",
gap: 7,
opacity: pressed ? 0.85 : 1,
})}
>
<View
style={{
width: 56,
height: 56,
borderRadius: radii.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: selected ? colors.brand : colors.surface,
borderWidth: 1,
borderColor: selected ? colors.brand : colors.line,
}}
>
<Ionicons name={icon} size={23} color={selected ? colors.ink : colors.brandText} />
</View>
<Text
variant="micro"
color={selected ? colors.ink : colors.muted}
numberOfLines={1}
center
>
{label}
</Text>
</Pressable>
);
}
/** Larger square action used for the prescription entry points on Home. */
export function ActionTile({
icon,
title,
subtitle,
tone = "brand",
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap;
title: string;
subtitle: string;
tone?: "brand" | "surface";
onPress?: () => void;
}) {
const brand = tone === "brand";
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
flex: 1,
backgroundColor: brand ? colors.brand : colors.surface,
borderRadius: radii.card,
borderWidth: 1,
borderColor: brand ? colors.brand : colors.line,
padding: 14,
gap: 10,
opacity: pressed ? 0.92 : 1,
})}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
backgroundColor: brand ? "rgba(16,20,16,0.12)" : colors.brandSoft,
}}
>
<Ionicons name={icon} size={19} color={brand ? colors.ink : colors.brandText} />
</View>
<View>
<Text variant="h3" color={colors.ink}>
{title}
</Text>
<Text
variant="caption"
color={brand ? "rgba(16,20,16,0.62)" : colors.subtle}
style={{ marginTop: 2 }}
>
{subtitle}
</Text>
</View>
</Pressable>
);
}

138
components/ui/Chip.tsx Normal file
View File

@ -0,0 +1,138 @@
import type { ReactNode } from "react";
import { Ionicons } from "@expo/vector-icons";
import { Pressable, ScrollView, View } from "react-native";
import { Text } from "@/components/ui/Text";
import { colors, radii, space } from "@/lib/theme";
export function Chip({
label,
selected,
onPress,
onRemove,
icon,
tone = "default",
size = "md",
}: {
label: string;
selected?: boolean;
onPress?: () => void;
onRemove?: () => void;
icon?: keyof typeof Ionicons.glyphMap;
tone?: "default" | "outline";
size?: "sm" | "md";
}) {
const bg = selected ? colors.brand : tone === "outline" ? "transparent" : colors.surface;
const fg = selected ? colors.ink : colors.muted;
const border = selected ? colors.brand : colors.line;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingVertical: size === "sm" ? 6 : 9,
paddingHorizontal: size === "sm" ? 10 : 14,
borderRadius: radii.pill,
backgroundColor: bg,
borderWidth: 1,
borderColor: border,
opacity: pressed ? 0.8 : 1,
})}
>
{icon ? <Ionicons name={icon} size={13} color={fg} /> : null}
<Text variant={size === "sm" ? "caption" : "label"} color={fg}>
{label}
</Text>
{onRemove ? (
<Pressable onPress={onRemove} hitSlop={8}>
<Ionicons name="close" size={14} color={fg} />
</Pressable>
) : null}
</Pressable>
);
}
/** Horizontally scrolling chip strip that bleeds to the screen edge. */
export function ChipRow({
children,
gutters = 20,
}: {
children: ReactNode;
gutters?: number;
}) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
gap: space.sm,
paddingHorizontal: gutters,
}}
style={{ marginHorizontal: -gutters }}
>
{children}
</ScrollView>
);
}
/** Read-only meta tag, e.g. "Tablet" or "Qty: 100". */
export function MetaTag({
label,
icon,
}: {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
}) {
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
{icon ? <Ionicons name={icon} size={11} color={colors.subtle} /> : null}
<Text variant="micro" color={colors.subtle}>
{label}
</Text>
</View>
);
}
export function Segmented<T extends string>({
options,
value,
onChange,
}: {
options: { value: T; label: string }[];
value: T;
onChange: (value: T) => void;
}) {
return (
<View
style={{
flexDirection: "row",
backgroundColor: colors.surfaceAlt,
borderRadius: radii.pill,
padding: 4,
}}
>
{options.map((option) => {
const active = option.value === value;
return (
<Pressable
key={option.value}
onPress={() => onChange(option.value)}
style={{
flex: 1,
alignItems: "center",
paddingVertical: 10,
borderRadius: radii.pill,
backgroundColor: active ? colors.surface : "transparent",
}}
>
<Text variant="h3" color={active ? colors.ink : colors.subtle}>
{option.label}
</Text>
</Pressable>
);
})}
</View>
);
}

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

@ -0,0 +1,61 @@
import { Ionicons } from "@expo/vector-icons";
import { View } from "react-native";
import { Button } from "@/components/ui/Button";
import { Text } from "@/components/ui/Text";
import { colors } from "@/lib/theme";
export function EmptyState({
icon = "cube-outline",
title,
body,
actionTitle,
onAction,
compact,
}: {
icon?: keyof typeof Ionicons.glyphMap;
title: string;
body?: string;
actionTitle?: string;
onAction?: () => void;
compact?: boolean;
}) {
return (
<View
style={{
alignItems: "center",
paddingVertical: compact ? 32 : 56,
paddingHorizontal: 24,
}}
>
<View
style={{
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name={icon} size={32} color={colors.brandText} />
</View>
<Text variant="h2" center style={{ marginTop: 18 }}>
{title}
</Text>
{body ? (
<Text variant="bodySm" color={colors.subtle} center style={{ marginTop: 6, maxWidth: 280 }}>
{body}
</Text>
) : null}
{actionTitle && onAction ? (
<Button
title={actionTitle}
onPress={onAction}
size="md"
fullWidth={false}
style={{ marginTop: 20 }}
/>
) : null}
</View>
);
}

232
components/ui/Field.tsx Normal file
View File

@ -0,0 +1,232 @@
import { useState, type ReactNode } from "react";
import { Ionicons } from "@expo/vector-icons";
import {
Pressable,
Switch,
TextInput,
View,
type TextInputProps,
} from "react-native";
import { Text } from "@/components/ui/Text";
import { colors, fonts, radii } from "@/lib/theme";
type FieldProps = TextInputProps & {
label?: string;
icon?: keyof typeof Ionicons.glyphMap;
error?: string;
hint?: string;
secure?: boolean;
};
export function Field({
label,
icon,
error,
hint,
secure,
style,
...rest
}: FieldProps) {
const [focused, setFocused] = useState(false);
const [hidden, setHidden] = useState(!!secure);
const borderColor = error ? colors.danger : focused ? colors.brand : colors.line;
return (
<View>
{label ? (
<Text variant="label" color={colors.muted} style={{ marginBottom: 8 }}>
{label}
</Text>
) : null}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 10,
backgroundColor: colors.surface,
borderRadius: radii.md,
borderWidth: 1.5,
borderColor,
paddingHorizontal: 14,
height: 52,
}}
>
{icon ? (
<Ionicons name={icon} size={18} color={focused ? colors.brandText : colors.subtle} />
) : null}
<TextInput
placeholderTextColor={colors.subtle}
secureTextEntry={hidden}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={[
{
flex: 1,
fontFamily: fonts.regular,
fontSize: 14,
color: colors.ink,
paddingVertical: 0,
},
style,
]}
{...rest}
/>
{secure ? (
<Pressable onPress={() => setHidden(!hidden)} hitSlop={8}>
<Ionicons
name={hidden ? "eye-off-outline" : "eye-outline"}
size={18}
color={colors.subtle}
/>
</Pressable>
) : null}
</View>
{error ? (
<View style={{ flexDirection: "row", alignItems: "center", gap: 5, marginTop: 6 }}>
<Ionicons name="alert-circle" size={13} color={colors.danger} />
<Text variant="caption" color={colors.danger}>
{error}
</Text>
</View>
) : hint ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 6 }}>
{hint}
</Text>
) : null}
</View>
);
}
export function SwitchRow({
title,
subtitle,
value,
onValueChange,
icon,
last,
}: {
title: string;
subtitle?: string;
value: boolean;
onValueChange: (v: boolean) => void;
icon?: keyof typeof Ionicons.glyphMap;
last?: boolean;
}) {
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 14,
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: colors.line,
}}
>
{icon ? (
<View
style={{
width: 38,
height: 38,
borderRadius: 12,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name={icon} size={19} color={colors.brandText} />
</View>
) : null}
<View style={{ flex: 1 }}>
<Text variant="h3">{title}</Text>
{subtitle ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{subtitle}
</Text>
) : null}
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: colors.line, true: colors.brand }}
thumbColor={colors.surface}
ios_backgroundColor={colors.line}
/>
</View>
);
}
/** Radio-style selectable row used for payment methods and addresses. */
export function OptionRow({
title,
subtitle,
selected,
onPress,
icon,
trailing,
last,
}: {
title: string;
subtitle?: string;
selected: boolean;
onPress: () => void;
icon?: keyof typeof Ionicons.glyphMap;
trailing?: ReactNode;
last?: boolean;
}) {
return (
<Pressable
onPress={onPress}
style={{
flexDirection: "row",
alignItems: "center",
gap: 14,
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: last ? 0 : 1,
borderBottomColor: colors.line,
backgroundColor: selected ? colors.brandWash : "transparent",
}}
>
{icon ? (
<View
style={{
width: 38,
height: 38,
borderRadius: 12,
backgroundColor: selected ? colors.brandSoft : colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons
name={icon}
size={19}
color={selected ? colors.brandText : colors.muted}
/>
</View>
) : null}
<View style={{ flex: 1 }}>
<Text variant="h3">{title}</Text>
{subtitle ? (
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{subtitle}
</Text>
) : null}
</View>
{trailing}
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: selected ? 7 : 1.5,
borderColor: selected ? colors.brand : colors.line,
backgroundColor: colors.surface,
}}
/>
</Pressable>
);
}

View File

@ -0,0 +1,143 @@
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Text } from "@/components/ui/Text";
import { colors, fonts, radii, shadows } from "@/lib/theme";
import { useCartStore } from "@/lib/stores/cartStore";
function TabIcon({
name,
focused,
badge,
}: {
name: keyof typeof Ionicons.glyphMap;
focused: boolean;
badge?: number;
}) {
return (
<View style={{ width: 46, height: 40, alignItems: "center", justifyContent: "center" }}>
<View
style={{
width: 40,
height: 32,
borderRadius: radii.pill,
alignItems: "center",
justifyContent: "center",
backgroundColor: focused ? colors.brandSoft : "transparent",
}}
>
<Ionicons
name={name}
size={20}
color={focused ? colors.brandText : colors.subtle}
/>
</View>
{badge ? (
<View
style={{
position: "absolute",
top: -2,
right: 2,
minWidth: 17,
height: 17,
paddingHorizontal: 4,
borderRadius: 9,
backgroundColor: colors.danger,
alignItems: "center",
justifyContent: "center",
borderWidth: 2,
borderColor: colors.surface,
}}
>
<Text variant="micro" color={colors.surface} style={{ fontSize: 9, lineHeight: 12 }}>
{badge > 9 ? "9+" : badge}
</Text>
</View>
) : null}
</View>
);
}
export function FloatingTabBar() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const cartCount = useCartStore((s) => s.totalItems());
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.brandText,
tabBarInactiveTintColor: colors.subtle,
tabBarLabelStyle: {
fontFamily: fonts.semibold,
fontSize: 11,
marginTop: 2,
},
tabBarItemStyle: { paddingTop: 8 },
tabBarStyle: [
{
position: "absolute",
left: 16,
right: 16,
bottom: Math.max(insets.bottom, 12),
height: 68,
borderRadius: radii.xl,
backgroundColor: colors.surface,
borderTopWidth: 0,
borderWidth: 1,
borderColor: colors.line,
paddingBottom: 8,
},
shadows.md,
],
}}
>
<Tabs.Screen
name="index"
options={{
title: t("common.home"),
tabBarIcon: ({ focused }) => (
<TabIcon name={focused ? "home" : "home-outline"} focused={focused} />
),
}}
/>
<Tabs.Screen
name="favorites"
options={{
title: t("common.favorites"),
tabBarIcon: ({ focused }) => (
<TabIcon name={focused ? "heart" : "heart-outline"} focused={focused} />
),
}}
/>
<Tabs.Screen
name="cart"
options={{
title: t("common.cart"),
tabBarIcon: ({ focused }) => (
<TabIcon
name={focused ? "bag-handle" : "bag-handle-outline"}
focused={focused}
badge={cartCount}
/>
),
}}
/>
<Tabs.Screen
name="prescriptions"
options={{
title: t("common.prescriptions"),
tabBarIcon: ({ focused }) => (
<TabIcon
name={focused ? "medkit" : "medkit-outline"}
focused={focused}
/>
),
}}
/>
</Tabs>
);
}

116
components/ui/Header.tsx Normal file
View File

@ -0,0 +1,116 @@
import type { ReactNode } from "react";
import { router } from "expo-router";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { IconButton } from "@/components/ui/Button";
import { Text } from "@/components/ui/Text";
import { colors, gutter } from "@/lib/theme";
type Props = {
title?: string;
subtitle?: string;
/** Large left-aligned title, matching the "Explore Products" reference. */
large?: boolean;
back?: boolean;
onBack?: () => void;
right?: ReactNode;
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,
large,
back = true,
onBack,
right,
background = "transparent",
}: Props) {
const insets = useSafeAreaInsets();
return (
<View
style={{
paddingTop: insets.top + 6,
paddingHorizontal: gutter,
paddingBottom: large ? 4 : 10,
backgroundColor: background,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 10,
minHeight: 44,
}}
>
{back ? (
<IconButton
icon="arrow-back"
accessibilityLabel="Go back"
onPress={onBack ?? (() => router.back())}
tone="surface"
/>
) : null}
{!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 ? (
<View
style={{
flexShrink: 0,
alignItems: "flex-end",
justifyContent: "center",
maxWidth: "36%",
}}
>
{right}
</View>
) : null}
</View>
{large && title ? (
<View style={{ marginTop: back || right ? 14 : 8, marginBottom: 4 }}>
<Text variant="h1">{title}</Text>
{subtitle ? (
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
{subtitle}
</Text>
) : null}
</View>
) : null}
</View>
);
}

View File

@ -0,0 +1,262 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Image, Pressable, View } from "react-native";
import { AddButton } from "@/components/ui/Button";
import { Badge, Price, StockLabel } from "@/components/ui/Badge";
import { MetaTag } from "@/components/ui/Chip";
import { Text } from "@/components/ui/Text";
import { getDrugFacts } from "@/lib/data/clinical";
import type { Product } from "@/lib/data/products";
import { routes } from "@/lib/routes";
import { useCartStore } from "@/lib/stores/cartStore";
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
import { colors, radii, shadows, space } from "@/lib/theme";
import { formatETB } from "@/lib/utils";
export const GRID_CARD_GAP = 12;
function discount(product: Product) {
if (!product.compareAt || product.compareAt <= product.price) return null;
return `${Math.round(((product.compareAt - product.price) / product.compareAt) * 100)}% OFF`;
}
function useCardActions(product: Product) {
const addProduct = useCartStore((s) => s.addProduct);
return {
open: () => router.push(routes.product(product.id)),
add: () => addProduct(product, 1),
};
}
/** Grid tile used in shop, search results and category listings. */
export function ProductCard({
product,
width,
}: {
product: Product;
width?: number | `${number}%`;
}) {
const { open, add } = useCardActions(product);
const facts = getDrugFacts(product);
const off = discount(product);
return (
<Pressable
onPress={open}
style={({ pressed }) => [
{
width,
backgroundColor: colors.surface,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.line,
padding: space.md,
opacity: pressed ? 0.94 : 1,
},
shadows.xs,
]}
>
<View
style={{
height: 108,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Image
source={{ uri: product.image }}
style={{ width: "82%", height: "82%" }}
resizeMode="contain"
/>
{off ? (
<Badge label={off} solid style={{ position: "absolute", top: 8, left: 8 }} />
) : null}
{product.requiresPrescription ? (
<Badge
label="RX"
tone="info"
style={{ position: "absolute", top: 8, right: 8 }}
/>
) : null}
</View>
<Text variant="micro" color={colors.subtle} style={{ marginTop: 10 }}>
{facts.useCase}
</Text>
<Text variant="h3" numberOfLines={2} style={{ marginTop: 2, minHeight: 40 }}>
{product.name}
</Text>
<View style={{ marginTop: 6 }}>
<Price value={product.price} compareAt={product.compareAt} format={formatETB} />
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, marginTop: 8 }}>
<MetaTag label={facts.form} icon="ellipse-outline" />
<MetaTag label={product.unit} />
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 10,
}}
>
<StockLabel inStock={product.inStock} count={facts.stockCount} />
<AddButton onPress={add} disabled={!product.inStock} />
</View>
</Pressable>
);
}
/** Horizontal row used in "Most popular", favorites and list-view results. */
export function ProductRow({ product }: { product: Product }) {
const { open, add } = useCardActions(product);
const facts = getDrugFacts(product);
return (
<Pressable
onPress={open}
style={({ pressed }) => [
{
flexDirection: "row",
gap: space.md,
backgroundColor: colors.surface,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.line,
padding: space.md,
alignItems: "center",
opacity: pressed ? 0.94 : 1,
},
shadows.xs,
]}
>
<View
style={{
width: 66,
height: 66,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Image
source={{ uri: product.image }}
style={{ width: "80%", height: "80%" }}
resizeMode="contain"
/>
</View>
<View style={{ flex: 1 }}>
<Text variant="micro" color={colors.subtle}>
{facts.useCase}
</Text>
<Text variant="h3" numberOfLines={1} style={{ marginTop: 1 }}>
{product.name}
</Text>
<View style={{ marginTop: 4 }}>
<Price value={product.price} compareAt={product.compareAt} format={formatETB} />
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, marginTop: 5 }}>
<MetaTag label={facts.form} icon="ellipse-outline" />
<StockLabel inStock={product.inStock} count={facts.stockCount} />
</View>
</View>
<AddButton onPress={add} disabled={!product.inStock} />
</Pressable>
);
}
/** Compact tile for horizontal carousels on the home screen. */
export function ProductTile({ product }: { product: Product }) {
const { open, add } = useCardActions(product);
const liked = useFavoritesStore((s) => s.has(product.id));
const toggle = useFavoritesStore((s) => s.toggle);
const off = discount(product);
return (
<Pressable
onPress={open}
style={({ pressed }) => [
{
width: 158,
backgroundColor: colors.surface,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.line,
padding: space.md,
opacity: pressed ? 0.94 : 1,
},
shadows.xs,
]}
>
<View
style={{
height: 96,
borderRadius: radii.sm,
backgroundColor: colors.surfaceAlt,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Image
source={{ uri: product.image }}
style={{ width: "82%", height: "82%" }}
resizeMode="contain"
/>
{off ? (
<Badge label={off} solid style={{ position: "absolute", top: 6, left: 6 }} />
) : null}
<Pressable
onPress={() => toggle(product.id)}
hitSlop={8}
style={{
position: "absolute",
top: 6,
right: 6,
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: colors.surface,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons
name={liked ? "heart" : "heart-outline"}
size={14}
color={liked ? colors.danger : colors.muted}
/>
</Pressable>
</View>
<Text variant="h3" numberOfLines={2} style={{ marginTop: 10, minHeight: 40 }}>
{product.name}
</Text>
<View style={{ marginTop: 4 }}>
<Price value={product.price} compareAt={product.compareAt} size="sm" format={formatETB} />
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
}}
>
<StockLabel inStock={product.inStock} />
<AddButton onPress={add} disabled={!product.inStock} size={30} />
</View>
</Pressable>
);
}

View File

@ -0,0 +1,83 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, View } from "react-native";
import { Text } from "@/components/ui/Text";
import { colors, radii } from "@/lib/theme";
export function QtyStepper({
value,
onChange,
min = 1,
max = 99,
size = "md",
}: {
value: number;
onChange: (n: number) => void;
min?: number;
max?: number;
size?: "sm" | "md";
}) {
const dim = size === "sm" ? 26 : 32;
const iconSize = size === "sm" ? 14 : 17;
const step = (delta: number) => {
const next = Math.min(max, Math.max(min, value + delta));
if (next !== value) onChange(next);
};
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.line,
borderRadius: radii.pill,
padding: 3,
gap: 2,
}}
>
<Pressable
accessibilityLabel="Decrease quantity"
onPress={() => step(-1)}
disabled={value <= min}
style={{
width: dim,
height: dim,
borderRadius: dim / 2,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surfaceAlt,
}}
>
<Ionicons
name="remove"
size={iconSize}
color={value <= min ? colors.disabled : colors.ink}
/>
</Pressable>
<View style={{ minWidth: 30, alignItems: "center" }}>
<Text variant="h3">{value}</Text>
</View>
<Pressable
accessibilityLabel="Increase quantity"
onPress={() => step(1)}
disabled={value >= max}
style={{
width: dim,
height: dim,
borderRadius: dim / 2,
alignItems: "center",
justifyContent: "center",
backgroundColor: value >= max ? colors.surfaceAlt : colors.brand,
}}
>
<Ionicons
name="add"
size={iconSize}
color={value >= max ? colors.disabled : colors.ink}
/>
</Pressable>
</View>
);
}

63
components/ui/Screen.tsx Normal file
View File

@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import {
ScrollView,
View,
type ScrollViewProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { colors, gutter } from "@/lib/theme";
type ScreenProps = {
children: ReactNode;
/** Adds bottom room for the floating tab bar. */
tabBarPadding?: boolean;
background?: string;
style?: StyleProp<ViewStyle>;
};
export function Screen({ children, background = colors.page, style }: ScreenProps) {
return <View style={[{ flex: 1, backgroundColor: background }, style]}>{children}</View>;
}
type ScreenScrollProps = ScrollViewProps & {
children: ReactNode;
/** Extra bottom inset, e.g. for a sticky bar or the floating tab bar. */
bottomInset?: number;
/** Set false when the screen renders its own header with the top inset. */
topInset?: boolean;
gutters?: boolean;
};
export function ScreenScroll({
children,
bottomInset = 32,
topInset = false,
gutters = true,
contentContainerStyle,
...rest
}: ScreenScrollProps) {
const insets = useSafeAreaInsets();
return (
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={[
{
paddingTop: topInset ? insets.top + 12 : 0,
paddingBottom: insets.bottom + bottomInset,
paddingHorizontal: gutters ? gutter : 0,
},
contentContainerStyle,
]}
{...rest}
>
{children}
</ScrollView>
);
}
/** Bottom padding that clears the floating tab bar. */
export const TAB_BAR_INSET = 96;

View File

@ -0,0 +1,119 @@
import { useState, type ReactNode } from "react";
import { Ionicons } from "@expo/vector-icons";
import {
Pressable,
TextInput,
View,
type TextInputProps,
} from "react-native";
import { Text } from "@/components/ui/Text";
import { colors, fonts, radii } from "@/lib/theme";
type Props = TextInputProps & {
onClear?: () => void;
/** Renders as a button instead of an input (home screen → search screen). */
readOnlyPress?: () => void;
trailing?: ReactNode;
};
export function SearchField({
value,
onClear,
readOnlyPress,
trailing,
placeholder = "Search medicine, brand or symptom",
...rest
}: Props) {
const [focused, setFocused] = useState(false);
const body = (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 10,
backgroundColor: colors.surface,
borderRadius: radii.pill,
borderWidth: 1.5,
borderColor: focused ? colors.brand : colors.line,
paddingLeft: 16,
paddingRight: trailing ? 6 : 16,
height: 52,
}}
>
<Ionicons name="search" size={18} color={focused ? colors.brandText : colors.subtle} />
{readOnlyPress ? (
<Text variant="body" color={colors.subtle} style={{ flex: 1 }} numberOfLines={1}>
{value || placeholder}
</Text>
) : (
<TextInput
value={value}
placeholder={placeholder}
placeholderTextColor={colors.subtle}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
returnKeyType="search"
style={{
flex: 1,
fontFamily: fonts.regular,
fontSize: 14,
color: colors.ink,
paddingVertical: 0,
}}
{...rest}
/>
)}
{value && onClear && !readOnlyPress ? (
<Pressable onPress={onClear} hitSlop={8}>
<Ionicons name="close-circle" size={18} color={colors.subtle} />
</Pressable>
) : null}
{trailing}
</View>
);
if (readOnlyPress) {
return (
<Pressable onPress={readOnlyPress} style={({ pressed }) => ({ opacity: pressed ? 0.9 : 1 })}>
{body}
</Pressable>
);
}
return body;
}
/** Lime pill action that sits inside the search field (scan entry point). */
export function SearchAction({
icon,
label,
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap;
label?: string;
onPress?: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: colors.brand,
borderRadius: radii.pill,
paddingHorizontal: label ? 14 : 10,
height: 40,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name={icon} size={16} color={colors.ink} />
{label ? (
<Text variant="label" color={colors.ink}>
{label}
</Text>
) : null}
</Pressable>
);
}

View File

@ -0,0 +1,62 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { colors, gutter, radii, shadows } from "@/lib/theme";
/**
* Bottom action bar. `floating` renders the dark pill used on product detail;
* the default renders a flush surface bar used in cart and checkout.
*/
export function StickyBar({
children,
floating,
}: {
children: ReactNode;
floating?: boolean;
}) {
const insets = useSafeAreaInsets();
if (floating) {
return (
<View
style={[
{
position: "absolute",
left: gutter,
right: gutter,
bottom: Math.max(insets.bottom, 14),
backgroundColor: colors.ink,
borderRadius: radii.pill,
paddingVertical: 12,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
},
shadows.md,
]}
>
{children}
</View>
);
}
return (
<View
style={[
{
backgroundColor: colors.surface,
borderTopWidth: 1,
borderTopColor: colors.line,
paddingHorizontal: gutter,
paddingTop: 14,
paddingBottom: Math.max(insets.bottom, 14),
},
shadows.md,
]}
>
{children}
</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>
);
}

27
components/ui/Text.tsx Normal file
View File

@ -0,0 +1,27 @@
import { Text as RNText, type TextProps as RNTextProps } from "react-native";
import { colors, typography, type TypeVariant } from "@/lib/theme";
export type TextProps = RNTextProps & {
variant?: TypeVariant;
color?: string;
center?: boolean;
};
/**
* App-wide text primitive. React Native does not inherit `fontFamily`, so every
* piece of copy goes through here to stay on the type scale.
*/
export function Text({
variant = "body",
color = colors.ink,
center,
style,
...rest
}: TextProps) {
return (
<RNText
style={[typography[variant], { color }, center && { textAlign: "center" }, style]}
{...rest}
/>
);
}

135
docs/BACKEND_SPEC.md Normal file
View File

@ -0,0 +1,135 @@
# Gishen-Mob Backend Spec
**Base URL (TBD):** `https://api.gishen.example/v1`
**Auth:** Bearer JWT after phone OTP
**Headers:** `Authorization`, `Accept-Language` (`en` | `am`), `Content-Type: application/json`
Shared with Gishen-Ecom / Gishen-B2B where noted.
Patch this file whenever a feature is added or finalized.
## Auth
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/auth/otp/request` | Required | `{ phone }` |
| POST | `/auth/otp/verify` | Required | `{ phone, code }` → session + user |
| POST | `/auth/biometric/bind` | Required | Device biometric public key |
| GET | `/users/me` | Required | Profile, locale, points, tier |
| PATCH | `/users/me` | Required | name, email, `locale`, biometric flag |
## Locale
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| PATCH | `/users/me` | Required | `{ locale: "en" \| "am" }` |
| — | Accept-Language | Required | All requests |
## Catalog
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/catalog/products` | Required | q, category, badge, max — parity with Ecom |
| GET | `/catalog/products/:idOrSlug` | Required | |
| GET | `/catalog/categories` | Required | |
## Branches / Stock
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/branches` | Required | |
| GET | `/branches/nearby` | Required | `lat`, `lng` |
| GET | `/stock/product/:productId` | Required | Nearby availability |
## Orders
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/orders` | Required | items, fulfillment, branchId?, location, paymentMethod, prescriptionFileKeys?, notes, credit? |
| GET | `/orders/me` | Required | |
| GET | `/orders/:id` | Required | |
| GET | `/orders/:id/tracking` | Required | Rider trail when assigned |
| POST | `/orders/:id/cancel` | Required | |
## Payments
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/payments/initiate` | Required | `chapa` \| `telebirr` \| `mpesa` only for delivery |
| POST | `/payments/webhook/*` | Required | Gateway callbacks |
| — | `pay_at_branch` | Required | Allowed **only** when `fulfillment=pickup` |
## Prescriptions
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/uploads/signed-url` | Required | Rx images |
| POST | `/prescriptions` | Required | Multi-page keys, medicines[], patient, memberId? |
| POST | `/prescriptions/check` | Required | Match catalog → ready/review/unavailable |
| GET | `/prescriptions/me` | Required | |
| POST | `/prescriptions/:id/reorder` | Required | |
## Reminders
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/health/reminders` | Required | Dose/refill schedules |
| POST | `/health/reminders/:id/confirm` | Required | `dose.confirmed` |
| GET | `/health/reminders/me` | Required | |
## Emergency contacts
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/users/me/emergency-contacts` | Required | |
| POST | `/users/me/emergency-contacts` | Required | name, phone, relationship |
| DELETE | `/users/me/emergency-contacts/:id` | Required | |
| — | Job `dose.missed_escalation` | Required | After grace (default 30m) SMS/push to contact if no confirm |
## Family
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/family/members` | Required | |
| POST | `/family/invites` | Required | phone or link code |
| POST | `/family/invites/accept` | Required | |
| DELETE | `/family/members/:id` | Required | |
## Subscriptions
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/subscriptions/me` | Required | |
| POST | `/subscriptions` | Required | |
| POST | `/subscriptions/:id/pause` | Required | |
| POST | `/subscriptions/:id/resume` | Required | |
| POST | `/subscriptions/:id/cancel` | Required | |
## B2B / Corporate (shared with Gishen-B2B)
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/users/me/contexts` | Required | personal + org memberships |
| POST | `/orgs/join` | Required | `{ code }` |
| GET | `/company/me` | Required | Member view: plan, allowance |
| POST | `/checkout/credit/quote` | Required | Basket split covered vs selfPay |
## Loyalty
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| GET | `/loyalty/me` | Required | |
| GET | `/loyalty/me/ledger` | Required | |
| POST | `/loyalty/redeem` | Required | |
| GET | `/referrals/me` | Required | |
## Push / Tracking
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/devices` | Required | push token |
| — | Events | Required | order.*, dispatch.*, dose.*, refill.*, escalation.* |
| GET | `/orders/:id/tracking` | Required | |
## Contact / Support
| Method | Path | Status | Notes |
|--------|------|--------|-------|
| POST | `/contact` | Required | |
| — | Chatwoot | Deeplink | https://chat.yaltopia.com |
### Changelog
- 2026-08-06: Scaffold — skeleton created with all domains.
- 2026-08-06: Auth/Locale — OTP mock, biometric bind flag, language picker en/am, Accept-Language noted.
- 2026-08-06: Catalog — products/categories from Ecom data port; shop/search/favorites.
- 2026-08-06: Branches/Stock — GPS nearest sort; pickup selection (stock map stub).
- 2026-08-06: Orders/Payments/B2B — checkout with Chapa/Telebirr/M-Pesa; pay_at_branch pickup-only; WhatsApp handoff; credit quote split stub.
- 2026-08-06: Prescriptions/Reminders/Emergency — camera multi-page scan, check statuses, calendar dose push, escalation job contract.
- 2026-08-06: Family/Subscriptions — invite/manage; refill subscription CRUD.
- 2026-08-06: B2B/Account — contexts, join codes, profile switcher.
- 2026-08-06: Loyalty/Push/Tracking — wallet redeem/referral; order tracking map mock; device push channels.
- 2026-08-06: Contact/Support — contact form + Chatwoot/WhatsApp/call deeplinks.

34
lib/api/client.ts Normal file
View File

@ -0,0 +1,34 @@
/** API client stub — swap BASE_URL when shared backend is live. */
export const BASE_URLS = {
core: process.env.EXPO_PUBLIC_API_URL ?? "https://api.gishen.example/v1",
} as const;
export type ApiError = { message: string; code?: string };
export async function callEndpoint<T>(
path: string,
init?: RequestInit & { locale?: string }
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
...(init?.locale ? { "Accept-Language": init.locale } : {}),
...(init?.headers as Record<string, string>),
};
// Mock mode until backend exists
if (!process.env.EXPO_PUBLIC_API_URL) {
throw new Error(`API not configured for ${path}`);
}
const res = await fetch(`${BASE_URLS.core}${path}`, {
...init,
headers,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as ApiError;
throw new Error(body.message || res.statusText);
}
return res.json() as Promise<T>;
}

31
lib/commerce.ts Normal file
View File

@ -0,0 +1,31 @@
/** Shared commerce numbers so cart, checkout and orders stay consistent. */
export const DELIVERY_FEE = 80;
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) {
return Math.round(subtotal * DEMO_VOUCHER_PCT);
}
return 0;
}
export function orderSubtotalTotal(input: {
subtotal: number;
deliveryFee: number;
discount?: number;
}) {
return Math.max(0, input.subtotal + input.deliveryFee - (input.discount ?? 0));
}

142
lib/data/branches.ts Normal file
View File

@ -0,0 +1,142 @@
export type Branch = {
id: string;
slug: string;
name: string;
area: string;
phone: string;
hours: string;
is24h: boolean;
lat: number;
lng: number;
pickupAvailable: boolean;
mapsUrl: string;
};
/** Verified from gishenpharmacy.wordpress.com contact page + public listings. */
export const branches: Branch[] = [
{
id: "bole",
slug: "bole",
name: "Bole Branch",
area: "Bole, Addis Ababa",
phone: "+251930033290",
hours: "Open 24 hours",
is24h: true,
lat: 8.9806,
lng: 38.799,
pickupAvailable: true,
mapsUrl:
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Bole%20Addis%20Ababa",
},
{
id: "cmc",
slug: "cmc-michael",
name: "CMC Michael Branch",
area: "CMC Michael, Addis Ababa",
phone: "+251930102175",
hours: "Open 24 hours",
is24h: true,
lat: 9.0227,
lng: 38.826,
pickupAvailable: true,
mapsUrl:
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20CMC%20Michael%20Addis%20Ababa",
},
{
id: "jemo",
slug: "jemo",
name: "Jemo Branch",
area: "Jemo, Addis Ababa",
phone: "+251930033292",
hours: "Open 24 hours",
is24h: true,
lat: 8.963,
lng: 38.72,
pickupAvailable: true,
mapsUrl:
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Jemo%20Addis%20Ababa",
},
{
id: "stadium",
slug: "stadium",
name: "Stadium Branch",
area: "Stadium, Addis Ababa",
phone: "+251930033291",
hours: "8:00 AM – 8:00 PM",
is24h: false,
lat: 9.012,
lng: 38.763,
pickupAvailable: true,
mapsUrl:
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Stadium%20Addis%20Ababa",
},
{
id: "mexico",
slug: "mexico",
name: "Mexico Branch",
area: "Mexico Square, Addis Ababa",
phone: "+251935998678",
hours: "8:00 AM – 9:00 PM",
is24h: false,
lat: 9.01,
lng: 38.745,
pickupAvailable: true,
mapsUrl:
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Mexico%20Addis%20Ababa",
},
{
id: "golagol",
slug: "golagol",
name: "Golagol Branch",
area: "Golagol, Addis Ababa",
phone: "+251930076930",
hours: "8:00 AM – 8:00 PM",
is24h: false,
lat: 9.03,
lng: 38.75,
pickupAvailable: true,
mapsUrl:
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Golagol%20Addis%20Ababa",
},
{
id: "hayahulet",
slug: "hayahulet",
name: "Hayahulet Branch",
area: "Hayahulet, Addis Ababa",
phone: "+251930033292",
hours: "8:00 AM – 8:00 PM",
is24h: false,
lat: 9.02,
lng: 38.79,
pickupAvailable: true,
mapsUrl:
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Hayahulet%20Addis%20Ababa",
},
];
export function getBranchById(id: string) {
return branches.find((b) => b.id === id);
}
export function distanceKm(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
) {
const toRad = (v: number) => (v * Math.PI) / 180;
const R = 6371;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function nearestBranches(lat: number, lng: number, limit = 3) {
return [...branches]
.map((b) => ({ ...b, distance: distanceKm(lat, lng, b.lat, b.lng) }))
.sort((a, b) => a.distance - b.distance)
.slice(0, limit);
}

38
lib/data/brand.ts Normal file
View File

@ -0,0 +1,38 @@
export const brand = {
name: "Gishen Pharmacy",
shortName: "Gishen",
tagline: "Healthcare delivered to your doorstep",
description:
"Established in 1994, Gishen Pharmacy is a trusted pharmaceutical retailer in Ethiopia with multiple branches across Addis Ababa.",
phone: "+251930099850",
phoneDisplay: "+251 930 099 850",
whatsapp: "251930099850",
email: "info@gishenpharmacy.org",
orderEmail: "orders@gishenpharmacy.org",
website: "https://gishenpharmacy.org",
founded: 1994,
branchCount: 7,
weeklyCustomers: 1218,
institutions: 146,
logo: "/brand/gishen-logo.png",
address: "Addis Ababa, Ethiopia",
googleRating: 4.4,
googleReviewCount: 120,
googleMapsUrl:
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Addis%20Ababa",
socials: [
{
id: "whatsapp",
label: "WhatsApp",
href: "https://wa.me/251930099850",
},
] as { id: string; label: string; href: string }[],
story:
"Established in 1994, Gishen Group (Gishen Pharmacy) is one of the household names in the pharmaceutical retail industry in Ethiopia. The company has grown from a single branch to a multi-branch operation in Addis Ababa, serving individuals and 146+ institutional clients with medicines, medical equipment, cosmetics, baby products, compounding, and consulting.",
purpose:
"To provide the highest quality products and services that enhance the well-being of humanity — improving access to exceptional healthcare solutions across Ethiopia.",
vision:
"To enhance accessibility to the finest pharmaceutical products, services, and medical equipment, bridging the gap between supply and demand.",
mission:
"To instill hope by delivering exceptional quality healthcare solutions through the retailing of pharmaceutical goods and services in Ethiopia.",
};

322
lib/data/clinical.ts Normal file
View File

@ -0,0 +1,322 @@
import type { Product } from "@/lib/data/products";
export type StorageNote = {
icon: "thermometer-outline" | "sunny-outline" | "happy-outline" | "water-outline";
title: string;
body: string;
};
export type DrugFacts = {
/** Short therapeutic label shown above the product name. */
useCase: string;
/** Dose form: Tablet, Syrup, Cream, Device… */
form: string;
genericName: string;
brandNames: string;
drugClass: string;
/** Selectable strengths on the detail page. Empty for non-dosed products. */
dosages: string[];
sideEffects: { mild: string[]; severe: string[] };
storage: StorageNote[];
directions: { title: string; body: string }[];
stockCount: number;
/** Pharmacist guidance shown in the safety callout. */
advice: string;
};
const CATEGORY_USE_CASE: Record<string, string> = {
medicines: "General Medicine",
vitamins: "Supplements",
"personal-care": "Personal Care",
diabetic: "Diabetes Care",
devices: "Medical Device",
baby: "Baby Care",
"first-aid": "First Aid",
wellness: "Sexual Wellness",
};
const CATEGORY_FORM: Record<string, string> = {
medicines: "Tablet",
vitamins: "Tablet",
"personal-care": "Topical",
diabetic: "Tablet",
devices: "Device",
baby: "Topical",
"first-aid": "Supply",
wellness: "Tablet",
};
/** Per-product clinical overrides. Everything else falls back to category defaults. */
const OVERRIDES: Record<string, Partial<DrugFacts>> = {
"panadol-advance": {
useCase: "Pain & Fever",
genericName: "Paracetamol (Acetaminophen)",
brandNames: "Panadol, Tylenol, Calpol",
drugClass: "Analgesic / Antipyretic",
dosages: ["500mg", "650mg"],
sideEffects: {
mild: ["Nausea", "Sleepiness", "Stomach upset"],
severe: ["Liver damage", "Rash", "Breathing issues"],
},
advice: "Do not exceed 4g of paracetamol in 24 hours across all products.",
},
paracetamol: {
useCase: "Pain & Fever",
genericName: "Paracetamol",
brandNames: "Panadol, Tylenol",
drugClass: "Analgesic / Antipyretic",
dosages: ["500mg"],
advice: "Avoid alcohol while taking this medicine.",
},
"paracetamol-tylenol": {
useCase: "Pain & Fever",
genericName: "Acetaminophen",
brandNames: "Tylenol, Panadol",
drugClass: "Analgesic",
dosages: ["500mg", "650mg"],
},
"paracetamol-caffeine": {
useCase: "Headache Relief",
genericName: "Paracetamol + Caffeine",
brandNames: "Panadol Extra",
drugClass: "Analgesic combination",
dosages: ["500mg / 65mg"],
advice: "Contains caffeine — limit other caffeinated drinks.",
},
snip: {
useCase: "Cold & Flu",
genericName: "Paracetamol + Pseudoephedrine + Chlorphenamine",
brandNames: "SNIP",
drugClass: "Decongestant combination",
sideEffects: {
mild: ["Drowsiness", "Dry mouth", "Dizziness"],
severe: ["Rapid heartbeat", "High blood pressure"],
},
advice: "May cause drowsiness. Do not drive until you know how it affects you.",
},
metformin: {
useCase: "Diabetes Care",
genericName: "Metformin hydrochloride",
brandNames: "Glucophage, Glumet",
drugClass: "Biguanide antidiabetic",
dosages: ["500mg", "850mg", "1000mg"],
sideEffects: {
mild: ["Nausea", "Diarrhoea", "Metallic taste"],
severe: ["Lactic acidosis", "Vitamin B12 deficiency"],
},
advice: "Take with meals to reduce stomach upset. Requires a valid prescription.",
},
ciprofloxacin: {
useCase: "Antibiotic",
genericName: "Ciprofloxacin",
brandNames: "Cipro, Ciprobay",
drugClass: "Fluoroquinolone antibiotic",
dosages: ["250mg", "500mg"],
sideEffects: {
mild: ["Nausea", "Headache", "Dizziness"],
severe: ["Tendon rupture", "Nerve damage"],
},
advice: "Finish the full course even if you feel better.",
},
azitro: {
useCase: "Antibiotic",
genericName: "Azithromycin",
brandNames: "Zithromax, Azitro",
drugClass: "Macrolide antibiotic",
dosages: ["250mg", "500mg"],
advice: "Usually taken once daily for 3 days. Complete the course as prescribed.",
},
diclofenac: {
useCase: "Pain & Inflammation",
genericName: "Diclofenac sodium",
brandNames: "Voltaren, Cataflam",
drugClass: "NSAID",
dosages: ["25mg", "50mg", "75mg"],
sideEffects: {
mild: ["Indigestion", "Headache", "Dizziness"],
severe: ["Stomach bleeding", "Kidney issues"],
},
advice: "Take after food. Avoid if you have a history of stomach ulcers.",
},
pantoprazole: {
useCase: "Acid Reflux",
genericName: "Pantoprazole sodium",
brandNames: "Protonix, Pantocid",
drugClass: "Proton pump inhibitor",
dosages: ["20mg", "40mg"],
advice: "Take 30–60 minutes before your first meal of the day.",
},
desloratidine: {
useCase: "Allergy Relief",
genericName: "Desloratadine",
brandNames: "Clarinex, Aerius",
drugClass: "Antihistamine",
dosages: ["5mg"],
advice: "Non-drowsy for most people, but avoid driving until you're sure.",
},
"nat-c-vitamin-c": {
useCase: "Immune Support",
genericName: "Ascorbic acid",
brandNames: "Nat C, Redoxon",
drugClass: "Vitamin supplement",
dosages: ["500mg", "1000mg"],
sideEffects: { mild: ["Stomach upset", "Heartburn"], severe: ["Kidney stones (high doses)"] },
},
gofen: {
useCase: "Pain & Inflammation",
genericName: "Ibuprofen",
brandNames: "Brufen, Advil, Gofen",
drugClass: "NSAID",
dosages: ["200mg", "400mg"],
advice: "Take with food or milk to protect your stomach.",
},
quemox: {
useCase: "Antibiotic",
genericName: "Amoxicillin",
brandNames: "Amoxil, Quemox",
drugClass: "Penicillin antibiotic",
dosages: ["250mg", "500mg"],
advice: "Tell your pharmacist about any penicillin allergy before use.",
},
sildenafil: {
useCase: "Sexual Wellness",
genericName: "Sildenafil citrate",
brandNames: "Viagra, Cialis (tadalafil alt.)",
drugClass: "PDE5 inhibitor",
dosages: ["25mg", "50mg", "100mg"],
advice: "Never combine with nitrate heart medicines. Prescription required.",
},
"ors-sachets": {
useCase: "Rehydration",
genericName: "Oral rehydration salts",
brandNames: "ORS, Hydralyte",
drugClass: "Electrolyte replacement",
advice: "Dissolve one sachet in 200ml of clean water. Use within 24 hours.",
},
"cough-syrup": {
useCase: "Cough Relief",
form: "Syrup",
genericName: "Dextromethorphan / Guaifenesin",
brandNames: "Robitussin, Benylin",
drugClass: "Antitussive / Expectorant",
dosages: ["100ml", "200ml"],
},
"lubricating-eye-drops": {
useCase: "Eye Care",
form: "Drops",
},
"eye-drops-lubricating": {
useCase: "Eye Care",
form: "Drops",
genericName: "Carboxymethylcellulose",
brandNames: "Refresh Tears, Optive",
drugClass: "Ocular lubricant",
},
"blood-pressure-monitor": {
useCase: "Monitoring",
form: "Device",
genericName: "Upper-arm oscillometric monitor",
brandNames: "HealthTech",
drugClass: "Class II medical device",
},
"glucose-meter-kit": {
useCase: "Diabetes Care",
form: "Device",
genericName: "Blood glucose monitoring system",
brandNames: "DiaCare",
drugClass: "Class II medical device",
},
};
const DEFAULT_SIDE_EFFECTS: DrugFacts["sideEffects"] = {
mild: ["Nausea", "Headache", "Mild dizziness"],
severe: ["Allergic reaction", "Difficulty breathing"],
};
const DEFAULT_STORAGE: StorageNote[] = [
{
icon: "thermometer-outline",
title: "Temperature",
body: "Store at room temperature, between 20°C and 25°C.",
},
{
icon: "sunny-outline",
title: "Light & moisture",
body: "Keep in a dry place, away from direct sunlight.",
},
{
icon: "happy-outline",
title: "Children",
body: "Keep out of reach of children to prevent accidental ingestion.",
},
];
const DEFAULT_DIRECTIONS: DrugFacts["directions"] = [
{
title: "Check with your pharmacist",
body: "Confirm the dose is right for your age, weight and other medicines.",
},
{
title: "Take as directed",
body: "Follow the schedule on the label. Do not double up on a missed dose.",
},
{
title: "Take with water",
body: "Swallow whole with a full glass of water unless told otherwise.",
},
{
title: "Track your response",
body: "Stop and contact us if symptoms persist beyond 3 days.",
},
];
const DEVICE_DIRECTIONS: DrugFacts["directions"] = [
{ title: "Unbox and inspect", body: "Check all parts are present and undamaged." },
{ title: "Prepare", body: "Sit still for 5 minutes and rest your arm at heart level." },
{ title: "Measure", body: "Follow the on-screen prompts and stay quiet during the reading." },
{ title: "Log the result", body: "Record readings so your pharmacist can review trends." },
];
/** Deterministic pseudo-stock so the number is stable between renders. */
function stockFor(product: Product): number {
if (!product.inStock) return 0;
const seed = product.id
.split("")
.reduce((total, char) => total + char.charCodeAt(0), 0);
return 8 + (seed % 44);
}
export function getDrugFacts(product: Product): DrugFacts {
const override = OVERRIDES[product.slug] ?? {};
const isDevice = product.category === "devices" || product.category === "first-aid";
return {
useCase: override.useCase ?? CATEGORY_USE_CASE[product.category] ?? "Healthcare",
form: override.form ?? CATEGORY_FORM[product.category] ?? "Unit",
genericName: override.genericName ?? product.name,
brandNames: override.brandNames ?? product.brand,
drugClass: override.drugClass ?? CATEGORY_USE_CASE[product.category] ?? "Healthcare product",
dosages: override.dosages ?? [],
sideEffects:
override.sideEffects ??
(isDevice ? { mild: ["Skin irritation"], severe: [] } : DEFAULT_SIDE_EFFECTS),
storage: override.storage ?? DEFAULT_STORAGE,
directions: override.directions ?? (isDevice ? DEVICE_DIRECTIONS : DEFAULT_DIRECTIONS),
stockCount: override.stockCount ?? stockFor(product),
advice:
override.advice ??
(product.requiresPrescription
? "A valid prescription is required. Our pharmacist will verify it before dispatch."
: "Speak to our pharmacist if you are pregnant, breastfeeding or taking other medicines."),
};
}
/** Star distribution for the review summary, weighted by the product rating. */
export function getReviewBreakdown(product: Product) {
const weights = [0.62, 0.22, 0.09, 0.04, 0.03];
const shift = Math.max(0, Math.round((4.8 - product.rating) * 10)) / 100;
return [5, 4, 3, 2, 1].map((stars, index) => {
const weight = Math.max(0.01, weights[index] - (index === 0 ? shift : -shift / 4));
return { stars, count: Math.max(1, Math.round(product.reviews * weight)) };
});
}

997
lib/data/products.ts Normal file
View File

@ -0,0 +1,997 @@
export type Product = {
id: string;
slug: string;
name: string;
brand: string;
category: string;
description: string;
price: number;
compareAt?: number;
image: string;
rating: number;
reviews: number;
badge?: "sale" | "new" | "bestseller";
requiresPrescription?: boolean;
inStock: boolean;
sku: string;
unit: string;
};
const u = (id: string) =>
`https://images.unsplash.com/${id}?w=600&q=80`;
export const products: Product[] = [
{
id: "p1",
slug: "panadol-advance",
name: "Panadol Advance",
brand: "GSK",
category: "medicines",
description:
"Fast-acting paracetamol tablets for relief of mild to moderate pain and fever.",
price: 85,
compareAt: 110,
image: "https://gishen-ecom.vercel.app/products/panadol-advance.jpg",
rating: 4.8,
reviews: 214,
badge: "bestseller",
inStock: true,
sku: "GSH-PAN-ADV",
unit: "16 tablets",
},
{
id: "p2",
slug: "paracetamol",
name: "Paracetamol",
brand: "Generic",
category: "medicines",
description: "Reliable pain and fever relief for everyday use.",
price: 35,
image: "https://gishen-ecom.vercel.app/products/paracetamol.jpg",
rating: 4.6,
reviews: 189,
badge: "bestseller",
inStock: true,
sku: "GSH-PCM-500",
unit: "20 tablets",
},
{
id: "p3",
slug: "paracetamol-tylenol",
name: "Paracetamol Tylenol",
brand: "Tylenol",
category: "medicines",
description: "Trusted acetaminophen formula for pain and fever.",
price: 120,
compareAt: 145,
image: "https://gishen-ecom.vercel.app/products/paracetamol-tylenol.jpg",
rating: 4.7,
reviews: 98,
badge: "sale",
inStock: true,
sku: "GSH-TYL-PCM",
unit: "24 tablets",
},
{
id: "p4",
slug: "paracetamol-caffeine",
name: "Paracetamol + Caffeine",
brand: "Generic",
category: "medicines",
description: "Enhanced pain relief combining paracetamol with caffeine.",
price: 55,
image: "https://gishen-ecom.vercel.app/products/paracetamol-caffein.jpg",
rating: 4.5,
reviews: 67,
inStock: true,
sku: "GSH-PCM-CAF",
unit: "20 tablets",
},
{
id: "p5",
slug: "snip",
name: "SNIP Cold & Flu",
brand: "SNIP",
category: "medicines",
description:
"Paracetamol, pseudoephedrine and chlorphenamine for cold and flu symptoms.",
price: 95,
compareAt: 120,
image: "https://gishen-ecom.vercel.app/products/snip-pcm-pseudoephedrine-chlorphenamine.jpg",
rating: 4.6,
reviews: 142,
badge: "sale",
inStock: true,
sku: "GSH-SNIP-01",
unit: "12 tablets",
},
{
id: "p6",
slug: "paracetamol-pseudoephedrine-snip",
name: "Paracetamol-Pseudoephedrine (SNIP)",
brand: "SNIP",
category: "medicines",
description: "Relieves nasal congestion with pain and fever control.",
price: 90,
image: "https://gishen-ecom.vercel.app/products/paracetamol-pseudoephedrine-snip.jpg",
rating: 4.4,
reviews: 76,
inStock: true,
sku: "GSH-SNIP-02",
unit: "12 tablets",
},
{
id: "p7",
slug: "metformin",
name: "Metformin",
brand: "Generic",
category: "diabetic",
description: "Oral antidiabetic medication for type 2 diabetes management.",
price: 75,
image: "https://gishen-ecom.vercel.app/products/metformin.jpg",
rating: 4.7,
reviews: 203,
badge: "bestseller",
requiresPrescription: true,
inStock: true,
sku: "GSH-MET-500",
unit: "30 tablets",
},
{
id: "p8",
slug: "ciprofloxacin",
name: "Ciprofloxacin",
brand: "Generic",
category: "medicines",
description: "Broad-spectrum antibiotic for bacterial infections.",
price: 140,
image: "https://gishen-ecom.vercel.app/products/ciprofloxacin.jpg",
rating: 4.5,
reviews: 88,
requiresPrescription: true,
inStock: true,
sku: "GSH-CIP-500",
unit: "10 tablets",
},
{
id: "p9",
slug: "azitro",
name: "Azitro (Azithromycin)",
brand: "Azitro",
category: "medicines",
description: "Macrolide antibiotic commonly used for respiratory infections.",
price: 180,
compareAt: 220,
image: "https://gishen-ecom.vercel.app/products/azitro.jpg",
rating: 4.6,
reviews: 112,
badge: "sale",
requiresPrescription: true,
inStock: true,
sku: "GSH-AZI-500",
unit: "3 tablets",
},
{
id: "p10",
slug: "diclofenac",
name: "Diclofenac",
brand: "Generic",
category: "medicines",
description: "NSAID for pain, inflammation and joint discomfort.",
price: 60,
image: "https://gishen-ecom.vercel.app/products/diclofenac.jpg",
rating: 4.5,
reviews: 156,
badge: "bestseller",
inStock: true,
sku: "GSH-DIC-50",
unit: "20 tablets",
},
{
id: "p11",
slug: "albendazole",
name: "Albendazole",
brand: "Generic",
category: "medicines",
description: "Anthelmintic treatment for worm infections.",
price: 45,
image: "https://gishen-ecom.vercel.app/products/albendazole.jpg",
rating: 4.4,
reviews: 91,
inStock: true,
sku: "GSH-ALB-400",
unit: "1 tablet",
},
{
id: "p12",
slug: "pantoprazole",
name: "Pantoprazole",
brand: "Generic",
category: "medicines",
description: "Proton pump inhibitor for acid reflux and ulcers.",
price: 110,
image: "https://gishen-ecom.vercel.app/products/pantoprazole.jpg",
rating: 4.6,
reviews: 134,
requiresPrescription: true,
inStock: true,
sku: "GSH-PAN-40",
unit: "14 tablets",
},
{
id: "p13",
slug: "desloratidine",
name: "Desloratidine",
brand: "Generic",
category: "medicines",
description: "Non-drowsy antihistamine for allergy relief.",
price: 95,
image: "https://gishen-ecom.vercel.app/products/desloratidine.jpg",
rating: 4.5,
reviews: 72,
badge: "new",
inStock: true,
sku: "GSH-DES-5",
unit: "10 tablets",
},
{
id: "p14",
slug: "nat-c-vitamin-c",
name: "Nat C (Vitamin C)",
brand: "Nat C",
category: "vitamins",
description: "Vitamin C supplement to support immunity and recovery.",
price: 150,
compareAt: 180,
image: "https://gishen-ecom.vercel.app/products/nat-c-vitamin-c.jpg",
rating: 4.8,
reviews: 267,
badge: "bestseller",
inStock: true,
sku: "GSH-NATC-01",
unit: "30 tablets",
},
{
id: "p15",
slug: "vigafen",
name: "Vigafen",
brand: "Vigafen",
category: "medicines",
description: "Combination analgesic for headache and body pain.",
price: 70,
image: "https://gishen-ecom.vercel.app/products/vigafen.jpg",
rating: 4.3,
reviews: 54,
inStock: true,
sku: "GSH-VIG-01",
unit: "20 tablets",
},
{
id: "p16",
slug: "gofen",
name: "Gofen",
brand: "Gofen",
category: "medicines",
description: "Ibuprofen soft gel capsules for pain and inflammation.",
price: 125,
compareAt: 150,
image: "https://gishen-ecom.vercel.app/products/gofen.jpg",
rating: 4.7,
reviews: 118,
badge: "sale",
inStock: true,
sku: "GSH-GOF-400",
unit: "10 softgels",
},
{
id: "p17",
slug: "quemox",
name: "Quemox",
brand: "Quemox",
category: "medicines",
description: "Amoxicillin antibiotic for bacterial infections.",
price: 160,
image: "https://gishen-ecom.vercel.app/products/quemox.jpg",
rating: 4.5,
reviews: 83,
requiresPrescription: true,
inStock: true,
sku: "GSH-QUE-500",
unit: "21 capsules",
},
{
id: "p18",
slug: "olit",
name: "Olit",
brand: "Olit",
category: "medicines",
description: "Gastrointestinal support medication.",
price: 100,
image: "https://gishen-ecom.vercel.app/products/olit.jpg",
rating: 4.2,
reviews: 41,
inStock: true,
sku: "GSH-OLIT-01",
unit: "14 tablets",
},
{
id: "p19",
slug: "degra",
name: "Degra",
brand: "Degra",
category: "medicines",
description: "Digestive relief support formula.",
price: 115,
image: "https://gishen-ecom.vercel.app/products/degra.jpg",
rating: 4.3,
reviews: 39,
badge: "new",
inStock: true,
sku: "GSH-DEG-01",
unit: "10 sachets",
},
{
id: "p20",
slug: "almetamin",
name: "Almetamin",
brand: "Almetamin",
category: "medicines",
description: "Antihistamine for allergy and cold symptoms.",
price: 65,
image: "https://gishen-ecom.vercel.app/products/almetamin.jpg",
rating: 4.4,
reviews: 58,
inStock: true,
sku: "GSH-ALM-01",
unit: "20 tablets",
},
{
id: "p21",
slug: "mela-one-tablet",
name: "Mela One Tablet",
brand: "Mela",
category: "medicines",
description: "Single-dose treatment tablet as directed by a pharmacist.",
price: 80,
image: "https://gishen-ecom.vercel.app/products/mela-one-tablet.jpg",
rating: 4.1,
reviews: 33,
inStock: true,
sku: "GSH-MELA-01",
unit: "1 tablet",
},
{
id: "p22",
slug: "sildenafil",
name: "Sildenafil",
brand: "Generic",
category: "wellness",
description: "Prescription medication for erectile dysfunction.",
price: 250,
image: "https://gishen-ecom.vercel.app/products/sildenafil.jpg",
rating: 4.6,
reviews: 97,
requiresPrescription: true,
inStock: true,
sku: "GSH-SIL-100",
unit: "4 tablets",
},
{
id: "p23",
slug: "hiwot-trust-condom",
name: "Hiwot Trust Condom",
brand: "Hiwot Trust",
category: "wellness",
description: "Trusted protection condoms for sexual wellness.",
price: 50,
image: "https://gishen-ecom.vercel.app/products/hiwot-trust-condom.jpg",
rating: 4.7,
reviews: 176,
badge: "bestseller",
inStock: true,
sku: "GSH-HTC-01",
unit: "3 pack",
},
{
id: "p24",
slug: "royal-face-mask",
name: "Royal Face Mask",
brand: "Royal",
category: "devices",
description: "Protective face masks for everyday hygiene.",
price: 40,
compareAt: 60,
image: "https://gishen-ecom.vercel.app/products/royal-face-mask.jpg",
rating: 4.5,
reviews: 210,
badge: "sale",
inStock: true,
sku: "GSH-RFM-50",
unit: "50 pcs",
},
{
id: "p25",
slug: "examination-glove-medium",
name: "Examination Glove Medium",
brand: "MedGloves",
category: "devices",
description: "Disposable examination gloves, medium size.",
price: 220,
image: "https://gishen-ecom.vercel.app/products/examination-glove-medium.jpg",
rating: 4.6,
reviews: 64,
inStock: true,
sku: "GSH-GLV-M",
unit: "100 pcs",
},
{
id: "p26",
slug: "surgical-glove",
name: "Surgical Glove",
brand: "MedGloves",
category: "devices",
description: "Sterile surgical gloves for clinical use.",
price: 280,
image: "https://gishen-ecom.vercel.app/products/surgical-glave.jpg",
rating: 4.5,
reviews: 48,
inStock: true,
sku: "GSH-SGL-M",
unit: "50 pairs",
},
{
id: "p27",
slug: "first-aid-plaster",
name: "First Aid Plaster",
brand: "CareAid",
category: "first-aid",
description: "Assorted adhesive plasters for minor cuts and scrapes.",
price: 45,
image: "https://gishen-ecom.vercel.app/products/first-aid-plaster.jpg",
rating: 4.6,
reviews: 122,
badge: "new",
inStock: true,
sku: "GSH-FAP-01",
unit: "20 strips",
},
// Expanded curated OTC / wellness catalog (licensed Unsplash imagery)
{
id: "p28",
slug: "vitamin-d3",
name: "Vitamin D3 1000 IU",
brand: "Wellness Plus",
category: "vitamins",
description: "Supports bone health and immune function.",
price: 280,
compareAt: 320,
image: u("photo-1559757148-5c350d0d3c56"),
rating: 4.7,
reviews: 145,
badge: "sale",
inStock: true,
sku: "GSH-VD3-01",
unit: "60 softgels",
},
{
id: "p29",
slug: "omega-3-fish-oil",
name: "Omega-3 Fish Oil",
brand: "Wellness Plus",
category: "vitamins",
description: "EPA & DHA support for heart and brain health.",
price: 450,
image: u("photo-1505751172876-fa1923c5c528"),
rating: 4.8,
reviews: 198,
badge: "bestseller",
inStock: true,
sku: "GSH-OM3-01",
unit: "90 softgels",
},
{
id: "p30",
slug: "multivitamin-daily",
name: "Daily Multivitamin",
brand: "VitaDay",
category: "vitamins",
description: "Complete daily micronutrient support for adults.",
price: 320,
image: u("photo-1471864190281-a93a3070b6de"),
rating: 4.6,
reviews: 231,
badge: "bestseller",
inStock: true,
sku: "GSH-MVIT-01",
unit: "30 tablets",
},
{
id: "p31",
slug: "zinc-tablets",
name: "Zinc Tablets 50mg",
brand: "VitaDay",
category: "vitamins",
description: "Zinc supplement to support immunity.",
price: 180,
image: u("photo-1585435557343-3b092031a831"),
rating: 4.4,
reviews: 87,
inStock: true,
sku: "GSH-ZNC-50",
unit: "30 tablets",
},
{
id: "p32",
slug: "iron-folic-acid",
name: "Iron + Folic Acid",
brand: "VitaDay",
category: "vitamins",
description: "Supports healthy blood and prenatal nutrition needs.",
price: 160,
image: u("photo-1587854692152-cbe660dbde88"),
rating: 4.5,
reviews: 104,
inStock: true,
sku: "GSH-IRN-01",
unit: "30 tablets",
},
{
id: "p33",
slug: "calcium-magnesium",
name: "Calcium + Magnesium",
brand: "BoneCare",
category: "vitamins",
description: "Mineral blend for bone and muscle support.",
price: 290,
image: u("photo-1628771065518-0d82f1938462"),
rating: 4.5,
reviews: 76,
badge: "new",
inStock: true,
sku: "GSH-CAM-01",
unit: "60 tablets",
},
{
id: "p34",
slug: "hand-sanitizer-500",
name: "Hand Sanitizer 500ml",
brand: "CleanHands",
category: "personal-care",
description: "Alcohol-based sanitizer for on-the-go hygiene.",
price: 120,
compareAt: 150,
image: u("photo-1582719471384-894fbb16e074"),
rating: 4.6,
reviews: 188,
badge: "sale",
inStock: true,
sku: "GSH-SAN-500",
unit: "500 ml",
},
{
id: "p35",
slug: "moisturizing-lotion",
name: "Moisturizing Body Lotion",
brand: "DermaSoft",
category: "personal-care",
description: "Daily hydrating lotion for dry and sensitive skin.",
price: 240,
image: u("photo-1556228578-0d85b1a4d571"),
rating: 4.7,
reviews: 132,
inStock: true,
sku: "GSH-LOT-01",
unit: "400 ml",
},
{
id: "p36",
slug: "sunscreen-spf50",
name: "Sunscreen SPF 50",
brand: "DermaSoft",
category: "personal-care",
description: "Broad-spectrum sun protection for face and body.",
price: 380,
image: u("photo-1556228720-195a672e8a03"),
rating: 4.8,
reviews: 165,
badge: "bestseller",
inStock: true,
sku: "GSH-SPF-50",
unit: "100 ml",
},
{
id: "p37",
slug: "antiseptic-mouthwash",
name: "Antiseptic Mouthwash",
brand: "OralCare",
category: "personal-care",
description: "Freshens breath and supports oral hygiene.",
price: 160,
image: u("photo-1607613009820-a29f7bb81c04"),
rating: 4.4,
reviews: 79,
inStock: true,
sku: "GSH-MW-01",
unit: "500 ml",
},
{
id: "p38",
slug: "shampoo-gentle",
name: "Gentle Care Shampoo",
brand: "HairWell",
category: "personal-care",
description: "Mild shampoo suitable for daily use.",
price: 210,
image: u("photo-1535585209827-a15fcdbc4c2d"),
rating: 4.3,
reviews: 91,
badge: "new",
inStock: true,
sku: "GSH-SHP-01",
unit: "400 ml",
},
{
id: "p39",
slug: "blood-pressure-monitor",
name: "Digital Blood Pressure Monitor",
brand: "HealthTech",
category: "devices",
description: "Upper-arm digital BP monitor for home use.",
price: 1850,
compareAt: 2100,
image: u("photo-1584362917165-526a968579e8"),
rating: 4.7,
reviews: 84,
badge: "sale",
inStock: true,
sku: "GSH-BPM-01",
unit: "1 unit",
},
{
id: "p40",
slug: "pulse-oximeter",
name: "Pulse Oximeter",
brand: "HealthTech",
category: "devices",
description: "Fingertip SpO2 and pulse rate monitor.",
price: 950,
image: u("photo-1666214280557-f1b5022eb634"),
rating: 4.6,
reviews: 113,
badge: "bestseller",
inStock: true,
sku: "GSH-OXM-01",
unit: "1 unit",
},
{
id: "p41",
slug: "digital-thermometer",
name: "Digital Thermometer",
brand: "HealthTech",
category: "devices",
description: "Fast-read digital thermometer for family use.",
price: 280,
image: u("photo-1584017911766-d451b3d0e843"),
rating: 4.5,
reviews: 156,
inStock: true,
sku: "GSH-THM-01",
unit: "1 unit",
},
{
id: "p42",
slug: "glucose-meter-kit",
name: "Blood Glucose Meter Kit",
brand: "DiaCare",
category: "diabetic",
description: "Home glucose monitoring kit with strips starter pack.",
price: 1450,
image: u("photo-1579684385127-1ef15d508118"),
rating: 4.7,
reviews: 99,
badge: "bestseller",
inStock: true,
sku: "GSH-BGM-01",
unit: "1 kit",
},
{
id: "p43",
slug: "glucose-test-strips",
name: "Glucose Test Strips",
brand: "DiaCare",
category: "diabetic",
description: "Compatible test strips for DiaCare meters.",
price: 420,
image: u("photo-1631549916768-4119b2e5f926"),
rating: 4.5,
reviews: 67,
inStock: true,
sku: "GSH-GTS-50",
unit: "50 strips",
},
{
id: "p44",
slug: "baby-lotion",
name: "Baby Soft Lotion",
brand: "TinyCare",
category: "baby",
description: "Hypoallergenic lotion for delicate baby skin.",
price: 260,
image: u("photo-1515488042361-ee00e0ddd4e4"),
rating: 4.8,
reviews: 143,
badge: "new",
inStock: true,
sku: "GSH-BBL-01",
unit: "200 ml",
},
{
id: "p45",
slug: "baby-wipes",
name: "Sensitive Baby Wipes",
brand: "TinyCare",
category: "baby",
description: "Fragrance-free wipes for sensitive skin.",
price: 95,
compareAt: 120,
image: u("photo-1544367567-0f2fcb009e0b"),
rating: 4.6,
reviews: 201,
badge: "sale",
inStock: true,
sku: "GSH-BBW-80",
unit: "80 wipes",
},
{
id: "p46",
slug: "diaper-rash-cream",
name: "Diaper Rash Cream",
brand: "TinyCare",
category: "baby",
description: "Soothing cream to protect against diaper irritation.",
price: 180,
image: u("photo-1492725764893-90b379c2b6e7"),
rating: 4.7,
reviews: 88,
inStock: true,
sku: "GSH-DRC-01",
unit: "100 g",
},
{
id: "p47",
slug: "first-aid-kit",
name: "Family First Aid Kit",
brand: "CareAid",
category: "first-aid",
description: "Compact kit with essentials for home and travel.",
price: 650,
compareAt: 780,
image: u("photo-1603398938378-e54eab446dde"),
rating: 4.8,
reviews: 124,
badge: "sale",
inStock: true,
sku: "GSH-FAK-01",
unit: "1 kit",
},
{
id: "p48",
slug: "antiseptic-solution",
name: "Antiseptic Solution",
brand: "CareAid",
category: "first-aid",
description: "Wound cleansing antiseptic for first aid use.",
price: 90,
image: u("photo-1584308666744-24d5c474f2ae"),
rating: 4.5,
reviews: 96,
inStock: true,
sku: "GSH-ANT-250",
unit: "250 ml",
},
{
id: "p49",
slug: "cotton-wool",
name: "Surgical Cotton Wool",
brand: "CareAid",
category: "first-aid",
description: "Absorbent cotton for wound care and cosmetics.",
price: 55,
image: u("photo-1576671081837-49000212a370"),
rating: 4.4,
reviews: 71,
inStock: true,
sku: "GSH-CTN-100",
unit: "100 g",
},
{
id: "p50",
slug: "ors-sachets",
name: "ORS Sachets",
brand: "HydraCare",
category: "medicines",
description: "Oral rehydration salts for dehydration support.",
price: 40,
image: u("photo-1585435557343-3b092031a831"),
rating: 4.7,
reviews: 158,
badge: "bestseller",
inStock: true,
sku: "GSH-ORS-10",
unit: "10 sachets",
},
{
id: "p51",
slug: "cough-syrup",
name: "Cough Syrup",
brand: "RespiCare",
category: "medicines",
description: "Soothing syrup for dry and productive coughs.",
price: 135,
image: u("photo-1587854692152-cbe660dbde88"),
rating: 4.4,
reviews: 109,
inStock: true,
sku: "GSH-CGH-100",
unit: "100 ml",
},
{
id: "p52",
slug: "antacid-chewable",
name: "Antacid Chewable",
brand: "GastroEase",
category: "medicines",
description: "Fast relief from heartburn and indigestion.",
price: 70,
image: u("photo-1471864190281-a93a3070b6de"),
rating: 4.5,
reviews: 134,
inStock: true,
sku: "GSH-ANTAC-01",
unit: "24 tablets",
},
{
id: "p53",
slug: "eye-drops-lubricating",
name: "Lubricating Eye Drops",
brand: "OptiClear",
category: "medicines",
description: "Relief for dry, irritated eyes.",
price: 190,
badge: "new",
image: u("photo-1576602976047-174e57a47881"),
rating: 4.6,
reviews: 62,
inStock: true,
sku: "GSH-EYE-01",
unit: "10 ml",
},
{
id: "p54",
slug: "protein-powder",
name: "Nutrition Protein Powder",
brand: "NutriStrong",
category: "vitamins",
description: "High-quality protein blend for recovery and wellness.",
price: 1250,
compareAt: 1400,
image: u("photo-1593095948071-474c5cc2989d"),
rating: 4.7,
reviews: 77,
badge: "sale",
inStock: true,
sku: "GSH-PRO-01",
unit: "1 kg",
},
{
id: "p55",
slug: "electrolyte-drink",
name: "Electrolyte Hydration Mix",
brand: "HydraCare",
category: "vitamins",
description: "Replenishes electrolytes after illness or exercise.",
price: 85,
image: u("photo-1628771065518-0d82f1938462"),
rating: 4.5,
reviews: 93,
inStock: true,
sku: "GSH-ELY-01",
unit: "10 sachets",
},
{
id: "p56",
slug: "pregnancy-test",
name: "Pregnancy Test Kit",
brand: "QuickCheck",
category: "devices",
description: "Rapid home pregnancy test with clear results.",
price: 95,
image: u("photo-1579154204601-01588f351e67"),
rating: 4.6,
reviews: 148,
inStock: true,
sku: "GSH-PTK-01",
unit: "1 kit",
},
{
id: "p57",
slug: "compression-socks",
name: "Compression Socks",
brand: "CircuFit",
category: "devices",
description: "Supportive socks for circulation and travel comfort.",
price: 340,
image: u("photo-1576091160550-2173dba999ef"),
rating: 4.3,
reviews: 45,
badge: "new",
inStock: true,
sku: "GSH-CSK-01",
unit: "1 pair",
},
{
id: "p58",
slug: "hot-water-bottle",
name: "Hot Water Bottle",
brand: "ComfortCare",
category: "first-aid",
description: "Classic hot water bottle for warmth and comfort.",
price: 220,
image: u("photo-1516549655169-df83a0774514"),
rating: 4.4,
reviews: 58,
inStock: true,
sku: "GSH-HWB-01",
unit: "1 unit",
},
{
id: "p59",
slug: "inhaler-spacer",
name: "Inhaler Spacer",
brand: "RespiCare",
category: "devices",
description: "Spacer chamber to improve inhaler medication delivery.",
price: 480,
image: u("photo-1584308666744-24d5c474f2ae"),
rating: 4.5,
reviews: 36,
inStock: true,
sku: "GSH-ISP-01",
unit: "1 unit",
},
{
id: "p60",
slug: "lip-balm-medicated",
name: "Medicated Lip Balm",
brand: "DermaSoft",
category: "personal-care",
description: "Soothing balm for dry and cracked lips.",
price: 65,
image: u("photo-1596755389378-c31d21fd1273"),
rating: 4.6,
reviews: 112,
inStock: true,
sku: "GSH-LIP-01",
unit: "1 stick",
},
];
export function getProductBySlug(slug: string) {
return products.find((p) => p.slug === slug);
}
export function getProductsByCategory(category: string) {
return products.filter((p) => p.category === category);
}
export function searchProducts(query: string) {
const q = query.toLowerCase().trim();
if (!q) return products;
return products.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.brand.toLowerCase().includes(q) ||
p.sku.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q),
);
}

314
lib/data/seed.ts Normal file
View File

@ -0,0 +1,314 @@
import type { CartItem } from "@/lib/stores/cartStore";
import type { EmergencyContact } from "@/lib/stores/emergencyContactStore";
import type { FamilyMember } from "@/lib/stores/familyStore";
import type { Order } from "@/lib/stores/ordersStore";
import type {
DoseLog,
Prescription,
Reminder,
} from "@/lib/stores/prescriptionStore";
import type { RefillSubscription } from "@/lib/stores/subscriptionStore";
import { products } from "@/lib/data/products";
function daysAgo(n: number) {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString();
}
function daysFromNow(n: number) {
const d = new Date();
d.setDate(d.getDate() + n);
return d.toISOString();
}
function hoursFromNow(n: number) {
const d = new Date();
d.setHours(d.getHours() + n);
return d.toISOString();
}
const panadol = products.find((p) => p.id === "p1")!;
const metformin = products.find((p) => p.id === "p7")!;
const vitaminC = products.find((p) => p.id === "p14")!;
const azitro = products.find((p) => p.id === "p9")!;
const gofen = products.find((p) => p.id === "p16")!;
function asCartItem(product: (typeof products)[0], quantity: number): CartItem {
return {
productId: product.id,
slug: product.slug,
name: product.name,
price: product.price,
image: product.image,
unit: product.unit,
quantity,
requiresPrescription: product.requiresPrescription,
};
}
export const seedPrescriptions: Prescription[] = [
{
id: "rx_demo_metformin",
patientName: "Kirukib Abebe",
memberId: null,
medicineName: "Metformin 500mg",
medicines: [
{ name: "Metformin 500mg", status: "ready" },
{ name: "Vitamin B12", status: "ready" },
],
imageUris: [
"https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?w=800&q=80",
],
daysSupply: 30,
lastFilledAt: daysAgo(25),
status: "filled",
notes: "Take with meals. Monitor fasting glucose weekly.",
createdAt: daysAgo(26),
},
{
id: "rx_demo_azitro",
patientName: "Kirukib Abebe",
memberId: null,
medicineName: "Azithromycin 500mg",
medicines: [
{ name: "Azithromycin 500mg", status: "ready" },
{ name: "Cough syrup", status: "review" },
],
imageUris: [
"https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=800&q=80",
],
daysSupply: 3,
lastFilledAt: daysAgo(1),
status: "approved",
notes: "3-day course. Finish even if symptoms improve.",
createdAt: daysAgo(2),
},
{
id: "rx_demo_pending",
patientName: "Sara Abebe",
memberId: "fam_demo_sara",
medicineName: "Amoxicillin 500mg",
medicines: [
{ name: "Amoxicillin 500mg", status: "review" },
{ name: "ORS sachets", status: "ready" },
],
imageUris: [
"https://images.unsplash.com/photo-1471864190281-a93a3070b6de?w=800&q=80",
],
daysSupply: 7,
lastFilledAt: null,
status: "under_review",
notes: "Pharmacist verifying dosage for pediatric use.",
createdAt: daysAgo(0),
},
];
export const seedReminders: Reminder[] = [
{
id: "rem_demo_metformin",
prescriptionId: "rx_demo_metformin",
medicineName: "Metformin 500mg",
times: ["08:00", "20:00"],
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
graceMinutes: 30,
escalateOnMiss: true,
memberId: null,
enabled: true,
},
{
id: "rem_demo_azitro",
prescriptionId: "rx_demo_azitro",
medicineName: "Azithromycin 500mg",
times: ["09:00"],
daysOfWeek: [1, 2, 3],
graceMinutes: 20,
escalateOnMiss: true,
memberId: null,
enabled: true,
},
];
export const seedDoseLogs: DoseLog[] = [
{
id: "dose_demo_1",
reminderId: "rem_demo_metformin",
scheduledAt: hoursFromNow(-2),
status: "taken",
confirmedAt: hoursFromNow(-1.8),
},
{
id: "dose_demo_2",
reminderId: "rem_demo_metformin",
scheduledAt: hoursFromNow(4),
status: "pending",
},
{
id: "dose_demo_3",
reminderId: "rem_demo_azitro",
scheduledAt: hoursFromNow(1),
status: "pending",
},
];
export const seedOrders: Order[] = [
{
id: "ord_demo_active",
createdAt: daysAgo(0),
items: [asCartItem(panadol, 2), asCartItem(vitaminC, 1)],
subtotal: panadol.price * 2 + vitaminC.price,
deliveryFee: 80,
discount: 0,
total: panadol.price * 2 + vitaminC.price + 80,
fulfillment: "delivery",
branchId: "bole",
areaLabel: "Bole, Addis Ababa",
addressLine: "Rwanda St, Building 12, Apt 4B · Near Edna Mall",
paymentMethod: "chapa",
status: "dispatched",
riderAssigned: true,
customerName: "Kirukib Abebe",
customerPhone: "+251911000000",
notes: "Leave with security if I'm out",
},
{
id: "ord_demo_pickup",
createdAt: daysAgo(3),
items: [asCartItem(metformin, 1)],
subtotal: metformin.price,
deliveryFee: 0,
discount: 0,
total: metformin.price,
fulfillment: "pickup",
branchId: "cmc",
areaLabel: "CMC Michael",
paymentMethod: "pay_at_branch",
status: "confirmed",
riderAssigned: false,
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",
createdAt: daysAgo(12),
items: [asCartItem(gofen, 1), asCartItem(azitro, 1)],
subtotal: gofen.price + azitro.price,
deliveryFee: 80,
discount: 32,
voucherCode: "GISHEN10",
total: gofen.price + azitro.price + 80 - 32,
fulfillment: "delivery",
branchId: "mexico",
areaLabel: "Mexico Square",
addressLine: "Gabon St, Yaltopia Tower, Floor 6",
paymentMethod: "telebirr",
status: "delivered",
riderAssigned: true,
customerName: "Kirukib Abebe",
customerPhone: "+251911000000",
},
];
export const seedFamily: FamilyMember[] = [
{
id: "fam_demo_sara",
name: "Sara Abebe",
relationship: "spouse",
phone: "+251911223344",
status: "accepted",
},
{
id: "fam_demo_yonas",
name: "Yonas Abebe",
relationship: "child",
phone: "+251922334455",
status: "accepted",
},
{
id: "fam_demo_pending",
name: "Hanna Bekele",
relationship: "parent",
phone: "+251933445566",
status: "pending",
},
];
export const seedSubscriptions: RefillSubscription[] = [
{
id: "sub_demo_metformin",
productId: "p7",
productName: "Metformin",
quantity: 1,
frequencyDays: 30,
fulfillment: "delivery",
paymentMethod: "chapa",
memberId: null,
status: "active",
nextFulfillmentAt: daysFromNow(5),
},
{
id: "sub_demo_vitc",
productId: "p14",
productName: "Nat C (Vitamin C)",
quantity: 2,
frequencyDays: 60,
fulfillment: "pickup",
paymentMethod: "telebirr",
memberId: null,
status: "paused",
nextFulfillmentAt: daysFromNow(20),
},
];
export const seedEmergencyContacts: EmergencyContact[] = [
{
id: "ec_demo_1",
name: "Sara Abebe",
phone: "+251911223344",
relationship: "Spouse",
},
{
id: "ec_demo_2",
name: "Dr. Michael Tadesse",
phone: "+251911000111",
relationship: "Family doctor",
},
];
export const seedFavoriteIds = ["p1", "p7", "p14", "p16", "p39"];
export const DEMO_USER = {
id: "u_demo_gishen",
name: "Kirukib Abebe",
phone: "+251911000000",
email: "kirukib@example.com",
points: 420,
tier: "silver" as const,
isGuest: false,
biometricEnabled: false,
avatarUri: null as string | null,
};

24
lib/i18n.ts Normal file
View File

@ -0,0 +1,24 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "@/locales/en/common.json";
import am from "@/locales/am/common.json";
import { useLangStore } from "@/lib/stores/langStore";
const initial = useLangStore.getState().language;
void i18n.use(initReactI18next).init({
compatibilityJSON: "v4",
resources: {
en: { translation: en },
am: { translation: am },
},
lng: initial,
fallbackLng: "en",
interpolation: { escapeValue: false },
});
useLangStore.subscribe((state) => {
void i18n.changeLanguage(state.language);
});
export default i18n;

175
lib/notifications.ts Normal file
View File

@ -0,0 +1,175 @@
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
export async function ensureNotificationPermissions() {
const settings = await Notifications.getPermissionsAsync();
if (settings.granted) return true;
const req = await Notifications.requestPermissionsAsync();
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;
secondsFromNow: number;
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: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: Math.max(5, opts.secondsFromNow),
repeats: false,
},
});
}
export async function scheduleEscalationCheck(opts: {
title: string;
body: string;
graceSeconds: number;
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,
seconds: Math.max(10, opts.graceSeconds),
repeats: false,
},
});
}
/**
* 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(() => {})
)
);
}
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,
};
}

53
lib/routes.ts Normal file
View File

@ -0,0 +1,53 @@
import type { Ionicons } from "@expo/vector-icons";
export const routes = {
welcome: "/(root)/auth/welcome",
otp: "/(root)/auth/otp",
language: "/(root)/auth/language",
home: "/(root)/(tabs)",
favorites: "/(root)/(tabs)/favorites",
cart: "/(root)/(tabs)/cart",
profile: "/(root)/(screens)/profile",
shop: "/(root)/(screens)/shop",
search: "/(root)/(screens)/search",
product: (id: string) => `/(root)/(screens)/product/${id}` as const,
branches: "/(root)/(screens)/branches",
checkout: "/(root)/(screens)/checkout",
rxScan: "/(root)/(screens)/prescription/scan",
rxReview: "/(root)/(screens)/prescription/review",
rxList: "/(root)/(tabs)/prescriptions",
rxDetail: (id: string) => `/(root)/(screens)/prescription/${id}` as const,
rxCalendar: (id: string) =>
`/(root)/(screens)/prescription/calendar/${id}` as const,
family: "/(root)/(screens)/family",
familyInvite: "/(root)/(screens)/family/invite",
subscriptions: "/(root)/(screens)/subscriptions",
profileSwitch: "/(root)/(screens)/profiles/switch",
profileJoin: "/(root)/(screens)/profiles/join",
emergencyContacts: "/(root)/(screens)/emergency-contacts",
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",
addresses: "/(root)/(screens)/addresses",
} as const;
export type Category = {
id: string;
labelKey: string;
icon: keyof typeof Ionicons.glyphMap;
};
export const categories: Category[] = [
{ id: "medicines", labelKey: "categories.medicines", icon: "medkit-outline" },
{ id: "vitamins", labelKey: "categories.vitamins", icon: "leaf-outline" },
{ id: "personal-care", labelKey: "categories.personalCare", icon: "sparkles-outline" },
{ id: "diabetic", labelKey: "categories.diabetic", icon: "pulse-outline" },
{ id: "devices", labelKey: "categories.devices", icon: "hardware-chip-outline" },
{ id: "baby", labelKey: "categories.baby", icon: "happy-outline" },
{ id: "first-aid", labelKey: "categories.firstAid", icon: "bandage-outline" },
{ id: "wellness", labelKey: "categories.wellness", icon: "heart-outline" },
];

103
lib/stores/addressStore.ts Normal file
View File

@ -0,0 +1,103 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type SavedAddress = {
id: string;
label: string;
area: string;
street: string;
landmark?: string;
notes?: string;
isDefault: boolean;
};
type AddressState = {
addresses: SavedAddress[];
add: (a: Omit<SavedAddress, "id" | "isDefault"> & { isDefault?: boolean }) => void;
update: (id: string, patch: Partial<SavedAddress>) => void;
remove: (id: string) => void;
setDefault: (id: string) => void;
defaultAddress: () => SavedAddress | null;
};
export const seedAddresses: SavedAddress[] = [
{
id: "addr_home",
label: "Home",
area: "Bole, Addis Ababa",
street: "Rwanda St, Building 12, Apt 4B",
landmark: "Near Edna Mall",
notes: "Call on arrival",
isDefault: true,
},
{
id: "addr_office",
label: "Office",
area: "Kazanchis, Addis Ababa",
street: "Gabon St, Yaltopia Tower, Floor 6",
landmark: "Opposite UNECA",
isDefault: false,
},
];
export const useAddressStore = create<AddressState>()(
persist(
(set, get) => ({
addresses: seedAddresses,
add: (a) => {
const id = `addr_${Date.now()}`;
const makeDefault = a.isDefault || get().addresses.length === 0;
set({
addresses: [
{
id,
label: a.label,
area: a.area,
street: a.street,
landmark: a.landmark,
notes: a.notes,
isDefault: makeDefault,
},
...get().addresses.map((x) =>
makeDefault ? { ...x, isDefault: false } : x
),
],
});
},
update: (id, patch) =>
set({
addresses: get().addresses.map((a) =>
a.id === id ? { ...a, ...patch } : a
),
}),
remove: (id) => {
const next = get().addresses.filter((a) => a.id !== id);
if (next.length && !next.some((a) => a.isDefault)) {
next[0] = { ...next[0], isDefault: true };
}
set({ addresses: next });
},
setDefault: (id) =>
set({
addresses: get().addresses.map((a) => ({
...a,
isDefault: a.id === id,
})),
}),
defaultAddress: () =>
get().addresses.find((a) => a.isDefault) ?? get().addresses[0] ?? null,
}),
{
name: "gishen-addresses",
storage: createJSONStorage(() => AsyncStorage),
version: 1,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<AddressState>;
return !state.addresses?.length
? { ...state, addresses: seedAddresses }
: state;
},
}
)
);

228
lib/stores/authStore.ts Normal file
View File

@ -0,0 +1,228 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { DEMO_USER } from "@/lib/data/seed";
export type AuthUser = {
id: string;
name: string;
phone: string;
email?: string;
points: number;
tier: "bronze" | "silver" | "gold";
isGuest: boolean;
biometricEnabled: boolean;
avatarUri?: string | null;
referralCode?: string;
activeVoucher?: string | null;
};
export type PointsEntry = {
id: string;
at: string;
label: string;
delta: number;
balance: number;
};
type AuthState = {
user: AuthUser | null;
hasOnboarded: boolean;
ledger: PointsEntry[];
setHasOnboarded: (v: boolean) => void;
signIn: (payload: Partial<AuthUser> & { phone: string; name: string }) => void;
signOut: () => void;
setBiometric: (enabled: boolean) => void;
setAvatar: (uri: string | null) => void;
updateProfile: (
patch: Partial<Pick<AuthUser, "name" | "email" | "phone">>
) => void;
addPoints: (pts: number, label?: string) => void;
redeemPoints: (pts: number) => boolean;
setActiveVoucher: (code: string | null) => void;
ensureDemoUser: () => void;
};
function tierFor(points: number): AuthUser["tier"] {
if (points >= 1000) return "gold";
if (points >= 300) return "silver";
return "bronze";
}
const seedLedger: PointsEntry[] = [
{
id: "pts_1",
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 20).toISOString(),
label: "Welcome bonus",
delta: 50,
balance: 50,
},
{
id: "pts_2",
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 12).toISOString(),
label: "Order reward",
delta: 28,
balance: 78,
},
{
id: "pts_3",
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(),
label: "Referral bonus",
delta: 100,
balance: 178,
},
{
id: "pts_4",
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
label: "Order reward",
delta: 242,
balance: 420,
},
];
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: {
...DEMO_USER,
email: DEMO_USER.email,
referralCode: "GISHEN-KIRU",
activeVoucher: null,
},
hasOnboarded: true,
ledger: seedLedger,
setHasOnboarded: (v) => set({ hasOnboarded: v }),
signIn: ({ phone, name, email, isGuest, avatarUri }) =>
set({
user: {
id: `u_${Date.now()}`,
name,
phone,
email,
points: isGuest ? 0 : 50,
tier: "bronze",
isGuest: !!isGuest,
biometricEnabled: false,
avatarUri: avatarUri ?? null,
referralCode: `GISHEN-${name.slice(0, 4).toUpperCase() || "USER"}`,
activeVoucher: null,
},
hasOnboarded: true,
ledger: isGuest
? []
: [
{
id: `pts_${Date.now()}`,
at: new Date().toISOString(),
label: "Welcome bonus",
delta: 50,
balance: 50,
},
],
}),
signOut: () => set({ user: null }),
setBiometric: (enabled) => {
const user = get().user;
if (!user) return;
set({ user: { ...user, biometricEnabled: enabled } });
},
setAvatar: (uri) => {
const user = get().user;
if (!user) return;
set({ user: { ...user, avatarUri: uri } });
},
updateProfile: (patch) => {
const user = get().user;
if (!user) return;
set({ user: { ...user, ...patch } });
},
addPoints: (pts, label = "Order reward") => {
const user = get().user;
if (!user || user.isGuest || pts === 0) return;
const points = user.points + pts;
const entry: PointsEntry = {
id: `pts_${Date.now()}`,
at: new Date().toISOString(),
label,
delta: pts,
balance: points,
};
set({
user: { ...user, points, tier: tierFor(points) },
ledger: [entry, ...get().ledger],
});
},
redeemPoints: (pts) => {
const user = get().user;
if (!user || user.isGuest || user.points < pts) return false;
const points = user.points - pts;
const entry: PointsEntry = {
id: `pts_${Date.now()}`,
at: new Date().toISOString(),
label: `Redeemed for voucher`,
delta: -pts,
balance: points,
};
set({
user: {
...user,
points,
tier: tierFor(points),
activeVoucher: "GISHEN10",
},
ledger: [entry, ...get().ledger],
});
return true;
},
setActiveVoucher: (code) => {
const user = get().user;
if (!user) return;
set({ user: { ...user, activeVoucher: code } });
},
ensureDemoUser: () => {
if (!get().user) {
set({
user: {
...DEMO_USER,
referralCode: "GISHEN-KIRU",
activeVoucher: null,
},
hasOnboarded: true,
ledger: seedLedger,
});
}
},
}),
{
name: "gishen-auth",
storage: createJSONStorage(() => AsyncStorage),
version: 3,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<AuthState>;
if (!state.user) {
return {
...state,
user: {
...DEMO_USER,
referralCode: "GISHEN-KIRU",
activeVoucher: null,
},
hasOnboarded: true,
ledger: seedLedger,
};
}
return {
...state,
user: {
...DEMO_USER,
...state.user,
avatarUri: state.user.avatarUri ?? null,
referralCode: state.user.referralCode ?? "GISHEN-KIRU",
activeVoucher: state.user.activeVoucher ?? null,
},
ledger: state.ledger?.length ? state.ledger : seedLedger,
};
},
}
)
);

80
lib/stores/cartStore.ts Normal file
View File

@ -0,0 +1,80 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { Product } from "@/lib/data/products";
export type CartItem = {
productId: string;
slug: string;
name: string;
price: number;
image: string;
unit: string;
quantity: number;
requiresPrescription?: boolean;
};
type CartState = {
items: CartItem[];
voucherCode: string | null;
addProduct: (product: Product, qty?: number) => void;
removeItem: (productId: string) => void;
setQuantity: (productId: string, quantity: number) => void;
clear: () => void;
setVoucher: (code: string | null) => void;
totalItems: () => number;
subtotal: () => number;
};
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
voucherCode: null,
addProduct: (product, qty = 1) => {
const items = [...get().items];
const idx = items.findIndex((i) => i.productId === product.id);
if (idx >= 0) {
items[idx] = {
...items[idx],
quantity: items[idx].quantity + qty,
};
} else {
items.push({
productId: product.id,
slug: product.slug,
name: product.name,
price: product.price,
image: product.image,
unit: product.unit,
quantity: qty,
requiresPrescription: product.requiresPrescription,
});
}
set({ items });
},
removeItem: (productId) =>
set({ items: get().items.filter((i) => i.productId !== productId) }),
setQuantity: (productId, quantity) => {
if (quantity <= 0) {
get().removeItem(productId);
return;
}
set({
items: get().items.map((i) =>
i.productId === productId ? { ...i, quantity } : i
),
});
},
clear: () => set({ items: [], voucherCode: null }),
setVoucher: (code) => set({ voucherCode: code }),
totalItems: () => get().items.reduce((n, i) => n + i.quantity, 0),
subtotal: () =>
get().items.reduce((n, i) => n + i.price * i.quantity, 0),
}),
{
name: "gishen-cart",
storage: createJSONStorage(() => AsyncStorage),
}
)
);

View File

@ -0,0 +1,54 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { seedEmergencyContacts } from "@/lib/data/seed";
export type EmergencyContact = {
id: string;
name: string;
phone: string;
relationship: string;
};
type EcState = {
contacts: EmergencyContact[];
graceMinutesDefault: number;
escalationEnabled: boolean;
lastEscalationAt?: string;
add: (c: Omit<EmergencyContact, "id">) => void;
remove: (id: string) => void;
setGrace: (m: number) => void;
setEscalationEnabled: (v: boolean) => void;
markEscalated: () => void;
};
export const useEmergencyContactStore = create<EcState>()(
persist(
(set, get) => ({
contacts: seedEmergencyContacts,
graceMinutesDefault: 30,
escalationEnabled: true,
add: (c) =>
set({
contacts: [{ ...c, id: `ec_${Date.now()}` }, ...get().contacts],
}),
remove: (id) =>
set({ contacts: get().contacts.filter((c) => c.id !== id) }),
setGrace: (m) => set({ graceMinutesDefault: m }),
setEscalationEnabled: (v) => set({ escalationEnabled: v }),
markEscalated: () =>
set({ lastEscalationAt: new Date().toISOString() }),
}),
{
name: "gishen-emergency",
storage: createJSONStorage(() => AsyncStorage),
version: 2,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<EcState>;
return !state.contacts?.length
? { ...state, contacts: seedEmergencyContacts }
: state;
},
}
)
);

67
lib/stores/familyStore.ts Normal file
View File

@ -0,0 +1,67 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { seedFamily } from "@/lib/data/seed";
export type FamilyMember = {
id: string;
name: string;
relationship: "spouse" | "child" | "parent" | "other";
phone?: string;
status: "pending" | "accepted";
};
type FamilyState = {
members: FamilyMember[];
activeMemberId: string | null;
inviteCode: string;
addMember: (m: Omit<FamilyMember, "id" | "status">) => void;
acceptInvite: (id: string) => void;
removeMember: (id: string) => void;
setActiveMember: (id: string | null) => void;
};
export const useFamilyStore = create<FamilyState>()(
persist(
(set, get) => ({
members: seedFamily,
activeMemberId: "fam_demo_sara",
inviteCode: "FAM-GISHEN",
addMember: (m) =>
set({
members: [
{
...m,
id: `fam_${Date.now()}`,
status: "pending",
},
...get().members,
],
}),
acceptInvite: (id) =>
set({
members: get().members.map((m) =>
m.id === id ? { ...m, status: "accepted" } : m
),
}),
removeMember: (id) =>
set({
members: get().members.filter((m) => m.id !== id),
activeMemberId:
get().activeMemberId === id ? null : get().activeMemberId,
}),
setActiveMember: (id) => set({ activeMemberId: id }),
}),
{
name: "gishen-family",
storage: createJSONStorage(() => AsyncStorage),
version: 2,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<FamilyState>;
return !state.members?.length
? { ...state, members: seedFamily, activeMemberId: "fam_demo_sara" }
: state;
},
}
)
);

View File

@ -0,0 +1,34 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { seedFavoriteIds } from "@/lib/data/seed";
type FavState = {
ids: string[];
toggle: (id: string) => void;
has: (id: string) => boolean;
};
export const useFavoritesStore = create<FavState>()(
persist(
(set, get) => ({
ids: seedFavoriteIds,
toggle: (id) => {
const ids = get().ids;
set({
ids: ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id],
});
},
has: (id) => get().ids.includes(id),
}),
{
name: "gishen-favorites",
storage: createJSONStorage(() => AsyncStorage),
version: 2,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<FavState>;
return !state.ids?.length ? { ...state, ids: seedFavoriteIds } : state;
},
}
)
);

23
lib/stores/langStore.ts Normal file
View File

@ -0,0 +1,23 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type Language = "en" | "am";
type LangState = {
language: Language;
setLanguage: (language: Language) => void;
};
export const useLangStore = create<LangState>()(
persist(
(set) => ({
language: "en",
setLanguage: (language) => set({ language }),
}),
{
name: "gishen-lang",
storage: createJSONStorage(() => AsyncStorage),
}
)
);

View File

@ -0,0 +1,59 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type Fulfillment = "delivery" | "pickup";
export type PaymentMethod =
| "chapa"
| "telebirr"
| "mpesa"
| "pay_at_branch";
type LocationState = {
fulfillment: Fulfillment;
areaLabel: string;
branchId: string | null;
lat: number | null;
lng: number | null;
paymentPreference: PaymentMethod;
setFulfillment: (f: Fulfillment) => void;
setAreaLabel: (label: string) => void;
setBranchId: (id: string | null) => void;
setCoords: (lat: number | null, lng: number | null) => void;
setPaymentPreference: (m: PaymentMethod) => void;
allowedPayments: () => PaymentMethod[];
};
export const useLocationStore = create<LocationState>()(
persist(
(set, get) => ({
fulfillment: "delivery",
areaLabel: "Addis Ababa",
branchId: null,
lat: null,
lng: null,
paymentPreference: "chapa",
setFulfillment: (fulfillment) => {
const next: Partial<LocationState> = { fulfillment };
if (fulfillment === "delivery" && get().paymentPreference === "pay_at_branch") {
next.paymentPreference = "chapa";
}
set(next);
},
setAreaLabel: (areaLabel) => set({ areaLabel }),
setBranchId: (branchId) => set({ branchId }),
setCoords: (lat, lng) => set({ lat, lng }),
setPaymentPreference: (paymentPreference) => set({ paymentPreference }),
allowedPayments: () => {
const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"];
return get().fulfillment === "pickup"
? [...base, "pay_at_branch"]
: base;
},
}),
{
name: "gishen-location",
storage: createJSONStorage(() => AsyncStorage),
}
)
);

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

187
lib/stores/ordersStore.ts Normal file
View File

@ -0,0 +1,187 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { seedOrders } from "@/lib/data/seed";
import type { CartItem } from "@/lib/stores/cartStore";
import type { Fulfillment, PaymentMethod } from "@/lib/stores/locationStore";
export type OrderStatus =
| "requested"
| "confirmed"
| "paid"
| "dispatched"
| "delivered"
| "completed"
| "cancelled";
export type Order = {
id: string;
createdAt: string;
items: CartItem[];
subtotal: number;
deliveryFee: number;
discount: number;
voucherCode?: string | null;
total: number;
fulfillment: Fulfillment;
branchId?: string | null;
areaLabel?: string;
addressId?: string | null;
addressLine?: string;
paymentMethod: PaymentMethod;
status: OrderStatus;
riderAssigned?: boolean;
corporateCovered?: number;
selfPay?: number;
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" }[];
};
type OrdersState = {
orders: Order[];
addOrder: (
order: Omit<Order, "id" | "createdAt" | "status" | "timeline" | "discount"> & {
discount?: number;
status?: OrderStatus;
}
) => Order;
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) {
const steps: Order["timeline"] = [
{ at: createdAt, label: "Order placed", status: "created" },
];
const add = (label: string, s: OrderStatus, hours: number) => {
const d = new Date(createdAt);
d.setHours(d.getHours() + hours);
steps!.push({ at: d.toISOString(), label, status: s });
};
if (["confirmed", "paid", "dispatched", "delivered", "completed"].includes(status)) {
add("Confirmed by pharmacy", "confirmed", 0.2);
}
if (["paid", "dispatched", "delivered", "completed"].includes(status) && status !== "confirmed") {
add("Payment received", "paid", 0.4);
}
if (["dispatched", "delivered", "completed"].includes(status)) {
add("Out for delivery / ready for pickup", "dispatched", 1);
}
if (["delivered", "completed"].includes(status)) {
add("Delivered / collected", "delivered", 3);
}
if (status === "cancelled") {
steps.push({
at: new Date().toISOString(),
label: "Order cancelled",
status: "cancelled",
});
}
return steps;
}
export const useOrdersStore = create<OrdersState>()(
persist(
(set, get) => ({
orders: seedOrders.map((o) => ({
...o,
discount: o.discount ?? 0,
customerName: o.customerName ?? "Kirukib Abebe",
customerPhone: o.customerPhone ?? "+251911000000",
notes: o.notes ?? "",
timeline: o.timeline ?? timelineFor(o.status, o.createdAt),
})),
addOrder: (payload) => {
const createdAt = new Date().toISOString();
const discount = payload.discount ?? 0;
const status =
payload.status ??
(payload.paymentMethod === "pay_at_branch" ? "confirmed" : "paid");
const order: Order = {
...payload,
discount,
id: `ord_${Date.now()}`,
createdAt,
status,
riderAssigned: false,
timeline: timelineFor(status, createdAt),
};
set({ orders: [order, ...get().orders] });
return order;
},
updateStatus: (id, status) =>
set({
orders: get().orders.map((o) => {
if (o.id !== id) return o;
const next = {
...o,
status,
riderAssigned:
status === "dispatched" || status === "delivered"
? true
: o.riderAssigned,
};
const stamp = new Date().toISOString();
const label =
status === "dispatched"
? "Rider assigned / ready"
: status === "delivered"
? "Delivered / collected"
: status === "cancelled"
? "Order cancelled"
: `Status: ${status}`;
return {
...next,
timeline: [...(o.timeline ?? []), { at: stamp, label, status }],
};
}),
}),
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: 4,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<OrdersState>;
if (!state.orders?.length) {
return {
...state,
orders: seedOrders.map((o) => ({
...o,
discount: o.discount ?? 0,
customerName: o.customerName ?? "Kirukib Abebe",
customerPhone: o.customerPhone ?? "+251911000000",
timeline: timelineFor(o.status, o.createdAt),
})),
};
}
return {
...state,
orders: state.orders.map((o) => ({
...o,
discount: o.discount ?? 0,
timeline: o.timeline ?? timelineFor(o.status, o.createdAt),
})),
};
},
}
)
);

View File

@ -0,0 +1,172 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
seedDoseLogs,
seedPrescriptions,
seedReminders,
} from "@/lib/data/seed";
export type RxStatus =
| "submitted"
| "under_review"
| "approved"
| "queried"
| "rejected"
| "filled";
export type LineStatus = "ready" | "review" | "unavailable";
export type Prescription = {
id: string;
patientName: string;
memberId?: string | null;
medicineName: string;
medicines: { name: string; status: LineStatus }[];
imageUris: string[];
daysSupply: number;
lastFilledAt: string | null;
status: RxStatus;
notes?: string;
createdAt: string;
};
export type Reminder = {
id: string;
prescriptionId: string;
medicineName: string;
times: string[];
daysOfWeek: number[];
graceMinutes: number;
escalateOnMiss: boolean;
memberId?: string | null;
enabled: boolean;
};
export type DoseLog = {
id: string;
reminderId: string;
scheduledAt: string;
status: "pending" | "taken" | "snoozed" | "missed_escalated";
confirmedAt?: string;
};
type RxState = {
prescriptions: Prescription[];
reminders: Reminder[];
doseLogs: DoseLog[];
draftImages: string[];
setDraftImages: (uris: string[]) => void;
addPrescription: (
p: Omit<Prescription, "id" | "createdAt" | "status">
) => Prescription;
updateRxStatus: (id: string, status: RxStatus) => void;
markRefilled: (id: string) => void;
removePrescription: (id: string) => void;
upsertReminder: (r: Omit<Reminder, "id"> & { id?: string }) => void;
removeReminder: (id: string) => void;
confirmDose: (logId: string) => void;
escalateDose: (logId: string) => void;
schedulePendingDose: (reminderId: string) => DoseLog;
};
export const usePrescriptionStore = create<RxState>()(
persist(
(set, get) => ({
prescriptions: seedPrescriptions,
reminders: seedReminders,
doseLogs: seedDoseLogs,
draftImages: [],
setDraftImages: (uris) => set({ draftImages: uris }),
addPrescription: (payload) => {
const rx: Prescription = {
...payload,
id: `rx_${Date.now()}`,
createdAt: new Date().toISOString(),
status: "submitted",
};
set({ prescriptions: [rx, ...get().prescriptions] });
return rx;
},
updateRxStatus: (id, status) =>
set({
prescriptions: get().prescriptions.map((p) =>
p.id === id ? { ...p, status } : p
),
}),
markRefilled: (id) =>
set({
prescriptions: get().prescriptions.map((p) =>
p.id === id
? { ...p, lastFilledAt: new Date().toISOString(), status: "filled" }
: p
),
}),
removePrescription: (id) =>
set({
prescriptions: get().prescriptions.filter((p) => p.id !== id),
reminders: get().reminders.filter((r) => r.prescriptionId !== id),
}),
upsertReminder: (r) => {
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) =>
set({ reminders: get().reminders.filter((r) => r.id !== id) }),
confirmDose: (logId) =>
set({
doseLogs: get().doseLogs.map((l) =>
l.id === logId
? {
...l,
status: "taken",
confirmedAt: new Date().toISOString(),
}
: l
),
}),
escalateDose: (logId) =>
set({
doseLogs: get().doseLogs.map((l) =>
l.id === logId ? { ...l, status: "missed_escalated" } : l
),
}),
schedulePendingDose: (reminderId) => {
const log: DoseLog = {
id: `dose_${Date.now()}`,
reminderId,
scheduledAt: new Date().toISOString(),
status: "pending",
};
set({ doseLogs: [log, ...get().doseLogs] });
return log;
},
}),
{
name: "gishen-prescriptions",
storage: createJSONStorage(() => AsyncStorage),
version: 2,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<RxState>;
const empty = !state.prescriptions?.length;
return empty
? {
...state,
prescriptions: seedPrescriptions,
reminders: seedReminders,
doseLogs: seedDoseLogs,
}
: state;
},
}
)
);

View File

@ -0,0 +1,109 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type ProfileMode = "personal" | "corporate";
export type OrgMembership = {
orgId: string;
orgName: string;
planName: string;
remainingAllowance: number;
monthlyCap: number;
};
type ProfileState = {
mode: ProfileMode;
memberships: OrgMembership[];
activeOrgId: string | null;
setMode: (mode: ProfileMode) => void;
setActiveOrg: (orgId: string | null) => void;
joinOrg: (code: string) => { ok: boolean; message: string };
activeMembership: () => OrgMembership | null;
quoteSplit: (subtotal: number) => { covered: number; selfPay: number };
consumeAllowance: (amount: number) => void;
};
const DEMO_ORGS: Record<string, OrgMembership> = {
"GISHEN-BANK": {
orgId: "org_bank",
orgName: "Demo Bank Ethiopia",
planName: "Staff",
remainingAllowance: 2500,
monthlyCap: 5000,
},
"GISHEN-NGO": {
orgId: "org_ngo",
orgName: "Demo NGO",
planName: "Standard",
remainingAllowance: 1200,
monthlyCap: 3000,
},
};
const seededMembership = { ...DEMO_ORGS["GISHEN-BANK"] };
export const useProfileModeStore = create<ProfileState>()(
persist(
(set, get) => ({
mode: "personal",
memberships: [seededMembership],
activeOrgId: null,
setMode: (mode) => set({ mode }),
setActiveOrg: (orgId) =>
set({ activeOrgId: orgId, mode: orgId ? "corporate" : "personal" }),
joinOrg: (code) => {
const key = code.trim().toUpperCase();
const org = DEMO_ORGS[key];
if (!org) return { ok: false, message: "Invalid join code" };
const memberships = get().memberships;
if (memberships.some((m) => m.orgId === org.orgId)) {
set({ activeOrgId: org.orgId, mode: "corporate" });
return { ok: true, message: "Already a member — switched to org" };
}
set({
memberships: [...memberships, { ...org }],
activeOrgId: org.orgId,
mode: "corporate",
});
return { ok: true, message: `Joined ${org.orgName}` };
},
activeMembership: () => {
const { activeOrgId, memberships, mode } = get();
if (mode !== "corporate" || !activeOrgId) return null;
return memberships.find((m) => m.orgId === activeOrgId) ?? null;
},
quoteSplit: (subtotal) => {
const m = get().activeMembership();
if (!m) return { covered: 0, selfPay: subtotal };
const covered = Math.min(subtotal, m.remainingAllowance);
return { covered, selfPay: Math.max(0, subtotal - covered) };
},
consumeAllowance: (amount) => {
const { activeOrgId, memberships } = get();
if (!activeOrgId || amount <= 0) return;
set({
memberships: memberships.map((m) =>
m.orgId === activeOrgId
? {
...m,
remainingAllowance: Math.max(0, m.remainingAllowance - amount),
}
: m
),
});
},
}),
{
name: "gishen-profile-mode",
storage: createJSONStorage(() => AsyncStorage),
version: 2,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<ProfileState>;
return !state.memberships?.length
? { ...state, memberships: [seededMembership] }
: state;
},
}
)
);

View File

@ -0,0 +1,91 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { seedSubscriptions } from "@/lib/data/seed";
import type { Fulfillment, PaymentMethod } from "@/lib/stores/locationStore";
export type SubscriptionStatus = "active" | "paused" | "cancelled";
export type RefillSubscription = {
id: string;
productId: string;
productName: string;
quantity: number;
frequencyDays: number;
fulfillment: Fulfillment;
paymentMethod: PaymentMethod;
memberId?: string | null;
status: SubscriptionStatus;
nextFulfillmentAt: string;
};
type SubState = {
items: RefillSubscription[];
add: (
s: Omit<RefillSubscription, "id" | "status" | "nextFulfillmentAt">
) => void;
pause: (id: string) => void;
resume: (id: string) => void;
cancel: (id: string) => void;
};
function nextDate(days: number) {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString();
}
export const useSubscriptionStore = create<SubState>()(
persist(
(set, get) => ({
items: seedSubscriptions,
add: (s) =>
set({
items: [
{
...s,
id: `sub_${Date.now()}`,
status: "active",
nextFulfillmentAt: nextDate(s.frequencyDays),
},
...get().items,
],
}),
pause: (id) =>
set({
items: get().items.map((i) =>
i.id === id ? { ...i, status: "paused" } : i
),
}),
resume: (id) =>
set({
items: get().items.map((i) =>
i.id === id
? {
...i,
status: "active",
nextFulfillmentAt: nextDate(i.frequencyDays),
}
: i
),
}),
cancel: (id) =>
set({
items: get().items.map((i) =>
i.id === id ? { ...i, status: "cancelled" } : i
),
}),
}),
{
name: "gishen-subscriptions",
storage: createJSONStorage(() => AsyncStorage),
version: 2,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<SubState>;
return !state.items?.length
? { ...state, items: seedSubscriptions }
: state;
},
}
)
);

168
lib/theme.ts Normal file
View File

@ -0,0 +1,168 @@
import { Platform } from "react-native";
/**
* Gishen design tokens.
*
* Brand lime is used as a *fill* with ink text (high contrast, distinctive).
* `brandText` is the accessible green reserved for green-on-white text.
*/
const palette = {
lime50: "#F6FAEC",
lime100: "#ECF4D8",
lime200: "#D9E9B2",
lime300: "#C3DB86",
lime400: "#A8C73A",
lime500: "#93B22E",
lime600: "#748E22",
lime700: "#55681B",
ink900: "#101410",
ink700: "#2C332B",
ink500: "#5B6157",
ink300: "#8A9188",
ink100: "#C3C8BE",
paper: "#F7F8F5",
white: "#FFFFFF",
hairline: "#E9ECE2",
wash: "#F1F3EC",
red500: "#D64545",
red50: "#FDF0F0",
amber500: "#E0870F",
amber50: "#FDF5E7",
blue500: "#2F6FED",
blue50: "#EEF3FE",
} as const;
export const colors = {
brand: palette.lime400,
brandDeep: palette.lime500,
brandText: palette.lime600,
brandSoft: palette.lime100,
brandWash: palette.lime50,
brandBorder: palette.lime200,
ink: palette.ink900,
inkDeep: palette.ink900,
inkSecondary: palette.ink500,
muted: palette.ink500,
subtle: palette.ink300,
disabled: palette.ink100,
page: palette.paper,
surface: palette.white,
surfaceAlt: palette.wash,
line: palette.hairline,
danger: palette.red500,
dangerSoft: palette.red50,
warn: palette.amber500,
warnSoft: palette.amber50,
accent: palette.amber500,
info: palette.blue500,
infoSoft: palette.blue50,
inStock: palette.lime600,
outOfStock: palette.red500,
star: "#F5A524",
} as const;
export const space = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
"2xl": 24,
"3xl": 32,
"4xl": 40,
} as const;
export const radii = {
xs: 8,
sm: 12,
md: 16,
lg: 20,
xl: 24,
card: 20,
pill: 999,
} as const;
/** Screen gutter used by every page so content lines up across the app. */
export const gutter = 20;
export const fonts = {
regular: "Jakarta_400Regular",
medium: "Jakarta_500Medium",
semibold: "Jakarta_600SemiBold",
bold: "Jakarta_700Bold",
extrabold: "Jakarta_800ExtraBold",
} as const;
export type TypeVariant =
| "display"
| "h1"
| "h2"
| "h3"
| "body"
| "bodyMedium"
| "bodySm"
| "label"
| "caption"
| "micro"
| "price"
| "priceLg";
export const typography: Record<
TypeVariant,
{ fontFamily: string; fontSize: number; lineHeight: number; letterSpacing?: number }
> = {
display: { fontFamily: fonts.extrabold, fontSize: 30, lineHeight: 36, letterSpacing: -0.6 },
h1: { fontFamily: fonts.extrabold, fontSize: 24, lineHeight: 30, letterSpacing: -0.4 },
h2: { fontFamily: fonts.bold, fontSize: 18, lineHeight: 24, letterSpacing: -0.2 },
h3: { fontFamily: fonts.semibold, fontSize: 15, lineHeight: 20, letterSpacing: -0.1 },
body: { fontFamily: fonts.regular, fontSize: 14, lineHeight: 21 },
bodyMedium: { fontFamily: fonts.medium, fontSize: 14, lineHeight: 20 },
bodySm: { fontFamily: fonts.regular, fontSize: 13, lineHeight: 19 },
label: { fontFamily: fonts.semibold, fontSize: 13, lineHeight: 17 },
caption: { fontFamily: fonts.medium, fontSize: 12, lineHeight: 16 },
micro: { fontFamily: fonts.semibold, fontSize: 11, lineHeight: 14, letterSpacing: 0.2 },
price: { fontFamily: fonts.bold, fontSize: 16, lineHeight: 21, letterSpacing: -0.2 },
priceLg: { fontFamily: fonts.extrabold, fontSize: 24, lineHeight: 29, letterSpacing: -0.5 },
};
type Shadow = {
shadowColor: string;
shadowOpacity: number;
shadowRadius: number;
shadowOffset: { width: number; height: number };
elevation: number;
};
const shadow = (opacity: number, radius: number, y: number, elevation: number): Shadow => ({
shadowColor: "#16240A",
shadowOpacity: Platform.OS === "android" ? opacity * 1.4 : opacity,
shadowRadius: radius,
shadowOffset: { width: 0, height: y },
elevation,
});
export const shadows = {
/** Resting cards. Nearly invisible, just lifts off the page. */
xs: shadow(0.04, 6, 2, 1),
sm: shadow(0.05, 10, 4, 2),
/** Sheets, floating tab bar, sticky bars. */
md: shadow(0.08, 18, 8, 8),
lg: shadow(0.12, 28, 14, 16),
} as const;
/** Card recipe reused across screens so every surface matches. */
export const card = {
backgroundColor: colors.surface,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.line,
} as const;
export const hitSlop = { top: 10, bottom: 10, left: 10, right: 10 } as const;

11
lib/utils.ts Normal file
View File

@ -0,0 +1,11 @@
export function formatETB(amount: number): string {
return `${amount.toLocaleString("en-ET")} ETB`;
}
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export const CALL_PHONE = "+251930099850";
export const WHATSAPP_NUMBER = "251930099850";
export const CHATWOOT_BASE = "https://chat.yaltopia.com";

86
locales/am/common.json Normal file
View File

@ -0,0 +1,86 @@
{
"common": {
"appName": "ጊሸን ፋርማሲ",
"tagline": "ጤናዎ ወደ በርዎ ይደርሳል",
"continue": "ቀጥል",
"getStarted": "ጀምር",
"save": "አስቀምጥ",
"cancel": "ሰርዝ",
"seeAll": "ሁሉንም ይመልከቱ",
"search": "እዚህ ይፈልጉ...",
"scan": "ቅኝት",
"home": "መነሻ",
"favorites": "ተወዳጆች",
"cart": "ጋሪ",
"profile": "መገለጫ",
"shop": "ሱቅ",
"branches": "ቅርንጫፎች",
"orders": "ትዕዛዞች",
"notifications": "ማሳወቂያዎች",
"loyalty": "ነጥቦች",
"about": "ስለ እኛ",
"contact": "አግኙን",
"language": "ቋንቋ",
"call": "ደውሉ",
"whatsapp": "ዋትስአፕ",
"chat": "ቀጥታ ውይይት",
"addProduct": "+ ምርት ጨምር",
"payNow": "አሁን ክፈል",
"checkout": "ክፍያ",
"delivery": "ማድረስ",
"pickup": "መውሰድ",
"total": "ድምር",
"payment": "ክፍያ",
"useVoucher": "+ ቫውቸር ተጠቀም",
"trustedTitle": "የታመነው",
"trustedSubtitle": "የመስመር ላይ ፋርማሲ",
"bestProducts": "ምርጥ ምርቶች",
"categories": "ምድቦች",
"personal": "ግል",
"corporate": "ድርጅት",
"taken": "ተወስዷል",
"snooze": "አራዝም",
"emergencyContacts": "የአደጋ ጊዜ እውቂያዎች",
"family": "ቤተሰብ",
"subscriptions": "ደንበኝነት ምዝገባዎች",
"prescriptions": "የሐኪም ትዕዛዞች",
"rxManage": "የሐኪም እንክብካቤ",
"switchProfile": "መገለጫ ቀይር",
"joinOrg": "ድርጅት ተቀላቀል",
"payAtBranch": "በቅርንጫፍ ክፈል",
"chapa": "ቻፓ",
"telebirr": "ቴሌብር",
"mpesa": "ኤም-ፔሳ",
"emptyCart": "ጋሪዎ ባዶ ነው",
"emptyFavorites": "ተወዳጆች የሉም",
"refillDue": "መሙላት ተቃርቧል",
"contactNotified": "የአደጋ ጊዜ እውቂያ ተነግሯል",
"guest": "እንደ እንግዳ ቀጥል",
"signIn": "ግባ",
"otpHint": "ወደ ስልክዎ የተላከውን ኮድ ያስገቡ",
"sendOtp": "ኮድ ላክ",
"verify": "አረጋግጥ",
"enableBiometric": "ባዮሜትሪክ ክፈት አንቃ",
"nearestBranch": "ቅርብ ቅርንጫፎች",
"useLocation": "አካባቢዬን ተጠቀም",
"addresses": "አድራሻዎች",
"rewards": "ሽልማቶች",
"referral": "የማጣቀሻ ኮድ",
"inviteFamily": "ቤተሰብ ጋብዝ",
"manageSubs": "ደንበኝነት አስተዳድር",
"scanRx": "የሐኪም ትዕዛዝ ቅኝት",
"calendarReminders": "የቀን መቁጠሪያ ማስታወሻዎች",
"graceMinutes": "የጥበቃ ደቂቃዎች",
"escalation": "መድሃኒት ካልተረጋገጠ የአደጋ ጊዜ እውቂያን አሳውቅ"
},
"categories": {
"medicines": "መድሃኒት",
"vitamins": "ቫይታሚን",
"personalCare": "ጤና",
"diabetic": "ስኳር",
"devices": "መሳሪያዎች",
"baby": "ሕፃን",
"firstAid": "የመጀመሪያ እርዳታ",
"wellness": "ጤናማ ሕይወት"
}
}

86
locales/en/common.json Normal file
View File

@ -0,0 +1,86 @@
{
"common": {
"appName": "Gishen Pharmacy",
"tagline": "Healthcare delivered to your doorstep",
"continue": "Continue",
"getStarted": "Get started",
"save": "Save",
"cancel": "Cancel",
"seeAll": "See all",
"search": "Search here...",
"scan": "Scan",
"home": "Home",
"favorites": "Favorites",
"cart": "Cart",
"profile": "Profile",
"shop": "Shop",
"branches": "Branches",
"orders": "Orders",
"notifications": "Notifications",
"loyalty": "Loyalty",
"about": "About",
"contact": "Contact",
"language": "Language",
"call": "Call",
"whatsapp": "WhatsApp",
"chat": "Live chat",
"addProduct": "+ Add product",
"payNow": "Pay Now",
"checkout": "Checkout",
"delivery": "Delivery",
"pickup": "Pickup",
"total": "Total",
"payment": "Payment",
"useVoucher": "+ Use voucher",
"trustedTitle": "Your Trusted",
"trustedSubtitle": "Online Pharmacy",
"bestProducts": "Best Products",
"categories": "Categories",
"personal": "Personal",
"corporate": "Corporate",
"taken": "Taken",
"snooze": "Snooze",
"emergencyContacts": "Emergency contacts",
"family": "Family",
"subscriptions": "Subscriptions",
"prescriptions": "Prescriptions",
"rxManage": "Rx Care",
"switchProfile": "Switch profile",
"joinOrg": "Join organisation",
"payAtBranch": "Pay at branch",
"chapa": "Chapa",
"telebirr": "Telebirr",
"mpesa": "M-Pesa",
"emptyCart": "Your cart is empty",
"emptyFavorites": "No favorites yet",
"refillDue": "Refill due soon",
"contactNotified": "Emergency contact notified",
"guest": "Continue as guest",
"signIn": "Sign in",
"otpHint": "Enter the code sent to your phone",
"sendOtp": "Send code",
"verify": "Verify",
"enableBiometric": "Enable biometric unlock",
"nearestBranch": "Nearest branches",
"useLocation": "Use my location",
"addresses": "Addresses",
"rewards": "Rewards",
"referral": "Referral code",
"inviteFamily": "Invite family",
"manageSubs": "Manage subscriptions",
"scanRx": "Scan prescription",
"calendarReminders": "Calendar reminders",
"graceMinutes": "Grace minutes",
"escalation": "Notify emergency contact if dose not confirmed"
},
"categories": {
"medicines": "Medicine",
"vitamins": "Vitamin",
"personalCare": "Health",
"diabetic": "Diabetic",
"devices": "Devices",
"baby": "Baby",
"firstAid": "First Aid",
"wellness": "Wellness"
}
}

6
metro.config.js Normal file
View File

@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./app/global.css" });

1
nativewind-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

10409
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "gishen-mob",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@expo-google-fonts/plus-jakarta-sans": "^0.4.2",
"@expo/vector-icons": "^14.1.0",
"@react-native-async-storage/async-storage": "2.1.2",
"expo": "~53.0.0",
"expo-camera": "~16.1.11",
"expo-constants": "~17.1.7",
"expo-font": "~13.3.1",
"expo-image-picker": "~16.1.4",
"expo-linking": "~7.1.7",
"expo-local-authentication": "~16.0.5",
"expo-location": "~18.1.6",
"expo-notifications": "~0.31.4",
"expo-router": "~5.1.6",
"expo-splash-screen": "~0.30.10",
"expo-status-bar": "~2.2.3",
"i18next": "^25.7.2",
"lucide-react-native": "^0.511.0",
"nativewind": "4.1.23",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^16.5.0",
"react-native": "0.79.6",
"react-native-gesture-handler": "~2.24.0",
"react-native-maps": "1.20.1",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-svg": "15.11.2",
"react-native-web": "^0.20.0",
"tailwindcss": "^3.4.17",
"zustand": "^5.0.3"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/react": "~19.0.10",
"typescript": "~5.8.3"
},
"private": true
}

40
tailwind.config.js Normal file
View File

@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
brand: "#A8C73A",
"brand-deep": "#93B22E",
"brand-text": "#748E22",
"brand-soft": "#ECF4D8",
"brand-wash": "#F6FAEC",
ink: "#101410",
"ink-secondary": "#5B6157",
muted: "#5B6157",
subtle: "#8A9188",
page: "#F7F8F5",
surface: "#FFFFFF",
"surface-alt": "#F1F3EC",
line: "#E9ECE2",
danger: "#D64545",
warn: "#E0870F",
accent: "#E0870F",
info: "#2F6FED",
},
borderRadius: {
card: "20px",
pill: "999px",
},
fontFamily: {
sans: ["Jakarta_400Regular"],
medium: ["Jakarta_500Medium"],
semibold: ["Jakarta_600SemiBold"],
bold: ["Jakarta_700Bold"],
extrabold: ["Jakarta_800ExtraBold"],
},
},
},
plugins: [],
};

Some files were not shown because too many files have changed in this diff Show More