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
+56 -2
View File
@@ -1,3 +1,9 @@
// The Gradle build resolves this config in a plain node process that does not
// read .env, unlike `expo start` / `expo prebuild`. Without this the release
// APK was written with the placeholder origin below and could not reach the
// API at all. @expo/env is Expo's own loader — the same one the CLI uses.
require("@expo/env").load(__dirname);
// Dynamic Expo config. We use a JS config (rather than static app.json) so the
// Android Google Maps API key can be pulled from EXPO_PUBLIC_GOOGLE_API_KEY
// at build time without committing the key to the repo.
@@ -11,6 +17,52 @@
const googleMapsApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY;
// Expo Router resolves relative API-route fetches ("/(api)/auth/login") against
// this origin. In development it is overridden with the dev server URL, so the
// placeholder never mattered; a release build has no such override and would
// send every request to example.com. Point it at the same host that serves the
// API routes.
const serverOrigin =
process.env.EXPO_PUBLIC_SERVER_URL || "https://example.com/";
// Adds the SYSTEM_ALERT_WINDOW permission to the AndroidManifest so the app
// can request "display over other apps". The grant itself is a special
// permission the user must toggle in system settings — it can't be requested
// at runtime — but the manifest entry is what makes that system screen offer
// the switch for our app.
const { withAndroidManifest } = require("@expo/config-plugins");
// Release builds block cleartext HTTP: only src/debug/AndroidManifest.xml opts
// in. A LAN test build talks to the dev server over http://, so allow it for
// every build type. Drop this plugin once the API is served over https.
const withCleartextTraffic = (config) =>
withAndroidManifest(config, (cfg) => {
const application = cfg.modResults.manifest.application?.[0];
if (application) {
application.$["android:usesCleartextTraffic"] = "true";
}
return cfg;
});
const withOverlayPermission = (config) =>
withAndroidManifest(config, (cfg) => {
const manifest = cfg.modResults.manifest;
manifest["uses-permission"] = manifest["uses-permission"] || [];
const alreadyDeclared = manifest["uses-permission"].some(
(entry) => entry.$ && entry.$["android:name"] === "android.permission.SYSTEM_ALERT_WINDOW",
);
if (!alreadyDeclared) {
manifest["uses-permission"].push({
$: { "android:name": "android.permission.SYSTEM_ALERT_WINDOW" },
});
}
return cfg;
});
module.exports = ({ config }) => ({
...config,
name: "Waseel",
@@ -52,16 +104,18 @@ module.exports = ({ config }) => ({
[
"expo-router",
{
origin: "https://example.com/",
origin: serverOrigin,
},
],
withOverlayPermission,
withCleartextTraffic,
],
experiments: {
typedRoutes: true,
},
extra: {
router: {
origin: "https://example.com/",
origin: serverOrigin,
},
},
});
+51 -49
View File
@@ -18,11 +18,13 @@ import { OAuth } from "@/components/oauth";
import { OtpField } from "@/components/otp-field";
import { icons, images } from "@/constants";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { getRememberedEmail, rememberEmail, useSession } from "@/lib/session";
const SignIn = () => {
const router = useRouter();
const { setSession } = useSession();
const t = useT();
const [form, setForm] = useState({
email: "",
password: "",
@@ -84,7 +86,7 @@ const SignIn = () => {
const onRequestReset = async () => {
if (!reset.email.trim()) {
setReset((prev) => ({ ...prev, error: "Enter your email address." }));
setReset((prev) => ({ ...prev, error: t("auth.signIn.errEmail") }));
return;
}
@@ -112,14 +114,14 @@ const SignIn = () => {
const onSubmitReset = async () => {
if (!/^\d{6}$/.test(reset.code)) {
setReset((prev) => ({ ...prev, error: "Enter the 6-digit code." }));
setReset((prev) => ({ ...prev, error: t("auth.signIn.errCode") }));
return;
}
if (reset.password.length < 8) {
setReset((prev) => ({
...prev,
error: "Password must be at least 8 characters.",
error: t("auth.signIn.errPassword"),
}));
return;
}
@@ -148,7 +150,7 @@ const SignIn = () => {
error:
err instanceof ApiError && err.status < 500
? err.message
: "Could not reset your password. Please try again.",
: t("auth.signIn.errReset"),
}));
}
};
@@ -160,8 +162,8 @@ const SignIn = () => {
// otherwise surfaces as a generic "could not sign in".
if (!form.email.trim() || !form.password) {
Alert.alert(
"Missing information",
"Enter both your email and your password.",
t("auth.signIn.alertMissingTitle"),
t("auth.signIn.alertMissingBody"),
);
return;
}
@@ -186,9 +188,9 @@ const SignIn = () => {
const message =
err instanceof ApiError && err.status < 500
? err.message
: "Could not sign in. Please try again.";
: t("auth.signIn.alertErrorFallback");
Alert.alert("Error", message);
Alert.alert(t("auth.signIn.alertErrorTitle"), message);
// Only a rejected password is worth retyping. Clearing it after a
// network blip or a 403 just makes the next attempt fail differently.
@@ -198,38 +200,38 @@ const SignIn = () => {
} finally {
setBusy(false);
}
}, [busy, form.email, form.password, remember, setSession, router]);
}, [busy, form.email, form.password, remember, setSession, router, t]);
return (
<KeyboardAvoidingView
className="flex-1 bg-white"
className="flex-1 bg-white dark:bg-neutral-950"
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 40 : 0}
>
<ScrollView
className="flex-1 bg-white"
className="flex-1 bg-white dark:bg-neutral-950"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ flexGrow: 1 }}
showsVerticalScrollIndicator={false}
>
<View className="flex-1 bg-white">
<View className="flex-1 bg-white dark:bg-neutral-950">
<View className="relative w-full h-[250px]">
<Image
source={images.signUpCar}
alt="Car"
alt={t("auth.signIn.carAlt")}
className="z-0 w-full h-[250px]"
resizeMode="contain"
/>
<Text className="text-2xl text-black font-JakartaSemiBold absolute bottom-5 left-5">
Welcome 👋
<Text className="text-2xl text-black dark:text-white font-JakartaSemiBold absolute bottom-5 left-5">
{t("auth.signIn.welcome")}
</Text>
</View>
<View className="p-5">
<InputField
label="Email"
placeholder="karim@email.com"
label={t("auth.signIn.email")}
placeholder={t("auth.signIn.emailPlaceholder")}
icon={icons.email}
value={form.email}
onChangeText={(value) =>
@@ -244,8 +246,8 @@ const SignIn = () => {
/>
<InputField
label="Password"
placeholder="••••••••"
label={t("auth.signIn.password")}
placeholder={t("auth.signIn.passwordPlaceholder")}
icon={icons.lock}
secureTextEntry
value={form.password}
@@ -270,7 +272,7 @@ const SignIn = () => {
className={`h-6 w-6 rounded-md items-center justify-center border-2 ${
remember
? "bg-primary-500 border-primary-500"
: "bg-white border-neutral-300"
: "bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700"
}`}
>
{remember ? (
@@ -278,13 +280,13 @@ const SignIn = () => {
) : null}
</View>
<Text className="ml-3 font-JakartaMedium text-[15px] text-black">
Keep me signed in
<Text className="ml-3 font-JakartaMedium text-[15px] text-black dark:text-white">
{t("auth.signIn.keepSignedIn")}
</Text>
</TouchableOpacity>
<CustomButton
title={busy ? "Signing in…" : "Sign In"}
title={busy ? t("auth.signIn.signingIn") : t("auth.signIn.signInBtn")}
onPress={onSignInPress}
disabled={busy}
className="mt-6"
@@ -292,18 +294,18 @@ const SignIn = () => {
<TouchableOpacity onPress={openReset} className="mt-4">
<Text className="text-primary-500 text-center font-JakartaMedium">
Forgot password?
{t("auth.signIn.forgotPassword")}
</Text>
</TouchableOpacity>
<OAuth title="Sign in with Google" />
<OAuth title={t("auth.signIn.signInGoogle")} />
<Link
href="/sign-up"
className="text-base text-center text-general-200 mt-10"
className="text-base text-center text-general-200 dark:text-neutral-400 mt-10"
>
<Text>Don&apos;t have an account? </Text>
<Text className="text-primary-500">Sign up</Text>
<Text className="text-black dark:text-white">{t("auth.signIn.noAccount")}</Text>
<Text className="text-primary-500">{t("auth.signIn.signUpLink")}</Text>
</Link>
</View>
@@ -311,18 +313,18 @@ const SignIn = () => {
isVisible={reset.state === "request"}
onBackdropPress={closeReset}
>
<View className="bg-white px-7 py-9 rounded-2xl min-h-[280px]">
<Text className="text-2xl font-JakartaExtraBold mb-2">
Reset password
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[280px]">
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
{t("auth.signIn.reset.title")}
</Text>
<Text className="font-Jakarta mb-5">
Enter your email and we&apos;ll send you a 6-digit reset code.
<Text className="font-Jakarta mb-5 text-black dark:text-white">
{t("auth.signIn.reset.requestBody")}
</Text>
<InputField
label="Email"
placeholder="karim@email.com"
label={t("auth.signIn.email")}
placeholder={t("auth.signIn.reset.emailPlaceholder")}
icon={icons.email}
value={reset.email}
keyboardType="email-address"
@@ -338,7 +340,7 @@ const SignIn = () => {
) : null}
<CustomButton
title={reset.busy ? "Sending" : "Send Code"}
title={reset.busy ? t("auth.signIn.reset.sending") : t("auth.signIn.reset.sendCode")}
onPress={onRequestReset}
disabled={reset.busy}
className="mt-5"
@@ -350,20 +352,20 @@ const SignIn = () => {
isVisible={reset.state === "reset"}
onBackdropPress={closeReset}
>
<View className="bg-white px-7 py-9 rounded-2xl min-h-[300px]">
<Text className="text-2xl font-JakartaExtraBold mb-2">
Enter new password
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[300px]">
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
{t("auth.signIn.reset.newPassTitle")}
</Text>
<Text className="font-Jakarta mb-5">
We&apos;ve sent a reset code to {reset.email}
<Text className="font-Jakarta mb-5 text-black dark:text-white">
{t("auth.signIn.reset.resetBody", { email: reset.email })}
</Text>
{reset.devCode ? (
<View className="bg-amber-50 border border-amber-300 rounded-xl p-3 mb-5">
<Text className="text-sm text-amber-700 font-Jakarta">
Email delivery is not configured on this server. Your reset
code is <Text className="font-JakartaBold">{reset.devCode}</Text>
<View className="bg-amber-50 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 rounded-xl p-3 mb-5">
<Text className="text-sm text-amber-700 dark:text-amber-400 font-Jakarta">
{t("auth.signIn.reset.devBanner")}
<Text className="font-JakartaBold">{reset.devCode}</Text>
</Text>
</View>
) : null}
@@ -376,9 +378,9 @@ const SignIn = () => {
/>
<InputField
label="New password"
label={t("auth.signIn.reset.newPassLabel")}
icon={icons.lock}
placeholder="••••••••"
placeholder={t("auth.signIn.reset.newPasswordPlaceholder")}
secureTextEntry
value={reset.password}
onChangeText={(password) =>
@@ -393,7 +395,7 @@ const SignIn = () => {
) : null}
<CustomButton
title={reset.busy ? "Resetting" : "Reset Password"}
title={reset.busy ? t("auth.signIn.reset.resetting") : t("auth.signIn.reset.resetBtn")}
onPress={onSubmitReset}
disabled={reset.busy}
className="mt-5 bg-emerald-500"
@@ -406,4 +408,4 @@ const SignIn = () => {
);
};
export default SignIn;
export default SignIn;
+61 -58
View File
@@ -18,18 +18,21 @@ import { OAuth } from "@/components/oauth";
import { OtpField } from "@/components/otp-field";
import { icons, images } from "@/constants";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useSession } from "@/lib/session";
const ROLES = [
{
value: "rider",
title: "I need a ride",
description: "Book rides and get where you're going",
titleKey: "auth.signUp.riderTitle",
descKey: "auth.signUp.riderDesc",
icon: "map" as const,
},
{
value: "driver",
title: "I want to drive",
description: "Offer rides and earn money with your car",
titleKey: "auth.signUp.driverTitle",
descKey: "auth.signUp.driverDesc",
icon: "dollar" as const,
},
] as const;
@@ -37,6 +40,7 @@ type Role = (typeof ROLES)[number]["value"];
const SignUp = () => {
const { setSession } = useSession();
const t = useT();
const [role, setRole] = useState<Role>("rider");
const [form, setForm] = useState({
@@ -59,16 +63,16 @@ const SignUp = () => {
const onSignUpPress = async () => {
if (!form.name.trim() || !form.email.trim() || !form.password) {
Alert.alert(
"Missing information",
"Please fill in your name, email and password.",
t("auth.signUp.alertMissingTitle"),
t("auth.signUp.alertMissingBody"),
);
return;
}
if (form.phone.trim() && !/^[0-9\s\-()+.]+$/.test(form.phone)) {
Alert.alert(
"Invalid phone number",
"Enter a valid Lebanese number, e.g. 70 123 456.",
t("auth.signUp.alertPhoneTitle"),
t("auth.signUp.alertPhoneBody"),
);
return;
}
@@ -105,10 +109,10 @@ const SignUp = () => {
password: "",
}));
Alert.alert(
"Error",
t("auth.signUp.alertErrorTitle"),
err instanceof ApiError && err.status < 500
? err.message
: "Could not create your account.",
: t("auth.signUp.alertErrorFallback"),
);
}
};
@@ -118,7 +122,7 @@ const SignUp = () => {
if (!/^\d{6}$/.test(code)) {
setVerification((prevVerification) => ({
...prevVerification,
error: "Enter the 6-digit code.",
error: t("auth.signUp.errCode"),
}));
return;
}
@@ -153,42 +157,42 @@ const SignUp = () => {
error:
err instanceof ApiError && err.status < 500
? err.message
: "Verification failed. Please try again.",
: t("auth.signUp.errVerify"),
}));
}
},
[form.email, setSession],
[form.email, setSession, t],
);
return (
<KeyboardAvoidingView
className="flex-1 bg-white"
className="flex-1 bg-white dark:bg-neutral-950"
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 40 : 0}
>
<ScrollView
className="flex-1 bg-white"
className="flex-1 bg-white dark:bg-neutral-950"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ flexGrow: 1 }}
showsVerticalScrollIndicator={false}
>
<View className="flex-1 bg-white">
<View className="flex-1 bg-white dark:bg-neutral-950">
<View className="relative w-full h-[250px]">
<Image
source={images.signUpCar}
alt="Car"
alt={t("auth.signUp.carAlt")}
className="z-0 w-full h-[250px]"
resizeMode="contain"
/>
<Text className="text-2xl text-black font-JakartaSemiBold absolute bottom-5 left-5">
Create Your Account
<Text className="text-2xl text-black dark:text-white font-JakartaSemiBold absolute bottom-5 left-5">
{t("auth.signUp.createAccount")}
</Text>
</View>
<View className="p-5">
<Text className="text-lg font-JakartaSemiBold mb-3">
How will you use Waseel?
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("auth.signUp.howUse")}
</Text>
<View className="flex-row gap-3 mb-4">
{ROLES.map((option) => {
@@ -201,24 +205,24 @@ const SignUp = () => {
className={`flex-1 justify-center rounded-2xl border p-4 ${
selected
? "border-primary-500 bg-primary-500/10"
: "border-neutral-100 bg-neutral-100"
: "border-neutral-100 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900"
}`}
>
<Image
source={option.value === "driver" ? icons.dollar : icons.map}
alt={`${option.title} icon`}
alt={t(`auth.signUp.${option.value === "driver" ? "driverTitle" : "riderTitle"}`)}
className="h-7 w-7 mb-2"
resizeMode="contain"
/>
<Text
className={`text-[15px] font-JakartaBold ${
selected ? "text-primary-500" : "text-black"
selected ? "text-primary-500" : "text-black dark:text-white"
}`}
>
{option.title}
{t(option.titleKey)}
</Text>
<Text className="text-xs text-neutral-400 font-Jakarta mt-1">
{option.description}
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-Jakarta mt-1">
{t(option.descKey)}
</Text>
</TouchableOpacity>
);
@@ -226,8 +230,8 @@ const SignUp = () => {
</View>
<InputField
label="Name"
placeholder="Karim Haddad"
label={t("auth.signUp.name")}
placeholder={t("auth.signUp.namePlaceholder")}
icon={icons.person}
value={form.name}
onChangeText={(value) =>
@@ -240,8 +244,8 @@ const SignUp = () => {
/>
<InputField
label="Email"
placeholder="karim@email.com"
label={t("auth.signUp.email")}
placeholder={t("auth.signUp.emailPlaceholder")}
icon={icons.email}
value={form.email}
onChangeText={(value) =>
@@ -254,8 +258,8 @@ const SignUp = () => {
/>
<InputField
label="Phone (optional)"
placeholder="70 123 456"
label={t("auth.signUp.phoneOptional")}
placeholder={t("auth.signUp.phonePlaceholder")}
icon={icons.chat}
value={form.phone}
onChangeText={(value) =>
@@ -268,8 +272,8 @@ const SignUp = () => {
/>
<InputField
label="Password"
placeholder="••••••••"
label={t("auth.signUp.password")}
placeholder={t("auth.signUp.passwordPlaceholder")}
icon={icons.lock}
secureTextEntry
value={form.password}
@@ -282,19 +286,19 @@ const SignUp = () => {
/>
<CustomButton
title="Sign Up"
title={t("auth.signUp.signUpBtn")}
onPress={onSignUpPress}
className="mt-6"
/>
<OAuth title="Sign up with Google" />
<OAuth title={t("auth.signUp.signUpGoogle")} />
<Link
href="/sign-in"
className="text-base text-center text-general-200 mt-10"
className="text-base text-center text-general-200 dark:text-neutral-400 mt-10"
>
<Text>Already have an account? </Text>
<Text className="text-primary-500">Sign in</Text>
<Text className="text-black dark:text-white">{t("auth.signUp.haveAccount")}</Text>
<Text className="text-primary-500">{t("auth.signUp.signInLink")}</Text>
</Link>
</View>
@@ -308,20 +312,19 @@ const SignUp = () => {
}
isVisible={verification.state === "pending"}
>
<View className="bg-white px-7 py-9 rounded-2xl min-h-[300px]">
<Text className="text-2xl font-JakartaExtraBold mb-2">
Verification
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[300px]">
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
{t("auth.signUp.verify.title")}
</Text>
<Text className="font-Jakarta mb-5">
We&apos;ve sent a verification code to {form.email}
<Text className="font-Jakarta mb-5 text-black dark:text-white">
{t("auth.signUp.verify.body", { email: form.email })}
</Text>
{verification.devCode ? (
<View className="bg-amber-50 border border-amber-300 rounded-xl p-3 mb-5">
<Text className="text-sm text-amber-700 font-Jakarta">
Email delivery is not configured on this server. Your
verification code is{" "}
<View className="bg-amber-50 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 rounded-xl p-3 mb-5">
<Text className="text-sm text-amber-700 dark:text-amber-400 font-Jakarta">
{t("auth.signUp.verify.devBanner")}
<Text className="font-JakartaBold">{verification.devCode}</Text>
</Text>
</View>
@@ -346,7 +349,7 @@ const SignUp = () => {
) : null}
<CustomButton
title={verification.busy ? "Verifying" : "Verify Email"}
title={verification.busy ? t("auth.signUp.verify.verifying") : t("auth.signUp.verify.verifyBtn")}
onPress={() => onPressVerify(verification.code)}
disabled={verification.busy}
className="mt-5 bg-emerald-500"
@@ -354,23 +357,23 @@ const SignUp = () => {
</View>
</ReactNativeModal>
<ReactNativeModal isVisible={verification.state === "success"}>
<View className="bg-white px-7 py-9 rounded-2xl min-h-[300px]">
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[300px]">
<Image
source={images.check}
alt="Check"
alt={t("auth.signUp.verify.checkAlt")}
className="w-[110px] h-[110px] mx-auto my-5"
/>
<Text className="text-3xl font-JakartaBold text-center">
Verified
<Text className="text-3xl font-JakartaBold text-center text-black dark:text-white">
{t("auth.signUp.verified.title")}
</Text>
<Text className="text-base text-gray-400 font-Jakarta text-center mt-2">
You&apos;ve succesfully verified your account.
<Text className="text-base text-gray-400 dark:text-neutral-500 font-Jakarta text-center mt-2">
{t("auth.signUp.verified.body")}
</Text>
<CustomButton
title="Browse Home"
title={t("common.browseHome")}
onPress={() => router.push("/")}
className="mt-5"
/>
@@ -382,4 +385,4 @@ const SignUp = () => {
);
};
export default SignUp;
export default SignUp;
+13 -9
View File
@@ -6,25 +6,29 @@ import Swiper from "react-native-swiper";
import { CustomButton } from "@/components/custom-button";
import { onboarding } from "@/constants";
import { useT } from "@/lib/i18n";
const Welcome = () => {
const swiperRef = useRef<Swiper>(null);
const [activeIndex, setActiveIndex] = useState(0);
const isLastSlide = activeIndex === onboarding.length - 1;
const t = useT();
return (
<SafeAreaView className="flex h-full items-center justify-between bg-white">
<SafeAreaView className="flex h-full items-center justify-between bg-white dark:bg-neutral-950">
<TouchableOpacity
onPress={() => router.push("/(auth)/sign-up")}
className="w-full flex justify-end items-end p-5"
>
<Text className="text-black text-base font-JakartaBold">Skip</Text>
<Text className="text-black dark:text-white text-base font-JakartaBold">
{t("onboarding.skip")}
</Text>
</TouchableOpacity>
<Swiper
ref={swiperRef}
loop={false}
dot={<View className="w-8 h-1 mx-1 bg-[#E2E8F0] rounded-full" />}
dot={<View className="w-8 h-1 mx-1 bg-[#E2E8F0] dark:bg-neutral-700 rounded-full" />}
activeDot={<View className="w-8 h-1 mx-1 bg-[#0286FF] rounded-full" />}
index={activeIndex}
onIndexChanged={setActiveIndex}
@@ -39,13 +43,13 @@ const Welcome = () => {
/>
<View className="flex flex-row items-center justify-center w-full mt-10">
<Text className="text-black text-3xl font-bold mx-10 text-center">
{item.title}
<Text className="text-black dark:text-white text-3xl font-bold mx-10 text-center">
{t(item.titleKey)}
</Text>
</View>
<Text className="text-base font-JakartaSemiBold text-center text-[#858585] mx-10 mt-3">
{item.description}
<Text className="text-base font-JakartaSemiBold text-center text-[#858585] dark:text-neutral-400 mx-10 mt-3">
{t(item.descKey)}
</Text>
</View>
))}
@@ -57,11 +61,11 @@ const Welcome = () => {
? router.push("/(auth)/sign-up")
: swiperRef.current?.scrollBy(1)
}
title={isLastSlide ? "Get Started" : "Next"}
title={isLastSlide ? t("onboarding.getStarted") : t("onboarding.next")}
className="w-11/12 mt-10"
/>
</SafeAreaView>
);
};
export default Welcome;
export default Welcome;
+51 -12
View File
@@ -1,7 +1,10 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
import { Image, type ImageSourcePropType, View } from "react-native";
import { icons } from "@/constants";
import { useT } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
const TabIcon = ({
source,
@@ -13,7 +16,7 @@ const TabIcon = ({
focused: boolean;
}) => (
<View
className={`flex flex-row justify-center items-center rounded-full ${focused && "bg-general-300"}`}
className={`flex flex-row justify-center items-center rounded-full ${focused && "bg-general-300 dark:bg-neutral-800"}`}
>
<View
className={`rounded-full w-12 h-12 items-center justify-center ${focused && "bg-general-400"}`}
@@ -29,7 +32,31 @@ const TabIcon = ({
</View>
);
const TabsLayout = () => (
// Settings uses a vector glyph (MaterialCommunityIcons "cog") instead of a PNG
// asset, so it gets its own icon renderer that matches the pill styling.
const TabIconVector = ({
name,
focused,
}: {
name: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
focused: boolean;
}) => (
<View
className={`flex flex-row justify-center items-center rounded-full ${focused && "bg-general-300 dark:bg-neutral-800"}`}
>
<View
className={`rounded-full w-12 h-12 items-center justify-center ${focused && "bg-general-400"}`}
>
<MaterialCommunityIcons name={name} size={28} color="white" />
</View>
</View>
);
const TabsLayout = () => {
const { isDark } = useTheme();
const t = useT();
return (
<Tabs
initialRouteName="home"
screenOptions={{
@@ -37,7 +64,7 @@ const TabsLayout = () => (
tabBarInactiveTintColor: "white",
tabBarShowLabel: false,
tabBarStyle: {
backgroundColor: "#333",
backgroundColor: isDark ? "#0a0a0a" : "#333",
borderRadius: 50,
paddingBottom: 0,
overflow: "hidden",
@@ -55,10 +82,10 @@ const TabsLayout = () => (
<Tabs.Screen
name="home"
options={{
title: "Home",
title: t("tabs.home"),
headerShown: false,
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} source={icons.home} alt="Home" />
<TabIcon focused={focused} source={icons.home} alt={t("tabs.home")} />
),
}}
/>
@@ -66,10 +93,10 @@ const TabsLayout = () => (
<Tabs.Screen
name="rides"
options={{
title: "Rides",
title: t("tabs.rides"),
headerShown: false,
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} source={icons.list} alt="Rides" />
<TabIcon focused={focused} source={icons.list} alt={t("tabs.rides")} />
),
}}
/>
@@ -77,10 +104,10 @@ const TabsLayout = () => (
<Tabs.Screen
name="chat"
options={{
title: "Chat",
title: t("tabs.chat"),
headerShown: false,
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} source={icons.chat} alt="Chat" />
<TabIcon focused={focused} source={icons.chat} alt={t("tabs.chat")} />
),
}}
/>
@@ -88,14 +115,26 @@ const TabsLayout = () => (
<Tabs.Screen
name="profile"
options={{
title: "Profile",
title: t("tabs.profile"),
headerShown: false,
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} source={icons.profile} alt="Profile" />
<TabIcon focused={focused} source={icons.profile} alt={t("tabs.profile")} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: t("tabs.settings"),
headerShown: false,
tabBarIcon: ({ focused }) => (
<TabIconVector focused={focused} name="cog" />
),
}}
/>
</Tabs>
);
);
};
export default TabsLayout;
+13 -8
View File
@@ -2,27 +2,32 @@ import { Image, ScrollView, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { images } from "@/constants";
import { useT } from "@/lib/i18n";
const Chat = () => {
const t = useT();
return (
<SafeAreaView className="flex-1 bg-white p-5">
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 p-5">
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<Text className="text-2xl font-JakartaBold">Chat</Text>
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{t("chat.title")}
</Text>
<View className="flex-1 h-fit flex justify-center items-center">
<Image
source={images.message}
alt="message"
alt={t("chat.messageAlt")}
className="w-full h-40"
resizeMode="contain"
/>
<Text className="text-3xl font-JakartaBold mt-3">
No Messages Yet
<Text className="text-3xl font-JakartaBold mt-3 text-black dark:text-white">
{t("chat.noMessages")}
</Text>
<Text className="text-base mt-2 text-center px-7">
Start a conversation with your friends and family
<Text className="text-base mt-2 text-center px-7 text-general-200 dark:text-neutral-400">
{t("chat.startConversation")}
</Text>
</View>
</ScrollView>
@@ -30,4 +35,4 @@ const Chat = () => {
);
};
export default Chat;
export default Chat;
+23 -19
View File
@@ -16,7 +16,9 @@ import { NearbySuggestions } from "@/components/nearby-suggestions";
import { RideCard } from "@/components/ride-card";
import { ServiceSelector } from "@/components/service-selector";
import { icons, images } from "@/constants";
import { useT } from "@/lib/i18n";
import { useSession } from "@/lib/session";
import { useTheme } from "@/lib/theme";
import { useUserLocation } from "@/lib/use-user-location";
import { useLocationStore } from "@/store";
import { useFetch } from "@/lib/fetch";
@@ -27,6 +29,8 @@ const Home = () => {
(state) => state.setDestinationLocation,
);
const { signOut, user } = useSession();
const { isDark } = useTheme();
const t = useT();
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
const { status: locationStatus, retry: retryLocation } = useUserLocation();
@@ -47,7 +51,7 @@ const Home = () => {
};
return (
<SafeAreaView className="bg-general-500">
<SafeAreaView className="bg-general-500 dark:bg-neutral-950">
<FlatList
data={recentRides?.slice(0, 5)}
renderItem={({ item }) => <RideCard ride={item} />}
@@ -62,14 +66,14 @@ const Home = () => {
<>
<Image
source={images.noResult}
alt="No recent rides found"
alt={t("home.noRecentAlt")}
className="w-40 h-40"
resizeMode="contain"
/>
<Text className="text-sm">No recent rides found.</Text>
<Text className="text-sm text-black dark:text-white">{t("home.noRecent")}</Text>
</>
) : (
<ActivityIndicator size="small" color="#000" />
<ActivityIndicator size="small" color={isDark ? "#fff" : "#000"} />
)}
</View>
}
@@ -83,33 +87,33 @@ const Home = () => {
<>
<View className="flex flex-row items-center justify-between my-5">
<Text
className="text-base font-JakartaExtraBold"
className="text-base font-JakartaExtraBold text-black dark:text-white"
numberOfLines={1}
>
Welcome {user?.name || user?.email} 👋
{t("home.welcome", { name: user?.name || user?.email || "" })}
</Text>
<View className="flex flex-row items-center gap-x-1">
<TouchableOpacity
onPress={handleSignOut}
className="justify-center items-center w-10 h-10 rounded-full bg-white"
className="justify-center items-center w-10 h-10 rounded-full bg-white dark:bg-neutral-900"
>
<Image source={icons.out} className="w-4 h-4" alt="Logout" />
<Image source={icons.out} className="w-4 h-4" alt={t("home.logoutAlt")} />
</TouchableOpacity>
</View>
</View>
<GoogleTextInput
icon={icons.search}
containerStyles="bg-white shadow-md shadow-neutral-300"
containerStyles="bg-white dark:bg-neutral-900 shadow-md shadow-neutral-300 dark:shadow-neutral-950/40"
handlePress={handleDestinationPress}
/>
<Text className="text-xl font-JakartaBold mt-5 mb-3">
Your Current Location
<Text className="text-xl font-JakartaBold mt-5 mb-3 text-black dark:text-white">
{t("home.currentLocation")}
</Text>
<View className="w-full h-[300px] rounded-2xl overflow-hidden bg-white">
<View className="w-full h-[300px] rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
{locationStatus === "pending" || locationStatus === "granted" ? (
<>
{/* The map draws straight away on the Beirut fallback so the
@@ -117,10 +121,10 @@ const Home = () => {
<Map />
{locationStatus === "pending" ? (
<View className="absolute bottom-3 self-center flex-row items-center rounded-full bg-white/95 px-4 py-2 shadow-md shadow-neutral-400/40">
<View className="absolute bottom-3 self-center flex-row items-center rounded-full bg-white/95 dark:bg-neutral-900/95 px-4 py-2 shadow-md shadow-neutral-400/40 dark:shadow-neutral-950/40">
<ActivityIndicator size="small" color="#0286ff" />
<Text className="ml-2 text-xs font-JakartaMedium text-general-200">
Finding your location
<Text className="ml-2 text-xs font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("home.findingLocation")}
</Text>
</View>
) : null}
@@ -133,8 +137,8 @@ const Home = () => {
)}
</View>
<Text className="text-xl font-JakartaBold mt-5 mb-3">
What do you need?
<Text className="text-xl font-JakartaBold mt-5 mb-3 text-black dark:text-white">
{t("home.whatNeed")}
</Text>
<ServiceSelector />
@@ -143,8 +147,8 @@ const Home = () => {
<NearbySuggestions />
</View>
<Text className="text-xl font-JakartaBold mt-5 mb-3">
Recent Rides
<Text className="text-xl font-JakartaBold mt-5 mb-3 text-black dark:text-white">
{t("home.recentRides")}
</Text>
</>
}
+16 -12
View File
@@ -3,49 +3,53 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { InputField } from "@/components/input-field";
import { icons } from "@/constants";
import { useT } from "@/lib/i18n";
import { useSession } from "@/lib/session";
const Profile = () => {
const { user } = useSession();
const t = useT();
return (
<SafeAreaView className="flex-1">
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<ScrollView
className="px-5"
contentContainerStyle={{ paddingBottom: 120 }}
>
<Text className="text-2xl font-JakartaBold my-5">My Profile</Text>
<Text className="text-2xl font-JakartaBold my-5 text-black dark:text-white">
{t("profile.title")}
</Text>
<View className="flex items-center justify-center my-5">
<Image
source={user?.avatarUrl ? { uri: user.avatarUrl } : icons.profile}
alt="Your Avatar"
alt={t("profile.avatarAlt")}
style={{ width: 110, height: 110, borderRadius: 110 / 2 }}
className=" rounded-full h-[110px] w-[110px] border-[3px] border-white shadow-sm shadow-neutral-300"
className=" rounded-full h-[110px] w-[110px] border-[3px] border-white dark:border-neutral-800 shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
/>
</View>
<View className="flex flex-col items-start justify-center bg-white rounded-lg shadow-sm shadow-neutral-300 px-5 py-3">
<View className="flex flex-col items-start justify-center bg-white dark:bg-neutral-900 rounded-lg shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40 px-5 py-3">
<View className="flex flex-col items-start justify-start w-full">
<InputField
label="First name"
placeholder={user?.name?.split(" ")[0] || "Your First name"}
label={t("profile.firstName")}
placeholder={user?.name?.split(" ")[0] || t("profile.firstNamePlaceholder")}
containerStyles="w-full mb-4"
inputStyles="p-3.5"
editable={false}
/>
<InputField
label="Last name"
placeholder={user?.name?.split(" ").slice(1).join(" ") || "Your Last name"}
label={t("profile.lastName")}
placeholder={user?.name?.split(" ").slice(1).join(" ") || t("profile.lastNamePlaceholder")}
containerStyles="w-full mb-4"
inputStyles="p-3.5"
editable={false}
/>
<InputField
label="Email"
placeholder={user?.email ?? "Your Email address"}
label={t("profile.email")}
placeholder={user?.email ?? t("profile.emailPlaceholder")}
containerStyles="w-full mb-4"
inputStyles="p-3.5"
editable={false}
@@ -57,4 +61,4 @@ const Profile = () => {
);
};
export default Profile;
export default Profile;
+14 -6
View File
@@ -4,13 +4,17 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { RideCard } from "@/components/ride-card";
import { images } from "@/constants";
import { useFetch } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
import type { Ride } from "@/types/type";
const Rides = () => {
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
const { isDark } = useTheme();
const t = useT();
return (
<SafeAreaView>
<SafeAreaView className="bg-general-500 dark:bg-neutral-950">
<FlatList
data={recentRides}
renderItem={({ item }) => <RideCard ride={item} />}
@@ -25,23 +29,27 @@ const Rides = () => {
<>
<Image
source={images.noResult}
alt="No recent rides found"
alt={t("rides.noRecentAlt")}
className="w-40 h-40"
resizeMode="contain"
/>
<Text className="text-sm">No recent rides found.</Text>
<Text className="text-sm text-black dark:text-white">
{t("rides.noRecent")}
</Text>
</>
) : (
<ActivityIndicator size="small" color="#000" />
<ActivityIndicator size="small" color={isDark ? "#fff" : "#000"} />
)}
</View>
}
ListHeaderComponent={
<Text className="text-2xl font-JakartaBold my-5">All rides</Text>
<Text className="text-2xl font-JakartaBold my-5 text-black dark:text-white">
{t("rides.allRides")}
</Text>
}
/>
</SafeAreaView>
);
};
export default Rides;
export default Rides;
+325
View File
@@ -0,0 +1,325 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useFocusEffect } from "expo-router";
import { Alert, Linking, Platform, ScrollView, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useCallback, useState } from "react";
import { SettingsRow } from "@/components/settings-row";
import {
type Lang,
type ThemeMode,
useSettingsStore,
} from "@/lib/settings";
import { useT } from "@/lib/i18n";
import { useLocationPermission } from "@/lib/use-location-permission";
import { useTheme } from "@/lib/theme";
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
const SectionHeader = ({ title }: { title: string }) => (
<Text className="text-xs font-JakartaSemiBold uppercase tracking-wide text-general-200 dark:text-neutral-500 mt-6 mb-2 px-1">
{title}
</Text>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{children}
</View>
);
const Settings = () => {
const t = useT();
const { isDark } = useTheme();
const mode = useSettingsStore((state) => state.mode);
const setMode = useSettingsStore((state) => state.setMode);
const lang = useSettingsStore((state) => state.lang);
const setLang = useSettingsStore((state) => state.setLang);
const keepAwake = useSettingsStore((state) => state.keepAwake);
const setKeepAwake = useSettingsStore((state) => state.setKeepAwake);
const overlayRequested = useSettingsStore(
(state) => state.overlayRequested,
);
const setOverlayRequested = useSettingsStore(
(state) => state.setOverlayRequested,
);
const { status, refresh, openSettings } = useLocationPermission();
useFocusEffect(
useCallback(() => {
void refresh();
}, [refresh]),
);
const [expandedSafety, setExpandedSafety] = useState<string | null>(null);
const locationStatusLabel =
status === "granted"
? t("settings.maps.statusGranted")
: status === "denied"
? t("settings.maps.statusDenied")
: status === "blocked"
? t("settings.maps.statusBlocked")
: t("settings.maps.statusUnknown");
const modeLabel =
mode === "light"
? t("settings.appearance.light")
: mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system");
const langLabel =
lang === "en"
? t("settings.language.en")
: lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr");
const callEmergency = useCallback(async () => {
try {
await Linking.openURL("tel:112");
} catch {
Alert.alert(
t("settings.safety.callFailedTitle"),
t("settings.safety.callFailedBody"),
);
}
}, [t]);
const chooseLanguage = useCallback(
(next: Lang) => {
const switchingToOrFromRTL = next === "ar" || lang === "ar";
setLang(next);
if (switchingToOrFromRTL) {
Alert.alert(
t("settings.language.rtlRestartTitle"),
t("settings.language.rtlRestartBody"),
);
}
},
[lang, setLang, t],
);
const openOverlaySettings = useCallback(async () => {
setOverlayRequested(true);
try {
await Linking.openSettings();
} catch {
// already flagged; nothing more to do
}
}, [setOverlayRequested]);
const appearanceOptions: { mode: ThemeMode; icon: IconName }[] = [
{ mode: "light", icon: "white-balance-sunny" },
{ mode: "dark", icon: "weather-night" },
{ mode: "system", icon: "theme-light-dark" },
];
const languageOptions: { lang: Lang; icon: IconName }[] = [
{ lang: "en", icon: "alpha-e-box" },
{ lang: "ar", icon: "alpha-a-box" },
{ lang: "fr", icon: "alpha-f-box" },
];
const safetyTiles: { key: string; icon: IconName; title: string; body: string }[] = [
{
key: "proactive",
icon: "shield-account",
title: t("settings.safety.proactive.title"),
body: t("settings.safety.proactive.body"),
},
{
key: "verification",
icon: "account-check",
title: t("settings.safety.verification.title"),
body: t("settings.safety.verification.body"),
},
{
key: "privacy",
icon: "lock",
title: t("settings.safety.privacy.title"),
body: t("settings.safety.privacy.body"),
},
];
return (
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<ScrollView
className="px-5"
contentContainerStyle={{ paddingBottom: 120 }}
>
<Text className="text-2xl font-JakartaBold my-5 text-black dark:text-white">
{t("settings.title")}
</Text>
{/* 1. Maps & Navigation */}
<SectionHeader title={t("settings.maps.title")} />
<Card>
<SettingsRow
icon="map-marker-radius"
title={t("settings.maps.title")}
subtitle={t("settings.maps.description")}
right="value"
value={locationStatusLabel}
/>
{status !== "granted" ? (
<View className="border-t border-neutral-100 dark:border-neutral-800">
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
</View>
) : null}
</Card>
{/* 2. Appearance */}
<SectionHeader title={t("settings.appearance.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.appearance.description")}
</Text>
</View>
{appearanceOptions.map((option, index) => (
<View
key={option.mode}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
}
>
<SettingsRow
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
right="value"
value={
mode === option.mode
? isDark
? "✓"
: "✓"
: ""
}
onPress={() => setMode(option.mode)}
/>
</View>
))}
</Card>
{/* 3. Safety */}
<SectionHeader title={t("settings.safety.title")} />
<Card>
<SettingsRow
icon="phone-in-talk"
title={t("settings.safety.call112")}
subtitle={t("settings.safety.call112Description")}
right="chevron"
danger
onPress={callEmergency}
/>
{safetyTiles.map((tile) => (
<View
key={tile.key}
className="border-t border-neutral-100 dark:border-neutral-800"
>
<SettingsRow
icon={tile.icon}
title={tile.title}
subtitle={
expandedSafety === tile.key ? undefined : tile.body
}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
</View>
))}
</Card>
{/* 4. Language */}
<SectionHeader title={t("settings.language.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.language.description")}
</Text>
</View>
{languageOptions.map((option, index) => (
<View
key={option.lang}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
}
>
<SettingsRow
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
right="value"
value={lang === option.lang ? "✓" : ""}
onPress={() => chooseLanguage(option.lang)}
/>
</View>
))}
</Card>
{/* 5. Keep awake */}
<SectionHeader title={t("settings.keepAwake.title")} />
<Card>
<SettingsRow
icon="monitor"
title={t("settings.keepAwake.title")}
subtitle={t("settings.keepAwake.description")}
right="switch"
switchValue={keepAwake}
onSwitchChange={setKeepAwake}
/>
</Card>
{/* 6. Display over other apps (Android only) */}
{Platform.OS === "android" ? (
<>
<SectionHeader title={t("settings.overlay.title")} />
<Card>
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
</Card>
</>
) : null}
</ScrollView>
</SafeAreaView>
);
};
export default Settings;
+45 -43
View File
@@ -14,18 +14,19 @@ import { CustomButton } from "@/components/custom-button";
import { Map } from "@/components/map";
import { icons, images } from "@/constants";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { formatTime } from "@/lib/utils";
import { useLocationStore } from "@/store";
import type { Ride } from "@/types/type";
const POLL_MS = 3000;
const statusLabel: Record<string, string> = {
requested: "Finding your driver…",
accepted: "Driver assigned — heading to you",
en_route: "On your trip",
completed: "You've arrived!",
cancelled: "Ride cancelled",
const STATUS_KEY: Record<string, string> = {
requested: "bookRide.status.requested",
accepted: "bookRide.status.accepted",
en_route: "bookRide.status.enRoute",
completed: "bookRide.status.completed",
cancelled: "bookRide.status.cancelled",
};
// book-ride is now the live ride-status screen. The rider lands here after
@@ -33,6 +34,7 @@ const statusLabel: Record<string, string> = {
const BookRide = () => {
const { id } = useLocalSearchParams<{ id: string }>();
const rideId = Number(id);
const t = useT();
const setUserLocation = useLocationStore((s) => s.setUserLocation);
const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation);
@@ -62,12 +64,12 @@ const BookRide = () => {
} catch (err) {
console.log("[BOOK_RIDE_LOAD]: ", err);
if (err instanceof ApiError && err.status === 404) {
setError("Ride not found.");
setError(t("bookRide.rideNotFound"));
}
} finally {
setLoading(false);
}
}, [rideId, setUserLocation, setDestinationLocation]);
}, [rideId, setUserLocation, setDestinationLocation, t]);
useEffect(() => {
void load();
@@ -92,7 +94,7 @@ const BookRide = () => {
await load();
} catch (err) {
console.log("[BOOK_RIDE_CANCEL]: ", err);
Alert.alert("Error", "Could not cancel this ride. Please try again.");
Alert.alert(t("bookRide.alertErrorTitle"), t("bookRide.alertErrorBody"));
} finally {
setCancelling(false);
}
@@ -100,7 +102,7 @@ const BookRide = () => {
if (loading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
<ActivityIndicator size="large" color="#0286ff" />
</SafeAreaView>
);
@@ -108,12 +110,12 @@ const BookRide = () => {
if (error || !ride) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center px-7">
<Text className="text-base text-general-200 text-center">
{error ?? "Could not load this ride."}
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center px-7">
<Text className="text-base text-general-200 dark:text-neutral-400 text-center">
{error ?? t("bookRide.couldNotLoad")}
</Text>
<CustomButton
title="Back Home"
title={t("bookRide.backHome")}
onPress={() => router.replace("/(root)/(tabs)/home")}
className="mt-6"
/>
@@ -125,75 +127,75 @@ const BookRide = () => {
const terminal = ride.status === "completed" || ride.status === "cancelled";
return (
<SafeAreaView className="flex-1 bg-general-500">
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<View className="h-[45%] bg-blue-500">
<Map />
</View>
<View className="flex-1 px-5 pt-4">
<Text className="text-2xl font-JakartaExtraBold mb-2">
{statusLabel[ride.status] ?? ride.status}
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
{STATUS_KEY[ride.status] ? t(STATUS_KEY[ride.status]) : ride.status}
</Text>
{/* Searching state */}
{ride.status === "requested" ? (
<View className="items-center mt-6">
<ActivityIndicator size="large" color="#0286ff" />
<Text className="text-general-200 mt-3 text-center">
We&apos;re matching you with the nearest {ride.service} driver.
<Text className="text-general-200 dark:text-neutral-400 mt-3 text-center">
{t("bookRide.matchingDriver", { service: ride.service })}
</Text>
</View>
) : null}
{/* Driver card — shown once a driver is assigned. */}
{driver?.id ? (
<View className="bg-white rounded-2xl p-4 mt-2">
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-2">
<View className="flex-row items-center">
<Image
source={{ uri: driver.profile_image_url ?? undefined }}
className="w-16 h-16 rounded-full"
/>
<View className="ml-4 flex-1">
<Text className="text-lg font-JakartaSemiBold">
<Text className="text-lg font-JakartaSemiBold text-black dark:text-white">
{driver.first_name} {driver.last_name}
</Text>
<View className="flex-row items-center mt-1">
<Image source={icons.star} className="w-4 h-4" />
<Text className="ml-1 text-general-200">
{driver.rating?.toFixed(1) ?? "—"}
<Text className="ml-1 text-general-200 dark:text-neutral-400">
{driver.rating?.toFixed(1) ?? t("bookRide.ratingFallback")}
</Text>
{driver.car_model ? (
<Text className="ml-3 text-general-200">
<Text className="ml-3 text-general-200 dark:text-neutral-400">
{driver.car_model}
</Text>
) : null}
</View>
</View>
<Text className="text-xs text-general-200 capitalize">
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize">
{driver.service ?? ride.service}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mt-4">
<Image source={icons.to} className="w-4 h-4" />
<Text className="font-JakartaMedium text-sm" numberOfLines={1}>
<Text className="font-JakartaMedium text-sm text-black dark:text-white" numberOfLines={1}>
{ride.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mt-2">
<Image source={icons.point} className="w-4 h-4" />
<Text className="font-JakartaMedium text-sm" numberOfLines={1}>
<Text className="font-JakartaMedium text-sm text-black dark:text-white" numberOfLines={1}>
{ride.destination_address}
</Text>
</View>
<View className="flex-row justify-between mt-4 pt-3 border-t border-neutral-100">
<Text className="text-general-200 text-xs">
<View className="flex-row justify-between mt-4 pt-3 border-t border-neutral-100 dark:border-neutral-800">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{ride.payment_status === "cash"
? "💵 Cash to driver"
: "💳 Paid by card"}
? t("bookRide.paymentCash")
: t("bookRide.paymentCard")}
</Text>
<Text className="font-JakartaBold text-emerald-600">
<Text className="font-JakartaBold text-emerald-600 dark:text-emerald-400">
${(ride.fare_price / 100).toFixed(2)}
</Text>
</View>
@@ -202,22 +204,22 @@ const BookRide = () => {
{/* Completed summary */}
{ride.status === "completed" ? (
<View className="bg-white rounded-2xl p-4 mt-4 items-center">
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
<Image source={images.check} className="w-12 h-12" />
<Text className="text-lg font-JakartaBold mt-3">
Fare: ${(ride.fare_price / 100).toFixed(2)}
<Text className="text-lg font-JakartaBold mt-3 text-black dark:text-white">
{t("bookRide.fare", { fare: (ride.fare_price / 100).toFixed(2) })}
</Text>
<Text className="text-general-200 text-sm mt-1">
Trip time {formatTime(ride.ride_time)}
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-1">
{t("bookRide.tripTime", { time: formatTime(ride.ride_time) })}
</Text>
</View>
) : null}
{/* Cancelled */}
{ride.status === "cancelled" ? (
<View className="bg-white rounded-2xl p-4 mt-4 items-center">
<Text className="text-general-200">
This ride was cancelled.
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
<Text className="text-general-200 dark:text-neutral-400">
{t("bookRide.rideCancelled")}
</Text>
</View>
) : null}
@@ -225,17 +227,17 @@ const BookRide = () => {
<View className="mt-auto pt-6">
{terminal ? (
<CustomButton
title="Back Home"
title={t("bookRide.backHome")}
onPress={() => router.replace("/(root)/(tabs)/home")}
/>
) : (
<TouchableOpacity
onPress={cancel}
disabled={cancelling}
className="rounded-full py-3 bg-white items-center border border-rose-300"
className="rounded-full py-3 bg-white dark:bg-neutral-900 items-center border border-rose-300 dark:border-rose-900"
>
<Text className="font-JakartaBold text-rose-500">
{cancelling ? "Cancelling" : "Cancel Ride"}
{cancelling ? t("bookRide.cancelling") : t("bookRide.cancelRide")}
</Text>
</TouchableOpacity>
)}
+65 -41
View File
@@ -6,6 +6,7 @@ import { CustomButton } from "@/components/custom-button";
import { RideLayout } from "@/components/ride-layout";
import { SERVICES } from "@/constants/services";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { calculateTripFare } from "@/lib/map";
import { formatLBP } from "@/lib/pricing";
import { requestRide } from "@/lib/request-ride";
@@ -38,6 +39,7 @@ const ConfirmRide = () => {
} = useLocationStore();
const { service: storeService, setService } = useServiceStore();
const { user } = useSession();
const t = useT();
const service = params.service ?? storeService;
const selected = SERVICES.find((s) => s.id === service) ?? SERVICES[0];
@@ -154,11 +156,17 @@ const ConfirmRide = () => {
const request = async () => {
if (!userLatitude || !userLongitude || !destinationLatitude || !destinationLongitude) {
Alert.alert("Missing route", "Please set a pickup and destination first.");
Alert.alert(
t("confirmRide.alertMissingRouteTitle"),
t("confirmRide.alertMissingRouteBody"),
);
return;
}
if (!estimate) {
Alert.alert("No estimate", "We couldn't estimate this fare. Please try again.");
Alert.alert(
t("confirmRide.alertNoEstimateTitle"),
t("confirmRide.alertNoEstimateBody"),
);
return;
}
@@ -194,8 +202,8 @@ const ConfirmRide = () => {
const msg =
err instanceof ApiError
? err.message
: "Something went wrong while booking your ride. Please try again.";
Alert.alert("Error", msg);
: t("confirmRide.alertErrorFallback");
Alert.alert(t("confirmRide.alertErrorTitle"), msg);
} finally {
setProcessing(false);
}
@@ -203,11 +211,11 @@ const ConfirmRide = () => {
if (method === "card") {
Alert.alert(
"Pay by card",
`Your card will be charged $${estimate.fare}.`,
t("confirmRide.alertPayCardTitle"),
t("confirmRide.alertPayCardBody", { fare: estimate.fare }),
[
{ text: "Cancel", style: "cancel" },
{ text: "Continue", onPress: () => void doRequest() },
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.continue"), onPress: () => void doRequest() },
],
);
} else {
@@ -216,39 +224,53 @@ const ConfirmRide = () => {
};
return (
<RideLayout title="Request Ride" snapPoints={["60%", "88%"]}>
<Text className="text-xl font-JakartaSemiBold mb-1">Your trip</Text>
<RideLayout title={t("confirmRide.title")} snapPoints={["60%", "88%"]}>
<Text className="text-xl font-JakartaSemiBold mb-1 text-black dark:text-white">
{t("confirmRide.yourTrip")}
</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 text-xs">Pickup</Text>
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("confirmRide.pickup")}
</Text>
</View>
<Text className="font-JakartaMedium mb-3" numberOfLines={1}>
<Text className="font-JakartaMedium mb-3 text-black dark:text-white" numberOfLines={1}>
{userAddress}
</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 text-xs">Destination</Text>
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("confirmRide.destination")}
</Text>
</View>
<Text className="font-JakartaMedium mb-4" numberOfLines={1}>
<Text className="font-JakartaMedium mb-4 text-black dark:text-white" numberOfLines={1}>
{destinationAddress}
</Text>
<View className="flex-row items-center justify-between bg-general-500 rounded-2xl p-4 mb-4">
<View className="flex-row items-center justify-between bg-general-500 dark:bg-neutral-950 rounded-2xl p-4 mb-4">
<View>
<Text className="text-general-200 text-xs font-JakartaMedium">
{selected.label} · {selected.tagline}
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t(selected.labelKey)} · {t(selected.taglineKey)}
</Text>
<Text className="text-general-200 text-xs mt-1">
Trip time {estimate ? formatTime(estimate.durationSeconds / 60) : "…"}
<Text className="text-general-200 dark:text-neutral-400 text-xs mt-1">
{t("confirmRide.tripTime", {
time: estimate ? formatTime(estimate.durationSeconds / 60) : "…",
})}
</Text>
</View>
<View className="items-end">
<Text className="text-2xl font-JakartaExtraBold">
{estimating ? "…" : estimate ? `$${estimate.fare}` : "—"}
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{estimating
? "…"
: estimate
? t("confirmRide.fareDisplay", { fare: estimate.fare })
: "—"}
</Text>
{estimate ? (
<Text className="text-xs text-general-200">
{formatLBP(parseFloat(estimate.fare))}
<Text className="text-xs text-general-200 dark:text-neutral-400">
{t("confirmRide.lbpEstimate", {
lbp: formatLBP(parseFloat(estimate.fare)),
})}
</Text>
) : null}
</View>
@@ -256,50 +278,52 @@ const ConfirmRide = () => {
<Text
className={`text-base font-JakartaMedium mb-2 ${
driversOnline === 0 ? "text-rose-500" : "text-general-200"
driversOnline === 0
? "text-rose-500"
: "text-general-200 dark:text-neutral-400"
}`}
>
{driversOnline === 0
? `No ${selected.label} drivers online right now`
? t("confirmRide.noDrivers", { service: t(selected.labelKey) })
: nearestEta == null
? "Finding drivers nearby…"
: `Nearest driver${nearestEta} min away`}
? t("confirmRide.findingDrivers")
: t("confirmRide.nearestDriver", { eta: nearestEta })}
</Text>
<Text className="text-lg font-JakartaSemiBold mt-2 mb-2">
Payment Method
<Text className="text-lg font-JakartaSemiBold mt-2 mb-2 text-black dark:text-white">
{t("confirmRide.paymentMethod")}
</Text>
<View className="flex-row gap-x-3 mb-2">
<TouchableOpacity
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("confirmRide.cash")}
</Text>
</TouchableOpacity>
<TouchableOpacity
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("confirmRide.card")}
</Text>
</TouchableOpacity>
</View>
@@ -307,12 +331,12 @@ const ConfirmRide = () => {
<CustomButton
title={
processing
? "Requesting"
? t("confirmRide.requesting")
: driversOnline === 0
? "No drivers online"
? t("confirmRide.noDriversOnline")
: method === "cash"
? "Request Ride · Pay cash to driver"
: "Request Ride · Pay by card"
? t("confirmRide.requestCash")
: t("confirmRide.requestCard")
}
className="mt-4"
onPress={request}
+147 -115
View File
@@ -17,7 +17,9 @@ import { CustomButton } from "@/components/custom-button";
import { icons, images } from "@/constants";
import { SERVICES, type ServiceId } from "@/constants/services";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useSession } from "@/lib/session";
import { useTheme } from "@/lib/theme";
import { useDriverLocation } from "@/lib/use-driver-location";
import { formatTime } from "@/lib/utils";
@@ -71,6 +73,8 @@ type Dashboard = {
const DriverHome = () => {
const { signOut, user } = useSession();
const { isDark } = useTheme();
const t = useT();
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<Profile | null>(null);
const [online, setOnline] = useState(false);
@@ -136,7 +140,7 @@ const DriverHome = () => {
if (!next) setDashboard(null);
} catch (err) {
console.log("[DRIVER_TOGGLE]: ", err);
Alert.alert("Error", "Could not change your status. Please try again.");
Alert.alert(t("driver.home.alertErrorTitle"), t("driver.home.alertToggleBody"));
} finally {
setBusy(false);
}
@@ -154,10 +158,10 @@ const DriverHome = () => {
} catch (err) {
console.log("[DRIVER_RESPOND]: ", err);
Alert.alert(
"Error",
t("driver.activeRide.alertErrorTitle"),
action === "accept"
? "Could not accept this ride. It may have been taken or expired."
: "Could not decline this ride. Please try again.",
? t("driver.activeRide.alertAcceptBody")
: t("driver.activeRide.alertDeclineBody"),
);
} finally {
setBusy(false);
@@ -175,7 +179,7 @@ const DriverHome = () => {
await fetchDashboard();
} catch (err) {
console.log("[DRIVER_ADVANCE]: ", err);
Alert.alert("Error", "Could not update the ride. Please try again.");
Alert.alert(t("driver.activeRide.alertErrorTitle"), t("driver.activeRide.alertUpdateBody"));
} finally {
setBusy(false);
}
@@ -183,8 +187,8 @@ const DriverHome = () => {
if (loading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#0286ff" />
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
<ActivityIndicator size="large" color={isDark ? "#0286ff" : "#0286ff"} />
</SafeAreaView>
);
}
@@ -199,20 +203,20 @@ const DriverHome = () => {
const rideCount = dashboard?.recent.length ?? 0;
return (
<SafeAreaView className="flex-1 bg-general-500">
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<ScrollView
className="flex-1 px-5"
contentContainerStyle={{ paddingBottom: 40 }}
>
<View className="flex-row items-center justify-between my-5">
<Text className="text-2xl font-JakartaExtraBold">
Driver mode
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{t("driver.home.driverMode")}
</Text>
<TouchableOpacity
onPress={signOut}
className="w-10 h-10 rounded-full bg-white items-center justify-center"
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center"
>
<Image source={icons.out} className="w-4 h-4" alt="Sign out" />
<Image source={icons.out} className="w-4 h-4" alt={t("driver.home.signOutAlt")} />
</TouchableOpacity>
</View>
@@ -221,29 +225,29 @@ const DriverHome = () => {
onPress={toggleOnline}
disabled={busy}
className={`rounded-2xl p-5 items-center mb-4 ${
online ? "bg-emerald-500" : "bg-neutral-700"
online ? "bg-emerald-500" : "bg-neutral-700 dark:bg-neutral-800"
}`}
>
<Text className="text-white text-lg font-JakartaBold">
{online ? "● Online — receiving ride requests" : "○ Go online to drive"}
{online ? t("driver.home.online") : t("driver.home.goOnline")}
</Text>
</TouchableOpacity>
{/* Earnings summary */}
<View className="bg-white rounded-2xl p-4 mb-4 flex-row justify-between">
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-4 flex-row justify-between">
<View>
<Text className="text-general-200 text-xs font-JakartaMedium">
Today&apos;s earnings
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("driver.home.todaysEarnings")}
</Text>
<Text className="text-2xl font-JakartaExtraBold">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
${(earnings / 100).toFixed(2)}
</Text>
</View>
<View className="items-end">
<Text className="text-general-200 text-xs font-JakartaMedium">
Completed today
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("driver.home.completedToday")}
</Text>
<Text className="text-2xl font-JakartaExtraBold">{rideCount}</Text>
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">{rideCount}</Text>
</View>
</View>
@@ -257,8 +261,10 @@ const DriverHome = () => {
) : null}
{/* Incoming offers */}
<Text className="text-xl font-JakartaBold mt-4 mb-3">
Incoming requests {online ? "" : "(offline)"}
<Text className="text-xl font-JakartaBold mt-4 mb-3 text-black dark:text-white">
{online
? t("driver.home.incomingRequests")
: t("driver.home.incomingRequestsOffline")}
</Text>
{!online ? null : dashboard?.offers.length ? (
@@ -272,10 +278,10 @@ const DriverHome = () => {
/>
))
) : (
<View className="bg-white rounded-2xl p-6 items-center">
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-6 items-center">
<Image source={images.noResult} className="w-24 h-24" resizeMode="contain" />
<Text className="text-general-200 mt-2">
{online ? "Waiting for ride requests" : "Go online to start driving."}
<Text className="text-general-200 dark:text-neutral-400 mt-2">
{online ? t("driver.home.waitingRequests") : t("driver.home.goOnlineStart")}
</Text>
</View>
)}
@@ -295,6 +301,8 @@ const Onboarding = ({
signOut: () => Promise<void>;
userName?: string | null;
}) => {
const t = useT();
const { isDark } = useTheme();
const [service, setService] = useState<ServiceId>("car");
const [carModel, setCarModel] = useState("");
const [carSeats, setCarSeats] = useState("4");
@@ -303,7 +311,7 @@ const Onboarding = ({
const submit = async () => {
const seats = Number(carSeats);
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
Alert.alert("Invalid seats", "Car seats must be a whole number 18.");
Alert.alert(t("driver.home.alertSeatsTitle"), t("driver.home.alertSeatsBody"));
return;
}
setSubmitting(true);
@@ -320,33 +328,35 @@ const Onboarding = ({
await onCreated();
} catch (err) {
console.log("[DRIVER_ONBOARD]: ", err);
Alert.alert("Error", "Could not create your driver profile. Please try again.");
Alert.alert(t("driver.home.alertErrorTitle"), t("driver.home.alertCreateBody"));
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView className="flex-1 bg-white">
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950">
<ScrollView className="flex-1 px-5" contentContainerStyle={{ paddingBottom: 40 }}>
<View className="flex-row items-center justify-between my-5">
<Text className="text-2xl font-JakartaExtraBold">
Welcome, {userName?.split(" ")[0] || "driver"}
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{t("driver.home.welcome", {
name: userName?.split(" ")[0] || t("driver.home.welcomeFallback"),
})}
</Text>
<TouchableOpacity
onPress={signOut}
className="w-10 h-10 rounded-full bg-neutral-100 items-center justify-center"
className="w-10 h-10 rounded-full bg-neutral-100 dark:bg-neutral-900 items-center justify-center"
>
<Image source={icons.out} className="w-4 h-4" alt="Sign out" />
<Image source={icons.out} className="w-4 h-4" alt={t("driver.home.signOutAlt")} />
</TouchableOpacity>
</View>
<Text className="text-base text-general-200 font-Jakarta mb-4">
Set up your driver profile to start receiving ride requests.
<Text className="text-base text-general-200 dark:text-neutral-400 font-Jakarta mb-4">
{t("driver.home.setupIntro")}
</Text>
<Text className="text-lg font-JakartaSemiBold mb-3">
What will you drive?
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("driver.home.whatDrive")}
</Text>
<View className="flex-row gap-2 mb-5">
{SERVICES.map((item) => {
@@ -358,46 +368,52 @@ const Onboarding = ({
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 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900"
}`}
>
<MaterialCommunityIcons
name={item.icon}
size={24}
color={active ? "#0286ff" : "#858585"}
color={active ? "#0286ff" : isDark ? "#9ca3af" : "#858585"}
/>
<Text
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>
);
})}
</View>
<Text className="text-lg font-JakartaSemiBold mb-3">Car model</Text>
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("driver.home.carModel")}
</Text>
<TextInput
value={carModel}
onChangeText={setCarModel}
placeholder="e.g. Toyota Camry"
className="bg-neutral-100 rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-4"
placeholder={t("driver.home.carModelPlaceholder")}
placeholderTextColor={isDark ? "#737373" : "#858585"}
className="bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-4"
autoCapitalize="words"
/>
<Text className="text-lg font-JakartaSemiBold mb-3">Car seats</Text>
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("driver.home.carSeats")}
</Text>
<TextInput
value={carSeats}
onChangeText={setCarSeats}
placeholder="4"
placeholder={t("driver.home.carSeatsPlaceholder")}
placeholderTextColor={isDark ? "#737373" : "#858585"}
keyboardType="number-pad"
className="bg-neutral-100 rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-8"
className="bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-8"
/>
<CustomButton
title={submitting ? "Saving" : "Start driving"}
title={submitting ? t("common.saving") : t("driver.home.startDriving")}
onPress={submit}
disabled={submitting}
/>
@@ -418,63 +434,74 @@ const OfferCard = ({
busy: boolean;
onAccept: () => void;
onDecline: () => void;
}) => (
<View className="bg-white rounded-2xl p-4 mb-3">
<View className="flex-row items-center justify-between mb-2">
<Text className="text-sm font-JakartaBold text-primary-500">
New request · {offer.service}
</Text>
<Text className="text-xs text-general-200">
{offer.payment_status === "cash" ? "💵 Cash" : "💳 Card"}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mb-1">
<Image source={icons.to} alt="From" className="w-4 h-4" />
<Text className="font-JakartaMedium" numberOfLines={1}>
{offer.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mb-3">
<Image source={icons.point} alt="To" className="w-4 h-4" />
<Text className="font-JakartaMedium" numberOfLines={1}>
{offer.destination_address}
</Text>
</View>
<View className="flex-row justify-between mb-3">
<Text className="text-general-200 text-xs">Trip time</Text>
<Text className="font-JakartaMedium text-xs">
{formatTime(offer.ride_time)}
</Text>
</View>
<View className="flex-row justify-between mb-3">
<Text className="text-general-200 text-xs">Fare</Text>
<Text className="font-JakartaMedium text-xs text-emerald-600">
${(offer.fare_price / 100).toFixed(2)}
</Text>
</View>
<View className="flex-row gap-3">
<TouchableOpacity
onPress={onDecline}
disabled={busy}
className="flex-1 rounded-full py-3 bg-neutral-200 items-center"
>
<Text className="font-JakartaBold text-neutral-700">Decline</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onAccept}
disabled={busy}
className="flex-1 rounded-full py-3 bg-emerald-500 items-center"
>
<Text className="font-JakartaBold text-white">
{busy ? "…" : "Accept"}
}) => {
const t = useT();
return (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-3">
<View className="flex-row items-center justify-between mb-2">
<Text className="text-sm font-JakartaBold text-primary-500">
{t("driver.offerCard.newRequest", { service: offer.service })}
</Text>
</TouchableOpacity>
<Text className="text-xs text-general-200 dark:text-neutral-400">
{offer.payment_status === "cash"
? t("driver.offerCard.cash")
: t("driver.offerCard.card")}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mb-1">
<Image source={icons.to} alt={t("driver.offerCard.fromAlt")} className="w-4 h-4" />
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
{offer.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mb-3">
<Image source={icons.point} alt={t("driver.offerCard.toAlt")} className="w-4 h-4" />
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
{offer.destination_address}
</Text>
</View>
<View className="flex-row justify-between mb-3">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("driver.offerCard.tripTime")}
</Text>
<Text className="font-JakartaMedium text-xs text-black dark:text-white">
{formatTime(offer.ride_time)}
</Text>
</View>
<View className="flex-row justify-between mb-3">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("driver.offerCard.fare")}
</Text>
<Text className="font-JakartaMedium text-xs text-emerald-600 dark:text-emerald-400">
${(offer.fare_price / 100).toFixed(2)}
</Text>
</View>
<View className="flex-row gap-3">
<TouchableOpacity
onPress={onDecline}
disabled={busy}
className="flex-1 rounded-full py-3 bg-neutral-200 dark:bg-neutral-800 items-center"
>
<Text className="font-JakartaBold text-neutral-700 dark:text-neutral-200">
{t("driver.offerCard.decline")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onAccept}
disabled={busy}
className="flex-1 rounded-full py-3 bg-emerald-500 items-center"
>
<Text className="font-JakartaBold text-white">
{busy ? "…" : t("driver.offerCard.accept")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
);
};
// --- Active ride card -----------------------------------------------------
@@ -487,11 +514,12 @@ const ActiveRideCard = ({
busy: boolean;
onAdvance: (rideId: number, status: "en_route" | "completed") => void;
}) => {
const t = useT();
const statusLabel =
ride.status === "accepted"
? "Head to pickup"
? t("driver.activeRide.headToPickup")
: ride.status === "en_route"
? "Trip in progress"
? t("driver.activeRide.tripInProgress")
: ride.status;
return (
@@ -500,36 +528,40 @@ const ActiveRideCard = ({
<Text className="text-sm font-JakartaBold text-primary-500">
{statusLabel}
</Text>
<Text className="text-xs text-general-200">{ride.service}</Text>
<Text className="text-xs text-general-200 dark:text-neutral-400">{ride.service}</Text>
</View>
{ride.rider_name ? (
<Text className="font-JakartaBold mb-2">{ride.rider_name}</Text>
<Text className="font-JakartaBold mb-2 text-black dark:text-white">
{t("driver.activeRide.rider", { name: ride.rider_name })}
</Text>
) : null}
<View className="flex-row items-center gap-x-2 mb-1">
<Image source={icons.to} alt="From" className="w-4 h-4" />
<Text className="font-JakartaMedium" numberOfLines={1}>
<Image source={icons.to} alt={t("driver.activeRide.fromAlt")} className="w-4 h-4" />
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
{ride.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mb-3">
<Image source={icons.point} alt="To" className="w-4 h-4" />
<Text className="font-JakartaMedium" numberOfLines={1}>
<Image source={icons.point} alt={t("driver.activeRide.toAlt")} className="w-4 h-4" />
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
{ride.destination_address}
</Text>
</View>
<View className="flex-row justify-between mb-4">
<Text className="text-general-200 text-xs">Fare</Text>
<Text className="font-JakartaMedium text-xs text-emerald-600">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("driver.activeRide.fare")}
</Text>
<Text className="font-JakartaMedium text-xs text-emerald-600 dark:text-emerald-400">
${(ride.fare_price / 100).toFixed(2)}
</Text>
</View>
{ride.status === "accepted" ? (
<CustomButton
title={busy ? "…" : "Start trip"}
title={busy ? "…" : t("driver.activeRide.startTrip")}
bgVariant="success"
onPress={() => onAdvance(ride.ride_id, "en_route")}
className="mb-2"
@@ -537,7 +569,7 @@ const ActiveRideCard = ({
) : null}
{ride.status === "en_route" ? (
<CustomButton
title={busy ? "…" : "Complete trip"}
title={busy ? "…" : t("driver.activeRide.completeTrip")}
bgVariant="success"
onPress={() => onAdvance(ride.ride_id, "completed")}
/>
+13 -9
View File
@@ -2,11 +2,13 @@ import { CustomButton } from "@/components/custom-button";
import { GoogleTextInput } from "@/components/google-text-input";
import { RideLayout } from "@/components/ride-layout";
import { icons } from "@/constants";
import { useT } from "@/lib/i18n";
import { useLocationStore } from "@/store";
import { router } from "expo-router";
import { Text, View } from "react-native";
const FindRide = () => {
const t = useT();
const {
userAddress,
destinationAddress,
@@ -25,33 +27,35 @@ const FindRide = () => {
!!destinationLongitude;
return (
<RideLayout title="Ride" snapPoints={["85%"]}>
<RideLayout title={t("findRide.title")} snapPoints={["85%"]}>
<View className="my-3">
<Text className="text-lg font-JakartaSemiBold mb-3">From</Text>
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("findRide.from")}
</Text>
<GoogleTextInput
icon={icons.target}
initialLocation={userAddress ?? ""}
containerStyles="bg-neutral-100"
textInputBackgroundColor="#F5F5F5"
containerStyles="bg-neutral-100 dark:bg-neutral-800"
handlePress={setUserLocation}
/>
</View>
<View className="my-3">
<Text className="text-lg font-JakartaSemiBold mb-3">To</Text>
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
{t("findRide.to")}
</Text>
<GoogleTextInput
icon={icons.map}
initialLocation={destinationAddress ?? ""}
containerStyles="bg-neutral-100"
textInputBackgroundColor="transparent"
containerStyles="bg-neutral-100 dark:bg-neutral-800"
handlePress={setDestinationLocation}
/>
</View>
<CustomButton
title="Find now"
title={t("findRide.findNow")}
onPress={() => router.push("/(root)/confirm-ride")}
disabled={!canFind}
className={`mt-5 ${!canFind ? "opacity-50" : ""}`}
@@ -60,4 +64,4 @@ const FindRide = () => {
);
};
export default FindRide;
export default FindRide;
+17 -13
View File
@@ -4,10 +4,12 @@ import { Alert, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useSession } from "@/lib/session";
const RoleSelection = () => {
const { setUserRole } = useSession();
const t = useT();
const [saving, setSaving] = useState(false);
const chooseRole = async (role: "rider" | "driver") => {
@@ -31,20 +33,20 @@ const RoleSelection = () => {
);
} catch (err) {
console.log("[ROLE_SELECT]: ", err);
Alert.alert("Error", "Could not save your choice. Please try again.");
Alert.alert(t("auth.role.alertErrorTitle"), t("auth.role.alertErrorBody"));
} finally {
setSaving(false);
}
};
return (
<SafeAreaView className="flex-1 bg-white justify-center px-7">
<Text className="text-3xl font-JakartaExtraBold text-center">
How will you use Waseel?
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 justify-center px-7">
<Text className="text-3xl font-JakartaExtraBold text-center text-black dark:text-white">
{t("auth.role.title")}
</Text>
<Text className="text-base text-general-200 font-Jakarta text-center mt-3 mb-10">
You can change this later by contacting support.
<Text className="text-base text-general-200 dark:text-neutral-400 font-Jakarta text-center mt-3 mb-10">
{t("auth.role.subtitle")}
</Text>
<TouchableOpacity
@@ -54,26 +56,28 @@ const RoleSelection = () => {
>
<Text className="text-5xl mb-3">🧍</Text>
<Text className="text-2xl font-JakartaBold text-white">
I&apos;m a Rider
{t("auth.role.riderTitle")}
</Text>
<Text className="text-sm font-Jakarta text-white/80 text-center mt-2">
Book rides and get around Lebanon
{t("auth.role.riderDesc")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => chooseRole("driver")}
disabled={saving}
className="bg-general-600 rounded-2xl p-7 items-center"
className="bg-general-600 dark:bg-primary-500/20 border border-primary-500 rounded-2xl p-7 items-center"
>
<Text className="text-5xl mb-3">🚗</Text>
<Text className="text-2xl font-JakartaBold">I&apos;m a Driver</Text>
<Text className="text-sm font-Jakarta text-general-200 text-center mt-2">
Give rides and earn money
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{t("auth.role.driverTitle")}
</Text>
<Text className="text-sm font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-2">
{t("auth.role.driverDesc")}
</Text>
</TouchableOpacity>
</SafeAreaView>
);
};
export default RoleSelection;
export default RoleSelection;
+16 -10
View File
@@ -1,11 +1,13 @@
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import "react-native-reanimated";
import { I18nProvider } from "@/lib/i18n";
import { SessionProvider } from "@/lib/session";
import { SettingsProvider } from "@/lib/settings-provider";
import { ThemeProvider } from "@/lib/theme";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -32,15 +34,19 @@ const RootLayout = () => {
}
return (
<SessionProvider>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(root)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
<StatusBar style="dark" />
</SessionProvider>
<SettingsProvider>
<ThemeProvider>
<I18nProvider>
<SessionProvider>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(root)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
</SessionProvider>
</I18nProvider>
</ThemeProvider>
</SettingsProvider>
);
};
+2 -2
View File
@@ -25,7 +25,7 @@ const App = () => {
if (!isLoaded) {
return (
<View className="flex-1 items-center justify-center bg-white">
<View className="flex-1 items-center justify-center bg-white dark:bg-neutral-950">
<ActivityIndicator size="large" color="#0286FF" />
</View>
);
@@ -36,7 +36,7 @@ const App = () => {
// Still loading the user's role from the database.
if (role === undefined) {
return (
<View className="flex-1 items-center justify-center bg-white">
<View className="flex-1 items-center justify-center bg-white dark:bg-neutral-950">
<ActivityIndicator size="large" color="#0286FF" />
</View>
);
+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>
);
};
+6 -9
View File
@@ -76,23 +76,20 @@ export const icons = {
export const onboarding = [
{
id: 1,
title: "The perfect ride is just a tap away!",
description:
"Your journey begins with Waseel. Find your ideal ride effortlessly.",
titleKey: "onboarding.slide1.title",
descKey: "onboarding.slide1.desc",
image: images.onboarding1,
},
{
id: 2,
title: "Best car in your hands with Waseel",
description:
"Discover the convenience of finding your perfect ride with Waseel",
titleKey: "onboarding.slide2.title",
descKey: "onboarding.slide2.desc",
image: images.onboarding2,
},
{
id: 3,
title: "Your ride, your way. Let's go!",
description:
"Enter your destination, sit back, and let us take care of the rest.",
titleKey: "onboarding.slide3.title",
descKey: "onboarding.slide3.desc",
image: images.onboarding3,
},
];
+16 -20
View File
@@ -1,13 +1,9 @@
// The services offered on the home screen.
//
// English-only for now. When the app gets a real language layer these are the
// intended Arabic names, chosen Levantine rather than formal MSA — "موتور" is
// what a motorcycle taxi is actually called in Lebanon, where "دراجة نارية"
// reads like a textbook translation:
// car → سيارة
// moto → موتور
// courier → توصيل طرود
// chauffeur → سائق خاص
// Labels and taglines are translation keys (resolved via `t()` at the call
// site) so each language carries its own names. The Arabic names are Levantine
// rather than formal MSA — "موتور" is what a motorcycle taxi is actually called
// in Lebanon, where "دراجة نارية" reads like a textbook translation.
export type ServiceId = "car" | "moto" | "courier" | "chauffeur";
@@ -15,10 +11,10 @@ export type Service = {
id: ServiceId;
/** MaterialCommunityIcons glyph name. */
icon: "car" | "motorbike" | "package-variant-closed" | "steering";
/** Kept to one short word so four tiles fit a phone width without scrolling. */
label: string;
/** Shown under the row once the service is selected. */
tagline: string;
/** i18n key for the short label (kept short so four tiles fit a phone width). */
labelKey: string;
/** i18n key for the tagline shown under the row once the service is selected. */
taglineKey: string;
/** Multiplier applied to the base fare for this service (car = 1.0). */
fareMultiplier: number;
};
@@ -27,29 +23,29 @@ export const SERVICES: Service[] = [
{
id: "car",
icon: "car",
label: "Car",
tagline: "An everyday ride, up to 4 seats.",
labelKey: "services.car.label",
taglineKey: "services.car.tagline",
fareMultiplier: 1.0,
},
{
id: "moto",
icon: "motorbike",
label: "Moto",
tagline: "Beat the traffic — one passenger, no luggage.",
labelKey: "services.moto.label",
taglineKey: "services.moto.tagline",
fareMultiplier: 0.7,
},
{
id: "courier",
icon: "package-variant-closed",
label: "Courier",
tagline: "Send a parcel across town without riding along.",
labelKey: "services.courier.label",
taglineKey: "services.courier.tagline",
fareMultiplier: 0.85,
},
{
id: "chauffeur",
icon: "steering",
label: "My Car",
tagline: "A driver comes to you and drives your own car.",
labelKey: "services.chauffeur.label",
taglineKey: "services.chauffeur.tagline",
fareMultiplier: 1.5,
},
];
+99
View File
@@ -0,0 +1,99 @@
# شروط استخدام Waseel.Courier
آخر تحديث: 24 يوليو 2026
> **ملاحظة قانونية:** هذه الوثيقة مشتقّة من شروط استخدام inDrive.Courier وتحويلها إلى شروط Waseel. يجب مراجعتها من مستشار قانوني قبل النشر.
مرحبًا بكم في Waseel.Courier!
تحكم شروط الاستخدام هذه ("الشروط") استخدامك لخدمة Waseel.Courier للأجهزة المحمولة ومواقع الويب والمنتجات والمحتوى والميزات والمنصة (يُشار إليها مجتمعةً باسم - "المنصة").
تعتبر شروط استخدام خدمة Waseel.Courier هذه جزءًا لا يتجزأ من شروط الاستخدام العامة. وباستخدامك لخدمة Waseel.Courier، فإنك تعبر صراحةً عن موافقتك الكاملة على هذه الشروط وشروط الاستخدام العامة. وفي حال وجود أي تعارض أو تناقض بين أحكام هذه الشروط وشروط الاستخدام العامة، تُطبَّق أحكام هذه الشروط.
عندما تؤكد قبولك لهذه الشروط أو تستخدم المنصة بطريقة أخرى، فإنك تدخل في عقد معنا. وتعتمد الشركة التي تتعاقد معها على المكان الذي تعيش فيه.
قد يخضع استخدامك للمنصة بصفتك عامل توصيل لشروط استخدام Waseel المحددة. وتوضح سياسة الخصوصية لدينا ممارسات الخصوصية الخاصة بنا بالتفصيل.
## 1. نموذج عمل Waseel.Courier
1.1. تربط منصتنا بين مزودي خدمات التوصيل المستقلين من الأطراف الثالثة ("عمال التوصيل") وعملائهم ("المرسلين") مع بعضهم البعض. عندما يطلب المرسلون تسليم طرد، يعرضون أسعارهم المقابلة لخدمات عمال التوصيل. ويمكن لعمال التوصيل الذين يرون الطلب إما الموافقة على السعر المعروض أو تقديم عرض مقابل.
1.2. وللمرسلين الحرية في اختيار عامل التوصيل من قائمة عمال التوصيل الذين أبدوا اهتمامهم بقبول الطلب. وتُبرم اتفاقية منفصلة بين عامل التوصيل والمرسل عندما يؤكد المرسل عملية توصيل الطرد.
1.3. يجب على المرسل دفع السعر المتفق عليه لعامل التوصيل من خلال المنصة. يشمل هذا السعر المتفق عليه جميع الرسوم المرتبطة بتوصيل الطرد (الرسوم والمبالغ والجبايات والضرائب وما إلى ذلك). ولا تتدخل Waseel ولا تؤثر بأي شكل على التسويات بين عامل التوصيل والمرسل.
## 2. مسؤولية المرسل
2.1. يتحمل المرسل المسؤولية عن تقديم معلومات التوصيل بشكل دقيق وكامل، بما في ذلك العنوان الصحيح للمستلم ومعلومات الاتصال وأي تعليمات خاصة ضرورية لتسليم الطرد بنجاح.
2.2. يتعهد المرسل بضمان وصول عامل التوصيل إلى مكان التسليم دون عوائق، كما يضمن وجوده أو وجود طرف ثالث قادر على استلام الطرد في مكان عنوان التسليم المحدد.
2.3. يتحمل المرسل المسؤولية عن ضمان تغليف الطرد بشكل صحيح وتأمينه للنقل. كما تقع عليه وحده المسؤولية الكاملة عن أي ضرر أو تأخير ناتج عن التغليف غير المناسب أو معلومات التوصيل غير الصحيحة التي قدمها.
2.4. يجب على المرسل ألا يرسل طردًا يزيد وزنه عن 20 كغ للتوصيل بالسيارة، ولا يزيد عن 10 كغ للتوصيل عبر أنواع التوصيل الأخرى (عامل توصيل ماشيًا، عامل توصيل على دراجة هوائية، عامل توصيل على دراجة نارية).
2.5. يقر المرسل ويتعهد بأن لديه الحق القانوني في امتلاك وإرسال العناصر المضمنة في الطرد. كما يتحمل المرسل المسؤولية عن ضمان أن البضائع المشحونة قد اشُترت بشكل قانوني ولا تنتهك أي قوانين أو لوائح أو قيود معمول بها.
2.6. يقر المرسل بأنه يتحمل وحده المسؤولية الكاملة عن أي عواقب أو مطالبات قانونية قد تنشأ فيما يتعلق بإرسال البضائع دون إثبات الملكية الصحيح أو التصريح ذي الصلة.
## 3. مسؤولية عامل التوصيل
3.1. يكون عامل التوصيل مسؤولاً عن النقل الآمن للطرد ونقله في المواعيد المحددة من نقطة الاستلام إلى نقطة التسليم. ويتعين عليه اتخاذ جميع الاحتياطات اللازمة لمنع أي فقدان للطرد أو تلفه أثناء نقله. وفي حال حدوث أي فقدان للطرد أو تضرره أثناء وجوده في حوزته، يتحمل عامل التوصيل المسؤولية المترتبة عن ذلك، مع مراعاة أي قيود أو استثناءات للمسؤولية المنصوص عليها في هذه الشروط أو القانون المعمول به.
3.2. يقر كلا الطرفين بأن مسؤولية كل طرف عن التسليم تقتصر على التزامات كل منهما المنصوص عليها أعلاه، ولن يتحمل أي طرف مسؤولية أي إخفاق أو تأخير في التسليم بسبب ظروف خارجة عن سيطرتهما المعقولة، بما في ذلك على سبيل المثال لا الحصر الكوارث الطبيعية أو الإجراءات الحكومية أو أي أحداث قوة قاهرة أخرى.
3.3. في حال فشل عملية التسليم بسبب عدم وجود المستلم في مكان التسليم أو معلومات التسليم غير الصحيحة المقدمة من المرسل، أو لأي سبب آخر خارج عن السيطرة المعقولة لعامل التوصيل، لن تكون Waseel مسؤولة عن تخزين الطرد. وفي مثل هذه الحالات يجب أن يدرك المرسل والمستلم أنه يتعين عليهما اتخاذ تدابير بديلة لتخزين الطرد أو التعامل معه.
3.4. بقبول طرد من المرسل، يحق لعامل التوصيل، ولكنه غير ملزم بـ:
3.4.1. قراءة محتوياته بتمعّن؛
3.4.2. الطلب من المرسل بيان محتويات الطرد وختم الطرد بوجوده؛
3.4.3. رفض قبول الطرد إذا رفض المرسل بيان محتوياته، أو إذا بدت المحتويات مشبوهة أو غير قانونية.
## 4. البضائع المحظورة
4.1. عند استخدام المنصة، فأنت مسؤول عن التأكد من أن الطرد الذي يتم تسليمه ليس سلعة محظورة.
4.2. البضائع المحظورة، بما في ذلك على سبيل المثال لا الحصر:
4.2.1. نوصي بألا تتجاوز قيمة أي طرد يتم إرساله عبر المنصة قيمةً يحددها Waseel ويُعلن عنها في التطبيق. باستخدامك للمنصة، فإنك تقر بأن Waseel ليست مسؤولة عن أي خسارة أو تلف أو مشاكل تتعلق بتسليم الطرد الخاص بك، بغض النظر عن قيمته؛
4.2.2. الأدوية المخدرة، الأدوية التي تصرف بوصفة طبية، المؤثرات العقلية، المواد شديدة الفعالية، المواد السامة، المواد المشعة، المواد المتفجرة؛
4.2.3. المواد السامة والكاوية والقابلة للاشتعال وغيرها من المواد الخطرة، بما في ذلك تلك المواد المضغوطة؛
4.2.4. الأسلحة النارية أو الأسلحة التي تعمل بالهواء المضغوط أو الأسلحة الغازية أو الأسلحة البيضاء وأجزائها والذخيرة والألعاب النارية والمشاعل والخراطيش؛
4.2.5. العملات الأجنبية والأوراق النقدية؛
4.2.6. الأشياء ذات القيمة العالية مثل المجوهرات والمعادن الثمينة والأحجار الكريمة والمنتجات التي تحتوي عليها؛
4.2.7. الأشياء والمواد التي قد تشكل بطبيعتها أو بسبب تغليفها خطراً على الأشخاص أو تسبب تلوثاً أو تفسد (تتلف) البضائع الأخرى أو تضر الأشخاص أو الأشياء من حولها؛
4.2.8. البشر والأنواع الخاضعة للرقابة والحيوانات والنباتات والمواد البيولوجية؛
4.2.9. المواد التي تتطلب مركبات مجهزة خصيصاً لنقلها، بما في ذلك المواد الغذائية؛
4.2.10. السوائل الموضوعة في حاويات غير مخصصة لها؛
4.2.11. المواد الهشة غير المغلفة بمواد وطريقة خاصة؛
4.2.12. جميع البضائع المشحونة التي يحظرها القانون؛
4.2.13. البضائع غير القانونية أو المسروقة أو المنتجات المقرصنة أو السلع المقلدة؛
4.2.14. النفايات الخطرة مثل البطاريات؛
4.2.15. المواد المتفجرة ومكوناتها؛
4.2.16. المشروبات الكحولية؛
4.2.17. منتجات التبغ والسجائر الإلكترونية؛
4.2.18. المواد الإباحية أو غير اللائقة.
## الاتصال بخدمة Waseel.Courier
يمكنك التواصل معنا عبر دعم المستخدم داخل التطبيق أو من خلال قنوات الدعم الرسمية لـ Waseel.
+186
View File
@@ -0,0 +1,186 @@
# شروط الاستخدام العامة — Waseel
آخر تحديث بتاريخ 24 يوليو 2026
> **ملاحظة قانونية:** هذه الوثيقة مشتقّة من شروط استخدام inDrive (نموذج غير منصوص على ملكيته) وتحويلها إلى شروط Waseel. يجب مراجعتها من مستشار قانوني قبل النشر. تم اقتطاع النص المصدر في قسم 12 (المسؤولية) — الأقسام التالية غير مكتملة.
مرحبًا بكم في Waseel!
تحكم شروط الاستخدام هذه ("الشروط") استخدامك لتطبيقات Waseel للأجهزة المحمولة ومواقع الويب والمنتجات والمحتوى والميزات والمنصة (يُشار إليها مجتمعةً باسم - "المنصة").
عندما تؤكد قبولك لهذه الشروط أو عندما تستخدم المنصة بأي شكلٍ من الأشكال، فإنك تُبرم عقدًا معنا. وتعتمد الشركة التي تتعاقد معها على المكان الذي تقيم فيه.
وقد تنطبق شروط تكميلية على فئة معيّنة من Waseel. وتوضح سياسة الخصوصية لدينا ممارسات الخصوصية الخاصة بنا بالتفصيل. وتعد سياسة الامتثال الخاصة بنا (نظام إدارة السلامة) جزءًا من هذه الشروط. وعندما تقبل هذه الشروط، فأنت تقبلها أيضًا.
## 1. استقلالية السائقين واختيارات الركاب
### نموذج أعمالنا
تربط منصتنا مزودي خدمات النقل المستقلين من الأطراف الثالثة ("السائقين") وعملائهم ("الركاب") مع بعضهم البعض. وعندما يحجز الركاب رحلة، يعرضون السعر المناسب لهم مقابل خدمات السائق. ويمكن للسائقين الذين يرون الطلب إما الموافقة على السعر المعروض أو تقديم عرضهم الخاص.
وللراكب الحرية في اختيار السائق من قائمة السائقين الذين أبدوا اهتمامًا بقبول الطلب. وتُبرم اتفاقية منفصلة بين السائق والراكب عندما يؤكد الراكب الرحلة.
يجب على الراكب دفع السعر المتفق عليه للسائق من خلال المنصة. ويشمل هذا السعر المتفق عليه جميع الرسوم المرتبطة بالرحلة (الرسوم والمبالغ والجبايات والضرائب وما إلى ذلك). لا تتدخل Waseel ولا تؤثر بأي شكل على التسويات بين السائق والراكب.
### حالة Waseel
Waseel هي شركة تقنية لا تقدم خدمات النقل أو الخدمات اللوجستية أو خدمات البريد السريع أو أي خدمات أخرى ذات صلة ("الخدمات"). فهذه الخدمات يقدمها سائقون مستقلون باستخدام منصتنا. وأي قرار لعرض الخدمات أو قبولها هو قرار مستقل يُتخذ وفقًا لتقدير كل مستخدم وعلى مسؤوليته الخاصة. ولا تقوم Waseel بتوجيه السائقين أو فرض تعليمات عليهم بشكلٍ عام أو في تقديمهم للخدمات. ولا يشكل أي جهد نبذله لتحسين تجربتك عند استخدام منصتنا أي علاقة عمل أو وكالة مع أي مستخدم.
لا تلغي هذه الشروط أو تؤثر بأي شكلٍ على قابلية إنفاذ أي اتفاقيات قد يبرمها الركاب مع السائقين فيما يتعلق بالخدمات المقدمة.
### الرسوم والمدفوعات
قد تفرض Waseel على السائقين رسوم ترخيص مقابل استخدام المنصة. ويجوز لنا تغيير مبلغ رسوم الترخيص من وقت لآخر. وسيشكل استمرار استخدامك للمنصة موافقتك الضمنية على الرسوم المحدّثة.
تُفرض رسوم الترخيص على الطلبات المكتملة فقط. وتُسجل المدفوعات في حسابك الشخصي، ويمكنك العثور على المبلغ الحالي لرسوم الترخيص في حسابك.
يُحجز مبلغ رسوم الترخيص للطلب المكتمل في حسابك بمجرد تأكيد الطلب (أي عند قبولك عرض الراكب، أو عند الاتفاق على سعر الرحلة مع الراكب من خلال المنصة)، ويُحصّل عند إتمام الطلب. وتُرد المبالغ المحجوزة إلى حسابك إذا لم يتم إتمام الطلب، بما في ذلك في حال إلغاء الراكب للطلب أو عدم حضوره. قد تستغرق عمليات رد الأموال في هذه الحالات ما يصل إلى 30 يومًا بعد إلغاء الطلب ومراجعته من قبل Waseel.
إذا بقي لديك أموال غير مستخدمة في حسابك بعد مراسلتنا لحذف تطبيق Waseel وإلغاء الشروط، فيرجى إرسال نسخة من طلبك المكتوب لاسترداد الأموال غير المستخدمة إلى فريق الدعم لدينا، بالإضافة إلى تفاصيل الحساب المصرفي الذي تريد رد المبلغ إليه.
لا ترد Waseel المبالغ المستحقة نقدًا. وسيتم رد المبلغ المستحق في غضون 10 أيام عمل من استلام طلبك الكتابي لاسترداد الأموال غير المستخدمة.
وفي حال أصبح رصيد حساب السائق في تطبيق Waseel سالبًا، فيجب على السائق سداد كامل مبلغ الدين في غضون يومين تقويميين. وسيكون وصول السائق إلى تطبيق Waseel محدودًا حتى يتم تعبئة الرصيد بمقدار الدين، وخلال هذه الفترة لن يتمكن السائق من رؤية طلبات الرحلات من الركاب أو التفاوض معهم.
### عمليات رد المبالغ المدفوعة والمدفوعات المعكوسة
تحدث "عملية رد المبالغ المدفوعة" أو "المدفوعات المعكوسة" عندما يتم عكس مبلغ مدفوع مرتبط بالمنصة أو استرداده أو الاعتراض عليه — مثل إيداع رصيد في حسابك أو دفعة عولجت من خلال المنصة — من قبل البنك أو جهة إصدار البطاقة أو معالج الدفع، بما في ذلك الحالات التي يتم فيها الإبلاغ عن معاملة على أنها غير معترف بها أو غير مصرح بها أو احتيالية.
في حال استرداد مبلغ أودع في حسابك، أو طُبّق على رسوم الترخيص أو أي مبلغ آخر مستحق الدفع، فإنك تفوضنا بتخفيض أو عكس أو خصم المبلغ المقابل من رصيد حسابك. بقبولك هذه الشروط، توافق على هذه التعديلات، ويجوز لنا إجراؤها دون إشعار منفصل مسبق.
تقع على عاتق السائقين مسؤولية تحصيل ودفع جميع الضرائب المطبقة المرتبطة بالخدمات المقدمة من خلال المنصة. ولن تتحمل Waseel أي مسؤولية فيما يتعلق بأي معاملات بين الركاب والسائقين يحدث فيها مخالفات ضريبية.
يجوز لـ Waseel وفقًا لتقديرها الخاص وفي أي وقت تراه مناسبًا تقديم عروض ترويجية وخصومات وبرامج إحالة وبرامج ولاء (يشار إليها — "العروض") بميزات مختلفة لأي راكب أو سائق. وقد تؤثر هذه العروض أو تُطبَّق على مدفوعات الخدمة أو تكلفة الرحلة، مما يقلل المبالغ المحددة، وتُقدم في شكل مكافآت.
تكون هذه المكافآت صالحة للاستخدام فقط داخل تطبيق Waseel، ولا يمكن تحويلها أو استبدالها بمبالغ نقدية. ويجوز استخدام المكافآت لدفع رسوم الترخيص المترتبة على طلبات السائقين.
يمكن الحصول على مزيد من المعلومات ذات الصلة بالعروض في إشعار داخل التطبيق أو بأي وسيلة اتصال أخرى مذكورة في هذه الشروط. وفي الوقت نفسه، تحتفظ Waseel بالحق في حجز أو خصم المكافآت أو المزايا الأخرى التي تم الحصول عليها من خلال العروض إذا خلصت أو اعتقدت أن استخدام العرض أو الحصول على المكافآت تم عن طريق الخطأ أو بالاحتيال أو بشكل غير قانوني أو ينتهك العروض السارية أو هذه الشروط. كما تحتفظ Waseel بالحق في إنهاء أو إيقاف أو تعديل أو إلغاء أي عرض في أي وقت ووفقًا لتقديرها الخاص دون إشعار المستخدم.
## 2. حساب Waseel الخاص بك
### تسجيل الحساب
للوصول إلى وظائف منصتنا والبدء في استخدامها، يجب عليك إنشاء حساب لدينا. للتسجيل على هذه المنصة، يجب ألا يقل عمرك عن 18 عامًا أو تكون بلغت سن الرشد القانوني في بلدك (أيهما أكبر)، وتتوفر لديك الصلاحية اللازمة لإبرام عقد معنا واستخدام المنصة. وعند إنشاء الحساب، يجب عليك تقديم معلومات دقيقة وحديثة عن نفسك.
لمنع الاحتيال وضمان أمانك والامتثال لقوانين ولوائح مكافحة غسل الأموال والعقوبات (حسب مقتضى الحال)، سنطلب منك معلومات في وقت فتح حسابك للتحقق من هويتك. وقد نطلب منك أيضًا تحديث معلوماتك وتأكيدها من وقت لآخر.
نوفر أنواعًا مختلفة من الحسابات اعتمادًا على ما إذا كنت تستخدم المنصة بصفتك راكبًا أو سائقًا. ولإنشاء حساب سائق، يجب أن تزودنا بمعلومات إضافية وتجتاز عملية التحقق.
إذا كنت تستخدم المنصة في بلد آخر، فإنك توافق على الالتزام بشروط Waseel الخاصة بذلك البلد.
### الحفاظ على حسابك نشطًا
يجب عليك تحديث بياناتك على الفور في حال تغييرها. إذا غيّرت رقم هاتفك المحمول، فيرجى إخبارنا في أقرب وقت ممكن. وإذا لم تعد تستخدم رقمك، فقد يمنح مشغّل الهاتف المحمول لديك للتعميل إليه إلى شخص جديد يمكنه الوصول إلى حسابك إذا استخدم المنصة.
لا يجوز لك السماح للآخرين باستخدام حسابك. ويتعين عليك الحفاظ على أمان الوصول إلى جهازك وسرية معلومات تسجيل الدخول الخاصة بك. وستتحمل المسؤولية عن جميع الأنشطة التي تحدث في حسابك. إذا شككت في أن أي طرف ثالث يعرف كلمة المرور الخاصة بك أو يمكنه الوصول إلى حسابك، فيرجى إخبارنا من خلال التواصل معنا عبر دعم المستخدم.
### حذف الحساب
يمكنك حذف حسابك في أي وقت تريد. يمكنك القيام بذلك من خلال إعدادات التطبيق أو عبر الاتصال بخدمة دعم المستخدم. قد لا تتمكن في بعض الحالات من حذف حسابك، أو قد نحتفظ ببعض المعلومات لأغراض قانونية، مثل منع الاحتيال وضمان سلامة مستخدمينا أو الامتثال للالتزامات القانونية أو إدارة أو حل أي مطالبات أو نزاعات معلقة. يرجى الرجوع إلى سياسة الخصوصية الخاصة بنا لفهم كيفية معالجتنا لمعلوماتك بعد حذف الحساب.
قد تحذف الحسابات التي تظل غير نشطة لفترة تتجاوز 3 سنوات. كما نحتفظ بالحق في حذف حسابك أو تعليق الوصول إليه (راجع قسم "حقوق Waseel").
## 3. سلامتك
تُعد صحة مستخدمي Waseel وسلامتهم على رأس أولوياتنا. نعمل خطوات معقولة لضمان أن تظل المنصة بيئة آمنة لمستخدمينا. على سبيل المثال، نتحقق من مستندات جميع السائقين قبل السماح لهم بتقديم خدماتهم. بالإضافة إلى ذلك، قد نجري فحوصات عشوائية للتحقق من استخدام حساب السائق من قبل السائق المسجل، وأن السائق يستخدم السيارة المرتبطة بحساب السائق. كما قد نطلب من الركاب اجتياز فحص حيوي أو التحقق من هويتهم من خلال تقديم رقم هوية صادر عن جهة حكومية.
وعلى الرغم من قصارى جهدنا، فإننا نقر بمحدودية قدرة المنصة على الإنترنت في ضمان الأمان في وضع عدم الاتصال بالإنترنت. وليس لدينا أي تحكم في جودة أو سلامة النقل الناتجة عن تقديم الخدمات.
كما لا يمكننا ضمان أن يكون كل راكب أو سائق هو ما يدّعون. يرجى مراجعة صور السائق أو الراكب التي تراها على المنصة للتأكد من أنها نفس الشخص الذي تراه شخصيًا. لكن إذا لاحظت أن صورة السائق الذي وصل إليك تختلف عن صورة السائق في تطبيق Waseel، فيرجى إبلاغنا وسنتحقق ونعمل على ذلك.
نحثك على الانتباه والحذر عند التعامل مع المستخدمين الآخرين. أنت تستخدم خدمات السائق وتوفرها على مسؤوليتك الخاصة.
وفي حال وجود خطر مشتبه على الصحة أو السلامة، يرجى إبلاغ دعم المستخدم فورًا أو استخدام "زر SOS".
يحتوي زر SOS على خيارين:
- يتيح لك الاتصال بالشرطة في بلدك.
- يتيح لك مشاركة معلومات رحلتك مع رقم من جهات الاتصال الخاصة بك.
نعمل باستمرار على تحسين وتعديل أنظمة التحقق من المستخدم ووظائف تطبيقنا.
## 4. التزامات السائق
من خلال تقديم الخدمات كسائق، فإنك تقر وتضمن وتوافق على:
- لديك رخصة قيادة سارية وجميع التصاريح اللازمة لتقديم الخدمات، وأنت لائق صحياً لتقديمها؛
- تمتلك أو لديك الحق القانوني في قيادة السيارة التي تستخدمها لتقديم الخدمات؛ ويجب أن تكون هذه السيارة في حالة عمل جيدة وتفي بالمعايير والمتطلبات القانونية ومعايير السلامة؛
- ستقدم الخدمات فقط باستخدام السيارة التي أبلغتها في Waseel؛
- لن تسمح لأي شخص بمرافقتك في السيارة أثناء تقديم الخدمات؛
- لن تقدم الخدمات وأنت تحت تأثير الإرهاق أو الكحول أو المخدرات، أو تشترك بطريقة أخرى في سلوك غير آمن أو غير قانوني؛
- لن تقوم بالتمييز بين الركاب؛
- لن تطلب أي مدفوعات إضافية بالإضافة إلى السعر المتفق عليه مع الراكب من خلال المنصة؛
- ستكون مسؤولاً عن حساب جميع الضرائب المطبقة التي تنص عليها التشريعات في بلدك؛
- ستمتثل لطلباتنا المقبولة لتقديم المعلومات فيما يتعلق بالخدمات واستخدامك للمنصة؛
- لن تستخدم المعلومات التي حصلت عليها من خلال المنصة لأي غرض لا يتعلق باستخدام أو توفير خدمات السائق؛
- ستمتثل لمتطلبات جميع قوانين مكافحة غسل الأموال والعقوبات والفساد والرشوة ومكافحة التجارة غير المشروعة ومكافحة تمويل الإرهاب السارية؛
- ستمتثل لسياسات Waseel المطبقة في بلدك.
## 5. التواصل بين السائق والراكب
يجب أن تعامل مستخدمي Waseel الآخرين باحترام. ولا يجوز لك التواصل مع مستخدمين آخرين إلا للأغراض المتعلقة بتقديم الخدمات. كما يجب عليك عدم الكشف عن أي معلومات اتصال غير ضرورية. ويجب قطع الاتصال بعد اكتمال تقديم الخدمة، إلا إذا كان ذلك يتعلق بإعادة عنصر مفقود. وقد يُعتبر أي اتصال آخر مضايقة وقد يؤدي إلى تعليق حسابك أو إنهائه.
نمكن المستخدمين من التواصل على المنصة، مثال عبر التعليقات أو الدردشة داخل التطبيق أو المكالمات داخل التطبيق (قد يختلف توفر هذه الميزات حسب موقعك). ولدينا الحق في مراقبة وتسجيل اتصالاتك مع المستخدمين الآخرين للتحقق من الامتثال لهذه الشروط.
## 6. الاتصالات في Waseel
قد نرسل لك إشعارات فورية حول حسابك أو الخدمات التي تقدمها، وتحديثات حول Waseel والمنصة، وطلبات للمراجعات، واتصالات تسويقية. وقد نتواصل معك عبر البريد الإلكتروني والرسائل القصيرة والهاتف والإشعارات الفورية. أما بالنسبة لأنواع الاتصالات التي تتطلب موافقتك، فسنلتزم بالقوانين المحلية ونمنحك خيار إلغاء الاشتراك.
## 7. ما لا يمكنك فعله على المنصة
يمنع عليك استخدام المنصة من أجل:
- ممارسة أي أعمال غير قانونية؛
- ممارسة أي أعمال تنتهك هذه الشروط أو أي قواعد أخرى للمنصة وسياسات Waseel؛
- استخدام المنصة لأي غرض لا تغطيه هذه الشروط؛
- نقل أو بيع حسابك أو كلمة المرور أو هويتك إلى أي طرف آخر؛
- انتحال شخصية شخص آخر أو إخفاء هويتك أو استخدام أو محاولة استخدام حساب مستخدم آخر؛
- حث الآخرين على ممارسة أنشطة غير قانونية أو خطيرة؛
- مضايقة الآخرين أو تهديدهم أو التحرش بهم؛
- تحميل أي محتوى على المنصة غير دقيق أو غير مناسب أو ينتهك حقوق أي شخص (مثل الملكية الفكرية أو الخصوصية أو حقوق الشخصية) أو غير قانوني بطريقة أخرى؛
- تقويض تشغيل المنصة أو أمنها، ومحاولة الوصول غير المصرح به إلى المنصة أو الأنظمة أو الشبكات المرتبطة بها؛
- استخراج أي بيانات أو محتوى من المنصة؛
- إنشاء مسؤولية عن Waseel أو جعلنا خاضعين للتنظيم كشركة نقل أو مزود خدمة سيارات أجرة.
### مكافحة الاحتيال
يُحظر على المستخدمين الانخراط في أي نشاط يهدف إلى التحايل أو تجاوز أو التلاعب بوظائف المنصة أو عملياتها أو رسومها الطبيعية، ويشمل ذلك على سبيل المثال لا الحصر:
**التلاعب بميزات المنصة:** يُمنع استغلال أو اختراق أو التلاعب بميزات المنصة أو وظائفها أو خوارزمياتها بهدف تشويه تجربة المستخدم المقصودة أو نموذج أعمال المنصة.
**استخدام التطبيقات والتعديلات الخارجية غير المصرح بها:** لا يجوز استخدام أي تطبيقات خارجية أو برامج أو أدوات غير مصرح بها من شأنها تعديل أو التدخل في أو تغيير الوظائف الطبيعية للمنصة.
**التواطؤ:** يُحظر التآمر مع مستخدمين آخرين أو أطراف ثالثة لدفع حصان قواعد المنصة، مثل الاتفاق على إلغاء الطلبات أو تقديم معلومات كاذبة أو مضللة.
**إساءة استخدام المدفوعات أو عمليات رد المبالغ أو الاسترداد:** يمنع إساءة استخدام آليات الدفع أو الإيداع أو عمليات رد المبالغ أو الاسترداد، بما في ذلك الإبلاغ عن معاملة مشروعة على أنها غير معترف بها أو احتيالية، أو إجراء ردود المبالغ بسوء نية للحصول على الخدمات أو الأموال دون دفع.
يؤدي أي انتهاك لأحكام مكافحة الاحتيال إلى فرض عقوبات تشمل الحظر المؤقت أو الدائم أو إجراءات أخرى، بناءً على جسامة المخالفة.
ونحتفظ، وفقًا لتقديرنا المعقول، بالحق في فرض رسوم ترخيص على أي طلب منفّذ فعليًا لم تُدفع رسومه الترخيصية المستحقة نتيجة لأي تلاعب أو تواطؤ أو انتهاك آخر لشروط المنصة. كما نحتفظ، وفقًا لتقديرنا المعقول، بالحق في تخفيض أو عكس أو خصم المبالغ من رصيد حسابك بما يتوافق مع "عمليات رد المبالغ المدفوعة والمدفوعات المعكوسة" في القسم 1.
يهدف هذا النظام إلى ضمان بيئة عادلة وشفافة لجميع المستخدمين والحفاظ على نزاهة العمليات التجارية للمنصة.
## 8. حقوق Waseel
لدينا الحق في التحقيق في أي انتهاك مزعوم لهذه الشروط. وعند القيام بذلك، يجوز لنا تعليق وصولك إلى بعض أو كل ميزات المنصة، والتصرف بشكل معقول وموضوعي، اعتمادًا على خطورة الانتهاك المزعوم.
ثم بعد ذلك، قد نقرر تعليق حسابك مؤقتًا أو بشكل دائم أو إنهائه أو فرض قيود على وصولك إلى ميزات المنصة في الحالات التالية:
- أن نحدد، بعمق وموضوعية وبشكل لا لقولي، أنك تقوم بانتهاك مادي أو متكرر لهذه الشروط أو قواعد وسياسات Waseel الأخرى؛
- لدينا أسباب للاعتقاد بشكل لا لبس فيه أنك على وشك انتهاك هذه الشروط بشكل خطير؛
- نحن مطالبون قانونًا بذلك؛
- مطلوب بشكل معقول للاستجابة لمشكلة تقنية أو أمنية أو تتعلق بالخصوصية.
إذا علقنا حسابك في وقت سابق لانتهاك هذه الشروط، ثم عدت إلى استخدام منصتنا مرة أخرى (مثل فتح حساب آخر)، فيحق لنا تعليق أو إنهاء جميع هذه الحسابات.
إذا كنت تعتقد أننا ارتكبنا خطأ في تعليق حسابك أو إنهائه، يمكنك استئناف ذلك عبر خدمة دعم المستخدم.
## 9. المحتوى الخاص بك
أنت مسؤول عن المعلومات والملفات والصور (يُشار إليها مجتمعةً — "المحتوى") التي تنشرها على المنصة. يجب عليك التأكد من أن المحتوى الخاص بك لا ينتهك القوانين أو حقوق أي شخص آخر. لسنا ملزمين بمراجعة محتوى المستخدم ولا نتحمل أي مسؤولية عنه. يجوز لنا إزالة أو تقييد الوصول إلى أي محتوى نعتقد أنه ينتهك هذه الشروط أو يسبب ضررًا لـ Waseel أو مستخدمينا أو الأطراف الثالثة.
نحن لا نملك المحتوى الخاص بك. ومن خلال إتاحة المحتوى على المنصة، تمنح Waseel ترخيصًا دائمًا وغير قابل للإلغاء وعالميًا وخاليًا من حقوق الملكية وغير حصري لاستخدام المحتوى الخاص بك، بما في ذلك إعادة إنتاج أو اقتباس أو إنشاء أعمال مشتقة منه وتنفيذه وإتاحته للجمهور، لأغراض تشغيل المنصة وتطويرها وتوفيرها.
## 10. الملكية الفكرية
تحتوي المنصة على محتوى (مثل التصميمات والصور والأصوات والنصوص وقواعد البيانات ورموز الحاسوب والعلامات التجارية وغيرها من العناصر المماثلة) مملوكة أو مرخصة من قبل Waseel وهي محمية بموجب حقوق النشر والعلامات التجارية وبراءات الاختراع والأسرار التجارية وغيرها من القوانين. تمتلك Waseel والمرخص لهم جميع الحقوق وحقوق الملكية والمصالح، بما في ذلك حقوق الملكية الفكرية ذات الصلة في المنصة (البرنامج أو التطبيق أو كليهما) والخدمة وأي اقتراحات وأفكار وطلبات للتحسين أو المراجعات أو التوصيات أو المعلومات الأخرى التي تقدمها.
تمنحك Waseel ترخيصًا محدودًا وغير حصري وغير قابل للتحويل أو التنازل وقابل للإلغاء من أجل: (أ) الوصول إلى المنصة واستخدامها على جهازك الشخصي لغرض وحيد هو استخدام المنصة؛ (ب) الوصول وعرض أي محتوى أو مواد متاحة من خلال المنصة، في كل حالة لاستخدامك الشخصي غير التجاري فقط. جميع الحقوق غير الممنوحة لك هنا محفوظة لـ Waseel أو لمرخصها.
لا يجوز لك، أو لا تسمح لأي طرف آخر بـ: (أ) تعديل أو إعادة إنتاج أو إنشاء أعمال مشتقة من المنصة؛ (ب) إجراء هندسة عكسية أو إلغاء تجميع أو تفكيك أو محاولة اكتشاف أو تغيير الكود المصدري للمنصة لإنشاء منتج أو خدمة منافسة؛ (ج) تأطير أو ربط أو عكس أي جزء من المنصة على أي خادم آخر أو جهاز متصل؛ (د) نشر أو توزيع أو إعادة إنتاج أي مواد محمية بحقوق النشر أو العلامات أو معلومات مملة لـ Waseel بأي شكل دون موافقة مسبقة.
## 11. التعويض
توافق على الدفاع عن Waseel والشركات التابعة لها ومسؤوليها وموظفيها ووكلائها وتعويضها وحمايتها من أي وجميع المطالبات والطلبات والأضرار والمسؤوليات والنفقات (بما في ذلك أتعاب المحاماة المعقولة) الناشئة عن أو فيما يتعلق بـ: (أ) استخدامك للمنصة أو الخدمات؛ (ب) انتهاكك أو إخلالك بأي من هذه الشروط أو أي قانون أو لائحة سارية أو حقوق أي طرف ثالث.
## 12. المسؤولية
### مسؤولية Waseel
دون تقييد القوانين واللوائح السارية، يُستبعد بموجب هذا، وإلى أقصى حد تسمح به القوانين، أي إقرارات وضمانات، صريحة أو ضمنية أو قانونية، بما في ذلك أي ضمان ضمني للقابلية للتسويق أو الملاءمة لغرض معين أو عدم انتهاك حقوق الآخرين.
في حدود ما تسمح به القوانين السارية، لن تكون Waseel بأي حال مسؤولة تجاهك أو تجاه أي شخص عن أي أضرار أو خسائر مباشرة أو غير مباشرة أو تأديبية أو اقتصادية أو مستقبلية أو خاصة أو نموذجية أو عراضية أو تابعة أو غيرها من الأضرار أو الخسائر من أي نوع كانت (بما في ذلك دون حصر الإصابة الشخصية والاضطراب العاطفي وفقدان البيانات أو السلع أو الإيرادات أو الأرباح أو الاستخدام أو أي منفعة اقتصادية أخرى)، سواء نشأت عن العقد أو الضرر (بما في ذلك الإهمال) أو نشأت عن المنصة أو ارتبطت بها بأي شكل، بما في ذلك دون حصر استخدام المنصة أو عدم القدرة على استخدامها، أو أي تعويل منك على اكتمال أو دقة أو وجود أي إعلان، أو نتيجة لأي علاقة أو معاملة بينك وبين أي سائق، حتى لو حُذرت Waseel مسبقًا من احتمال حدوث مثل هذا الضرر.
### خدمات الطرف الثالث
لا توجد أي علاقة مشروع مشترك أو شراكة أو توظيف أو وكالة بين Waseel وأي من مستخدمينا. وإلى الحد الأقصى الذي يسمح به القانون...
---
*نهاية النص المُقدَّم. النص المصدر اقتُطع في هذا الموضع — الأقسام المتبقية (استكمال حدود المسؤولية، حل النزاعات، القانون الحاكم، التعديلات على الشروط، الإتصال، تاريخ السريان، إلخ) لم تُقدَّم ويلزم إكمالها.*
+130
View File
@@ -0,0 +1,130 @@
import {
createContext,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import { useSettingsStore, type Lang } from "@/lib/settings";
import { ar } from "@/lib/translations/ar";
import { en } from "@/lib/translations/en";
import { fr } from "@/lib/translations/fr";
export type Vars = Record<string, string | number>;
/**
* Lightweight i18n. No external dependency: three languages, dot-path keys,
* `{var}` interpolation, and a `one`/`other`/`zero` plural convention that
* covers the few counts this app shows (minutes, seats).
*/
const DICTS: Record<Lang, Record<string, unknown>> = { en, ar, fr };
const interpolate = (text: string, vars?: Vars): string =>
vars
? text.replace(/\{(\w+)\}/g, (_, key: string) => String(vars[key] ?? ""))
: text;
const readPath = (dict: Record<string, unknown>, path: string): unknown =>
path.split(".").reduce<unknown>(
(acc, segment) =>
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[segment]
: undefined,
dict,
);
const translate = (
lang: Lang,
key: string,
vars?: Vars,
count?: number,
): string => {
const dict = DICTS[lang] ?? en;
if (count !== undefined) {
const pluralSuffix =
count === 0 ? "zero" : count === 1 ? "one" : "other";
const pluralValue = readPath(dict, `${key}.${pluralSuffix}`);
if (typeof pluralValue === "string") {
return interpolate(pluralValue, { n: count, ...vars });
}
}
const value = readPath(dict, key);
if (typeof value === "string") return interpolate(value, vars);
// Fall back to English, then to the key itself, so a missing translation
// never renders an empty string.
if (lang !== "en") {
const fallback = readPath(en, key);
if (typeof fallback === "string") return interpolate(fallback, vars);
}
return key;
};
/**
* Module-level translator for non-React helpers (`lib/utils.ts`,
* `lib/pricing.ts`) that can't call `useT`. The I18nProvider sets this on
* mount and whenever the language changes; until then it defaults to English.
*/
let activeLang: Lang = "en";
export const setTranslator = (lang: Lang) => {
activeLang = lang;
};
export const tr = (key: string, vars?: Vars, count?: number): string =>
translate(activeLang, key, vars, count);
type I18nContextValue = {
lang: Lang;
isRTL: boolean;
t: (key: string, vars?: Vars, count?: number) => string;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export const I18nProvider = ({ children }: { children: ReactNode }) => {
const lang = useSettingsStore((state) => state.lang);
useEffect(() => {
setTranslator(lang);
}, [lang]);
const value = useMemo<I18nContextValue>(
() => ({
lang,
isRTL: lang === "ar",
t: (key, vars, count) => translate(lang, key, vars, count),
}),
[lang],
);
return (
<I18nContext.Provider value={value}>{children}</I18nContext.Provider>
);
};
export const useT = (): I18nContextValue["t"] => {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useT must be used within I18nProvider.");
}
return context.t;
};
export const useI18n = (): I18nContextValue => {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within I18nProvider.");
}
return context;
};
+125 -23
View File
@@ -1,7 +1,10 @@
import type { MaterialCommunityIcons } from "@expo/vector-icons";
// Google Places (New) Nearby Search — powers the "nearby mall / hospital /
// pharmacy / restaurant" destination chips on the home screen. Reuses the same
// API key and header pattern as the autocomplete in components/google-text-input.
import { tr } from "@/lib/i18n";
import { haversine } from "@/lib/utils";
import type { NearbyPlace } from "@/types/type";
@@ -11,25 +14,83 @@ const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!;
// Google Places (New) `includedTypes` value.
export type PoiCategory = {
id: "mall" | "hospital" | "pharmacy" | "restaurant";
label: string;
/** MaterialCommunityIcons glyph name. */
icon: string;
/** i18n key for the chip label. */
labelKey: string;
/** MaterialCommunityIcons glyph name. Typed so a bad name fails the build
* rather than warning at runtime and rendering nothing. */
icon: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
googleType: string;
};
export const POI_CATEGORIES: PoiCategory[] = [
{ id: "mall", label: "Mall", icon: "shopping-mall", googleType: "shopping_mall" },
{ id: "hospital", label: "Hospital", icon: "hospital", googleType: "hospital" },
{ id: "pharmacy", label: "Pharmacy", icon: "pill", googleType: "pharmacy" },
{ id: "restaurant", label: "Restaurant", icon: "silverware-fork-knife", googleType: "restaurant" },
{
id: "mall",
labelKey: "pois.mall",
icon: "storefront",
googleType: "shopping_mall",
},
{
id: "hospital",
labelKey: "pois.hospital",
icon: "hospital",
googleType: "hospital",
},
{
id: "pharmacy",
labelKey: "pois.pharmacy",
icon: "pill",
googleType: "pharmacy",
},
{
id: "restaurant",
labelKey: "pois.restaurant",
icon: "silverware-fork-knife",
googleType: "restaurant",
},
];
const DEFAULT_RADIUS_M = 4000;
// Nearby Search ranks by popularity unless told otherwise, which put a mall a
// 29-minute drive away ahead of one 6 minutes away. Rank by distance instead,
// then re-rank the closest few by their actual driving route: straight-line
// distance is a poor proxy in Lebanon's mountain terrain, where a place 2.9 km
// away as the crow flies can be a 17.9 km drive around a valley. Each candidate
// costs one Directions call, so keep the set small.
const ROUTE_CANDIDATES = 3;
type DrivingRoute = { distanceMeters: number; durationSeconds: number };
// Road distance/time between two points. Returns null when Google finds no
// route (ZERO_RESULTS) or the request fails, so callers can fall back.
const fetchDrivingRoute = async (
from: { latitude: number; longitude: number },
to: { latitude: number; longitude: number },
): Promise<DrivingRoute | null> => {
try {
const res = await fetch(
`https://maps.googleapis.com/maps/api/directions/json?origin=${from.latitude},${from.longitude}&destination=${to.latitude},${to.longitude}&mode=driving&key=${googleApiKey}`,
);
const data = await res.json();
const leg = data.routes?.[0]?.legs?.[0];
if (!leg) return null;
return {
distanceMeters: leg.distance.value,
durationSeconds: leg.duration.value,
};
} catch (error) {
console.log("[PLACES_ROUTE]: ", error);
return null;
}
};
// Searches for the nearest place of `googleType` around (latitude, longitude)
// and returns it as a NearbyPlace with its distance from the rider. Returns
// null when no place of that type is found nearby — the chip then shows an
// empty state rather than a broken one.
// and returns it as a NearbyPlace carrying its real driving distance and time.
// "Nearest" means nearest by road, not by straight line. Returns null when no
// place of that type is found nearby — the chip then shows an empty state
// rather than a broken one.
export const searchNearby = async (
googleType: string,
{
@@ -53,6 +114,8 @@ export const searchNearby = async (
includedTypes: [googleType],
languageCode: "en",
regionCode: "lb",
rankPreference: "DISTANCE",
maxResultCount: ROUTE_CANDIDATES,
locationRestriction: {
circle: {
center: { latitude, longitude },
@@ -63,24 +126,63 @@ export const searchNearby = async (
},
);
const data = await res.json();
const place = data.places?.[0];
if (!place) return null;
const lat = place.location?.latitude as number;
const lng = place.location?.longitude as number;
const candidates = ((data.places ?? []) as Record<string, any>[])
.map((place) => {
const lat = place.location?.latitude as number;
const lng = place.location?.longitude as number;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return {
name: (place.displayName?.text as string) ?? tr("pois.nearbyPlace"),
address: (place.formattedAddress as string) ?? "",
latitude: lat,
longitude: lng,
distanceMeters: haversine(latitude, longitude, lat, lng),
};
})
.filter(
(candidate): candidate is NonNullable<typeof candidate> =>
candidate !== null,
);
if (!candidates.length) return null;
const routes = await Promise.all(
candidates.map((candidate) =>
fetchDrivingRoute({ latitude, longitude }, candidate),
),
);
const routed = candidates
.map((candidate, index) => ({ candidate, route: routes[index] }))
.filter(
(
entry,
): entry is {
candidate: (typeof candidates)[number];
route: DrivingRoute;
} => entry.route !== null,
);
// Every candidate unreachable, or Directions failed for all of them: show
// the straight-line nearest rather than dropping the chip entirely.
if (!routed.length) return candidates[0];
const best = routed.reduce((shortest, entry) =>
entry.route.distanceMeters < shortest.route.distanceMeters
? entry
: shortest,
);
return {
name: (place.displayName?.text as string) ?? "Nearby place",
address: (place.formattedAddress as string) ?? "",
latitude: lat,
longitude: lng,
distanceMeters:
Number.isFinite(lat) && Number.isFinite(lng)
? haversine(latitude, longitude, lat, lng)
: undefined,
...best.candidate,
routeDistanceMeters: best.route.distanceMeters,
routeDurationSeconds: best.route.durationSeconds,
};
} catch (error) {
console.log("[PLACES_NEARBY]: ", error);
return null;
}
};
};
+3 -1
View File
@@ -5,6 +5,7 @@
// L.B.P. equivalent shown for cash settlement.
import { DEFAULT_SERVICE, SERVICES, type ServiceId } from "@/constants/services";
import { tr } from "@/lib/i18n";
export const FARE = {
base: 1.5, // USD, flag drop
@@ -38,5 +39,6 @@ export const calculateFare = (
};
// Rounds to the nearest 1,000 L.B.P. — the smallest practical cash note.
// The currency suffix is localized (" L.B.P." / " ل.ل." / " LBP").
export const formatLBP = (usd: number): string =>
`${(Math.round((usd * LBP_RATE) / 1000) * 1000).toLocaleString("en-US")} L.B.P.`;
`${(Math.round((usd * LBP_RATE) / 1000) * 1000).toLocaleString("en-US")}${tr("units.lbpSuffix")}`;
+53
View File
@@ -0,0 +1,53 @@
import {
activateKeepAwakeAsync,
deactivateKeepAwake,
} from "expo-keep-awake";
import { I18nManager } from "react-native";
import { useEffect, type ReactNode } from "react";
import { useSettingsStore } from "@/lib/settings";
const KEEP_AWAKE_TAG = "waseel-settings";
/**
* Boots the settings store and owns the two cross-cutting side-effects that
* must run for the whole app lifetime regardless of which screen is mounted:
* the keep-awake wake lock (imperative, so it survives navigation away from
* Settings) and the Arabic RTL flip.
*/
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const hydrated = useSettingsStore((state) => state.hydrated);
const keepAwake = useSettingsStore((state) => state.keepAwake);
const lang = useSettingsStore((state) => state.lang);
const hydrate = useSettingsStore((state) => state.hydrate);
useEffect(() => {
void hydrate();
}, [hydrate]);
useEffect(() => {
if (keepAwake) {
activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch((error) =>
console.warn("[KEEP_AWAKE]: ", error),
);
} else {
deactivateKeepAwake(KEEP_AWAKE_TAG);
}
}, [keepAwake]);
useEffect(() => {
const shouldRTL = lang === "ar";
if (I18nManager.isRTL !== shouldRTL) {
I18nManager.forceRTL(shouldRTL);
I18nManager.swapLeftAndRightInRTL(shouldRTL);
}
}, [lang]);
// Until SecureStore has been read we'd otherwise flash the defaults
// (English / light) before the persisted values apply. The root layout
// already gates on fonts; gating here too avoids the theme/lang flash.
if (!hydrated) return null;
return <>{children}</>;
};
+97
View File
@@ -0,0 +1,97 @@
import * as SecureStore from "expo-secure-store";
import { create } from "zustand";
/**
* Theme, language, keep-awake, and the overlay-permission flag are the only
* non-secret app preferences. SecureStore is used (not AsyncStorage) because
* AsyncStorage isn't actually installed in this project and SecureStore already
* backs the session one small JSON blob stays well under its 2 KB value cap.
*/
const SETTINGS_KEY = "waseel_settings";
export type ThemeMode = "light" | "dark" | "system";
export type Lang = "en" | "ar" | "fr";
type PersistedSettings = Pick<
SettingsState,
"mode" | "lang" | "keepAwake" | "overlayRequested"
>;
type SettingsState = {
/** False until hydrate() has merged SecureStore values into the store. */
hydrated: boolean;
mode: ThemeMode;
lang: Lang;
keepAwake: boolean;
/**
* Only records that we sent the user to the system screen SYSTEM_ALERT_WINDOW
* can't be queried from JS, so this is not a granted/denied signal.
*/
overlayRequested: boolean;
setMode: (mode: ThemeMode) => void;
setLang: (lang: Lang) => void;
setKeepAwake: (value: boolean) => void;
setOverlayRequested: (value: boolean) => void;
hydrate: () => Promise<void>;
};
const write = async (state: SettingsState) => {
const blob: PersistedSettings = {
mode: state.mode,
lang: state.lang,
keepAwake: state.keepAwake,
overlayRequested: state.overlayRequested,
};
await SecureStore.setItemAsync(SETTINGS_KEY, JSON.stringify(blob));
};
export const useSettingsStore = create<SettingsState>((set, get) => ({
hydrated: false,
mode: "system",
lang: "en",
keepAwake: false,
overlayRequested: false,
setMode: (mode) => {
set({ mode });
void write(get());
},
setLang: (lang) => {
set({ lang });
void write(get());
},
setKeepAwake: (value) => {
set({ keepAwake: value });
void write(get());
},
setOverlayRequested: (value) => {
set({ overlayRequested: value });
void write(get());
},
hydrate: async () => {
try {
const stored = await SecureStore.getItemAsync(SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<PersistedSettings>;
set({
mode: parsed.mode ?? "system",
lang: parsed.lang ?? "en",
keepAwake: parsed.keepAwake ?? false,
overlayRequested: parsed.overlayRequested ?? false,
hydrated: true,
});
return;
}
} catch (error) {
console.warn("[SETTINGS hydrate]: ", error);
}
set({ hydrated: true });
},
}));
+64
View File
@@ -0,0 +1,64 @@
import { StatusBar } from "expo-status-bar";
import { Appearance, useColorScheme } from "react-native";
import {
createContext,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import { useSettingsStore, type ThemeMode } from "@/lib/settings";
type ThemeContextValue = {
/** "light" | "dark" — the scheme actually in effect (honors our override). */
colorScheme: "light" | "dark";
isDark: boolean;
mode: ThemeMode;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
/**
* Drives the whole-app theme.
*
* NativeWind's `dark:` variants read the React Native `Appearance` module, so
* calling `Appearance.setColorScheme` with our chosen mode is what makes every
* `dark:` class apply app-wide no `darkMode` config key is needed (the
* default `media` strategy already reads Appearance). The inline-styled spots
* that can't use Tailwind classes (map style, BottomSheet, tab bar, spinners)
* consume `useTheme().isDark` instead.
*/
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const mode = useSettingsStore((state) => state.mode);
useEffect(() => {
// `null` clears the override and falls back to the device scheme.
Appearance.setColorScheme(mode === "system" ? null : mode);
}, [mode]);
const deviceScheme = useColorScheme();
const resolved = deviceScheme === "dark" ? "dark" : "light";
const value = useMemo<ThemeContextValue>(
() => ({ colorScheme: resolved, isDark: resolved === "dark", mode }),
[resolved, mode],
);
return (
<ThemeContext.Provider value={value}>
{children}
<StatusBar style={resolved === "dark" ? "light" : "dark"} />
</ThemeContext.Provider>
);
};
export const useTheme = (): ThemeContextValue => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider.");
}
return context;
};
+485
View File
@@ -0,0 +1,485 @@
export const ar = {
common: {
cancel: "إلغاء",
continue: "متابعة",
back: "رجوع",
retry: "حاول مجددًا",
loading: "جارٍ التحميل…",
saving: "جارٍ الحفظ…",
error: "خطأ",
save: "حفظ",
or: "أو",
browseHome: "الصفحة الرئيسية",
backHome: "العودة للرئيسية",
openSettings: "فتح الإعدادات",
yourLocation: "موقعك",
},
onboarding: {
skip: "تخطٍّ",
next: "التالي",
getStarted: "ابدأ الآن",
slide1: {
title: "رحلتك المثالية على بُعد نقرة واحدة!",
desc: "تبدأ رحلتك مع وصّيل. اعثر على رحلتك المثالية بكل سهولة.",
},
slide2: {
title: "أفضل سيارة بين يديك مع وصّيل",
desc: "اكتشف سهولة العثور على رحلتك المثالية مع وصّيل",
},
slide3: {
title: "رحلتك بطريقتك. لننطلق!",
desc: "أدخل وجهتك، وارتَح، ودعنا نتولّى الباقي.",
},
},
auth: {
signIn: {
welcome: "أهلاً 👋",
email: "البريد الإلكتروني",
emailPlaceholder: "karim@email.com",
password: "كلمة المرور",
passwordPlaceholder: "••••••••",
keepSignedIn: "إبقائي مسجّلاً",
signingIn: "جارٍ تسجيل الدخول…",
signInBtn: "تسجيل الدخول",
signInGoogle: "تسجيل الدخول عبر Google",
forgotPassword: "نسيت كلمة المرور؟",
noAccount: "ليس لديك حساب؟ ",
signUpLink: "إنشاء حساب",
carAlt: "سيارة",
reset: {
title: "إعادة تعيين كلمة المرور",
requestBody:
"أدخل بريدك الإلكتروني وسنرسل لك رمز إعادة تعيين مكوّن من 6 أرقام.",
emailPlaceholder: "karim@email.com",
sending: "جارٍ الإرسال…",
sendCode: "إرسال الرمز",
newPassTitle: "أدخل كلمة المرور الجديدة",
resetBody: "أرسلنا رمز إعادة التعيين إلى {email}",
devBanner:
"إرسال البريد غير مُفعّل على هذا الخادم. رمز إعادة التعيين هو ",
newPassLabel: "كلمة المرور الجديدة",
newPasswordPlaceholder: "••••••••",
resetting: "جارٍ إعادة التعيين…",
resetBtn: "إعادة تعيين كلمة المرور",
},
alertMissingTitle: "معلومات ناقصة",
alertMissingBody: "أدخل بريدك الإلكتروني وكلمة المرور.",
alertErrorTitle: "خطأ",
alertErrorFallback: "تعذّر تسجيل الدخول. حاول مرة أخرى.",
errEmail: "أدخل بريدك الإلكتروني.",
errCode: "أدخل الرمز المكوّن من 6 أرقام.",
errPassword: "يجب أن تكون كلمة المرور 8 أحرف على الأقل.",
errReset: "تعذّر إعادة تعيين كلمة المرور. حاول مرة أخرى.",
},
signUp: {
createAccount: "أنشئ حسابك",
howUse: "كيف ستستخدم وصّيل؟",
riderTitle: "أحتاج رحلة",
riderDesc: "احجز رحلات وصل إلى وجهتك",
driverTitle: "أريد القيادة",
driverDesc: "قدّم رحلات واكسب المال بسيارتك",
carAlt: "سيارة",
name: "الاسم",
namePlaceholder: "كريم حداد",
email: "البريد الإلكتروني",
emailPlaceholder: "karim@email.com",
phoneOptional: "الهاتف (اختياري)",
phonePlaceholder: "70 123 456",
password: "كلمة المرور",
passwordPlaceholder: "••••••••",
signUpBtn: "إنشاء حساب",
signUpGoogle: "التسجيل عبر Google",
haveAccount: "هل لديك حساب بالفعل؟ ",
signInLink: "تسجيل الدخول",
verify: {
title: "التحقق",
body: "أرسلنا رمز تحقق إلى {email}",
devBanner: "إرسال البريد غير مُفعّل على هذا الخادم. رمز التحقق هو ",
verifying: "جارٍ التحقق…",
verifyBtn: "تحقق من البريد",
checkAlt: "تحقق",
},
verified: {
title: "تم التحقق",
body: "تم التحقق من حسابك بنجاح.",
},
alertMissingTitle: "معلومات ناقصة",
alertMissingBody: "يرجى ملء الاسم والبريد الإلكتروني وكلمة المرور.",
alertPhoneTitle: "رقم هاتف غير صالح",
alertPhoneBody: "أدخل رقمًا لبنانيًا صالحًا، مثلاً 70 123 456.",
alertErrorTitle: "خطأ",
alertErrorFallback: "تعذّر إنشاء حسابك.",
errCode: "أدخل الرمز المكوّن من 6 أرقام.",
errVerify: "فشل التحقق. حاول مرة أخرى.",
},
role: {
title: "كيف ستستخدم وصّيل؟",
subtitle: "يمكنك تغيير ذلك لاحقًا بالتواصل مع الدعم.",
riderTitle: "أنا راكب",
riderDesc: "احجز رحلات وتنقّل في لبنان",
driverTitle: "أنا سائق",
driverDesc: "قدّم رحلات واكسب المال",
alertErrorTitle: "خطأ",
alertErrorBody: "تعذّر حفظ اختيارك. حاول مرة أخرى.",
},
},
home: {
welcome: "أهلاً {name} 👋",
logoutAlt: "تسجيل الخروج",
currentLocation: "موقعك الحالي",
findingLocation: "جارٍ العثور على موقعك…",
whatNeed: "بماذا تحتاج؟",
recentRides: "الرحلات الأخيرة",
noRecent: "لا توجد رحلات حديثة.",
noRecentAlt: "لا توجد رحلات حديثة",
},
rides: {
allRides: "كل الرحلات",
noRecent: "لا توجد رحلات حديثة.",
noRecentAlt: "لا توجد رحلات حديثة",
},
tabs: {
home: "الرئيسية",
rides: "الرحلات",
chat: "الدردشة",
profile: "الملف",
settings: "الإعدادات",
},
chat: {
title: "الدردشة",
messageAlt: "رسالة",
noMessages: "لا توجد رسائل بعد",
startConversation: "ابدأ محادثة مع أصدقائك وعائلتك",
},
profile: {
title: "ملفي الشخصي",
avatarAlt: "صورتك الشخصية",
firstName: "الاسم الأول",
firstNamePlaceholder: "اسمك الأول",
lastName: "اسم العائلة",
lastNamePlaceholder: "اسم عائلتك",
email: "البريد الإلكتروني",
emailPlaceholder: "بريدك الإلكتروني",
},
findRide: {
title: "الرحلة",
from: "من",
to: "إلى",
findNow: "ابحث الآن",
},
confirmRide: {
title: "طلب رحلة",
yourTrip: "رحلتك",
pickup: "نقطة الصعود",
destination: "الوجهة",
tripTime: "زمن الرحلة {time}",
paymentMethod: "طريقة الدفع",
cash: "💵 نقدًا",
card: "💳 بطاقة",
fareDisplay: "${fare}",
lbpEstimate: "≈ {lbp}",
noDrivers: "لا يوجد سائقو {service} متصلون الآن",
findingDrivers: "جارٍ البحث عن سائقين قريبين…",
nearestDriver: "أقرب سائق ≈ {eta} دقيقة",
requesting: "جارٍ الطلب…",
noDriversOnline: "لا يوجد سائقون متصلون",
requestCash: "اطلب رحلة · ادفع نقدًا للسائق",
requestCard: "اطلب رحلة · ادفع بالبطاقة",
alertMissingRouteTitle: "المسار ناقص",
alertMissingRouteBody: "يرجى تحديد نقطة الصعود والوجهة أولاً.",
alertNoEstimateTitle: "لا يوجد تقدير",
alertNoEstimateBody: "تعذّر تقدير الأجرة. حاول مرة أخرى.",
alertErrorTitle: "خطأ",
alertErrorFallback: "حدث خطأ أثناء حجز رحلتك. حاول مرة أخرى.",
alertPayCardTitle: "الدفع بالبطاقة",
alertPayCardBody: "سيتم خصم ${fare} من بطاقتك.",
},
bookRide: {
status: {
requested: "جارٍ البحث عن سائقك…",
accepted: "تم تعيين سائق — في طريقه إليك",
enRoute: "أنت في الرحلة",
completed: "وصلت!",
cancelled: "تم إلغاء الرحلة",
},
rideNotFound: "الرحلة غير موجودة.",
couldNotLoad: "تعذّر تحميل هذه الرحلة.",
backHome: "العودة للرئيسية",
matchingDriver: "نطابقك مع أقرب سائق {service}.",
ratingFallback: "—",
paymentCash: "💵 نقدًا للسائق",
paymentCard: "💳 مدفوع بالبطاقة",
fare: "الأجرة: ${fare}",
tripTime: "زمن الرحلة {time}",
rideCancelled: "تم إلغاء هذه الرحلة.",
cancelRide: "إلغاء الرحلة",
cancelling: "جارٍ الإلغاء…",
alertErrorTitle: "خطأ",
alertErrorBody: "تعذّر إلغاء هذه الرحلة. حاول مرة أخرى.",
},
driver: {
home: {
signOutAlt: "تسجيل الخروج",
driverMode: "وضع السائق",
online: "● متصل — يتلقّى طلبات الرحلات",
goOnline: "○ اتصل لتقديم الرحلات",
todaysEarnings: "أرباح اليوم",
completedToday: "المكتملة اليوم",
incomingRequests: "الطلبات الواردة",
incomingRequestsOffline: "الطلبات الواردة (غير متصل)",
waitingRequests: "بانتظار طلبات الرحلات…",
goOnlineStart: "اتصل لبدء القيادة.",
welcome: "أهلاً {name}",
welcomeFallback: "سائق",
setupIntro: "أكمل ملف السائق لبدء تلقّي طلبات الرحلات.",
whatDrive: "ماذا ستقود؟",
carModel: "موديل السيارة",
carModelPlaceholder: "مثال: Toyota Camry",
carSeats: "عدد المقاعد",
carSeatsPlaceholder: "4",
saving: "جارٍ الحفظ…",
startDriving: "ابدأ القيادة",
alertSeatsTitle: "عدد مقاعد غير صالح",
alertSeatsBody: "يجب أن يكون عدد المقاعد عددًا صحيحًا بين 1 و8.",
alertErrorTitle: "خطأ",
alertCreateBody: "تعذّر إنشاء ملف السائق. حاول مرة أخرى.",
alertToggleBody: "تعذّر تغيير حالتك. حاول مرة أخرى.",
noRequestsAlt: "لا توجد رحلات حديثة",
},
offerCard: {
newRequest: "طلب جديد · {service}",
cash: "💵 نقدًا",
card: "💳 بطاقة",
fromAlt: "من",
toAlt: "إلى",
tripTime: "زمن الرحلة",
fare: "الأجرة",
decline: "رفض",
accept: "قبول",
},
activeRide: {
headToPickup: "اتجه إلى نقطة الصعود",
tripInProgress: "الرحلة جارية",
rider: "{name}",
fromAlt: "من",
toAlt: "إلى",
fare: "الأجرة",
startTrip: "ابدأ الرحلة",
completeTrip: "أنهِ الرحلة",
alertErrorTitle: "خطأ",
alertAcceptBody: "تعذّر قبول هذه الرحلة. ربما حُجزت أو انتهت صلاحيتها.",
alertDeclineBody: "تعذّر رفض هذه الرحلة. حاول مرة أخرى.",
alertUpdateBody: "تعذّر تحديث الرحلة. حاول مرة أخرى.",
},
},
services: {
car: { label: "سيارة", tagline: "رحلة يومية، حتى 4 مقاعد." },
moto: { label: "موتور", tagline: "تجنّب الزحام — راكب واحد، بدون أمتعة." },
courier: {
label: "توصيل طرود",
tagline: "أرسل طردًا عبر المدينة دون ركوب.",
},
chauffeur: { label: "سائق خاص", tagline: "يأتيك سائق ويقود سيارتك أنت." },
},
pois: {
nearbyTitle: "اقتراحات قريبة",
mall: "مركز تسوّق",
hospital: "مستشفى",
pharmacy: "صيدلية",
restaurant: "مطعم",
searching: "جارٍ البحث…",
noneNearby: "لا يوجد قريب",
kmAway: "{km} كم",
routeAway: "{km} كم · {min} دقيقة",
nearbyPlace: "مكان قريب",
},
units: {
min: { zero: "0 دقيقة", one: "{n} دقيقة", other: "{n} دقائق" },
h: "{n} س",
m: "{n} د",
hoursMinutes: "{h}س {m}د",
seats: { zero: "{n} مقاعد", one: "مقعد", other: "{n} مقاعد" },
lbpSuffix: " ل.ل.",
},
months: {
jan: "يناير",
feb: "فبراير",
mar: "مارس",
apr: "أبريل",
may: "مايو",
jun: "يونيو",
jul: "يوليو",
aug: "أغسطس",
sep: "سبتمبر",
oct: "أكتوبر",
nov: "نوفمبر",
dec: "ديسمبر",
},
components: {
oauth: {
or: "أو",
googleLogoAlt: "شعار Google",
alertFailTitle: "فشل تسجيل الدخول عبر Google",
alertFailNoToken: "لم يُعَد أي رمز. حاول مرة أخرى.",
alertFailFallback: "حاول مرة أخرى.",
},
otp: {
code: "الرمز",
codePlaceholder: "123456",
pasteCode: "لصق الرمز من Gmail",
},
googleTextInput: {
searchAlt: "بحث",
placeholder: "إلى أين تريد الذهاب؟",
},
inputField: {
labelIconAlt: "أيقونة {label}",
},
locationNotice: {
denied: {
title: "الوصول إلى الموقع مُيقَف",
body: "يحتاج وصّيل إلى موقعك لإظهار السائقين القريبين وتحديد نقطة الصعود.",
action: "فتح الإعدادات",
},
servicesOff: {
title: "خدمات الموقع مُيقَفة",
body: "فعّل الموقع على جهازك ثم حاول مجددًا.",
action: "حاول مجددًا",
},
unavailable: {
title: "تعذّر العثور على موقعك",
body: "انتقل إلى مكان بإشارة أوضح، أو حدّد نقطة الصعود يدويًا.",
action: "حاول مجددًا",
},
},
map: {
destination: "الوجهة",
webUnavailable:
"الخريطة غير متاحة على الويب.\nافتح التطبيق على أندرويد أو iOS للتجربة الكاملة.",
},
rideCard: {
mapAlt: "خريطة",
originAlt: "المصدر",
destinationAlt: "الوجهة",
dateTime: "التاريخ والوقت",
driver: "السائق",
carSeats: "المقاعد",
fare: "الأجرة",
paymentStatus: "حالة الدفع",
paymentCash: "نقدًا · ادفع للسائق",
paymentPaid: "مدفوع بالبطاقة",
paymentOther: "{status}",
},
rideLayout: {
goBack: "رجوع",
backArrowAlt: "سهم الرجوع",
},
payment: {
paymentMethod: "طريقة الدفع",
cash: "💵 نقدًا",
card: "💳 بطاقة",
processing: "جارٍ المعالجة...",
bookCash: "احجز رحلة · ادفع نقدًا للسائق",
confirmCard: "تأكيد والدفع بالبطاقة",
checkAlt: "تحقق",
rideBooked: "تم حجز الرحلة!",
successBody: "شكرًا لحجزك.\n تم تسجيل حجزك.\n",
cashInstruction: "يرجى تجهيز {lbp}.",
backHome: "العودة للرئيسية",
alertPayCardTitle: "الدفع بالبطاقة",
alertPayCardBody: "سيتم خصم ${amount} من بطاقتك.",
alertErrorTitle: "خطأ",
alertErrorBody: "حدث خطأ أثناء حجز رحلتك. حاول مرة أخرى.",
alertPaymentNotCompletedTitle: "لم يكتمل الدفع",
alertPaymentNotCompletedBody:
"أُلغي الدفع أو تعذّر التحقق منه. حاول مرة أخرى.",
alertProcessingTitle: "خطأ",
alertProcessingBody: "حدث خطأ أثناء معالجة الدفع. حاول مرة أخرى.",
},
driverCard: {
avatarAlt: "صورة السائق",
starAlt: "نجمة",
dollarAlt: "دولار",
carAlt: "سيارة",
seats: "{n} مقاعد",
},
},
settings: {
title: "الإعدادات",
maps: {
title: "الخرائط والتنقّل",
description:
"يستخدم وصّيل موقعك لإظهار السائقين القريبين والتنقّل إلى نقطة الصعود.",
statusGranted: "مُتاح",
statusDenied: "غير مسموح",
statusBlocked: "محظور",
statusUnknown: "جارٍ التحقق…",
openSettings: "فتح الإعدادات",
},
appearance: {
title: "المظهر",
description: "اختر شكل وصّيل.",
light: "فاتح",
dark: "داكن",
system: "النظام",
},
safety: {
title: "الأمان",
call112: "اتصل بـ 112 — الطوارئ",
call112Description:
"اتصل برقم الطوارئ في لبنان للشرطة أو الإسعاف أو الإطفاء.",
callFailedTitle: "تعذّر إجراء المكالمة",
callFailedBody: "لا يوجد هاتف على هذا الجهاز. اتصل بـ 112 يدويًا.",
proactive: {
title: "الأمان الاستباقي",
body: "شارك حالة رحلتك وموقعك المباشر مع جهات موثوقة، وامتحن بثقة مع وجود المساعدة على بُعد نقرة.",
},
verification: {
title: "التحقق من الركاب",
body: "يتحقق الركاب والسائقون من أرقام هواتفهم وهويتهم، فتعرف دائمًا مع من تركب.",
},
privacy: {
title: "حماية خصوصيتك",
body: "يبقى رقم هاتفك وبياناتك مخفية في الدردشة. الرسائل ظاهرة فقط بينك وبين سائقك لهذه الرحلة.",
},
},
language: {
title: "اللغة",
description: "لغة التطبيق.",
en: "English",
ar: "العربية",
fr: "Français",
rtlRestartTitle: "إعادة التشغيل للعربية",
rtlRestartBody:
"سيُطبَّق التخطيط العربي بالكامل في المرة القادمة التي تفتح فيها التطبيق.",
},
keepAwake: {
title: "عدم قفل الشاشة",
description: "إبقاء الشاشة مضاءة أثناء فتح التطبيق.",
},
overlay: {
title: "العرض فوق التطبيقات الأخرى",
description: "مطلوب لتظهر تنبيهات الرحلات فوق التطبيقات الأخرى.",
allow: "السماح بالعرض فوق التطبيقات الأخرى",
openedHint: "تم فتح إعدادات النظام",
notAndroid: "متاح على أندرويد فقط.",
},
},
};
+501
View File
@@ -0,0 +1,501 @@
export const en = {
common: {
cancel: "Cancel",
continue: "Continue",
back: "Back",
retry: "Try Again",
loading: "Loading…",
saving: "Saving…",
error: "Error",
save: "Save",
or: "Or",
browseHome: "Browse Home",
backHome: "Back Home",
openSettings: "Open Settings",
yourLocation: "Your location",
},
onboarding: {
skip: "Skip",
next: "Next",
getStarted: "Get Started",
slide1: {
title: "The perfect ride is just a tap away!",
desc: "Your journey begins with Waseel. Find your ideal ride effortlessly.",
},
slide2: {
title: "Best car in your hands with Waseel",
desc: "Discover the convenience of finding your perfect ride with Waseel",
},
slide3: {
title: "Your ride, your way. Let's go!",
desc: "Enter your destination, sit back, and let us take care of the rest.",
},
},
auth: {
signIn: {
welcome: "Welcome 👋",
email: "Email",
emailPlaceholder: "karim@email.com",
password: "Password",
passwordPlaceholder: "••••••••",
keepSignedIn: "Keep me signed in",
signingIn: "Signing in…",
signInBtn: "Sign In",
signInGoogle: "Sign in with Google",
forgotPassword: "Forgot password?",
noAccount: "Don't have an account? ",
signUpLink: "Sign up",
carAlt: "Car",
reset: {
title: "Reset password",
requestBody:
"Enter your email and we'll send you a 6-digit reset code.",
emailPlaceholder: "karim@email.com",
sending: "Sending…",
sendCode: "Send Code",
newPassTitle: "Enter new password",
resetBody: "We've sent a reset code to {email}",
devBanner:
"Email delivery is not configured on this server. Your reset code is ",
newPassLabel: "New password",
newPasswordPlaceholder: "••••••••",
resetting: "Resetting…",
resetBtn: "Reset Password",
},
alertMissingTitle: "Missing information",
alertMissingBody: "Enter both your email and your password.",
alertErrorTitle: "Error",
alertErrorFallback: "Could not sign in. Please try again.",
errEmail: "Enter your email address.",
errCode: "Enter the 6-digit code.",
errPassword: "Password must be at least 8 characters.",
errReset: "Could not reset your password. Please try again.",
},
signUp: {
createAccount: "Create Your Account",
howUse: "How will you use Waseel?",
riderTitle: "I need a ride",
riderDesc: "Book rides and get where you're going",
driverTitle: "I want to drive",
driverDesc: "Offer rides and earn money with your car",
carAlt: "Car",
name: "Name",
namePlaceholder: "Karim Haddad",
email: "Email",
emailPlaceholder: "karim@email.com",
phoneOptional: "Phone (optional)",
phonePlaceholder: "70 123 456",
password: "Password",
passwordPlaceholder: "••••••••",
signUpBtn: "Sign Up",
signUpGoogle: "Sign up with Google",
haveAccount: "Already have an account? ",
signInLink: "Sign in",
verify: {
title: "Verification",
body: "We've sent a verification code to {email}",
devBanner:
"Email delivery is not configured on this server. Your verification code is ",
verifying: "Verifying…",
verifyBtn: "Verify Email",
checkAlt: "Check",
},
verified: {
title: "Verified",
body: "You've successfully verified your account.",
},
alertMissingTitle: "Missing information",
alertMissingBody: "Please fill in your name, email and password.",
alertPhoneTitle: "Invalid phone number",
alertPhoneBody: "Enter a valid Lebanese number, e.g. 70 123 456.",
alertErrorTitle: "Error",
alertErrorFallback: "Could not create your account.",
errCode: "Enter the 6-digit code.",
errVerify: "Verification failed. Please try again.",
},
role: {
title: "How will you use Waseel?",
subtitle: "You can change this later by contacting support.",
riderTitle: "I'm a Rider",
riderDesc: "Book rides and get around Lebanon",
driverTitle: "I'm a Driver",
driverDesc: "Give rides and earn money",
alertErrorTitle: "Error",
alertErrorBody: "Could not save your choice. Please try again.",
},
},
home: {
welcome: "Welcome {name} 👋",
logoutAlt: "Logout",
currentLocation: "Your Current Location",
findingLocation: "Finding your location…",
whatNeed: "What do you need?",
recentRides: "Recent Rides",
noRecent: "No recent rides found.",
noRecentAlt: "No recent rides found",
},
rides: {
allRides: "All rides",
noRecent: "No recent rides found.",
noRecentAlt: "No recent rides found",
},
tabs: {
home: "Home",
rides: "Rides",
chat: "Chat",
profile: "Profile",
settings: "Settings",
},
chat: {
title: "Chat",
messageAlt: "message",
noMessages: "No Messages Yet",
startConversation: "Start a conversation with your friends and family",
},
profile: {
title: "My Profile",
avatarAlt: "Your Avatar",
firstName: "First name",
firstNamePlaceholder: "Your First name",
lastName: "Last name",
lastNamePlaceholder: "Your Last name",
email: "Email",
emailPlaceholder: "Your Email address",
},
findRide: {
title: "Ride",
from: "From",
to: "To",
findNow: "Find now",
},
confirmRide: {
title: "Request Ride",
yourTrip: "Your trip",
pickup: "Pickup",
destination: "Destination",
tripTime: "Trip time {time}",
paymentMethod: "Payment Method",
cash: "💵 Cash",
card: "💳 Card",
fareDisplay: "${fare}",
lbpEstimate: "≈ {lbp}",
noDrivers: "No {service} drivers online right now",
findingDrivers: "Finding drivers nearby…",
nearestDriver: "Nearest driver ≈ {eta} min away",
requesting: "Requesting…",
noDriversOnline: "No drivers online",
requestCash: "Request Ride · Pay cash to driver",
requestCard: "Request Ride · Pay by card",
alertMissingRouteTitle: "Missing route",
alertMissingRouteBody: "Please set a pickup and destination first.",
alertNoEstimateTitle: "No estimate",
alertNoEstimateBody: "We couldn't estimate this fare. Please try again.",
alertErrorTitle: "Error",
alertErrorFallback:
"Something went wrong while booking your ride. Please try again.",
alertPayCardTitle: "Pay by card",
alertPayCardBody: "Your card will be charged ${fare}.",
},
bookRide: {
status: {
requested: "Finding your driver…",
accepted: "Driver assigned — heading to you",
enRoute: "On your trip",
completed: "You've arrived!",
cancelled: "Ride cancelled",
},
rideNotFound: "Ride not found.",
couldNotLoad: "Could not load this ride.",
backHome: "Back Home",
matchingDriver: "We're matching you with the nearest {service} driver.",
ratingFallback: "—",
paymentCash: "💵 Cash to driver",
paymentCard: "💳 Paid by card",
fare: "Fare: ${fare}",
tripTime: "Trip time {time}",
rideCancelled: "This ride was cancelled.",
cancelRide: "Cancel Ride",
cancelling: "Cancelling…",
alertErrorTitle: "Error",
alertErrorBody: "Could not cancel this ride. Please try again.",
},
driver: {
home: {
signOutAlt: "Sign out",
driverMode: "Driver mode",
online: "● Online — receiving ride requests",
goOnline: "○ Go online to drive",
todaysEarnings: "Today's earnings",
completedToday: "Completed today",
incomingRequests: "Incoming requests",
incomingRequestsOffline: "Incoming requests (offline)",
waitingRequests: "Waiting for ride requests…",
goOnlineStart: "Go online to start driving.",
welcome: "Welcome, {name}",
welcomeFallback: "driver",
setupIntro:
"Set up your driver profile to start receiving ride requests.",
whatDrive: "What will you drive?",
carModel: "Car model",
carModelPlaceholder: "e.g. Toyota Camry",
carSeats: "Car seats",
carSeatsPlaceholder: "4",
saving: "Saving…",
startDriving: "Start driving",
alertSeatsTitle: "Invalid seats",
alertSeatsBody: "Car seats must be a whole number 18.",
alertErrorTitle: "Error",
alertCreateBody:
"Could not create your driver profile. Please try again.",
alertToggleBody: "Could not change your status. Please try again.",
noRequestsAlt: "No recent rides found",
},
offerCard: {
newRequest: "New request · {service}",
cash: "💵 Cash",
card: "💳 Card",
fromAlt: "From",
toAlt: "To",
tripTime: "Trip time",
fare: "Fare",
decline: "Decline",
accept: "Accept",
},
activeRide: {
headToPickup: "Head to pickup",
tripInProgress: "Trip in progress",
rider: "{name}",
fromAlt: "From",
toAlt: "To",
fare: "Fare",
startTrip: "Start trip",
completeTrip: "Complete trip",
alertErrorTitle: "Error",
alertAcceptBody:
"Could not accept this ride. It may have been taken or expired.",
alertDeclineBody: "Could not decline this ride. Please try again.",
alertUpdateBody: "Could not update the ride. Please try again.",
},
},
services: {
car: { label: "Car", tagline: "An everyday ride, up to 4 seats." },
moto: {
label: "Moto",
tagline: "Beat the traffic — one passenger, no luggage.",
},
courier: {
label: "Courier",
tagline: "Send a parcel across town without riding along.",
},
chauffeur: {
label: "My Car",
tagline: "A driver comes to you and drives your own car.",
},
},
pois: {
nearbyTitle: "Nearby suggestions",
mall: "Mall",
hospital: "Hospital",
pharmacy: "Pharmacy",
restaurant: "Restaurant",
searching: "searching…",
noneNearby: "none nearby",
kmAway: "{km} km away",
routeAway: "{km} km · {min} min",
nearbyPlace: "Nearby place",
},
units: {
min: { zero: "0 min", one: "{n} min", other: "{n} min" },
h: "{n}h",
m: "{n}m",
hoursMinutes: "{h}h {m}m",
seats: { zero: "{n} seats", one: "{n} seat", other: "{n} seats" },
lbpSuffix: " L.B.P.",
},
months: {
jan: "Jan",
feb: "Feb",
mar: "Mar",
apr: "Apr",
may: "May",
jun: "Jun",
jul: "Jul",
aug: "Aug",
sep: "Sep",
oct: "Oct",
nov: "Nov",
dec: "Dec",
},
components: {
oauth: {
or: "Or",
googleLogoAlt: "Google logo",
alertFailTitle: "Google sign-in failed",
alertFailNoToken: "No token returned. Try again.",
alertFailFallback: "Please try again.",
},
otp: {
code: "Code",
codePlaceholder: "123456",
pasteCode: "Paste code from Gmail",
},
googleTextInput: {
searchAlt: "Search",
placeholder: "Where do you want to go?",
},
inputField: {
labelIconAlt: "{label} icon",
},
locationNotice: {
denied: {
title: "Location access is off",
body: "Waseel needs your location to show nearby drivers and set your pickup point.",
action: "Open Settings",
},
servicesOff: {
title: "Location services are off",
body: "Turn on location on your device, then try again.",
action: "Try Again",
},
unavailable: {
title: "Couldn't find your location",
body: "Move somewhere with a clearer signal, or set your pickup point manually.",
action: "Try Again",
},
},
map: {
destination: "Destination",
webUnavailable:
"Map is not available on web.\nRun on Android/iOS for the full experience.",
},
rideCard: {
mapAlt: "Map",
originAlt: "Origin",
destinationAlt: "Destination",
dateTime: "Date & Time",
driver: "Driver",
carSeats: "Car Seats",
fare: "Fare",
paymentStatus: "Payment Status",
paymentCash: "Cash · Pay to driver",
paymentPaid: "Paid by card",
paymentOther: "{status}",
},
rideLayout: {
goBack: "Go Back",
backArrowAlt: "Back arrow",
},
payment: {
paymentMethod: "Payment Method",
cash: "💵 Cash",
card: "💳 Card",
processing: "Processing...",
bookCash: "Book ride · Pay cash to driver",
confirmCard: "Confirm & Pay by Card",
checkAlt: "Check",
rideBooked: "Ride Booked!",
successBody:
"Thank you for your booking.\n Your reservation has been placed.\n",
cashInstruction: "Please have {lbp} ready.",
backHome: "Back Home",
alertPayCardTitle: "Pay by card",
alertPayCardBody: "Your card will be charged ${amount}.",
alertErrorTitle: "Error",
alertErrorBody:
"Something went wrong while booking your ride. Please try again.",
alertPaymentNotCompletedTitle: "Payment not completed",
alertPaymentNotCompletedBody:
"Your payment was cancelled or could not be verified. Please try again.",
alertProcessingTitle: "Error",
alertProcessingBody:
"Something went wrong while processing your payment. Please try again.",
},
driverCard: {
avatarAlt: "Driver Avatar",
starAlt: "Star",
dollarAlt: "Dollar",
carAlt: "Car",
seats: "{n} seats",
},
},
settings: {
title: "Settings",
maps: {
title: "Maps & Navigation",
description:
"Waseel uses your location to show nearby drivers and navigate to your pickup.",
statusGranted: "Granted",
statusDenied: "Not allowed",
statusBlocked: "Blocked",
statusUnknown: "Checking…",
openSettings: "Open Settings",
},
appearance: {
title: "Appearance",
description: "Choose how Waseel looks.",
light: "Light",
dark: "Dark",
system: "System",
},
safety: {
title: "Safety",
call112: "Call 112 — Emergency",
call112Description:
"Call Lebanon's emergency number for law enforcement, ambulance, or fire.",
callFailedTitle: "Could not place a call",
callFailedBody:
"No dialer is available on this device. Call 112 manually.",
proactive: {
title: "Proactive safety",
body: "Share your trip status and live location with trusted contacts, and ride with confidence knowing help is one tap away.",
},
verification: {
title: "Passenger verification",
body: "Riders and drivers verify their phone number and identity, so you always know who you're riding with.",
},
privacy: {
title: "Protecting your privacy",
body: "Your phone number and contact details stay hidden in chat. Messages are visible only between you and your driver for this ride.",
},
},
language: {
title: "Language",
description: "App language.",
en: "English",
ar: "العربية",
fr: "Français",
rtlRestartTitle: "Restart for Arabic",
rtlRestartBody:
"Arabic layout will apply fully the next time you open the app.",
},
keepAwake: {
title: "Do not lock screen",
description: "Keep the display on while the app is open.",
},
overlay: {
title: "Display over other apps",
description:
"Required so incoming ride alerts can appear over other apps.",
allow: "Allow display over other apps",
openedHint: "Opened system settings",
notAndroid: "Only available on Android.",
},
},
};
+509
View File
@@ -0,0 +1,509 @@
export const fr = {
common: {
cancel: "Annuler",
continue: "Continuer",
back: "Retour",
retry: "Réessayer",
loading: "Chargement…",
saving: "Enregistrement…",
error: "Erreur",
save: "Enregistrer",
or: "Ou",
browseHome: "Accueil",
backHome: "Retour à l'accueil",
openSettings: "Ouvrir les réglages",
yourLocation: "Votre position",
},
onboarding: {
skip: "Passer",
next: "Suivant",
getStarted: "Commencer",
slide1: {
title: "La course parfaite à un geste près !",
desc: "Votre voyage commence avec Waseel. Trouvez votre course idéale sans effort.",
},
slide2: {
title: "La meilleure voiture avec Waseel",
desc: "Découvrez la facilité de trouver votre course idéale avec Waseel",
},
slide3: {
title: "Votre course, à votre façon. C'est parti !",
desc: "Saisissez votre destination, détendez-vous, et laissez-nous faire le reste.",
},
},
auth: {
signIn: {
welcome: "Bienvenue 👋",
email: "E-mail",
emailPlaceholder: "karim@email.com",
password: "Mot de passe",
passwordPlaceholder: "••••••••",
keepSignedIn: "Rester connecté",
signingIn: "Connexion…",
signInBtn: "Se connecter",
signInGoogle: "Se connecter avec Google",
forgotPassword: "Mot de passe oublié ?",
noAccount: "Pas encore de compte ? ",
signUpLink: "S'inscrire",
carAlt: "Voiture",
reset: {
title: "Réinitialiser le mot de passe",
requestBody:
"Saisissez votre e-mail et nous vous enverrons un code à 6 chiffres.",
emailPlaceholder: "karim@email.com",
sending: "Envoi…",
sendCode: "Envoyer le code",
newPassTitle: "Saisir le nouveau mot de passe",
resetBody: "Nous avons envoyé un code à {email}",
devBanner:
"L'envoi d'e-mails n'est pas configuré sur ce serveur. Votre code est ",
newPassLabel: "Nouveau mot de passe",
newPasswordPlaceholder: "••••••••",
resetting: "Réinitialisation…",
resetBtn: "Réinitialiser le mot de passe",
},
alertMissingTitle: "Informations manquantes",
alertMissingBody: "Saisissez votre e-mail et votre mot de passe.",
alertErrorTitle: "Erreur",
alertErrorFallback: "Connexion impossible. Réessayez.",
errEmail: "Saisissez votre adresse e-mail.",
errCode: "Saisissez le code à 6 chiffres.",
errPassword: "Le mot de passe doit comporter au moins 8 caractères.",
errReset: "Réinitialisation impossible. Réessayez.",
},
signUp: {
createAccount: "Créez votre compte",
howUse: "Comment utiliserez-vous Waseel ?",
riderTitle: "J'ai besoin d'une course",
riderDesc: "Réservez des courses et arrivez à destination",
driverTitle: "Je veux conduire",
driverDesc:
"Proposez des courses et gagnez de l'argent avec votre voiture",
carAlt: "Voiture",
name: "Nom",
namePlaceholder: "Karim Haddad",
email: "E-mail",
emailPlaceholder: "karim@email.com",
phoneOptional: "Téléphone (facultatif)",
phonePlaceholder: "70 123 456",
password: "Mot de passe",
passwordPlaceholder: "••••••••",
signUpBtn: "S'inscrire",
signUpGoogle: "S'inscrire avec Google",
haveAccount: "Vous avez déjà un compte ? ",
signInLink: "Se connecter",
verify: {
title: "Vérification",
body: "Nous avons envoyé un code de vérification à {email}",
devBanner:
"L'envoi d'e-mails n'est pas configuré sur ce serveur. Votre code de vérification est ",
verifying: "Vérification…",
verifyBtn: "Vérifier l'e-mail",
checkAlt: "Vérifié",
},
verified: {
title: "Vérifié",
body: "Votre compte a été vérifié avec succès.",
},
alertMissingTitle: "Informations manquantes",
alertMissingBody:
"Veuillez renseigner votre nom, votre e-mail et votre mot de passe.",
alertPhoneTitle: "Numéro de téléphone invalide",
alertPhoneBody: "Saisissez un numéro libanais valide, ex. 70 123 456.",
alertErrorTitle: "Erreur",
alertErrorFallback: "Création du compte impossible.",
errCode: "Saisissez le code à 6 chiffres.",
errVerify: "Échec de la vérification. Réessayez.",
},
role: {
title: "Comment utiliserez-vous Waseel ?",
subtitle: "Vous pouvez changer cela plus tard en contactant le support.",
riderTitle: "Je suis un passager",
riderDesc: "Réservez des courses et déplacez-vous au Liban",
driverTitle: "Je suis un chauffeur",
driverDesc: "Proposez des courses et gagnez de l'argent",
alertErrorTitle: "Erreur",
alertErrorBody: "Enregistrement du choix impossible. Réessayez.",
},
},
home: {
welcome: "Bienvenue {name} 👋",
logoutAlt: "Déconnexion",
currentLocation: "Votre position actuelle",
findingLocation: "Localisation en cours…",
whatNeed: "De quoi avez-vous besoin ?",
recentRides: "Courses récentes",
noRecent: "Aucune course récente.",
noRecentAlt: "Aucune course récente",
},
rides: {
allRides: "Toutes les courses",
noRecent: "Aucune course récente.",
noRecentAlt: "Aucune course récente",
},
tabs: {
home: "Accueil",
rides: "Courses",
chat: "Discussion",
profile: "Profil",
settings: "Réglages",
},
chat: {
title: "Discussion",
messageAlt: "message",
noMessages: "Pas encore de messages",
startConversation:
"Démarrez une conversation avec vos amis et votre famille",
},
profile: {
title: "Mon profil",
avatarAlt: "Votre avatar",
firstName: "Prénom",
firstNamePlaceholder: "Votre prénom",
lastName: "Nom",
lastNamePlaceholder: "Votre nom",
email: "E-mail",
emailPlaceholder: "Votre adresse e-mail",
},
findRide: {
title: "Course",
from: "De",
to: "À",
findNow: "Rechercher",
},
confirmRide: {
title: "Demander une course",
yourTrip: "Votre trajet",
pickup: "Départ",
destination: "Destination",
tripTime: "Durée {time}",
paymentMethod: "Mode de paiement",
cash: "💵 Espèces",
card: "💳 Carte",
fareDisplay: "${fare}",
lbpEstimate: "≈ {lbp}",
noDrivers: "Aucun chauffeur {service} en ligne pour l'instant",
findingDrivers: "Recherche de chauffeurs à proximité…",
nearestDriver: "Chauffeur le plus proche ≈ {eta} min",
requesting: "Demande en cours…",
noDriversOnline: "Aucun chauffeur en ligne",
requestCash: "Demander une course · Payer en espèces",
requestCard: "Demander une course · Payer par carte",
alertMissingRouteTitle: "Itinéraire manquant",
alertMissingRouteBody:
"Veuillez d'abord définir un départ et une destination.",
alertNoEstimateTitle: "Pas d'estimation",
alertNoEstimateBody: "Nous n'avons pas pu estimer ce tarif. Réessayez.",
alertErrorTitle: "Erreur",
alertErrorFallback:
"Une erreur est survenue lors de la réservation. Réessayez.",
alertPayCardTitle: "Payer par carte",
alertPayCardBody: "Votre carte sera débitée de ${fare}.",
},
bookRide: {
status: {
requested: "Recherche de votre chauffeur…",
accepted: "Chauffeur assigné — en route vers vous",
enRoute: "Course en cours",
completed: "Vous êtes arrivé !",
cancelled: "Course annulée",
},
rideNotFound: "Course introuvable.",
couldNotLoad: "Chargement de cette course impossible.",
backHome: "Retour à l'accueil",
matchingDriver:
"Nous vous mettons en relation avec le chauffeur {service} le plus proche.",
ratingFallback: "—",
paymentCash: "💵 Espèces au chauffeur",
paymentCard: "💳 Payé par carte",
fare: "Tarif : ${fare}",
tripTime: "Durée {time}",
rideCancelled: "Cette course a été annulée.",
cancelRide: "Annuler la course",
cancelling: "Annulation…",
alertErrorTitle: "Erreur",
alertErrorBody: "Annulation de cette course impossible. Réessayez.",
},
driver: {
home: {
signOutAlt: "Déconnexion",
driverMode: "Mode chauffeur",
online: "● En ligne — réception des demandes",
goOnline: "○ Passer en ligne pour rouler",
todaysEarnings: "Gain du jour",
completedToday: "Terminées aujourd'hui",
incomingRequests: "Demandes entrantes",
incomingRequestsOffline: "Demandes entrantes (hors ligne)",
waitingRequests: "En attente de demandes…",
goOnlineStart: "Passez en ligne pour commencer à rouler.",
welcome: "Bienvenue, {name}",
welcomeFallback: "chauffeur",
setupIntro:
"Configurez votre profil chauffeur pour commencer à recevoir des demandes.",
whatDrive: "Que allez-vous conduire ?",
carModel: "Modèle de voiture",
carModelPlaceholder: "ex. Toyota Camry",
carSeats: "Places",
carSeatsPlaceholder: "4",
saving: "Enregistrement…",
startDriving: "Commencer à rouler",
alertSeatsTitle: "Places invalides",
alertSeatsBody: "Le nombre de places doit être un entier entre 1 et 8.",
alertErrorTitle: "Erreur",
alertCreateBody: "Création du profil chauffeur impossible. Réessayez.",
alertToggleBody: "Changement de statut impossible. Réessayez.",
noRequestsAlt: "Aucune course récente",
},
offerCard: {
newRequest: "Nouvelle demande · {service}",
cash: "💵 Espèces",
card: "💳 Carte",
fromAlt: "De",
toAlt: "À",
tripTime: "Durée",
fare: "Tarif",
decline: "Refuser",
accept: "Accepter",
},
activeRide: {
headToPickup: "Direction le départ",
tripInProgress: "Course en cours",
rider: "{name}",
fromAlt: "De",
toAlt: "À",
fare: "Tarif",
startTrip: "Démarrer la course",
completeTrip: "Terminer la course",
alertErrorTitle: "Erreur",
alertAcceptBody:
"Acceptation impossible. La course a peut-être été prise ou a expiré.",
alertDeclineBody: "Refus de la course impossible. Réessayez.",
alertUpdateBody: "Mise à jour de la course impossible. Réessayez.",
},
},
services: {
car: {
label: "Voiture",
tagline: "Une course quotidienne, jusqu'à 4 places.",
},
moto: {
label: "Moto",
tagline: "Évitez le trafic — un passager, sans bagages.",
},
courier: {
label: "Coursier",
tagline: "Envoyez un colis en ville sans vous déplacer.",
},
chauffeur: {
label: "Ma voiture",
tagline: "Un chauffeur vient conduire votre propre voiture.",
},
},
pois: {
nearbyTitle: "Suggestions à proximité",
mall: "Centre commercial",
hospital: "Hôpital",
pharmacy: "Pharmacie",
restaurant: "Restaurant",
searching: "recherche…",
noneNearby: "rien à proximité",
kmAway: "{km} km",
routeAway: "{km} km · {min} min",
nearbyPlace: "Lieu à proximité",
},
units: {
min: { zero: "0 min", one: "{n} min", other: "{n} min" },
h: "{n}h",
m: "{n}m",
hoursMinutes: "{h}h {m}m",
seats: { zero: "{n} places", one: "{n} place", other: "{n} places" },
lbpSuffix: " LBP",
},
months: {
jan: "janv.",
feb: "févr.",
mar: "mars",
apr: "avr.",
may: "mai",
jun: "juin",
jul: "juil.",
aug: "août",
sep: "sept.",
oct: "oct.",
nov: "nov.",
dec: "déc.",
},
components: {
oauth: {
or: "Ou",
googleLogoAlt: "Logo Google",
alertFailTitle: "Échec de la connexion Google",
alertFailNoToken: "Aucun jeton renvoyé. Réessayez.",
alertFailFallback: "Réessayez.",
},
otp: {
code: "Code",
codePlaceholder: "123456",
pasteCode: "Coller le code depuis Gmail",
},
googleTextInput: {
searchAlt: "Rechercher",
placeholder: "Où voulez-vous aller ?",
},
inputField: {
labelIconAlt: "Icône {label}",
},
locationNotice: {
denied: {
title: "Accès à la position désactivé",
body: "Waseel a besoin de votre position pour afficher les chauffeurs à proximité et définir votre point de départ.",
action: "Ouvrir les réglages",
},
servicesOff: {
title: "Services de localisation désactivés",
body: "Activez la localisation sur votre appareil, puis réessayez.",
action: "Réessayer",
},
unavailable: {
title: "Position introuvable",
body: "Déplacez-vous vers une zone avec un meilleur signal, ou définissez votre départ manuellement.",
action: "Réessayer",
},
},
map: {
destination: "Destination",
webUnavailable:
"La carte n'est pas disponible sur le web.\nUtilisez Android ou iOS pour l'expérience complète.",
},
rideCard: {
mapAlt: "Carte",
originAlt: "Origine",
destinationAlt: "Destination",
dateTime: "Date & Heure",
driver: "Chauffeur",
carSeats: "Places",
fare: "Tarif",
paymentStatus: "Statut du paiement",
paymentCash: "Espèces · Payer au chauffeur",
paymentPaid: "Payé par carte",
paymentOther: "{status}",
},
rideLayout: {
goBack: "Retour",
backArrowAlt: "Flèche retour",
},
payment: {
paymentMethod: "Mode de paiement",
cash: "💵 Espèces",
card: "💳 Carte",
processing: "Traitement...",
bookCash: "Réserver · Payer en espèces au chauffeur",
confirmCard: "Confirmer & Payer par carte",
checkAlt: "Vérifié",
rideBooked: "Course réservée !",
successBody:
"Merci pour votre réservation.\n Votre commande a été enregistrée.\n",
cashInstruction: "Préparez {lbp}.",
backHome: "Retour à l'accueil",
alertPayCardTitle: "Payer par carte",
alertPayCardBody: "Votre carte sera débitée de ${amount}.",
alertErrorTitle: "Erreur",
alertErrorBody:
"Une erreur est survenue lors de la réservation. Réessayez.",
alertPaymentNotCompletedTitle: "Paiement non effectué",
alertPaymentNotCompletedBody:
"Votre paiement a été annulé ou n'a pas pu être vérifié. Réessayez.",
alertProcessingTitle: "Erreur",
alertProcessingBody:
"Une erreur est survenue lors du traitement du paiement. Réessayez.",
},
driverCard: {
avatarAlt: "Avatar du chauffeur",
starAlt: "Étoile",
dollarAlt: "Dollar",
carAlt: "Voiture",
seats: "{n} places",
},
},
settings: {
title: "Réglages",
maps: {
title: "Cartes & Navigation",
description:
"Waseel utilise votre position pour afficher les chauffeurs à proximité et naviguer vers votre départ.",
statusGranted: "Accordé",
statusDenied: "Non autorisé",
statusBlocked: "Bloqué",
statusUnknown: "Vérification…",
openSettings: "Ouvrir les réglages",
},
appearance: {
title: "Apparence",
description: "Choisissez l'apparence de Waseel.",
light: "Clair",
dark: "Sombre",
system: "Système",
},
safety: {
title: "Sécurité",
call112: "Appeler le 112 — Urgences",
call112Description:
"Appelez le numéro d'urgence du Liban pour la police, les secours ou les pompiers.",
callFailedTitle: "Appel impossible",
callFailedBody:
"Aucun composeur disponible sur cet appareil. Appelez le 112 manuellement.",
proactive: {
title: "Sécurité proactive",
body: "Partagez le statut de votre course et votre position en direct avec vos contacts de confiance, et voyagez en sachant que l'aide est à un geste près.",
},
verification: {
title: "Vérification des passagers",
body: "Passagers et chauffeurs vérifient leur numéro de téléphone et leur identité, afin que vous sachiez toujours avec qui vous roulez.",
},
privacy: {
title: "Protection de votre vie privée",
body: "Votre numéro de téléphone et vos coordonnées restent masqués dans la discussion. Les messages ne sont visibles qu'entre vous et votre chauffeur pour cette course.",
},
},
language: {
title: "Langue",
description: "Langue de l'application.",
en: "English",
ar: "العربية",
fr: "Français",
rtlRestartTitle: "Redémarrage pour l'arabe",
rtlRestartBody:
"La mise en page arabe s'appliquera pleinement à la prochaine ouverture de l'application.",
},
keepAwake: {
title: "Ne pas verrouiller l'écran",
description:
"Maintenir l'écran allumé lorsque l'application est ouverte.",
},
overlay: {
title: "Affichage par-dessus d'autres applications",
description:
"Requis pour que les alertes de course apparaissent par-dessus les autres applications.",
allow: "Autoriser l'affichage par-dessus les autres applications",
openedHint: "Réglages système ouverts",
notAndroid: "Disponible uniquement sur Android.",
},
},
};
+54
View File
@@ -0,0 +1,54 @@
import * as Location from "expo-location";
import { Linking } from "react-native";
import { useCallback, useEffect, useState } from "react";
export type LocationPermissionStatus =
/** Permission granted. */
| "granted"
/** The user denied but we can still ask again. */
| "denied"
/** The user denied permanently ("Don't ask again") — must go to Settings. */
| "blocked"
/** First read hasn't completed. */
| "unknown";
/**
* Reports the foreground location permission as it is right now, so the
* Settings screen can show the real state without re-running the request
* prompt. Call `refresh()` again on focus (e.g. with `useFocusEffect`) so the
* row updates when the user comes back from the system settings screen.
*/
export const useLocationPermission = () => {
const [status, setStatus] = useState<LocationPermissionStatus>("unknown");
const refresh = useCallback(async () => {
try {
const result = await Location.getForegroundPermissionsAsync();
if (result.granted) {
setStatus("granted");
} else if (result.canAskAgain) {
setStatus("denied");
} else {
setStatus("blocked");
}
} catch (error) {
console.warn("[LOCATION_PERMISSION]: ", error);
setStatus("unknown");
}
}, []);
const openSettings = useCallback(async () => {
try {
await Linking.openSettings();
} catch (error) {
console.warn("[OPEN_SETTINGS]: ", error);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { status, refresh, openSettings };
};
+6 -1
View File
@@ -1,6 +1,7 @@
import * as Location from "expo-location";
import { useCallback, useEffect, useState } from "react";
import { tr } from "@/lib/i18n";
import { useLocationStore } from "@/store";
export type LocationStatus =
@@ -51,7 +52,11 @@ export const useUserLocation = () => {
const apply = ({ coords }: Location.LocationObject) => {
const { latitude, longitude } = coords;
setUserLocation({ latitude, longitude, address: "Your location" });
setUserLocation({
latitude,
longitude,
address: tr("common.yourLocation"),
});
Location.reverseGeocodeAsync({ latitude, longitude })
.then(([place]) => {
+24 -19
View File
@@ -1,3 +1,4 @@
import { tr } from "@/lib/i18n";
import type { Ride } from "@/types/type";
export const sortRides = (rides: Ride[]): Ride[] => {
@@ -7,36 +8,40 @@ export const sortRides = (rides: Ride[]): Ride[] => {
);
};
// Unit words and month names are localized through the module-level translator
// in lib/i18n (set by I18nProvider). Until the provider boots, `tr` falls back
// to English, so these stay safe to call during the initial render.
export function formatTime(minutes: number): string {
const formattedMinutes = Math.round(minutes) || 0;
if (formattedMinutes < 60) {
return `${formattedMinutes} min`;
} else {
const hours = Math.floor(formattedMinutes / 60);
const remainingMinutes = formattedMinutes % 60;
return `${hours}h ${remainingMinutes}m`;
return tr("units.min", {}, formattedMinutes);
}
const hours = Math.floor(formattedMinutes / 60);
const remainingMinutes = formattedMinutes % 60;
return tr("units.hoursMinutes", { h: hours, m: remainingMinutes });
}
export function formatDate(dateString: string): string {
const date = new Date(dateString);
const day = date.getDate();
const monthNames = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
const monthKeys = [
"months.jan",
"months.feb",
"months.mar",
"months.apr",
"months.may",
"months.jun",
"months.jul",
"months.aug",
"months.sep",
"months.oct",
"months.nov",
"months.dec",
];
const month = monthNames[date.getMonth()];
const month = tr(monthKeys[date.getMonth()]);
const year = date.getFullYear();
return `${day < 10 ? "0" + day : day} ${month} ${year}`;
+1
View File
@@ -82,6 +82,7 @@
"expo-constants": "~16.0.2",
"expo-crypto": "~13.0.2",
"expo-font": "~12.0.9",
"expo-keep-awake": "~13.0.2",
"expo-linking": "^6.3.1",
"expo-location": "^17.0.1",
"expo-router": "~3.5.23",
+4
View File
@@ -90,7 +90,11 @@ declare interface NearbyPlace {
address: string;
latitude: number;
longitude: number;
/** Straight-line distance from the rider. */
distanceMeters?: number;
/** Distance and time along the actual driving route, when one was found. */
routeDistanceMeters?: number;
routeDurationSeconds?: number;
category?: string;
}