fix(mobile-ux): Mints in the nav, instant directions, no stray signer prompt

- Nav: MIDDLE_SLOTS 3->4 and add 'mints' to the default middle + desktop tabs,
  so the Cashu mints directory is reachable on the phone out of the box (also
  still linked from Wallet; any of the 4 slots can be swapped via long-press).
- Directions: RouteDialog now seeds the origin from the last known location and
  draws immediately, then refines with a live fix — no more hanging on 'Finding
  your location…'. Shortened the web geolocation fallback 15s -> 8s.
- Amber: /api/ai/status is now public (it only reports a small balance), so
  showing the AI balance no longer signs NIP-98 / opens the signer. The signer is
  reserved for actions that actually need it.
This commit is contained in:
SkateSpots 2026-09-27 15:54:29 +07:00
parent 7969e8e9de
commit f95e2233c8
5 changed files with 25 additions and 19 deletions

View file

@ -202,10 +202,9 @@ function json(body, status, headers) {
* simpler "low balance" signal than wiring a Routstr webhook, which would need a * simpler "low balance" signal than wiring a Routstr webhook, which would need a
* notification channel (a signing key) we don't otherwise have server-side. * notification channel (a signing key) we don't otherwise have server-side.
*/ */
async function handleStatus(request, headers) { async function handleStatus(_request, headers) {
const pubkey = verifyNip98(request.headers.get('x-sasquatch-auth')); // Public on purpose: this is just a small prepaid balance, and requiring a
if (!pubkey) return json({ error: 'auth required' }, 401, headers); // NIP-98 signature would make the signer pop up merely to display a number.
if (!PLATFORM_OWNER_PUBKEYS.has(pubkey)) return json({ error: 'forbidden' }, 403, headers);
if (!AI_KEY) return json({ configured: false }, 200, headers); if (!AI_KEY) return json({ configured: false }, 200, headers);
try { try {
const r = await fetch(`${AI_BASE}/balance/info`, { const r = await fetch(`${AI_BASE}/balance/info`, {

View file

@ -11,6 +11,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getCurrentPosition, GeolocationError } from '@/lib/geolocation'; import { getCurrentPosition, GeolocationError } from '@/lib/geolocation';
import { getLastLocation } from '@/lib/lastLocation';
import { import {
fetchRoute, fetchRoute,
fetchTransitRoute, fetchTransitRoute,
@ -123,10 +124,18 @@ export function RouteDialog({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (open && !origin && !locating && !locError) { if (!open) return;
locate(); // Seed from the last known fix so a route can be drawn immediately, then
// refine with a live reading. This is what makes opening Directions feel
// instant instead of hanging on "Finding your location…".
if (!origin) {
const cached = getLastLocation();
if (cached) setOrigin({ lat: cached.lat, lng: cached.lng });
} }
}, [open, origin, locating, locError, locate]); locate();
// Re-run only when the dialog opens (locate is stable).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Reset transient state when closed // Reset transient state when closed
useEffect(() => { useEffect(() => {

View file

@ -69,10 +69,11 @@ export const NAV_CATALOG: NavTab[] = [
]; ];
/** Number of user-customizable middle slots. */ /** Number of user-customizable middle slots. */
export const MIDDLE_SLOTS = 3; export const MIDDLE_SLOTS = 4;
/** Default middle tabs (matches the app's original hardcoded bar). */ /** Default middle tabs. Includes Mints (Cashu mint directory) so it's reachable
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews']; * from the phone nav out of the box; users can swap any of them via long-press. */
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews', 'mints'];
const STORAGE_KEY = 'skatespots:nav-tabs:v1'; const STORAGE_KEY = 'skatespots:nav-tabs:v1';
@ -82,8 +83,8 @@ const STORAGE_KEY = 'skatespots:nav-tabs:v1';
// order; anything not shown still lives in the "More" dropdown, so nothing is // order; anything not shown still lives in the "More" dropdown, so nothing is
// ever lost. Map is always the first primary tab and cannot be hidden/moved. // ever lost. Map is always the first primary tab and cannot be hidden/moved.
/** Default desktop primary tabs (matches the original hardcoded header). */ /** Default desktop primary tabs. */
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews']; export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'mints'];
const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1'; const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1';

View file

@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { buildProAuthHeaders } from '@/lib/proAuth';
/** Report shape from the proxy's owner-only `/api/ai/status`. */ /** Report shape from the proxy's owner-only `/api/ai/status`. */
export interface SasquatchAiStatus { export interface SasquatchAiStatus {
@ -31,13 +30,11 @@ export function useSasquatchAiStatus(options: { enabled?: boolean } = {}) {
refetchInterval: 5 * 60 * 1000, refetchInterval: 5 * 60 * 1000,
staleTime: 60 * 1000, staleTime: 60 * 1000,
queryFn: async () => { queryFn: async () => {
const authHeaders = await buildProAuthHeaders({ // No signature required — see handleStatus(); avoids an Amber prompt just
pubkey: user!.pubkey, // to show a balance.
signEvent: (e) => user!.signer.signEvent(e),
});
const res = await fetch(AI_STATUS_URL, { const res = await fetch(AI_STATUS_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders }, headers: { 'Content-Type': 'application/json' },
body: '{}', body: '{}',
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}); });

View file

@ -340,7 +340,7 @@ function getWebPosition(): Promise<GeoCoords> {
settled = true; settled = true;
cleanup(); cleanup();
reject(new GeolocationError('timeout', 'Location request timed out.')); reject(new GeolocationError('timeout', 'Location request timed out.'));
}, 15000); }, 8000);
// 1) watchPosition — first update wins (fast network fix). // 1) watchPosition — first update wins (fast network fix).
try { try {