Waseel: driver app, dispatch, POI suggestions, map fixes
This commit is contained in:
+130
@@ -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
@@ -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
@@ -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")}`;
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: "متاح على أندرويد فقط.",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 1–8.",
|
||||
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.",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user