Skip to content

Commit c5abebf

Browse files
bryanfawcettclaude
andauthored
fix(events): store venue placeId + adopt the Shamwari icon (#121)
* fix(events): store the resolved venue placeId, report catalogue misses to fundi-ingestion createEventForPerson/updateEventForPerson hardcoded placeId: null on every event, so the built EventVenueCard/VenueVerifyCta (Kweli) surfaces had nothing to ever resolve. Thread the placeId a DB hit or a promoted OSM suggestion already carries from location-modal.tsx through the create/edit forms into the event doc, clearing it if the venue is hand-edited afterward. Additively, when geocodeAddress() falls through to Nominatim, fire a best-effort search_miss task at the fundi-ingestion worker so it bulk-seeds the surrounding area (fuller Overpass/enrichment pass than nhimbe's own single-pick promotion) for future searches, without blocking this request. * feat(ui): adopt the hex-cluster mark as the Shamwari/AI icon New ShamwariIcon (src/components/ui/shamwari-icon.tsx) replaces the generic lucide Bot glyph everywhere it stood in for the Shamwari assistant: the description wizard, the "Ask Shamwari" trigger, the search pill, and the Shamwari answer block in NyuchiSearchView. Left the Sparkles icons on upgrade-to-pro-alert and the avatar picker's Gravatar button alone — neither is actually AI-branded. Idle pulse animation via the harness's motion config (skipped under prefers-reduced-motion), wrapped in SectionErrorBoundary so a render failure in the icon can't take an AI-adjacent surface down with it, and defaults to role="img" + aria-label="Shamwari AI" unless the caller marks it decorative with aria-hidden (the common case, next to visible "Shamwari" text). --------- Co-authored-by: Bryan Fawcett <noreply@anthropic.com>
1 parent cd9e1e4 commit c5abebf

11 files changed

Lines changed: 250 additions & 20 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ Set in Vercel (prod + preview) and locally in `.env.local`:
337337
- `SHAMWARI_AI_GATEWAY_URL`, `SHAMWARI_AI_GATEWAY_TOKEN` — Cloudflare AI Gateway base + provider bearer; optional `SHAMWARI_AI_GATEWAY_AUTH_TOKEN` for the authenticated gateway.
338338
- `RESEND_API_KEY` — server-only; used for transactional email via Resend (`src/lib/email/`). Sends from the verified `notify.mukoko.com` domain (`events@notify.mukoko.com`). When unset, email sends are skipped (never throw).
339339
- `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` — server-only R2 S3 API credentials for cover-image uploads (`src/lib/r2.ts`). `R2_BUCKET` *(optional)* defaults to `mukoko-storage`. When unset, `/api/media/upload` returns 503 and uploads fall back to a gradient cover.
340+
- `FUNDI_API_TOKEN` — server-only bearer token for the `fundi-ingestion` Cloudflare Worker's `POST /tasks` endpoint. `src/app/actions/geocode.ts` fires a best-effort `search_miss` task whenever a places-catalogue search falls through to OSM Nominatim, so fundi-ingestion bulk-seeds the surrounding area (Overpass + Plus Codes/what3words/Wikidata/AI-description enrichment) into the shared `places.places`/`entity.entities` collections for next time. When unset, reporting is skipped silently — nhimbe's own address search is unaffected either way. `FUNDI_INGESTION_URL` *(optional)* defaults to `https://fundi-ingestion.nyuchi.dev`.
340341
- `NEXT_PUBLIC_SITE_URL` — the **primary/canonical** public origin (default `https://events.mukoko.com`). See "Dual-domain" below — this is the single origin all self-referential URLs (canonical tags, OG images, sitemap, robots, JSON-LD, `.well-known` resource) point at, resolved once in `src/lib/site-url.ts` (`SITE_URL`).
341342
- `NEXT_PUBLIC_ASSETS_URL` *(optional)* — override the R2 assets host (defaults to `https://assets-s001.mukoko.com`).
342343
- `ADMIN_URL` *(optional)* — where `/admin*` redirects (defaults to `https://admin.events.mukoko.com`). The admin app itself is a separate repo (`nyuchi/mukoko-events-admin`) and Vercel project with its own env.

src/app/actions/events.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ export interface CreateEventActionInput {
4949
streetAddress?: string;
5050
addressLocality?: string;
5151
addressCountry?: string;
52+
/**
53+
* The `places.places._id` behind the chosen venue, when the location picker
54+
* resolved one (a catalogue hit, or a promoted OSM suggestion) — cleared by
55+
* the form the moment someone hand-edits the venue/address fields, since a
56+
* stale id would point at the wrong place. Never validated server-side
57+
* beyond shape: it's a display/verification link, not an authorization
58+
* boundary, and a bad id just makes `EventVenueCard`/Kweli lookups no-op.
59+
*/
60+
placeId?: string | null;
5261
/** IANA timezone the venue resolves to (e.g. "Africa/Harare"). */
5362
timezone?: string | null;
5463
meetingUrl?: string | null;
@@ -213,7 +222,7 @@ export async function createEventForPerson(
213222
totalAttendeeCount: 0,
214223
surfaceContext: "mukoko_events",
215224
location,
216-
placeId: null,
225+
placeId: input.isOnline ? null : input.placeId?.trim() || null,
217226
circleId: null,
218227
calendarId: null,
219228
offers,
@@ -285,6 +294,8 @@ export interface UpdateEventInput {
285294
streetAddress?: string;
286295
addressLocality?: string;
287296
addressCountry?: string;
297+
/** See `CreateEventActionInput.placeId`. */
298+
placeId?: string | null;
288299
/** IANA timezone the venue resolves to (e.g. "Africa/Harare"). */
289300
timezone?: string | null;
290301
meetingUrl?: string | null;
@@ -406,6 +417,7 @@ export async function updateEventForPerson(
406417
},
407418
timezone: patch.timezone ?? undefined,
408419
};
420+
set.placeId = patch.isOnline ? null : patch.placeId?.trim() || null;
409421
contentChanged = true;
410422
}
411423

src/app/actions/geocode.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,43 @@ async function searchNominatim(query: string, limit: number): Promise<GeocodeSug
278278
}
279279
}
280280

281+
const FUNDI_INGESTION_URL = process.env.FUNDI_INGESTION_URL ?? "https://fundi-ingestion.nyuchi.dev";
282+
const SEARCH_MISS_RADIUS_METERS = 3000;
283+
284+
/**
285+
* Report a places-catalogue miss to the fundi-ingestion worker so it bulk-seeds
286+
* the surrounding area from OSM/Overpass for next time (richer enrichment —
287+
* Plus Codes, what3words, Wikidata, AI descriptions — than nhimbe's own
288+
* single-pick promotion below can do). Fire-and-forget in effect: awaited so a
289+
* serverless invocation doesn't get torn down mid-request, but every failure
290+
* (missing token, network, non-2xx) is swallowed — a reporting hiccup must
291+
* never affect the address search the caller actually asked for.
292+
*/
293+
async function reportSearchMiss(query: string, near: GeocodeSuggestion): Promise<void> {
294+
const token = process.env.FUNDI_API_TOKEN;
295+
if (!token) return;
296+
try {
297+
await fetch(`${FUNDI_INGESTION_URL}/tasks`, {
298+
method: "POST",
299+
headers: {
300+
"content-type": "application/json",
301+
authorization: `Bearer ${token}`,
302+
},
303+
body: JSON.stringify({
304+
region: {
305+
kind: "point_radius",
306+
center: [near.longitude, near.latitude],
307+
radiusMeters: SEARCH_MISS_RADIUS_METERS,
308+
},
309+
categories: "all",
310+
source: { kind: "search_miss", surface: "geocode-address", query },
311+
}),
312+
});
313+
} catch (e) {
314+
console.error("[mukoko] reportSearchMiss failed", e);
315+
}
316+
}
317+
281318
/**
282319
* Geocode a free-text address query.
283320
*
@@ -299,7 +336,9 @@ export async function geocodeAddress(
299336
const dbHits = await searchPlacesDb(q, limit);
300337
if (dbHits.length > 0) return dbHits;
301338

302-
return searchNominatim(q, limit);
339+
const osmHits = await searchNominatim(q, limit);
340+
if (osmHits.length > 0) await reportSearchMiss(q, osmHits[0]);
341+
return osmHits;
303342
}
304343

305344
export interface ReverseGeocodeResult {

src/app/events/[id]/edit/edit-event-form.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export function EditEventForm({ event }: EditEventFormProps) {
8989
const [selectedTimezone, setSelectedTimezone] = useState<string | null>(
9090
!wasOnline ? event.timezone ?? null : null,
9191
);
92+
const [placeId, setPlaceId] = useState<string | null>(!wasOnline ? event.placeId ?? null : null);
9293

9394
const [capacity, setCapacity] = useState<number | null>(event.maximumAttendeeCapacity ?? null);
9495
const [isFree, setIsFree] = useState(!event.offers?.url);
@@ -200,6 +201,7 @@ export function EditEventForm({ event }: EditEventFormProps) {
200201
isOnline,
201202
venue: venue.trim(),
202203
streetAddress: address.trim(),
204+
placeId,
203205
addressLocality: selectedCity?.addressLocality,
204206
addressCountry: selectedCity?.addressCountry,
205207
timezone: isOnline ? null : selectedTimezone,
@@ -369,6 +371,8 @@ export function EditEventForm({ event }: EditEventFormProps) {
369371
setAddress={setAddress}
370372
selectedCity={selectedCity}
371373
setSelectedCity={setSelectedCity}
374+
placeId={placeId}
375+
setPlaceId={setPlaceId}
372376
cities={cities}
373377
selectedTimezone={selectedTimezone}
374378
setSelectedTimezone={setSelectedTimezone}

src/app/events/create/create-event-form.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ export default function CreateEventForm() {
161161
const [address, setAddress] = useState("");
162162
const [addressSearch, setAddressSearch] = useState("");
163163
const [selectedCity, setSelectedCity] = useState<{ addressLocality: string; addressCountry: string } | null>(null);
164+
// The places.places._id behind the venue, when the picker resolved one.
165+
const [placeId, setPlaceId] = useState<string | null>(null);
164166
// Resolved from the venue when a location is picked (see LocationModal); an
165167
// online event or one with no location yet falls back to the organiser's
166168
// own browser timezone.
@@ -366,6 +368,7 @@ export default function CreateEventForm() {
366368
isOnline,
367369
venue: venue.trim(),
368370
streetAddress: address.trim(),
371+
placeId,
369372
addressLocality: selectedCity?.addressLocality,
370373
addressCountry: selectedCity?.addressCountry,
371374
timezone: isOnline ? null : selectedTimezone,
@@ -614,7 +617,7 @@ export default function CreateEventForm() {
614617

615618
{/* Modals */}
616619
<DateTimeModal isOpen={showDateModal} onClose={() => { setShowDateModal(false); touchForm(); }} eventDate={eventDate} setEventDate={setEventDate} startTime={startTime} setStartTime={setStartTime} endTime={endTime} setEndTime={setEndTime} />
617-
<LocationModal isOpen={showLocationModal} onClose={() => { setShowLocationModal(false); touchForm(); }} isOnline={isOnline} setIsOnline={setIsOnline} meetingPlatform={meetingPlatform} setMeetingPlatform={setMeetingPlatform} meetingUrl={meetingUrl} setMeetingUrl={setMeetingUrl} addressSearch={addressSearch} setAddressSearch={setAddressSearch} venue={venue} setVenue={setVenue} address={address} setAddress={setAddress} selectedCity={selectedCity} setSelectedCity={setSelectedCity} cities={cities} selectedTimezone={selectedTimezone} setSelectedTimezone={setSelectedTimezone} />
620+
<LocationModal isOpen={showLocationModal} onClose={() => { setShowLocationModal(false); touchForm(); }} isOnline={isOnline} setIsOnline={setIsOnline} meetingPlatform={meetingPlatform} setMeetingPlatform={setMeetingPlatform} meetingUrl={meetingUrl} setMeetingUrl={setMeetingUrl} addressSearch={addressSearch} setAddressSearch={setAddressSearch} venue={venue} setVenue={setVenue} address={address} setAddress={setAddress} selectedCity={selectedCity} setSelectedCity={setSelectedCity} cities={cities} selectedTimezone={selectedTimezone} setSelectedTimezone={setSelectedTimezone} placeId={placeId} setPlaceId={setPlaceId} />
618621
<CategoryModal isOpen={showCategoryModal} onClose={() => { setShowCategoryModal(false); touchForm(); }} categories={categories} category={category} setCategory={setCategory} tags={tags} tagInput={tagInput} setTagInput={setTagInput} addTag={addTag} removeTag={removeTag} />
619622
<DescriptionModal isOpen={showDescriptionModal} onClose={() => { setShowDescriptionModal(false); touchForm(); }} description={description} setDescription={setDescription} eventName={eventName} category={category} isOnline={isOnline} />
620623
<TicketingModal isOpen={showPriceModal} onClose={() => { setShowPriceModal(false); touchForm(); }} isFree={isFree} setIsFree={setIsFree} ticketUrl={ticketUrl} setTicketUrl={setTicketUrl} />

