Added mapbox integration
This commit is contained in:
@@ -14,6 +14,7 @@ PHOTO_DELIVERY_MODE=proxy
|
|||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
BACKEND_URL=http://localhost:5000
|
BACKEND_URL=http://localhost:5000
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
|
MAPBOX_ACCESS_TOKEN=
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
TRUST_PROXY=
|
TRUST_PROXY=
|
||||||
ADMIN_EMAILS=corey@blaishome.online
|
ADMIN_EMAILS=corey@blaishome.online
|
||||||
|
|||||||
+156
-2
@@ -288,6 +288,7 @@ const birdProfileListSchema = z
|
|||||||
|
|
||||||
const verifiedLocationDetailsSchema = z
|
const verifiedLocationDetailsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
label: z.string().trim().max(220).optional().or(z.literal('')),
|
||||||
city: z.string().trim().max(120).optional().or(z.literal('')),
|
city: z.string().trim().max(120).optional().or(z.literal('')),
|
||||||
region: z.string().trim().max(120).optional().or(z.literal('')),
|
region: z.string().trim().max(120).optional().or(z.literal('')),
|
||||||
country: z.string().trim().max(120).optional().or(z.literal('')),
|
country: z.string().trim().max(120).optional().or(z.literal('')),
|
||||||
@@ -295,10 +296,16 @@ const verifiedLocationDetailsSchema = z
|
|||||||
latitude: z.coerce.number().min(-90).max(90).optional().nullable().or(z.literal('')),
|
latitude: z.coerce.number().min(-90).max(90).optional().nullable().or(z.literal('')),
|
||||||
longitude: z.coerce.number().min(-180).max(180).optional().nullable().or(z.literal('')),
|
longitude: z.coerce.number().min(-180).max(180).optional().nullable().or(z.literal('')),
|
||||||
precision: z.enum(['city', 'region', 'country']).optional(),
|
precision: z.enum(['city', 'region', 'country']).optional(),
|
||||||
|
provider: z.literal('mapbox').optional().nullable(),
|
||||||
|
providerPlaceId: z.string().trim().max(220).optional().or(z.literal('')),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.nullable();
|
.nullable();
|
||||||
|
|
||||||
|
const locationSearchSchema = z.object({
|
||||||
|
q: z.string().trim().min(3).max(120),
|
||||||
|
});
|
||||||
|
|
||||||
const birdSchema = z.object({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
@@ -367,12 +374,16 @@ const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) =
|
|||||||
const latitude = typeof value.latitude === 'number' ? Number(value.latitude.toFixed(4)) : null;
|
const latitude = typeof value.latitude === 'number' ? Number(value.latitude.toFixed(4)) : null;
|
||||||
const longitude = typeof value.longitude === 'number' ? Number(value.longitude.toFixed(4)) : null;
|
const longitude = typeof value.longitude === 'number' ? Number(value.longitude.toFixed(4)) : null;
|
||||||
const precision = value.precision ?? (city ? 'city' : region ? 'region' : country ? 'country' : null);
|
const precision = value.precision ?? (city ? 'city' : region ? 'region' : country ? 'country' : null);
|
||||||
|
const label = value.label?.trim() || [city, region, country].filter(Boolean).join(', ') || null;
|
||||||
|
const provider = value.provider ?? null;
|
||||||
|
const providerPlaceId = value.providerPlaceId?.trim() || null;
|
||||||
|
|
||||||
if (!city && !region && !country && !countryCode && latitude === null && longitude === null) {
|
if (!label && !city && !region && !country && !countryCode && latitude === null && longitude === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
label,
|
||||||
city,
|
city,
|
||||||
region,
|
region,
|
||||||
country,
|
country,
|
||||||
@@ -380,12 +391,93 @@ const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) =
|
|||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
precision,
|
precision,
|
||||||
|
provider,
|
||||||
|
providerPlaceId,
|
||||||
verifiedAt: new Date().toISOString(),
|
verifiedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatVerifiedLocationLabel = (details: ReturnType<typeof normalizeVerifiedLocationDetails>) =>
|
const formatVerifiedLocationLabel = (details: ReturnType<typeof normalizeVerifiedLocationDetails>) =>
|
||||||
details ? [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null;
|
details ? details.label || [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null;
|
||||||
|
|
||||||
|
type VerifiedLocationSearchResult = NonNullable<ReturnType<typeof normalizeVerifiedLocationDetails>>;
|
||||||
|
|
||||||
|
type MapboxGeocodeFeature = {
|
||||||
|
id?: string;
|
||||||
|
geometry?: {
|
||||||
|
coordinates?: [number, number];
|
||||||
|
};
|
||||||
|
properties?: {
|
||||||
|
mapbox_id?: string;
|
||||||
|
feature_type?: string;
|
||||||
|
name?: string;
|
||||||
|
full_address?: string;
|
||||||
|
place_formatted?: string;
|
||||||
|
coordinates?: {
|
||||||
|
longitude?: number;
|
||||||
|
latitude?: number;
|
||||||
|
};
|
||||||
|
context?: {
|
||||||
|
place?: { name?: string };
|
||||||
|
locality?: { name?: string };
|
||||||
|
region?: { name?: string; region_code?: string; region_code_full?: string };
|
||||||
|
country?: { name?: string; country_code?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapboxGeocodeResponse = {
|
||||||
|
features?: MapboxGeocodeFeature[];
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapboxAccessToken = process.env.MAPBOX_ACCESS_TOKEN?.trim() ?? '';
|
||||||
|
const allowedMapboxLocationTypes = new Set(['place', 'locality', 'region', 'country']);
|
||||||
|
const mapboxLocationCache = new Map<string, { expiresAt: number; results: VerifiedLocationSearchResult[] }>();
|
||||||
|
const mapboxLocationCacheTtlMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const getMapboxContextName = (feature: MapboxGeocodeFeature, key: 'place' | 'locality' | 'region' | 'country') =>
|
||||||
|
feature.properties?.context?.[key]?.name?.trim() || null;
|
||||||
|
|
||||||
|
const normalizeMapboxLocationFeature = (feature: MapboxGeocodeFeature): VerifiedLocationSearchResult | null => {
|
||||||
|
const properties = feature.properties;
|
||||||
|
const featureType = properties?.feature_type;
|
||||||
|
|
||||||
|
if (!featureType || !allowedMapboxLocationTypes.has(featureType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = properties.name?.trim() || null;
|
||||||
|
const contextPlace = getMapboxContextName(feature, 'place');
|
||||||
|
const contextLocality = getMapboxContextName(feature, 'locality');
|
||||||
|
const contextRegion = getMapboxContextName(feature, 'region');
|
||||||
|
const contextCountry = getMapboxContextName(feature, 'country');
|
||||||
|
const city = featureType === 'place' || featureType === 'locality' ? name : contextPlace || contextLocality;
|
||||||
|
const region = featureType === 'region' ? name : contextRegion;
|
||||||
|
const country = featureType === 'country' ? name : contextCountry;
|
||||||
|
const countryCode = properties.context?.country?.country_code?.trim().toUpperCase() || null;
|
||||||
|
const longitude = properties.coordinates?.longitude ?? feature.geometry?.coordinates?.[0] ?? null;
|
||||||
|
const latitude = properties.coordinates?.latitude ?? feature.geometry?.coordinates?.[1] ?? null;
|
||||||
|
const label = [city, region, country].filter(Boolean).join(', ') || properties.full_address || properties.place_formatted || name || null;
|
||||||
|
const precision = featureType === 'country' ? 'country' : featureType === 'region' ? 'region' : 'city';
|
||||||
|
|
||||||
|
if (!label || typeof latitude !== 'number' || typeof longitude !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeVerifiedLocationDetails({
|
||||||
|
label,
|
||||||
|
city: city ?? '',
|
||||||
|
region: region ?? '',
|
||||||
|
country: country ?? '',
|
||||||
|
countryCode: countryCode ?? '',
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
precision,
|
||||||
|
provider: 'mapbox',
|
||||||
|
providerPlaceId: properties.mapbox_id || feature.id || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const weightSchema = z.object({
|
const weightSchema = z.object({
|
||||||
weightGrams: z.coerce.number().positive().max(10000),
|
weightGrams: z.coerce.number().positive().max(10000),
|
||||||
@@ -966,6 +1058,13 @@ const lostBirdReportLimiter = rateLimit({
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: { error: 'Too many found bird reports. Please try again later.' },
|
message: { error: 'Too many found bird reports. Please try again later.' },
|
||||||
});
|
});
|
||||||
|
const locationSearchLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
limit: 40,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many location searches. Please try again later.' },
|
||||||
|
});
|
||||||
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
|
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
|
||||||
if (!stripeWebhookSecret) {
|
if (!stripeWebhookSecret) {
|
||||||
res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
||||||
@@ -3652,6 +3751,61 @@ app.get('/api/audit-log', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/locations/search', requireAuth, locationSearchLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = locationSearchSchema.safeParse(req.query);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Enter at least 3 characters to search for a location.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapboxAccessToken) {
|
||||||
|
res.status(503).json({ error: 'Location search is not configured.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = parsed.data.q.replace(/\s+/g, ' ').trim();
|
||||||
|
const cacheKey = query.toLocaleLowerCase();
|
||||||
|
const cached = mapboxLocationCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
res.json({ results: cached.results });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchUrl = new URL('https://api.mapbox.com/search/geocode/v6/forward');
|
||||||
|
searchUrl.searchParams.set('q', query);
|
||||||
|
searchUrl.searchParams.set('access_token', mapboxAccessToken);
|
||||||
|
searchUrl.searchParams.set('autocomplete', 'false');
|
||||||
|
searchUrl.searchParams.set('types', 'place,locality,region,country');
|
||||||
|
searchUrl.searchParams.set('limit', '5');
|
||||||
|
searchUrl.searchParams.set('permanent', 'true');
|
||||||
|
|
||||||
|
const mapboxResponse = await fetch(searchUrl);
|
||||||
|
const data = (await mapboxResponse.json().catch(() => null)) as MapboxGeocodeResponse | null;
|
||||||
|
|
||||||
|
if (!mapboxResponse.ok) {
|
||||||
|
res.status(502).json({ error: data?.message || 'Location search failed.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = (data?.features ?? [])
|
||||||
|
.map(normalizeMapboxLocationFeature)
|
||||||
|
.filter((result): result is VerifiedLocationSearchResult => result !== null)
|
||||||
|
.filter((result, index, allResults) => allResults.findIndex((entry) => entry.providerPlaceId === result.providerPlaceId) === index);
|
||||||
|
|
||||||
|
mapboxLocationCache.set(cacheKey, {
|
||||||
|
expiresAt: Date.now() + mapboxLocationCacheTtlMs,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ results });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const [birds, memorializedBirds] = await Promise.all([
|
const [birds, memorializedBirds] = await Promise.all([
|
||||||
|
|||||||
@@ -208,7 +208,21 @@ export const createBirdTimelineEvent = async ({
|
|||||||
created_by_user_id,
|
created_by_user_id,
|
||||||
location_details
|
location_details
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9::text, $6::text, $5::text), $10, COALESCE($11::date, CURRENT_DATE), $12, $13)
|
VALUES (
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
$5::varchar(160),
|
||||||
|
$6::varchar(160),
|
||||||
|
$7::varchar(320),
|
||||||
|
$8::varchar(320),
|
||||||
|
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
|
||||||
|
$10,
|
||||||
|
COALESCE($11::date, CURRENT_DATE),
|
||||||
|
$12,
|
||||||
|
$13
|
||||||
|
)
|
||||||
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
|
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
|
||||||
[
|
[
|
||||||
birdId,
|
birdId,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
|
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
@@ -136,6 +137,7 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
|
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
|
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
@@ -129,6 +130,7 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
|
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
|
|||||||
+195
-209
@@ -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 birdSilhouette from './assets/bird-silhouette.jpg';
|
||||||
import flockPalLandingArt from './assets/flockpal-landing-art.png';
|
import flockPalLandingArt from './assets/flockpal-landing-art.png';
|
||||||
import flockPalTextArt from './assets/flockpal-text.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 BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
||||||
|
|
||||||
type VerifiedLocationDetails = {
|
type VerifiedLocationDetails = {
|
||||||
|
label?: string | null;
|
||||||
city: string;
|
city: string;
|
||||||
region: string;
|
region: string;
|
||||||
country: string;
|
country: string;
|
||||||
@@ -24,9 +25,18 @@ type VerifiedLocationDetails = {
|
|||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
precision: 'city' | 'region' | 'country';
|
precision: 'city' | 'region' | 'country';
|
||||||
|
provider?: 'mapbox' | null;
|
||||||
|
providerPlaceId?: string | null;
|
||||||
verifiedAt?: string;
|
verifiedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LocationSearchState = {
|
||||||
|
query: string;
|
||||||
|
results: VerifiedLocationDetails[];
|
||||||
|
searching: boolean;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Bird = {
|
type Bird = {
|
||||||
id: string;
|
id: string;
|
||||||
workspaceId?: number;
|
workspaceId?: number;
|
||||||
@@ -713,6 +723,7 @@ const parseBirdImportRows = (rows: Record<string, unknown>[]): BirdImportPreview
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
|
const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
|
||||||
|
label: '',
|
||||||
city: '',
|
city: '',
|
||||||
region: '',
|
region: '',
|
||||||
country: '',
|
country: '',
|
||||||
@@ -720,11 +731,21 @@ const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
|
|||||||
latitude: null,
|
latitude: null,
|
||||||
longitude: null,
|
longitude: null,
|
||||||
precision: 'city',
|
precision: 'city',
|
||||||
|
provider: null,
|
||||||
|
providerPlaceId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyLocationSearchState: LocationSearchState = {
|
||||||
|
query: '',
|
||||||
|
results: [],
|
||||||
|
searching: false,
|
||||||
|
error: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetails> | null | undefined): VerifiedLocationDetails => ({
|
const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetails> | null | undefined): VerifiedLocationDetails => ({
|
||||||
...emptyVerifiedLocationDetails,
|
...emptyVerifiedLocationDetails,
|
||||||
...details,
|
...details,
|
||||||
|
label: details?.label ?? '',
|
||||||
city: details?.city ?? '',
|
city: details?.city ?? '',
|
||||||
region: details?.region ?? '',
|
region: details?.region ?? '',
|
||||||
country: details?.country ?? '',
|
country: details?.country ?? '',
|
||||||
@@ -732,10 +753,95 @@ const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetai
|
|||||||
latitude: typeof details?.latitude === 'number' ? details.latitude : null,
|
latitude: typeof details?.latitude === 'number' ? details.latitude : null,
|
||||||
longitude: typeof details?.longitude === 'number' ? details.longitude : null,
|
longitude: typeof details?.longitude === 'number' ? details.longitude : null,
|
||||||
precision: details?.precision ?? 'city',
|
precision: details?.precision ?? 'city',
|
||||||
|
provider: details?.provider ?? null,
|
||||||
|
providerPlaceId: details?.providerPlaceId ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatVerifiedLocationLabel = (details: VerifiedLocationDetails) =>
|
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 = {
|
const emptyBirdForm: BirdFormState = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -1846,6 +1952,8 @@ function App() {
|
|||||||
const [birdTimelineLoading, setBirdTimelineLoading] = useState(false);
|
const [birdTimelineLoading, setBirdTimelineLoading] = useState(false);
|
||||||
const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false);
|
const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false);
|
||||||
const [birdTimelineNotice, setBirdTimelineNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
|
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 [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
|
||||||
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
|
||||||
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
||||||
@@ -1954,6 +2062,7 @@ function App() {
|
|||||||
setBirdTimelineEvents([]);
|
setBirdTimelineEvents([]);
|
||||||
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
|
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
|
||||||
setBirdTimelineNotice(null);
|
setBirdTimelineNotice(null);
|
||||||
|
setTimelineLocationSearch(emptyLocationSearchState);
|
||||||
}, [selectedBirdId]);
|
}, [selectedBirdId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2893,6 +3002,7 @@ function App() {
|
|||||||
if (!editingBird) {
|
if (!editingBird) {
|
||||||
setEditingBirdId('');
|
setEditingBirdId('');
|
||||||
setBirdForm(emptyBirdForm);
|
setBirdForm(emptyBirdForm);
|
||||||
|
setBirdLocationSearch(emptyLocationSearchState);
|
||||||
setBirdPhotoName('');
|
setBirdPhotoName('');
|
||||||
setPhotoCrop(null);
|
setPhotoCrop(null);
|
||||||
setPhotoDrag(null);
|
setPhotoDrag(null);
|
||||||
@@ -2900,6 +3010,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBirdForm(toBirdForm(editingBird));
|
setBirdForm(toBirdForm(editingBird));
|
||||||
|
setBirdLocationSearch(emptyLocationSearchState);
|
||||||
setBirdPhotoName('');
|
setBirdPhotoName('');
|
||||||
setPhotoCrop(null);
|
setPhotoCrop(null);
|
||||||
setPhotoDrag(null);
|
setPhotoDrag(null);
|
||||||
@@ -2928,6 +3039,7 @@ function App() {
|
|||||||
setEditingBirdId('');
|
setEditingBirdId('');
|
||||||
setBirdEditorOpen(true);
|
setBirdEditorOpen(true);
|
||||||
setBirdForm(emptyBirdForm);
|
setBirdForm(emptyBirdForm);
|
||||||
|
setBirdLocationSearch(emptyLocationSearchState);
|
||||||
setBirdPhotoName('');
|
setBirdPhotoName('');
|
||||||
setPhotoCrop(null);
|
setPhotoCrop(null);
|
||||||
setPhotoDrag(null);
|
setPhotoDrag(null);
|
||||||
@@ -2935,6 +3047,42 @@ function App() {
|
|||||||
setActivePage('flock');
|
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>) => {
|
const handleAuthSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -6580,105 +6728,23 @@ function App() {
|
|||||||
<p className="eyebrow">Location</p>
|
<p className="eyebrow">Location</p>
|
||||||
<h3>Mappable location</h3>
|
<h3>Mappable location</h3>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<VerifiedLocationSearchField
|
||||||
City
|
label="Location"
|
||||||
<input
|
location={birdForm.locationDetails}
|
||||||
value={birdForm.locationDetails.city}
|
fallbackLabel={birdForm.locationLabel}
|
||||||
onChange={(event) =>
|
searchState={birdLocationSearch}
|
||||||
setBirdForm({
|
onSearchStateChange={setBirdLocationSearch}
|
||||||
...birdForm,
|
onSearch={() => searchVerifiedLocations(birdLocationSearch, setBirdLocationSearch)}
|
||||||
locationDetails: { ...birdForm.locationDetails, city: event.target.value, precision: 'city' },
|
onSelect={(location) => {
|
||||||
})
|
const locationLabel = formatVerifiedLocationLabel(location);
|
||||||
}
|
setBirdForm({ ...birdForm, locationDetails: location, locationLabel });
|
||||||
placeholder="City"
|
setBirdLocationSearch({ ...emptyLocationSearchState, query: locationLabel });
|
||||||
/>
|
}}
|
||||||
</label>
|
onClear={() => {
|
||||||
<label>
|
setBirdForm({ ...birdForm, locationDetails: emptyVerifiedLocationDetails, locationLabel: '' });
|
||||||
Region
|
setBirdLocationSearch(emptyLocationSearchState);
|
||||||
<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>
|
|
||||||
<div className="segmented-field wide-field">
|
<div className="segmented-field wide-field">
|
||||||
<span>Gender</span>
|
<span>Gender</span>
|
||||||
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
||||||
@@ -7899,6 +7965,9 @@ function App() {
|
|||||||
value={birdTimelineEventForm.eventType}
|
value={birdTimelineEventForm.eventType}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const eventType = event.target.value as BirdTimelineEventFormState['eventType'];
|
const eventType = event.target.value as BirdTimelineEventFormState['eventType'];
|
||||||
|
if (eventType === 'owner_changed') {
|
||||||
|
setTimelineLocationSearch(emptyLocationSearchState);
|
||||||
|
}
|
||||||
setBirdTimelineEventForm({
|
setBirdTimelineEventForm({
|
||||||
...birdTimelineEventForm,
|
...birdTimelineEventForm,
|
||||||
eventType,
|
eventType,
|
||||||
@@ -7924,114 +7993,31 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{birdTimelineEventForm.eventType !== 'owner_changed' ? (
|
{birdTimelineEventForm.eventType !== 'owner_changed' ? (
|
||||||
<>
|
<VerifiedLocationSearchField
|
||||||
<label>
|
label="Location"
|
||||||
City
|
location={birdTimelineEventForm.locationDetails}
|
||||||
<input
|
fallbackLabel={birdTimelineEventForm.locationLabel || selectedBird.locationLabel}
|
||||||
value={birdTimelineEventForm.locationDetails.city}
|
searchState={timelineLocationSearch}
|
||||||
onChange={(event) =>
|
onSearchStateChange={setTimelineLocationSearch}
|
||||||
setBirdTimelineEventForm({
|
onSearch={() => searchVerifiedLocations(timelineLocationSearch, setTimelineLocationSearch)}
|
||||||
...birdTimelineEventForm,
|
onSelect={(location) => {
|
||||||
locationDetails: {
|
const locationLabel = formatVerifiedLocationLabel(location);
|
||||||
...birdTimelineEventForm.locationDetails,
|
setBirdTimelineEventForm({
|
||||||
city: event.target.value,
|
...birdTimelineEventForm,
|
||||||
precision: 'city',
|
locationDetails: location,
|
||||||
},
|
locationLabel,
|
||||||
})
|
});
|
||||||
}
|
setTimelineLocationSearch({ ...emptyLocationSearchState, query: locationLabel });
|
||||||
placeholder={selectedBird.locationDetails?.city || 'City'}
|
}}
|
||||||
/>
|
onClear={() => {
|
||||||
</label>
|
setBirdTimelineEventForm({
|
||||||
<label>
|
...birdTimelineEventForm,
|
||||||
Region
|
locationDetails: emptyVerifiedLocationDetails,
|
||||||
<input
|
locationLabel: '',
|
||||||
value={birdTimelineEventForm.locationDetails.region}
|
});
|
||||||
onChange={(event) =>
|
setTimelineLocationSearch(emptyLocationSearchState);
|
||||||
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>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{birdTimelineEventForm.eventType === 'location_updated' ? (
|
{birdTimelineEventForm.eventType === 'location_updated' ? (
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
|
|||||||
+63
-1
@@ -2040,6 +2040,67 @@ label {
|
|||||||
font-size: 0.95rem;
|
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"] {
|
.toggle-card input[type="checkbox"] {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -2374,7 +2435,8 @@ label {
|
|||||||
.inline-form,
|
.inline-form,
|
||||||
.profile-hero,
|
.profile-hero,
|
||||||
.photo-editor,
|
.photo-editor,
|
||||||
.settings-nested-grid {
|
.settings-nested-grid,
|
||||||
|
.verified-location-search-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user