Added mapbox integration
Deploy / deploy-dev (push) Successful in 2m33s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
blaisadmin
2026-06-29 19:50:08 -04:00
parent 9ddd85b5c4
commit 7ef20ab0fb
7 changed files with 434 additions and 213 deletions
+195 -209
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import birdSilhouette from './assets/bird-silhouette.jpg';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
import flockPalTextArt from './assets/flockpal-text.png';
@@ -17,6 +17,7 @@ type IntegrationTokenScope = 'read_only' | 'read_write';
type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
type VerifiedLocationDetails = {
label?: string | null;
city: string;
region: string;
country: string;
@@ -24,9 +25,18 @@ type VerifiedLocationDetails = {
latitude: number | null;
longitude: number | null;
precision: 'city' | 'region' | 'country';
provider?: 'mapbox' | null;
providerPlaceId?: string | null;
verifiedAt?: string;
};
type LocationSearchState = {
query: string;
results: VerifiedLocationDetails[];
searching: boolean;
error: string;
};
type Bird = {
id: string;
workspaceId?: number;
@@ -713,6 +723,7 @@ const parseBirdImportRows = (rows: Record<string, unknown>[]): BirdImportPreview
};
const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
label: '',
city: '',
region: '',
country: '',
@@ -720,11 +731,21 @@ const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
latitude: null,
longitude: null,
precision: 'city',
provider: null,
providerPlaceId: '',
};
const emptyLocationSearchState: LocationSearchState = {
query: '',
results: [],
searching: false,
error: '',
};
const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetails> | null | undefined): VerifiedLocationDetails => ({
...emptyVerifiedLocationDetails,
...details,
label: details?.label ?? '',
city: details?.city ?? '',
region: details?.region ?? '',
country: details?.country ?? '',
@@ -732,10 +753,95 @@ const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetai
latitude: typeof details?.latitude === 'number' ? details.latitude : null,
longitude: typeof details?.longitude === 'number' ? details.longitude : null,
precision: details?.precision ?? 'city',
provider: details?.provider ?? null,
providerPlaceId: details?.providerPlaceId ?? '',
});
const formatVerifiedLocationLabel = (details: VerifiedLocationDetails) =>
[details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', ');
details.label?.trim() || [details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', ');
type VerifiedLocationSearchFieldProps = {
label: string;
location: VerifiedLocationDetails;
fallbackLabel?: string | null;
searchState: LocationSearchState;
onSearchStateChange: (state: LocationSearchState) => void;
onSearch: () => void;
onSelect: (location: VerifiedLocationDetails) => void;
onClear: () => void;
};
const VerifiedLocationSearchField = ({
label,
location,
fallbackLabel,
searchState,
onSearchStateChange,
onSearch,
onSelect,
onClear,
}: VerifiedLocationSearchFieldProps) => {
const selectedLabel = formatVerifiedLocationLabel(location) || fallbackLabel || '';
return (
<div className="verified-location-field wide-field">
<div className="verified-location-label">
<span>{label}</span>
<div className="verified-location-search-row">
<input
aria-label={label}
value={searchState.query}
onChange={(event) => onSearchStateChange({ ...searchState, query: event.target.value, error: '' })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSearch();
}
}}
placeholder="Search city, region, or country"
/>
<button className="secondary-button" type="button" onClick={onSearch} disabled={searchState.searching}>
{searchState.searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
{selectedLabel ? (
<div className="verified-location-selected">
<div>
<strong>{selectedLabel}</strong>
<small>{location.provider === 'mapbox' ? 'Verified by Mapbox' : 'Saved location'}</small>
</div>
<button className="secondary-button" type="button" onClick={onClear}>
Clear
</button>
</div>
) : null}
{searchState.error ? (
<p className="error-banner" role="alert">
{searchState.error}
</p>
) : null}
{searchState.results.length ? (
<div className="verified-location-results">
{searchState.results.map((result) => {
const resultLabel = formatVerifiedLocationLabel(result);
return (
<button
key={result.providerPlaceId || resultLabel}
className="verified-location-result"
type="button"
onClick={() => onSelect(result)}
>
<strong>{resultLabel}</strong>
<small>{result.precision === 'city' ? 'City-level' : result.precision === 'region' ? 'Region-level' : 'Country-level'}</small>
</button>
);
})}
</div>
) : null}
</div>
);
};
const emptyBirdForm: BirdFormState = {
name: '',
@@ -1846,6 +1952,8 @@ function App() {
const [birdTimelineLoading, setBirdTimelineLoading] = useState(false);
const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false);
const [birdTimelineNotice, setBirdTimelineNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
const [birdLocationSearch, setBirdLocationSearch] = useState<LocationSearchState>(emptyLocationSearchState);
const [timelineLocationSearch, setTimelineLocationSearch] = useState<LocationSearchState>(emptyLocationSearchState);
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
@@ -1954,6 +2062,7 @@ function App() {
setBirdTimelineEvents([]);
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
setBirdTimelineNotice(null);
setTimelineLocationSearch(emptyLocationSearchState);
}, [selectedBirdId]);
useEffect(() => {
@@ -2893,6 +3002,7 @@ function App() {
if (!editingBird) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdLocationSearch(emptyLocationSearchState);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
@@ -2900,6 +3010,7 @@ function App() {
}
setBirdForm(toBirdForm(editingBird));
setBirdLocationSearch(emptyLocationSearchState);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
@@ -2928,6 +3039,7 @@ function App() {
setEditingBirdId('');
setBirdEditorOpen(true);
setBirdForm(emptyBirdForm);
setBirdLocationSearch(emptyLocationSearchState);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
@@ -2935,6 +3047,42 @@ function App() {
setActivePage('flock');
};
const searchVerifiedLocations = async (
searchState: LocationSearchState,
setSearchState: Dispatch<SetStateAction<LocationSearchState>>,
) => {
const query = searchState.query.replace(/\s+/g, ' ').trim();
if (query.length < 3) {
setSearchState((current) => ({ ...current, error: 'Enter at least 3 characters to search.', results: [] }));
return;
}
setSearchState((current) => ({ ...current, searching: true, error: '', results: [] }));
try {
const response = await apiFetch(`/locations/search?q=${encodeURIComponent(query)}`, authToken);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to search locations.'));
}
const data = (await readJsonSafely<{ results?: VerifiedLocationDetails[] }>(response)) ?? {};
setSearchState((current) => ({
...current,
searching: false,
results: (data.results ?? []).map(normalizeVerifiedLocationDetails),
error: data.results?.length ? '' : 'No matching city, region, or country found.',
}));
} catch (searchError) {
setSearchState((current) => ({
...current,
searching: false,
error: searchError instanceof Error ? searchError.message : 'Unable to search locations.',
}));
}
};
const handleAuthSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
@@ -6580,105 +6728,23 @@ function App() {
<p className="eyebrow">Location</p>
<h3>Mappable location</h3>
</div>
<label>
City
<input
value={birdForm.locationDetails.city}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: { ...birdForm.locationDetails, city: event.target.value, precision: 'city' },
})
}
placeholder="City"
/>
</label>
<label>
Region
<input
value={birdForm.locationDetails.region}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: { ...birdForm.locationDetails, region: event.target.value },
})
}
placeholder="State, province, or region"
/>
</label>
<label>
Country
<input
value={birdForm.locationDetails.country}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: { ...birdForm.locationDetails, country: event.target.value },
})
}
placeholder="Country"
/>
</label>
<label>
Country code
<input
value={birdForm.locationDetails.countryCode}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: { ...birdForm.locationDetails, countryCode: event.target.value.toUpperCase().slice(0, 2) },
})
}
placeholder="US"
maxLength={2}
/>
</label>
<label>
Latitude
<input
type="number"
step="0.0001"
min="-90"
max="90"
value={birdForm.locationDetails.latitude ?? ''}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: {
...birdForm.locationDetails,
latitude: event.target.value ? Number(event.target.value) : null,
},
})
}
/>
</label>
<label>
Longitude
<input
type="number"
step="0.0001"
min="-180"
max="180"
value={birdForm.locationDetails.longitude ?? ''}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: {
...birdForm.locationDetails,
longitude: event.target.value ? Number(event.target.value) : null,
},
})
}
/>
</label>
<label className="wide-field">
Display label
<input
value={birdForm.locationLabel}
onChange={(event) => setBirdForm({ ...birdForm, locationLabel: event.target.value })}
placeholder="City, region, or country"
/>
</label>
<VerifiedLocationSearchField
label="Location"
location={birdForm.locationDetails}
fallbackLabel={birdForm.locationLabel}
searchState={birdLocationSearch}
onSearchStateChange={setBirdLocationSearch}
onSearch={() => searchVerifiedLocations(birdLocationSearch, setBirdLocationSearch)}
onSelect={(location) => {
const locationLabel = formatVerifiedLocationLabel(location);
setBirdForm({ ...birdForm, locationDetails: location, locationLabel });
setBirdLocationSearch({ ...emptyLocationSearchState, query: locationLabel });
}}
onClear={() => {
setBirdForm({ ...birdForm, locationDetails: emptyVerifiedLocationDetails, locationLabel: '' });
setBirdLocationSearch(emptyLocationSearchState);
}}
/>
<div className="segmented-field wide-field">
<span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
@@ -7899,6 +7965,9 @@ function App() {
value={birdTimelineEventForm.eventType}
onChange={(event) => {
const eventType = event.target.value as BirdTimelineEventFormState['eventType'];
if (eventType === 'owner_changed') {
setTimelineLocationSearch(emptyLocationSearchState);
}
setBirdTimelineEventForm({
...birdTimelineEventForm,
eventType,
@@ -7924,114 +7993,31 @@ function App() {
/>
</label>
{birdTimelineEventForm.eventType !== 'owner_changed' ? (
<>
<label>
City
<input
value={birdTimelineEventForm.locationDetails.city}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: {
...birdTimelineEventForm.locationDetails,
city: event.target.value,
precision: 'city',
},
})
}
placeholder={selectedBird.locationDetails?.city || 'City'}
/>
</label>
<label>
Region
<input
value={birdTimelineEventForm.locationDetails.region}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: { ...birdTimelineEventForm.locationDetails, region: event.target.value },
})
}
placeholder={selectedBird.locationDetails?.region || 'State or region'}
/>
</label>
<label>
Country
<input
value={birdTimelineEventForm.locationDetails.country}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: { ...birdTimelineEventForm.locationDetails, country: event.target.value },
})
}
placeholder={selectedBird.locationDetails?.country || 'Country'}
/>
</label>
<label>
Country code
<input
value={birdTimelineEventForm.locationDetails.countryCode}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: {
...birdTimelineEventForm.locationDetails,
countryCode: event.target.value.toUpperCase().slice(0, 2),
},
})
}
placeholder={selectedBird.locationDetails?.countryCode || 'US'}
maxLength={2}
/>
</label>
<label>
Latitude
<input
type="number"
step="0.0001"
min="-90"
max="90"
value={birdTimelineEventForm.locationDetails.latitude ?? ''}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: {
...birdTimelineEventForm.locationDetails,
latitude: event.target.value ? Number(event.target.value) : null,
},
})
}
/>
</label>
<label>
Longitude
<input
type="number"
step="0.0001"
min="-180"
max="180"
value={birdTimelineEventForm.locationDetails.longitude ?? ''}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: {
...birdTimelineEventForm.locationDetails,
longitude: event.target.value ? Number(event.target.value) : null,
},
})
}
/>
</label>
<label className="wide-field">
Display label
<input
value={birdTimelineEventForm.locationLabel}
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, locationLabel: event.target.value })}
placeholder={selectedBird.locationLabel || 'City, region, or country'}
/>
</label>
</>
<VerifiedLocationSearchField
label="Location"
location={birdTimelineEventForm.locationDetails}
fallbackLabel={birdTimelineEventForm.locationLabel || selectedBird.locationLabel}
searchState={timelineLocationSearch}
onSearchStateChange={setTimelineLocationSearch}
onSearch={() => searchVerifiedLocations(timelineLocationSearch, setTimelineLocationSearch)}
onSelect={(location) => {
const locationLabel = formatVerifiedLocationLabel(location);
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: location,
locationLabel,
});
setTimelineLocationSearch({ ...emptyLocationSearchState, query: locationLabel });
}}
onClear={() => {
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: emptyVerifiedLocationDetails,
locationLabel: '',
});
setTimelineLocationSearch(emptyLocationSearchState);
}}
/>
) : null}
{birdTimelineEventForm.eventType === 'location_updated' ? (
<label className="checkbox-row">
+63 -1
View File
@@ -2040,6 +2040,67 @@ label {
font-size: 0.95rem;
}
.verified-location-field {
display: grid;
gap: 0.75rem;
}
.verified-location-label {
display: grid;
gap: 0.35rem;
font-weight: 600;
}
.verified-location-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: end;
}
.verified-location-selected {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 0.95rem;
border: 1px solid rgba(35, 138, 90, 0.2);
border-radius: 8px;
background: rgba(35, 138, 90, 0.08);
}
.verified-location-selected div {
display: grid;
gap: 0.15rem;
}
.verified-location-selected small,
.verified-location-result small {
color: var(--muted);
}
.verified-location-results {
display: grid;
gap: 0.45rem;
}
.verified-location-result {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.8rem 0.9rem;
text-align: left;
color: var(--ink);
border: 1px solid rgba(53, 129, 98, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
}
.verified-location-result:hover {
border-color: rgba(39, 105, 179, 0.28);
background: rgba(255, 255, 255, 0.78);
}
.toggle-card input[type="checkbox"] {
width: 20px;
height: 20px;
@@ -2374,7 +2435,8 @@ label {
.inline-form,
.profile-hero,
.photo-editor,
.settings-nested-grid {
.settings-nested-grid,
.verified-location-search-row {
grid-template-columns: 1fr;
}