Add Gishen mobile app with complete demo shopping and care flows.
Ship Expo Router screens, seeded stores, and polished UX so auth, shop, checkout, prescriptions, loyalty, family, and account processes work end-to-end offline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
03da99aca7
commit
7310a349c5
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
.expo-export-check/
|
||||||
|
.expo-export-ios/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
31
README.md
31
README.md
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Gishen Pharmacy — Mobile
|
||||||
|
|
||||||
|
React Native (Expo) customer app for Gishen Pharmacy.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Expo SDK 53 + expo-router
|
||||||
|
- NativeWind / Tailwind
|
||||||
|
- Zustand + AsyncStorage
|
||||||
|
- i18next (`en` / `am`)
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brand
|
||||||
|
|
||||||
|
Colors and logo from Gishen-Ecom (`#A8C73A` / `#141414`).
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Living contract: [`docs/BACKEND_SPEC.md`](docs/BACKEND_SPEC.md) — update whenever features change.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
- OTP: any 4+ digits (hint `1234`)
|
||||||
|
- Org join codes: `GISHEN-BANK`, `GISHEN-NGO`
|
||||||
|
- Payments: Chapa / Telebirr / M-Pesa; pay-at-branch only for pickup
|
||||||
73
app.json
Normal file
73
app.json
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Gishen Pharmacy",
|
||||||
|
"slug": "gishen-mob",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"scheme": "gishen",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#F6F7F2"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.gishen.pharmacy",
|
||||||
|
"infoPlist": {
|
||||||
|
"NSCameraUsageDescription": "Gishen needs camera access to scan prescriptions.",
|
||||||
|
"NSPhotoLibraryUsageDescription": "Gishen needs photo access to upload prescriptions.",
|
||||||
|
"NSLocationWhenInUseUsageDescription": "Gishen uses your location to find the nearest branch.",
|
||||||
|
"NSFaceIDUsageDescription": "Gishen uses biometrics to unlock the app securely.",
|
||||||
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#F6F7F2"
|
||||||
|
},
|
||||||
|
"package": "com.gishen.pharmacy",
|
||||||
|
"permissions": [
|
||||||
|
"CAMERA",
|
||||||
|
"ACCESS_FINE_LOCATION",
|
||||||
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
"VIBRATE"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-font",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow Gishen to scan prescriptions."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-location",
|
||||||
|
{
|
||||||
|
"locationWhenInUsePermission": "Allow Gishen to find nearby branches."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"color": "#A8C73A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-local-authentication"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/(root)/(screens)/_layout.tsx
Normal file
13
app/(root)/(screens)/_layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { colors } from "@/lib/theme";
|
||||||
|
|
||||||
|
export default function ScreensLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.page },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
app/(root)/(screens)/about.tsx
Normal file
96
app/(root)/(screens)/about.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { Image, Linking, ScrollView, View } from "react-native";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card, InfoRow } from "@/components/ui/Card";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { brand } from "@/lib/data/brand";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
export default function AboutScreen() {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title="About" subtitle={brand.tagline} />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/logo/gishen-logo.png")}
|
||||||
|
style={{ width: 64, height: 64, resizeMode: "contain" }}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h2">{brand.name}</Text>
|
||||||
|
<Text variant="caption" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
Est. {brand.founded} · {brand.branchCount} branches
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 14 }}>
|
||||||
|
<Badge label={`${brand.institutions}+ institutions`} tone="brand" />
|
||||||
|
<Badge label={`${brand.googleRating}★ Google`} tone="neutral" />
|
||||||
|
<Badge label={`${brand.weeklyCustomers}/wk customers`} tone="info" />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3">Our story</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8, lineHeight: 22 }}>
|
||||||
|
{brand.story}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3">Purpose</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8, lineHeight: 22 }}>
|
||||||
|
{brand.purpose}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3">Vision</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8, lineHeight: 22 }}>
|
||||||
|
{brand.vision}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3">Mission</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8, lineHeight: 22 }}>
|
||||||
|
{brand.mission}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padded={false}>
|
||||||
|
<InfoRow label="Phone" value={brand.phoneDisplay} />
|
||||||
|
<InfoRow label="Email" value={brand.email} />
|
||||||
|
<InfoRow label="Orders" value={brand.orderEmail} />
|
||||||
|
<InfoRow label="Website" value={brand.website.replace("https://", "")} last />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Call pharmacy"
|
||||||
|
icon="call-outline"
|
||||||
|
onPress={() => Linking.openURL(`tel:${brand.phone}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="WhatsApp"
|
||||||
|
variant="outline"
|
||||||
|
icon="logo-whatsapp"
|
||||||
|
onPress={() => Linking.openURL(brand.socials[0].href)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Open website"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => Linking.openURL(brand.website)}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
app/(root)/(screens)/addresses.tsx
Normal file
136
app/(root)/(screens)/addresses.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { useAddressStore } from "@/lib/stores/addressStore";
|
||||||
|
import { useLocationStore } from "@/lib/stores/locationStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
export default function AddressesScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const addresses = useAddressStore((s) => s.addresses);
|
||||||
|
const add = useAddressStore((s) => s.add);
|
||||||
|
const remove = useAddressStore((s) => s.remove);
|
||||||
|
const setDefault = useAddressStore((s) => s.setDefault);
|
||||||
|
const setAreaLabel = useLocationStore((s) => s.setAreaLabel);
|
||||||
|
|
||||||
|
const [label, setLabel] = useState("Home");
|
||||||
|
const [area, setArea] = useState("Bole, Addis Ababa");
|
||||||
|
const [street, setStreet] = useState("");
|
||||||
|
const [landmark, setLandmark] = useState("");
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title={t("common.addresses")} subtitle="Saved delivery locations" />
|
||||||
|
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 12 }}>
|
||||||
|
{addresses.map((a) => (
|
||||||
|
<Card key={a.id}>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{a.label}</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
{a.street}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
||||||
|
{a.area}
|
||||||
|
{a.landmark ? ` · ${a.landmark}` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{a.isDefault ? <Badge label="Default" tone="brand" /> : null}
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
|
||||||
|
{!a.isDefault ? (
|
||||||
|
<Button
|
||||||
|
title="Set default"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => {
|
||||||
|
setDefault(a.id);
|
||||||
|
setAreaLabel(a.area);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
title="Use for checkout"
|
||||||
|
size="sm"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => {
|
||||||
|
setAreaLabel(a.area);
|
||||||
|
router.push("/(root)/(tabs)/cart");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
title="Remove"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert("Remove address?", a.label, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{ text: "Remove", style: "destructive", onPress: () => remove(a.id) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!showForm ? (
|
||||||
|
<Button title="Add address" icon="add" onPress={() => setShowForm(true)} />
|
||||||
|
) : (
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3" style={{ marginBottom: 12 }}>
|
||||||
|
New address
|
||||||
|
</Text>
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Field label="Label" value={label} onChangeText={setLabel} placeholder="Home, Office…" />
|
||||||
|
<Field label="Area" value={area} onChangeText={setArea} placeholder="Bole, Addis Ababa" />
|
||||||
|
<Field
|
||||||
|
label="Street / building"
|
||||||
|
value={street}
|
||||||
|
onChangeText={setStreet}
|
||||||
|
placeholder="Street, building, apt"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Landmark (optional)"
|
||||||
|
value={landmark}
|
||||||
|
onChangeText={setLandmark}
|
||||||
|
placeholder="Near Edna Mall"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Save address"
|
||||||
|
onPress={() => {
|
||||||
|
if (!street.trim() || !area.trim()) {
|
||||||
|
Alert.alert("Missing info", "Area and street are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add({
|
||||||
|
label: label.trim() || "Address",
|
||||||
|
area: area.trim(),
|
||||||
|
street: street.trim(),
|
||||||
|
landmark: landmark.trim() || undefined,
|
||||||
|
isDefault: addresses.length === 0,
|
||||||
|
});
|
||||||
|
setStreet("");
|
||||||
|
setLandmark("");
|
||||||
|
setShowForm(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button title="Cancel" variant="ghost" onPress={() => setShowForm(false)} />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
app/(root)/(screens)/branches.tsx
Normal file
146
app/(root)/(screens)/branches.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import * as Location from "expo-location";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, Linking, ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { branches } from "@/lib/data/branches";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useLocationStore } from "@/lib/stores/locationStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
function dist(a: { lat: number; lng: number }, b: { lat: number; lng: number }) {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = toRad(b.lat - a.lat);
|
||||||
|
const dLng = toRad(b.lng - a.lng);
|
||||||
|
const x =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BranchesScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setBranchId = useLocationStore((s) => s.setBranchId);
|
||||||
|
const setFulfillment = useLocationStore((s) => s.setFulfillment);
|
||||||
|
const setCoords = useLocationStore((s) => s.setCoords);
|
||||||
|
const branchId = useLocationStore((s) => s.branchId);
|
||||||
|
const [coords, setLocal] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
if (!coords) return branches;
|
||||||
|
return [...branches].sort((a, b) => dist(coords, a) - dist(coords, b));
|
||||||
|
}, [coords]);
|
||||||
|
|
||||||
|
const useMyLocation = async () => {
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status !== "granted") {
|
||||||
|
Alert.alert("Location", "Permission denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pos = await Location.getCurrentPositionAsync({});
|
||||||
|
const c = { lat: pos.coords.latitude, lng: pos.coords.longitude };
|
||||||
|
setLocal(c);
|
||||||
|
setCoords(c.lat, c.lng);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
title={t("common.branches")}
|
||||||
|
subtitle={`${branches.length} locations across Addis Ababa`}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
title={t("common.useLocation")}
|
||||||
|
icon="navigate-outline"
|
||||||
|
onPress={useMyLocation}
|
||||||
|
/>
|
||||||
|
<Text variant="h3">{t("common.nearestBranch")}</Text>
|
||||||
|
|
||||||
|
{sorted.map((b, index) => {
|
||||||
|
const selected = branchId === b.id;
|
||||||
|
const km = coords ? dist(coords, b) : null;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={b.id}
|
||||||
|
style={{
|
||||||
|
borderWidth: selected ? 2 : 1,
|
||||||
|
borderColor: selected ? colors.brand : colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 10 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{b.name}</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
{b.area}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
||||||
|
{b.hours}
|
||||||
|
{b.pickupAvailable ? " · Pickup" : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ alignItems: "flex-end", gap: 6 }}>
|
||||||
|
{selected ? <Badge label="Selected" tone="brand" /> : null}
|
||||||
|
{index === 0 && coords ? <Badge label="Nearest" tone="info" /> : null}
|
||||||
|
{km != null ? (
|
||||||
|
<Text variant="label" color={colors.brandText}>
|
||||||
|
{km.toFixed(1)} km
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 14 }}>
|
||||||
|
<Button
|
||||||
|
title="Call"
|
||||||
|
size="md"
|
||||||
|
fullWidth={false}
|
||||||
|
variant="outline"
|
||||||
|
icon="call-outline"
|
||||||
|
onPress={() => Linking.openURL(`tel:${b.phone}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Maps"
|
||||||
|
size="md"
|
||||||
|
fullWidth={false}
|
||||||
|
variant="outline"
|
||||||
|
icon="map-outline"
|
||||||
|
onPress={() => Linking.openURL(b.mapsUrl)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Set pickup"
|
||||||
|
size="md"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => {
|
||||||
|
setBranchId(b.id);
|
||||||
|
setFulfillment("pickup");
|
||||||
|
Alert.alert("Pickup set", `${b.name} selected for checkout.`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Go to checkout"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => router.push(routes.checkout)}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
445
app/(root)/(screens)/checkout.tsx
Normal file
445
app/(root)/(screens)/checkout.tsx
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { 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, 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 { 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 allowed = useLocationStore((s) => s.allowedPayments());
|
||||||
|
const addresses = useAddressStore((s) => s.addresses);
|
||||||
|
const defaultAddress = useAddressStore((s) => s.defaultAddress());
|
||||||
|
const addOrder = useOrdersStore((s) => s.addOrder);
|
||||||
|
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 [name, setName] = useState(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);
|
||||||
|
|
||||||
|
const selectedAddress = useMemo(
|
||||||
|
() => addresses.find((a) => a.id === addressId) ?? defaultAddress,
|
||||||
|
[addresses, addressId, defaultAddress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deliveryFee = fulfillment === "delivery" && items.length ? DELIVERY_FEE : 0;
|
||||||
|
const discount = voucherDiscount(subtotal, voucher);
|
||||||
|
const afterDiscount = Math.max(0, subtotal - discount);
|
||||||
|
const split =
|
||||||
|
mode === "corporate"
|
||||||
|
? 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)) {
|
||||||
|
Alert.alert("Payment", "Select a valid payment method.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fulfillment === "pickup" && !branchId) {
|
||||||
|
Alert.alert("Pickup", "Select a branch for pickup.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fulfillment === "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})` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
const order = addOrder({
|
||||||
|
items,
|
||||||
|
subtotal,
|
||||||
|
deliveryFee,
|
||||||
|
discount,
|
||||||
|
voucherCode: discount ? voucher.trim().toUpperCase() : null,
|
||||||
|
total: afterDiscount + deliveryFee,
|
||||||
|
fulfillment,
|
||||||
|
branchId,
|
||||||
|
areaLabel:
|
||||||
|
fulfillment === "pickup"
|
||||||
|
? branch?.area ?? areaLabel
|
||||||
|
: selectedAddress?.area ?? areaLabel,
|
||||||
|
addressId: fulfillment === "delivery" ? selectedAddress?.id ?? null : null,
|
||||||
|
addressLine,
|
||||||
|
paymentMethod: payment,
|
||||||
|
corporateCovered: split.covered,
|
||||||
|
selfPay: split.selfPay + deliveryFee,
|
||||||
|
customerName: name.trim(),
|
||||||
|
customerPhone: phone.trim(),
|
||||||
|
notes: orderNotes,
|
||||||
|
riderAssigned: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (forMemberId) setActiveMember(forMemberId);
|
||||||
|
|
||||||
|
if (split.covered > 0) consumeAllowance(split.covered);
|
||||||
|
addPoints(Math.max(1, Math.floor(afterDiscount / 10)), "Order reward");
|
||||||
|
if (discount) {
|
||||||
|
setActiveVoucher(null);
|
||||||
|
setCartVoucher(null);
|
||||||
|
}
|
||||||
|
clear();
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Order placed",
|
||||||
|
payment === "pay_at_branch"
|
||||||
|
? `Pay ${formatETB(order.selfPay ?? order.total)} at ${branch?.name ?? "the branch"}.`
|
||||||
|
: `Mock ${labelPayment(payment, t)} payment of ${formatETB(order.total)} succeeded.`,
|
||||||
|
[
|
||||||
|
{ text: "View order", onPress: () => router.replace(routes.orderDetail(order.id)) },
|
||||||
|
{ text: "Track", onPress: () => 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>
|
||||||
|
<Segmented
|
||||||
|
value={fulfillment}
|
||||||
|
onChange={setFulfillment}
|
||||||
|
options={[
|
||||||
|
{ value: "delivery", label: t("common.delivery") },
|
||||||
|
{ value: "pickup", label: t("common.pickup") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{fulfillment === "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 }}>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<Pressable
|
||||||
|
key={b.id}
|
||||||
|
onPress={() => setBranchId(b.id)}
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: branchId === b.id ? colors.brand : colors.line,
|
||||||
|
backgroundColor: branchId === b.id ? colors.brandWash : colors.surface,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{fulfillment === "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={fulfillment === "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";
|
||||||
|
}
|
||||||
143
app/(root)/(screens)/contact.tsx
Normal file
143
app/(root)/(screens)/contact.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Linking, ScrollView, View } from "react-native";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card, InfoRow } from "@/components/ui/Card";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { brand } from "@/lib/data/brand";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { CALL_PHONE, CHATWOOT_BASE, WHATSAPP_NUMBER } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Ticket = {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
status: "received" | "in_progress" | "resolved";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContactScreen() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [name, setName] = useState(user?.name ?? "");
|
||||||
|
const [subject, setSubject] = useState("Order help");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([
|
||||||
|
{
|
||||||
|
id: "tkt_demo_1",
|
||||||
|
at: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(),
|
||||||
|
subject: "Refill timing",
|
||||||
|
message: "When will my Metformin refill be ready at CMC?",
|
||||||
|
status: "resolved",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title="Contact" subtitle="Talk to Gishen support" />
|
||||||
|
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}>
|
||||||
|
<Card elevated={false}>
|
||||||
|
<InfoRow label="Phone" value={brand.phoneDisplay} />
|
||||||
|
<InfoRow label="Email" value={brand.email} />
|
||||||
|
<InfoRow label="Orders email" value={brand.orderEmail} last />
|
||||||
|
<View style={{ flexDirection: "row", gap: 8, marginTop: 12, flexWrap: "wrap" }}>
|
||||||
|
<Button
|
||||||
|
title="Call"
|
||||||
|
size="sm"
|
||||||
|
fullWidth={false}
|
||||||
|
icon="call-outline"
|
||||||
|
onPress={() => Linking.openURL(`tel:${CALL_PHONE}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="WhatsApp"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
fullWidth={false}
|
||||||
|
icon="logo-whatsapp"
|
||||||
|
onPress={() => Linking.openURL(`https://wa.me/${WHATSAPP_NUMBER}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Live chat"
|
||||||
|
size="sm"
|
||||||
|
variant="soft"
|
||||||
|
fullWidth={false}
|
||||||
|
icon="chatbubble-ellipses-outline"
|
||||||
|
onPress={() => Linking.openURL(CHATWOOT_BASE)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3" style={{ marginBottom: 12 }}>
|
||||||
|
Send a message
|
||||||
|
</Text>
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Field label="Your name" value={name} onChangeText={setName} />
|
||||||
|
<Field label="Subject" value={subject} onChangeText={setSubject} />
|
||||||
|
<Field
|
||||||
|
label="Message"
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
placeholder="How can we help?"
|
||||||
|
multiline
|
||||||
|
style={{ height: 100, textAlignVertical: "top", paddingTop: 12 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Send message"
|
||||||
|
onPress={() => {
|
||||||
|
if (!message.trim()) {
|
||||||
|
Alert.alert("Message required", "Tell us how we can help.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = `tkt_${Date.now().toString().slice(-6)}`;
|
||||||
|
setTickets((prev) => [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
subject: subject.trim() || "General",
|
||||||
|
message: message.trim(),
|
||||||
|
status: "received",
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
setMessage("");
|
||||||
|
Alert.alert(
|
||||||
|
"Ticket created",
|
||||||
|
`Reference ${id}. We'll reply by phone or WhatsApp (demo — not sent to a server).`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||||
|
Your tickets
|
||||||
|
</Text>
|
||||||
|
{tickets.map((t) => (
|
||||||
|
<View
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="h3">
|
||||||
|
{t.subject} · {t.id}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
||||||
|
{new Date(t.at).toLocaleString()} · {t.status}
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
{t.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
app/(root)/(screens)/emergency-contacts.tsx
Normal file
143
app/(root)/(screens)/emergency-contacts.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { Linking, ScrollView, View } from "react-native";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button, IconButton } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Chip } from "@/components/ui/Chip";
|
||||||
|
import { Field, SwitchRow } from "@/components/ui/Field";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { useEmergencyContactStore } from "@/lib/stores/emergencyContactStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
const RELATIONSHIPS = ["spouse", "parent", "child", "sibling", "friend", "other"] as const;
|
||||||
|
|
||||||
|
export default function EmergencyContactsScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const contacts = useEmergencyContactStore((s) => s.contacts);
|
||||||
|
const add = useEmergencyContactStore((s) => s.add);
|
||||||
|
const remove = useEmergencyContactStore((s) => s.remove);
|
||||||
|
const grace = useEmergencyContactStore((s) => s.graceMinutesDefault);
|
||||||
|
const setGrace = useEmergencyContactStore((s) => s.setGrace);
|
||||||
|
const escalationEnabled = useEmergencyContactStore((s) => s.escalationEnabled);
|
||||||
|
const setEscalationEnabled = useEmergencyContactStore((s) => s.setEscalationEnabled);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [relationship, setRelationship] =
|
||||||
|
useState<(typeof RELATIONSHIPS)[number]>("spouse");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
title={t("common.emergencyContacts")}
|
||||||
|
subtitle="Notified if a dose isn’t confirmed in time"
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card padded={false}>
|
||||||
|
<SwitchRow
|
||||||
|
icon="notifications-outline"
|
||||||
|
title="Enable escalation"
|
||||||
|
subtitle={t("common.escalation")}
|
||||||
|
value={escalationEnabled}
|
||||||
|
onValueChange={setEscalationEnabled}
|
||||||
|
/>
|
||||||
|
<View style={{ padding: 16, paddingTop: 4 }}>
|
||||||
|
<Field
|
||||||
|
label={t("common.graceMinutes")}
|
||||||
|
value={String(grace)}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
onChangeText={(v) => setGrace(Number(v) || 30)}
|
||||||
|
hint="Minutes to wait before notifying your contact"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Text variant="h3">Add contact</Text>
|
||||||
|
<View style={{ gap: 12, marginTop: 12 }}>
|
||||||
|
<Field label="Name" value={name} onChangeText={setName} icon="person-outline" />
|
||||||
|
<Field
|
||||||
|
label="Phone"
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
icon="call-outline"
|
||||||
|
/>
|
||||||
|
<Text variant="label" color={colors.muted}>
|
||||||
|
Relationship
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
{RELATIONSHIPS.map((r) => (
|
||||||
|
<Chip
|
||||||
|
key={r}
|
||||||
|
label={r}
|
||||||
|
selected={relationship === r}
|
||||||
|
onPress={() => setRelationship(r)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
title="Add contact"
|
||||||
|
onPress={() => {
|
||||||
|
if (!name.trim() || !phone.trim()) {
|
||||||
|
Alert.alert("Required", "Name and phone are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add({ name: name.trim(), phone: phone.trim(), relationship });
|
||||||
|
setName("");
|
||||||
|
setPhone("");
|
||||||
|
Alert.alert("Added", `${name.trim()} can receive missed-dose alerts.`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Text variant="h3">{contacts.length ? "Your contacts" : "No contacts yet"}</Text>
|
||||||
|
{contacts.map((c) => (
|
||||||
|
<Card key={c.id}>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{c.name}</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 2 }}>
|
||||||
|
{c.phone}
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: 8 }}>
|
||||||
|
<Badge label={c.relationship} tone="neutral" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<IconButton
|
||||||
|
icon="call-outline"
|
||||||
|
onPress={() => Linking.openURL(`tel:${c.phone}`)}
|
||||||
|
accessibilityLabel="Call"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="trash-outline"
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert("Remove contact?", c.name, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Remove",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => remove(c.id),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
accessibilityLabel="Remove"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
app/(root)/(screens)/family/index.tsx
Normal file
94
app/(root)/(screens)/family/index.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { useFamilyStore } from "@/lib/stores/familyStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function FamilyScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const members = useFamilyStore((s) => s.members);
|
||||||
|
const inviteCode = useFamilyStore((s) => s.inviteCode);
|
||||||
|
const acceptInvite = useFamilyStore((s) => s.acceptInvite);
|
||||||
|
const removeMember = useFamilyStore((s) => s.removeMember);
|
||||||
|
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
||||||
|
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title={t("common.family")} subtitle="Manage members and shared care" />
|
||||||
|
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 12 }}>
|
||||||
|
<Card tone="brand" elevated={false}>
|
||||||
|
<Text variant="caption" color={colors.muted}>
|
||||||
|
Your invite code
|
||||||
|
</Text>
|
||||||
|
<Text variant="h1" style={{ marginTop: 4 }}>
|
||||||
|
{inviteCode}
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||||
|
Share this code so family can join your care circle.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={t("common.inviteFamily")}
|
||||||
|
icon="person-add-outline"
|
||||||
|
onPress={() => router.push(routes.familyInvite)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{members.map((m) => (
|
||||||
|
<Card
|
||||||
|
key={m.id}
|
||||||
|
onPress={() => setActiveMember(m.id === activeMemberId ? null : m.id)}
|
||||||
|
style={{
|
||||||
|
borderColor: activeMemberId === m.id ? colors.brand : colors.line,
|
||||||
|
borderWidth: activeMemberId === m.id ? 1.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{m.name}</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
|
{m.relationship}
|
||||||
|
{m.phone ? ` · ${m.phone}` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge
|
||||||
|
label={m.status}
|
||||||
|
tone={m.status === "accepted" ? "brand" : "warn"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", gap: 16, marginTop: 12 }}>
|
||||||
|
{m.status === "pending" ? (
|
||||||
|
<Button
|
||||||
|
title="Accept"
|
||||||
|
size="sm"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => acceptInvite(m.id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
title="Remove"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert("Remove member?", m.name, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{ text: "Remove", style: "destructive", onPress: () => removeMember(m.id) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/(root)/(screens)/family/invite.tsx
Normal file
85
app/(root)/(screens)/family/invite.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Share, View } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Chip } from "@/components/ui/Chip";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { useFamilyStore } from "@/lib/stores/familyStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
const RELATIONS = ["spouse", "child", "parent", "other"] as const;
|
||||||
|
|
||||||
|
export default function FamilyInviteScreen() {
|
||||||
|
const addMember = useFamilyStore((s) => s.addMember);
|
||||||
|
const inviteCode = useFamilyStore((s) => s.inviteCode);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [relationship, setRelationship] = useState<(typeof RELATIONS)[number]>("child");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title="Invite family" subtitle="Share care, orders, and reminders" />
|
||||||
|
<View style={{ flex: 1, paddingHorizontal: gutter, gap: 14 }}>
|
||||||
|
<Card tone="brand" elevated={false}>
|
||||||
|
<Text variant="caption" color={colors.muted}>
|
||||||
|
Family invite code
|
||||||
|
</Text>
|
||||||
|
<Text variant="h2" style={{ marginTop: 4 }}>
|
||||||
|
{inviteCode}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Share code"
|
||||||
|
variant="outline"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
onPress={() =>
|
||||||
|
Share.share({
|
||||||
|
message: `Join my Gishen family group with code ${inviteCode}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Text variant="h3">Or add by phone</Text>
|
||||||
|
<Field label="Name" value={name} onChangeText={setName} icon="person-outline" />
|
||||||
|
<Field
|
||||||
|
label="Phone"
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
icon="call-outline"
|
||||||
|
/>
|
||||||
|
<Text variant="label" color={colors.muted}>
|
||||||
|
Relationship
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
{RELATIONS.map((r) => (
|
||||||
|
<Chip
|
||||||
|
key={r}
|
||||||
|
label={r}
|
||||||
|
selected={relationship === r}
|
||||||
|
onPress={() => setRelationship(r)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
title="Send invite"
|
||||||
|
onPress={() => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
Alert.alert("Name required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addMember({ name: name.trim(), phone, relationship });
|
||||||
|
Alert.alert("Invited", `${name.trim()} is pending acceptance.`);
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
app/(root)/(screens)/loyalty.tsx
Normal file
167
app/(root)/(screens)/loyalty.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { Share, ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card, InfoRow, Section } from "@/components/ui/Card";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { DEMO_VOUCHER } from "@/lib/commerce";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
import { useCartStore } from "@/lib/stores/cartStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
|
||||||
|
const tiers = [
|
||||||
|
{ id: "bronze", min: 0, perks: "1 pt per 10 ETB · Birthday surprise" },
|
||||||
|
{ id: "silver", min: 300, perks: "1.25x points · Free delivery over 800 ETB" },
|
||||||
|
{ id: "gold", min: 1000, perks: "1.5x points · Priority pharmacist consult" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function LoyaltyScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const ledger = useAuthStore((s) => s.ledger);
|
||||||
|
const redeemPoints = useAuthStore((s) => s.redeemPoints);
|
||||||
|
const setVoucher = useCartStore((s) => s.setVoucher);
|
||||||
|
|
||||||
|
const points = user?.points ?? 0;
|
||||||
|
const next = tiers.find((tier) => points < tier.min) ?? null;
|
||||||
|
const progress = next
|
||||||
|
? Math.min(1, points / next.min)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title={t("common.loyalty")} subtitle="Earn points on every paid order" />
|
||||||
|
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}>
|
||||||
|
<Card tone="brand" elevated={false}>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
|
<View>
|
||||||
|
<Text variant="caption" color={colors.muted}>
|
||||||
|
Available points
|
||||||
|
</Text>
|
||||||
|
<Text variant="display" style={{ marginTop: 4 }}>
|
||||||
|
{points}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge label={(user?.tier ?? "bronze").toUpperCase()} solid />
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: "rgba(16,20,16,0.12)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${Math.round(progress * 100)}%`,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: colors.ink,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text variant="caption" color={colors.muted} style={{ marginTop: 8 }}>
|
||||||
|
{next
|
||||||
|
? `${next.min - points} pts to ${next.id}`
|
||||||
|
: "You've reached Gold — enjoy premium perks"}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Section title="How tiers work">
|
||||||
|
<Card elevated={false}>
|
||||||
|
{tiers.map((tier, i) => (
|
||||||
|
<InfoRow
|
||||||
|
key={tier.id}
|
||||||
|
label={`${tier.id} · ${tier.min}+ pts`}
|
||||||
|
value={tier.perks}
|
||||||
|
last={i === tiers.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3">Redeem rewards</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||||
|
Redeem 100 points for a {DEMO_VOUCHER} voucher (10% off your next order).
|
||||||
|
</Text>
|
||||||
|
{user?.activeVoucher ? (
|
||||||
|
<Badge
|
||||||
|
label={`Active voucher: ${user.activeVoucher}`}
|
||||||
|
tone="brand"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
title="Redeem 100 pts"
|
||||||
|
style={{ marginTop: 14 }}
|
||||||
|
disabled={!user || user.isGuest || points < 100}
|
||||||
|
onPress={() => {
|
||||||
|
const ok = redeemPoints(100);
|
||||||
|
if (!ok) {
|
||||||
|
Alert.alert("Not enough points", "You need at least 100 points.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVoucher(DEMO_VOUCHER);
|
||||||
|
Alert.alert(
|
||||||
|
"Voucher unlocked",
|
||||||
|
`${DEMO_VOUCHER} is ready in your cart / checkout.`,
|
||||||
|
[
|
||||||
|
{ text: "Go to cart", onPress: () => router.push(routes.cart) },
|
||||||
|
{ text: "OK" },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3">{t("common.referral")}</Text>
|
||||||
|
<Text variant="priceLg" style={{ marginTop: 8 }}>
|
||||||
|
{user?.referralCode ?? "GISHEN-GUEST"}
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||||
|
Friends get 50 welcome points. You get 100 when they place their first order.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Share code"
|
||||||
|
variant="outline"
|
||||||
|
icon="share-outline"
|
||||||
|
style={{ marginTop: 14 }}
|
||||||
|
onPress={() =>
|
||||||
|
Share.share({
|
||||||
|
message: `Join Gishen Pharmacy with my code ${user?.referralCode ?? ""}. Healthcare delivered to your doorstep.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Section title="Points history">
|
||||||
|
<Card elevated={false}>
|
||||||
|
{ledger.length === 0 ? (
|
||||||
|
<Text variant="bodySm" color={colors.muted}>
|
||||||
|
Points from orders will appear here.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
ledger.map((entry, i) => (
|
||||||
|
<InfoRow
|
||||||
|
key={entry.id}
|
||||||
|
label={`${entry.label} · ${new Date(entry.at).toLocaleDateString()}`}
|
||||||
|
value={`${entry.delta > 0 ? "+" : ""}${entry.delta}`}
|
||||||
|
valueColor={entry.delta >= 0 ? colors.brandText : colors.danger}
|
||||||
|
last={i === ledger.length - 1}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
app/(root)/(screens)/orders/[id].tsx
Normal file
240
app/(root)/(screens)/orders/[id].tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
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}
|
||||||
|
</Text>
|
||||||
|
<Text variant="h2" style={{ marginTop: 4 }}>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
{branch ? (
|
||||||
|
<InfoRow label="Branch" value={`${branch.name} · ${branch.hours}`} last />
|
||||||
|
) : (
|
||||||
|
<InfoRow label="Branch" value="—" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
app/(root)/(screens)/orders/index.tsx
Normal file
107
app/(root)/(screens)/orders/index.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Image, ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
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 { 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title={t("common.orders")} subtitle="Track delivery and pickup status" />
|
||||||
|
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40 }}>
|
||||||
|
{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)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
{orders.map((o) => (
|
||||||
|
<Card key={o.id} onPress={() => router.push(routes.orderDetail(o.id))}>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">
|
||||||
|
{o.fulfillment === "pickup" ? "Pickup" : "Delivery"} · {o.areaLabel}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
|
{new Date(o.createdAt).toLocaleString()} · {o.paymentMethod}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge label={o.status} tone={toneFor[o.status]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", gap: 8, marginTop: 12 }}>
|
||||||
|
{o.items.slice(0, 3).map((item) => (
|
||||||
|
<Image
|
||||||
|
key={item.productId}
|
||||||
|
source={{ uri: item.image }}
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
}}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{o.items.length > 3 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="micro">+{o.items.length - 3}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="price">{formatETB(o.total)}</Text>
|
||||||
|
<Text variant="label" color={colors.brandText}>
|
||||||
|
View details
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
app/(root)/(screens)/prescription/[id].tsx
Normal file
211
app/(root)/(screens)/prescription/[id].tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { 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 { products } from "@/lib/data/products";
|
||||||
|
import { useCartStore } from "@/lib/stores/cartStore";
|
||||||
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
|
import { colors, gutter, radii } 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 { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const rx = usePrescriptionStore((s) => s.prescriptions.find((p) => p.id === id));
|
||||||
|
const reminders = usePrescriptionStore((s) =>
|
||||||
|
s.reminders.filter((r) => r.prescriptionId === id)
|
||||||
|
);
|
||||||
|
const markRefilled = usePrescriptionStore((s) => s.markRefilled);
|
||||||
|
const updateRxStatus = usePrescriptionStore((s) => s.updateRxStatus);
|
||||||
|
const addProduct = useCartStore((s) => s.addProduct);
|
||||||
|
|
||||||
|
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));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title="Prescription detail" />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rx.imageUris[0] ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: rx.imageUris[0] }}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 180,
|
||||||
|
borderRadius: radii.card,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
}}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h1">{rx.medicineName}</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||||
|
For {rx.patientName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge
|
||||||
|
label={rx.status.replace("_", " ")}
|
||||||
|
tone={statusTone[rx.status]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{dueIn !== null ? (
|
||||||
|
<Text
|
||||||
|
variant="label"
|
||||||
|
color={dueIn <= 3 ? colors.warn : colors.brandText}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
>
|
||||||
|
{dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text variant="label" color={colors.warn} style={{ marginTop: 12 }}>
|
||||||
|
Awaiting first fill
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Section title="Medicines on this Rx">
|
||||||
|
<Card elevated={false}>
|
||||||
|
{rx.medicines.map((m, i) => (
|
||||||
|
<View
|
||||||
|
key={m.name}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: i === rx.medicines.length - 1 ? 0 : 1,
|
||||||
|
borderBottomColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
||||||
|
<Ionicons name="medkit-outline" size={18} color={colors.brandText} />
|
||||||
|
<Text variant="h3">{m.name}</Text>
|
||||||
|
</View>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Section title="Reminders">
|
||||||
|
{reminders.length === 0 ? (
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="bodySm" color={colors.muted}>
|
||||||
|
No reminders yet. Set times so we can nudge you — and escalate if a dose is missed.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
reminders.map((r) => (
|
||||||
|
<Card key={r.id} elevated={false} style={{ marginBottom: 8 }}>
|
||||||
|
<Text variant="h3">{r.medicineName}</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
{r.times.join(" · ")} · {r.graceMinutes} min grace
|
||||||
|
{r.escalateOnMiss ? " · escalate if missed" : ""}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
title="Manage calendar reminders"
|
||||||
|
variant="soft"
|
||||||
|
icon="calendar-outline"
|
||||||
|
onPress={() => router.push(routes.rxCalendar(rx.id))}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<View style={{ gap: 10, marginTop: 8 }}>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
{rx.status === "under_review" || rx.status === "submitted" ? (
|
||||||
|
<Button
|
||||||
|
title="Simulate pharmacist approval"
|
||||||
|
variant="soft"
|
||||||
|
onPress={() => updateRxStatus(rx.id, "approved")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
app/(root)/(screens)/prescription/calendar/[id].tsx
Normal file
265
app/(root)/(screens)/prescription/calendar/[id].tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
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 {
|
||||||
|
scheduleDoseReminder,
|
||||||
|
scheduleEscalationCheck,
|
||||||
|
} from "@/lib/notifications";
|
||||||
|
import { useEmergencyContactStore } from "@/lib/stores/emergencyContactStore";
|
||||||
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
const PRESETS = ["08:00", "12:00", "14:00", "18:00", "20:00"] as const;
|
||||||
|
|
||||||
|
export default function RxCalendarScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
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 [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,
|
||||||
|
});
|
||||||
|
await scheduleDoseReminder({
|
||||||
|
title: `Time for ${rx.medicineName}`,
|
||||||
|
body: "Tap to confirm you took your dose",
|
||||||
|
secondsFromNow: 15,
|
||||||
|
data: { prescriptionId: rx.id, type: "dose" },
|
||||||
|
});
|
||||||
|
const log = schedulePendingDose(reminderId);
|
||||||
|
setPendingLogId(log.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("Scheduled", `Reminders at ${parsed.join(", ")}. Demo push in ~15s.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
app/(root)/(screens)/prescription/list.tsx
Normal file
109
app/(root)/(screens)/prescription/list.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
|
const statusTone = {
|
||||||
|
submitted: "neutral",
|
||||||
|
under_review: "warn",
|
||||||
|
approved: "brand",
|
||||||
|
queried: "info",
|
||||||
|
rejected: "danger",
|
||||||
|
filled: "brand",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function RxListScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
title={t("common.prescriptions")}
|
||||||
|
subtitle="Scan, track status, refill and remind"
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
title={t("common.scanRx")}
|
||||||
|
icon="scan-outline"
|
||||||
|
onPress={() => router.push(routes.rxScan)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{prescriptions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="document-text-outline"
|
||||||
|
title="No prescriptions yet"
|
||||||
|
body="Scan or upload a prescription to get started."
|
||||||
|
actionTitle="Scan prescription"
|
||||||
|
onAction={() => router.push(routes.rxScan)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ marginTop: 16, gap: 12 }}>
|
||||||
|
{prescriptions.map((rx) => {
|
||||||
|
const dueIn = (() => {
|
||||||
|
if (!rx.lastFilledAt) return null;
|
||||||
|
const due = new Date(rx.lastFilledAt);
|
||||||
|
due.setDate(due.getDate() + rx.daysSupply);
|
||||||
|
return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={rx.id} onPress={() => router.push(routes.rxDetail(rx.id))}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{rx.medicineName}</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
|
{rx.patientName} · {rx.medicines.length} item
|
||||||
|
{rx.medicines.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge
|
||||||
|
label={rx.status.replace("_", " ")}
|
||||||
|
tone={statusTone[rx.status]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{dueIn !== null ? (
|
||||||
|
<Text
|
||||||
|
variant="label"
|
||||||
|
color={dueIn <= 3 ? colors.warn : colors.brandText}
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
>
|
||||||
|
{dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn} days`}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text variant="label" color={colors.warn} style={{ marginTop: 10 }}>
|
||||||
|
Waiting for first fill
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
|
||||||
|
Tap for details, reminders and reorder
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
app/(root)/(screens)/prescription/review.tsx
Normal file
222
app/(root)/(screens)/prescription/review.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, Image, ScrollView, View } from "react-native";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Chip } from "@/components/ui/Chip";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { products } from "@/lib/data/products";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
import { useCartStore } from "@/lib/stores/cartStore";
|
||||||
|
import { useFamilyStore } from "@/lib/stores/familyStore";
|
||||||
|
import { useLocationStore } from "@/lib/stores/locationStore";
|
||||||
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
|
import { colors, gutter, radii } from "@/lib/theme";
|
||||||
|
|
||||||
|
function matchStatus(name: string): "ready" | "review" | "unavailable" {
|
||||||
|
const hit = products.find((p) =>
|
||||||
|
p.name.toLowerCase().includes(name.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
if (!hit) return "review";
|
||||||
|
if (!hit.inStock) return "unavailable";
|
||||||
|
return "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RxReviewScreen() {
|
||||||
|
const draftImages = usePrescriptionStore((s) => s.draftImages);
|
||||||
|
const setDraftImages = usePrescriptionStore((s) => s.setDraftImages);
|
||||||
|
const addPrescription = usePrescriptionStore((s) => s.addPrescription);
|
||||||
|
const addProduct = useCartStore((s) => s.addProduct);
|
||||||
|
const setFulfillment = useLocationStore((s) => s.setFulfillment);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const members = useFamilyStore((s) => s.members);
|
||||||
|
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
||||||
|
const activeMember = members.find((m) => m.id === activeMemberId);
|
||||||
|
|
||||||
|
const [patientName, setPatientName] = useState(
|
||||||
|
activeMember?.name || user?.name || ""
|
||||||
|
);
|
||||||
|
const [memberId, setMemberId] = useState<string | null>(activeMemberId);
|
||||||
|
const [medicineName, setMedicineName] = useState("Metformin 500mg");
|
||||||
|
const [daysSupply, setDaysSupply] = useState("30");
|
||||||
|
const [list, setList] = useState("Metformin 500mg, Vitamin B12");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const medicines = useMemo(() => {
|
||||||
|
return list
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((name) => ({ name, status: matchStatus(name) }));
|
||||||
|
}, [list]);
|
||||||
|
|
||||||
|
const save = (toCart: boolean) => {
|
||||||
|
if (!draftImages.length) {
|
||||||
|
Alert.alert("Image required", "Scan or upload at least one prescription photo first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!patientName.trim() || !medicineName.trim()) {
|
||||||
|
Alert.alert("Missing", "Patient and primary medicine are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meds =
|
||||||
|
medicines.length > 0
|
||||||
|
? medicines
|
||||||
|
: [{ name: medicineName, status: matchStatus(medicineName) }];
|
||||||
|
const rx = addPrescription({
|
||||||
|
patientName: patientName.trim(),
|
||||||
|
memberId,
|
||||||
|
medicineName: medicineName.trim(),
|
||||||
|
medicines: meds,
|
||||||
|
imageUris: draftImages,
|
||||||
|
daysSupply: Number(daysSupply) || 30,
|
||||||
|
lastFilledAt: null,
|
||||||
|
notes: notes.trim(),
|
||||||
|
});
|
||||||
|
if (toCart) {
|
||||||
|
meds
|
||||||
|
.filter((m) => m.status === "ready")
|
||||||
|
.forEach((m) => {
|
||||||
|
const p = products.find((x) =>
|
||||||
|
x.name.toLowerCase().includes(m.name.toLowerCase().split(" ")[0])
|
||||||
|
);
|
||||||
|
if (p) addProduct(p);
|
||||||
|
});
|
||||||
|
setFulfillment("pickup");
|
||||||
|
}
|
||||||
|
setDraftImages([]);
|
||||||
|
Alert.alert("Saved", "Prescription submitted for pharmacist review", [
|
||||||
|
{ text: "Open details", onPress: () => router.replace(routes.rxDetail(rx.id)) },
|
||||||
|
{ text: "Reminders", onPress: () => router.replace(routes.rxCalendar(rx.id)) },
|
||||||
|
{ text: "List", onPress: () => router.replace(routes.rxList) },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title="Review prescription" subtitle="Confirm patient and medicines" />
|
||||||
|
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 14 }}>
|
||||||
|
{draftImages.length ? (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
|
||||||
|
{draftImages.map((uri) => (
|
||||||
|
<Image
|
||||||
|
key={uri}
|
||||||
|
source={{ uri }}
|
||||||
|
style={{ width: 96, height: 96, borderRadius: radii.md, backgroundColor: colors.surfaceAlt }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<Card tone="brand" elevated={false}>
|
||||||
|
<Text variant="h3">No images attached</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
Go back to scan or upload a prescription photo.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title="Scan / upload"
|
||||||
|
size="md"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
onPress={() => router.replace(routes.rxScan)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||||
|
Patient
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
|
||||||
|
<Chip
|
||||||
|
label={user?.name || "Me"}
|
||||||
|
selected={!memberId}
|
||||||
|
onPress={() => {
|
||||||
|
setMemberId(null);
|
||||||
|
setPatientName(user?.name || "");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{members
|
||||||
|
.filter((m) => m.status === "accepted")
|
||||||
|
.map((m) => (
|
||||||
|
<Chip
|
||||||
|
key={m.id}
|
||||||
|
label={m.name}
|
||||||
|
selected={memberId === m.id}
|
||||||
|
onPress={() => {
|
||||||
|
setMemberId(m.id);
|
||||||
|
setPatientName(m.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Field label="Patient name" value={patientName} onChangeText={setPatientName} />
|
||||||
|
<Field
|
||||||
|
label="Primary medicine"
|
||||||
|
value={medicineName}
|
||||||
|
onChangeText={setMedicineName}
|
||||||
|
hint="Used as the prescription title"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="All medicines (comma-separated)"
|
||||||
|
value={list}
|
||||||
|
onChangeText={setList}
|
||||||
|
placeholder="Metformin 500mg, Vitamin B12"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Days supply"
|
||||||
|
value={daysSupply}
|
||||||
|
onChangeText={setDaysSupply}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Notes for pharmacist"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Allergies, instructions, preferred brand…"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3" style={{ marginBottom: 10 }}>
|
||||||
|
Availability preview
|
||||||
|
</Text>
|
||||||
|
{(medicines.length ? medicines : [{ name: medicineName || "—", status: "review" as const }]).map(
|
||||||
|
(m) => (
|
||||||
|
<View
|
||||||
|
key={m.name}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="bodyMedium">{m.name}</Text>
|
||||||
|
<Badge
|
||||||
|
label={m.status}
|
||||||
|
tone={
|
||||||
|
m.status === "ready" ? "brand" : m.status === "review" ? "warn" : "danger"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button title="Submit for review" onPress={() => save(false)} />
|
||||||
|
<Button
|
||||||
|
title="Submit & add ready items to cart"
|
||||||
|
variant="soft"
|
||||||
|
onPress={() => save(true)}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
app/(root)/(screens)/prescription/scan.tsx
Normal file
196
app/(root)/(screens)/prescription/scan.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Alert, Image, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function RxScanScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
const draftImages = usePrescriptionStore((s) => s.draftImages);
|
||||||
|
const setDraftImages = usePrescriptionStore((s) => s.setDraftImages);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const capture = async () => {
|
||||||
|
const photo = await cameraRef.current?.takePictureAsync({ quality: 0.7 });
|
||||||
|
if (photo?.uri) setDraftImages([...draftImages, photo.uri]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = async () => {
|
||||||
|
const res = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
allowsMultipleSelection: true,
|
||||||
|
quality: 0.7,
|
||||||
|
});
|
||||||
|
if (!res.canceled) {
|
||||||
|
setDraftImages([...draftImages, ...res.assets.map((a) => a.uri)]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAt = (uri: string) => {
|
||||||
|
setDraftImages(draftImages.filter((u) => u !== uri));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission?.granted) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: insets.top + 24,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="h1">Scan prescription</Text>
|
||||||
|
<Text variant="body" color={colors.muted}>
|
||||||
|
Allow camera access to photograph your prescription, or upload existing photos.
|
||||||
|
</Text>
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="bodySm" color={colors.muted}>
|
||||||
|
Tips: place the Rx on a flat surface, include the full page, and avoid glare.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Button title="Grant camera permission" onPress={requestPermission} />
|
||||||
|
<Button title="Upload from photos" variant="outline" onPress={pick} />
|
||||||
|
{draftImages.length ? (
|
||||||
|
<Button
|
||||||
|
title={`Continue with ${draftImages.length} photo${draftImages.length === 1 ? "" : "s"}`}
|
||||||
|
variant="soft"
|
||||||
|
onPress={() => router.push(routes.rxReview)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button title="Cancel" variant="ghost" onPress={() => router.back()} />
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000" }}>
|
||||||
|
<CameraView
|
||||||
|
ref={cameraRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
facing="back"
|
||||||
|
onCameraReady={() => setReady(true)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top + 8,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable onPress={() => router.back()} hitSlop={8}>
|
||||||
|
<Ionicons name="close" size={28} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
<Text variant="label" color="#fff">
|
||||||
|
{t("common.scanRx")}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color="rgba(255,255,255,0.75)">
|
||||||
|
{draftImages.length} saved
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "20%",
|
||||||
|
left: "8%",
|
||||||
|
right: "8%",
|
||||||
|
bottom: "30%",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.brand,
|
||||||
|
borderRadius: 16,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color="rgba(255,255,255,0.85)"
|
||||||
|
center
|
||||||
|
style={{ position: "absolute", top: "16%", alignSelf: "center", width: "80%" }}
|
||||||
|
>
|
||||||
|
Align the full prescription inside the frame
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 36,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScrollView horizontal contentContainerStyle={{ gap: 8, paddingHorizontal: 16 }}>
|
||||||
|
{draftImages.map((uri) => (
|
||||||
|
<Pressable key={uri} onLongPress={() => removeAt(uri)}>
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={{ width: 56, height: 56, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
{draftImages.length ? (
|
||||||
|
<Text variant="micro" color="rgba(255,255,255,0.7)">
|
||||||
|
Long-press a thumbnail to remove
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={{ flexDirection: "row", gap: 24, alignItems: "center" }}>
|
||||||
|
<Pressable onPress={pick} accessibilityLabel="Upload photos">
|
||||||
|
<Ionicons name="images" size={28} color="#fff" />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
disabled={!ready}
|
||||||
|
onPress={capture}
|
||||||
|
accessibilityLabel="Capture"
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 36,
|
||||||
|
backgroundColor: colors.brand,
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "#fff",
|
||||||
|
opacity: ready ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
if (!draftImages.length) {
|
||||||
|
Alert.alert("Add a photo", "Capture or upload at least one image first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(routes.rxReview);
|
||||||
|
}}
|
||||||
|
accessibilityLabel="Continue to review"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-circle"
|
||||||
|
size={34}
|
||||||
|
color={draftImages.length ? colors.brand : "#666"}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CameraView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
439
app/(root)/(screens)/product/[id].tsx
Normal file
439
app/(root)/(screens)/product/[id].tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
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="chevron-back" onPress={() => router.back()} />
|
||||||
|
<IconButton
|
||||||
|
icon={liked ? "heart" : "heart-outline"}
|
||||||
|
onPress={() => toggle(product.id)}
|
||||||
|
color={liked ? colors.danger : colors.ink}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
app/(root)/(screens)/profiles/join.tsx
Normal file
60
app/(root)/(screens)/profiles/join.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Chip } from "@/components/ui/Chip";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
const DEMO_CODES = ["GISHEN-BANK", "GISHEN-NGO"] as const;
|
||||||
|
|
||||||
|
export default function JoinOrgScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const joinOrg = useProfileModeStore((s) => s.joinOrg);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
title={t("common.joinOrg")}
|
||||||
|
subtitle="Unlock corporate allowance and benefits"
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1, paddingHorizontal: gutter, gap: 14 }}>
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="bodySm" color={colors.muted}>
|
||||||
|
Enter the join code from your HR or organisation admin. Demo codes work offline.
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
|
||||||
|
{DEMO_CODES.map((c) => (
|
||||||
|
<Chip key={c} label={c} selected={code === c} onPress={() => setCode(c)} size="sm" />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Join code"
|
||||||
|
value={code}
|
||||||
|
onChangeText={setCode}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
icon="key-outline"
|
||||||
|
placeholder="GISHEN-BANK"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Join organisation"
|
||||||
|
onPress={() => {
|
||||||
|
const res = joinOrg(code);
|
||||||
|
Alert.alert(res.ok ? "Joined" : "Failed", res.message);
|
||||||
|
if (res.ok) router.back();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
app/(root)/(screens)/profiles/switch.tsx
Normal file
134
app/(root)/(screens)/profiles/switch.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useProfileModeStore } from "@/lib/stores/profileModeStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { formatETB } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ProfileSwitchScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mode = useProfileModeStore((s) => s.mode);
|
||||||
|
const setMode = useProfileModeStore((s) => s.setMode);
|
||||||
|
const memberships = useProfileModeStore((s) => s.memberships);
|
||||||
|
const setActiveOrg = useProfileModeStore((s) => s.setActiveOrg);
|
||||||
|
const activeOrgId = useProfileModeStore((s) => s.activeOrgId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
title={t("common.switchProfile")}
|
||||||
|
subtitle="Personal shopping or organisation benefits"
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 32,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setMode("personal");
|
||||||
|
setActiveOrg(null);
|
||||||
|
}}
|
||||||
|
style={cardStyle(mode === "personal")}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.brandSoft,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="person" size={20} color={colors.brandText} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{t("common.personal")}</Text>
|
||||||
|
<Text variant="caption" color={colors.muted} style={{ marginTop: 2 }}>
|
||||||
|
Retail shopping, loyalty points, family
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{mode === "personal" ? <Badge label="Active" tone="brand" /> : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{memberships.map((m) => {
|
||||||
|
const active = mode === "corporate" && activeOrgId === m.orgId;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={m.orgId}
|
||||||
|
onPress={() => setActiveOrg(m.orgId)}
|
||||||
|
style={cardStyle(active)}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.brandSoft,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="business" size={20} color={colors.brandText} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{m.orgName}</Text>
|
||||||
|
<Text variant="caption" color={colors.muted} style={{ marginTop: 2 }}>
|
||||||
|
{m.planName}
|
||||||
|
</Text>
|
||||||
|
<Text variant="label" color={colors.brandText} style={{ marginTop: 6 }}>
|
||||||
|
{formatETB(m.remainingAllowance)} allowance left
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{active ? <Badge label="Active" tone="brand" /> : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!memberships.length ? (
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="body" color={colors.muted}>
|
||||||
|
No organisations yet. Join with a demo code from your HR team.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
title={t("common.joinOrg")}
|
||||||
|
onPress={() => router.push(routes.profileJoin)}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
title={t("common.joinOrg")}
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => router.push(routes.profileJoin)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardStyle(selected: boolean) {
|
||||||
|
return {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 16,
|
||||||
|
borderWidth: selected ? 2 : 1,
|
||||||
|
borderColor: selected ? colors.brand : colors.line,
|
||||||
|
};
|
||||||
|
}
|
||||||
111
app/(root)/(screens)/search.tsx
Normal file
111
app/(root)/(screens)/search.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { FlatList, Pressable, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Chip, ChipRow } from "@/components/ui/Chip";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { ProductRow } from "@/components/ui/ProductCard";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { SearchField } from "@/components/ui/SearchField";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { products, searchProducts } from "@/lib/data/products";
|
||||||
|
import { categories, routes } from "@/lib/routes";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
const recent = ["Panadol", "Metformin", "Vitamin C", "Cough syrup", "Glucose meter"];
|
||||||
|
|
||||||
|
export default function SearchScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const params = useLocalSearchParams<{ q?: string }>();
|
||||||
|
const [q, setQ] = useState(params.q ?? "");
|
||||||
|
|
||||||
|
const results = useMemo(() => (q.trim() ? searchProducts(q) : []), [q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title="Search" />
|
||||||
|
<View style={{ paddingHorizontal: gutter }}>
|
||||||
|
<SearchField
|
||||||
|
value={q}
|
||||||
|
onChangeText={setQ}
|
||||||
|
onClear={() => setQ("")}
|
||||||
|
autoFocus
|
||||||
|
placeholder={t("common.search")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!q.trim() ? (
|
||||||
|
<View style={{ paddingHorizontal: gutter, paddingTop: 20 }}>
|
||||||
|
<Text variant="h3">Recent searches</Text>
|
||||||
|
<View style={{ marginTop: 12, gap: 10 }}>
|
||||||
|
{recent.map((term) => (
|
||||||
|
<Pressable
|
||||||
|
key={term}
|
||||||
|
onPress={() => setQ(term)}
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 10 }}
|
||||||
|
>
|
||||||
|
<Text variant="body" color={colors.muted}>
|
||||||
|
{term}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="h3" style={{ marginTop: 28, marginBottom: 12 }}>
|
||||||
|
Browse by category
|
||||||
|
</Text>
|
||||||
|
<ChipRow gutters={0}>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<Chip
|
||||||
|
key={c.id}
|
||||||
|
label={t(c.labelKey)}
|
||||||
|
icon={c.icon}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({ pathname: routes.shop, params: { category: c.id } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChipRow>
|
||||||
|
|
||||||
|
<Text variant="h3" style={{ marginTop: 28, marginBottom: 12 }}>
|
||||||
|
Popular right now
|
||||||
|
</Text>
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
{products
|
||||||
|
.filter((p) => p.badge === "bestseller")
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((p) => (
|
||||||
|
<ProductRow key={p.id} product={p} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={results}
|
||||||
|
keyExtractor={(i) => i.id}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<EmptyState
|
||||||
|
icon="search-outline"
|
||||||
|
title="Nothing found"
|
||||||
|
body="Try a brand name, symptom or category."
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginBottom: 8 }}>
|
||||||
|
{results.length} result{results.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => <ProductRow product={item} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
app/(root)/(screens)/shop.tsx
Normal file
91
app/(root)/(screens)/shop.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Chip, ChipRow } from "@/components/ui/Chip";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { ProductCard, GRID_CARD_GAP } from "@/components/ui/ProductCard";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { SearchField } from "@/components/ui/SearchField";
|
||||||
|
import { products } from "@/lib/data/products";
|
||||||
|
import { categories } from "@/lib/routes";
|
||||||
|
import { gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
export default function ShopScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const params = useLocalSearchParams<{ q?: string; category?: string }>();
|
||||||
|
const [q, setQ] = useState(params.q ?? "");
|
||||||
|
const [category, setCategory] = useState(params.category ?? "");
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const cardWidth = (width - gutter * 2 - GRID_CARD_GAP) / 2;
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
return products.filter((p) => {
|
||||||
|
const catOk = !category || p.category === category;
|
||||||
|
const qOk =
|
||||||
|
!q ||
|
||||||
|
p.name.toLowerCase().includes(q.toLowerCase()) ||
|
||||||
|
p.brand.toLowerCase().includes(q.toLowerCase()) ||
|
||||||
|
p.description.toLowerCase().includes(q.toLowerCase());
|
||||||
|
return catOk && qOk;
|
||||||
|
});
|
||||||
|
}, [q, category]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
large
|
||||||
|
back
|
||||||
|
title="Explore products"
|
||||||
|
subtitle="Trusted medicines, vitamins and care essentials"
|
||||||
|
/>
|
||||||
|
<View style={{ paddingHorizontal: gutter, marginBottom: 12 }}>
|
||||||
|
<SearchField
|
||||||
|
value={q}
|
||||||
|
onChangeText={setQ}
|
||||||
|
onClear={() => setQ("")}
|
||||||
|
placeholder={t("common.search")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ChipRow gutters={gutter}>
|
||||||
|
<Chip
|
||||||
|
label="All"
|
||||||
|
selected={!category}
|
||||||
|
onPress={() => setCategory("")}
|
||||||
|
/>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<Chip
|
||||||
|
key={c.id}
|
||||||
|
label={t(c.labelKey)}
|
||||||
|
selected={category === c.id}
|
||||||
|
onPress={() => setCategory(c.id)}
|
||||||
|
icon={c.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChipRow>
|
||||||
|
<FlatList
|
||||||
|
data={data}
|
||||||
|
numColumns={2}
|
||||||
|
keyExtractor={(i) => i.id}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 40,
|
||||||
|
gap: GRID_CARD_GAP,
|
||||||
|
}}
|
||||||
|
columnWrapperStyle={{ gap: GRID_CARD_GAP }}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<EmptyState
|
||||||
|
icon="search-outline"
|
||||||
|
title="No matches"
|
||||||
|
body="Try another medicine name, brand or category."
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => <ProductCard product={item} width={cardWidth} />}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
app/(root)/(screens)/subscriptions/index.tsx
Normal file
255
app/(root)/(screens)/subscriptions/index.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Chip, Segmented } from "@/components/ui/Chip";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { products } from "@/lib/data/products";
|
||||||
|
import { useFamilyStore } from "@/lib/stores/familyStore";
|
||||||
|
import { useLocationStore, type Fulfillment, type PaymentMethod } from "@/lib/stores/locationStore";
|
||||||
|
import { useSubscriptionStore } from "@/lib/stores/subscriptionStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
const FREQUENCIES = [
|
||||||
|
{ days: 7, label: "Weekly" },
|
||||||
|
{ days: 14, label: "Every 2 weeks" },
|
||||||
|
{ days: 30, label: "Monthly" },
|
||||||
|
{ days: 60, label: "Every 2 months" },
|
||||||
|
{ days: 90, label: "Every 3 months" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SubscriptionsScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const items = useSubscriptionStore((s) => s.items);
|
||||||
|
const add = useSubscriptionStore((s) => s.add);
|
||||||
|
const pause = useSubscriptionStore((s) => s.pause);
|
||||||
|
const resume = useSubscriptionStore((s) => s.resume);
|
||||||
|
const cancel = useSubscriptionStore((s) => s.cancel);
|
||||||
|
const paymentPref = useLocationStore((s) => s.paymentPreference);
|
||||||
|
const fulfillmentPref = useLocationStore((s) => s.fulfillment);
|
||||||
|
const members = useFamilyStore((s) => s.members);
|
||||||
|
|
||||||
|
const refillCandidates = useMemo(
|
||||||
|
() =>
|
||||||
|
products.filter(
|
||||||
|
(p) =>
|
||||||
|
p.requiresPrescription ||
|
||||||
|
p.category === "diabetic" ||
|
||||||
|
p.category === "vitamins" ||
|
||||||
|
p.badge === "bestseller"
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [productId, setProductId] = useState(refillCandidates[0]?.id ?? products[0].id);
|
||||||
|
const [qty, setQty] = useState("1");
|
||||||
|
const [frequencyDays, setFrequencyDays] = useState(30);
|
||||||
|
const [fulfillment, setFulfillment] = useState<Fulfillment>(fulfillmentPref);
|
||||||
|
const [payment, setPayment] = useState<PaymentMethod>(
|
||||||
|
paymentPref === "pay_at_branch" && fulfillmentPref === "delivery"
|
||||||
|
? "chapa"
|
||||||
|
: paymentPref
|
||||||
|
);
|
||||||
|
const [memberId, setMemberId] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const filtered = refillCandidates.filter(
|
||||||
|
(p) =>
|
||||||
|
!query ||
|
||||||
|
p.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
p.brand.toLowerCase().includes(query.toLowerCase())
|
||||||
|
);
|
||||||
|
const selected = products.find((p) => p.id === productId) ?? products[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
title={t("common.subscriptions")}
|
||||||
|
subtitle="Auto-refill chronic medicines on a schedule"
|
||||||
|
/>
|
||||||
|
<ScrollView contentContainerStyle={{ paddingHorizontal: gutter, paddingBottom: 40, gap: 12 }}>
|
||||||
|
{!showCreate ? (
|
||||||
|
<Button
|
||||||
|
title="Create refill subscription"
|
||||||
|
icon="add-circle-outline"
|
||||||
|
onPress={() => setShowCreate(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3" style={{ marginBottom: 12 }}>
|
||||||
|
New subscription
|
||||||
|
</Text>
|
||||||
|
<Field
|
||||||
|
label="Search product"
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="Metformin, Vitamin C…"
|
||||||
|
icon="search-outline"
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
contentContainerStyle={{ gap: 8 }}
|
||||||
|
>
|
||||||
|
{filtered.slice(0, 12).map((p) => (
|
||||||
|
<Chip
|
||||||
|
key={p.id}
|
||||||
|
label={p.name}
|
||||||
|
selected={productId === p.id}
|
||||||
|
onPress={() => setProductId(p.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 8 }}>
|
||||||
|
Selected: {selected.name} · {selected.unit}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ marginTop: 14, gap: 12 }}>
|
||||||
|
<Field
|
||||||
|
label="Quantity"
|
||||||
|
value={qty}
|
||||||
|
onChangeText={setQty}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
<Text variant="label" color={colors.muted}>
|
||||||
|
Frequency
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
{FREQUENCIES.map((f) => (
|
||||||
|
<Chip
|
||||||
|
key={f.days}
|
||||||
|
label={f.label}
|
||||||
|
selected={frequencyDays === f.days}
|
||||||
|
onPress={() => setFrequencyDays(f.days)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="label" color={colors.muted}>
|
||||||
|
Fulfillment
|
||||||
|
</Text>
|
||||||
|
<Segmented
|
||||||
|
value={fulfillment}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFulfillment(v);
|
||||||
|
if (v === "delivery" && payment === "pay_at_branch") setPayment("chapa");
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "delivery", label: "Delivery" },
|
||||||
|
{ value: "pickup", label: "Pickup" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text variant="label" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
Payment
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
"chapa",
|
||||||
|
"telebirr",
|
||||||
|
"mpesa",
|
||||||
|
...(fulfillment === "pickup" ? (["pay_at_branch"] as const) : []),
|
||||||
|
] as PaymentMethod[]
|
||||||
|
).map((m) => (
|
||||||
|
<Chip
|
||||||
|
key={m}
|
||||||
|
label={m}
|
||||||
|
selected={payment === m}
|
||||||
|
onPress={() => setPayment(m)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="label" color={colors.muted}>
|
||||||
|
For family member (optional)
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
<Chip label="Me" selected={!memberId} onPress={() => setMemberId(null)} />
|
||||||
|
{members
|
||||||
|
.filter((m) => m.status === "accepted")
|
||||||
|
.map((m) => (
|
||||||
|
<Chip
|
||||||
|
key={m.id}
|
||||||
|
label={m.name}
|
||||||
|
selected={memberId === m.id}
|
||||||
|
onPress={() => setMemberId(m.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Start subscription"
|
||||||
|
onPress={() => {
|
||||||
|
add({
|
||||||
|
productId: selected.id,
|
||||||
|
productName: selected.name,
|
||||||
|
quantity: Math.max(1, Number(qty) || 1),
|
||||||
|
frequencyDays,
|
||||||
|
fulfillment,
|
||||||
|
paymentMethod: payment,
|
||||||
|
memberId,
|
||||||
|
});
|
||||||
|
setShowCreate(false);
|
||||||
|
Alert.alert(
|
||||||
|
"Subscription active",
|
||||||
|
`${selected.name} every ${frequencyDays} days via ${fulfillment}.`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button title="Cancel" variant="ghost" onPress={() => setShowCreate(false)} />
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map((s) => (
|
||||||
|
<Card key={s.id}>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", gap: 12 }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{s.productName}</Text>
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
|
Every {s.frequencyDays} days · Qty {s.quantity} · {s.fulfillment}
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||||
|
Next: {new Date(s.nextFulfillmentAt).toLocaleDateString()} · {s.paymentMethod}
|
||||||
|
{s.memberId
|
||||||
|
? ` · ${members.find((m) => m.id === s.memberId)?.name ?? "Family"}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Badge
|
||||||
|
label={s.status}
|
||||||
|
tone={
|
||||||
|
s.status === "active" ? "brand" : s.status === "paused" ? "warn" : "danger"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", gap: 10, marginTop: 14 }}>
|
||||||
|
{s.status === "active" ? (
|
||||||
|
<Button title="Pause" size="sm" variant="outline" fullWidth={false} onPress={() => pause(s.id)} />
|
||||||
|
) : s.status === "paused" ? (
|
||||||
|
<Button title="Resume" size="sm" fullWidth={false} onPress={() => resume(s.id)} />
|
||||||
|
) : null}
|
||||||
|
{s.status !== "cancelled" ? (
|
||||||
|
<Button
|
||||||
|
title="Cancel"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
fullWidth={false}
|
||||||
|
onPress={() => cancel(s.id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
app/(root)/(screens)/tracking/[id].tsx
Normal file
122
app/(root)/(screens)/tracking/[id].tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { useLocalSearchParams, router } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import MapView, { Marker } from "@/components/maps/AppMap";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card, InfoRow } from "@/components/ui/Card";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { getBranchById } from "@/lib/data/branches";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useOrdersStore } from "@/lib/stores/ordersStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { formatETB } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function TrackingScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const order = useOrdersStore((s) => s.orders.find((o) => o.id === id));
|
||||||
|
const updateStatus = useOrdersStore((s) => s.updateStatus);
|
||||||
|
const branch = order?.branchId ? getBranchById(order.branchId) : undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!order) return;
|
||||||
|
if (order.fulfillment === "pickup") return;
|
||||||
|
if (order.riderAssigned || order.status === "cancelled" || order.status === "delivered") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (order.status === "paid" || order.status === "confirmed") {
|
||||||
|
const t = setTimeout(() => updateStatus(order.id, "dispatched"), 2500);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [order?.id, order?.status, order?.fulfillment]);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title="Tracking" />
|
||||||
|
<View style={{ padding: gutter }}>
|
||||||
|
<Text variant="body" color={colors.muted}>
|
||||||
|
Order not found.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = branch?.lat ?? 9.03;
|
||||||
|
const lng = branch?.lng ?? 38.74;
|
||||||
|
const isPickup = order.fulfillment === "pickup";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader
|
||||||
|
title={isPickup ? "Pickup status" : "Live tracking"}
|
||||||
|
subtitle={order.id}
|
||||||
|
right={<Badge label={order.status} tone="brand" />}
|
||||||
|
/>
|
||||||
|
<MapView
|
||||||
|
style={{ flex: 1, marginHorizontal: gutter, borderRadius: 20, overflow: "hidden" }}
|
||||||
|
initialRegion={{
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
latitudeDelta: 0.06,
|
||||||
|
longitudeDelta: 0.06,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Marker
|
||||||
|
coordinate={{ latitude: lat, longitude: lng }}
|
||||||
|
title={isPickup ? branch?.name ?? "Branch" : "Delivery area"}
|
||||||
|
pinColor={colors.brand}
|
||||||
|
/>
|
||||||
|
{!isPickup && order.riderAssigned ? (
|
||||||
|
<Marker
|
||||||
|
coordinate={{ latitude: lat - 0.01, longitude: lng + 0.012 }}
|
||||||
|
title="Rider"
|
||||||
|
description={order.status}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
<View style={{ padding: gutter, gap: 12 }}>
|
||||||
|
<Card elevated={false}>
|
||||||
|
<Text variant="h3">
|
||||||
|
{isPickup
|
||||||
|
? order.status === "delivered" || order.status === "completed"
|
||||||
|
? "Collected"
|
||||||
|
: "Ready for pickup soon"
|
||||||
|
: order.riderAssigned
|
||||||
|
? "Rider on the way"
|
||||||
|
: "Preparing your order"}
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 6 }}>
|
||||||
|
{isPickup
|
||||||
|
? `${branch?.name ?? "Branch"} · ${branch?.hours ?? ""} · ${branch?.area ?? order.areaLabel}`
|
||||||
|
: `${order.areaLabel ?? "Addis Ababa"} · ETA 25–40 min (mock)`}
|
||||||
|
</Text>
|
||||||
|
<InfoRow label="Total" value={formatETB(order.total)} />
|
||||||
|
<InfoRow label="Payment" value={order.paymentMethod} last />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="View full order"
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => router.push(routes.orderDetail(order.id))}
|
||||||
|
/>
|
||||||
|
{!isPickup && order.status !== "delivered" && order.status !== "cancelled" ? (
|
||||||
|
<Button
|
||||||
|
title="Mark delivered (demo)"
|
||||||
|
onPress={() => updateStatus(order.id, "delivered")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isPickup && order.status !== "delivered" && order.status !== "cancelled" ? (
|
||||||
|
<Button
|
||||||
|
title="Mark collected (demo)"
|
||||||
|
onPress={() => updateStatus(order.id, "delivered")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(root)/(tabs)/_layout.tsx
Normal file
5
app/(root)/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { FloatingTabBar } from "@/components/ui/FloatingTabBar";
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
return <FloatingTabBar />;
|
||||||
|
}
|
||||||
183
app/(root)/(tabs)/cart.tsx
Normal file
183
app/(root)/(tabs)/cart.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card, InfoRow } from "@/components/ui/Card";
|
||||||
|
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 { 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 insets = useSafeAreaInsets();
|
||||||
|
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>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 16,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
app/(root)/(tabs)/favorites.tsx
Normal file
61
app/(root)/(tabs)/favorites.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { ProductCard, GRID_CARD_GAP } from "@/components/ui/ProductCard";
|
||||||
|
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { products } from "@/lib/data/products";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
|
||||||
|
import { gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
export default function FavoritesScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const ids = useFavoritesStore((s) => s.ids);
|
||||||
|
const items = products.filter((p) => ids.includes(p.id));
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const cardWidth = (width - gutter * 2 - GRID_CARD_GAP) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 16,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="h1">{t("common.favorites")}</Text>
|
||||||
|
<Text variant="bodySm" color="#8A9188" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
app/(root)/(tabs)/index.tsx
Normal file
247
app/(root)/(tabs)/index.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { FlatList, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { ActionTile, CategoryTile } from "@/components/ui/CategoryTile";
|
||||||
|
import { Card, Section } from "@/components/ui/Card";
|
||||||
|
import { ProductRow, ProductTile } from "@/components/ui/ProductCard";
|
||||||
|
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
||||||
|
import { SearchAction, SearchField } from "@/components/ui/SearchField";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { products } from "@/lib/data/products";
|
||||||
|
import { categories, routes } from "@/lib/routes";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
||||||
|
import { colors, gutter, space } from "@/lib/theme";
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [category, setCategory] = useState("medicines");
|
||||||
|
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 12,
|
||||||
|
paddingBottom: TAB_BAR_INSET + 24,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push(routes.profile)}
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||||
|
>
|
||||||
|
<Avatar name={user?.name} uri={user?.avatarUri} size={46} />
|
||||||
|
<View>
|
||||||
|
<Text variant="caption" color={colors.subtle}>
|
||||||
|
Good day
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3">{user?.name?.split(" ")[0] || "Guest"}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push(routes.orders)}
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="notifications-outline" size={20} color={colors.ink} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="display" style={{ marginTop: 22 }}>
|
||||||
|
{t("common.trustedTitle")}
|
||||||
|
</Text>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
app/(root)/(tabs)/profile.tsx
Normal file
307
app/(root)/(tabs)/profile.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card, MenuRow } from "@/components/ui/Card";
|
||||||
|
import { Field } from "@/components/ui/Field";
|
||||||
|
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
||||||
|
import { 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 insets = useSafeAreaInsets();
|
||||||
|
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.prescriptions"), href: routes.rxList, icon: "document-text-outline" },
|
||||||
|
{ label: t("common.loyalty"), href: routes.loyalty, icon: "star-outline", subtitle: `${user?.points ?? 0} pts` },
|
||||||
|
{ label: t("common.family"), href: routes.family, icon: "people-outline" },
|
||||||
|
{ label: t("common.subscriptions"), href: routes.subscriptions, icon: "repeat-outline" },
|
||||||
|
{ label: t("common.emergencyContacts"), href: routes.emergencyContacts, icon: "alert-circle-outline" },
|
||||||
|
{ label: t("common.branches"), href: routes.branches, icon: "location-outline" },
|
||||||
|
{ 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>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 16,
|
||||||
|
paddingBottom: TAB_BAR_INSET + 24,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="h1">{t("common.profile")}</Text>
|
||||||
|
|
||||||
|
<Card style={{ marginTop: 16 }}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/(root)/_layout.tsx
Normal file
13
app/(root)/_layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { colors } from "@/lib/theme";
|
||||||
|
|
||||||
|
export default function RootGroupLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.page },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(root)/auth/_layout.tsx
Normal file
5
app/(root)/auth/_layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
58
app/(root)/auth/language.tsx
Normal file
58
app/(root)/auth/language.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { AppHeader } from "@/components/ui/Header";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useLangStore, type Language } from "@/lib/stores/langStore";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
const OPTIONS: { code: Language; label: string; hint: string }[] = [
|
||||||
|
{ code: "en", label: "English", hint: "Continue in English" },
|
||||||
|
{ code: "am", label: "አማርኛ", hint: "በአማርኛ ይቀጥሉ" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function LanguageScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const language = useLangStore((s) => s.language);
|
||||||
|
const setLanguage = useLangStore((s) => s.setLanguage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<AppHeader title={t("common.language")} subtitle="Choose your preferred language" />
|
||||||
|
<View style={{ flex: 1, paddingHorizontal: gutter, paddingTop: 8 }}>
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
{OPTIONS.map((opt) => {
|
||||||
|
const selected = language === opt.code;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={opt.code}
|
||||||
|
onPress={() => setLanguage(opt.code)}
|
||||||
|
style={{
|
||||||
|
padding: 18,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: selected ? 2 : 1,
|
||||||
|
borderColor: selected ? colors.brand : colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="h3">{opt.label}</Text>
|
||||||
|
<Text variant="caption" color={colors.muted} style={{ marginTop: 4 }}>
|
||||||
|
{opt.hint}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
<Button
|
||||||
|
title={t("common.continue")}
|
||||||
|
onPress={() => router.push(routes.otp)}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
app/(root)/auth/otp.tsx
Normal file
135
app/(root)/auth/otp.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, View } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import * as LocalAuthentication from "expo-local-authentication";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Field, SwitchRow } from "@/components/ui/Field";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { DEMO_OTP } from "@/lib/commerce";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
|
||||||
|
export default function OtpScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const signIn = useAuthStore((s) => s.signIn);
|
||||||
|
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
|
||||||
|
const setBiometric = useAuthStore((s) => s.setBiometric);
|
||||||
|
const [phone, setPhone] = useState("+251911000000");
|
||||||
|
const [name, setName] = useState("Kirukib Abebe");
|
||||||
|
const [email, setEmail] = useState("kirukib@example.com");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [bio, setBio] = useState(false);
|
||||||
|
|
||||||
|
const sendCode = () => {
|
||||||
|
if (!name.trim() || phone.replace(/\D/g, "").length < 9) {
|
||||||
|
Alert.alert("Missing info", "Enter your name and a valid phone number.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSent(true);
|
||||||
|
Alert.alert(
|
||||||
|
"Code sent (demo)",
|
||||||
|
`Use OTP ${DEMO_OTP} to verify. No real SMS is sent in this build.`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verify = async () => {
|
||||||
|
if (!sent) return;
|
||||||
|
if (code.trim() !== DEMO_OTP) {
|
||||||
|
Alert.alert("Invalid OTP", `Enter the demo code ${DEMO_OTP}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bio) {
|
||||||
|
const hw = await LocalAuthentication.hasHardwareAsync();
|
||||||
|
if (hw) {
|
||||||
|
const res = await LocalAuthentication.authenticateAsync({
|
||||||
|
promptMessage: "Enable biometric unlock",
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
Alert.alert("Biometric", "Skipped — you can enable later in profile.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signIn({
|
||||||
|
name: name.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
isGuest: false,
|
||||||
|
});
|
||||||
|
setBiometric(bio);
|
||||||
|
setHasOnboarded(true);
|
||||||
|
router.replace(routes.home);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: insets.top + 24,
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="h1">{t("common.signIn")}</Text>
|
||||||
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
|
||||||
|
{t("common.otpHint")} Demo OTP is always {DEMO_OTP}.
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: 20, gap: 12 }}>
|
||||||
|
<Field label="Full name" value={name} onChangeText={setName} icon="person-outline" />
|
||||||
|
<Field
|
||||||
|
label="Phone"
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
icon="call-outline"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Email (optional)"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
icon="mail-outline"
|
||||||
|
/>
|
||||||
|
{sent ? (
|
||||||
|
<Field
|
||||||
|
label="OTP code"
|
||||||
|
value={code}
|
||||||
|
onChangeText={setCode}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder={DEMO_OTP}
|
||||||
|
icon="key-outline"
|
||||||
|
hint={`Demo code: ${DEMO_OTP}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Card padded={false}>
|
||||||
|
<SwitchRow
|
||||||
|
icon="finger-print-outline"
|
||||||
|
title={t("common.enableBiometric")}
|
||||||
|
subtitle="Unlock the app faster next time"
|
||||||
|
value={bio}
|
||||||
|
onValueChange={setBio}
|
||||||
|
last
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
{!sent ? (
|
||||||
|
<Button title={t("common.sendOtp")} onPress={sendCode} />
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
<Button title={t("common.verify")} onPress={verify} />
|
||||||
|
<Button title="Resend code" variant="ghost" onPress={sendCode} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/(root)/auth/welcome.tsx
Normal file
71
app/(root)/auth/welcome.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Image, View } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Screen } from "@/components/ui/Screen";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { brand } from "@/lib/data/brand";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function WelcomeScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const setHasOnboarded = useAuthStore((s) => s.setHasOnboarded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: insets.top + 24,
|
||||||
|
paddingBottom: insets.bottom + 24,
|
||||||
|
paddingHorizontal: gutter + 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/logo/gishen-logo.png")}
|
||||||
|
style={{ width: 160, height: 160, resizeMode: "contain" }}
|
||||||
|
/>
|
||||||
|
<Text variant="display" center style={{ marginTop: 24 }}>
|
||||||
|
{t("common.appName")}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" color={colors.muted} center style={{ marginTop: 8, maxWidth: 300 }}>
|
||||||
|
{t("common.tagline")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={colors.subtle}
|
||||||
|
center
|
||||||
|
style={{ marginTop: 16, maxWidth: 320 }}
|
||||||
|
>
|
||||||
|
Est. {brand.founded} · {brand.branchCount} branches · Serving {brand.institutions}+
|
||||||
|
institutions across Addis Ababa.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t("common.getStarted")} onPress={() => router.push(routes.language)} />
|
||||||
|
<Button
|
||||||
|
title={t("common.guest")}
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => {
|
||||||
|
useAuthStore.getState().signIn({
|
||||||
|
name: "Guest",
|
||||||
|
phone: "",
|
||||||
|
isGuest: true,
|
||||||
|
});
|
||||||
|
setHasOnboarded(true);
|
||||||
|
router.replace(routes.home);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text variant="caption" color={colors.subtle} center>
|
||||||
|
Guests can browse. Loyalty, corporate and some care features need a signed-in account.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
app/_layout.tsx
Normal file
49
app/_layout.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import "./global.css";
|
||||||
|
import "@/lib/i18n";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { useFonts } from "expo-font";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { PlusJakartaSans_400Regular } from "@expo-google-fonts/plus-jakarta-sans/400Regular";
|
||||||
|
import { PlusJakartaSans_500Medium } from "@expo-google-fonts/plus-jakarta-sans/500Medium";
|
||||||
|
import { PlusJakartaSans_600SemiBold } from "@expo-google-fonts/plus-jakarta-sans/600SemiBold";
|
||||||
|
import { PlusJakartaSans_700Bold } from "@expo-google-fonts/plus-jakarta-sans/700Bold";
|
||||||
|
import { PlusJakartaSans_800ExtraBold } from "@expo-google-fonts/plus-jakarta-sans/800ExtraBold";
|
||||||
|
import { colors } from "@/lib/theme";
|
||||||
|
|
||||||
|
SplashScreen.preventAutoHideAsync().catch(() => {});
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [fontsLoaded, fontError] = useFonts({
|
||||||
|
Jakarta_400Regular: PlusJakartaSans_400Regular,
|
||||||
|
Jakarta_500Medium: PlusJakartaSans_500Medium,
|
||||||
|
Jakarta_600SemiBold: PlusJakartaSans_600SemiBold,
|
||||||
|
Jakarta_700Bold: PlusJakartaSans_700Bold,
|
||||||
|
Jakarta_800ExtraBold: PlusJakartaSans_800ExtraBold,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = fontsLoaded || !!fontError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) SplashScreen.hideAsync().catch(() => {});
|
||||||
|
}, [ready]);
|
||||||
|
|
||||||
|
if (!ready) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.page },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
app/global.css
Normal file
3
app/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
app/index.tsx
Normal file
10
app/index.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Redirect } from "expo-router";
|
||||||
|
import { useAuthStore } from "@/lib/stores/authStore";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const hasOnboarded = useAuthStore((s) => s.hasOnboarded);
|
||||||
|
if (!hasOnboarded) {
|
||||||
|
return <Redirect href="/(root)/auth/welcome" />;
|
||||||
|
}
|
||||||
|
return <Redirect href="/(root)/(tabs)" />;
|
||||||
|
}
|
||||||
BIN
assets/adaptive-icon.png
Normal file
BIN
assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
assets/logo/gishen-logo.png
Normal file
BIN
assets/logo/gishen-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
16
babel.config.js
Normal file
16
babel.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
"babel-preset-expo",
|
||||||
|
{
|
||||||
|
jsxImportSource: "nativewind",
|
||||||
|
// Zustand ESM uses import.meta; required for Expo web (SDK 53).
|
||||||
|
unstable_transformImportMeta: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"nativewind/babel",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
2
components/maps/AppMap.tsx
Normal file
2
components/maps/AppMap.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from "react-native-maps";
|
||||||
|
export { Marker } from "react-native-maps";
|
||||||
62
components/maps/AppMap.web.tsx
Normal file
62
components/maps/AppMap.web.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
import { colors } from "@/lib/theme";
|
||||||
|
|
||||||
|
type Region = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
latitudeDelta: number;
|
||||||
|
longitudeDelta: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapProps = {
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
initialRegion?: Region;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MapView({ style, children }: MapProps) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.wrap, style]}>
|
||||||
|
<Text style={styles.title}>Map preview</Text>
|
||||||
|
<Text style={styles.sub}>Live maps are available on iOS and Android.</Text>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkerProps = {
|
||||||
|
coordinate: { latitude: number; longitude: number };
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
pinColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Marker({ title, description }: MarkerProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.marker}>
|
||||||
|
<Text style={styles.markerTitle}>{title ?? "Pin"}</Text>
|
||||||
|
{description ? <Text style={styles.sub}>{description}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: {
|
||||||
|
backgroundColor: "#E8EDF2",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
title: { fontSize: 16, fontWeight: "800", color: colors.ink },
|
||||||
|
sub: { fontSize: 13, color: colors.muted, textAlign: "center" },
|
||||||
|
marker: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
markerTitle: { fontWeight: "700", color: colors.ink },
|
||||||
|
});
|
||||||
60
components/ui/Avatar.tsx
Normal file
60
components/ui/Avatar.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Image, View } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors } from "@/lib/theme";
|
||||||
|
|
||||||
|
function initials(name?: string) {
|
||||||
|
if (!name) return "G";
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
name,
|
||||||
|
uri,
|
||||||
|
size = 44,
|
||||||
|
tone = "brand",
|
||||||
|
}: {
|
||||||
|
name?: string;
|
||||||
|
uri?: string | null;
|
||||||
|
size?: number;
|
||||||
|
tone?: "brand" | "soft";
|
||||||
|
}) {
|
||||||
|
if (uri) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: tone === "brand" ? colors.brand : colors.brandSoft,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="h3"
|
||||||
|
color={tone === "brand" ? colors.ink : colors.brandText}
|
||||||
|
style={{ fontSize: size * 0.36 }}
|
||||||
|
>
|
||||||
|
{initials(name)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
components/ui/Badge.tsx
Normal file
138
components/ui/Badge.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors, radii } from "@/lib/theme";
|
||||||
|
|
||||||
|
type Tone = "brand" | "danger" | "warn" | "info" | "neutral" | "dark";
|
||||||
|
|
||||||
|
const tones: Record<Tone, { bg: string; fg: string }> = {
|
||||||
|
brand: { bg: colors.brandSoft, fg: colors.brandText },
|
||||||
|
danger: { bg: colors.dangerSoft, fg: colors.danger },
|
||||||
|
warn: { bg: colors.warnSoft, fg: colors.warn },
|
||||||
|
info: { bg: colors.infoSoft, fg: colors.info },
|
||||||
|
neutral: { bg: colors.surfaceAlt, fg: colors.muted },
|
||||||
|
dark: { bg: colors.ink, fg: colors.surface },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
label,
|
||||||
|
tone = "brand",
|
||||||
|
icon,
|
||||||
|
solid,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
tone?: Tone;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
/** Filled lime treatment used for discount flags on imagery. */
|
||||||
|
solid?: boolean;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}) {
|
||||||
|
const skin = tones[tone];
|
||||||
|
const bg = solid ? colors.brand : skin.bg;
|
||||||
|
const fg = solid ? colors.ink : skin.fg;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
backgroundColor: bg,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: radii.xs,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{icon ? <Ionicons name={icon} size={11} color={fg} /> : null}
|
||||||
|
<Text variant="micro" color={fg}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockLabel({
|
||||||
|
inStock,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
inStock: boolean;
|
||||||
|
count?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Text variant="micro" color={inStock ? colors.inStock : colors.outOfStock}>
|
||||||
|
{inStock ? `In stock${count ? ` (${count})` : ""}` : "Out of stock"}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Rating({
|
||||||
|
value,
|
||||||
|
count,
|
||||||
|
showStars = true,
|
||||||
|
size = 13,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
count?: number;
|
||||||
|
showStars?: boolean;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||||
|
{showStars ? (
|
||||||
|
<View style={{ flexDirection: "row", gap: 1 }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Ionicons
|
||||||
|
key={i}
|
||||||
|
name={
|
||||||
|
value >= i ? "star" : value >= i - 0.5 ? "star-half" : "star-outline"
|
||||||
|
}
|
||||||
|
size={size}
|
||||||
|
color={value >= i - 0.5 ? colors.star : colors.disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Ionicons name="star" size={size} color={colors.star} />
|
||||||
|
)}
|
||||||
|
<Text variant="micro" color={colors.muted}>
|
||||||
|
{value.toFixed(1)}
|
||||||
|
{count !== undefined ? ` (${count})` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact price with optional struck-through original. */
|
||||||
|
export function Price({
|
||||||
|
value,
|
||||||
|
compareAt,
|
||||||
|
size = "md",
|
||||||
|
format,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
compareAt?: number;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
format: (n: number) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
|
||||||
|
<Text variant={size === "lg" ? "priceLg" : size === "sm" ? "label" : "price"}>
|
||||||
|
{format(value)}
|
||||||
|
</Text>
|
||||||
|
{compareAt && compareAt > value ? (
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={colors.subtle}
|
||||||
|
style={{ textDecorationLine: "line-through" }}
|
||||||
|
>
|
||||||
|
{format(compareAt)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
components/ui/Button.tsx
Normal file
200
components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
onPress={onPress}
|
||||||
|
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} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small round "+" affordance on product cards. */
|
||||||
|
export function AddButton({
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
size = 34,
|
||||||
|
}: {
|
||||||
|
onPress?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Add to cart"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: disabled ? colors.surfaceAlt : colors.brand,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: pressed ? 0.8 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={size * 0.55} color={disabled ? colors.subtle : colors.ink} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonRow({ children }: { children: ReactNode }) {
|
||||||
|
return <View style={{ flexDirection: "row", gap: 10 }}>{children}</View>;
|
||||||
|
}
|
||||||
221
components/ui/Card.tsx
Normal file
221
components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors, radii, shadows, space } from "@/lib/theme";
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
padded = true,
|
||||||
|
tone = "surface",
|
||||||
|
elevated = true,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
padded?: boolean;
|
||||||
|
tone?: "surface" | "wash" | "brand";
|
||||||
|
elevated?: boolean;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}) {
|
||||||
|
const bg =
|
||||||
|
tone === "wash" ? colors.surfaceAlt : tone === "brand" ? colors.brandWash : colors.surface;
|
||||||
|
|
||||||
|
const base: StyleProp<ViewStyle> = [
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRadius: radii.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: tone === "brand" ? colors.brandBorder : colors.line,
|
||||||
|
padding: padded ? space.lg : 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
elevated && tone === "surface" ? shadows.xs : null,
|
||||||
|
style,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!onPress) return <View style={base}>{children}</View>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => [base, pressed && { opacity: 0.92 }]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionHeader({
|
||||||
|
title,
|
||||||
|
action,
|
||||||
|
onAction,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
action?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: space.md,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text variant="h2">{title}</Text>
|
||||||
|
{action ? (
|
||||||
|
<Pressable onPress={onAction} hitSlop={8}>
|
||||||
|
<Text variant="label" color={colors.brandText}>
|
||||||
|
{action}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({
|
||||||
|
title,
|
||||||
|
action,
|
||||||
|
onAction,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
action?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[{ marginTop: space["2xl"] }, style]}>
|
||||||
|
{title ? <SectionHeader title={title} action={action} onAction={onAction} /> : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Divider({ style }: { style?: StyleProp<ViewStyle> }) {
|
||||||
|
return <View style={[{ height: 1, backgroundColor: colors.line }, style]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key/value row used for drug facts and order summaries. */
|
||||||
|
export function InfoRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueColor,
|
||||||
|
emphasis,
|
||||||
|
last,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueColor?: string;
|
||||||
|
emphasis?: boolean;
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: last ? 0 : 1,
|
||||||
|
borderBottomColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="bodySm" color={colors.muted}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant={emphasis ? "h3" : "label"}
|
||||||
|
color={valueColor ?? colors.ink}
|
||||||
|
style={{ flexShrink: 1, textAlign: "right" }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tappable settings/menu row with icon tile, title and chevron. */
|
||||||
|
export function MenuRow({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onPress,
|
||||||
|
right,
|
||||||
|
tone = "brand",
|
||||||
|
last,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
right?: ReactNode;
|
||||||
|
tone?: "brand" | "danger" | "neutral";
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
|
const iconBg =
|
||||||
|
tone === "danger"
|
||||||
|
? colors.dangerSoft
|
||||||
|
: tone === "neutral"
|
||||||
|
? colors.surfaceAlt
|
||||||
|
: colors.brandSoft;
|
||||||
|
const iconFg =
|
||||||
|
tone === "danger" ? colors.danger : tone === "neutral" ? colors.muted : colors.brandText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: space.lg,
|
||||||
|
borderBottomWidth: last ? 0 : 1,
|
||||||
|
borderBottomColor: colors.line,
|
||||||
|
backgroundColor: pressed ? colors.surfaceAlt : "transparent",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: iconBg,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={19} color={iconFg} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3" color={tone === "danger" ? colors.danger : colors.ink}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{right ?? <Ionicons name="chevron-forward" size={18} color={colors.subtle} />}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
components/ui/CategoryTile.tsx
Normal file
109
components/ui/CategoryTile.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors, radii } from "@/lib/theme";
|
||||||
|
|
||||||
|
export function CategoryTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 72,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
opacity: pressed ? 0.85 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: selected ? colors.brand : colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: selected ? colors.brand : colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={23} color={selected ? colors.ink : colors.brandText} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
variant="micro"
|
||||||
|
color={selected ? colors.ink : colors.muted}
|
||||||
|
numberOfLines={1}
|
||||||
|
center
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Larger square action used for the prescription entry points on Home. */
|
||||||
|
export function ActionTile({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
tone = "brand",
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
tone?: "brand" | "surface";
|
||||||
|
onPress?: () => void;
|
||||||
|
}) {
|
||||||
|
const brand = tone === "brand";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: brand ? colors.brand : colors.surface,
|
||||||
|
borderRadius: radii.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: brand ? colors.brand : colors.line,
|
||||||
|
padding: 14,
|
||||||
|
gap: 10,
|
||||||
|
opacity: pressed ? 0.92 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: brand ? "rgba(16,20,16,0.12)" : colors.brandSoft,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={19} color={brand ? colors.ink : colors.brandText} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text variant="h3" color={colors.ink}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={brand ? "rgba(16,20,16,0.62)" : colors.subtle}
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
components/ui/Chip.tsx
Normal file
138
components/ui/Chip.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors, radii, space } from "@/lib/theme";
|
||||||
|
|
||||||
|
export function Chip({
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
onRemove,
|
||||||
|
icon,
|
||||||
|
tone = "default",
|
||||||
|
size = "md",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
tone?: "default" | "outline";
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}) {
|
||||||
|
const bg = selected ? colors.brand : tone === "outline" ? "transparent" : colors.surface;
|
||||||
|
const fg = selected ? colors.ink : colors.muted;
|
||||||
|
const border = selected ? colors.brand : colors.line;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingVertical: size === "sm" ? 6 : 9,
|
||||||
|
paddingHorizontal: size === "sm" ? 10 : 14,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
opacity: pressed ? 0.8 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{icon ? <Ionicons name={icon} size={13} color={fg} /> : null}
|
||||||
|
<Text variant={size === "sm" ? "caption" : "label"} color={fg}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{onRemove ? (
|
||||||
|
<Pressable onPress={onRemove} hitSlop={8}>
|
||||||
|
<Ionicons name="close" size={14} color={fg} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontally scrolling chip strip that bleeds to the screen edge. */
|
||||||
|
export function ChipRow({
|
||||||
|
children,
|
||||||
|
gutters = 20,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
gutters?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
gap: space.sm,
|
||||||
|
paddingHorizontal: gutters,
|
||||||
|
}}
|
||||||
|
style={{ marginHorizontal: -gutters }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only meta tag, e.g. "Tablet" or "Qty: 100". */
|
||||||
|
export function MetaTag({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||||
|
{icon ? <Ionicons name={icon} size={11} color={colors.subtle} /> : null}
|
||||||
|
<Text variant="micro" color={colors.subtle}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Segmented<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
onPress={() => onChange(option.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
backgroundColor: active ? colors.surface : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="h3" color={active ? colors.ink : colors.subtle}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/ui/EmptyState.tsx
Normal file
61
components/ui/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors } from "@/lib/theme";
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon = "cube-outline",
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
actionTitle,
|
||||||
|
onAction,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
actionTitle?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: compact ? 32 : 56,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 36,
|
||||||
|
backgroundColor: colors.brandSoft,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={32} color={colors.brandText} />
|
||||||
|
</View>
|
||||||
|
<Text variant="h2" center style={{ marginTop: 18 }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{body ? (
|
||||||
|
<Text variant="bodySm" color={colors.subtle} center style={{ marginTop: 6, maxWidth: 280 }}>
|
||||||
|
{body}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{actionTitle && onAction ? (
|
||||||
|
<Button
|
||||||
|
title={actionTitle}
|
||||||
|
onPress={onAction}
|
||||||
|
size="md"
|
||||||
|
fullWidth={false}
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
components/ui/Field.tsx
Normal file
232
components/ui/Field.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors, fonts, radii } from "@/lib/theme";
|
||||||
|
|
||||||
|
type FieldProps = TextInputProps & {
|
||||||
|
label?: string;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
secure?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
secure,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: FieldProps) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [hidden, setHidden] = useState(!!secure);
|
||||||
|
|
||||||
|
const borderColor = error ? colors.danger : focused ? colors.brand : colors.line;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{label ? (
|
||||||
|
<Text variant="label" color={colors.muted} style={{ marginBottom: 8 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
height: 52,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons name={icon} size={18} color={focused ? colors.brandText : colors.subtle} />
|
||||||
|
) : null}
|
||||||
|
<TextInput
|
||||||
|
placeholderTextColor={colors.subtle}
|
||||||
|
secureTextEntry={hidden}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: fonts.regular,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.ink,
|
||||||
|
paddingVertical: 0,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{secure ? (
|
||||||
|
<Pressable onPress={() => setHidden(!hidden)} hitSlop={8}>
|
||||||
|
<Ionicons
|
||||||
|
name={hidden ? "eye-off-outline" : "eye-outline"}
|
||||||
|
size={18}
|
||||||
|
color={colors.subtle}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{error ? (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 5, marginTop: 6 }}>
|
||||||
|
<Ionicons name="alert-circle" size={13} color={colors.danger} />
|
||||||
|
<Text variant="caption" color={colors.danger}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : hint ? (
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 6 }}>
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchRow({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
icon,
|
||||||
|
last,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (v: boolean) => void;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: last ? 0 : 1,
|
||||||
|
borderBottomColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.brandSoft,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={19} color={colors.brandText} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{title}</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
trackColor={{ false: colors.line, true: colors.brand }}
|
||||||
|
thumbColor={colors.surface}
|
||||||
|
ios_backgroundColor={colors.line}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radio-style selectable row used for payment methods and addresses. */
|
||||||
|
export function OptionRow({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
icon,
|
||||||
|
trailing,
|
||||||
|
last,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: last ? 0 : 1,
|
||||||
|
borderBottomColor: colors.line,
|
||||||
|
backgroundColor: selected ? colors.brandWash : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: selected ? colors.brandSoft : colors.surfaceAlt,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={19}
|
||||||
|
color={selected ? colors.brandText : colors.muted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="h3">{title}</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{trailing}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
borderWidth: selected ? 7 : 1.5,
|
||||||
|
borderColor: selected ? colors.brand : colors.line,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
components/ui/FloatingTabBar.tsx
Normal file
140
components/ui/FloatingTabBar.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
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="profile"
|
||||||
|
options={{
|
||||||
|
title: t("common.profile"),
|
||||||
|
tabBarIcon: ({ focused }) => (
|
||||||
|
<TabIcon name={focused ? "person" : "person-outline"} focused={focused} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/ui/Header.tsx
Normal file
76
components/ui/Header.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
large,
|
||||||
|
back = true,
|
||||||
|
onBack,
|
||||||
|
right,
|
||||||
|
background = "transparent",
|
||||||
|
}: Props) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 8,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingBottom: large ? 4 : 12,
|
||||||
|
backgroundColor: background,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
|
||||||
|
{back ? (
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-back"
|
||||||
|
accessibilityLabel="Go back"
|
||||||
|
onPress={onBack ?? (() => router.back())}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!large && title ? (
|
||||||
|
<Text variant="h2" numberOfLines={1} style={{ flex: 1 }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
)}
|
||||||
|
{right}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{large && title ? (
|
||||||
|
<View style={{ marginTop: 16, marginBottom: 8 }}>
|
||||||
|
<Text variant="h1">{title}</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!large && subtitle ? (
|
||||||
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2, marginLeft: 52 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
components/ui/ProductCard.tsx
Normal file
262
components/ui/ProductCard.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { Image, Pressable, View } from "react-native";
|
||||||
|
import { AddButton } from "@/components/ui/Button";
|
||||||
|
import { Badge, Price, StockLabel } from "@/components/ui/Badge";
|
||||||
|
import { MetaTag } from "@/components/ui/Chip";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { getDrugFacts } from "@/lib/data/clinical";
|
||||||
|
import type { Product } from "@/lib/data/products";
|
||||||
|
import { routes } from "@/lib/routes";
|
||||||
|
import { useCartStore } from "@/lib/stores/cartStore";
|
||||||
|
import { useFavoritesStore } from "@/lib/stores/favoritesStore";
|
||||||
|
import { colors, radii, shadows, space } from "@/lib/theme";
|
||||||
|
import { formatETB } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const GRID_CARD_GAP = 12;
|
||||||
|
|
||||||
|
function discount(product: Product) {
|
||||||
|
if (!product.compareAt || product.compareAt <= product.price) return null;
|
||||||
|
return `${Math.round(((product.compareAt - product.price) / product.compareAt) * 100)}% OFF`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCardActions(product: Product) {
|
||||||
|
const addProduct = useCartStore((s) => s.addProduct);
|
||||||
|
return {
|
||||||
|
open: () => router.push(routes.product(product.id)),
|
||||||
|
add: () => addProduct(product, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grid tile used in shop, search results and category listings. */
|
||||||
|
export function ProductCard({
|
||||||
|
product,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
product: Product;
|
||||||
|
width?: number | `${number}%`;
|
||||||
|
}) {
|
||||||
|
const { open, add } = useCardActions(product);
|
||||||
|
const facts = getDrugFacts(product);
|
||||||
|
const off = discount(product);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={open}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: radii.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
padding: space.md,
|
||||||
|
opacity: pressed ? 0.94 : 1,
|
||||||
|
},
|
||||||
|
shadows.xs,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 108,
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: product.image }}
|
||||||
|
style={{ width: "82%", height: "82%" }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
{off ? (
|
||||||
|
<Badge label={off} solid style={{ position: "absolute", top: 8, left: 8 }} />
|
||||||
|
) : null}
|
||||||
|
{product.requiresPrescription ? (
|
||||||
|
<Badge
|
||||||
|
label="RX"
|
||||||
|
tone="info"
|
||||||
|
style={{ position: "absolute", top: 8, right: 8 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="micro" color={colors.subtle} style={{ marginTop: 10 }}>
|
||||||
|
{facts.useCase}
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" numberOfLines={2} style={{ marginTop: 2, minHeight: 40 }}>
|
||||||
|
{product.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ marginTop: 6 }}>
|
||||||
|
<Price value={product.price} compareAt={product.compareAt} format={formatETB} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, marginTop: 8 }}>
|
||||||
|
<MetaTag label={facts.form} icon="ellipse-outline" />
|
||||||
|
<MetaTag label={product.unit} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StockLabel inStock={product.inStock} count={facts.stockCount} />
|
||||||
|
<AddButton onPress={add} disabled={!product.inStock} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal row used in "Most popular", favorites and list-view results. */
|
||||||
|
export function ProductRow({ product }: { product: Product }) {
|
||||||
|
const { open, add } = useCardActions(product);
|
||||||
|
const facts = getDrugFacts(product);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={open}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: space.md,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: radii.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
padding: space.md,
|
||||||
|
alignItems: "center",
|
||||||
|
opacity: pressed ? 0.94 : 1,
|
||||||
|
},
|
||||||
|
shadows.xs,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 66,
|
||||||
|
height: 66,
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: product.image }}
|
||||||
|
style={{ width: "80%", height: "80%" }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text variant="micro" color={colors.subtle}>
|
||||||
|
{facts.useCase}
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" numberOfLines={1} style={{ marginTop: 1 }}>
|
||||||
|
{product.name}
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: 4 }}>
|
||||||
|
<Price value={product.price} compareAt={product.compareAt} format={formatETB} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10, marginTop: 5 }}>
|
||||||
|
<MetaTag label={facts.form} icon="ellipse-outline" />
|
||||||
|
<StockLabel inStock={product.inStock} count={facts.stockCount} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<AddButton onPress={add} disabled={!product.inStock} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact tile for horizontal carousels on the home screen. */
|
||||||
|
export function ProductTile({ product }: { product: Product }) {
|
||||||
|
const { open, add } = useCardActions(product);
|
||||||
|
const liked = useFavoritesStore((s) => s.has(product.id));
|
||||||
|
const toggle = useFavoritesStore((s) => s.toggle);
|
||||||
|
const off = discount(product);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={open}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
{
|
||||||
|
width: 158,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: radii.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
padding: space.md,
|
||||||
|
opacity: pressed ? 0.94 : 1,
|
||||||
|
},
|
||||||
|
shadows.xs,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 96,
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: product.image }}
|
||||||
|
style={{ width: "82%", height: "82%" }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
{off ? (
|
||||||
|
<Badge label={off} solid style={{ position: "absolute", top: 6, left: 6 }} />
|
||||||
|
) : null}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => toggle(product.id)}
|
||||||
|
hitSlop={8}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 13,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={liked ? "heart" : "heart-outline"}
|
||||||
|
size={14}
|
||||||
|
color={liked ? colors.danger : colors.muted}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="h3" numberOfLines={2} style={{ marginTop: 10, minHeight: 40 }}>
|
||||||
|
{product.name}
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: 4 }}>
|
||||||
|
<Price value={product.price} compareAt={product.compareAt} size="sm" format={formatETB} />
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StockLabel inStock={product.inStock} />
|
||||||
|
<AddButton onPress={add} disabled={!product.inStock} size={30} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
components/ui/QtyStepper.tsx
Normal file
83
components/ui/QtyStepper.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors, radii } from "@/lib/theme";
|
||||||
|
|
||||||
|
export function QtyStepper({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 1,
|
||||||
|
max = 99,
|
||||||
|
size = "md",
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (n: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}) {
|
||||||
|
const dim = size === "sm" ? 26 : 32;
|
||||||
|
const iconSize = size === "sm" ? 14 : 17;
|
||||||
|
|
||||||
|
const step = (delta: number) => {
|
||||||
|
const next = Math.min(max, Math.max(min, value + delta));
|
||||||
|
if (next !== value) onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
padding: 3,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Decrease quantity"
|
||||||
|
onPress={() => step(-1)}
|
||||||
|
disabled={value <= min}
|
||||||
|
style={{
|
||||||
|
width: dim,
|
||||||
|
height: dim,
|
||||||
|
borderRadius: dim / 2,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="remove"
|
||||||
|
size={iconSize}
|
||||||
|
color={value <= min ? colors.disabled : colors.ink}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ minWidth: 30, alignItems: "center" }}>
|
||||||
|
<Text variant="h3">{value}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Increase quantity"
|
||||||
|
onPress={() => step(1)}
|
||||||
|
disabled={value >= max}
|
||||||
|
style={{
|
||||||
|
width: dim,
|
||||||
|
height: dim,
|
||||||
|
borderRadius: dim / 2,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: value >= max ? colors.surfaceAlt : colors.brand,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="add"
|
||||||
|
size={iconSize}
|
||||||
|
color={value >= max ? colors.disabled : colors.ink}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/ui/Screen.tsx
Normal file
63
components/ui/Screen.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
type ScrollViewProps,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { colors, gutter } from "@/lib/theme";
|
||||||
|
|
||||||
|
type ScreenProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Adds bottom room for the floating tab bar. */
|
||||||
|
tabBarPadding?: boolean;
|
||||||
|
background?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Screen({ children, background = colors.page, style }: ScreenProps) {
|
||||||
|
return <View style={[{ flex: 1, backgroundColor: background }, style]}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScreenScrollProps = ScrollViewProps & {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Extra bottom inset, e.g. for a sticky bar or the floating tab bar. */
|
||||||
|
bottomInset?: number;
|
||||||
|
/** Set false when the screen renders its own header with the top inset. */
|
||||||
|
topInset?: boolean;
|
||||||
|
gutters?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScreenScroll({
|
||||||
|
children,
|
||||||
|
bottomInset = 32,
|
||||||
|
topInset = false,
|
||||||
|
gutters = true,
|
||||||
|
contentContainerStyle,
|
||||||
|
...rest
|
||||||
|
}: ScreenScrollProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={[
|
||||||
|
{
|
||||||
|
paddingTop: topInset ? insets.top + 12 : 0,
|
||||||
|
paddingBottom: insets.bottom + bottomInset,
|
||||||
|
paddingHorizontal: gutters ? gutter : 0,
|
||||||
|
},
|
||||||
|
contentContainerStyle,
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom padding that clears the floating tab bar. */
|
||||||
|
export const TAB_BAR_INSET = 96;
|
||||||
119
components/ui/SearchField.tsx
Normal file
119
components/ui/SearchField.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/Text";
|
||||||
|
import { colors, fonts, radii } from "@/lib/theme";
|
||||||
|
|
||||||
|
type Props = TextInputProps & {
|
||||||
|
onClear?: () => void;
|
||||||
|
/** Renders as a button instead of an input (home screen → search screen). */
|
||||||
|
readOnlyPress?: () => void;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SearchField({
|
||||||
|
value,
|
||||||
|
onClear,
|
||||||
|
readOnlyPress,
|
||||||
|
trailing,
|
||||||
|
placeholder = "Search medicine, brand or symptom",
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: focused ? colors.brand : colors.line,
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingRight: trailing ? 6 : 16,
|
||||||
|
height: 52,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="search" size={18} color={focused ? colors.brandText : colors.subtle} />
|
||||||
|
{readOnlyPress ? (
|
||||||
|
<Text variant="body" color={colors.subtle} style={{ flex: 1 }} numberOfLines={1}>
|
||||||
|
{value || placeholder}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={colors.subtle}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
returnKeyType="search"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: fonts.regular,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.ink,
|
||||||
|
paddingVertical: 0,
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{value && onClear && !readOnlyPress ? (
|
||||||
|
<Pressable onPress={onClear} hitSlop={8}>
|
||||||
|
<Ionicons name="close-circle" size={18} color={colors.subtle} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
{trailing}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (readOnlyPress) {
|
||||||
|
return (
|
||||||
|
<Pressable onPress={readOnlyPress} style={({ pressed }) => ({ opacity: pressed ? 0.9 : 1 })}>
|
||||||
|
{body}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lime pill action that sits inside the search field (scan entry point). */
|
||||||
|
export function SearchAction({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: colors.brand,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
paddingHorizontal: label ? 14 : 10,
|
||||||
|
height: 40,
|
||||||
|
opacity: pressed ? 0.85 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={16} color={colors.ink} />
|
||||||
|
{label ? (
|
||||||
|
<Text variant="label" color={colors.ink}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
components/ui/StickyBar.tsx
Normal file
62
components/ui/StickyBar.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { colors, gutter, radii, shadows } from "@/lib/theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom action bar. `floating` renders the dark pill used on product detail;
|
||||||
|
* the default renders a flush surface bar used in cart and checkout.
|
||||||
|
*/
|
||||||
|
export function StickyBar({
|
||||||
|
children,
|
||||||
|
floating,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
floating?: boolean;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (floating) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
left: gutter,
|
||||||
|
right: gutter,
|
||||||
|
bottom: Math.max(insets.bottom, 14),
|
||||||
|
backgroundColor: colors.ink,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
shadows.md,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.line,
|
||||||
|
paddingHorizontal: gutter,
|
||||||
|
paddingTop: 14,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 14),
|
||||||
|
},
|
||||||
|
shadows.md,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/ui/Text.tsx
Normal file
27
components/ui/Text.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Text as RNText, type TextProps as RNTextProps } from "react-native";
|
||||||
|
import { colors, typography, type TypeVariant } from "@/lib/theme";
|
||||||
|
|
||||||
|
export type TextProps = RNTextProps & {
|
||||||
|
variant?: TypeVariant;
|
||||||
|
color?: string;
|
||||||
|
center?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide text primitive. React Native does not inherit `fontFamily`, so every
|
||||||
|
* piece of copy goes through here to stay on the type scale.
|
||||||
|
*/
|
||||||
|
export function Text({
|
||||||
|
variant = "body",
|
||||||
|
color = colors.ink,
|
||||||
|
center,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: TextProps) {
|
||||||
|
return (
|
||||||
|
<RNText
|
||||||
|
style={[typography[variant], { color }, center && { textAlign: "center" }, style]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
docs/BACKEND_SPEC.md
Normal file
135
docs/BACKEND_SPEC.md
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# Gishen-Mob Backend Spec
|
||||||
|
|
||||||
|
**Base URL (TBD):** `https://api.gishen.example/v1`
|
||||||
|
**Auth:** Bearer JWT after phone OTP
|
||||||
|
**Headers:** `Authorization`, `Accept-Language` (`en` | `am`), `Content-Type: application/json`
|
||||||
|
|
||||||
|
Shared with Gishen-Ecom / Gishen-B2B where noted.
|
||||||
|
Patch this file whenever a feature is added or finalized.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| POST | `/auth/otp/request` | Required | `{ phone }` |
|
||||||
|
| POST | `/auth/otp/verify` | Required | `{ phone, code }` → session + user |
|
||||||
|
| POST | `/auth/biometric/bind` | Required | Device biometric public key |
|
||||||
|
| GET | `/users/me` | Required | Profile, locale, points, tier |
|
||||||
|
| PATCH | `/users/me` | Required | name, email, `locale`, biometric flag |
|
||||||
|
|
||||||
|
## Locale
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| PATCH | `/users/me` | Required | `{ locale: "en" \| "am" }` |
|
||||||
|
| — | Accept-Language | Required | All requests |
|
||||||
|
|
||||||
|
## Catalog
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| GET | `/catalog/products` | Required | q, category, badge, max — parity with Ecom |
|
||||||
|
| GET | `/catalog/products/:idOrSlug` | Required | |
|
||||||
|
| GET | `/catalog/categories` | Required | |
|
||||||
|
|
||||||
|
## Branches / Stock
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| GET | `/branches` | Required | |
|
||||||
|
| GET | `/branches/nearby` | Required | `lat`, `lng` |
|
||||||
|
| GET | `/stock/product/:productId` | Required | Nearby availability |
|
||||||
|
|
||||||
|
## Orders
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| POST | `/orders` | Required | items, fulfillment, branchId?, location, paymentMethod, prescriptionFileKeys?, notes, credit? |
|
||||||
|
| GET | `/orders/me` | Required | |
|
||||||
|
| GET | `/orders/:id` | Required | |
|
||||||
|
| GET | `/orders/:id/tracking` | Required | Rider trail when assigned |
|
||||||
|
| POST | `/orders/:id/cancel` | Required | |
|
||||||
|
|
||||||
|
## Payments
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| POST | `/payments/initiate` | Required | `chapa` \| `telebirr` \| `mpesa` only for delivery |
|
||||||
|
| POST | `/payments/webhook/*` | Required | Gateway callbacks |
|
||||||
|
| — | `pay_at_branch` | Required | Allowed **only** when `fulfillment=pickup` |
|
||||||
|
|
||||||
|
## Prescriptions
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| POST | `/uploads/signed-url` | Required | Rx images |
|
||||||
|
| POST | `/prescriptions` | Required | Multi-page keys, medicines[], patient, memberId? |
|
||||||
|
| POST | `/prescriptions/check` | Required | Match catalog → ready/review/unavailable |
|
||||||
|
| GET | `/prescriptions/me` | Required | |
|
||||||
|
| POST | `/prescriptions/:id/reorder` | Required | |
|
||||||
|
|
||||||
|
## Reminders
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| POST | `/health/reminders` | Required | Dose/refill schedules |
|
||||||
|
| POST | `/health/reminders/:id/confirm` | Required | `dose.confirmed` |
|
||||||
|
| GET | `/health/reminders/me` | Required | |
|
||||||
|
|
||||||
|
## Emergency contacts
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| GET | `/users/me/emergency-contacts` | Required | |
|
||||||
|
| POST | `/users/me/emergency-contacts` | Required | name, phone, relationship |
|
||||||
|
| DELETE | `/users/me/emergency-contacts/:id` | Required | |
|
||||||
|
| — | Job `dose.missed_escalation` | Required | After grace (default 30m) SMS/push to contact if no confirm |
|
||||||
|
|
||||||
|
## Family
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| GET | `/family/members` | Required | |
|
||||||
|
| POST | `/family/invites` | Required | phone or link code |
|
||||||
|
| POST | `/family/invites/accept` | Required | |
|
||||||
|
| DELETE | `/family/members/:id` | Required | |
|
||||||
|
|
||||||
|
## Subscriptions
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| GET | `/subscriptions/me` | Required | |
|
||||||
|
| POST | `/subscriptions` | Required | |
|
||||||
|
| POST | `/subscriptions/:id/pause` | Required | |
|
||||||
|
| POST | `/subscriptions/:id/resume` | Required | |
|
||||||
|
| POST | `/subscriptions/:id/cancel` | Required | |
|
||||||
|
|
||||||
|
## B2B / Corporate (shared with Gishen-B2B)
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| GET | `/users/me/contexts` | Required | personal + org memberships |
|
||||||
|
| POST | `/orgs/join` | Required | `{ code }` |
|
||||||
|
| GET | `/company/me` | Required | Member view: plan, allowance |
|
||||||
|
| POST | `/checkout/credit/quote` | Required | Basket split covered vs selfPay |
|
||||||
|
|
||||||
|
## Loyalty
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| GET | `/loyalty/me` | Required | |
|
||||||
|
| GET | `/loyalty/me/ledger` | Required | |
|
||||||
|
| POST | `/loyalty/redeem` | Required | |
|
||||||
|
| GET | `/referrals/me` | Required | |
|
||||||
|
|
||||||
|
## Push / Tracking
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| POST | `/devices` | Required | push token |
|
||||||
|
| — | Events | Required | order.*, dispatch.*, dose.*, refill.*, escalation.* |
|
||||||
|
| GET | `/orders/:id/tracking` | Required | |
|
||||||
|
|
||||||
|
## Contact / Support
|
||||||
|
| Method | Path | Status | Notes |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| POST | `/contact` | Required | |
|
||||||
|
| — | Chatwoot | Deeplink | https://chat.yaltopia.com |
|
||||||
|
|
||||||
|
### Changelog
|
||||||
|
- 2026-08-06: Scaffold — skeleton created with all domains.
|
||||||
|
- 2026-08-06: Auth/Locale — OTP mock, biometric bind flag, language picker en/am, Accept-Language noted.
|
||||||
|
- 2026-08-06: Catalog — products/categories from Ecom data port; shop/search/favorites.
|
||||||
|
- 2026-08-06: Branches/Stock — GPS nearest sort; pickup selection (stock map stub).
|
||||||
|
- 2026-08-06: Orders/Payments/B2B — checkout with Chapa/Telebirr/M-Pesa; pay_at_branch pickup-only; WhatsApp handoff; credit quote split stub.
|
||||||
|
- 2026-08-06: Prescriptions/Reminders/Emergency — camera multi-page scan, check statuses, calendar dose push, escalation job contract.
|
||||||
|
- 2026-08-06: Family/Subscriptions — invite/manage; refill subscription CRUD.
|
||||||
|
- 2026-08-06: B2B/Account — contexts, join codes, profile switcher.
|
||||||
|
- 2026-08-06: Loyalty/Push/Tracking — wallet redeem/referral; order tracking map mock; device push channels.
|
||||||
|
- 2026-08-06: Contact/Support — contact form + Chatwoot/WhatsApp/call deeplinks.
|
||||||
34
lib/api/client.ts
Normal file
34
lib/api/client.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/** API client stub — swap BASE_URL when shared backend is live. */
|
||||||
|
|
||||||
|
export const BASE_URLS = {
|
||||||
|
core: process.env.EXPO_PUBLIC_API_URL ?? "https://api.gishen.example/v1",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ApiError = { message: string; code?: string };
|
||||||
|
|
||||||
|
export async function callEndpoint<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit & { locale?: string }
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
...(init?.locale ? { "Accept-Language": init.locale } : {}),
|
||||||
|
...(init?.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock mode until backend exists
|
||||||
|
if (!process.env.EXPO_PUBLIC_API_URL) {
|
||||||
|
throw new Error(`API not configured for ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URLS.core}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as ApiError;
|
||||||
|
throw new Error(body.message || res.statusText);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
21
lib/commerce.ts
Normal file
21
lib/commerce.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/** 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";
|
||||||
|
|
||||||
|
export function voucherDiscount(subtotal: number, code: string | null | undefined) {
|
||||||
|
if (!code) return 0;
|
||||||
|
if (code.trim().toUpperCase() === DEMO_VOUCHER) {
|
||||||
|
return Math.round(subtotal * DEMO_VOUCHER_PCT);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderSubtotalTotal(input: {
|
||||||
|
subtotal: number;
|
||||||
|
deliveryFee: number;
|
||||||
|
discount?: number;
|
||||||
|
}) {
|
||||||
|
return Math.max(0, input.subtotal + input.deliveryFee - (input.discount ?? 0));
|
||||||
|
}
|
||||||
142
lib/data/branches.ts
Normal file
142
lib/data/branches.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
export type Branch = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
area: string;
|
||||||
|
phone: string;
|
||||||
|
hours: string;
|
||||||
|
is24h: boolean;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
pickupAvailable: boolean;
|
||||||
|
mapsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Verified from gishenpharmacy.wordpress.com contact page + public listings. */
|
||||||
|
export const branches: Branch[] = [
|
||||||
|
{
|
||||||
|
id: "bole",
|
||||||
|
slug: "bole",
|
||||||
|
name: "Bole Branch",
|
||||||
|
area: "Bole, Addis Ababa",
|
||||||
|
phone: "+251930033290",
|
||||||
|
hours: "Open 24 hours",
|
||||||
|
is24h: true,
|
||||||
|
lat: 8.9806,
|
||||||
|
lng: 38.799,
|
||||||
|
pickupAvailable: true,
|
||||||
|
mapsUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Bole%20Addis%20Ababa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cmc",
|
||||||
|
slug: "cmc-michael",
|
||||||
|
name: "CMC Michael Branch",
|
||||||
|
area: "CMC Michael, Addis Ababa",
|
||||||
|
phone: "+251930102175",
|
||||||
|
hours: "Open 24 hours",
|
||||||
|
is24h: true,
|
||||||
|
lat: 9.0227,
|
||||||
|
lng: 38.826,
|
||||||
|
pickupAvailable: true,
|
||||||
|
mapsUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20CMC%20Michael%20Addis%20Ababa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jemo",
|
||||||
|
slug: "jemo",
|
||||||
|
name: "Jemo Branch",
|
||||||
|
area: "Jemo, Addis Ababa",
|
||||||
|
phone: "+251930033292",
|
||||||
|
hours: "Open 24 hours",
|
||||||
|
is24h: true,
|
||||||
|
lat: 8.963,
|
||||||
|
lng: 38.72,
|
||||||
|
pickupAvailable: true,
|
||||||
|
mapsUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Jemo%20Addis%20Ababa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stadium",
|
||||||
|
slug: "stadium",
|
||||||
|
name: "Stadium Branch",
|
||||||
|
area: "Stadium, Addis Ababa",
|
||||||
|
phone: "+251930033291",
|
||||||
|
hours: "8:00 AM – 8:00 PM",
|
||||||
|
is24h: false,
|
||||||
|
lat: 9.012,
|
||||||
|
lng: 38.763,
|
||||||
|
pickupAvailable: true,
|
||||||
|
mapsUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Stadium%20Addis%20Ababa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mexico",
|
||||||
|
slug: "mexico",
|
||||||
|
name: "Mexico Branch",
|
||||||
|
area: "Mexico Square, Addis Ababa",
|
||||||
|
phone: "+251935998678",
|
||||||
|
hours: "8:00 AM – 9:00 PM",
|
||||||
|
is24h: false,
|
||||||
|
lat: 9.01,
|
||||||
|
lng: 38.745,
|
||||||
|
pickupAvailable: true,
|
||||||
|
mapsUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Mexico%20Addis%20Ababa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "golagol",
|
||||||
|
slug: "golagol",
|
||||||
|
name: "Golagol Branch",
|
||||||
|
area: "Golagol, Addis Ababa",
|
||||||
|
phone: "+251930076930",
|
||||||
|
hours: "8:00 AM – 8:00 PM",
|
||||||
|
is24h: false,
|
||||||
|
lat: 9.03,
|
||||||
|
lng: 38.75,
|
||||||
|
pickupAvailable: true,
|
||||||
|
mapsUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Golagol%20Addis%20Ababa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hayahulet",
|
||||||
|
slug: "hayahulet",
|
||||||
|
name: "Hayahulet Branch",
|
||||||
|
area: "Hayahulet, Addis Ababa",
|
||||||
|
phone: "+251930033292",
|
||||||
|
hours: "8:00 AM – 8:00 PM",
|
||||||
|
is24h: false,
|
||||||
|
lat: 9.02,
|
||||||
|
lng: 38.79,
|
||||||
|
pickupAvailable: true,
|
||||||
|
mapsUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Hayahulet%20Addis%20Ababa",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getBranchById(id: string) {
|
||||||
|
return branches.find((b) => b.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distanceKm(
|
||||||
|
lat1: number,
|
||||||
|
lng1: number,
|
||||||
|
lat2: number,
|
||||||
|
lng2: number,
|
||||||
|
) {
|
||||||
|
const toRad = (v: number) => (v * Math.PI) / 180;
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLng = toRad(lng2 - lng1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearestBranches(lat: number, lng: number, limit = 3) {
|
||||||
|
return [...branches]
|
||||||
|
.map((b) => ({ ...b, distance: distanceKm(lat, lng, b.lat, b.lng) }))
|
||||||
|
.sort((a, b) => a.distance - b.distance)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
38
lib/data/brand.ts
Normal file
38
lib/data/brand.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
export const brand = {
|
||||||
|
name: "Gishen Pharmacy",
|
||||||
|
shortName: "Gishen",
|
||||||
|
tagline: "Healthcare delivered to your doorstep",
|
||||||
|
description:
|
||||||
|
"Established in 1994, Gishen Pharmacy is a trusted pharmaceutical retailer in Ethiopia with multiple branches across Addis Ababa.",
|
||||||
|
phone: "+251930099850",
|
||||||
|
phoneDisplay: "+251 930 099 850",
|
||||||
|
whatsapp: "251930099850",
|
||||||
|
email: "info@gishenpharmacy.org",
|
||||||
|
orderEmail: "orders@gishenpharmacy.org",
|
||||||
|
website: "https://gishenpharmacy.org",
|
||||||
|
founded: 1994,
|
||||||
|
branchCount: 7,
|
||||||
|
weeklyCustomers: 1218,
|
||||||
|
institutions: 146,
|
||||||
|
logo: "/brand/gishen-logo.png",
|
||||||
|
address: "Addis Ababa, Ethiopia",
|
||||||
|
googleRating: 4.4,
|
||||||
|
googleReviewCount: 120,
|
||||||
|
googleMapsUrl:
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=Gishen%20Pharmacy%20Addis%20Ababa",
|
||||||
|
socials: [
|
||||||
|
{
|
||||||
|
id: "whatsapp",
|
||||||
|
label: "WhatsApp",
|
||||||
|
href: "https://wa.me/251930099850",
|
||||||
|
},
|
||||||
|
] as { id: string; label: string; href: string }[],
|
||||||
|
story:
|
||||||
|
"Established in 1994, Gishen Group (Gishen Pharmacy) is one of the household names in the pharmaceutical retail industry in Ethiopia. The company has grown from a single branch to a multi-branch operation in Addis Ababa, serving individuals and 146+ institutional clients with medicines, medical equipment, cosmetics, baby products, compounding, and consulting.",
|
||||||
|
purpose:
|
||||||
|
"To provide the highest quality products and services that enhance the well-being of humanity — improving access to exceptional healthcare solutions across Ethiopia.",
|
||||||
|
vision:
|
||||||
|
"To enhance accessibility to the finest pharmaceutical products, services, and medical equipment, bridging the gap between supply and demand.",
|
||||||
|
mission:
|
||||||
|
"To instill hope by delivering exceptional quality healthcare solutions through the retailing of pharmaceutical goods and services in Ethiopia.",
|
||||||
|
};
|
||||||
322
lib/data/clinical.ts
Normal file
322
lib/data/clinical.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
import type { Product } from "@/lib/data/products";
|
||||||
|
|
||||||
|
export type StorageNote = {
|
||||||
|
icon: "thermometer-outline" | "sunny-outline" | "happy-outline" | "water-outline";
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DrugFacts = {
|
||||||
|
/** Short therapeutic label shown above the product name. */
|
||||||
|
useCase: string;
|
||||||
|
/** Dose form: Tablet, Syrup, Cream, Device… */
|
||||||
|
form: string;
|
||||||
|
genericName: string;
|
||||||
|
brandNames: string;
|
||||||
|
drugClass: string;
|
||||||
|
/** Selectable strengths on the detail page. Empty for non-dosed products. */
|
||||||
|
dosages: string[];
|
||||||
|
sideEffects: { mild: string[]; severe: string[] };
|
||||||
|
storage: StorageNote[];
|
||||||
|
directions: { title: string; body: string }[];
|
||||||
|
stockCount: number;
|
||||||
|
/** Pharmacist guidance shown in the safety callout. */
|
||||||
|
advice: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_USE_CASE: Record<string, string> = {
|
||||||
|
medicines: "General Medicine",
|
||||||
|
vitamins: "Supplements",
|
||||||
|
"personal-care": "Personal Care",
|
||||||
|
diabetic: "Diabetes Care",
|
||||||
|
devices: "Medical Device",
|
||||||
|
baby: "Baby Care",
|
||||||
|
"first-aid": "First Aid",
|
||||||
|
wellness: "Sexual Wellness",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_FORM: Record<string, string> = {
|
||||||
|
medicines: "Tablet",
|
||||||
|
vitamins: "Tablet",
|
||||||
|
"personal-care": "Topical",
|
||||||
|
diabetic: "Tablet",
|
||||||
|
devices: "Device",
|
||||||
|
baby: "Topical",
|
||||||
|
"first-aid": "Supply",
|
||||||
|
wellness: "Tablet",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-product clinical overrides. Everything else falls back to category defaults. */
|
||||||
|
const OVERRIDES: Record<string, Partial<DrugFacts>> = {
|
||||||
|
"panadol-advance": {
|
||||||
|
useCase: "Pain & Fever",
|
||||||
|
genericName: "Paracetamol (Acetaminophen)",
|
||||||
|
brandNames: "Panadol, Tylenol, Calpol",
|
||||||
|
drugClass: "Analgesic / Antipyretic",
|
||||||
|
dosages: ["500mg", "650mg"],
|
||||||
|
sideEffects: {
|
||||||
|
mild: ["Nausea", "Sleepiness", "Stomach upset"],
|
||||||
|
severe: ["Liver damage", "Rash", "Breathing issues"],
|
||||||
|
},
|
||||||
|
advice: "Do not exceed 4g of paracetamol in 24 hours across all products.",
|
||||||
|
},
|
||||||
|
paracetamol: {
|
||||||
|
useCase: "Pain & Fever",
|
||||||
|
genericName: "Paracetamol",
|
||||||
|
brandNames: "Panadol, Tylenol",
|
||||||
|
drugClass: "Analgesic / Antipyretic",
|
||||||
|
dosages: ["500mg"],
|
||||||
|
advice: "Avoid alcohol while taking this medicine.",
|
||||||
|
},
|
||||||
|
"paracetamol-tylenol": {
|
||||||
|
useCase: "Pain & Fever",
|
||||||
|
genericName: "Acetaminophen",
|
||||||
|
brandNames: "Tylenol, Panadol",
|
||||||
|
drugClass: "Analgesic",
|
||||||
|
dosages: ["500mg", "650mg"],
|
||||||
|
},
|
||||||
|
"paracetamol-caffeine": {
|
||||||
|
useCase: "Headache Relief",
|
||||||
|
genericName: "Paracetamol + Caffeine",
|
||||||
|
brandNames: "Panadol Extra",
|
||||||
|
drugClass: "Analgesic combination",
|
||||||
|
dosages: ["500mg / 65mg"],
|
||||||
|
advice: "Contains caffeine — limit other caffeinated drinks.",
|
||||||
|
},
|
||||||
|
snip: {
|
||||||
|
useCase: "Cold & Flu",
|
||||||
|
genericName: "Paracetamol + Pseudoephedrine + Chlorphenamine",
|
||||||
|
brandNames: "SNIP",
|
||||||
|
drugClass: "Decongestant combination",
|
||||||
|
sideEffects: {
|
||||||
|
mild: ["Drowsiness", "Dry mouth", "Dizziness"],
|
||||||
|
severe: ["Rapid heartbeat", "High blood pressure"],
|
||||||
|
},
|
||||||
|
advice: "May cause drowsiness. Do not drive until you know how it affects you.",
|
||||||
|
},
|
||||||
|
metformin: {
|
||||||
|
useCase: "Diabetes Care",
|
||||||
|
genericName: "Metformin hydrochloride",
|
||||||
|
brandNames: "Glucophage, Glumet",
|
||||||
|
drugClass: "Biguanide antidiabetic",
|
||||||
|
dosages: ["500mg", "850mg", "1000mg"],
|
||||||
|
sideEffects: {
|
||||||
|
mild: ["Nausea", "Diarrhoea", "Metallic taste"],
|
||||||
|
severe: ["Lactic acidosis", "Vitamin B12 deficiency"],
|
||||||
|
},
|
||||||
|
advice: "Take with meals to reduce stomach upset. Requires a valid prescription.",
|
||||||
|
},
|
||||||
|
ciprofloxacin: {
|
||||||
|
useCase: "Antibiotic",
|
||||||
|
genericName: "Ciprofloxacin",
|
||||||
|
brandNames: "Cipro, Ciprobay",
|
||||||
|
drugClass: "Fluoroquinolone antibiotic",
|
||||||
|
dosages: ["250mg", "500mg"],
|
||||||
|
sideEffects: {
|
||||||
|
mild: ["Nausea", "Headache", "Dizziness"],
|
||||||
|
severe: ["Tendon rupture", "Nerve damage"],
|
||||||
|
},
|
||||||
|
advice: "Finish the full course even if you feel better.",
|
||||||
|
},
|
||||||
|
azitro: {
|
||||||
|
useCase: "Antibiotic",
|
||||||
|
genericName: "Azithromycin",
|
||||||
|
brandNames: "Zithromax, Azitro",
|
||||||
|
drugClass: "Macrolide antibiotic",
|
||||||
|
dosages: ["250mg", "500mg"],
|
||||||
|
advice: "Usually taken once daily for 3 days. Complete the course as prescribed.",
|
||||||
|
},
|
||||||
|
diclofenac: {
|
||||||
|
useCase: "Pain & Inflammation",
|
||||||
|
genericName: "Diclofenac sodium",
|
||||||
|
brandNames: "Voltaren, Cataflam",
|
||||||
|
drugClass: "NSAID",
|
||||||
|
dosages: ["25mg", "50mg", "75mg"],
|
||||||
|
sideEffects: {
|
||||||
|
mild: ["Indigestion", "Headache", "Dizziness"],
|
||||||
|
severe: ["Stomach bleeding", "Kidney issues"],
|
||||||
|
},
|
||||||
|
advice: "Take after food. Avoid if you have a history of stomach ulcers.",
|
||||||
|
},
|
||||||
|
pantoprazole: {
|
||||||
|
useCase: "Acid Reflux",
|
||||||
|
genericName: "Pantoprazole sodium",
|
||||||
|
brandNames: "Protonix, Pantocid",
|
||||||
|
drugClass: "Proton pump inhibitor",
|
||||||
|
dosages: ["20mg", "40mg"],
|
||||||
|
advice: "Take 30–60 minutes before your first meal of the day.",
|
||||||
|
},
|
||||||
|
desloratidine: {
|
||||||
|
useCase: "Allergy Relief",
|
||||||
|
genericName: "Desloratadine",
|
||||||
|
brandNames: "Clarinex, Aerius",
|
||||||
|
drugClass: "Antihistamine",
|
||||||
|
dosages: ["5mg"],
|
||||||
|
advice: "Non-drowsy for most people, but avoid driving until you're sure.",
|
||||||
|
},
|
||||||
|
"nat-c-vitamin-c": {
|
||||||
|
useCase: "Immune Support",
|
||||||
|
genericName: "Ascorbic acid",
|
||||||
|
brandNames: "Nat C, Redoxon",
|
||||||
|
drugClass: "Vitamin supplement",
|
||||||
|
dosages: ["500mg", "1000mg"],
|
||||||
|
sideEffects: { mild: ["Stomach upset", "Heartburn"], severe: ["Kidney stones (high doses)"] },
|
||||||
|
},
|
||||||
|
gofen: {
|
||||||
|
useCase: "Pain & Inflammation",
|
||||||
|
genericName: "Ibuprofen",
|
||||||
|
brandNames: "Brufen, Advil, Gofen",
|
||||||
|
drugClass: "NSAID",
|
||||||
|
dosages: ["200mg", "400mg"],
|
||||||
|
advice: "Take with food or milk to protect your stomach.",
|
||||||
|
},
|
||||||
|
quemox: {
|
||||||
|
useCase: "Antibiotic",
|
||||||
|
genericName: "Amoxicillin",
|
||||||
|
brandNames: "Amoxil, Quemox",
|
||||||
|
drugClass: "Penicillin antibiotic",
|
||||||
|
dosages: ["250mg", "500mg"],
|
||||||
|
advice: "Tell your pharmacist about any penicillin allergy before use.",
|
||||||
|
},
|
||||||
|
sildenafil: {
|
||||||
|
useCase: "Sexual Wellness",
|
||||||
|
genericName: "Sildenafil citrate",
|
||||||
|
brandNames: "Viagra, Cialis (tadalafil alt.)",
|
||||||
|
drugClass: "PDE5 inhibitor",
|
||||||
|
dosages: ["25mg", "50mg", "100mg"],
|
||||||
|
advice: "Never combine with nitrate heart medicines. Prescription required.",
|
||||||
|
},
|
||||||
|
"ors-sachets": {
|
||||||
|
useCase: "Rehydration",
|
||||||
|
genericName: "Oral rehydration salts",
|
||||||
|
brandNames: "ORS, Hydralyte",
|
||||||
|
drugClass: "Electrolyte replacement",
|
||||||
|
advice: "Dissolve one sachet in 200ml of clean water. Use within 24 hours.",
|
||||||
|
},
|
||||||
|
"cough-syrup": {
|
||||||
|
useCase: "Cough Relief",
|
||||||
|
form: "Syrup",
|
||||||
|
genericName: "Dextromethorphan / Guaifenesin",
|
||||||
|
brandNames: "Robitussin, Benylin",
|
||||||
|
drugClass: "Antitussive / Expectorant",
|
||||||
|
dosages: ["100ml", "200ml"],
|
||||||
|
},
|
||||||
|
"lubricating-eye-drops": {
|
||||||
|
useCase: "Eye Care",
|
||||||
|
form: "Drops",
|
||||||
|
},
|
||||||
|
"eye-drops-lubricating": {
|
||||||
|
useCase: "Eye Care",
|
||||||
|
form: "Drops",
|
||||||
|
genericName: "Carboxymethylcellulose",
|
||||||
|
brandNames: "Refresh Tears, Optive",
|
||||||
|
drugClass: "Ocular lubricant",
|
||||||
|
},
|
||||||
|
"blood-pressure-monitor": {
|
||||||
|
useCase: "Monitoring",
|
||||||
|
form: "Device",
|
||||||
|
genericName: "Upper-arm oscillometric monitor",
|
||||||
|
brandNames: "HealthTech",
|
||||||
|
drugClass: "Class II medical device",
|
||||||
|
},
|
||||||
|
"glucose-meter-kit": {
|
||||||
|
useCase: "Diabetes Care",
|
||||||
|
form: "Device",
|
||||||
|
genericName: "Blood glucose monitoring system",
|
||||||
|
brandNames: "DiaCare",
|
||||||
|
drugClass: "Class II medical device",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SIDE_EFFECTS: DrugFacts["sideEffects"] = {
|
||||||
|
mild: ["Nausea", "Headache", "Mild dizziness"],
|
||||||
|
severe: ["Allergic reaction", "Difficulty breathing"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_STORAGE: StorageNote[] = [
|
||||||
|
{
|
||||||
|
icon: "thermometer-outline",
|
||||||
|
title: "Temperature",
|
||||||
|
body: "Store at room temperature, between 20°C and 25°C.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "sunny-outline",
|
||||||
|
title: "Light & moisture",
|
||||||
|
body: "Keep in a dry place, away from direct sunlight.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "happy-outline",
|
||||||
|
title: "Children",
|
||||||
|
body: "Keep out of reach of children to prevent accidental ingestion.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_DIRECTIONS: DrugFacts["directions"] = [
|
||||||
|
{
|
||||||
|
title: "Check with your pharmacist",
|
||||||
|
body: "Confirm the dose is right for your age, weight and other medicines.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Take as directed",
|
||||||
|
body: "Follow the schedule on the label. Do not double up on a missed dose.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Take with water",
|
||||||
|
body: "Swallow whole with a full glass of water unless told otherwise.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Track your response",
|
||||||
|
body: "Stop and contact us if symptoms persist beyond 3 days.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEVICE_DIRECTIONS: DrugFacts["directions"] = [
|
||||||
|
{ title: "Unbox and inspect", body: "Check all parts are present and undamaged." },
|
||||||
|
{ title: "Prepare", body: "Sit still for 5 minutes and rest your arm at heart level." },
|
||||||
|
{ title: "Measure", body: "Follow the on-screen prompts and stay quiet during the reading." },
|
||||||
|
{ title: "Log the result", body: "Record readings so your pharmacist can review trends." },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Deterministic pseudo-stock so the number is stable between renders. */
|
||||||
|
function stockFor(product: Product): number {
|
||||||
|
if (!product.inStock) return 0;
|
||||||
|
const seed = product.id
|
||||||
|
.split("")
|
||||||
|
.reduce((total, char) => total + char.charCodeAt(0), 0);
|
||||||
|
return 8 + (seed % 44);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDrugFacts(product: Product): DrugFacts {
|
||||||
|
const override = OVERRIDES[product.slug] ?? {};
|
||||||
|
const isDevice = product.category === "devices" || product.category === "first-aid";
|
||||||
|
|
||||||
|
return {
|
||||||
|
useCase: override.useCase ?? CATEGORY_USE_CASE[product.category] ?? "Healthcare",
|
||||||
|
form: override.form ?? CATEGORY_FORM[product.category] ?? "Unit",
|
||||||
|
genericName: override.genericName ?? product.name,
|
||||||
|
brandNames: override.brandNames ?? product.brand,
|
||||||
|
drugClass: override.drugClass ?? CATEGORY_USE_CASE[product.category] ?? "Healthcare product",
|
||||||
|
dosages: override.dosages ?? [],
|
||||||
|
sideEffects:
|
||||||
|
override.sideEffects ??
|
||||||
|
(isDevice ? { mild: ["Skin irritation"], severe: [] } : DEFAULT_SIDE_EFFECTS),
|
||||||
|
storage: override.storage ?? DEFAULT_STORAGE,
|
||||||
|
directions: override.directions ?? (isDevice ? DEVICE_DIRECTIONS : DEFAULT_DIRECTIONS),
|
||||||
|
stockCount: override.stockCount ?? stockFor(product),
|
||||||
|
advice:
|
||||||
|
override.advice ??
|
||||||
|
(product.requiresPrescription
|
||||||
|
? "A valid prescription is required. Our pharmacist will verify it before dispatch."
|
||||||
|
: "Speak to our pharmacist if you are pregnant, breastfeeding or taking other medicines."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Star distribution for the review summary, weighted by the product rating. */
|
||||||
|
export function getReviewBreakdown(product: Product) {
|
||||||
|
const weights = [0.62, 0.22, 0.09, 0.04, 0.03];
|
||||||
|
const shift = Math.max(0, Math.round((4.8 - product.rating) * 10)) / 100;
|
||||||
|
return [5, 4, 3, 2, 1].map((stars, index) => {
|
||||||
|
const weight = Math.max(0.01, weights[index] - (index === 0 ? shift : -shift / 4));
|
||||||
|
return { stars, count: Math.max(1, Math.round(product.reviews * weight)) };
|
||||||
|
});
|
||||||
|
}
|
||||||
997
lib/data/products.ts
Normal file
997
lib/data/products.ts
Normal file
|
|
@ -0,0 +1,997 @@
|
||||||
|
export type Product = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
compareAt?: number;
|
||||||
|
image: string;
|
||||||
|
rating: number;
|
||||||
|
reviews: number;
|
||||||
|
badge?: "sale" | "new" | "bestseller";
|
||||||
|
requiresPrescription?: boolean;
|
||||||
|
inStock: boolean;
|
||||||
|
sku: string;
|
||||||
|
unit: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const u = (id: string) =>
|
||||||
|
`https://images.unsplash.com/${id}?w=600&q=80`;
|
||||||
|
|
||||||
|
export const products: Product[] = [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
slug: "panadol-advance",
|
||||||
|
name: "Panadol Advance",
|
||||||
|
brand: "GSK",
|
||||||
|
category: "medicines",
|
||||||
|
description:
|
||||||
|
"Fast-acting paracetamol tablets for relief of mild to moderate pain and fever.",
|
||||||
|
price: 85,
|
||||||
|
compareAt: 110,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/panadol-advance.jpg",
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 214,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-PAN-ADV",
|
||||||
|
unit: "16 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p2",
|
||||||
|
slug: "paracetamol",
|
||||||
|
name: "Paracetamol",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Reliable pain and fever relief for everyday use.",
|
||||||
|
price: 35,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/paracetamol.jpg",
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 189,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-PCM-500",
|
||||||
|
unit: "20 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p3",
|
||||||
|
slug: "paracetamol-tylenol",
|
||||||
|
name: "Paracetamol Tylenol",
|
||||||
|
brand: "Tylenol",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Trusted acetaminophen formula for pain and fever.",
|
||||||
|
price: 120,
|
||||||
|
compareAt: 145,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/paracetamol-tylenol.jpg",
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 98,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-TYL-PCM",
|
||||||
|
unit: "24 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p4",
|
||||||
|
slug: "paracetamol-caffeine",
|
||||||
|
name: "Paracetamol + Caffeine",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Enhanced pain relief combining paracetamol with caffeine.",
|
||||||
|
price: 55,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/paracetamol-caffein.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 67,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-PCM-CAF",
|
||||||
|
unit: "20 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p5",
|
||||||
|
slug: "snip",
|
||||||
|
name: "SNIP Cold & Flu",
|
||||||
|
brand: "SNIP",
|
||||||
|
category: "medicines",
|
||||||
|
description:
|
||||||
|
"Paracetamol, pseudoephedrine and chlorphenamine for cold and flu symptoms.",
|
||||||
|
price: 95,
|
||||||
|
compareAt: 120,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/snip-pcm-pseudoephedrine-chlorphenamine.jpg",
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 142,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-SNIP-01",
|
||||||
|
unit: "12 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p6",
|
||||||
|
slug: "paracetamol-pseudoephedrine-snip",
|
||||||
|
name: "Paracetamol-Pseudoephedrine (SNIP)",
|
||||||
|
brand: "SNIP",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Relieves nasal congestion with pain and fever control.",
|
||||||
|
price: 90,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/paracetamol-pseudoephedrine-snip.jpg",
|
||||||
|
rating: 4.4,
|
||||||
|
reviews: 76,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-SNIP-02",
|
||||||
|
unit: "12 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p7",
|
||||||
|
slug: "metformin",
|
||||||
|
name: "Metformin",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "diabetic",
|
||||||
|
description: "Oral antidiabetic medication for type 2 diabetes management.",
|
||||||
|
price: 75,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/metformin.jpg",
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 203,
|
||||||
|
badge: "bestseller",
|
||||||
|
requiresPrescription: true,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-MET-500",
|
||||||
|
unit: "30 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p8",
|
||||||
|
slug: "ciprofloxacin",
|
||||||
|
name: "Ciprofloxacin",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Broad-spectrum antibiotic for bacterial infections.",
|
||||||
|
price: 140,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/ciprofloxacin.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 88,
|
||||||
|
requiresPrescription: true,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-CIP-500",
|
||||||
|
unit: "10 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p9",
|
||||||
|
slug: "azitro",
|
||||||
|
name: "Azitro (Azithromycin)",
|
||||||
|
brand: "Azitro",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Macrolide antibiotic commonly used for respiratory infections.",
|
||||||
|
price: 180,
|
||||||
|
compareAt: 220,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/azitro.jpg",
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 112,
|
||||||
|
badge: "sale",
|
||||||
|
requiresPrescription: true,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-AZI-500",
|
||||||
|
unit: "3 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p10",
|
||||||
|
slug: "diclofenac",
|
||||||
|
name: "Diclofenac",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "medicines",
|
||||||
|
description: "NSAID for pain, inflammation and joint discomfort.",
|
||||||
|
price: 60,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/diclofenac.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 156,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-DIC-50",
|
||||||
|
unit: "20 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p11",
|
||||||
|
slug: "albendazole",
|
||||||
|
name: "Albendazole",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Anthelmintic treatment for worm infections.",
|
||||||
|
price: 45,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/albendazole.jpg",
|
||||||
|
rating: 4.4,
|
||||||
|
reviews: 91,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-ALB-400",
|
||||||
|
unit: "1 tablet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p12",
|
||||||
|
slug: "pantoprazole",
|
||||||
|
name: "Pantoprazole",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Proton pump inhibitor for acid reflux and ulcers.",
|
||||||
|
price: 110,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/pantoprazole.jpg",
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 134,
|
||||||
|
requiresPrescription: true,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-PAN-40",
|
||||||
|
unit: "14 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p13",
|
||||||
|
slug: "desloratidine",
|
||||||
|
name: "Desloratidine",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Non-drowsy antihistamine for allergy relief.",
|
||||||
|
price: 95,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/desloratidine.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 72,
|
||||||
|
badge: "new",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-DES-5",
|
||||||
|
unit: "10 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p14",
|
||||||
|
slug: "nat-c-vitamin-c",
|
||||||
|
name: "Nat C (Vitamin C)",
|
||||||
|
brand: "Nat C",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "Vitamin C supplement to support immunity and recovery.",
|
||||||
|
price: 150,
|
||||||
|
compareAt: 180,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/nat-c-vitamin-c.jpg",
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 267,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-NATC-01",
|
||||||
|
unit: "30 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p15",
|
||||||
|
slug: "vigafen",
|
||||||
|
name: "Vigafen",
|
||||||
|
brand: "Vigafen",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Combination analgesic for headache and body pain.",
|
||||||
|
price: 70,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/vigafen.jpg",
|
||||||
|
rating: 4.3,
|
||||||
|
reviews: 54,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-VIG-01",
|
||||||
|
unit: "20 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p16",
|
||||||
|
slug: "gofen",
|
||||||
|
name: "Gofen",
|
||||||
|
brand: "Gofen",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Ibuprofen soft gel capsules for pain and inflammation.",
|
||||||
|
price: 125,
|
||||||
|
compareAt: 150,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/gofen.jpg",
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 118,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-GOF-400",
|
||||||
|
unit: "10 softgels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p17",
|
||||||
|
slug: "quemox",
|
||||||
|
name: "Quemox",
|
||||||
|
brand: "Quemox",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Amoxicillin antibiotic for bacterial infections.",
|
||||||
|
price: 160,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/quemox.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 83,
|
||||||
|
requiresPrescription: true,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-QUE-500",
|
||||||
|
unit: "21 capsules",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p18",
|
||||||
|
slug: "olit",
|
||||||
|
name: "Olit",
|
||||||
|
brand: "Olit",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Gastrointestinal support medication.",
|
||||||
|
price: 100,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/olit.jpg",
|
||||||
|
rating: 4.2,
|
||||||
|
reviews: 41,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-OLIT-01",
|
||||||
|
unit: "14 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p19",
|
||||||
|
slug: "degra",
|
||||||
|
name: "Degra",
|
||||||
|
brand: "Degra",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Digestive relief support formula.",
|
||||||
|
price: 115,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/degra.jpg",
|
||||||
|
rating: 4.3,
|
||||||
|
reviews: 39,
|
||||||
|
badge: "new",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-DEG-01",
|
||||||
|
unit: "10 sachets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p20",
|
||||||
|
slug: "almetamin",
|
||||||
|
name: "Almetamin",
|
||||||
|
brand: "Almetamin",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Antihistamine for allergy and cold symptoms.",
|
||||||
|
price: 65,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/almetamin.jpg",
|
||||||
|
rating: 4.4,
|
||||||
|
reviews: 58,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-ALM-01",
|
||||||
|
unit: "20 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p21",
|
||||||
|
slug: "mela-one-tablet",
|
||||||
|
name: "Mela One Tablet",
|
||||||
|
brand: "Mela",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Single-dose treatment tablet as directed by a pharmacist.",
|
||||||
|
price: 80,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/mela-one-tablet.jpg",
|
||||||
|
rating: 4.1,
|
||||||
|
reviews: 33,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-MELA-01",
|
||||||
|
unit: "1 tablet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p22",
|
||||||
|
slug: "sildenafil",
|
||||||
|
name: "Sildenafil",
|
||||||
|
brand: "Generic",
|
||||||
|
category: "wellness",
|
||||||
|
description: "Prescription medication for erectile dysfunction.",
|
||||||
|
price: 250,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/sildenafil.jpg",
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 97,
|
||||||
|
requiresPrescription: true,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-SIL-100",
|
||||||
|
unit: "4 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p23",
|
||||||
|
slug: "hiwot-trust-condom",
|
||||||
|
name: "Hiwot Trust Condom",
|
||||||
|
brand: "Hiwot Trust",
|
||||||
|
category: "wellness",
|
||||||
|
description: "Trusted protection condoms for sexual wellness.",
|
||||||
|
price: 50,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/hiwot-trust-condom.jpg",
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 176,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-HTC-01",
|
||||||
|
unit: "3 pack",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p24",
|
||||||
|
slug: "royal-face-mask",
|
||||||
|
name: "Royal Face Mask",
|
||||||
|
brand: "Royal",
|
||||||
|
category: "devices",
|
||||||
|
description: "Protective face masks for everyday hygiene.",
|
||||||
|
price: 40,
|
||||||
|
compareAt: 60,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/royal-face-mask.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 210,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-RFM-50",
|
||||||
|
unit: "50 pcs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p25",
|
||||||
|
slug: "examination-glove-medium",
|
||||||
|
name: "Examination Glove Medium",
|
||||||
|
brand: "MedGloves",
|
||||||
|
category: "devices",
|
||||||
|
description: "Disposable examination gloves, medium size.",
|
||||||
|
price: 220,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/examination-glove-medium.jpg",
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 64,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-GLV-M",
|
||||||
|
unit: "100 pcs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p26",
|
||||||
|
slug: "surgical-glove",
|
||||||
|
name: "Surgical Glove",
|
||||||
|
brand: "MedGloves",
|
||||||
|
category: "devices",
|
||||||
|
description: "Sterile surgical gloves for clinical use.",
|
||||||
|
price: 280,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/surgical-glave.jpg",
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 48,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-SGL-M",
|
||||||
|
unit: "50 pairs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p27",
|
||||||
|
slug: "first-aid-plaster",
|
||||||
|
name: "First Aid Plaster",
|
||||||
|
brand: "CareAid",
|
||||||
|
category: "first-aid",
|
||||||
|
description: "Assorted adhesive plasters for minor cuts and scrapes.",
|
||||||
|
price: 45,
|
||||||
|
image: "https://gishen-ecom.vercel.app/products/first-aid-plaster.jpg",
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 122,
|
||||||
|
badge: "new",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-FAP-01",
|
||||||
|
unit: "20 strips",
|
||||||
|
},
|
||||||
|
// Expanded curated OTC / wellness catalog (licensed Unsplash imagery)
|
||||||
|
{
|
||||||
|
id: "p28",
|
||||||
|
slug: "vitamin-d3",
|
||||||
|
name: "Vitamin D3 1000 IU",
|
||||||
|
brand: "Wellness Plus",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "Supports bone health and immune function.",
|
||||||
|
price: 280,
|
||||||
|
compareAt: 320,
|
||||||
|
image: u("photo-1559757148-5c350d0d3c56"),
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 145,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-VD3-01",
|
||||||
|
unit: "60 softgels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p29",
|
||||||
|
slug: "omega-3-fish-oil",
|
||||||
|
name: "Omega-3 Fish Oil",
|
||||||
|
brand: "Wellness Plus",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "EPA & DHA support for heart and brain health.",
|
||||||
|
price: 450,
|
||||||
|
image: u("photo-1505751172876-fa1923c5c528"),
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 198,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-OM3-01",
|
||||||
|
unit: "90 softgels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p30",
|
||||||
|
slug: "multivitamin-daily",
|
||||||
|
name: "Daily Multivitamin",
|
||||||
|
brand: "VitaDay",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "Complete daily micronutrient support for adults.",
|
||||||
|
price: 320,
|
||||||
|
image: u("photo-1471864190281-a93a3070b6de"),
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 231,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-MVIT-01",
|
||||||
|
unit: "30 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p31",
|
||||||
|
slug: "zinc-tablets",
|
||||||
|
name: "Zinc Tablets 50mg",
|
||||||
|
brand: "VitaDay",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "Zinc supplement to support immunity.",
|
||||||
|
price: 180,
|
||||||
|
image: u("photo-1585435557343-3b092031a831"),
|
||||||
|
rating: 4.4,
|
||||||
|
reviews: 87,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-ZNC-50",
|
||||||
|
unit: "30 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p32",
|
||||||
|
slug: "iron-folic-acid",
|
||||||
|
name: "Iron + Folic Acid",
|
||||||
|
brand: "VitaDay",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "Supports healthy blood and prenatal nutrition needs.",
|
||||||
|
price: 160,
|
||||||
|
image: u("photo-1587854692152-cbe660dbde88"),
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 104,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-IRN-01",
|
||||||
|
unit: "30 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p33",
|
||||||
|
slug: "calcium-magnesium",
|
||||||
|
name: "Calcium + Magnesium",
|
||||||
|
brand: "BoneCare",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "Mineral blend for bone and muscle support.",
|
||||||
|
price: 290,
|
||||||
|
image: u("photo-1628771065518-0d82f1938462"),
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 76,
|
||||||
|
badge: "new",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-CAM-01",
|
||||||
|
unit: "60 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p34",
|
||||||
|
slug: "hand-sanitizer-500",
|
||||||
|
name: "Hand Sanitizer 500ml",
|
||||||
|
brand: "CleanHands",
|
||||||
|
category: "personal-care",
|
||||||
|
description: "Alcohol-based sanitizer for on-the-go hygiene.",
|
||||||
|
price: 120,
|
||||||
|
compareAt: 150,
|
||||||
|
image: u("photo-1582719471384-894fbb16e074"),
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 188,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-SAN-500",
|
||||||
|
unit: "500 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p35",
|
||||||
|
slug: "moisturizing-lotion",
|
||||||
|
name: "Moisturizing Body Lotion",
|
||||||
|
brand: "DermaSoft",
|
||||||
|
category: "personal-care",
|
||||||
|
description: "Daily hydrating lotion for dry and sensitive skin.",
|
||||||
|
price: 240,
|
||||||
|
image: u("photo-1556228578-0d85b1a4d571"),
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 132,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-LOT-01",
|
||||||
|
unit: "400 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p36",
|
||||||
|
slug: "sunscreen-spf50",
|
||||||
|
name: "Sunscreen SPF 50",
|
||||||
|
brand: "DermaSoft",
|
||||||
|
category: "personal-care",
|
||||||
|
description: "Broad-spectrum sun protection for face and body.",
|
||||||
|
price: 380,
|
||||||
|
image: u("photo-1556228720-195a672e8a03"),
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 165,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-SPF-50",
|
||||||
|
unit: "100 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p37",
|
||||||
|
slug: "antiseptic-mouthwash",
|
||||||
|
name: "Antiseptic Mouthwash",
|
||||||
|
brand: "OralCare",
|
||||||
|
category: "personal-care",
|
||||||
|
description: "Freshens breath and supports oral hygiene.",
|
||||||
|
price: 160,
|
||||||
|
image: u("photo-1607613009820-a29f7bb81c04"),
|
||||||
|
rating: 4.4,
|
||||||
|
reviews: 79,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-MW-01",
|
||||||
|
unit: "500 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p38",
|
||||||
|
slug: "shampoo-gentle",
|
||||||
|
name: "Gentle Care Shampoo",
|
||||||
|
brand: "HairWell",
|
||||||
|
category: "personal-care",
|
||||||
|
description: "Mild shampoo suitable for daily use.",
|
||||||
|
price: 210,
|
||||||
|
image: u("photo-1535585209827-a15fcdbc4c2d"),
|
||||||
|
rating: 4.3,
|
||||||
|
reviews: 91,
|
||||||
|
badge: "new",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-SHP-01",
|
||||||
|
unit: "400 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p39",
|
||||||
|
slug: "blood-pressure-monitor",
|
||||||
|
name: "Digital Blood Pressure Monitor",
|
||||||
|
brand: "HealthTech",
|
||||||
|
category: "devices",
|
||||||
|
description: "Upper-arm digital BP monitor for home use.",
|
||||||
|
price: 1850,
|
||||||
|
compareAt: 2100,
|
||||||
|
image: u("photo-1584362917165-526a968579e8"),
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 84,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-BPM-01",
|
||||||
|
unit: "1 unit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p40",
|
||||||
|
slug: "pulse-oximeter",
|
||||||
|
name: "Pulse Oximeter",
|
||||||
|
brand: "HealthTech",
|
||||||
|
category: "devices",
|
||||||
|
description: "Fingertip SpO2 and pulse rate monitor.",
|
||||||
|
price: 950,
|
||||||
|
image: u("photo-1666214280557-f1b5022eb634"),
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 113,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-OXM-01",
|
||||||
|
unit: "1 unit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p41",
|
||||||
|
slug: "digital-thermometer",
|
||||||
|
name: "Digital Thermometer",
|
||||||
|
brand: "HealthTech",
|
||||||
|
category: "devices",
|
||||||
|
description: "Fast-read digital thermometer for family use.",
|
||||||
|
price: 280,
|
||||||
|
image: u("photo-1584017911766-d451b3d0e843"),
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 156,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-THM-01",
|
||||||
|
unit: "1 unit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p42",
|
||||||
|
slug: "glucose-meter-kit",
|
||||||
|
name: "Blood Glucose Meter Kit",
|
||||||
|
brand: "DiaCare",
|
||||||
|
category: "diabetic",
|
||||||
|
description: "Home glucose monitoring kit with strips starter pack.",
|
||||||
|
price: 1450,
|
||||||
|
image: u("photo-1579684385127-1ef15d508118"),
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 99,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-BGM-01",
|
||||||
|
unit: "1 kit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p43",
|
||||||
|
slug: "glucose-test-strips",
|
||||||
|
name: "Glucose Test Strips",
|
||||||
|
brand: "DiaCare",
|
||||||
|
category: "diabetic",
|
||||||
|
description: "Compatible test strips for DiaCare meters.",
|
||||||
|
price: 420,
|
||||||
|
image: u("photo-1631549916768-4119b2e5f926"),
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 67,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-GTS-50",
|
||||||
|
unit: "50 strips",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p44",
|
||||||
|
slug: "baby-lotion",
|
||||||
|
name: "Baby Soft Lotion",
|
||||||
|
brand: "TinyCare",
|
||||||
|
category: "baby",
|
||||||
|
description: "Hypoallergenic lotion for delicate baby skin.",
|
||||||
|
price: 260,
|
||||||
|
image: u("photo-1515488042361-ee00e0ddd4e4"),
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 143,
|
||||||
|
badge: "new",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-BBL-01",
|
||||||
|
unit: "200 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p45",
|
||||||
|
slug: "baby-wipes",
|
||||||
|
name: "Sensitive Baby Wipes",
|
||||||
|
brand: "TinyCare",
|
||||||
|
category: "baby",
|
||||||
|
description: "Fragrance-free wipes for sensitive skin.",
|
||||||
|
price: 95,
|
||||||
|
compareAt: 120,
|
||||||
|
image: u("photo-1544367567-0f2fcb009e0b"),
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 201,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-BBW-80",
|
||||||
|
unit: "80 wipes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p46",
|
||||||
|
slug: "diaper-rash-cream",
|
||||||
|
name: "Diaper Rash Cream",
|
||||||
|
brand: "TinyCare",
|
||||||
|
category: "baby",
|
||||||
|
description: "Soothing cream to protect against diaper irritation.",
|
||||||
|
price: 180,
|
||||||
|
image: u("photo-1492725764893-90b379c2b6e7"),
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 88,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-DRC-01",
|
||||||
|
unit: "100 g",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p47",
|
||||||
|
slug: "first-aid-kit",
|
||||||
|
name: "Family First Aid Kit",
|
||||||
|
brand: "CareAid",
|
||||||
|
category: "first-aid",
|
||||||
|
description: "Compact kit with essentials for home and travel.",
|
||||||
|
price: 650,
|
||||||
|
compareAt: 780,
|
||||||
|
image: u("photo-1603398938378-e54eab446dde"),
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 124,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-FAK-01",
|
||||||
|
unit: "1 kit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p48",
|
||||||
|
slug: "antiseptic-solution",
|
||||||
|
name: "Antiseptic Solution",
|
||||||
|
brand: "CareAid",
|
||||||
|
category: "first-aid",
|
||||||
|
description: "Wound cleansing antiseptic for first aid use.",
|
||||||
|
price: 90,
|
||||||
|
image: u("photo-1584308666744-24d5c474f2ae"),
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 96,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-ANT-250",
|
||||||
|
unit: "250 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p49",
|
||||||
|
slug: "cotton-wool",
|
||||||
|
name: "Surgical Cotton Wool",
|
||||||
|
brand: "CareAid",
|
||||||
|
category: "first-aid",
|
||||||
|
description: "Absorbent cotton for wound care and cosmetics.",
|
||||||
|
price: 55,
|
||||||
|
image: u("photo-1576671081837-49000212a370"),
|
||||||
|
rating: 4.4,
|
||||||
|
reviews: 71,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-CTN-100",
|
||||||
|
unit: "100 g",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p50",
|
||||||
|
slug: "ors-sachets",
|
||||||
|
name: "ORS Sachets",
|
||||||
|
brand: "HydraCare",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Oral rehydration salts for dehydration support.",
|
||||||
|
price: 40,
|
||||||
|
image: u("photo-1585435557343-3b092031a831"),
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 158,
|
||||||
|
badge: "bestseller",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-ORS-10",
|
||||||
|
unit: "10 sachets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p51",
|
||||||
|
slug: "cough-syrup",
|
||||||
|
name: "Cough Syrup",
|
||||||
|
brand: "RespiCare",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Soothing syrup for dry and productive coughs.",
|
||||||
|
price: 135,
|
||||||
|
image: u("photo-1587854692152-cbe660dbde88"),
|
||||||
|
rating: 4.4,
|
||||||
|
reviews: 109,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-CGH-100",
|
||||||
|
unit: "100 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p52",
|
||||||
|
slug: "antacid-chewable",
|
||||||
|
name: "Antacid Chewable",
|
||||||
|
brand: "GastroEase",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Fast relief from heartburn and indigestion.",
|
||||||
|
price: 70,
|
||||||
|
image: u("photo-1471864190281-a93a3070b6de"),
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 134,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-ANTAC-01",
|
||||||
|
unit: "24 tablets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p53",
|
||||||
|
slug: "eye-drops-lubricating",
|
||||||
|
name: "Lubricating Eye Drops",
|
||||||
|
brand: "OptiClear",
|
||||||
|
category: "medicines",
|
||||||
|
description: "Relief for dry, irritated eyes.",
|
||||||
|
price: 190,
|
||||||
|
badge: "new",
|
||||||
|
image: u("photo-1576602976047-174e57a47881"),
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 62,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-EYE-01",
|
||||||
|
unit: "10 ml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p54",
|
||||||
|
slug: "protein-powder",
|
||||||
|
name: "Nutrition Protein Powder",
|
||||||
|
brand: "NutriStrong",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "High-quality protein blend for recovery and wellness.",
|
||||||
|
price: 1250,
|
||||||
|
compareAt: 1400,
|
||||||
|
image: u("photo-1593095948071-474c5cc2989d"),
|
||||||
|
rating: 4.7,
|
||||||
|
reviews: 77,
|
||||||
|
badge: "sale",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-PRO-01",
|
||||||
|
unit: "1 kg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p55",
|
||||||
|
slug: "electrolyte-drink",
|
||||||
|
name: "Electrolyte Hydration Mix",
|
||||||
|
brand: "HydraCare",
|
||||||
|
category: "vitamins",
|
||||||
|
description: "Replenishes electrolytes after illness or exercise.",
|
||||||
|
price: 85,
|
||||||
|
image: u("photo-1628771065518-0d82f1938462"),
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 93,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-ELY-01",
|
||||||
|
unit: "10 sachets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p56",
|
||||||
|
slug: "pregnancy-test",
|
||||||
|
name: "Pregnancy Test Kit",
|
||||||
|
brand: "QuickCheck",
|
||||||
|
category: "devices",
|
||||||
|
description: "Rapid home pregnancy test with clear results.",
|
||||||
|
price: 95,
|
||||||
|
image: u("photo-1579154204601-01588f351e67"),
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 148,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-PTK-01",
|
||||||
|
unit: "1 kit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p57",
|
||||||
|
slug: "compression-socks",
|
||||||
|
name: "Compression Socks",
|
||||||
|
brand: "CircuFit",
|
||||||
|
category: "devices",
|
||||||
|
description: "Supportive socks for circulation and travel comfort.",
|
||||||
|
price: 340,
|
||||||
|
image: u("photo-1576091160550-2173dba999ef"),
|
||||||
|
rating: 4.3,
|
||||||
|
reviews: 45,
|
||||||
|
badge: "new",
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-CSK-01",
|
||||||
|
unit: "1 pair",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p58",
|
||||||
|
slug: "hot-water-bottle",
|
||||||
|
name: "Hot Water Bottle",
|
||||||
|
brand: "ComfortCare",
|
||||||
|
category: "first-aid",
|
||||||
|
description: "Classic hot water bottle for warmth and comfort.",
|
||||||
|
price: 220,
|
||||||
|
image: u("photo-1516549655169-df83a0774514"),
|
||||||
|
rating: 4.4,
|
||||||
|
reviews: 58,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-HWB-01",
|
||||||
|
unit: "1 unit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p59",
|
||||||
|
slug: "inhaler-spacer",
|
||||||
|
name: "Inhaler Spacer",
|
||||||
|
brand: "RespiCare",
|
||||||
|
category: "devices",
|
||||||
|
description: "Spacer chamber to improve inhaler medication delivery.",
|
||||||
|
price: 480,
|
||||||
|
image: u("photo-1584308666744-24d5c474f2ae"),
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 36,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-ISP-01",
|
||||||
|
unit: "1 unit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p60",
|
||||||
|
slug: "lip-balm-medicated",
|
||||||
|
name: "Medicated Lip Balm",
|
||||||
|
brand: "DermaSoft",
|
||||||
|
category: "personal-care",
|
||||||
|
description: "Soothing balm for dry and cracked lips.",
|
||||||
|
price: 65,
|
||||||
|
image: u("photo-1596755389378-c31d21fd1273"),
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 112,
|
||||||
|
inStock: true,
|
||||||
|
sku: "GSH-LIP-01",
|
||||||
|
unit: "1 stick",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getProductBySlug(slug: string) {
|
||||||
|
return products.find((p) => p.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductsByCategory(category: string) {
|
||||||
|
return products.filter((p) => p.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchProducts(query: string) {
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
if (!q) return products;
|
||||||
|
return products.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.brand.toLowerCase().includes(q) ||
|
||||||
|
p.sku.toLowerCase().includes(q) ||
|
||||||
|
p.description.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
291
lib/data/seed.ts
Normal file
291
lib/data/seed.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ord_demo_done",
|
||||||
|
createdAt: daysAgo(12),
|
||||||
|
items: [asCartItem(gofen, 1), asCartItem(azitro, 1)],
|
||||||
|
subtotal: gofen.price + azitro.price,
|
||||||
|
deliveryFee: 80,
|
||||||
|
discount: 32,
|
||||||
|
voucherCode: "GISHEN10",
|
||||||
|
total: gofen.price + azitro.price + 80 - 32,
|
||||||
|
fulfillment: "delivery",
|
||||||
|
branchId: "mexico",
|
||||||
|
areaLabel: "Mexico Square",
|
||||||
|
addressLine: "Gabon St, Yaltopia Tower, Floor 6",
|
||||||
|
paymentMethod: "telebirr",
|
||||||
|
status: "delivered",
|
||||||
|
riderAssigned: true,
|
||||||
|
customerName: "Kirukib Abebe",
|
||||||
|
customerPhone: "+251911000000",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedFamily: FamilyMember[] = [
|
||||||
|
{
|
||||||
|
id: "fam_demo_sara",
|
||||||
|
name: "Sara Abebe",
|
||||||
|
relationship: "spouse",
|
||||||
|
phone: "+251911223344",
|
||||||
|
status: "accepted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fam_demo_yonas",
|
||||||
|
name: "Yonas Abebe",
|
||||||
|
relationship: "child",
|
||||||
|
phone: "+251922334455",
|
||||||
|
status: "accepted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fam_demo_pending",
|
||||||
|
name: "Hanna Bekele",
|
||||||
|
relationship: "parent",
|
||||||
|
phone: "+251933445566",
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedSubscriptions: RefillSubscription[] = [
|
||||||
|
{
|
||||||
|
id: "sub_demo_metformin",
|
||||||
|
productId: "p7",
|
||||||
|
productName: "Metformin",
|
||||||
|
quantity: 1,
|
||||||
|
frequencyDays: 30,
|
||||||
|
fulfillment: "delivery",
|
||||||
|
paymentMethod: "chapa",
|
||||||
|
memberId: null,
|
||||||
|
status: "active",
|
||||||
|
nextFulfillmentAt: daysFromNow(5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sub_demo_vitc",
|
||||||
|
productId: "p14",
|
||||||
|
productName: "Nat C (Vitamin C)",
|
||||||
|
quantity: 2,
|
||||||
|
frequencyDays: 60,
|
||||||
|
fulfillment: "pickup",
|
||||||
|
paymentMethod: "telebirr",
|
||||||
|
memberId: null,
|
||||||
|
status: "paused",
|
||||||
|
nextFulfillmentAt: daysFromNow(20),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedEmergencyContacts: EmergencyContact[] = [
|
||||||
|
{
|
||||||
|
id: "ec_demo_1",
|
||||||
|
name: "Sara Abebe",
|
||||||
|
phone: "+251911223344",
|
||||||
|
relationship: "Spouse",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ec_demo_2",
|
||||||
|
name: "Dr. Michael Tadesse",
|
||||||
|
phone: "+251911000111",
|
||||||
|
relationship: "Family doctor",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedFavoriteIds = ["p1", "p7", "p14", "p16", "p39"];
|
||||||
|
|
||||||
|
export const DEMO_USER = {
|
||||||
|
id: "u_demo_gishen",
|
||||||
|
name: "Kirukib Abebe",
|
||||||
|
phone: "+251911000000",
|
||||||
|
email: "kirukib@example.com",
|
||||||
|
points: 420,
|
||||||
|
tier: "silver" as const,
|
||||||
|
isGuest: false,
|
||||||
|
biometricEnabled: false,
|
||||||
|
avatarUri: null as string | null,
|
||||||
|
};
|
||||||
24
lib/i18n.ts
Normal file
24
lib/i18n.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import en from "@/locales/en/common.json";
|
||||||
|
import am from "@/locales/am/common.json";
|
||||||
|
import { useLangStore } from "@/lib/stores/langStore";
|
||||||
|
|
||||||
|
const initial = useLangStore.getState().language;
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
compatibilityJSON: "v4",
|
||||||
|
resources: {
|
||||||
|
en: { translation: en },
|
||||||
|
am: { translation: am },
|
||||||
|
},
|
||||||
|
lng: initial,
|
||||||
|
fallbackLng: "en",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
useLangStore.subscribe((state) => {
|
||||||
|
void i18n.changeLanguage(state.language);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
72
lib/notifications.ts
Normal file
72
lib/notifications.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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 async function scheduleDoseReminder(opts: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
secondsFromNow: number;
|
||||||
|
data?: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
await ensureNotificationPermissions();
|
||||||
|
return Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
data: opts.data,
|
||||||
|
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();
|
||||||
|
return Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
data: { ...opts.data, type: "escalation" },
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||||
|
seconds: Math.max(10, opts.graceSeconds),
|
||||||
|
repeats: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function androidChannel() {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
return Notifications.setNotificationChannelAsync("gishen-reminders", {
|
||||||
|
name: "Gishen reminders",
|
||||||
|
importance: Notifications.AndroidImportance.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
52
lib/routes.ts
Normal file
52
lib/routes.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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)/(tabs)/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)/(screens)/prescription/list",
|
||||||
|
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,
|
||||||
|
loyalty: "/(root)/(screens)/loyalty",
|
||||||
|
about: "/(root)/(screens)/about",
|
||||||
|
contact: "/(root)/(screens)/contact",
|
||||||
|
addresses: "/(root)/(screens)/addresses",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
id: string;
|
||||||
|
labelKey: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const categories: Category[] = [
|
||||||
|
{ id: "medicines", labelKey: "categories.medicines", icon: "medkit-outline" },
|
||||||
|
{ id: "vitamins", labelKey: "categories.vitamins", icon: "leaf-outline" },
|
||||||
|
{ id: "personal-care", labelKey: "categories.personalCare", icon: "sparkles-outline" },
|
||||||
|
{ id: "diabetic", labelKey: "categories.diabetic", icon: "pulse-outline" },
|
||||||
|
{ id: "devices", labelKey: "categories.devices", icon: "hardware-chip-outline" },
|
||||||
|
{ id: "baby", labelKey: "categories.baby", icon: "happy-outline" },
|
||||||
|
{ id: "first-aid", labelKey: "categories.firstAid", icon: "bandage-outline" },
|
||||||
|
{ id: "wellness", labelKey: "categories.wellness", icon: "heart-outline" },
|
||||||
|
];
|
||||||
103
lib/stores/addressStore.ts
Normal file
103
lib/stores/addressStore.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export type SavedAddress = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
area: string;
|
||||||
|
street: string;
|
||||||
|
landmark?: string;
|
||||||
|
notes?: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AddressState = {
|
||||||
|
addresses: SavedAddress[];
|
||||||
|
add: (a: Omit<SavedAddress, "id" | "isDefault"> & { isDefault?: boolean }) => void;
|
||||||
|
update: (id: string, patch: Partial<SavedAddress>) => void;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
setDefault: (id: string) => void;
|
||||||
|
defaultAddress: () => SavedAddress | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedAddresses: SavedAddress[] = [
|
||||||
|
{
|
||||||
|
id: "addr_home",
|
||||||
|
label: "Home",
|
||||||
|
area: "Bole, Addis Ababa",
|
||||||
|
street: "Rwanda St, Building 12, Apt 4B",
|
||||||
|
landmark: "Near Edna Mall",
|
||||||
|
notes: "Call on arrival",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "addr_office",
|
||||||
|
label: "Office",
|
||||||
|
area: "Kazanchis, Addis Ababa",
|
||||||
|
street: "Gabon St, Yaltopia Tower, Floor 6",
|
||||||
|
landmark: "Opposite UNECA",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useAddressStore = create<AddressState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
addresses: seedAddresses,
|
||||||
|
add: (a) => {
|
||||||
|
const id = `addr_${Date.now()}`;
|
||||||
|
const makeDefault = a.isDefault || get().addresses.length === 0;
|
||||||
|
set({
|
||||||
|
addresses: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
label: a.label,
|
||||||
|
area: a.area,
|
||||||
|
street: a.street,
|
||||||
|
landmark: a.landmark,
|
||||||
|
notes: a.notes,
|
||||||
|
isDefault: makeDefault,
|
||||||
|
},
|
||||||
|
...get().addresses.map((x) =>
|
||||||
|
makeDefault ? { ...x, isDefault: false } : x
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
update: (id, patch) =>
|
||||||
|
set({
|
||||||
|
addresses: get().addresses.map((a) =>
|
||||||
|
a.id === id ? { ...a, ...patch } : a
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
remove: (id) => {
|
||||||
|
const next = get().addresses.filter((a) => a.id !== id);
|
||||||
|
if (next.length && !next.some((a) => a.isDefault)) {
|
||||||
|
next[0] = { ...next[0], isDefault: true };
|
||||||
|
}
|
||||||
|
set({ addresses: next });
|
||||||
|
},
|
||||||
|
setDefault: (id) =>
|
||||||
|
set({
|
||||||
|
addresses: get().addresses.map((a) => ({
|
||||||
|
...a,
|
||||||
|
isDefault: a.id === id,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
defaultAddress: () =>
|
||||||
|
get().addresses.find((a) => a.isDefault) ?? get().addresses[0] ?? null,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-addresses",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 1,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<AddressState>;
|
||||||
|
return !state.addresses?.length
|
||||||
|
? { ...state, addresses: seedAddresses }
|
||||||
|
: state;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
228
lib/stores/authStore.ts
Normal file
228
lib/stores/authStore.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { DEMO_USER } from "@/lib/data/seed";
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
points: number;
|
||||||
|
tier: "bronze" | "silver" | "gold";
|
||||||
|
isGuest: boolean;
|
||||||
|
biometricEnabled: boolean;
|
||||||
|
avatarUri?: string | null;
|
||||||
|
referralCode?: string;
|
||||||
|
activeVoucher?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PointsEntry = {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
label: string;
|
||||||
|
delta: number;
|
||||||
|
balance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
user: AuthUser | null;
|
||||||
|
hasOnboarded: boolean;
|
||||||
|
ledger: PointsEntry[];
|
||||||
|
setHasOnboarded: (v: boolean) => void;
|
||||||
|
signIn: (payload: Partial<AuthUser> & { phone: string; name: string }) => void;
|
||||||
|
signOut: () => void;
|
||||||
|
setBiometric: (enabled: boolean) => void;
|
||||||
|
setAvatar: (uri: string | null) => void;
|
||||||
|
updateProfile: (
|
||||||
|
patch: Partial<Pick<AuthUser, "name" | "email" | "phone">>
|
||||||
|
) => void;
|
||||||
|
addPoints: (pts: number, label?: string) => void;
|
||||||
|
redeemPoints: (pts: number) => boolean;
|
||||||
|
setActiveVoucher: (code: string | null) => void;
|
||||||
|
ensureDemoUser: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function tierFor(points: number): AuthUser["tier"] {
|
||||||
|
if (points >= 1000) return "gold";
|
||||||
|
if (points >= 300) return "silver";
|
||||||
|
return "bronze";
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedLedger: PointsEntry[] = [
|
||||||
|
{
|
||||||
|
id: "pts_1",
|
||||||
|
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 20).toISOString(),
|
||||||
|
label: "Welcome bonus",
|
||||||
|
delta: 50,
|
||||||
|
balance: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pts_2",
|
||||||
|
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 12).toISOString(),
|
||||||
|
label: "Order reward",
|
||||||
|
delta: 28,
|
||||||
|
balance: 78,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pts_3",
|
||||||
|
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(),
|
||||||
|
label: "Referral bonus",
|
||||||
|
delta: 100,
|
||||||
|
balance: 178,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pts_4",
|
||||||
|
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
|
||||||
|
label: "Order reward",
|
||||||
|
delta: 242,
|
||||||
|
balance: 420,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
user: {
|
||||||
|
...DEMO_USER,
|
||||||
|
email: DEMO_USER.email,
|
||||||
|
referralCode: "GISHEN-KIRU",
|
||||||
|
activeVoucher: null,
|
||||||
|
},
|
||||||
|
hasOnboarded: true,
|
||||||
|
ledger: seedLedger,
|
||||||
|
setHasOnboarded: (v) => set({ hasOnboarded: v }),
|
||||||
|
signIn: ({ phone, name, email, isGuest, avatarUri }) =>
|
||||||
|
set({
|
||||||
|
user: {
|
||||||
|
id: `u_${Date.now()}`,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
points: isGuest ? 0 : 50,
|
||||||
|
tier: "bronze",
|
||||||
|
isGuest: !!isGuest,
|
||||||
|
biometricEnabled: false,
|
||||||
|
avatarUri: avatarUri ?? null,
|
||||||
|
referralCode: `GISHEN-${name.slice(0, 4).toUpperCase() || "USER"}`,
|
||||||
|
activeVoucher: null,
|
||||||
|
},
|
||||||
|
hasOnboarded: true,
|
||||||
|
ledger: isGuest
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: `pts_${Date.now()}`,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
label: "Welcome bonus",
|
||||||
|
delta: 50,
|
||||||
|
balance: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
signOut: () => set({ user: null }),
|
||||||
|
setBiometric: (enabled) => {
|
||||||
|
const user = get().user;
|
||||||
|
if (!user) return;
|
||||||
|
set({ user: { ...user, biometricEnabled: enabled } });
|
||||||
|
},
|
||||||
|
setAvatar: (uri) => {
|
||||||
|
const user = get().user;
|
||||||
|
if (!user) return;
|
||||||
|
set({ user: { ...user, avatarUri: uri } });
|
||||||
|
},
|
||||||
|
updateProfile: (patch) => {
|
||||||
|
const user = get().user;
|
||||||
|
if (!user) return;
|
||||||
|
set({ user: { ...user, ...patch } });
|
||||||
|
},
|
||||||
|
addPoints: (pts, label = "Order reward") => {
|
||||||
|
const user = get().user;
|
||||||
|
if (!user || user.isGuest || pts === 0) return;
|
||||||
|
const points = user.points + pts;
|
||||||
|
const entry: PointsEntry = {
|
||||||
|
id: `pts_${Date.now()}`,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
label,
|
||||||
|
delta: pts,
|
||||||
|
balance: points,
|
||||||
|
};
|
||||||
|
set({
|
||||||
|
user: { ...user, points, tier: tierFor(points) },
|
||||||
|
ledger: [entry, ...get().ledger],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
redeemPoints: (pts) => {
|
||||||
|
const user = get().user;
|
||||||
|
if (!user || user.isGuest || user.points < pts) return false;
|
||||||
|
const points = user.points - pts;
|
||||||
|
const entry: PointsEntry = {
|
||||||
|
id: `pts_${Date.now()}`,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
label: `Redeemed for voucher`,
|
||||||
|
delta: -pts,
|
||||||
|
balance: points,
|
||||||
|
};
|
||||||
|
set({
|
||||||
|
user: {
|
||||||
|
...user,
|
||||||
|
points,
|
||||||
|
tier: tierFor(points),
|
||||||
|
activeVoucher: "GISHEN10",
|
||||||
|
},
|
||||||
|
ledger: [entry, ...get().ledger],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
setActiveVoucher: (code) => {
|
||||||
|
const user = get().user;
|
||||||
|
if (!user) return;
|
||||||
|
set({ user: { ...user, activeVoucher: code } });
|
||||||
|
},
|
||||||
|
ensureDemoUser: () => {
|
||||||
|
if (!get().user) {
|
||||||
|
set({
|
||||||
|
user: {
|
||||||
|
...DEMO_USER,
|
||||||
|
referralCode: "GISHEN-KIRU",
|
||||||
|
activeVoucher: null,
|
||||||
|
},
|
||||||
|
hasOnboarded: true,
|
||||||
|
ledger: seedLedger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-auth",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 3,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<AuthState>;
|
||||||
|
if (!state.user) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: {
|
||||||
|
...DEMO_USER,
|
||||||
|
referralCode: "GISHEN-KIRU",
|
||||||
|
activeVoucher: null,
|
||||||
|
},
|
||||||
|
hasOnboarded: true,
|
||||||
|
ledger: seedLedger,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user: {
|
||||||
|
...DEMO_USER,
|
||||||
|
...state.user,
|
||||||
|
avatarUri: state.user.avatarUri ?? null,
|
||||||
|
referralCode: state.user.referralCode ?? "GISHEN-KIRU",
|
||||||
|
activeVoucher: state.user.activeVoucher ?? null,
|
||||||
|
},
|
||||||
|
ledger: state.ledger?.length ? state.ledger : seedLedger,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
80
lib/stores/cartStore.ts
Normal file
80
lib/stores/cartStore.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import type { Product } from "@/lib/data/products";
|
||||||
|
|
||||||
|
export type CartItem = {
|
||||||
|
productId: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
image: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: number;
|
||||||
|
requiresPrescription?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CartState = {
|
||||||
|
items: CartItem[];
|
||||||
|
voucherCode: string | null;
|
||||||
|
addProduct: (product: Product, qty?: number) => void;
|
||||||
|
removeItem: (productId: string) => void;
|
||||||
|
setQuantity: (productId: string, quantity: number) => void;
|
||||||
|
clear: () => void;
|
||||||
|
setVoucher: (code: string | null) => void;
|
||||||
|
totalItems: () => number;
|
||||||
|
subtotal: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCartStore = create<CartState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
items: [],
|
||||||
|
voucherCode: null,
|
||||||
|
addProduct: (product, qty = 1) => {
|
||||||
|
const items = [...get().items];
|
||||||
|
const idx = items.findIndex((i) => i.productId === product.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
items[idx] = {
|
||||||
|
...items[idx],
|
||||||
|
quantity: items[idx].quantity + qty,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
productId: product.id,
|
||||||
|
slug: product.slug,
|
||||||
|
name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
image: product.image,
|
||||||
|
unit: product.unit,
|
||||||
|
quantity: qty,
|
||||||
|
requiresPrescription: product.requiresPrescription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
set({ items });
|
||||||
|
},
|
||||||
|
removeItem: (productId) =>
|
||||||
|
set({ items: get().items.filter((i) => i.productId !== productId) }),
|
||||||
|
setQuantity: (productId, quantity) => {
|
||||||
|
if (quantity <= 0) {
|
||||||
|
get().removeItem(productId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
items: get().items.map((i) =>
|
||||||
|
i.productId === productId ? { ...i, quantity } : i
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clear: () => set({ items: [], voucherCode: null }),
|
||||||
|
setVoucher: (code) => set({ voucherCode: code }),
|
||||||
|
totalItems: () => get().items.reduce((n, i) => n + i.quantity, 0),
|
||||||
|
subtotal: () =>
|
||||||
|
get().items.reduce((n, i) => n + i.price * i.quantity, 0),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-cart",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
54
lib/stores/emergencyContactStore.ts
Normal file
54
lib/stores/emergencyContactStore.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { seedEmergencyContacts } from "@/lib/data/seed";
|
||||||
|
|
||||||
|
export type EmergencyContact = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
relationship: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EcState = {
|
||||||
|
contacts: EmergencyContact[];
|
||||||
|
graceMinutesDefault: number;
|
||||||
|
escalationEnabled: boolean;
|
||||||
|
lastEscalationAt?: string;
|
||||||
|
add: (c: Omit<EmergencyContact, "id">) => void;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
setGrace: (m: number) => void;
|
||||||
|
setEscalationEnabled: (v: boolean) => void;
|
||||||
|
markEscalated: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEmergencyContactStore = create<EcState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
contacts: seedEmergencyContacts,
|
||||||
|
graceMinutesDefault: 30,
|
||||||
|
escalationEnabled: true,
|
||||||
|
add: (c) =>
|
||||||
|
set({
|
||||||
|
contacts: [{ ...c, id: `ec_${Date.now()}` }, ...get().contacts],
|
||||||
|
}),
|
||||||
|
remove: (id) =>
|
||||||
|
set({ contacts: get().contacts.filter((c) => c.id !== id) }),
|
||||||
|
setGrace: (m) => set({ graceMinutesDefault: m }),
|
||||||
|
setEscalationEnabled: (v) => set({ escalationEnabled: v }),
|
||||||
|
markEscalated: () =>
|
||||||
|
set({ lastEscalationAt: new Date().toISOString() }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-emergency",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 2,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<EcState>;
|
||||||
|
return !state.contacts?.length
|
||||||
|
? { ...state, contacts: seedEmergencyContacts }
|
||||||
|
: state;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
67
lib/stores/familyStore.ts
Normal file
67
lib/stores/familyStore.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { seedFamily } from "@/lib/data/seed";
|
||||||
|
|
||||||
|
export type FamilyMember = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
relationship: "spouse" | "child" | "parent" | "other";
|
||||||
|
phone?: string;
|
||||||
|
status: "pending" | "accepted";
|
||||||
|
};
|
||||||
|
|
||||||
|
type FamilyState = {
|
||||||
|
members: FamilyMember[];
|
||||||
|
activeMemberId: string | null;
|
||||||
|
inviteCode: string;
|
||||||
|
addMember: (m: Omit<FamilyMember, "id" | "status">) => void;
|
||||||
|
acceptInvite: (id: string) => void;
|
||||||
|
removeMember: (id: string) => void;
|
||||||
|
setActiveMember: (id: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFamilyStore = create<FamilyState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
members: seedFamily,
|
||||||
|
activeMemberId: "fam_demo_sara",
|
||||||
|
inviteCode: "FAM-GISHEN",
|
||||||
|
addMember: (m) =>
|
||||||
|
set({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
...m,
|
||||||
|
id: `fam_${Date.now()}`,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
...get().members,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
acceptInvite: (id) =>
|
||||||
|
set({
|
||||||
|
members: get().members.map((m) =>
|
||||||
|
m.id === id ? { ...m, status: "accepted" } : m
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
removeMember: (id) =>
|
||||||
|
set({
|
||||||
|
members: get().members.filter((m) => m.id !== id),
|
||||||
|
activeMemberId:
|
||||||
|
get().activeMemberId === id ? null : get().activeMemberId,
|
||||||
|
}),
|
||||||
|
setActiveMember: (id) => set({ activeMemberId: id }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-family",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 2,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<FamilyState>;
|
||||||
|
return !state.members?.length
|
||||||
|
? { ...state, members: seedFamily, activeMemberId: "fam_demo_sara" }
|
||||||
|
: state;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
34
lib/stores/favoritesStore.ts
Normal file
34
lib/stores/favoritesStore.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { seedFavoriteIds } from "@/lib/data/seed";
|
||||||
|
|
||||||
|
type FavState = {
|
||||||
|
ids: string[];
|
||||||
|
toggle: (id: string) => void;
|
||||||
|
has: (id: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFavoritesStore = create<FavState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
ids: seedFavoriteIds,
|
||||||
|
toggle: (id) => {
|
||||||
|
const ids = get().ids;
|
||||||
|
set({
|
||||||
|
ids: ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
has: (id) => get().ids.includes(id),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-favorites",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 2,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<FavState>;
|
||||||
|
return !state.ids?.length ? { ...state, ids: seedFavoriteIds } : state;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
23
lib/stores/langStore.ts
Normal file
23
lib/stores/langStore.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export type Language = "en" | "am";
|
||||||
|
|
||||||
|
type LangState = {
|
||||||
|
language: Language;
|
||||||
|
setLanguage: (language: Language) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLangStore = create<LangState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
language: "en",
|
||||||
|
setLanguage: (language) => set({ language }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-lang",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
59
lib/stores/locationStore.ts
Normal file
59
lib/stores/locationStore.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export type Fulfillment = "delivery" | "pickup";
|
||||||
|
export type PaymentMethod =
|
||||||
|
| "chapa"
|
||||||
|
| "telebirr"
|
||||||
|
| "mpesa"
|
||||||
|
| "pay_at_branch";
|
||||||
|
|
||||||
|
type LocationState = {
|
||||||
|
fulfillment: Fulfillment;
|
||||||
|
areaLabel: string;
|
||||||
|
branchId: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lng: number | null;
|
||||||
|
paymentPreference: PaymentMethod;
|
||||||
|
setFulfillment: (f: Fulfillment) => void;
|
||||||
|
setAreaLabel: (label: string) => void;
|
||||||
|
setBranchId: (id: string | null) => void;
|
||||||
|
setCoords: (lat: number | null, lng: number | null) => void;
|
||||||
|
setPaymentPreference: (m: PaymentMethod) => void;
|
||||||
|
allowedPayments: () => PaymentMethod[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLocationStore = create<LocationState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
fulfillment: "delivery",
|
||||||
|
areaLabel: "Addis Ababa",
|
||||||
|
branchId: null,
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
paymentPreference: "chapa",
|
||||||
|
setFulfillment: (fulfillment) => {
|
||||||
|
const next: Partial<LocationState> = { fulfillment };
|
||||||
|
if (fulfillment === "delivery" && get().paymentPreference === "pay_at_branch") {
|
||||||
|
next.paymentPreference = "chapa";
|
||||||
|
}
|
||||||
|
set(next);
|
||||||
|
},
|
||||||
|
setAreaLabel: (areaLabel) => set({ areaLabel }),
|
||||||
|
setBranchId: (branchId) => set({ branchId }),
|
||||||
|
setCoords: (lat, lng) => set({ lat, lng }),
|
||||||
|
setPaymentPreference: (paymentPreference) => set({ paymentPreference }),
|
||||||
|
allowedPayments: () => {
|
||||||
|
const base: PaymentMethod[] = ["chapa", "telebirr", "mpesa"];
|
||||||
|
return get().fulfillment === "pickup"
|
||||||
|
? [...base, "pay_at_branch"]
|
||||||
|
: base;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-location",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
174
lib/stores/ordersStore.ts
Normal file
174
lib/stores/ordersStore.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-orders",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 3,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<OrdersState>;
|
||||||
|
if (!state.orders?.length) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
orders: seedOrders.map((o) => ({
|
||||||
|
...o,
|
||||||
|
discount: 0,
|
||||||
|
customerName: "Kirukib Abebe",
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
165
lib/stores/prescriptionStore.ts
Normal file
165
lib/stores/prescriptionStore.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
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 id = r.id ?? `rem_${Date.now()}`;
|
||||||
|
const next = { ...r, id } as Reminder;
|
||||||
|
const others = get().reminders.filter((x) => x.id !== id);
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
109
lib/stores/profileModeStore.ts
Normal file
109
lib/stores/profileModeStore.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export type ProfileMode = "personal" | "corporate";
|
||||||
|
|
||||||
|
export type OrgMembership = {
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
planName: string;
|
||||||
|
remainingAllowance: number;
|
||||||
|
monthlyCap: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProfileState = {
|
||||||
|
mode: ProfileMode;
|
||||||
|
memberships: OrgMembership[];
|
||||||
|
activeOrgId: string | null;
|
||||||
|
setMode: (mode: ProfileMode) => void;
|
||||||
|
setActiveOrg: (orgId: string | null) => void;
|
||||||
|
joinOrg: (code: string) => { ok: boolean; message: string };
|
||||||
|
activeMembership: () => OrgMembership | null;
|
||||||
|
quoteSplit: (subtotal: number) => { covered: number; selfPay: number };
|
||||||
|
consumeAllowance: (amount: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEMO_ORGS: Record<string, OrgMembership> = {
|
||||||
|
"GISHEN-BANK": {
|
||||||
|
orgId: "org_bank",
|
||||||
|
orgName: "Demo Bank Ethiopia",
|
||||||
|
planName: "Staff",
|
||||||
|
remainingAllowance: 2500,
|
||||||
|
monthlyCap: 5000,
|
||||||
|
},
|
||||||
|
"GISHEN-NGO": {
|
||||||
|
orgId: "org_ngo",
|
||||||
|
orgName: "Demo NGO",
|
||||||
|
planName: "Standard",
|
||||||
|
remainingAllowance: 1200,
|
||||||
|
monthlyCap: 3000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const seededMembership = { ...DEMO_ORGS["GISHEN-BANK"] };
|
||||||
|
|
||||||
|
export const useProfileModeStore = create<ProfileState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
mode: "personal",
|
||||||
|
memberships: [seededMembership],
|
||||||
|
activeOrgId: null,
|
||||||
|
setMode: (mode) => set({ mode }),
|
||||||
|
setActiveOrg: (orgId) =>
|
||||||
|
set({ activeOrgId: orgId, mode: orgId ? "corporate" : "personal" }),
|
||||||
|
joinOrg: (code) => {
|
||||||
|
const key = code.trim().toUpperCase();
|
||||||
|
const org = DEMO_ORGS[key];
|
||||||
|
if (!org) return { ok: false, message: "Invalid join code" };
|
||||||
|
const memberships = get().memberships;
|
||||||
|
if (memberships.some((m) => m.orgId === org.orgId)) {
|
||||||
|
set({ activeOrgId: org.orgId, mode: "corporate" });
|
||||||
|
return { ok: true, message: "Already a member — switched to org" };
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
memberships: [...memberships, { ...org }],
|
||||||
|
activeOrgId: org.orgId,
|
||||||
|
mode: "corporate",
|
||||||
|
});
|
||||||
|
return { ok: true, message: `Joined ${org.orgName}` };
|
||||||
|
},
|
||||||
|
activeMembership: () => {
|
||||||
|
const { activeOrgId, memberships, mode } = get();
|
||||||
|
if (mode !== "corporate" || !activeOrgId) return null;
|
||||||
|
return memberships.find((m) => m.orgId === activeOrgId) ?? null;
|
||||||
|
},
|
||||||
|
quoteSplit: (subtotal) => {
|
||||||
|
const m = get().activeMembership();
|
||||||
|
if (!m) return { covered: 0, selfPay: subtotal };
|
||||||
|
const covered = Math.min(subtotal, m.remainingAllowance);
|
||||||
|
return { covered, selfPay: Math.max(0, subtotal - covered) };
|
||||||
|
},
|
||||||
|
consumeAllowance: (amount) => {
|
||||||
|
const { activeOrgId, memberships } = get();
|
||||||
|
if (!activeOrgId || amount <= 0) return;
|
||||||
|
set({
|
||||||
|
memberships: memberships.map((m) =>
|
||||||
|
m.orgId === activeOrgId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
remainingAllowance: Math.max(0, m.remainingAllowance - amount),
|
||||||
|
}
|
||||||
|
: m
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-profile-mode",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 2,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<ProfileState>;
|
||||||
|
return !state.memberships?.length
|
||||||
|
? { ...state, memberships: [seededMembership] }
|
||||||
|
: state;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
91
lib/stores/subscriptionStore.ts
Normal file
91
lib/stores/subscriptionStore.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { seedSubscriptions } from "@/lib/data/seed";
|
||||||
|
import type { Fulfillment, PaymentMethod } from "@/lib/stores/locationStore";
|
||||||
|
|
||||||
|
export type SubscriptionStatus = "active" | "paused" | "cancelled";
|
||||||
|
|
||||||
|
export type RefillSubscription = {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
quantity: number;
|
||||||
|
frequencyDays: number;
|
||||||
|
fulfillment: Fulfillment;
|
||||||
|
paymentMethod: PaymentMethod;
|
||||||
|
memberId?: string | null;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
nextFulfillmentAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubState = {
|
||||||
|
items: RefillSubscription[];
|
||||||
|
add: (
|
||||||
|
s: Omit<RefillSubscription, "id" | "status" | "nextFulfillmentAt">
|
||||||
|
) => void;
|
||||||
|
pause: (id: string) => void;
|
||||||
|
resume: (id: string) => void;
|
||||||
|
cancel: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function nextDate(days: number) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubscriptionStore = create<SubState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
items: seedSubscriptions,
|
||||||
|
add: (s) =>
|
||||||
|
set({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...s,
|
||||||
|
id: `sub_${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
nextFulfillmentAt: nextDate(s.frequencyDays),
|
||||||
|
},
|
||||||
|
...get().items,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
pause: (id) =>
|
||||||
|
set({
|
||||||
|
items: get().items.map((i) =>
|
||||||
|
i.id === id ? { ...i, status: "paused" } : i
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
resume: (id) =>
|
||||||
|
set({
|
||||||
|
items: get().items.map((i) =>
|
||||||
|
i.id === id
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
status: "active",
|
||||||
|
nextFulfillmentAt: nextDate(i.frequencyDays),
|
||||||
|
}
|
||||||
|
: i
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
cancel: (id) =>
|
||||||
|
set({
|
||||||
|
items: get().items.map((i) =>
|
||||||
|
i.id === id ? { ...i, status: "cancelled" } : i
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "gishen-subscriptions",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
version: 2,
|
||||||
|
migrate: (persisted: unknown) => {
|
||||||
|
const state = (persisted ?? {}) as Partial<SubState>;
|
||||||
|
return !state.items?.length
|
||||||
|
? { ...state, items: seedSubscriptions }
|
||||||
|
: state;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
168
lib/theme.ts
Normal file
168
lib/theme.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gishen design tokens.
|
||||||
|
*
|
||||||
|
* Brand lime is used as a *fill* with ink text (high contrast, distinctive).
|
||||||
|
* `brandText` is the accessible green reserved for green-on-white text.
|
||||||
|
*/
|
||||||
|
const palette = {
|
||||||
|
lime50: "#F6FAEC",
|
||||||
|
lime100: "#ECF4D8",
|
||||||
|
lime200: "#D9E9B2",
|
||||||
|
lime300: "#C3DB86",
|
||||||
|
lime400: "#A8C73A",
|
||||||
|
lime500: "#93B22E",
|
||||||
|
lime600: "#748E22",
|
||||||
|
lime700: "#55681B",
|
||||||
|
|
||||||
|
ink900: "#101410",
|
||||||
|
ink700: "#2C332B",
|
||||||
|
ink500: "#5B6157",
|
||||||
|
ink300: "#8A9188",
|
||||||
|
ink100: "#C3C8BE",
|
||||||
|
|
||||||
|
paper: "#F7F8F5",
|
||||||
|
white: "#FFFFFF",
|
||||||
|
hairline: "#E9ECE2",
|
||||||
|
wash: "#F1F3EC",
|
||||||
|
|
||||||
|
red500: "#D64545",
|
||||||
|
red50: "#FDF0F0",
|
||||||
|
amber500: "#E0870F",
|
||||||
|
amber50: "#FDF5E7",
|
||||||
|
blue500: "#2F6FED",
|
||||||
|
blue50: "#EEF3FE",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const colors = {
|
||||||
|
brand: palette.lime400,
|
||||||
|
brandDeep: palette.lime500,
|
||||||
|
brandText: palette.lime600,
|
||||||
|
brandSoft: palette.lime100,
|
||||||
|
brandWash: palette.lime50,
|
||||||
|
brandBorder: palette.lime200,
|
||||||
|
|
||||||
|
ink: palette.ink900,
|
||||||
|
inkDeep: palette.ink900,
|
||||||
|
inkSecondary: palette.ink500,
|
||||||
|
muted: palette.ink500,
|
||||||
|
subtle: palette.ink300,
|
||||||
|
disabled: palette.ink100,
|
||||||
|
|
||||||
|
page: palette.paper,
|
||||||
|
surface: palette.white,
|
||||||
|
surfaceAlt: palette.wash,
|
||||||
|
line: palette.hairline,
|
||||||
|
|
||||||
|
danger: palette.red500,
|
||||||
|
dangerSoft: palette.red50,
|
||||||
|
warn: palette.amber500,
|
||||||
|
warnSoft: palette.amber50,
|
||||||
|
accent: palette.amber500,
|
||||||
|
info: palette.blue500,
|
||||||
|
infoSoft: palette.blue50,
|
||||||
|
|
||||||
|
inStock: palette.lime600,
|
||||||
|
outOfStock: palette.red500,
|
||||||
|
star: "#F5A524",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const space = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 20,
|
||||||
|
"2xl": 24,
|
||||||
|
"3xl": 32,
|
||||||
|
"4xl": 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const radii = {
|
||||||
|
xs: 8,
|
||||||
|
sm: 12,
|
||||||
|
md: 16,
|
||||||
|
lg: 20,
|
||||||
|
xl: 24,
|
||||||
|
card: 20,
|
||||||
|
pill: 999,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Screen gutter used by every page so content lines up across the app. */
|
||||||
|
export const gutter = 20;
|
||||||
|
|
||||||
|
export const fonts = {
|
||||||
|
regular: "Jakarta_400Regular",
|
||||||
|
medium: "Jakarta_500Medium",
|
||||||
|
semibold: "Jakarta_600SemiBold",
|
||||||
|
bold: "Jakarta_700Bold",
|
||||||
|
extrabold: "Jakarta_800ExtraBold",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TypeVariant =
|
||||||
|
| "display"
|
||||||
|
| "h1"
|
||||||
|
| "h2"
|
||||||
|
| "h3"
|
||||||
|
| "body"
|
||||||
|
| "bodyMedium"
|
||||||
|
| "bodySm"
|
||||||
|
| "label"
|
||||||
|
| "caption"
|
||||||
|
| "micro"
|
||||||
|
| "price"
|
||||||
|
| "priceLg";
|
||||||
|
|
||||||
|
export const typography: Record<
|
||||||
|
TypeVariant,
|
||||||
|
{ fontFamily: string; fontSize: number; lineHeight: number; letterSpacing?: number }
|
||||||
|
> = {
|
||||||
|
display: { fontFamily: fonts.extrabold, fontSize: 30, lineHeight: 36, letterSpacing: -0.6 },
|
||||||
|
h1: { fontFamily: fonts.extrabold, fontSize: 24, lineHeight: 30, letterSpacing: -0.4 },
|
||||||
|
h2: { fontFamily: fonts.bold, fontSize: 18, lineHeight: 24, letterSpacing: -0.2 },
|
||||||
|
h3: { fontFamily: fonts.semibold, fontSize: 15, lineHeight: 20, letterSpacing: -0.1 },
|
||||||
|
body: { fontFamily: fonts.regular, fontSize: 14, lineHeight: 21 },
|
||||||
|
bodyMedium: { fontFamily: fonts.medium, fontSize: 14, lineHeight: 20 },
|
||||||
|
bodySm: { fontFamily: fonts.regular, fontSize: 13, lineHeight: 19 },
|
||||||
|
label: { fontFamily: fonts.semibold, fontSize: 13, lineHeight: 17 },
|
||||||
|
caption: { fontFamily: fonts.medium, fontSize: 12, lineHeight: 16 },
|
||||||
|
micro: { fontFamily: fonts.semibold, fontSize: 11, lineHeight: 14, letterSpacing: 0.2 },
|
||||||
|
price: { fontFamily: fonts.bold, fontSize: 16, lineHeight: 21, letterSpacing: -0.2 },
|
||||||
|
priceLg: { fontFamily: fonts.extrabold, fontSize: 24, lineHeight: 29, letterSpacing: -0.5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Shadow = {
|
||||||
|
shadowColor: string;
|
||||||
|
shadowOpacity: number;
|
||||||
|
shadowRadius: number;
|
||||||
|
shadowOffset: { width: number; height: number };
|
||||||
|
elevation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shadow = (opacity: number, radius: number, y: number, elevation: number): Shadow => ({
|
||||||
|
shadowColor: "#16240A",
|
||||||
|
shadowOpacity: Platform.OS === "android" ? opacity * 1.4 : opacity,
|
||||||
|
shadowRadius: radius,
|
||||||
|
shadowOffset: { width: 0, height: y },
|
||||||
|
elevation,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const shadows = {
|
||||||
|
/** Resting cards. Nearly invisible, just lifts off the page. */
|
||||||
|
xs: shadow(0.04, 6, 2, 1),
|
||||||
|
sm: shadow(0.05, 10, 4, 2),
|
||||||
|
/** Sheets, floating tab bar, sticky bars. */
|
||||||
|
md: shadow(0.08, 18, 8, 8),
|
||||||
|
lg: shadow(0.12, 28, 14, 16),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Card recipe reused across screens so every surface matches. */
|
||||||
|
export const card = {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: radii.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const hitSlop = { top: 10, bottom: 10, left: 10, right: 10 } as const;
|
||||||
11
lib/utils.ts
Normal file
11
lib/utils.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function formatETB(amount: number): string {
|
||||||
|
return `${amount.toLocaleString("en-ET")} ETB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CALL_PHONE = "+251930099850";
|
||||||
|
export const WHATSAPP_NUMBER = "251930099850";
|
||||||
|
export const CHATWOOT_BASE = "https://chat.yaltopia.com";
|
||||||
84
locales/am/common.json
Normal file
84
locales/am/common.json
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "ጊሸን ፋርማሲ",
|
||||||
|
"tagline": "ጤናዎ ወደ በርዎ ይደርሳል",
|
||||||
|
"continue": "ቀጥል",
|
||||||
|
"getStarted": "ጀምር",
|
||||||
|
"save": "አስቀምጥ",
|
||||||
|
"cancel": "ሰርዝ",
|
||||||
|
"seeAll": "ሁሉንም ይመልከቱ",
|
||||||
|
"search": "እዚህ ይፈልጉ...",
|
||||||
|
"scan": "ቅኝት",
|
||||||
|
"home": "መነሻ",
|
||||||
|
"favorites": "ተወዳጆች",
|
||||||
|
"cart": "ጋሪ",
|
||||||
|
"profile": "መገለጫ",
|
||||||
|
"shop": "ሱቅ",
|
||||||
|
"branches": "ቅርንጫፎች",
|
||||||
|
"orders": "ትዕዛዞች",
|
||||||
|
"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": "የሐኪም ትዕዛዞች",
|
||||||
|
"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": "ጤናማ ሕይወት"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
locales/en/common.json
Normal file
84
locales/en/common.json
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"switchProfile": "Switch profile",
|
||||||
|
"joinOrg": "Join organisation",
|
||||||
|
"payAtBranch": "Pay at branch",
|
||||||
|
"chapa": "Chapa",
|
||||||
|
"telebirr": "Telebirr",
|
||||||
|
"mpesa": "M-Pesa",
|
||||||
|
"emptyCart": "Your cart is empty",
|
||||||
|
"emptyFavorites": "No favorites yet",
|
||||||
|
"refillDue": "Refill due soon",
|
||||||
|
"contactNotified": "Emergency contact notified",
|
||||||
|
"guest": "Continue as guest",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"otpHint": "Enter the code sent to your phone",
|
||||||
|
"sendOtp": "Send code",
|
||||||
|
"verify": "Verify",
|
||||||
|
"enableBiometric": "Enable biometric unlock",
|
||||||
|
"nearestBranch": "Nearest branches",
|
||||||
|
"useLocation": "Use my location",
|
||||||
|
"addresses": "Addresses",
|
||||||
|
"rewards": "Rewards",
|
||||||
|
"referral": "Referral code",
|
||||||
|
"inviteFamily": "Invite family",
|
||||||
|
"manageSubs": "Manage subscriptions",
|
||||||
|
"scanRx": "Scan prescription",
|
||||||
|
"calendarReminders": "Calendar reminders",
|
||||||
|
"graceMinutes": "Grace minutes",
|
||||||
|
"escalation": "Notify emergency contact if dose not confirmed"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"medicines": "Medicine",
|
||||||
|
"vitamins": "Vitamin",
|
||||||
|
"personalCare": "Health",
|
||||||
|
"diabetic": "Diabetic",
|
||||||
|
"devices": "Devices",
|
||||||
|
"baby": "Baby",
|
||||||
|
"firstAid": "First Aid",
|
||||||
|
"wellness": "Wellness"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
metro.config.js
Normal file
6
metro.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const { withNativeWind } = require("nativewind/metro");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: "./app/global.css" });
|
||||||
1
nativewind-env.d.ts
vendored
Normal file
1
nativewind-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="nativewind/types" />
|
||||||
10409
package-lock.json
generated
Normal file
10409
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"name": "gishen-mob",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/plus-jakarta-sans": "^0.4.2",
|
||||||
|
"@expo/vector-icons": "^14.1.0",
|
||||||
|
"@react-native-async-storage/async-storage": "2.1.2",
|
||||||
|
"expo": "~53.0.0",
|
||||||
|
"expo-camera": "~16.1.11",
|
||||||
|
"expo-constants": "~17.1.7",
|
||||||
|
"expo-font": "~13.3.1",
|
||||||
|
"expo-image-picker": "~16.1.4",
|
||||||
|
"expo-linking": "~7.1.7",
|
||||||
|
"expo-local-authentication": "~16.0.5",
|
||||||
|
"expo-location": "~18.1.6",
|
||||||
|
"expo-notifications": "~0.31.4",
|
||||||
|
"expo-router": "~5.1.6",
|
||||||
|
"expo-splash-screen": "~0.30.10",
|
||||||
|
"expo-status-bar": "~2.2.3",
|
||||||
|
"i18next": "^25.7.2",
|
||||||
|
"lucide-react-native": "^0.511.0",
|
||||||
|
"nativewind": "4.1.23",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"react-i18next": "^16.5.0",
|
||||||
|
"react-native": "0.79.6",
|
||||||
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
|
"react-native-maps": "1.20.1",
|
||||||
|
"react-native-reanimated": "~3.17.4",
|
||||||
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
"react-native-screens": "~4.11.1",
|
||||||
|
"react-native-svg": "15.11.2",
|
||||||
|
"react-native-web": "^0.20.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.26.0",
|
||||||
|
"@types/react": "~19.0.10",
|
||||||
|
"typescript": "~5.8.3"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
40
tailwind.config.js
Normal file
40
tailwind.config.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: "#A8C73A",
|
||||||
|
"brand-deep": "#93B22E",
|
||||||
|
"brand-text": "#748E22",
|
||||||
|
"brand-soft": "#ECF4D8",
|
||||||
|
"brand-wash": "#F6FAEC",
|
||||||
|
ink: "#101410",
|
||||||
|
"ink-secondary": "#5B6157",
|
||||||
|
muted: "#5B6157",
|
||||||
|
subtle: "#8A9188",
|
||||||
|
page: "#F7F8F5",
|
||||||
|
surface: "#FFFFFF",
|
||||||
|
"surface-alt": "#F1F3EC",
|
||||||
|
line: "#E9ECE2",
|
||||||
|
danger: "#D64545",
|
||||||
|
warn: "#E0870F",
|
||||||
|
accent: "#E0870F",
|
||||||
|
info: "#2F6FED",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
card: "20px",
|
||||||
|
pill: "999px",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Jakarta_400Regular"],
|
||||||
|
medium: ["Jakarta_500Medium"],
|
||||||
|
semibold: ["Jakarta_600SemiBold"],
|
||||||
|
bold: ["Jakarta_700Bold"],
|
||||||
|
extrabold: ["Jakarta_800ExtraBold"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user