frontend: RTL sidebar arrow placement + reverse-geocode clinic location

- Arabic RTL: stack the collapse arrow/bell above the nav icons instead of
  pinning them to the opposite edge; mirror the panel-toggle glyph; flip the
  notifications popover to open toward the content side.
- "Use my current location" now reverse-geocodes (OpenStreetMap Nominatim) to
  fill address/city/country, not just latitude/longitude, with a graceful
  coordinates-only fallback. New settings.location.geoPartial key in all 5 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-08 19:32:23 +03:00
parent ab17978b5c
commit fb9fa299c9
10 changed files with 110 additions and 16 deletions
@@ -14,6 +14,7 @@ import {
} from "@/components/settings/settings-parts";
import { cn } from "@/lib/utils";
import { getClinicSettings, saveClinicLocation } from "@/lib/clinic";
import { reverseGeocode } from "@/lib/geocode";
import { notify } from "@/lib/toast";
// Parse a coordinate input into a number or null (empty ⇒ null). Returns
@@ -29,7 +30,7 @@ function parseCoord(value: string): number | null | false {
// Persists the clinic's address + optional map coordinates so the wallet app can
// display the clinic location later.
export function ClinicLocationSection() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [country, setCountry] = useState("");
@@ -62,8 +63,10 @@ export function ClinicLocationSection() {
};
}, []);
// Fill the coordinates from the browser's geolocation (the clinician runs this
// on a device at the clinic). Client-only — no backend or map service.
// Fill the location from the browser's geolocation (the clinician runs this on
// a device at the clinic): sets the coordinates, then reverse-geocodes them to
// also fill address / city / country. The address lookup is best-effort — on
// failure we keep the coordinates and just tell the user to fill the rest.
const useMyLocation = () => {
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
notify.error(
@@ -74,10 +77,23 @@ export function ClinicLocationSection() {
}
setLocating(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6));
async (pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setLatitude(lat.toFixed(6));
setLongitude(lng.toFixed(6));
const place = await reverseGeocode(lat, lng, i18n.language);
setLocating(false);
if (place) {
if (place.address) setAddress(place.address);
if (place.city) setCity(place.city);
if (place.country) setCountry(place.country);
} else {
notify.info(
t("settings.location.savedTitle"),
t("settings.location.geoPartial"),
);
}
},
() => {
setLocating(false);
@@ -31,7 +31,8 @@ export function DashboardSidebar() {
const { t, i18n } = useTranslation();
// Anchor the sidebar to the right for RTL locales (Arabic) so the whole shell
// mirrors instead of leaving the fixed sidebar pinned physically left.
const side = dirFor(i18n.language) === "rtl" ? "right" : "left";
const isRtl = dirFor(i18n.language) === "rtl";
const side = isRtl ? "right" : "left";
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const isCollapsed = state === "collapsed";
@@ -64,7 +65,11 @@ export function DashboardSidebar() {
"flex md:pt-3.5",
isCollapsed
? "flex-row items-center justify-between gap-y-4 md:flex-col md:items-start md:justify-start"
: "flex-row items-center justify-between"
: "flex-row items-center justify-between",
// RTL (Arabic): stack the logo and the toggle/bell controls into a
// right-aligned column so the collapse arrow sits ABOVE the nav icons
// instead of being pinned to the opposite (left) edge.
isRtl && !isCollapsed && "flex-col items-end justify-start gap-y-3"
)}
>
<Tooltip>
@@ -15,6 +15,7 @@ import {
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
import { dirFor } from "@/lib/i18n/config";
import { markNotificationRead, notificationHref } from "@/lib/notifications";
import { useNotifications } from "@/lib/use-notifications";
@@ -30,9 +31,11 @@ function relativeTime(iso: string): string {
}
export function NotificationsPopover() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const router = useRouter();
const { items, unread, markAllRead } = useNotifications();
// Open toward the content side; flips to the left under RTL.
const popupSide = dirFor(i18n.language) === "rtl" ? "left" : "right";
return (
<Menu
@@ -58,7 +61,7 @@ export function NotificationsPopover() {
</span>
)}
</MenuTrigger>
<MenuPopup side="right" className="my-6 w-80">
<MenuPopup side={popupSide} className="my-6 w-80">
<MenuGroup>
<MenuGroupLabel>{t("nav.notifications")}</MenuGroupLabel>
</MenuGroup>
+2 -1
View File
@@ -299,7 +299,8 @@ export function SidebarTrigger({
variant="ghost"
{...props}
>
<PanelLeftIcon />
{/* Mirror the panel arrow under RTL so it points toward the sidebar edge. */}
<PanelLeftIcon className="rtl:rotate-180" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
+64
View File
@@ -0,0 +1,64 @@
// Reverse geocoding via OpenStreetMap Nominatim. Used by the clinic-location
// settings so "Use my current location" can fill the address/city/country
// fields, not just the raw coordinates.
//
// Nominatim's usage policy asks for a descriptive User-Agent/Referer and low
// request volumes — this is only hit on an explicit button click, so occasional
// lookups are well within the acceptable-use limits.
export interface ReverseGeocodeResult {
address: string;
city: string;
country: string;
}
// Reverse-geocode a coordinate into a best-effort street address, city, and
// country. Resolves to `null` on any failure (network, rate limit, no match) so
// the caller can silently fall back to coordinates-only.
export async function reverseGeocode(
latitude: number,
longitude: number,
language?: string,
): Promise<ReverseGeocodeResult | null> {
try {
const url = new URL("https://nominatim.openstreetmap.org/reverse");
url.searchParams.set("format", "jsonv2");
url.searchParams.set("lat", String(latitude));
url.searchParams.set("lon", String(longitude));
url.searchParams.set("zoom", "18");
url.searchParams.set("addressdetails", "1");
const res = await fetch(url.toString(), {
headers: {
// Nominatim requires an identifying header; browsers forbid setting
// User-Agent, so Accept-Language localises the returned names.
"Accept-Language": language || "en",
},
});
if (!res.ok) return null;
const data = (await res.json()) as {
address?: Record<string, string>;
display_name?: string;
};
const a = data.address ?? {};
// Compose a street line from the most specific parts available.
const street = [a.house_number, a.road].filter(Boolean).join(" ").trim();
const address =
street ||
a.neighbourhood ||
a.suburb ||
a.pedestrian ||
(data.display_name ? data.display_name.split(",")[0] : "") ||
"";
const city =
a.city || a.town || a.village || a.municipality || a.county || a.state || "";
const country = a.country || "";
if (!address && !city && !country) return null;
return { address, city, country };
} catch {
return null;
}
}
@@ -2037,7 +2037,8 @@
"useMyLocation": "استخدام موقعي الحالي",
"locating": "جارٍ تحديد الموقع…",
"geoUnsupported": "الموقع غير متاح على هذا الجهاز.",
"geoError": "تعذّر الحصول على موقعك. تحقّق من الأذونات وحاول مرة أخرى."
"geoError": "تعذّر الحصول على موقعك. تحقّق من الأذونات وحاول مرة أخرى.",
"geoPartial": "تم إدخال الإحداثيات — الرجاء إضافة تفاصيل العنوان يدويًا."
},
"signing": {
"keyTitle": "مفتاح التوقيع",
@@ -2017,7 +2017,8 @@
"useMyLocation": "Aktuellen Standort verwenden",
"locating": "Standort wird ermittelt …",
"geoUnsupported": "Standort ist auf diesem Gerät nicht verfügbar.",
"geoError": "Standort konnte nicht ermittelt werden. Bitte Berechtigungen prüfen und erneut versuchen."
"geoError": "Standort konnte nicht ermittelt werden. Bitte Berechtigungen prüfen und erneut versuchen.",
"geoPartial": "Koordinaten eingetragen bitte Adressdetails manuell ergänzen."
},
"signing": {
"keyTitle": "Signierschlüssel",
@@ -2017,7 +2017,8 @@
"useMyLocation": "Use my current location",
"locating": "Locating…",
"geoUnsupported": "Location isn't available on this device.",
"geoError": "Couldn't get your location. Check permissions and try again."
"geoError": "Couldn't get your location. Check permissions and try again.",
"geoPartial": "Filled the coordinates — please add the address details manually."
},
"signing": {
"keyTitle": "Signing key",
@@ -2017,7 +2017,8 @@
"useMyLocation": "Utiliser ma position actuelle",
"locating": "Localisation…",
"geoUnsupported": "La localisation n'est pas disponible sur cet appareil.",
"geoError": "Impossible d'obtenir votre position. Vérifiez les autorisations et réessayez."
"geoError": "Impossible d'obtenir votre position. Vérifiez les autorisations et réessayez.",
"geoPartial": "Coordonnées renseignées — veuillez ajouter l'adresse manuellement."
},
"signing": {
"keyTitle": "Clé de signature",
@@ -2017,7 +2017,8 @@
"useMyLocation": "Isticmaal goobtayda hadda",
"locating": "Waa la helayaa goobta…",
"geoUnsupported": "Goobta laguma heli karo qalabkan.",
"geoError": "Lama heli karin goobtaada. Hubi oggolaanshaha oo isku day mar kale."
"geoError": "Lama heli karin goobtaada. Hubi oggolaanshaha oo isku day mar kale.",
"geoPartial": "Isudhigyada waa la buuxiyay — fadlan gacanta ku gali faahfaahinta ciwaanka."
},
"signing": {
"keyTitle": "Furaha saxiixa",