Waseel: driver app, dispatch, POI suggestions, map fixes

This commit is contained in:
Krikorios
2026-08-25 02:57:49 +03:00
parent 899ca93cd5
commit 1d84003e0a
50 changed files with 3625 additions and 625 deletions
+3 -3
View File
@@ -11,7 +11,7 @@ const getBgVariantStyle = (variant: ButtonProps["bgVariant"]) => {
case "success":
return "bg-emerald-500";
case "outline":
return "bg-transparent-500 border-neutral-300 border-[0.5px]";
return "bg-transparent border-neutral-300 dark:border-neutral-700 border-[0.5px]";
default:
return "bg-[#0286ff]";
}
@@ -20,7 +20,7 @@ const getBgVariantStyle = (variant: ButtonProps["bgVariant"]) => {
const getTextVariantStyle = (variant: ButtonProps["textVariant"]) => {
switch (variant) {
case "primary":
return "text-black";
return "text-black dark:text-white";
case "secondary":
return "text-gray-100";
case "danger":
@@ -44,7 +44,7 @@ export const CustomButton = ({
}: ButtonProps) => (
<TouchableOpacity
onPress={onPress}
className={`w-full rounded-full p-3 flex flex-row justify-center items-center shadow-md shadow-neutral-400/70 ${getBgVariantStyle(bgVariant)} ${className}`}
className={`w-full rounded-full p-3 flex flex-row justify-center items-center shadow-md shadow-neutral-400/70 dark:shadow-neutral-950/70 ${getBgVariantStyle(bgVariant)} ${className}`}
{...props}
>
{IconLeft && <IconLeft />}
+16 -13
View File
@@ -1,6 +1,7 @@
import { Image, Text, TouchableOpacity, View } from "react-native";
import { icons } from "@/constants";
import { tr } from "@/lib/i18n";
import { formatTime } from "@/lib/utils";
import { DriverCardProps } from "@/types/type";
@@ -13,56 +14,58 @@ export const DriverCard = ({
<TouchableOpacity
onPress={setSelected}
className={`${
selected === item.id ? "bg-general-600" : "bg-white"
selected === item.id
? "bg-general-600 dark:bg-primary-500/20"
: "bg-white dark:bg-neutral-900"
} flex flex-row items-center justify-between py-5 px-3 rounded-xl`}
>
<Image
source={{ uri: item.profile_image_url }}
alt="Driver Avatar"
alt={tr("components.driverCard.avatarAlt")}
className="w-14 h-14 rounded-full"
/>
<View className="flex-1 flex flex-col items-start justify-center mx-3">
<View className="flex flex-row items-center justify-start mb-1">
<Text className="text-lg font-JakartaRegular">
<Text className="text-lg font-JakartaRegular text-black dark:text-white">
{item.title ?? `${item.first_name} ${item.last_name}`}
</Text>
<View className="flex flex-row items-center space-x-1 ml-2">
<Image source={icons.star} alt="Star" className="w-3.5 h-3.5" />
<Text className="text-sm font-JakartaRegular">{item.rating}</Text>
<Image source={icons.star} alt={tr("components.driverCard.starAlt")} className="w-3.5 h-3.5" />
<Text className="text-sm font-JakartaRegular text-black dark:text-white">{item.rating}</Text>
</View>
</View>
<View className="flex flex-row items-center justify-start">
<View className="flex flex-row items-center">
<Image source={icons.dollar} alt="Dollar" className="w-4 h-4" />
<Text className="text-sm font-JakartaRegular ml-1">
<Image source={icons.dollar} alt={tr("components.driverCard.dollarAlt")} className="w-4 h-4" />
<Text className="text-sm font-JakartaRegular ml-1 text-black dark:text-white">
${item.price}
</Text>
</View>
<Text className="text-sm font-JakartaRegular text-general-800 mx-1">
<Text className="text-sm font-JakartaRegular text-general-800 dark:text-neutral-400 mx-1">
|
</Text>
<Text className="text-sm font-JakartaRegular text-general-800">
<Text className="text-sm font-JakartaRegular text-general-800 dark:text-neutral-400">
{formatTime(parseInt(`${item.time}`))}
</Text>
<Text className="text-sm font-JakartaRegular text-general-800 mx-1">
<Text className="text-sm font-JakartaRegular text-general-800 dark:text-neutral-400 mx-1">
|
</Text>
<Text className="text-sm font-JakartaRegular text-general-800">
{item.car_seats} seats
<Text className="text-sm font-JakartaRegular text-general-800 dark:text-neutral-400">
{tr("components.driverCard.seats", {}, item.car_seats)}
</Text>
</View>
</View>
<Image
source={{ uri: item.car_image_url }}
alt="Car"
alt={tr("components.driverCard.carAlt")}
className="h-14 w-14"
resizeMode="contain"
/>
+17 -10
View File
@@ -9,6 +9,8 @@ import {
} from "react-native";
import { icons } from "@/constants";
import { useT } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
import type { GoogleInputProps } from "@/types/type";
const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!;
@@ -69,10 +71,15 @@ export const GoogleTextInput = ({
textInputBackgroundColor,
handlePress,
}: GoogleInputProps) => {
const t = useT();
const { isDark } = useTheme();
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputBg = textInputBackgroundColor || (isDark ? "#1a1a1a" : "white");
const inputShadow = isDark ? "#000000" : "#d4d4d4";
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -118,14 +125,14 @@ export const GoogleTextInput = ({
<View
className="flex flex-row items-center rounded-full px-4 mt-1"
style={{
backgroundColor: textInputBackgroundColor || "white",
shadowColor: "#d4d4d4",
backgroundColor: inputBg,
shadowColor: inputShadow,
}}
>
<View className="justify-center items-center w-6 h-6">
<Image
source={icon ? icon : icons.search}
alt="Search"
alt={t("components.googleTextInput.searchAlt")}
className="w-6 h-6"
resizeMode="contain"
/>
@@ -134,9 +141,9 @@ export const GoogleTextInput = ({
<TextInput
value={query}
onChangeText={setQuery}
placeholder={initialLocation ?? "Where do you want to go?"}
placeholderTextColor="gray"
className="flex-1 p-3 text-base font-JakartaSemiBold"
placeholder={initialLocation ?? t("components.googleTextInput.placeholder")}
placeholderTextColor="#a3a3a3"
className="flex-1 p-3 text-base font-JakartaSemiBold text-black dark:text-white"
/>
</View>
@@ -144,8 +151,8 @@ export const GoogleTextInput = ({
<View
className="rounded-xl mt-1"
style={{
backgroundColor: textInputBackgroundColor || "white",
shadowColor: "#d4d4d4",
backgroundColor: inputBg,
shadowColor: inputShadow,
}}
>
<FlatList
@@ -155,9 +162,9 @@ export const GoogleTextInput = ({
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => onSelect(item)}
className="p-3 border-b border-general-700"
className="p-3 border-b border-general-700 dark:border-neutral-700"
>
<Text className="text-base font-JakartaRegular">
<Text className="text-base font-JakartaRegular text-black dark:text-white">
{item.text}
</Text>
</TouchableOpacity>
+8 -4
View File
@@ -9,6 +9,7 @@ import {
View,
} from "react-native";
import { tr } from "@/lib/i18n";
import type { InputFieldProps } from "@/types/type";
export const InputField = ({
@@ -25,26 +26,29 @@ export const InputField = ({
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View className="my-2 w-full">
<Text className={`text-lg font-JakartaSemiBold mb-3 ${labelStyles}`}>
<Text
className={`text-lg font-JakartaSemiBold mb-3 text-black dark:text-white ${labelStyles}`}
>
{label}
</Text>
<View
className={`flex flex-row justify-start items-center relative bg-neutral-100 rounded-full border border-neutral-100 focus:border-primary-500 ${containerStyles}`}
className={`flex flex-row justify-start items-center relative bg-neutral-100 dark:bg-neutral-800 rounded-full border border-neutral-100 dark:border-neutral-800 focus:border-primary-500 ${containerStyles}`}
>
{icon && (
<Image
source={icon}
alt={`${label} icon`}
alt={tr("components.inputField.labelIconAlt", { label })}
className={`h-6 w-6 ml-4 mt-1 ${iconStyles}`}
/>
)}
<TextInput
className={`rounded-full p-4 font-JakartaSemiBold text-[15px] flex-1 text-left ${inputStyles}`}
className={`rounded-full p-4 font-JakartaSemiBold text-[15px] flex-1 text-left text-black dark:text-white ${inputStyles}`}
secureTextEntry={secureTextEntry}
autoCapitalize="none"
autoComplete="off"
placeholderTextColor="#a3a3a3"
selectionColor="#0286ff"
{...props}
/>
+17 -16
View File
@@ -1,22 +1,23 @@
import { Linking, Text, TouchableOpacity, View } from "react-native";
import { tr } from "@/lib/i18n";
import type { LocationStatus } from "@/lib/use-user-location";
const COPY: Record<string, { title: string; body: string; action: string }> = {
const COPY: Record<string, { titleKey: string; bodyKey: string; actionKey: string }> = {
denied: {
title: "Location access is off",
body: "Waseel needs your location to show nearby drivers and set your pickup point.",
action: "Open Settings",
titleKey: "components.locationNotice.denied.title",
bodyKey: "components.locationNotice.denied.body",
actionKey: "components.locationNotice.denied.action",
},
"services-off": {
title: "Location services are off",
body: "Turn on location on your device, then try again.",
action: "Try Again",
titleKey: "components.locationNotice.servicesOff.title",
bodyKey: "components.locationNotice.servicesOff.body",
actionKey: "components.locationNotice.servicesOff.action",
},
unavailable: {
title: "Couldn't find your location",
body: "Move somewhere with a clearer signal, or set your pickup point manually.",
action: "Try Again",
titleKey: "components.locationNotice.unavailable.title",
bodyKey: "components.locationNotice.unavailable.body",
actionKey: "components.locationNotice.unavailable.action",
},
};
@@ -34,12 +35,12 @@ export const LocationNotice = ({
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-base font-JakartaBold text-black text-center">
{copy.title}
<Text className="text-base font-JakartaBold text-black dark:text-white text-center">
{tr(copy.titleKey)}
</Text>
<Text className="text-sm font-Jakarta text-general-200 text-center mt-2">
{copy.body}
<Text className="text-sm font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-2">
{tr(copy.bodyKey)}
</Text>
<TouchableOpacity
@@ -50,9 +51,9 @@ export const LocationNotice = ({
className="mt-5 rounded-full bg-primary-500 px-6 py-3"
>
<Text className="text-white font-JakartaBold text-sm">
{copy.action}
{tr(copy.actionKey)}
</Text>
</TouchableOpacity>
</View>
);
};
};
+26 -4
View File
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Platform, StyleSheet } from "react-native";
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
import MapViewDirections from "react-native-maps-directions";
import { icons } from "@/constants";
import { useFetch } from "@/lib/fetch";
import { tr } from "@/lib/i18n";
import {
calculateDriverTimes,
calculateRegion,
generateMarkersFromData,
} from "@/lib/map";
import { useTheme } from "@/lib/theme";
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
import type { Driver, MarkerData } from "@/types/type";
@@ -48,6 +50,7 @@ export const Map = () => {
} = useLocationStore();
const { service } = useServiceStore();
const { selectedDriver, setDrivers } = useDriverStore();
const { isDark } = useTheme();
// Online drivers of the selected service near the rider. Falls back to a
// Beirut center when the rider's position isn't resolved yet so the map
@@ -59,6 +62,7 @@ export const Map = () => {
);
const [markers, setMarkers] = useState<MarkerData[]>([]);
const mapRef = useRef<MapView>(null);
const region = calculateRegion({
userLatitude,
@@ -67,6 +71,23 @@ export const Map = () => {
destinationLongitude,
});
// `initialRegion` is read once, at mount. The map mounts before the location
// fix arrives, so it would sit on the Beirut fallback forever and never zoom
// out to fit a destination the rider picks later. Animate on every real
// change instead. Keyed on the coordinates so the repeated setUserLocation
// from reverse geocoding (same coords, new address) doesn't yank the camera
// back while the rider is panning.
const regionKey = `${region.latitude},${region.longitude},${region.latitudeDelta},${region.longitudeDelta}`;
const lastRegionKey = useRef(regionKey);
useEffect(() => {
if (lastRegionKey.current === regionKey) return;
lastRegionKey.current = regionKey;
mapRef.current?.animateToRegion(region, 500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [regionKey]);
useEffect(() => {
if (Array.isArray(drivers)) {
if (!userLatitude || !userLongitude) return;
@@ -113,15 +134,16 @@ export const Map = () => {
return (
<MapView
ref={mapRef}
provider={PROVIDER_DEFAULT}
style={styles.map}
tintColor="black"
tintColor={isDark ? "white" : "black"}
mapType={MAP_TYPE}
customMapStyle={MUTED_POI_STYLE}
showsPointsOfInterest={false}
initialRegion={region}
showsUserLocation
userInterfaceStyle="light"
userInterfaceStyle={isDark ? "dark" : "light"}
>
{markers.map((marker) => (
<Marker
@@ -148,7 +170,7 @@ export const Map = () => {
latitude: destinationLatitude,
longitude: destinationLongitude,
}}
title="Destination"
title={tr("components.map.destination")}
image={icons.pin}
/>
+5 -2
View File
@@ -1,13 +1,16 @@
import { Text, View } from "react-native";
import { useT } from "@/lib/i18n";
// react-native-maps does not support web. This stub keeps the web bundle
// working for local testing; use a native build for real map functionality.
export const Map = () => {
const t = useT();
return (
<View className="w-full h-full rounded-2xl bg-general-100 flex items-center justify-center">
<Text className="text-general-200 text-center font-JakartaMedium">
Map is not available on web.{"\n"}Run on Android/iOS for the full
experience.
{t("components.map.webUnavailable")}
</Text>
</View>
);
+30 -11
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
import { POI_CATEGORIES, searchNearby } from "@/lib/places";
import { useT } from "@/lib/i18n";
import { useLocationStore } from "@/store";
import type { NearbyPlace } from "@/types/type";
@@ -19,6 +20,7 @@ type ChipState =
export const NearbySuggestions = () => {
const { userLatitude, userLongitude, setDestinationLocation } =
useLocationStore();
const t = useT();
const [chips, setChips] = useState<Record<string, ChipState>>({});
useEffect(() => {
@@ -57,8 +59,8 @@ export const NearbySuggestions = () => {
return (
<View>
<Text className="text-base font-JakartaSemiBold mb-3">
Nearby suggestions
<Text className="text-base font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("pois.nearbyTitle")}
</Text>
<ScrollView
@@ -79,7 +81,7 @@ export const NearbySuggestions = () => {
className={`flex-row items-center rounded-2xl border px-3 py-2.5 ${
ready
? "border-primary-500 bg-primary-500/10"
: "border-neutral-200 bg-neutral-100"
: "border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
}`}
style={{ minWidth: 150 }}
>
@@ -96,19 +98,36 @@ export const NearbySuggestions = () => {
}`}
numberOfLines={1}
>
{category.label}
{t(category.labelKey)}
</Text>
<Text
className="text-[11px] text-general-200"
className="text-[11px] text-general-200 dark:text-neutral-400"
numberOfLines={1}
>
{!state || state.status === "loading"
? "searching"
? t("pois.searching")
: state.status === "empty"
? "none nearby"
: state.place.distanceMeters != null
? `${Math.round(state.place.distanceMeters / 100) / 10} km away`
: state.place.name}
? t("pois.noneNearby")
: state.place.routeDistanceMeters != null
? t("pois.routeAway", {
km:
Math.round(
state.place.routeDistanceMeters / 100,
) / 10,
min: Math.max(
1,
Math.round(
(state.place.routeDurationSeconds ?? 0) / 60,
),
),
})
: state.place.distanceMeters != null
? t("pois.kmAway", {
km:
Math.round(state.place.distanceMeters / 100) /
10,
})
: state.place.name}
</Text>
</View>
</TouchableOpacity>
@@ -117,4 +136,4 @@ export const NearbySuggestions = () => {
</ScrollView>
</View>
);
};
};
+10 -8
View File
@@ -5,6 +5,7 @@ import { Image, Text, View, Alert } from "react-native";
import { icons } from "@/constants";
import { googleAuth } from "@/lib/auth";
import { useT } from "@/lib/i18n";
import { useSession } from "@/lib/session";
import { CustomButton } from "./custom-button";
@@ -39,6 +40,7 @@ function GoogleOAuth({
androidClientId?: string;
}) {
const { setSession } = useSession();
const t = useT();
const [request, response, promptAsync] = Google.useIdTokenAuthRequest({
clientId,
@@ -52,7 +54,7 @@ function GoogleOAuth({
const idToken = response.params?.id_token;
if (!idToken) {
Alert.alert("Google sign-in failed", "No token returned. Try again.");
Alert.alert(t("components.oauth.alertFailTitle"), t("components.oauth.alertFailNoToken"));
return;
}
@@ -63,12 +65,12 @@ function GoogleOAuth({
} catch (err: any) {
console.error("OAuth error", err);
Alert.alert(
"Google sign-in failed",
err?.message || "Please try again.",
t("components.oauth.alertFailTitle"),
err?.message || t("components.oauth.alertFailFallback"),
);
}
})();
}, [response, setSession]);
}, [response, setSession, t]);
const handleGoogleOAuth = useCallback(() => {
void promptAsync();
@@ -77,11 +79,11 @@ function GoogleOAuth({
return (
<View>
<View className="flex flex-row justify-center items-center mt-4 gap-x-3">
<View className="flex-1 h-px bg-general-100" />
<View className="flex-1 h-px bg-general-100 dark:bg-neutral-700" />
<Text className="text-lg">Or</Text>
<Text className="text-lg text-black dark:text-white">{t("components.oauth.or")}</Text>
<View className="flex-1 h-px bg-general-100" />
<View className="flex-1 h-px bg-general-100 dark:bg-neutral-700" />
</View>
<CustomButton
@@ -90,7 +92,7 @@ function GoogleOAuth({
iconLeft={() => (
<Image
source={icons.google}
alt="Google logo"
alt={t("components.oauth.googleLogoAlt")}
resizeMode="contain"
className="h-5 w-5 mx-2"
/>
+4 -3
View File
@@ -4,6 +4,7 @@ import { AppState, Text, TouchableOpacity, View } from "react-native";
import { InputField } from "@/components/input-field";
import { icons } from "@/constants";
import { tr } from "@/lib/i18n";
// `\b` won't match between two digits, so a longer run like an order number
// never yields a false positive.
@@ -33,7 +34,7 @@ type OtpFieldProps = {
* 3. Typing it.
*/
export const OtpField = ({
label = "Code",
label = tr("components.otp.code"),
value,
onChange,
onComplete,
@@ -105,7 +106,7 @@ export const OtpField = ({
<InputField
label={label}
icon={icons.lock}
placeholder="123456"
placeholder={tr("components.otp.codePlaceholder")}
value={value}
onChangeText={handleChange}
keyboardType="number-pad"
@@ -125,7 +126,7 @@ export const OtpField = ({
className="self-start mt-2 rounded-full bg-primary-500/10 px-4 py-2"
>
<Text className="text-primary-500 font-JakartaSemiBold text-sm">
Paste code from Gmail
{tr("components.otp.pasteCode")}
</Text>
</TouchableOpacity>
) : null}
+41 -34
View File
@@ -6,6 +6,7 @@ import ReactNativeModal from "react-native-modal";
import { images } from "@/constants";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { formatLBP } from "@/lib/pricing";
import { useLocationStore } from "@/store";
import type { PaymentProps } from "@/types/type";
@@ -32,6 +33,7 @@ export const Payment = ({
const [method, setMethod] = useState<PaymentMethod>("cash");
const [success, setSuccess] = useState(false);
const [processing, setProcessing] = useState(false);
const t = useT();
const fareCents = Math.round(parseFloat(amount) * 100); // in cents
@@ -66,8 +68,8 @@ export const Payment = ({
} catch (err) {
console.log("[PAYMENT]: ", err);
Alert.alert(
"Error",
"Something went wrong while booking your ride. Please try again.",
t("components.payment.alertErrorTitle"),
t("components.payment.alertErrorBody"),
);
} finally {
setProcessing(false);
@@ -138,8 +140,8 @@ export const Payment = ({
setSuccess(true);
} else {
Alert.alert(
"Payment not completed",
"Your payment was cancelled or could not be verified. Please try again.",
t("components.payment.alertPaymentNotCompletedTitle"),
t("components.payment.alertPaymentNotCompletedBody"),
);
}
} catch (err) {
@@ -149,13 +151,13 @@ export const Payment = ({
// branch every cancellation lands in the generic "something went wrong".
if (err instanceof ApiError && err.status === 400) {
Alert.alert(
"Payment not completed",
"Your payment was cancelled or could not be verified. Please try again.",
t("components.payment.alertPaymentNotCompletedTitle"),
t("components.payment.alertPaymentNotCompletedBody"),
);
} else {
Alert.alert(
"Error",
"Something went wrong while processing your payment. Please try again.",
t("components.payment.alertProcessingTitle"),
t("components.payment.alertProcessingBody"),
);
}
} finally {
@@ -166,15 +168,19 @@ export const Payment = ({
const confirm = () =>
method === "cash"
? payWithCash()
: Alert.alert("Pay by card", `Your card will be charged $${amount}.`, [
{ text: "Cancel", style: "cancel" },
{ text: "Continue", onPress: () => void payWithCard() },
]);
: Alert.alert(
t("components.payment.alertPayCardTitle"),
t("components.payment.alertPayCardBody", { amount }),
[
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.continue"), onPress: () => void payWithCard() },
],
);
return (
<>
<Text className="text-lg font-JakartaSemiBold mt-4 mb-2">
Payment Method
<Text className="text-lg font-JakartaSemiBold mt-4 mb-2 text-black dark:text-white">
{t("components.payment.paymentMethod")}
</Text>
<View className="flex flex-row gap-x-3">
@@ -182,16 +188,16 @@ export const Payment = ({
onPress={() => setMethod("cash")}
className={`flex-1 items-center py-3 rounded-xl border ${
method === "cash"
? "bg-general-600 border-primary-500"
: "bg-white border-general-700"
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
}`}
>
<Text
className={`font-JakartaMedium ${
method === "cash" ? "text-white" : "text-black"
method === "cash" ? "text-white" : "text-black dark:text-white"
}`}
>
💵 Cash
{t("components.payment.cash")}
</Text>
</TouchableOpacity>
@@ -199,16 +205,16 @@ export const Payment = ({
onPress={() => setMethod("card")}
className={`flex-1 items-center py-3 rounded-xl border ${
method === "card"
? "bg-general-600 border-primary-500"
: "bg-white border-general-700"
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
}`}
>
<Text
className={`font-JakartaMedium ${
method === "card" ? "text-white" : "text-black"
method === "card" ? "text-white" : "text-black dark:text-white"
}`}
>
💳 Card
{t("components.payment.card")}
</Text>
</TouchableOpacity>
</View>
@@ -216,10 +222,10 @@ export const Payment = ({
<CustomButton
title={
processing
? "Processing..."
? t("components.payment.processing")
: method === "cash"
? "Book ride · Pay cash to driver"
: "Confirm & Pay by Card"
? t("components.payment.bookCash")
: t("components.payment.confirmCard")
}
className="my-2 mt-4"
onPress={confirm}
@@ -230,23 +236,24 @@ export const Payment = ({
isVisible={success}
onBackdropPress={() => setSuccess(false)}
>
<View className="flex flex-col items-center justify-center bg-white p-7 rounded-2xl">
<Image source={images.check} alt="Check" className="w-28 h-28 mt-5" />
<View className="flex flex-col items-center justify-center bg-white dark:bg-neutral-900 p-7 rounded-2xl">
<Image source={images.check} alt={t("components.payment.checkAlt")} className="w-28 h-28 mt-5" />
<Text className="text-2xl text-center font-JakartaBold mt-5">
Ride Booked!
<Text className="text-2xl text-center font-JakartaBold mt-5 text-black dark:text-white">
{t("components.payment.rideBooked")}
</Text>
<Text className="text-base text-general-200 text-JakartaMedium text-center mt-3">
Thank you for your booking.{"\n"} Your reservation has been placed.
{"\n"}
<Text className="text-base text-general-200 dark:text-neutral-400 text-JakartaMedium text-center mt-3">
{t("components.payment.successBody")}
{method === "cash"
? `Please have ${formatLBP(parseFloat(amount))} ready.`
? t("components.payment.cashInstruction", {
lbp: formatLBP(parseFloat(amount)),
})
: null}
</Text>
<CustomButton
title="Back Home"
title={t("components.payment.backHome")}
onPress={() => {
setSuccess(false);
router.push("/(root)/(tabs)/home");
+26 -25
View File
@@ -1,6 +1,7 @@
import { Image, Text, View } from "react-native";
import { icons } from "@/constants";
import { tr } from "@/lib/i18n";
import { formatDate, formatTime } from "@/lib/utils";
import type { Ride } from "@/types/type";
@@ -17,22 +18,22 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
} = ride;
return (
<View className="flex flex-row items-center justify-center bg-white rounded-lg shadow-sm shadow-neutral-300 mb-3">
<View className="flex flex-row items-center justify-center bg-white dark:bg-neutral-900 rounded-lg shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40 mb-3">
<View className="flex flex-col items-center justify-center p-3">
<View className="flex flex-row items-center justify-between">
<Image
source={{
uri: `https://maps.geoapify.com/v1/staticmap?style=osm-bright&width=600&height=400&center=lonlat:${destination_longitude},${destination_latitude}&zoom=14&apiKey=${process.env.EXPO_PUBLIC_GEOAPIFY_API_KEY}`,
}}
alt="Map"
alt={tr("components.rideCard.mapAlt")}
className="w-[80px] h-[90px] rounded-lg"
/>
<View className="flex flex-col mx-5 gap-y-5 flex-1">
<View className="flex flex-row items-center gap-x-2">
<Image source={icons.to} alt="Origin" className="w-5 h-5" />
<Image source={icons.to} alt={tr("components.rideCard.originAlt")} className="w-5 h-5" />
<Text className="font-JakartaMedium" numberOfLines={1}>
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
{origin_address}
</Text>
</View>
@@ -40,71 +41,71 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
<View className="flex flex-row items-center gap-x-2">
<Image
source={icons.point}
alt="Destination"
alt={tr("components.rideCard.destinationAlt")}
className="w-5 h-5"
/>
<Text className="font-JakartaMedium" numberOfLines={1}>
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
{destination_address}
</Text>
</View>
</View>
</View>
<View className="flex flex-col w-full mt-5 bg-general-500 rounded-lg p-3 items-start justify-center">
<View className="flex flex-col w-full mt-5 bg-general-500 dark:bg-neutral-800 rounded-lg p-3 items-start justify-center">
<View className="flex flex-row items-center w-full justify-between mb-5">
<Text className="font-JakartaMedium text-gray-500 text-xs">
Date &amp; Time
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{tr("components.rideCard.dateTime")}
</Text>
<Text className="font-JakartaMedium text-gray-500 text-xs">
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{formatDate(created_at)}, {formatTime(ride_time)}
</Text>
</View>
<View className="flex flex-row items-center w-full justify-between mb-5">
<Text className="font-JakartaMedium text-gray-500 text-xs">
Driver
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{tr("components.rideCard.driver")}
</Text>
<Text className="font-JakartaMedium text-gray-500 text-xs">
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{driver.first_name} {driver.last_name}
</Text>
</View>
<View className="flex flex-row items-center w-full justify-between mb-5">
<Text className="font-JakartaMedium text-gray-500 text-xs">
Car Seats
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{tr("components.rideCard.carSeats")}
</Text>
<Text className="font-JakartaMedium text-gray-500 text-xs">
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{driver.car_seats}
</Text>
</View>
<View className="flex flex-row items-center w-full justify-between mb-5">
<Text className="font-JakartaMedium text-gray-500 text-xs">
Fare
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{tr("components.rideCard.fare")}
</Text>
<Text className="font-JakartaMedium text-gray-500 text-xs">
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
${(ride.fare_price / 100).toFixed(2)}
</Text>
</View>
<View className="flex flex-row items-center w-full justify-between mb-5">
<Text className="font-JakartaMedium text-gray-500 text-xs">
Payment Status
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{tr("components.rideCard.paymentStatus")}
</Text>
<Text
className={`font-JakartaMedium capitalize text-xs ${payment_status === "paid" ? "text-emerald-500" : "text-gray-500"}`}
className={`font-JakartaMedium capitalize text-xs ${payment_status === "paid" ? "text-emerald-500 dark:text-emerald-400" : "text-gray-500 dark:text-neutral-400"}`}
>
{payment_status === "cash"
? "Cash · Pay to driver"
? tr("components.rideCard.paymentCash")
: payment_status === "paid"
? "Paid by card"
: payment_status}
? tr("components.rideCard.paymentPaid")
: tr("components.rideCard.paymentOther", { status: payment_status })}
</Text>
</View>
</View>
+15 -6
View File
@@ -5,6 +5,8 @@ import { Image, Text, TouchableOpacity, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { icons } from "@/constants";
import { tr } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
import { Map } from "./map";
@@ -14,29 +16,32 @@ type RideLayoutProps = {
};
export const RideLayout = ({
title = "Go Back",
title,
snapPoints,
children,
}: PropsWithChildren<RideLayoutProps>) => {
const bottomSheetRef = useRef<BottomSheet>(null);
const { isDark } = useTheme();
return (
<GestureHandlerRootView>
<View className="flex-1 bg-white">
<View className="flex flex-col h-screen bg-blue-500">
<View className="flex-1 bg-white dark:bg-neutral-950">
<View className="flex flex-col h-screen bg-blue-500 dark:bg-neutral-900">
<View className="flex flex-row absolute z-10 top-16 items-center justify-start px-5">
<TouchableOpacity onPress={() => router.back()}>
<View className="w-10 h-10 bg-white rounded-full items-center justify-center">
<View className="w-10 h-10 bg-white dark:bg-neutral-900 rounded-full items-center justify-center">
<Image
source={icons.backArrow}
alt="Back arrow"
alt={tr("components.rideLayout.backArrowAlt")}
resizeMode="contain"
className="w-6 h-6"
/>
</View>
</TouchableOpacity>
<Text className="text-xl font-JakartaSemiBold ml-5">{title}</Text>
<Text className="text-xl font-JakartaSemiBold ml-5 text-black dark:text-white">
{title ?? tr("components.rideLayout.goBack")}
</Text>
</View>
<Map />
@@ -47,6 +52,10 @@ export const RideLayout = ({
ref={bottomSheetRef}
snapPoints={snapPoints ?? ["40%", "85%"]}
index={0}
backgroundStyle={{ backgroundColor: isDark ? "#0a0a0a" : "#ffffff" }}
handleIndicatorStyle={{
backgroundColor: isDark ? "#525252" : "#d4d4d4",
}}
>
<BottomSheetView
style={{
+7 -5
View File
@@ -2,6 +2,7 @@ import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import { SERVICES } from "@/constants/services";
import { useT } from "@/lib/i18n";
import { useServiceStore } from "@/store";
/**
@@ -14,6 +15,7 @@ import { useServiceStore } from "@/store";
*/
export const ServiceSelector = () => {
const { service, setService } = useServiceStore();
const t = useT();
const selected = SERVICES.find((item) => item.id === service);
@@ -33,7 +35,7 @@ export const ServiceSelector = () => {
className={`flex-1 items-center rounded-2xl border py-3 ${
active
? "border-primary-500 bg-primary-500/10"
: "border-neutral-100 bg-neutral-100"
: "border-neutral-100 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
}`}
>
<MaterialCommunityIcons
@@ -45,10 +47,10 @@ export const ServiceSelector = () => {
<Text
numberOfLines={1}
className={`mt-1.5 text-xs font-JakartaBold ${
active ? "text-primary-500" : "text-black"
active ? "text-primary-500" : "text-black dark:text-white"
}`}
>
{item.label}
{t(item.labelKey)}
</Text>
</TouchableOpacity>
);
@@ -56,8 +58,8 @@ export const ServiceSelector = () => {
</View>
{selected ? (
<Text className="mt-3 text-sm font-Jakarta text-general-200">
{selected.tagline}
<Text className="mt-3 text-sm font-Jakarta text-general-200 dark:text-neutral-400">
{t(selected.taglineKey)}
</Text>
) : null}
</View>
+109
View File
@@ -0,0 +1,109 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Switch, Text, TouchableOpacity, View } from "react-native";
import { useTheme } from "@/lib/theme";
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
type RightKind = "chevron" | "switch" | "value" | "none";
type SettingsRowProps = {
icon: IconName;
title: string;
subtitle?: string;
right?: RightKind;
/** For `right: "value"` — the string shown on the trailing side. */
value?: string;
/** For `right: "switch"`. */
switchValue?: boolean;
onSwitchChange?: (value: boolean) => void;
onPress?: () => void;
/** Red accent — used for the emergency-call row. */
danger?: boolean;
};
/** A single row in the Settings screen. Born dark-aware. */
export const SettingsRow = ({
icon,
title,
subtitle,
right = "none",
value,
switchValue,
onSwitchChange,
onPress,
danger = false,
}: SettingsRowProps) => {
const { isDark } = useTheme();
const interactive = right === "chevron" || right === "value";
const content = (
<View className="flex-row items-center py-3.5">
<View
className={`w-10 h-10 rounded-full items-center justify-center mr-3.5 ${
danger
? "bg-rose-500/15"
: "bg-neutral-100 dark:bg-neutral-800"
}`}
>
<MaterialCommunityIcons
name={icon}
size={20}
color={danger ? "#e11d48" : isDark ? "#e5e5e5" : "#404040"}
/>
</View>
<View className="flex-1">
<Text
className={`text-[15px] font-JakartaSemiBold ${
danger ? "text-rose-600 dark:text-rose-400" : "text-black dark:text-white"
}`}
>
{title}
</Text>
{subtitle ? (
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mt-0.5">
{subtitle}
</Text>
) : null}
</View>
{right === "switch" ? (
<Switch
value={switchValue}
onValueChange={onSwitchChange}
trackColor={{ false: "#d4d4d4", true: "#0286ff" }}
/>
) : null}
{right === "value" ? (
<Text className="text-sm font-JakartaMedium text-general-200 dark:text-neutral-400 mr-1">
{value}
</Text>
) : null}
{right === "chevron" ? (
<MaterialCommunityIcons
name="chevron-right"
size={22}
color={isDark ? "#737373" : "#a3a3a3"}
/>
) : null}
</View>
);
if (right === "switch" || !interactive || !onPress) {
return <View className="px-4">{content}</View>;
}
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.6}
className="px-4"
>
{content}
</TouchableOpacity>
);
};