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
+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}`;