GeoGlobal — Integration Guide for TagMe & Takemehome
Copy-paste-ready patterns for the two apps that get the most value from this service. All examples are .NET 10 / C# 14 / Blazor (UI.Shared) since both apps share that stack.
If you only have time for one thing, jump to the Top Wins at the top of each app section.
0. One-time setup (both apps)
Add a typed HTTP client for the GeoGlobal REST API. Drop this in UI.Shared/Services/ or wherever you keep API clients.
0.1 — The DTOs
UI.Shared/Models/GeoGlobal.cs
namespace TheGeekNetwork.UI.Shared.Models.GeoGlobal;
public record Place(
string Name,
string Country,
string? Fclass,
string? Fcode,
long? Population,
double Lat,
double Lng,
int? Meters = null,
double? Sim = null,
List<Poi>? InterestingNearby = null);
public record Poi(
string Name,
string? Description,
string Category,
string? Subcategory,
string? Source,
string Country,
double Lat,
double Lng,
double InterestScore,
List<string> Tags,
int? Meters = null);
public record Maneuver(
string Instruction,
double DistanceKm,
double DurationMin,
List<string>? StreetNames);
public record RouteResult(
double DistanceKm,
double DurationMin,
string Mode,
List<Maneuver> Maneuvers);
public record QuestResult(
double? TotalDistanceKm,
double? TotalDurationMin,
List<Poi> OrderedPois,
int? LegCount,
string Mode,
string? Theme,
string? RoutingError,
string? Error);
public enum RouteMode { Auto, Bicycle, Pedestrian, Motorcycle, Truck }
0.2 — The client
UI.Shared/Services/IGeoGlobalClient.cs
using TheGeekNetwork.UI.Shared.Models.GeoGlobal;
namespace TheGeekNetwork.UI.Shared.Services;
public interface IGeoGlobalClient
{
Task<List<Place>> GeocodeAsync(string query, int limit = 5,
bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
CancellationToken ct = default);
Task<List<Place>> ReverseGeocodeAsync(double lat, double lng, int limit = 5,
bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
CancellationToken ct = default);
Task<List<Place>> SearchPlacesAsync(string query, int limit = 10,
bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
CancellationToken ct = default);
Task<List<Poi>> NearbyPoisAsync(double lat, double lng, double withinKm = 10.0,
int limit = 10, string? theme = null, CancellationToken ct = default);
Task<RouteResult> RouteAsync(double fromLat, double fromLng,
double toLat, double toLng, RouteMode mode = RouteMode.Auto,
CancellationToken ct = default);
Task<QuestResult> DiscoverQuestAsync(double lat, double lng,
string? theme = null, double withinKm = 20.0, int maxPois = 5,
RouteMode mode = RouteMode.Auto, CancellationToken ct = default);
}
UI.Shared/Services/GeoGlobalClient.cs
using System.Net.Http.Json;
using TheGeekNetwork.UI.Shared.Models.GeoGlobal;
namespace TheGeekNetwork.UI.Shared.Services;
public sealed class GeoGlobalClient : IGeoGlobalClient
{
private readonly HttpClient _http;
private readonly ILogger<GeoGlobalClient> _log;
public GeoGlobalClient(HttpClient http, ILogger<GeoGlobalClient> log)
{
_http = http;
_log = log;
}
public async Task<List<Place>> GeocodeAsync(string query, int limit = 5,
bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
CancellationToken ct = default)
{
var url = $"/api/v2/geocode?query={Uri.EscapeDataString(query)}&limit={limit}"
+ $"&include_interesting={includeInteresting.ToString().ToLower()}"
+ $"&within_km={withinKm}&max_interesting={maxInteresting}";
return await GetAsync<List<Place>>(url, ct) ?? new();
}
public async Task<List<Place>> ReverseGeocodeAsync(double lat, double lng, int limit = 5,
bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
CancellationToken ct = default)
{
var url = $"/api/v2/reverse-geocode?lat={lat}&lng={lng}&limit={limit}"
+ $"&include_interesting={includeInteresting.ToString().ToLower()}"
+ $"&within_km={withinKm}&max_interesting={maxInteresting}";
return await GetAsync<List<Place>>(url, ct) ?? new();
}
public async Task<List<Place>> SearchPlacesAsync(string query, int limit = 10,
bool includeInteresting = false, double withinKm = 10.0, int maxInteresting = 5,
CancellationToken ct = default)
{
var url = $"/api/v2/search?query={Uri.EscapeDataString(query)}&limit={limit}"
+ $"&include_interesting={includeInteresting.ToString().ToLower()}"
+ $"&within_km={withinKm}&max_interesting={maxInteresting}";
return await GetAsync<List<Place>>(url, ct) ?? new();
}
public async Task<List<Poi>> NearbyPoisAsync(double lat, double lng, double withinKm = 10.0,
int limit = 10, string? theme = null, CancellationToken ct = default)
{
var url = $"/api/v2/poi/nearby?lat={lat}&lng={lng}&within_km={withinKm}&limit={limit}"
+ (theme is null ? "" : $"&theme={Uri.EscapeDataString(theme)}");
return await GetAsync<List<Poi>>(url, ct) ?? new();
}
public async Task<RouteResult> RouteAsync(double fromLat, double fromLng,
double toLat, double toLng, RouteMode mode = RouteMode.Auto,
CancellationToken ct = default)
{
var body = new {
from_lat = fromLat, from_lng = fromLng,
to_lat = toLat, to_lng = toLng,
mode = mode.ToString().ToLower()
};
var resp = await _http.PostAsJsonAsync("/api/v2/route", body, ct);
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<RouteResult>(cancellationToken: ct))!;
}
public async Task<QuestResult> DiscoverQuestAsync(double lat, double lng,
string? theme = null, double withinKm = 20.0, int maxPois = 5,
RouteMode mode = RouteMode.Auto, CancellationToken ct = default)
{
var body = new {
lat, lng, theme,
within_km = withinKm,
max_pois = maxPois,
mode = mode.ToString().ToLower()
};
var resp = await _http.PostAsJsonAsync("/api/v2/quest", body, ct);
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<QuestResult>(cancellationToken: ct))!;
}
private async Task<T?> GetAsync<T>(string url, CancellationToken ct)
{
try { return await _http.GetFromJsonAsync<T>(url, ct); }
catch (Exception ex) { _log.LogWarning(ex, "GeoGlobal call failed: {Url}", url); return default; }
}
}
0.3 — DI registration
In Web/Program.cs and Web.Client/Program.cs (and MauiProgram.cs for the MAUI flavour):
builder.Services.AddHttpClient<IGeoGlobalClient, GeoGlobalClient>(c =>
{
c.BaseAddress = new Uri(builder.Configuration["GeoGlobal:BaseUrl"]
?? "https://maps.dataacuity.co.za");
c.Timeout = TimeSpan.FromSeconds(15);
});
In appsettings.json:
{
"GeoGlobal": { "BaseUrl": "https://maps.dataacuity.co.za" }
}
For local dev pointing straight at .106, use http://197.97.200.106:5020 (the maps_api host port).
0.4 — Auth header (passing through DataAcuity Keycloak token)
Both apps already have a DataAcuityTokenHandler or similar. Reuse it:
builder.Services.AddHttpClient<IGeoGlobalClient, GeoGlobalClient>(...)
.AddHttpMessageHandler<DataAcuityTokenHandler>();
If you don't have one yet, the simplest pattern is a DelegatingHandler that fetches the bearer from ITokenStorageService and adds Authorization: Bearer {token} to every request.
1. TagMe — integration patterns
🏆 Top Wins
| # | Win | Files touched | Effort |
|---|---|---|---|
| 1 | Auto-fill address on POI creation | CreatePOI.razor |
30 min |
| 2 | Smart POI suggestions at check-in | CheckInService.cs |
1 h |
| 3 | Turn-by-turn directions to events | Events.razor, new Directions.razor |
2-3 h |
| 4 | GeoBlast campaign target validation | GeoBlast.razor |
1 h |
| 5 | "Nearby Landmarks" trending carousel | Index.razor |
2 h |
1.1 — Auto-fill address on POI creation
Problem: Users skip the Address field on CreatePOI.razor:48 because typing a full address is annoying.
Fix: After they pin a location on the map, reverse-geocode the coordinate and pre-fill the field. They edit if needed.
// In CreatePOI.razor.cs (or @code block)
[Inject] IGeoGlobalClient Geo { get; set; } = default!;
private async Task OnMapPinned(double lat, double lng)
{
PoiCoordinates = $"{lat:F6}, {lng:F6}";
// New: prefill address from reverse geocode (best-effort)
var places = await Geo.ReverseGeocodeAsync(lat, lng, limit: 1);
if (places is [{ Name: var name, Country: var cc }, ..])
{
AddressInput = $"{name}, {cc}"; // user can refine
// Optional: also surface POI category hints
var pois = await Geo.NearbyPoisAsync(lat, lng, withinKm: 0.1, limit: 3);
if (pois.Count > 0)
SuggestedCategoryFromPoi = pois[0].Category; // "tourism", "amenity", ...
}
StateHasChanged();
}
1.2 — Smart POI suggestions at check-in
Problem: CheckInService.CreateCheckInAsync(lat, lng, ...) writes coordinates only; users have to manually type "where" they are.
Fix: Look up the nearest 1–3 POIs in a 50m radius and offer them as "Are you at...?" pre-fills before the user types.
// In CheckInService.cs (extension or wrapped service)
public async Task<List<Poi>> SuggestVenuesAsync(double lat, double lng, CancellationToken ct = default)
{
var pois = await _geo.NearbyPoisAsync(lat, lng, withinKm: 0.05, limit: 3, ct: ct);
return pois.Where(p => p.Meters <= 60).Take(3).ToList(); // tight radius, very close only
}
UI on CheckIn.razor:
@if (suggestedVenues.Count > 0)
{
<div class="venue-suggestions">
<span>Are you at...</span>
@foreach (var v in suggestedVenues)
{
<button @onclick="() => PickVenue(v)">@v.Name <small>(@v.Meters m)</small></button>
}
</div>
}
1.3 — Turn-by-turn directions to events
Problem: Events.razor has a map view but no "How do I get there?" — users open Google Maps to actually navigate. RSVP conversion suffers.
Fix: Add a Directions button on the event card; show maneuvers in a side panel using RouteAsync.
@page "/event/{EventId:guid}/directions"
@inject IGeoGlobalClient Geo
@inject IGeolocationService Geolocation
<h2>Directions to @currentEvent.Name</h2>
@if (route is null)
{
<p>Loading…</p>
}
else
{
<p><strong>@route.DistanceKm.ToString("F1") km · @route.DurationMin.ToString("F0") min</strong></p>
<ol class="maneuvers">
@foreach (var m in route.Maneuvers)
{
<li>
<strong>@m.Instruction</strong>
<small>@m.DistanceKm km · @m.DurationMin min</small>
</li>
}
</ol>
}
@code {
[Parameter] public Guid EventId { get; set; }
private TagMeEvent currentEvent = default!;
private RouteResult? route;
protected override async Task OnInitializedAsync()
{
currentEvent = await EventService.GetAsync(EventId);
var me = await Geolocation.GetCurrentAsync();
try {
route = await Geo.RouteAsync(me.Lat, me.Lng, currentEvent.Lat, currentEvent.Lng);
} catch (HttpRequestException) {
// Outside Africa or routing failure — show fallback "Open in Google Maps" link
}
}
}
1.4 — GeoBlast campaign target validation
Problem: GeoBlast.razor:80 lets users pick a campaign center coord, but doesn't show the human-readable place name back to them. Easy to target the wrong area.
Fix: After they pick a coord, reverse-geocode and show "Targeting: Sandton, ZA (5 km radius)". Refuse if places is empty (likely the middle of an ocean).
private string TargetDescription = "";
private bool TargetIsValid;
private async Task OnTargetPicked(double lat, double lng, double radiusKm)
{
var places = await Geo.ReverseGeocodeAsync(lat, lng, limit: 1);
if (places is [{ Name: var name, Country: var cc, Population: var pop }, ..])
{
TargetDescription = $"Targeting: {name}, {cc} (~{pop ?? 0:N0} people · {radiusKm:F1} km radius)";
TargetIsValid = true;
}
else
{
TargetDescription = "⚠️ No populated places near this point. Pick a different center.";
TargetIsValid = false;
}
}
1.5 — "Nearby Landmarks" trending carousel
Problem: Index.razor:60 has a "Trending Locations" carousel that's currently powered by check-in counts only. Long tail places never trend.
Fix: Add a parallel "Landmarks near you" carousel sourced from the 3.5M POI dataset, ranked by interest_score within 25 km.
// On Index.razor OnInitializedAsync, after geolocation
nearbyLandmarks = await Geo.NearbyPoisAsync(
me.Lat, me.Lng,
withinKm: 25,
limit: 12,
theme: "tourism");
Render as a horizontal scroll component with the existing carousel widget. Each card: name, category, distance, optional Wikipedia thumbnail (when source=wikipedia).
2. Takemehome.co.za — integration patterns
Takemehome already has 7 maps endpoints implemented backend-side that were never wired to the UI. This is the lowest-hanging fruit in the entire ecosystem.
🏆 Top Wins
| # | Win | Files touched | Effort | Impact |
|---|---|---|---|---|
| 1 | Interactive map on PropertyDetail with property pin + nearby POIs | PropertyDetail.razor |
1 day | Direct CTR to affiliate booking link |
| 2 | "Nearby attractions" section on PropertyDetail | PropertyDetail.razor |
0.5 day | Differentiator vs Booking.com |
| 3 | Destination radius search ("lodges within 20 km of Kruger Gate") | Home.razor, Search.razor |
1 day | Underserved destination discovery |
| 4 | Travel-time matrix on search results ("45 min from CBD") | Search.razor |
1 day | Filter by commute |
| 5 | Reverse-geocoded "near me" entry point | Home.razor |
0.5 day | Mobile-first discovery |
| 6 | Road-trip itinerary builder | new Itinerary.razor |
2-3 days | Affiliate multi-night bookings |
| 7 | AI-generated guides via Butler | Butler skill |
1 day | SEO long tail |
2.1 — Interactive map on PropertyDetail with nearby POIs overlay
Problem: PropertyDetail.razor:48-49 shows the address as plain text. Users have to copy-paste to Google Maps to understand "where" the property actually is.
Fix: Embed a map (DataAcuity tile layer) centred on the property, with discover_quest-style overlay of the top 5 nearby attractions.
@inject IGeoGlobalClient Geo
<div class="property-map">
<MapEmbed
Lat="@property.Location.Lat"
Lng="@property.Location.Lng"
Zoom="14"
Markers="@mapMarkers" />
</div>
@code {
private List<MapMarker> mapMarkers = new();
protected override async Task OnParametersSetAsync()
{
// Marker for the property itself
mapMarkers.Add(new MapMarker(property.Location.Lat, property.Location.Lng, "🏨 " + property.Name, MarkerKind.Property));
// Top 5 nearby POIs
var pois = await Geo.NearbyPoisAsync(
property.Location.Lat, property.Location.Lng,
withinKm: 5, limit: 5);
mapMarkers.AddRange(pois.Select(p =>
new MapMarker(p.Lat, p.Lng, p.Name, MarkerKind.Attraction)));
}
}
2.2 — "Nearby Attractions" section on PropertyDetail
Problem: Same page lacks any "things to do nearby" — the conversion lever Booking.com uses heavily.
Fix: A card grid below the property gallery showing 5–8 nearby POIs with travel time (Valhalla route).
@if (nearbyAttractions.Count > 0)
{
<section class="nearby-attractions">
<h3>Things to do nearby</h3>
<div class="grid">
@foreach (var (poi, travelTime) in nearbyAttractions)
{
<div class="card">
<strong>@poi.Name</strong>
<small>@poi.Category · @poi.InterestScore.ToString("P0") interesting</small>
<p>@((MarkupString)(poi.Description ?? ""))</p>
<small>@travelTime.DistanceKm km · @travelTime.DurationMin min by car</small>
</div>
}
</div>
</section>
}
@code {
private List<(Poi Poi, RouteResult Route)> nearbyAttractions = new();
protected override async Task OnParametersSetAsync()
{
var pois = await Geo.NearbyPoisAsync(
property.Location.Lat, property.Location.Lng,
withinKm: 30, limit: 8, theme: "tourism");
// Get travel times in parallel
var tasks = pois.Select(async p => (Poi: p, Route: await Geo.RouteAsync(
property.Location.Lat, property.Location.Lng,
p.Lat, p.Lng,
RouteMode.Auto)));
nearbyAttractions = (await Task.WhenAll(tasks)).ToList();
}
}
Cost-conscious version: skip the per-POI route call and just show
poi.Meters(already returned by the nearby endpoint). Trade some accuracy (straight-line vs road) for speed.
2.3 — Destination radius search
Problem: Home.razor:12-143 and Search.razor take a destination string only — exact match. Users searching "Kruger" don't see lodges 30 km outside the park gate.
Fix: Geocode the destination, then run the property search with a radius around the centroid. Lift radius from a slider on Search.razor.
// Search.razor.cs
private async Task<SearchResults> RunSearch(string destination, double? radiusKm)
{
// 1. Resolve destination text -> centroid (with fuzzy fallback)
var places = await Geo.GeocodeAsync(destination, limit: 1);
if (places.Count == 0)
places = await Geo.SearchPlacesAsync(destination, limit: 1); // fuzzy fallback
if (places.Count == 0)
return SearchResults.Empty($"Couldn't find \"{destination}\"");
var center = places[0];
// 2. Run existing property search with bounding box around (center.Lat, center.Lng)
return await PropertySearch.SearchInRadiusAsync(center.Lat, center.Lng, radiusKm ?? 25);
}
2.4 — Travel-time matrix on Search results
Problem: Search results show price and rating, but nothing about how far the property is from a reference point (airport, CBD, user's location).
Fix: Add an optional "How far from..." dropdown. Run route for each result row in parallel (capped at ~20 properties at a time).
private async Task EnrichWithTravelTime(IEnumerable<Property> results, double refLat, double refLng)
{
var batch = results.Take(20);
var routes = await Task.WhenAll(batch.Select(p =>
Geo.RouteAsync(refLat, refLng, p.Location.Lat, p.Location.Lng)
.ContinueWith(t => (Property: p, Route: t.IsCompletedSuccessfully ? t.Result : null))));
foreach (var (prop, route) in routes)
{
prop.TravelTime = route is null ? null
: $"{route.DistanceKm:F0} km · {route.DurationMin:F0} min from ref";
}
}
UI as an extra column / chip on each result row.
2.5 — Reverse-geocoded "near me" entry point
Problem: Mobile users opening Takemehome have no quick "what's near me" path. They have to type a destination cold.
Fix: A hero-button on Home.razor — "📍 Properties near you" — that grabs geolocation, reverse-geocodes to find the nearest city, and runs a radius search.
<button class="hero-btn" @onclick="ExploreNearby">📍 Properties near you</button>
@code {
private async Task ExploreNearby()
{
var pos = await Geolocation.GetCurrentAsync();
var places = await Geo.ReverseGeocodeAsync(pos.Lat, pos.Lng, limit: 1);
if (places.Count == 0) { /* fallback to "destination unknown" */ return; }
Nav.NavigateTo($"/search?destination={Uri.EscapeDataString(places[0].Name)}&radius=30");
}
}
2.6 — Road-trip itinerary builder
Problem: Takemehome has nothing for multi-city trips. Booking.com does. Users planning a 2-week Africa road trip leak to competitors.
Fix: A new Itinerary.razor page. User picks N stops; we use discover_quest to suggest POIs at each stop, and route to link the stops with driving times. Each leg shows recommended properties at the destination.
// At each leg of the itinerary
foreach (var (from, to) in legs)
{
var leg = await Geo.RouteAsync(from.Lat, from.Lng, to.Lat, to.Lng, RouteMode.Auto);
var nearbyAttractionsAtDest = await Geo.NearbyPoisAsync(to.Lat, to.Lng, withinKm: 30, limit: 5);
var propertiesAtDest = await PropertySearch.SearchInRadiusAsync(to.Lat, to.Lng, 20);
// ... assemble the itinerary view
}
2.7 — AI-generated travel guides via B! (Butler)
Problem: Long tail SEO traffic ("best weekend getaways from Pretoria") is dominated by content sites.
Fix: Register geo_mcp as a Butler MCP source. Butler then composes (reverse_geocode → discover_quest) to generate fresh, on-demand guides for any query.
// in butler's MCP config (Butler / B! AI ops settings)
{
"mcpServers": {
"geo-global": {
"url": "http://geo_mcp:8000/sse",
"transport": "sse"
}
}
}
Once registered, B! can answer prompts like "Plan a 3-day weekend break from Pretoria, drivable, with at least 2 history sites and 1 wildlife stop" end-to-end by composing the tools. See CircleAI / Butler docs for how the skill builder consumes MCP tool definitions.
3. Common patterns across both apps
3.1 — Graceful degradation when outside Africa
Routing fails outside Africa. Detect and offer a fallback (link to Google Maps, hide the directions button, etc.):
try
{
var route = await Geo.RouteAsync(fromLat, fromLng, toLat, toLng);
// render maneuvers
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.BadRequest)
{
// outside Africa — show fallback
fallbackLink = $"https://www.google.com/maps/dir/{fromLat},{fromLng}/{toLat},{toLng}";
}
3.2 — Caching at the call site
The server caches geocoding for 24h. But every UI re-render shouldn't re-call. Memoize at the component level using IMemoryCache:
[Inject] IMemoryCache Cache { get; set; } = default!;
private async Task<List<Place>> ReverseGeocodeCached(double lat, double lng)
{
var key = $"geo-rev-{lat:F4}-{lng:F4}";
if (Cache.TryGetValue<List<Place>>(key, out var cached) && cached is not null)
return cached;
var result = await Geo.ReverseGeocodeAsync(lat, lng);
Cache.Set(key, result, TimeSpan.FromHours(6));
return result;
}
3.3 — Handling slow / no network (CircleAether mesh fallback)
Both apps run over CircleAether mesh in offline mode. GeoGlobal calls will fail — wrap them defensively. Return a cached/local result when the network is mesh-only, and queue the fresh call when internet comes back:
if (Aether.IsMeshOnly)
{
// Use last-known geocode/POI from local cache
return await LocalGeoCache.GetReverseAsync(lat, lng);
}
3.4 — Telemetry
Log all GeoGlobal calls into BigBruh telemetry so we can see usage, p99 latency, error rates. The HttpClient already inherits the app's IHttpMessageHandlerFactory — add a LoggingHandler if not already global.
4. Migration order (recommended)
Both apps. Smallest, lowest-risk changes first.
- (both) Add the
IGeoGlobalClientregistration to DI. Ship it. No UI changes yet — just makes the client available. - TagMe Auto-fill address on
CreatePOI.razor(Section 1.1). Tiny UI change, big UX win. - TagMe Smart venue suggestions on check-in (Section 1.2).
- Takemehome Embed map on PropertyDetail with POI overlay (Section 2.1) — the single highest-revenue change.
- Takemehome "Nearby attractions" section on PropertyDetail (Section 2.2).
- TagMe Turn-by-turn to events (Section 1.3).
- Takemehome Destination radius search (Section 2.3).
- (both) Register MCP with Butler so B! gains geographic reasoning.
Then circle back for the larger items (Takemehome itinerary builder, TagMe trending landmarks carousel).