Compare commits
No commits in common. "5391780b9b655f8a933b3cf7708ecf0084ae6504" and "03da99aca700cbd4bf5ec6434a9a5c3f5786048f" have entirely different histories.
5391780b9b
...
03da99aca7
23
.gitignore
vendored
23
.gitignore
vendored
|
|
@ -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
|
||||
31
README.md
31
README.md
|
|
@ -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
|
||||
73
app.json
73
app.json
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { FloatingTabBar } from "@/components/ui/FloatingTabBar";
|
||||
|
||||
export default function TabsLayout() {
|
||||
return <FloatingTabBar />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -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 |
BIN
assets/icon.png
BIN
assets/icon.png
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 |
|
|
@ -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",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { default } from "react-native-maps";
|
||||
export { Marker } from "react-native-maps";
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.",
|
||||
};
|
||||
|
|
@ -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)) };
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
314
lib/data/seed.ts
314
lib/data/seed.ts
|
|
@ -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,
|
||||
};
|
||||
24
lib/i18n.ts
24
lib/i18n.ts
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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" },
|
||||
];
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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),
|
||||
})),
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
168
lib/theme.ts
168
lib/theme.ts
|
|
@ -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;
|
||||
11
lib/utils.ts
11
lib/utils.ts
|
|
@ -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";
|
||||
|
|
@ -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": "ጤናማ ሕይወት"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
1
nativewind-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="nativewind/types" />
|
||||
10409
package-lock.json
generated
10409
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Reference in New Issue
Block a user