Compare commits

..

No commits in common. "5391780b9b655f8a933b3cf7708ecf0084ae6504" and "03da99aca700cbd4bf5ec6434a9a5c3f5786048f" have entirely different histories.

101 changed files with 0 additions and 22905 deletions

23
.gitignore vendored
View File

@ -1,23 +0,0 @@
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

@ -1,31 +0,0 @@
# 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

View File

@ -1,73 +0,0 @@
{
"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

@ -1,13 +0,0 @@
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

@ -1,96 +0,0 @@
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

@ -1,136 +0,0 @@
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

@ -1,146 +0,0 @@
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

@ -1,555 +0,0 @@
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

@ -1,143 +0,0 @@
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

@ -1,143 +0,0 @@
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

@ -1,7 +0,0 @@
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

@ -1,85 +0,0 @@
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

@ -1,167 +0,0 @@
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

@ -1,159 +0,0 @@
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

@ -1,268 +0,0 @@
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

@ -1,155 +0,0 @@
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

@ -1,376 +0,0 @@
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

@ -1,283 +0,0 @@
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

@ -1,7 +0,0 @@
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

@ -1,222 +0,0 @@
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

@ -1,196 +0,0 @@
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

@ -1,444 +0,0 @@
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

@ -1,304 +0,0 @@
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

@ -1,60 +0,0 @@
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

@ -1,134 +0,0 @@
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

@ -1,111 +0,0 @@
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

@ -1,91 +0,0 @@
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

@ -1,255 +0,0 @@
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

@ -1,122 +0,0 @@
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

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

View File

@ -1,182 +0,0 @@
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

@ -1,55 +0,0 @@
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>
);
}

View File

@ -1,207 +0,0 @@
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