src/components/modals/location-modal.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ interface LocationModalProps {
4040
/** IANA timezone the venue resolves to — drives what "3pm" means on submit. */
4141
selectedTimezone: string | null;
4242
setSelectedTimezone: (value: string | null) => void;
43+
/** The resolved `places.places._id` behind the venue, when there is one. */
44+
placeId: string | null;
45+
setPlaceId: (value: string | null) => void;
4346
}
4447

4548
export function LocationModal({
@@ -62,6 +65,8 @@ export function LocationModal({
6265
cities,
6366
selectedTimezone,
6467
setSelectedTimezone,
68+
placeId,
69+
setPlaceId,
6570
}: LocationModalProps) {
6671
return (
6772
<ResponsiveModal open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }} title="Event Location">
@@ -158,16 +163,20 @@ export function LocationModal({
158163
} else {
159164
setSelectedTimezone(null);
160165
}
161-
// Promote a confirmed OSM/Nominatim pick into the places
162-
// catalogue so the next search for this venue hits the DB
163-
// tier first — best-effort, fire-and-forget.
164-
if (
165-
components.source === "osm" &&
166+
if (components.source === "db") {
167+
// Already a real places.places._id.
168+
setPlaceId(components.placeId);
169+
} else if (
166170
components.osmType &&
167171
components.osmId !== undefined &&
168172
components.latitude !== undefined &&
169173
components.longitude !== undefined
170174
) {
175+
// The suggestion's placeId is a synthetic "osm:type/id"
176+
// placeholder until promoted — swap it for the real
177+
// places.places._id once ensurePlaceFromOsmSuggestion
178+
// creates (or finds) the catalogue row.
179+
setPlaceId(null);
171180
ensurePlaceFromOsmSuggestion({
172181
name: components.venue,
173182
address: components.address,
@@ -177,11 +186,16 @@ export function LocationModal({
177186
longitude: components.longitude,
178187
osmType: components.osmType,
179188
osmId: components.osmId,
180-
});
189+
}).then((resolvedId) => setPlaceId(resolvedId));
190+
} else {
191+
setPlaceId(null);
181192
}
182193
}}
183194
placeholder="Search for a venue or address..."
184195
/>
196+
{placeId && (
197+
<p className="text-xs text-text-tertiary mt-2">Matched to the places catalogue</p>
198+
)}
185199
</div>
186200

187201
{/* Divider */}
@@ -198,7 +212,7 @@ export function LocationModal({
198212
inputMode="text"
199213
autoComplete="organization"
200214
value={venue}
201-
onChange={(e) => setVenue(e.target.value)}
215+
onChange={(e) => { setVenue(e.target.value); setPlaceId(null); }}
202216
placeholder="e.g., Rainbow Towers Hotel"
203217
className="w-full px-4 py-3 bg-surface text-foreground placeholder:text-text-tertiary rounded-xl border border-border outline-none focus-visible:ring-2 focus-visible:ring-ring/50 text-base"
204218
/>
@@ -210,7 +224,7 @@ export function LocationModal({
210224
inputMode="text"
211225
autoComplete="street-address"
212226
value={address}
213-
onChange={(e) => setAddress(e.target.value)}
227+
onChange={(e) => { setAddress(e.target.value); setPlaceId(null); }}
214228
placeholder="Street address"
215229
className="w-full px-4 py-3 bg-surface text-foreground placeholder:text-text-tertiary rounded-xl border border-border outline-none focus-visible:ring-2 focus-visible:ring-ring/50 text-base"
216230
/>

src/components/ui/ai-description-wizard.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"use client";
22

33
import { useEffect, useRef, useState } from "react";
4-
import { Bot, ArrowRight, ArrowLeft, Loader2, RefreshCw, Check, X } from "lucide-react";
4+
import { ArrowRight, ArrowLeft, Loader2, RefreshCw, Check, X } from "lucide-react";
55
import { Button } from "./button";
66
import { Textarea } from "./textarea";
77
import { Badge } from "./badge";
8+
import { ShamwariIcon } from "./shamwari-icon";
89
import { UpgradeToProAlert } from "./upgrade-to-pro-alert";
910
import type { DescriptionContext, GeneratedDescription } from "@/lib/api";
1011
import { generateEventDescription, regenerateEventDescription } from "@/app/actions/ai";
@@ -274,7 +275,7 @@ export function AIDescriptionWizard({
274275
<div className="flex items-center gap-3">
275276
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center">
276277
{/* Shamwari AI glyph — sodalite mineral (intelligence, depth) */}
277-
<Bot className="dikdik w-5 h-5" />
278+
<ShamwariIcon className="dikdik w-5 h-5" aria-hidden="true" />
278279
</div>
279280
<div>
280281
<h3 className="font-semibold">Shamwari</h3>
@@ -401,7 +402,7 @@ export function AIDescriptionWizard({
401402
<div className="flex items-center gap-3">
402403
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center">
403404
{/* Shamwari AI glyph — sodalite mineral (intelligence, depth) */}
404-
<Bot className="dikdik w-5 h-5" />
405+
<ShamwariIcon className="dikdik w-5 h-5" aria-hidden="true" />
405406
</div>
406407
<div>
407408
<h3 className="font-semibold">Shamwari</h3>
@@ -481,7 +482,7 @@ export function AIDescriptionWizard({
481482
</>
482483
) : isLastStep ? (
483484
<>
484-
<Bot className="w-4 h-4" />
485+
<ShamwariIcon className="w-4 h-4" aria-hidden="true" />
485486
Generate Description
486487
</>
487488
) : (
@@ -525,7 +526,7 @@ export function AIDescriptionBadge({
525526
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded-xl text-xs font-medium transition-colors border border-primary/20 h-auto min-h-0"
526527
title="Ask Shamwari to help write your description"
527528
>
528-
<Bot className="w-3.5 h-3.5" />
529+
<ShamwariIcon className="w-3.5 h-3.5" aria-hidden="true" />
529530
Ask Shamwari
530531
<Badge variant="default" className="ml-0.5 px-1.5 py-0 text-[10px] leading-4">
531532
Pro

src/components/ui/nyuchi-search-view.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"use client";
22

33
import * as React from "react";
4-
import { Search, X, Bot, Loader2, Clock, TrendingUp } from "lucide-react";
4+
import { Search, X, Loader2, Clock, TrendingUp } from "lucide-react";
55
import { cn } from "@/lib/utils";
6+
import { ShamwariIcon } from "@/components/ui/shamwari-icon";
67
import { useNyuchiHarness } from "@/components/ui/harness";
78
import { FilterBar, type FilterOption } from "@/components/ui/filter-bar";
89
import { NyuchiListingCard, type NyuchiListingMeta } from "@/components/ui/nyuchi-listing-card";
@@ -161,7 +162,7 @@ export function NyuchiSearchView({
161162
{/* Shamwari AI summary */}
162163
{aiSummary && (
163164
<div className="flex items-start gap-3 rounded-[var(--radius-lg,14px)] border border-primary/20 bg-primary/10 p-4">
164-
<Bot className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden />
165+
<ShamwariIcon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden />
165166
<p className="text-sm leading-relaxed text-foreground">{aiSummary}</p>
166167
</div>
167168
)}

src/components/ui/search-pill.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as React from "react";
22
import Link from "next/link";
3-
import { Search, Bot } from "lucide-react";
3+
import { Search } from "lucide-react";
44
import { cn } from "@/lib/utils";
5+
import { ShamwariIcon } from "@/components/ui/shamwari-icon";
56

67
interface SearchPillProps {
78
/** Where the pill routes. Defaults to the AI search page. */
@@ -49,7 +50,7 @@ function SearchPill({
4950
<span className="duiker">{placeholder}</span>
5051
<span className="impala" aria-hidden />
5152
<span className="springbok">
52-
<Bot className="w-[11px] h-[11px]" strokeWidth={2.2} aria-hidden />
53+
<ShamwariIcon className="w-[11px] h-[11px]" strokeWidth={2.2} aria-hidden />
5354
AI
5455
</span>
5556
</Link>

0 commit comments

Comments
 (0)