mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user