fix(map+badges+sasquatch): attribution contrast/overlap, dedupe 🦧, label AI balance, persist NIP-98

- Map attribution: raise contrast (grey-500/80 → grey-200/90) and add `isolate`
  to the map container so its internal z-indexes (Leaflet panes, overlays up to
  z-2000) stay inside the map — the © OpenStreetMap line was painting OVER the
  Sasquatch panel (z-60). Panel/bubble drop to z-40 so Radix dialogs (z-50) now
  sit above it instead of under it.
- Sasquatch header: remove the duplicate 🦧 (the avatar + context subtitle carry
  it), and label the owner-only chip '⚡ AI <n>' (it is the AI credit balance, not
  a wallet balance).
- proAuth: persist the NIP-98 auth in localStorage for its validity window, so
  Amber signs once per ~11h instead of on every app load (the panel itself never
  signed — this is the first-message auth).
This commit is contained in:
SkateSpots 2026-09-28 20:16:15 +07:00
parent 359a510c65
commit 53c451ccfc
3 changed files with 63 additions and 22 deletions

View file

@ -1474,7 +1474,7 @@ Instructions:
<div <div
ref={dragRef} ref={dragRef}
className={cn( className={cn(
'z-[60] touch-none select-none', 'z-40 touch-none select-none',
// Mobile: bottom-left, clear of the bottom nav. Desktop: bottom-RIGHT // Mobile: bottom-left, clear of the bottom nav. Desktop: bottom-RIGHT
// so the bubble is easy to spot and never hides behind the footer // so the bubble is easy to spot and never hides behind the footer
// (which is bottom-left on desktop). // (which is bottom-left on desktop).
@ -1513,7 +1513,7 @@ Instructions:
{shouldShowPanel && !isMinimized && ( {shouldShowPanel && !isMinimized && (
<div <div
className={cn( className={cn(
'fixed z-[60] flex flex-col transition-all duration-300 ease-out', 'fixed z-40 flex flex-col transition-all duration-300 ease-out',
// Mobile: left-anchored, max ~65% width — leaves right side free for Quick Snap + filter pills. // Mobile: left-anchored, max ~65% width — leaves right side free for Quick Snap + filter pills.
// Height is bounded to the visible viewport (dvh) with safe-area insets so the // Height is bounded to the visible viewport (dvh) with safe-area insets so the
// header/close button never slide off the top of the screen behind the status bar. // header/close button never slide off the top of the screen behind the status bar.
@ -1558,7 +1558,7 @@ Instructions:
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="font-black text-xs text-[#ccff00] uppercase tracking-wider" style={{ fontFamily: 'Impact, Arial Black, sans-serif' }}> <span className="font-black text-xs text-[#ccff00] uppercase tracking-wider" style={{ fontFamily: 'Impact, Arial Black, sans-serif' }}>
🦧 Sasquatch Sasquatch
</span> </span>
{isPro ? ( {isPro ? (
<span <span
@ -1583,9 +1583,9 @@ Instructions:
'flex items-center gap-0.5 rounded px-1 py-0.5 text-[8px] font-black uppercase tracking-wider', 'flex items-center gap-0.5 rounded px-1 py-0.5 text-[8px] font-black uppercase tracking-wider',
aiStatus.data.low ? 'bg-amber-500/20 text-amber-400' : 'bg-emerald-500/15 text-emerald-400', aiStatus.data.low ? 'bg-amber-500/20 text-amber-400' : 'bg-emerald-500/15 text-emerald-400',
)} )}
title={`Sasquatch AI balance — top up at ${aiStatus.data.provider ?? 'the node'}`} title={`Sasquatch AI credit balance (owner-only) — top up at ${aiStatus.data.provider ?? 'the node'}`}
> >
⚡ {aiStatus.data.balanceSats.toLocaleString()} sats ⚡ AI {aiStatus.data.balanceSats.toLocaleString()}
</span> </span>
)} )}
</div> </div>

View file

@ -6,8 +6,9 @@
* • `x-sasquatch-auth` — a base64 NIP-98 (kind 27235) event proving the caller; * • `x-sasquatch-auth` — a base64 NIP-98 (kind 27235) event proving the caller;
* • `x-sasquatch-pro` — base64 of the Pro zap receipt proving an active pass. * • `x-sasquatch-pro` — base64 of the Pro zap receipt proving an active pass.
* *
* The NIP-98 event is signed ONCE per session and reused, so an interactive * The NIP-98 event is signed ONCE and reused for its whole validity window —
* signer (Amber) doesn't pop on every message. * persisted to localStorage so an interactive signer (Amber) does not pop again
* on every app load. (It expires with the 12 h window the proxy enforces.)
*/ */
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { sasquatchAiBase } from '@/lib/sasquatchAi'; import { sasquatchAiBase } from '@/lib/sasquatchAi';
@ -23,6 +24,32 @@ type SignEvent = (event: {
let cachedAuthHeader: string | null = null; let cachedAuthHeader: string | null = null;
let cachedAuthPubkey: string | null = null; let cachedAuthPubkey: string | null = null;
/** Persisted NIP-98 auth (keyed to the pubkey, valid while inside the window). */
const AUTH_STORAGE_KEY = 'skatespots:sasquatch-nip98';
/** Reuse for 11 h to stay safely inside the proxy's 12 h acceptance window. */
const AUTH_MAX_AGE_SECONDS = 11 * 3600;
function loadStoredAuth(pubkey: string): string | null {
try {
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { pubkey?: string; header?: string; createdAt?: number };
if (parsed?.pubkey !== pubkey || !parsed.header || !parsed.createdAt) return null;
if (Math.abs(Date.now() / 1000 - parsed.createdAt) > AUTH_MAX_AGE_SECONDS) return null;
return parsed.header;
} catch {
return null;
}
}
function storeAuth(pubkey: string, header: string, createdAt: number): void {
try {
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ pubkey, header, createdAt }));
} catch {
/* private mode — the in-memory cache still works for this session */
}
}
function toBase64(value: string): string { function toBase64(value: string): string {
// Event JSON is ASCII (hex pubkeys/ids, tags), so btoa is safe here. // Event JSON is ASCII (hex pubkeys/ids, tags), so btoa is safe here.
return btoa(value); return btoa(value);
@ -39,9 +66,17 @@ export async function buildProAuthHeaders(opts: {
if (!pubkey || !signEvent) return headers; if (!pubkey || !signEvent) return headers;
if (cachedAuthPubkey !== pubkey || !cachedAuthHeader) { if (cachedAuthPubkey !== pubkey || !cachedAuthHeader) {
// Reuse a stored signature first — otherwise Amber pops on every app load
// for a signer that would happily reuse the same NIP-98 for 12 h.
const stored = loadStoredAuth(pubkey);
if (stored) {
cachedAuthHeader = stored;
cachedAuthPubkey = pubkey;
} else {
const createdAt = Math.floor(Date.now() / 1000);
const event = await signEvent({ const event = await signEvent({
kind: 27235, kind: 27235,
created_at: Math.floor(Date.now() / 1000), created_at: createdAt,
tags: [ tags: [
['u', sasquatchAiBase()], ['u', sasquatchAiBase()],
['method', 'POST'], ['method', 'POST'],
@ -50,6 +85,8 @@ export async function buildProAuthHeaders(opts: {
}); });
cachedAuthHeader = toBase64(JSON.stringify(event)); cachedAuthHeader = toBase64(JSON.stringify(event));
cachedAuthPubkey = pubkey; cachedAuthPubkey = pubkey;
storeAuth(pubkey, cachedAuthHeader, createdAt);
}
} }
headers['x-sasquatch-auth'] = cachedAuthHeader; headers['x-sasquatch-auth'] = cachedAuthHeader;

View file

@ -1603,7 +1603,11 @@ export default function MapPage() {
)} )}
{/* Main Content */} {/* Main Content */}
<div className="flex-1 relative"> {/* `isolate` creates a stacking context so the map's internal z-indexes
(Leaflet panes, our overlays up to z-[2000]) stay INSIDE the map. Without
it they competed with the root and the © OpenStreetMap attribution
(z-999) painted on top of the Sasquatch panel (z-40). */}
<div className="relative isolate flex-1">
{viewMode === 'map' ? ( {viewMode === 'map' ? (
<> <>
<Suspense fallback={ <Suspense fallback={
@ -1773,10 +1777,10 @@ export default function MapPage() {
<div <div
className="absolute left-1.5 z-[999] pointer-events-none bottom-[calc(5rem+env(safe-area-inset-bottom,0px)+0.25rem)] md:bottom-1.5" className="absolute left-1.5 z-[999] pointer-events-none bottom-[calc(5rem+env(safe-area-inset-bottom,0px)+0.25rem)] md:bottom-1.5"
> >
<div className="bg-black/40 backdrop-blur-sm px-1 py-px rounded text-[8px] text-gray-500/80 leading-none pointer-events-auto"> <div className="bg-black/55 backdrop-blur-sm px-1.5 py-0.5 rounded text-[9px] font-medium text-gray-200/90 leading-none pointer-events-auto">
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="hover:text-gray-300 transition-colors">© OpenStreetMap</a> <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">© OpenStreetMap</a>
{showBtcMap && ( {showBtcMap && (
<>{' · '}<a href="https://btcmap.org" target="_blank" rel="noopener noreferrer" className="text-[#f7931a]/60 hover:text-[#f7931a] transition-colors">BTC Map</a></> <>{' · '}<a href="https://btcmap.org" target="_blank" rel="noopener noreferrer" className="text-[#f7931a] hover:text-[#ffb04d] transition-colors">BTC Map</a></>
)} )}
</div> </div>
</div> </div>