Sasquatch can now DRAFT actions from natural language and publish them only after the user reviews & confirms — it never publishes on its own. - useSasquatchActions: publishes confirmed drafts via the app's normal publish path. Supports post_status (NIP-38 kind 30315, auto-expiring) and list_gear (NIP-99 kind 30402, skateboarding-tagged), reusing the existing event schemas. - SasquatchActionCard: inline "review & confirm" card with editable key fields (status text / listing title, price, condition), Publish + Cancel, and done/cancelled states. Nothing is signed until Publish. - SasquatchGuide: new draft_action tool (Pro-gated). The AI emits a draft; a card renders in-chat; confirmDraft signs & publishes. Non-Pro users get an upgrade nudge. Draft lifecycle tracked per message. - Prompt teaches the model to use draft_action for "post/sell/list/set my status" requests and to fill gear fields from the stash first. Examples now working (Pro): "post that I'm skating Southbank for 3 hours" · "list my Spitfire wheels for 8000 sats". Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
1136 lines
58 KiB
TypeScript
1136 lines
58 KiB
TypeScript
/**
|
|
* SasquatchGuide — AI-powered skate assistant
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Uses OpenRouter free model (no credits needed, works for any user) for
|
|
* natural language understanding, then calls the SkateSpots CVM server on
|
|
* Nostr for live data (spots, events, coaches, gear).
|
|
* Falls back to static FAQ if AI is unavailable.
|
|
*/
|
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
|
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useCVMClient } from '@/hooks/useCVMClient';
|
|
import { usePersonalContext } from '@/hooks/usePersonalContext';
|
|
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
|
|
import { SasquatchProDialog } from '@/components/SasquatchProDialog';
|
|
import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions';
|
|
import { SasquatchActionCard } from '@/components/SasquatchActionCard';
|
|
import { callSasquatchAI } from '@/lib/sasquatchBrain';
|
|
import { answerFromGlossary } from '@/lib/glossary';
|
|
import { cn } from '@/lib/utils';
|
|
import { fetchWeather, calculateSkateability } from '@/lib/weather';
|
|
import ngeohash from 'ngeohash';
|
|
|
|
// ─── Weather helper — fetch current conditions for a city/spot via Open-Meteo ──
|
|
|
|
async function fetchWeatherForCity(city: string): Promise<string> {
|
|
// Geocode the city using Nominatim
|
|
const geoResp = await fetch(
|
|
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(city)}&format=json&limit=1`,
|
|
{ headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' } }
|
|
);
|
|
const geoData = await geoResp.json();
|
|
if (!geoData?.length) return `No location found for "${city}"`;
|
|
|
|
const lat = parseFloat(geoData[0].lat);
|
|
const lng = parseFloat(geoData[0].lon);
|
|
const weather = await fetchWeather(lat, lng);
|
|
const geohash = ngeohash.encode(lat, lng, 5);
|
|
const skateability = calculateSkateability(weather, undefined);
|
|
|
|
return JSON.stringify({
|
|
city,
|
|
temperature_c: weather.temperature,
|
|
feels_like_c: weather.feelsLike,
|
|
condition: weather.weatherDescription,
|
|
icon: weather.weatherIcon,
|
|
precipitation_mm: weather.precipitation,
|
|
wind_kmh: weather.windSpeed,
|
|
humidity_pct: weather.humidity,
|
|
uv_index: weather.uvIndex,
|
|
skateability_score: skateability.score,
|
|
skateability_label: skateability.label,
|
|
skateability_reasons: skateability.reasons,
|
|
geohash,
|
|
is_day: weather.isDay,
|
|
});
|
|
}
|
|
|
|
// AI calls route through the shared Sasquatch brain (sasquatchBrain.ts), which
|
|
// handles the free-model fallback list + rate-limit backoff/retry in one place.
|
|
const callAI = callSasquatchAI;
|
|
|
|
export type SasquatchContext = 'home' | 'onboarding' | 'map' | 'lab';
|
|
|
|
export interface GuideMessage {
|
|
id: string;
|
|
title: string;
|
|
message: string;
|
|
context?: SasquatchContext;
|
|
suggestedQuestions?: string[];
|
|
action?: {
|
|
label: string;
|
|
onClick: () => void;
|
|
};
|
|
enableChat?: boolean;
|
|
/** When true, the panel auto-opens on navigation. When false/undefined, the
|
|
* message only populates content once the user taps the bubble. */
|
|
autoShow?: boolean;
|
|
}
|
|
|
|
interface SasquatchGuideProps {
|
|
message?: GuideMessage;
|
|
onDismiss?: () => void;
|
|
}
|
|
|
|
interface ChatMessage {
|
|
role: 'user' | 'sasquatch';
|
|
text: string;
|
|
timestamp: number;
|
|
isLive?: boolean; // came from CVM live data
|
|
isStreaming?: boolean;
|
|
/** A drafted action (Sasquatch Pro) awaiting user confirmation. */
|
|
draft?: SasquatchDraft;
|
|
/** Lifecycle of the drafted action's confirmation card. */
|
|
draftStatus?: 'pending' | 'publishing' | 'done' | 'cancelled';
|
|
/** Success label after publishing the draft. */
|
|
draftResult?: string;
|
|
}
|
|
|
|
// ─── System prompt for the AI ─────────────────────────────────────────────────
|
|
// Sasquatch is the user-facing agent. The CVM server is the data layer —
|
|
// it aggregates and crunches Nostr events across multiple relays, deduplicates,
|
|
// filters by features inside events (things Nostr filters alone can't do),
|
|
// and returns structured results. Sasquatch MUST always route data queries
|
|
// through CVM — never guess from training data.
|
|
|
|
const SYSTEM_PROMPT = `You are The Sasquatch, the AI guide for SkateSpots — a decentralized skateboarding platform built on Nostr. Chill, skater-friendly, concise.
|
|
|
|
## CVM Tools — query live data
|
|
Respond with ONLY a JSON tool call when you need data. Nothing else on that line.
|
|
{"tool":"<name>","args":{}}
|
|
|
|
### find_spots
|
|
Args: city?(string), country?(string), difficulty?("beginner"|"intermediate"|"advanced"|"all"), features?(string[]), search?(string), limit?(number)
|
|
- Filter checks: city tag, location text, spot title, description — all case-insensitive
|
|
- For country queries use city of the capital/main city too: Thailand → use location:"Bangkok"
|
|
- Always use limit:20+ for city/country searches
|
|
- 0 results = not added yet, encourage user to add via skate-spots.org/map
|
|
Example: {"tool":"find_spots","args":{"city":"Bangkok","limit":20}}
|
|
|
|
### find_events
|
|
Args: city?(string), country?(string), event_type?(string), from_date?(string), to_date?(string), limit?(number)
|
|
Example: {"tool":"find_events","args":{"city":"Bangkok"}}
|
|
|
|
### find_coaches
|
|
Args: role?("coach"|"filmer"|"editor"|"any"), skills?(string[]), location?(string), limit?(number)
|
|
- Covers all The Lab service providers: coaches, filmers, photographers, editors, and ramp/park builders
|
|
- location searches name, bio, city tag — use the CITY name not country name for best results
|
|
- "coaches in Thailand" → use location:"Bangkok" (main city)
|
|
- "coaches in Bangkok" → use location:"Bangkok"
|
|
- Always fetch with limit:20 to avoid missing results
|
|
Example: {"tool":"find_coaches","args":{"location":"Bangkok","limit":20}}
|
|
|
|
### find_gear
|
|
Args: category?(string), condition?("new"|"like_new"|"good"|"fair"|"any"), max_price_sats?(number), search?(string), limit?(number)
|
|
Example: {"tool":"find_gear","args":{"category":"deck"}}
|
|
|
|
### find_crews
|
|
Find skate crews (community groups). Args: city?(string), country?(string), vibe?(string e.g. "street"|"transition"|"diy"), search?(string), limit?(number)
|
|
- For country queries use the main city too (Thailand → city:"Bangkok")
|
|
- Always use limit:20+ for city/country searches
|
|
Example: {"tool":"find_crews","args":{"city":"Barcelona","limit":20}}
|
|
|
|
### get_community_stats
|
|
Args: none
|
|
Example: {"tool":"get_community_stats","args":{}}
|
|
|
|
### get_weather
|
|
Get real-time weather + skateability score for any city or spot location.
|
|
Args: city(string) — city name or location
|
|
Example: {"tool":"get_weather","args":{"city":"Bangkok"}}
|
|
Example: {"tool":"get_weather","args":{"city":"Barcelona"}}
|
|
|
|
## Rules
|
|
1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess
|
|
2. Tool call = ONE JSON object on its own line, nothing else before or after
|
|
3. App/protocol/how-to questions → answer directly (no tool). Stash, Wallet, Tours, Shops, Supporters have NO live search tool — answer these from the App facts below and point users to the right page. (Crews DO have find_crews — use it for "find/show crews".)
|
|
4. Be concise, skater-friendly
|
|
5. Never invent spot names, event dates, or user data
|
|
6. 0 results = honest "none listed yet" + encourage adding content
|
|
|
|
## App facts (features & how they connect)
|
|
- Nostr = decentralized protocol; your cryptographic keys ARE your identity — one login works everywhere, nobody can ban you
|
|
- Map (the homepage) = discover spots, shops, gear, tours, events and crews worldwide. Toggle layers via the Layers control. Add a spot with the + button.
|
|
- The Lab = hire skate-scene pros: Coaches, Filmers, Photographers, Video Editors, and ramp/park/DIY Builders. Providers can be an individual OR a collective (crew/studio) — collective members confirm with their own key (green = confirmed, amber = pending).
|
|
- Crews = your map-anchored skate family (who you skate with, NOT for hire). A crew has home turf on the map (teal pins), home spots, members, vibes and a manifesto. Create one at /crews → "Start a Crew". Crews reuse the same member-confirmation trust as Lab collectives. Use find_crews to search them. A crew can cross-link its Lab service listings, and you can "Zap the Crew" to split a tip across confirmed members.
|
|
- Events = find/join skate events & comps (map layer + Events page)
|
|
- Tours = guided local/national/world skate tours
|
|
- Skate Shops = local shops on the map
|
|
- Gear Swap / Marketplace = buy & sell used gear (NIP-99 listings), can be pinned to a spot on the map
|
|
- My Stash = your PRIVATE, encrypted gear inventory (NIP-44). Track decks/trucks/wheels/shoes, storage location, condition, rarity, price. Only you can read it unless you mark items public.
|
|
- Rider Card = your skate ID / park access card
|
|
- Trust scores (0-100, powered by Relatr) filter bots/spam across the app
|
|
|
|
## Money & wallet (all interconnected)
|
|
- Wallet page = one place for it all. Three rails, auto-detected: Cashu (built-in ecash), NWC (connect Alby/Minibits/Zeus/Coinos), and WebLN (browser extension).
|
|
- Zaps = instant Bitcoin Lightning tips on any content (⚡). Zapping auto-picks your best rail: NWC → WebLN → Cashu balance → manual QR. Works from spots, profiles, crews, Lab profiles, events.
|
|
- Cashu = built-in ecash wallet, private and works offline at the skatepark. Pay zaps straight from your Cashu balance.
|
|
- Nutzaps (NIP-61) = ecash tips sent over Nostr; you can set up nutzap RECEIVING in the wallet (sending full nutzaps is rolling out — use Lightning zaps for now).
|
|
- CLINK (noffer) = an optional reusable Nostr-native payment code a shop/crew/Lab pro can add so you can pay them without a Lightning address. Shows a "Pay via CLINK" button.
|
|
- goskatespots.nostr1.com = primary community relay
|
|
- 100% free, no ads, no tracking, community-funded via Geyser (see Supporters). 🛹`;
|
|
|
|
// ─── Personal tools (Sasquatch Pro) — only offered when the user is logged in ─
|
|
// These read the user's OWN data client-side (Stash is decrypted locally).
|
|
const PERSONAL_TOOLS_PROMPT = `
|
|
|
|
## Personal tools (logged-in user's OWN data — use for "my/mine" questions)
|
|
### get_my_stash
|
|
The user's private gear inventory (decks, trucks, wheels, shoes…). Args: none.
|
|
Use for "what's in my stash", "what gear do I have", "what am I selling".
|
|
Example: {"tool":"get_my_stash","args":{}}
|
|
|
|
### get_my_crews
|
|
Crews the user founded or is a member of. Args: none.
|
|
Use for "my crews", "events near my crew" (call this first to get the crew's location, then find_events there).
|
|
Example: {"tool":"get_my_crews","args":{}}
|
|
|
|
### get_my_listings
|
|
The user's active marketplace/gear listings. Args: none.
|
|
Example: {"tool":"get_my_listings","args":{}}
|
|
|
|
### get_my_spots
|
|
Skate spots the user created/added. Args: none.
|
|
Example: {"tool":"get_my_spots","args":{}}
|
|
|
|
## Action drafts (Pro — you DRAFT, the user confirms before anything is published)
|
|
Never publish directly. To help the user DO something, emit a draft_action tool
|
|
call. The app shows a confirmation card; the user reviews and taps Publish.
|
|
|
|
### draft_action → post_status
|
|
Draft a skating status (NIP-38). Args: action:"post_status", text(string, include an emoji), expiresInMinutes?(number, default 120).
|
|
Use for "post that I'm skating X", "set my status", "let people know I'm at the park".
|
|
Example: {"tool":"draft_action","args":{"action":"post_status","text":"🛹 Skating Southbank — come thru","expiresInMinutes":180}}
|
|
|
|
### draft_action → list_gear
|
|
Draft a gear-for-sale listing (NIP-99). Args: action:"list_gear", title(string), description?(string), price?(number, sats), category?("deck"|"trucks"|"wheels"|"bearings"|"shoes"|"clothing"|"other"), condition?(string), size?(string).
|
|
Use for "list my X for sale", "sell my Y". If selling an item from their stash, fill fields from get_my_stash first.
|
|
Example: {"tool":"draft_action","args":{"action":"list_gear","title":"Spitfire F4 99a 53mm","price":8000,"category":"wheels","condition":"like new"}}
|
|
|
|
Rule: for "my/mine" questions ALWAYS use a personal tool — never guess about the user's data.
|
|
Rule: for "post/sell/list/set my status" requests use draft_action — never claim you posted something.`;
|
|
|
|
// ─── Detect if a question needs live CVM data ─────────────────────────────────
|
|
|
|
function needsLiveData(question: string): boolean {
|
|
const q = question.toLowerCase();
|
|
const liveKeywords = [
|
|
'find', 'show me', 'search', 'where', 'any spots', 'spots in', 'spots near',
|
|
'events in', 'events near', 'upcoming', 'coaches in', 'filmers in',
|
|
'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count',
|
|
'list', 'nearby', 'close to', 'around', 'available',
|
|
'crews in', 'crews near', 'find crew', 'find a crew', 'crews around',
|
|
// Personal (Sasquatch Pro) — route to AI so it picks a get_my_* tool
|
|
'my stash', 'my gear', 'my crew', 'my crews', 'my spots', 'my listings',
|
|
'in my stash', "what am i selling", 'what do i own', 'my inventory',
|
|
];
|
|
return liveKeywords.some((kw) => q.includes(kw));
|
|
}
|
|
|
|
// ─── Static FAQ fallback (for protocol/app questions without AI) ──────────────
|
|
|
|
const STATIC_FAQ: Record<string, string> = {
|
|
'hi': "Hey! I'm The Sasquatch 🦧 Your AI skate guide. Ask me about spots, events, coaches, gear — or anything about the app. What do you need?",
|
|
'hello': "Wassup! 🛹 I'm The Sasquatch. Ask me about skate spots, events, coaches, or how the app works. What's good?",
|
|
'hey': "Hey skater! 🤙 Ask me about spots near you, upcoming events, coaches, or anything SkateSpots.",
|
|
'who are you': "I'm The Sasquatch 🦧 — the AI guide for SkateSpots. I can find skate spots worldwide, look up events, coaches, filmers, and gear listings. All powered by live Nostr data. Ask away!",
|
|
'what can you do': "I can find skate spots by city or country, look up upcoming events, find coaches and filmers in The Lab, search gear listings, and answer questions about how the app works. Try: 'spots in Bangkok' or 'find a coach'! 🛹",
|
|
'what is nostr': "Nostr is a simple, open protocol for decentralized social networks. Think Twitter but no one can ban you. You use cryptographic keys instead of usernames — one account works everywhere across all Nostr apps! 🔑",
|
|
'what are zaps': "Zaps are instant Bitcoin Lightning tips sent over Nostr! Click ⚡ on any content, send sats directly to the creator's wallet. Like hitting 'like' but with real money. Instant, peer-to-peer, no middlemen. ⚡",
|
|
'how to login': "Two ways:\n1. **Browser extension** (Desktop): Install Alby or nos2x, then click 'Log in'\n2. **New user**: Click 'Sign up' to generate keys. Download your nsec (secret key) and SAVE IT — this is your account forever!",
|
|
'what is cashu': "Built-in Bitcoin wallet! Cashu ecash tokens backed by Lightning — works offline at skate spots, privacy-focused (mints can't track payments). Go to Wallet in Settings to use it. 🥜",
|
|
'what is nwc': "Nostr Wallet Connect — like USB-C for Lightning wallets. Connect Alby, Minibits, Zeus, or Coinos once, and zap with one click forever. Encrypted, you control spending limits. 🔌⚡",
|
|
'is it free': "100% free. No ads, no subscriptions, no corporate BS. Built by skaters for skaters. Community-funded via Geyser — check the Supporters page. You can also earn Bitcoin through community zaps for contributing quality spots. 🛹",
|
|
'what can i do': "Discover spots worldwide on the map, hire pros in The Lab (coaches, filmers, photographers, editors, ramp builders), roll with a crew, join events, take tours, buy/sell gear, track your private Stash, zap creators, check spot weather, and build the decentralized skate community!",
|
|
'what is the lab': "The Lab is where you hire skate-scene pros: Coaches, Filmers, Photographers, Video Editors, and ramp/park/DIY Builders. Each can be a solo person or a collective (crew/studio) — members confirm with their own keys so it's legit. All on Nostr, no fake accounts. 🎓",
|
|
'what is a crew': "Crews are your map-anchored skate family — who you skate WITH, not who you hire. Each crew has home turf on the map (teal pins), home spots, members, vibes and a manifesto. Members confirm from their own key. Start one at Crews → 'Start a Crew'. 🤘",
|
|
'what are crews': "Crews are your map-anchored skate family — who you skate WITH, not who you hire. Each crew has home turf on the map (teal pins), home spots, members and a manifesto. Members confirm from their own key. Head to the Crews tab to start or join one! 🤘",
|
|
'how to start a crew': "Go to the Crews tab → 'Start a Crew'. Name it, drop your home turf on the map, add vibes (or your own custom #hashtag), link your home spots, and add members by npub. Each member confirms from their own key so the roster is legit. 🤘",
|
|
'what is my stash': "My Stash is your PRIVATE, encrypted gear locker (NIP-44 encryption). Track your decks, trucks, wheels, shoes — with storage location, condition, rarity and price. Only YOU can read it unless you mark items public. Great for insurance or planning a swap. 🎒",
|
|
'what is stash': "My Stash is your PRIVATE, encrypted gear inventory. Log everything you own — condition, where it's stored, value — all encrypted so only you can see it. Mark items public if you want to sell or show them off. 🎒",
|
|
'what is a builder': "Builders are ramp/park/DIY crews in The Lab you can hire to build transitions, bowls, rails, or a whole park. They list what they build, materials, project scale, and can be licensed/insured. Often collectives — and they can link the spots they've built to the map. 🏗️",
|
|
'what is a nutzap': "Nutzaps (NIP-61) are ecash tips sent over Nostr — private Cashu tokens zapped straight to someone. You can set up nutzap RECEIVING in the Wallet. (Sending full nutzaps is rolling out — for now use Lightning zaps.) 🥜⚡",
|
|
'what is clink': "CLINK is a Nostr-native payment code (an 'noffer'). Shops, crews, and Lab pros can add one so you can pay them without needing a Lightning address — you'll see a 'Pay via CLINK' button with a QR. Scan it with a CLINK wallet like ShockWallet. 🔗⚡",
|
|
'what is noffer': "An noffer is a CLINK payment code — a reusable, Nostr-native way to get paid without a web server or Lightning address. Add yours on your shop/crew/Lab profile and people can pay you directly. 🔗",
|
|
'what are tours': "Tours are guided skate trips — local sessions, national road trips, or world destruction tours. Find them on the map or the Tours section, meet up, and skate new terrain with locals. 🗺️🛹",
|
|
'what is a shop': "Skate Shops are local shops listed on the map. Discover and support real brick-and-mortar (or online) skate shops in your area — some even accept Bitcoin. 🏪",
|
|
'how to sell gear': "Head to Gear Swap / Marketplace and create a listing (NIP-99). Add photos, price in sats, condition, and optionally pin it to a spot so it shows on the map. Buyers zap or message you directly. 🛹💸",
|
|
'supporters': "SkateSpots is community-funded via Geyser (Bitcoin Lightning crowdfunding) — no ads, no investors, no data selling. Check the Supporters page to chip in and keep the streets free. ❤️⚡",
|
|
'trust score': "Trust scores (0-100) powered by Relatr. Analyzes your Nostr network: followers, activity, engagement, zaps. Higher = more trusted. Helps filter bots and spam. Check any profile to see their score. 🛡️",
|
|
'what relay': "SkateSpots uses: 🛹 goskatespots.nostr1.com (primary, allowlisted for quality) + relay.ditto.pub (global) + purplepag.es (profile aggregator). Your data is replicated across all of them for redundancy.",
|
|
};
|
|
|
|
function getStaticAnswer(question: string): string | null {
|
|
const q = question.toLowerCase().trim();
|
|
// Exact match first (for greetings like "hi", "hey")
|
|
if (STATIC_FAQ[q]) return STATIC_FAQ[q];
|
|
// Then partial match
|
|
for (const [key, answer] of Object.entries(STATIC_FAQ)) {
|
|
if (q.includes(key)) return answer;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── CVM connection status indicator ─────────────────────────────────────────
|
|
|
|
function CVMStatus({ connected }: { connected: boolean | null }) {
|
|
if (connected === null) return null;
|
|
return (
|
|
<div className={cn(
|
|
'flex items-center gap-0.5 text-[8px] px-1 py-0.5 rounded',
|
|
connected
|
|
? 'bg-[#ccff00]/15 text-[#ccff00]'
|
|
: 'bg-white/5 text-gray-500'
|
|
)}>
|
|
{connected ? (
|
|
<><Wifi className="h-2 w-2" /> live</>
|
|
) : (
|
|
<><WifiOff className="h-2 w-2" /> offline</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── ChatText — renders markdown links as real <a> tags ──────────────────────
|
|
|
|
function ChatText({ text }: { text: string }) {
|
|
// Split on markdown links [label](url) and bare URLs
|
|
const parts = text.split(/(\[([^\]]+)\]\((https?:\/\/[^\)]+)\)|https?:\/\/\S+)/g);
|
|
const elements: React.ReactNode[] = [];
|
|
let i = 0;
|
|
// Use a regex to find all links in order
|
|
const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^\)]+)\)|👉\s*(https?:\/\/\S+)|(https?:\/\/\S+)/g;
|
|
let last = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = linkRegex.exec(text)) !== null) {
|
|
// Text before this match
|
|
if (match.index > last) {
|
|
elements.push(<span key={i++}>{text.slice(last, match.index)}</span>);
|
|
}
|
|
if (match[1] && match[2]) {
|
|
// [label](url)
|
|
elements.push(
|
|
<a key={i++} href={match[2]} target="_blank" rel="noopener noreferrer"
|
|
className="text-[#ccff00] underline underline-offset-2 hover:text-[#d4ff1a] break-all">
|
|
{match[1]}
|
|
</a>
|
|
);
|
|
} else if (match[3]) {
|
|
// 👉 url
|
|
elements.push(
|
|
<a key={i++} href={match[3]} target="_blank" rel="noopener noreferrer"
|
|
className="text-[#ccff00] underline underline-offset-2 hover:text-[#d4ff1a] break-all">
|
|
{match[3]}
|
|
</a>
|
|
);
|
|
} else if (match[4]) {
|
|
// bare url
|
|
elements.push(
|
|
<a key={i++} href={match[4]} target="_blank" rel="noopener noreferrer"
|
|
className="text-[#ccff00] underline underline-offset-2 hover:text-[#d4ff1a] break-all">
|
|
{match[4]}
|
|
</a>
|
|
);
|
|
}
|
|
last = match.index + match[0].length;
|
|
}
|
|
if (last < text.length) {
|
|
elements.push(<span key={i++}>{text.slice(last)}</span>);
|
|
}
|
|
return <>{elements}</>;
|
|
}
|
|
|
|
// ─── Main component ───────────────────────────────────────────────────────────
|
|
|
|
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|
const { user } = useCurrentUser();
|
|
const cvm = useCVMClient();
|
|
const personal = usePersonalContext();
|
|
const { isPro } = useSasquatchPro();
|
|
const { publishDraft } = useSasquatchActions();
|
|
const [proDialogOpen, setProDialogOpen] = useState(false);
|
|
|
|
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
|
const [userInput, setUserInput] = useState('');
|
|
const [showChat, setShowChat] = useState(false);
|
|
const [isThinking, setIsThinking] = useState(false);
|
|
const [cvmConnected, setCvmConnected] = useState<boolean | null>(null);
|
|
const [streamingText, setStreamingText] = useState('');
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Draggable position state for minimized bubble
|
|
const [dragPos, setDragPos] = useLocalStorage<{ x: number; y: number } | null>('sasquatch-pos', null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragRef = useRef<HTMLDivElement>(null);
|
|
const dragStart = useRef<{ mx: number; my: number; bx: number; by: number } | null>(null);
|
|
const dragMoved = useRef(false);
|
|
// Timestamp of the last panel-open via tap. Used to ignore stray
|
|
// close/minimize events that fire from the same tap on mobile.
|
|
const justOpenedRef = useRef(0);
|
|
const recentlyOpened = () => Date.now() - justOpenedRef.current < 400;
|
|
|
|
// Dragging handlers
|
|
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
|
if (!dragRef.current) return;
|
|
dragMoved.current = false;
|
|
const rect = dragRef.current.getBoundingClientRect();
|
|
dragStart.current = { mx: e.clientX, my: e.clientY, bx: rect.left, by: rect.top };
|
|
dragRef.current.setPointerCapture(e.pointerId);
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
|
if (!dragStart.current) return;
|
|
const dx = e.clientX - dragStart.current.mx;
|
|
const dy = e.clientY - dragStart.current.my;
|
|
if (!dragMoved.current && Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
|
dragMoved.current = true;
|
|
setIsDragging(true);
|
|
const newX = Math.max(0, Math.min(window.innerWidth - 56, dragStart.current.bx + dx));
|
|
const newY = Math.max(0, Math.min(window.innerHeight - 56, dragStart.current.by + dy));
|
|
setDragPos({ x: newX, y: newY });
|
|
}, [setDragPos]);
|
|
|
|
// Single source of truth for opening the panel. Sets the "just opened" lock
|
|
// so a stray follow-up event from the same tap can't immediately close it.
|
|
const openPanel = useCallback(() => {
|
|
justOpenedRef.current = Date.now();
|
|
setIsMinimized(false);
|
|
setIsVisible(true);
|
|
}, [setIsMinimized]);
|
|
|
|
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
|
try {
|
|
if (dragRef.current) dragRef.current.releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
// releasePointerCapture can throw if the element is unmounting — ignore.
|
|
}
|
|
const wasDrag = dragMoved.current;
|
|
dragStart.current = null;
|
|
dragMoved.current = false;
|
|
setTimeout(() => setIsDragging(false), 50);
|
|
// If this pointer sequence was a genuine tap (no real drag), open here.
|
|
// A parallel onClick handler also opens as a reliable fallback for mobile
|
|
// WebViews where the pointer-capture tap can be flaky on first interaction;
|
|
// openPanel is idempotent so double-firing is harmless.
|
|
if (!wasDrag) {
|
|
openPanel();
|
|
}
|
|
}, [openPanel]);
|
|
|
|
// Reliable open path: plain click. On mobile WebViews the pointer-capture tap
|
|
// detection can miss the very first interaction after mount (the "1st tap does
|
|
// nothing, 2nd works" bug). A normal onClick is the most dependable "I tapped
|
|
// this" signal, so we use it as a fallback — but ignore it if the gesture was
|
|
// actually a drag.
|
|
const onBubbleClick = useCallback(() => {
|
|
if (dragMoved.current) return;
|
|
openPanel();
|
|
}, [openPanel]);
|
|
|
|
const bubbleStyle = useMemo(() => {
|
|
if (dragPos) {
|
|
return { position: 'fixed' as const, left: dragPos.x, top: dragPos.y, bottom: 'auto', right: 'auto', zIndex: 50 };
|
|
}
|
|
return undefined;
|
|
}, [dragPos]);
|
|
|
|
// Only auto-open the panel when the context message explicitly opts in via
|
|
// `autoShow`. On the map (`/`) and The Lab (`/lab`) the guide stays minimized
|
|
// until the user taps the bubble — the message content is still used to
|
|
// populate the panel when they do.
|
|
useEffect(() => {
|
|
if (message?.autoShow) {
|
|
const timer = setTimeout(() => setIsVisible(true), 500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [message?.autoShow, message?.id]);
|
|
|
|
// Auto-scroll chat to bottom
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [chatMessages, streamingText]);
|
|
|
|
const appendMessage = useCallback((msg: ChatMessage) => {
|
|
setChatMessages((prev) => [...prev, msg]);
|
|
}, []);
|
|
|
|
// Update the draft lifecycle on a specific message (identified by timestamp).
|
|
const setDraftStatus = useCallback(
|
|
(ts: number, draftStatus: ChatMessage['draftStatus'], draftResult?: string) => {
|
|
setChatMessages((prev) =>
|
|
prev.map((m) => (m.timestamp === ts ? { ...m, draftStatus, draftResult } : m)),
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Publish a confirmed draft (Pro action). Signs + publishes via the app's
|
|
// normal publish path; the human already reviewed it in the card.
|
|
const confirmDraft = useCallback(
|
|
async (ts: number, draft: SasquatchDraft) => {
|
|
setDraftStatus(ts, 'publishing');
|
|
try {
|
|
const { label } = await publishDraft(draft);
|
|
setDraftStatus(ts, 'done', label);
|
|
} catch (err) {
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: `Couldn't publish that: ${err instanceof Error ? err.message : 'unknown error'}. Try again in a sec.`,
|
|
timestamp: Date.now(),
|
|
});
|
|
setDraftStatus(ts, 'pending');
|
|
}
|
|
},
|
|
[publishDraft, setDraftStatus, appendMessage],
|
|
);
|
|
|
|
// ── Core: handle a user question ───────────────────────────────────────────
|
|
|
|
const handleSendMessage = useCallback(async (questionOverride?: string) => {
|
|
const question = (questionOverride ?? userInput).trim();
|
|
if (!question || isThinking) return;
|
|
|
|
setUserInput('');
|
|
setIsThinking(true);
|
|
setStreamingText('');
|
|
|
|
appendMessage({ role: 'user', text: question, timestamp: Date.now() });
|
|
|
|
try {
|
|
// 1. Check static FAQ first (fast, no API call needed)
|
|
const staticAnswer = getStaticAnswer(question);
|
|
if (staticAnswer && !needsLiveData(question)) {
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: staticAnswer,
|
|
timestamp: Date.now(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 1b. Trick / terminology questions → answer locally from the glossary.
|
|
// No API call, so this NEVER hits a rate limit. Skip if the question
|
|
// clearly wants live data (e.g. "find a kickflip coach").
|
|
if (!needsLiveData(question)) {
|
|
const glossaryAnswer = answerFromGlossary(question);
|
|
if (glossaryAnswer) {
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: glossaryAnswer,
|
|
timestamp: Date.now(),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 1c. Personal ("my/mine") question but the user isn't Pro → nudge to upgrade
|
|
// locally (no API call). Pro users fall through to the AI + personal tools.
|
|
if (personal.available && !isPro && /\bmy (stash|gear|crew|crews|spots?|listings?|inventory)\b|what am i selling|what do i own/i.test(question)) {
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: "That's a **Sasquatch Pro** thing 🦧⚡ — with Pro I can read your Stash, crews, spots and listings, draft gear listings and spot updates, and plan your trips. It's a Lightning supporter pass that keeps SkateSpots free & ad-free. Tap the **⚡ Go Pro** button up top to unlock it. 🛹",
|
|
timestamp: Date.now(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 2. Build conversation history for AI
|
|
const history = chatMessages.map((m) => ({
|
|
role: m.role === 'user' ? ('user' as const) : ('assistant' as const),
|
|
content: m.text,
|
|
}));
|
|
|
|
// 3. Try AI agent loop — routes data queries through CVM, answers app questions directly
|
|
let accumulated = '';
|
|
let aiSucceeded = false;
|
|
let usedCVM = false;
|
|
try {
|
|
// Build messages with system prompt embedded in first user message.
|
|
// Personal tools are only offered to logged-in Pro supporters.
|
|
const systemPrompt = personal.available && isPro
|
|
? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}`
|
|
: SYSTEM_PROMPT;
|
|
const historyWithContext: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
|
{ role: 'user', content: `${systemPrompt}\n\n---\n\nUser: ${question}` },
|
|
...history.slice(0, -1).map((m) => ({ role: m.role, content: m.content })),
|
|
];
|
|
|
|
// First pass — AI either answers directly or outputs a JSON tool call
|
|
const aiText = await callAI(historyWithContext);
|
|
|
|
// Parse tool call — look for {"tool":"...","args":{...}} anywhere in the response
|
|
let toolCall: { tool: string; args: Record<string, unknown> } | null = null;
|
|
const jsonMatch = aiText.match(/\{[\s\S]*"tool"\s*:\s*"(\w+)"[\s\S]*\}/);
|
|
if (jsonMatch) {
|
|
try {
|
|
toolCall = JSON.parse(jsonMatch[0]);
|
|
} catch { /* not valid JSON, treat as plain answer */ }
|
|
}
|
|
|
|
// ── draft_action (Pro) — build a draft card the user must confirm ──
|
|
if (toolCall && toolCall.tool === 'draft_action') {
|
|
setStreamingText('');
|
|
if (!isPro) {
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: "Drafting actions (posting a status, listing gear) is a **Sasquatch Pro** perk 🦧⚡ — tap **⚡ Go Pro** up top to unlock it.",
|
|
timestamp: Date.now(),
|
|
});
|
|
aiSucceeded = true;
|
|
} else {
|
|
const a = toolCall.args as Record<string, unknown>;
|
|
const action = String(a.action ?? '');
|
|
let draft: SasquatchDraft | null = null;
|
|
if (action === 'post_status' && typeof a.text === 'string') {
|
|
draft = {
|
|
type: 'post_status',
|
|
text: a.text,
|
|
expiresInMinutes: typeof a.expiresInMinutes === 'number' ? a.expiresInMinutes : undefined,
|
|
};
|
|
} else if (action === 'list_gear' && typeof a.title === 'string') {
|
|
draft = {
|
|
type: 'list_gear',
|
|
title: a.title,
|
|
description: typeof a.description === 'string' ? a.description : undefined,
|
|
price: typeof a.price === 'number' ? a.price : undefined,
|
|
currency: typeof a.currency === 'string' ? a.currency : undefined,
|
|
category: typeof a.category === 'string' ? a.category : undefined,
|
|
condition: typeof a.condition === 'string' ? a.condition : undefined,
|
|
size: typeof a.size === 'string' ? a.size : undefined,
|
|
};
|
|
}
|
|
if (draft) {
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: "Here's a draft — review it and tap to publish. Nothing goes live until you confirm. 🛹",
|
|
timestamp: Date.now(),
|
|
draft,
|
|
draftStatus: 'pending',
|
|
});
|
|
} else {
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: "I couldn't quite build that draft — try telling me the details again (e.g. \"list my Spitfire wheels for 8000 sats\").",
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
aiSucceeded = true;
|
|
}
|
|
setStreamingText('');
|
|
setIsThinking(false);
|
|
return;
|
|
}
|
|
|
|
if (toolCall) {
|
|
const { tool: toolName, args: toolArgs } = toolCall;
|
|
setStreamingText(`🔍 Querying CVM for ${toolName.replace(/_/g, ' ')}...`);
|
|
|
|
try {
|
|
// Call the live CVM server — it aggregates across all relays, deduplicates,
|
|
// and runs complex feature filters that plain Nostr queries can't do
|
|
let toolResult: unknown;
|
|
switch (toolName) {
|
|
case 'find_spots':
|
|
toolResult = await cvm.findSpots(toolArgs as Parameters<typeof cvm.findSpots>[0]);
|
|
break;
|
|
case 'find_events':
|
|
toolResult = await cvm.findEvents(toolArgs as Parameters<typeof cvm.findEvents>[0]);
|
|
break;
|
|
case 'find_coaches':
|
|
toolResult = await cvm.findCoaches(toolArgs as Parameters<typeof cvm.findCoaches>[0]);
|
|
break;
|
|
case 'find_gear':
|
|
toolResult = await cvm.findGear(toolArgs as Parameters<typeof cvm.findGear>[0]);
|
|
break;
|
|
case 'find_crews':
|
|
toolResult = await cvm.findCrews(toolArgs as Parameters<typeof cvm.findCrews>[0]);
|
|
break;
|
|
case 'get_community_stats':
|
|
toolResult = await cvm.getCommunityStats();
|
|
break;
|
|
case 'get_weather': {
|
|
const weatherCity = (toolArgs as { city?: string }).city ?? '';
|
|
const weatherJson = await fetchWeatherForCity(weatherCity);
|
|
toolResult = { content: [{ type: 'text', text: weatherJson }] };
|
|
break;
|
|
}
|
|
// ── Personal tools (Sasquatch Pro) — require login + Pro ───────
|
|
case 'get_my_stash':
|
|
case 'get_my_crews':
|
|
case 'get_my_listings':
|
|
case 'get_my_spots': {
|
|
if (!personal.available) {
|
|
toolResult = { error: 'not_logged_in', message: 'Log in to use personal features.' };
|
|
} else if (!isPro) {
|
|
toolResult = {
|
|
error: 'pro_required',
|
|
message: 'Personal assistant features (your Stash, crews, spots, listings) are part of Sasquatch Pro — a Lightning supporter pass. Tell the user this is Pro-only and invite them to tap the ⚡ Go Pro button.',
|
|
};
|
|
} else if (toolName === 'get_my_stash') {
|
|
toolResult = await personal.getMyStash();
|
|
} else if (toolName === 'get_my_crews') {
|
|
toolResult = await personal.getMyCrews();
|
|
} else if (toolName === 'get_my_listings') {
|
|
toolResult = await personal.getMyListings();
|
|
} else {
|
|
toolResult = await personal.getMySpots();
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
toolResult = { error: `Unknown tool: ${toolName}` };
|
|
}
|
|
|
|
setCvmConnected(true);
|
|
usedCVM = true;
|
|
setStreamingText('');
|
|
|
|
// Second pass — give AI the structured CVM result to present naturally
|
|
const toolResultStr = JSON.stringify(toolResult, null, 2);
|
|
accumulated = await callAI([
|
|
{
|
|
role: 'user',
|
|
content: `You are The Sasquatch, a chill skater AI guide. Present the following live data to the user naturally and concisely.
|
|
|
|
IMPORTANT: Do NOT output any JSON tool calls. Just answer in plain conversational text with bullet points.
|
|
|
|
User asked: "${question}"
|
|
|
|
Live data from ${toolName}:
|
|
${toolResultStr}
|
|
|
|
Instructions:
|
|
- If count is 0: say nothing found yet, encourage them to add content at skate-spots.org
|
|
- If count > 0: list results as bullet points with name, brief description, and url link
|
|
- Keep it short and skater-friendly
|
|
- Never make up data not in the results above`,
|
|
},
|
|
]);
|
|
} catch (toolErr) {
|
|
setCvmConnected(false);
|
|
console.warn('[Sasquatch] CVM call failed:', toolErr);
|
|
accumulated = "Couldn't reach the live data server right now. Try again in a moment! The CVM server aggregates spots from all relays — it should be back shortly. 🛹";
|
|
}
|
|
} else {
|
|
// No tool call — AI answered directly from knowledge
|
|
accumulated = aiText;
|
|
}
|
|
|
|
setStreamingText('');
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: accumulated || "Hmm, couldn't process that. Try rephrasing!",
|
|
timestamp: Date.now(),
|
|
isLive: usedCVM,
|
|
});
|
|
aiSucceeded = true;
|
|
} catch (aiErr) {
|
|
console.warn('[Sasquatch] AI unavailable, using static FAQ:', aiErr);
|
|
}
|
|
|
|
if (!aiSucceeded) {
|
|
// Last-resort local answers so the user still gets something useful
|
|
// even when the AI is unreachable: static FAQ, then glossary.
|
|
const fallback =
|
|
staticAnswer ??
|
|
answerFromGlossary(question) ??
|
|
"I couldn't reach my AI brain just now 🦧 — but I've still got you. Try:\n\n• 🗺️ [Map](/) — spots, shops, events near you\n• 🧪 [The Lab](/lab) — coaches, filmers, editors\n• 📖 [Glossary](/glossary) — 150+ tricks & terms\n• 👥 [Crews](/crews) — find your people\n\n…or ask me about a specific trick (e.g. \"what is a nollie backside flip\") and I'll answer straight from the glossary. 🛹";
|
|
appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() });
|
|
}
|
|
} catch (err) {
|
|
console.error('[Sasquatch] Error:', err);
|
|
appendMessage({
|
|
role: 'sasquatch',
|
|
text: "Something went sideways on my end. Try again in a sec! 🦧",
|
|
timestamp: Date.now(),
|
|
});
|
|
} finally {
|
|
setIsThinking(false);
|
|
setStreamingText('');
|
|
}
|
|
}, [userInput, isThinking, chatMessages, user, cvm, personal, isPro, appendMessage, cvmConnected]);
|
|
|
|
// Listen for external events from FAQ/About pages
|
|
useEffect(() => {
|
|
const handleOpen = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail as { greeting?: string };
|
|
setIsMinimized(false);
|
|
setIsVisible(true);
|
|
setShowChat(true);
|
|
if (detail?.greeting) {
|
|
setChatMessages([{
|
|
role: 'sasquatch',
|
|
text: detail.greeting,
|
|
timestamp: Date.now(),
|
|
}]);
|
|
}
|
|
};
|
|
|
|
const handleAsk = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail as { question?: string };
|
|
setIsMinimized(false);
|
|
setIsVisible(true);
|
|
setShowChat(true);
|
|
if (detail?.question) {
|
|
// Small delay so the panel animates open first
|
|
setTimeout(() => handleSendMessage(detail.question), 300);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('sasquatch:open', handleOpen);
|
|
window.addEventListener('sasquatch:ask', handleAsk);
|
|
return () => {
|
|
window.removeEventListener('sasquatch:open', handleOpen);
|
|
window.removeEventListener('sasquatch:ask', handleAsk);
|
|
};
|
|
}, [handleSendMessage, setIsMinimized]);
|
|
|
|
// The minimized bubble is ALWAYS visible so the user can reopen Sasquatch on any page.
|
|
// The full panel shows only when opened externally, by user tap, or by an
|
|
// opt-in auto-show message (e.g. onboarding) — never automatically on / or /lab.
|
|
const shouldShowPanel = isVisible;
|
|
|
|
// Notify the rest of the app (e.g. MapPage FAB) when the panel opens or closes
|
|
useEffect(() => {
|
|
const panelOpen = shouldShowPanel && !isMinimized;
|
|
window.dispatchEvent(new CustomEvent('sasquatch:panelchange', { detail: { open: panelOpen } }));
|
|
}, [shouldShowPanel, isMinimized]);
|
|
|
|
const inputDisabled = isThinking;
|
|
|
|
// Build a fallback generic message for pages with no specific context
|
|
const activeMessage = message ?? {
|
|
id: 'generic',
|
|
title: 'SkateSpots Assistant 🦧',
|
|
message: "Yo! I'm The Sasquatch. Ask me anything about spots, events, coaches, gear, or how the app works. I've got live data from the SkateSpots relay!",
|
|
context: undefined,
|
|
suggestedQuestions: [
|
|
'Find spots near me',
|
|
'Any upcoming events?',
|
|
'What are crews?',
|
|
'How do zaps work?',
|
|
'What is My Stash?',
|
|
'What is the Lab?',
|
|
],
|
|
enableChat: true,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* ── Minimized bubble — always visible, draggable ── */}
|
|
{(!shouldShowPanel || isMinimized) && (
|
|
<div
|
|
ref={dragRef}
|
|
className={cn(
|
|
'z-50 touch-none select-none',
|
|
dragPos ? '' : 'fixed bottom-[9.5rem] left-4 md:bottom-6 md:left-4',
|
|
)}
|
|
style={bubbleStyle}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onClick={onBubbleClick}
|
|
title="Press and drag to move · Tap to open"
|
|
>
|
|
<div
|
|
className={cn(
|
|
'relative group flex items-center justify-center w-12 h-12 md:w-14 md:h-14 rounded-full shadow-2xl border-2 border-[#ccff00]/60 transition-all duration-200',
|
|
isDragging ? 'scale-110 border-[#ccff00] cursor-grabbing' : 'hover:border-[#ccff00] hover:scale-110 active:scale-95 cursor-grab',
|
|
)}
|
|
style={{
|
|
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
|
|
boxShadow: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
|
|
}}
|
|
aria-label="Open Sasquatch guide"
|
|
>
|
|
<img
|
|
src="/images/sasquatch-guide.webp"
|
|
alt="The Sasquatch"
|
|
className="w-8 h-8 md:w-9 md:h-9 rounded-full object-cover pointer-events-none"
|
|
/>
|
|
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00]" />
|
|
<span className="absolute top-0 right-0 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Full panel ── */}
|
|
{shouldShowPanel && !isMinimized && (
|
|
<div
|
|
className={cn(
|
|
'fixed z-50 transition-all duration-300 ease-out',
|
|
// Mobile: left-anchored, max ~65% width — leaves right side free for Quick Snap + filter pills
|
|
'bottom-[4.5rem] left-2 w-[min(calc(100vw-5rem),340px)]',
|
|
// Desktop: compact panel bottom-left
|
|
'md:bottom-6 md:left-4 md:w-[340px]',
|
|
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3 pointer-events-none'
|
|
)}
|
|
>
|
|
{/* Jungle/dark card */}
|
|
<div
|
|
className="rounded-xl border border-[#ccff00]/20 shadow-2xl overflow-hidden"
|
|
style={{
|
|
background: 'linear-gradient(160deg, #0f1f08 0%, #0a1505 50%, #060d03 100%)',
|
|
boxShadow: '0 0 40px rgba(0,0,0,0.9), 0 0 20px rgba(204,255,0,0.06), inset 0 1px 0 rgba(204,255,0,0.08)',
|
|
}}
|
|
>
|
|
{/* Jungle texture strip at top */}
|
|
<div
|
|
className="h-1 w-full"
|
|
style={{ background: 'linear-gradient(90deg, #1a3d0a, #ccff00, #2d5c10, #ccff00, #1a3d0a)' }}
|
|
/>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2.5 px-3 py-2.5 border-b border-[#ccff00]/10">
|
|
<div className="relative flex-shrink-0">
|
|
<img
|
|
src="/images/sasquatch-guide.webp"
|
|
alt="The Sasquatch"
|
|
className="h-9 w-9 rounded-full object-cover border border-[#ccff00]/40"
|
|
style={{ boxShadow: '0 0 10px rgba(204,255,0,0.2)' }}
|
|
/>
|
|
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-[#ccff00] rounded-full border border-black shadow-[0_0_6px_#ccff00]" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<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' }}>
|
|
🦧 Sasquatch
|
|
</span>
|
|
{isPro ? (
|
|
<span className="flex items-center gap-0.5 rounded bg-[#ccff00]/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-[#ccff00]">
|
|
⚡ Pro
|
|
</span>
|
|
) : user ? (
|
|
<button
|
|
onClick={() => setProDialogOpen(true)}
|
|
className="flex items-center gap-0.5 rounded bg-red-500/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-red-400 hover:bg-red-500/30 transition-colors"
|
|
title="Upgrade to Sasquatch Pro"
|
|
>
|
|
⚡ Go Pro
|
|
</button>
|
|
) : null}
|
|
<CVMStatus connected={cvmConnected} />
|
|
</div>
|
|
<div className="text-[10px] text-[#ccff00]/50 leading-none mt-0.5">
|
|
{activeMessage.title}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1 flex-shrink-0">
|
|
{/* Minimize — shrinks to draggable bubble */}
|
|
<button
|
|
onClick={() => { if (recentlyOpened()) return; setIsMinimized(true); }}
|
|
className="w-6 h-6 flex items-center justify-center rounded text-[#ccff00]/40 hover:text-[#ccff00] hover:bg-[#ccff00]/10 transition-colors"
|
|
aria-label="Minimize to bubble"
|
|
title="Minimize"
|
|
>
|
|
<Minimize2 className="h-3 w-3" />
|
|
</button>
|
|
{/* Close — hides the panel entirely */}
|
|
<button
|
|
onClick={() => { if (recentlyOpened()) return; setIsVisible(false); if (onDismiss) onDismiss(); }}
|
|
className="w-6 h-6 flex items-center justify-center rounded text-white/30 hover:text-white/80 hover:bg-white/10 transition-colors"
|
|
aria-label="Close panel"
|
|
title="Close"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-3">
|
|
{/* Greeting */}
|
|
<p className="text-xs text-gray-300 leading-relaxed mb-3">
|
|
{activeMessage.message}
|
|
</p>
|
|
|
|
{/* Action button */}
|
|
{activeMessage.action && (
|
|
<button
|
|
onClick={activeMessage.action.onClick}
|
|
className="w-full py-2 px-3 rounded-lg text-xs font-bold text-black bg-[#ccff00] hover:bg-[#d4ff1a] transition-colors mb-3"
|
|
>
|
|
{activeMessage.action.label}
|
|
</button>
|
|
)}
|
|
|
|
{/* Chat section */}
|
|
{activeMessage.enableChat && (
|
|
<div className="border-t border-[#ccff00]/10 pt-3">
|
|
{!showChat ? (
|
|
<button
|
|
onClick={() => setShowChat(true)}
|
|
className="w-full py-2 px-3 rounded-lg text-xs font-bold text-[#ccff00] border border-[#ccff00]/30 hover:border-[#ccff00]/60 hover:bg-[#ccff00]/5 transition-all flex items-center justify-center gap-1.5"
|
|
>
|
|
<HelpCircle className="h-3 w-3" />
|
|
{activeMessage.context === 'lab' && 'Ask about Coaching & Filming'}
|
|
{activeMessage.context === 'map' && 'Ask about Spots, Shops & Gear'}
|
|
{(activeMessage.context === 'home' || activeMessage.context === 'onboarding') && 'Ask me anything 🛹'}
|
|
{(!activeMessage.context || (activeMessage.context !== 'lab' && activeMessage.context !== 'map' && activeMessage.context !== 'home' && activeMessage.context !== 'onboarding')) && 'Ask me anything 🛹'}
|
|
</button>
|
|
) : (
|
|
<div className="space-y-2.5">
|
|
|
|
{/* Suggested questions */}
|
|
{chatMessages.length === 0 && activeMessage.suggestedQuestions && (
|
|
<div className="space-y-1">
|
|
<div className="text-[9px] text-[#ccff00]/50 font-bold uppercase tracking-wider mb-1.5">
|
|
Quick questions:
|
|
</div>
|
|
{activeMessage.suggestedQuestions.map((q) => (
|
|
<button
|
|
key={q}
|
|
onClick={() => handleSendMessage(q)}
|
|
disabled={isThinking}
|
|
className="w-full text-left text-[11px] text-gray-300 hover:text-[#ccff00] px-2.5 py-1.5 rounded border border-white/5 hover:border-[#ccff00]/20 hover:bg-[#ccff00]/5 transition-all disabled:opacity-50"
|
|
>
|
|
{q}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Chat history */}
|
|
{(chatMessages.length > 0 || streamingText) && (
|
|
<ScrollArea className="h-48 md:h-52 rounded-lg border border-white/5 bg-black/40 p-2">
|
|
<div className="space-y-2" ref={scrollRef}>
|
|
{chatMessages.map((msg, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={cn(
|
|
'text-[11px] p-2 rounded-lg leading-relaxed',
|
|
msg.role === 'user'
|
|
? 'bg-[#ff0040]/10 text-gray-200 ml-4 border border-[#ff0040]/20'
|
|
: 'bg-[#ccff00]/5 text-gray-200 mr-4 border border-[#ccff00]/10'
|
|
)}
|
|
>
|
|
<div className="font-bold text-[9px] mb-1 flex items-center gap-1 opacity-60">
|
|
{msg.role === 'user' ? '🧑 You' : '🦧 Sasquatch'}
|
|
{msg.isLive && (
|
|
<span className="text-[#ccff00] bg-[#ccff00]/10 px-1 rounded text-[8px]">
|
|
live
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="whitespace-pre-wrap"><ChatText text={msg.text} /></div>
|
|
{msg.draft && (
|
|
<div className="mt-2">
|
|
<SasquatchActionCard
|
|
draft={msg.draft}
|
|
status={msg.draftStatus ?? 'pending'}
|
|
resultLabel={msg.draftResult}
|
|
onPublish={(d) => confirmDraft(msg.timestamp, d)}
|
|
onCancel={() => setDraftStatus(msg.timestamp, 'cancelled')}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* Thinking indicator */}
|
|
{(isThinking || streamingText) && (
|
|
<div className="bg-[#ccff00]/5 text-gray-300 mr-4 text-[11px] p-2 rounded-lg border border-[#ccff00]/10">
|
|
<div className="font-bold text-[9px] mb-1 opacity-60">🦧 Sasquatch</div>
|
|
{streamingText ? (
|
|
<span className="italic text-[#ccff00]/70 text-[10px]">{streamingText}</span>
|
|
) : (
|
|
<span className="flex items-center gap-1.5 text-[#ccff00]/60">
|
|
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
|
<span className="text-[10px]">thinking...</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
|
|
{/* Input row */}
|
|
<div className="flex gap-1.5">
|
|
<Input
|
|
value={userInput}
|
|
onChange={(e) => setUserInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && !inputDisabled && handleSendMessage()}
|
|
placeholder="Ask about spots, events, coaches..."
|
|
disabled={inputDisabled}
|
|
className="text-xs h-8 bg-black/40 border-[#ccff00]/20 text-white placeholder:text-gray-600 focus-visible:ring-[#ccff00]/40 focus-visible:border-[#ccff00]/40"
|
|
/>
|
|
<button
|
|
onClick={() => handleSendMessage()}
|
|
disabled={!userInput.trim() || inputDisabled}
|
|
className="h-8 w-8 flex-shrink-0 flex items-center justify-center rounded-md bg-[#ccff00] hover:bg-[#d4ff1a] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isThinking ? (
|
|
<Loader2 className="h-3 w-3 animate-spin text-black" />
|
|
) : (
|
|
<Send className="h-3 w-3 text-black" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[9px] text-gray-600 flex items-center gap-1 flex-wrap">
|
|
Powered by{' '}
|
|
<a
|
|
href="https://contextvm.org/s/e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[#ccff00]/60 hover:text-[#ccff00] underline underline-offset-1 transition-colors"
|
|
>
|
|
ContextVM
|
|
</a>
|
|
{' & '}
|
|
Nostr Relays Data
|
|
</p>
|
|
{chatMessages.length > 0 && (
|
|
<button
|
|
onClick={() => { setChatMessages([]); setShowChat(false); setCvmConnected(null); }}
|
|
className="text-[9px] text-gray-600 hover:text-[#ccff00] transition-colors flex-shrink-0"
|
|
>
|
|
clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom jungle strip */}
|
|
<div
|
|
className="h-0.5 w-full opacity-30"
|
|
style={{ background: 'linear-gradient(90deg, transparent, #ccff00, transparent)' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sasquatch Pro upgrade dialog */}
|
|
<SasquatchProDialog open={proDialogOpen} onOpenChange={setProDialogOpen} />
|
|
</>
|
|
);
|
|
}
|