@ -1,398 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Image, Pressable, Share, ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card, Section } from "@/components/ui/Card";
import { DoseReminderPanel } from "@/components/ui/DoseReminderPanel";
import { EmptyState } from "@/components/ui/EmptyState";
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
import { TabTopBar } from "@/components/ui/TabTopBar";
import { Text } from "@/components/ui/Text";
import { routes } from "@/lib/routes";
import { useFamilyStore } from "@/lib/stores/familyStore";
import {
usePrescriptionStore,
type Prescription,
type Reminder,
} from "@/lib/stores/prescriptionStore";
import { colors, gutter } from "@/lib/theme";
const statusTone = {
submitted: "neutral",
under_review: "warn",
approved: "brand",
queried: "info",
rejected: "danger",
filled: "brand",
} as const;
function refillDays(rx: Prescription) {
if (!rx.lastFilledAt) return null;
const due = new Date(rx.lastFilledAt);
due.setDate(due.getDate() + rx.daysSupply);
return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
function RxCareCard({
rx,
reminder,
expanded,
onToggleExpand,
}: {
rx: Prescription;
reminder?: Reminder;
expanded: boolean;
onToggleExpand: () => void;
}) {
const dueIn = refillDays(rx);
const thumb = rx.imageUris[0];
return (
<Card elevated style={{ padding: 0, overflow: "hidden" }}>
<Pressable
onPress={() => router.push(routes.rxDetail(rx.id))}
style={{ padding: 14 }}
>
<View style={{ flexDirection: "row", gap: 12 }}>
<View
style={{
width: 64,
height: 64,
borderRadius: 16,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
{thumb ? (
<Image source={{ uri: thumb }} style={{ width: 64, height: 64 }} />
) : (
<Ionicons name="medkit" size={26} color={colors.brandText} />
)}
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
<Text variant="h3" style={{ flex: 1 }} numberOfLines={2}>
{rx.medicineName}
</Text>
<Badge label={rx.status.replace("_", " ")} tone={statusTone[rx.status]} />
</View>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
{rx.patientName} · {rx.medicines.length} medicine
{rx.medicines.length === 1 ? "" : "s"} · {rx.daysSupply}d supply
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
{reminder?.enabled ? (
<Badge
label={`Reminders ${reminder.times.join(" · ")}`}
tone="brand"
icon="alarm-outline"
/>
) : (
<Badge label="No dose reminder" tone="warn" icon="alarm-outline" />
)}
{dueIn !== null ? (
<Badge
label={
dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn}d`
}
tone={dueIn <= 3 ? "warn" : "neutral"}
/>
) : (
<Badge label="Awaiting fill" tone="info" />
)}
</View>
</View>
</View>
</Pressable>
<View
style={{
flexDirection: "row",
borderTopWidth: 1,
borderTopColor: colors.line,
}}
>
<Pressable
onPress={onToggleExpand}
style={{
flex: 1,
paddingVertical: 12,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 6,
}}
>
<Ionicons
name={expanded ? "chevron-up" : "notifications-outline"}
size={16}
color={colors.brandText}
/>
<Text variant="label" color={colors.brandText}>
{expanded ? "Hide setup" : reminder?.enabled ? "Edit reminder" : "Set reminder"}
</Text>
</Pressable>
<View style={{ width: 1, backgroundColor: colors.line }} />
<Pressable
onPress={() => router.push(routes.rxCalendar(rx.id))}
style={{
flex: 1,
paddingVertical: 12,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 6,
}}
>
<Ionicons name="calendar-outline" size={16} color={colors.brandText} />
<Text variant="label" color={colors.brandText}>
Calendar
</Text>
</Pressable>
</View>
{expanded ? (
<View style={{ paddingHorizontal: 14, paddingBottom: 14 }}>
<DoseReminderPanel rx={rx} reminder={reminder} compact />
</View>
) : null}
</Card>
);
}
export default function PrescriptionsTabScreen() {
const { t } = useTranslation();
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
const reminders = usePrescriptionStore((s) => s.reminders);
const members = useFamilyStore((s) => s.members);
const inviteCode = useFamilyStore((s) => s.inviteCode);
const acceptInvite = useFamilyStore((s) => s.acceptInvite);
const removeMember = useFamilyStore((s) => s.removeMember);
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
const [expandedId, setExpandedId] = useState<string | null>(null);
const reminderByRx = useMemo(() => {
const map = new Map<string, Reminder>();
reminders.forEach((r) => map.set(r.prescriptionId, r));
return map;
}, [reminders]);
const activeReminderCount = reminders.filter((r) => r.enabled).length;
const needingSetup = prescriptions.filter((p) => !reminderByRx.get(p.id)?.enabled).length;
return (
<Screen>
<TabTopBar subtitle={t("common.prescriptions")} />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingBottom: TAB_BAR_INSET + 24,
paddingHorizontal: gutter,
gap: 14,
}}
>
<View>
<Text variant="h1">{t("common.prescriptions")}</Text>
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
Manage Rx, dose calendars, and family care shares
</Text>
</View>
<View style={{ flexDirection: "row", gap: 10 }}>
<Card elevated={false} tone="brand" style={{ flex: 1 }}>
<Text variant="caption" color={colors.muted}>
Active reminders
</Text>
<Text variant="h1" style={{ marginTop: 2 }}>
{activeReminderCount}
</Text>
</Card>
<Card elevated={false} style={{ flex: 1 }}>
<Text variant="caption" color={colors.muted}>
Need setup
</Text>
<Text variant="h1" style={{ marginTop: 2 }}>
{needingSetup}
</Text>
</Card>
</View>
<Button
title={t("common.scanRx")}
icon="scan-outline"
onPress={() => router.push(routes.rxScan)}
/>
<Section title="Your prescriptions">
{prescriptions.length === 0 ? (
<EmptyState
compact
icon="document-text-outline"
title="No prescriptions yet"
body="Scan or upload a prescription to start reminders."
actionTitle="Scan prescription"
onAction={() => router.push(routes.rxScan)}
/>
) : (
<View style={{ gap: 12 }}>
{prescriptions.map((rx) => (
<RxCareCard
key={rx.id}
rx={rx}
reminder={reminderByRx.get(rx.id)}
expanded={expandedId === rx.id}
onToggleExpand={() =>
setExpandedId((cur) => (cur === rx.id ? null : rx.id))
}
/>
))}
</View>
)}
</Section>
<Section title={t("common.family")}>
<Card tone="brand" elevated={false}>
<View style={{ flexDirection: "row", gap: 12, alignItems: "center" }}>
<View
style={{
width: 48,
height: 48,
borderRadius: 14,
backgroundColor: colors.surface,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: colors.brandBorder,
}}
>
<Ionicons name="people" size={22} color={colors.brandText} />
</View>
<View style={{ flex: 1 }}>
<Text variant="caption" color={colors.muted}>
Family invite code
</Text>
<Text variant="h2" style={{ marginTop: 2, letterSpacing: 1 }}>
{inviteCode}
</Text>
</View>
</View>
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 10 }}>
Share care so family can help with dose reminders and refills.
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
<Button
title="Share code"
size="md"
fullWidth={false}
variant="outline"
icon="share-outline"
onPress={() =>
Share.share({
message: `Join my Gishen family care group with code ${inviteCode}`,
})
}
/>
<Button
title={t("common.inviteFamily")}
size="md"
fullWidth={false}
icon="person-add-outline"
onPress={() => router.push(routes.familyInvite)}
/>
</View>
</Card>
{members.length === 0 ? (
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
No family members yet. Invite someone to share Rx care.
</Text>
) : (
<View style={{ gap: 10, marginTop: 4 }}>
{members.map((m) => {
const active = activeMemberId === m.id;
return (
<Card
key={m.id}
onPress={() => setActiveMember(active ? null : m.id)}
style={{
borderColor: active ? colors.brand : colors.line,
borderWidth: active ? 1.5 : 1,
}}
>
<View style={{ flexDirection: "row", gap: 12, alignItems: "center" }}>
<View
style={{
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: colors.brandSoft,
alignItems: "center",
justifyContent: "center",
}}
>
<Text variant="label" color={colors.brandText}>
{m.name.slice(0, 1).toUpperCase()}
</Text>
</View>
<View style={{ flex: 1 }}>
<Text variant="h3">{m.name}</Text>
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
{m.relationship}
{m.phone ? ` · ${m.phone}` : ""}
{active ? " · Active for Rx" : ""}
</Text>
</View>
<Badge
label={m.status}
tone={m.status === "accepted" ? "brand" : "warn"}
/>
</View>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
marginTop: 12,
}}
>
{m.status === "pending" ? (
<Button
title="Accept"
size="sm"
fullWidth={false}
onPress={() => acceptInvite(m.id)}
/>
) : null}
<Button
title="Remove"
size="sm"
variant="ghost"
fullWidth={false}
onPress={() =>
Alert.alert("Remove member?", m.name, [
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: () => removeMember(m.id),
},
])
}
/>
</View>
</Card>
);
})}
</View>
)}
</Section>
</ScrollView>
</Screen>
);
}

View File

@ -1,13 +0,0 @@
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

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

View File

@ -1,58 +0,0 @@
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>
);
}

View File

@ -1,135 +0,0 @@
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

@ -1,71 +0,0 @@
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>
);
}

View File

@ -1,49 +0,0 @@
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>
);
}

View File

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

View File

@ -1,10 +0,0 @@
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)" />;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

@ -1,16 +0,0 @@
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

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

View File

@ -1,62 +0,0 @@
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 },
});

View File

@ -1,60 +0,0 @@
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>
);
}

View File

@ -1,138 +0,0 @@
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>
);
}

View File

@ -1,208 +0,0 @@
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>;
}

View File

@ -1,221 +0,0 @@
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

@ -1,109 +0,0 @@
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>
);
}

View File

@ -1,138 +0,0 @@
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

@ -1,202 +0,0 @@
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

@ -1,61 +0,0 @@
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>
);
}

View File

@ -1,232 +0,0 @@
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

@ -1,143 +0,0 @@
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>
);
}

View File

@ -1,116 +0,0 @@
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

@ -1,262 +0,0 @@
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

@ -1,83 +0,0 @@
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>
);
}

View File

@ -1,63 +0,0 @@
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

@ -1,119 +0,0 @@
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

@ -1,62 +0,0 @@
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>
);
}

View File

@ -1,103 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar } from "@/components/ui/Avatar";
import { Text } from "@/components/ui/Text";
import { routes } from "@/lib/routes";
import { useAuthStore } from "@/lib/stores/authStore";
import { useNotificationStore } from "@/lib/stores/notificationStore";
import { colors, gutter } from "@/lib/theme";
function greetingForNow() {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
}
/** Shared top bar for all bottom-tab screens (avatar → profile, bell → notifications). */
export function TabTopBar({
subtitle,
}: {
/** Optional line under the user name, e.g. the current tab context */
subtitle?: string;
}) {
const insets = useSafeAreaInsets();
const user = useAuthStore((s) => s.user);
const unread = useNotificationStore((s) => s.unreadCount());
return (
<View
style={{
paddingTop: insets.top + 10,
paddingHorizontal: gutter,
paddingBottom: 10,
backgroundColor: colors.page,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Pressable
onPress={() => router.push(routes.profile)}
style={{ flexDirection: "row", alignItems: "center", gap: 12, flex: 1 }}
accessibilityRole="button"
accessibilityLabel="Open profile"
>
<Avatar name={user?.name} uri={user?.avatarUri} size={46} />
<View style={{ flex: 1 }}>
<Text variant="caption" color={colors.subtle}>
{subtitle || greetingForNow()}
</Text>
<Text variant="h3" numberOfLines={1}>
{user?.name?.split(" ")[0] || "Guest"}
</Text>
</View>
</Pressable>
<Pressable
onPress={() => router.push(routes.notifications)}
accessibilityRole="button"
accessibilityLabel="Notifications"
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.line,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="notifications-outline" size={20} color={colors.ink} />
{unread > 0 ? (
<View
style={{
position: "absolute",
top: 8,
right: 9,
minWidth: 16,
height: 16,
borderRadius: 8,
backgroundColor: colors.danger,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 3,
}}
>
<Text variant="micro" color={colors.surface}>
{unread > 9 ? "9+" : unread}
</Text>
</View>
) : null}
</Pressable>
</View>
</View>
);
}

View File

@ -1,27 +0,0 @@
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}
/>
);
}

View File

@ -1,135 +0,0 @@
# 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.

View File

@ -1,34 +0,0 @@
/** 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>;
}

View File

@ -1,31 +0,0 @@
/** 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));
}

View File

@ -1,142 +0,0 @@
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);
}

View File

@ -1,38 +0,0 @@
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.",
};

View File

@ -1,322 +0,0 @@
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)) };
});
}

View File

@ -1,997 +0,0 @@
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),
);
}

View File

@ -1,314 +0,0 @@
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,
};

View File

@ -1,24 +0,0 @@
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;

View File

@ -1,175 +0,0 @@
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,
};
}

View File

@ -1,53 +0,0 @@
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" },
];

View File

@ -1,103 +0,0 @@
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;
},
}
)
);

View File

@ -1,228 +0,0 @@
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,
};
},
}
)
);

View File

@ -1,80 +0,0 @@
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

@ -1,54 +0,0 @@
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;
},
}
)
);

View File

@ -1,67 +0,0 @@
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

@ -1,34 +0,0 @@
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;
},
}
)
);

View File

@ -1,23 +0,0 @@
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

@ -1,59 +0,0 @@
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

@ -1,141 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type NotificationKind =
| "order"
| "rx"
| "dose"
| "loyalty"
| "promo"
| "system";
export type AppNotification = {
id: string;
kind: NotificationKind;
title: string;
body: string;
createdAt: string;
read: boolean;
/** Deep-link target inside the app */
href?: string | null;
};
type NotificationState = {
items: AppNotification[];
unreadCount: () => number;
markRead: (id: string) => void;
markAllRead: () => void;
remove: (id: string) => void;
push: (
n: Omit<AppNotification, "id" | "createdAt" | "read"> & {
id?: string;
createdAt?: string;
read?: boolean;
}
) => AppNotification;
};
function hoursAgo(n: number) {
const d = new Date();
d.setHours(d.getHours() - n);
return d.toISOString();
}
export const seedNotifications: AppNotification[] = [
{
id: "ntf_dose_1",
kind: "dose",
title: "Time for Metformin 500mg",
body: "Confirm your 20:00 dose or snooze for 10 minutes.",
createdAt: hoursAgo(1),
read: false,
href: "/(root)/(screens)/prescription/calendar/rx_demo_metformin",
},
{
id: "ntf_rx_1",
kind: "rx",
title: "Prescription under review",
body: "A pharmacist is checking your Azithromycin Rx. We’ll notify you when it’s ready.",
createdAt: hoursAgo(5),
read: false,
href: "/(root)/(screens)/prescription/rx_demo_azitro",
},
{
id: "ntf_order_1",
kind: "order",
title: "Rider is on the way",
body: "Your Bole delivery is out for delivery. Track live progress anytime.",
createdAt: hoursAgo(8),
read: false,
href: "/(root)/(screens)/tracking/ord_demo_active",
},
{
id: "ntf_loyalty_1",
kind: "loyalty",
title: "120 points ready to redeem",
body: "Redeem 100 pts for voucher GISHEN10 at checkout.",
createdAt: hoursAgo(26),
read: true,
href: "/(root)/(screens)/loyalty",
},
{
id: "ntf_promo_1",
kind: "promo",
title: "Weekend wellness picks",
body: "Save on vitamins and personal care this week at any Gishen branch.",
createdAt: hoursAgo(40),
read: true,
href: "/(root)/(screens)/shop",
},
{
id: "ntf_system_1",
kind: "system",
title: "Guest pickup tip",
body: "Shopping as a guest? Use your quick pickup code at any branch counter.",
createdAt: hoursAgo(72),
read: true,
href: null,
},
];
export const useNotificationStore = create<NotificationState>()(
persist(
(set, get) => ({
items: seedNotifications,
unreadCount: () => get().items.filter((n) => !n.read).length,
markRead: (id) =>
set({
items: get().items.map((n) => (n.id === id ? { ...n, read: true } : n)),
}),
markAllRead: () =>
set({ items: get().items.map((n) => ({ ...n, read: true })) }),
remove: (id) => set({ items: get().items.filter((n) => n.id !== id) }),
push: (payload) => {
const item: AppNotification = {
id: payload.id ?? `ntf_${Date.now()}`,
createdAt: payload.createdAt ?? new Date().toISOString(),
read: payload.read ?? false,
kind: payload.kind,
title: payload.title,
body: payload.body,
href: payload.href ?? null,
};
set({ items: [item, ...get().items] });
return item;
},
}),
{
name: "gishen-notifications",
storage: createJSONStorage(() => AsyncStorage),
version: 1,
migrate: (persisted: unknown) => {
const state = (persisted ?? {}) as Partial<NotificationState>;
if (!state.items?.length) {
return { ...state, items: seedNotifications };
}
return state;
},
}
)
);

View File

@ -1,187 +0,0 @@
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

@ -1,172 +0,0 @@
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

@ -1,109 +0,0 @@
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

@ -1,91 +0,0 @@
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;
},
}
)
);

View File

@ -1,168 +0,0 @@
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;

View File

@ -1,11 +0,0 @@
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";

View File

@ -1,86 +0,0 @@
{
"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": "ጤናማ ሕይወት"
}
}

View File

@ -1,86 +0,0 @@
{
"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"
}
}

View File

@ -1,6 +0,0 @@
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
View File

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

10409
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +0,0 @@
{
"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
}

View File

@ -1,40 +0,0 @@
/** @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