feat: map as homepage, 3D globe, camera fix, Sasquatch upgrades, UI/UX polish
## 1. Map as Homepage - `/` now renders MapPage (the globe/map) — removed old Index homepage - `/home` redirects to Index (legacy), `/map` alias still works - Navbar: removed HOME link, SPOTS (MapPin) is now first nav item - isActive() treats both `/` and `/map` as active for SPOTS tab ## 2. 3D Globe View - New SkateGlobe component using pure Three.js (no h3-js/CDN issues) - Earth night texture with atmospheric neon red glow - Spot pins (red) + shop pins (lime) on globe surface - Click pin → zoom camera in + show info card - Drag to rotate, scroll/pinch to zoom, auto-rotation when idle - Globe toggle button added to map/list view switcher - Selected marker ring + info card with View Spot/Shop links - Lazy-loaded with Suspense skeleton ## 3. Settings → Stash Link - Added My Stash card in Settings > Wallet tab - Links to /stash with Package icon and description - Smooth hover animation with ChevronRight ## 4. Camera Fix (Capacitor Native) - CameraCapture now detects native Capacitor platform at runtime - On native Android/iOS: uses @capacitor/camera (OS-level camera, no WebView permission popup) - On browser: falls back to getUserMedia stream (existing behaviour) - Added Upload from Gallery fallback when camera permission denied - Better error handling for user cancellation ## 5. Sasquatch Upgrades - FAQ page: replaced static accordion wall with Sasquatch-powered UI - Quick question chips that pre-load questions into Sasquatch - "Open The Sasquatch" CTA button - Quick facts grid, clean modern design - About page: added Sasquatch CTA card at bottom - SasquatchGuide: listens for sasquatch:open and sasquatch:ask custom events - External pages can now trigger and pre-fill the Sasquatch - Sasquatch now shows even without a page-level message prop (for external triggers) ## 6. UI/UX Audit & Polish - Focus rings: on-brand red (WCAG accessible) - Card hover micro-interactions: subtle translateY + shadow - Button/link transition consistency (150ms ease) - Reduced motion support (@media prefers-reduced-motion) - Modal backdrop blur improvement - Skeleton shimmer animation utility - Globe CSS utilities (touch-action: none for canvas) - Typography helpers (text-balance, text-pretty) - Improved map-full-height responsive calculation Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
cf1a18e941
commit
6331a937f2
14 changed files with 1088 additions and 381 deletions
32
package-lock.json
generated
32
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
"@capacitor/camera": "^8.2.1",
|
||||
"@cashu/cashu-ts": "2.8.1",
|
||||
"@contextvm/sdk": "^0.12.0",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
|
|
@ -84,6 +85,7 @@
|
|||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-globe.gl": "^2.38.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
|
|
@ -92,6 +94,7 @@
|
|||
"recharts": "^2.12.7",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.185.1",
|
||||
"vaul": "^0.9.3",
|
||||
"zod": "^3.25.71"
|
||||
},
|
||||
|
|
@ -110,6 +113,7 @@
|
|||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/three": "^0.185.1",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
|
@ -282,6 +286,12 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/camera": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.1.tgz",
|
||||
"integrity": "sha512-yq4TbVcv9Z91JLr7C5407s3rrXr/Dmk+Pr9FAVRn65s3Jg4iYabjwG4fVKUYlQL5TiUdBbZA3LiNAbUxJNcUkw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz",
|
||||
|
|
@ -3768,6 +3778,13 @@
|
|||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
|
||||
"license": "MIT",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.31.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz",
|
||||
|
|
@ -7226,6 +7243,15 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-globe.gl": {
|
||||
"version": "2.38.0",
|
||||
"resolved": "https://registry.npmjs.org/react-globe.gl/-/react-globe.gl-2.38.0.tgz",
|
||||
"integrity": "sha512-NQkMy5BGD269lkdjm9BJ2PEK81++y9H82+j4/QuWVlfXPJPTVfzaxnkC3MvaiGmpus8P6czq58OfMeYvn/4Xqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.56.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz",
|
||||
|
|
@ -7983,6 +8009,12 @@
|
|||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
"@capacitor/camera": "^8.2.1",
|
||||
"@cashu/cashu-ts": "2.8.1",
|
||||
"@contextvm/sdk": "^0.12.0",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
|
|
@ -97,6 +98,7 @@
|
|||
"recharts": "^2.12.7",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.185.1",
|
||||
"vaul": "^0.9.3",
|
||||
"zod": "^3.25.71"
|
||||
},
|
||||
|
|
@ -115,6 +117,7 @@
|
|||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/three": "^0.185.1",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { HashRouter, Route, Routes } from "react-router-dom";
|
|||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { DeepLinkHandler } from "./components/DeepLinkHandler";
|
||||
|
||||
// Eagerly load only the most-visited pages
|
||||
import Index from "./pages/Index";
|
||||
// Eagerly load the map — it's now the homepage
|
||||
import MapPage from "./pages/MapPage";
|
||||
|
||||
// Lazy load everything else — each page becomes its own JS chunk
|
||||
const MapPage = lazy(() => import("./pages/MapPage"));
|
||||
const Index = lazy(() => import("./pages/Index"));
|
||||
const EventsPage = lazy(() => import("./pages/EventsPage"));
|
||||
const LabPage = lazy(() => import("./pages/LabPage"));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage"));
|
||||
|
|
@ -67,8 +67,12 @@ export function AppRouter() {
|
|||
<ScrollToTop />
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
{/* Map is the homepage */}
|
||||
<Route path="/" element={<MapPage />} />
|
||||
{/* /map redirects to / */}
|
||||
<Route path="/map" element={<MapPage />} />
|
||||
{/* Legacy home page still accessible */}
|
||||
<Route path="/home" element={<Index />} />
|
||||
<Route path="/events" element={<EventsPage />} />
|
||||
<Route path="/lab" element={<LabPage />} />
|
||||
{/* Legacy route redirect */}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
/**
|
||||
* CameraCapture — unified camera for browser + native Capacitor app
|
||||
*
|
||||
* On native (Android / iOS via Capacitor):
|
||||
* → Uses @capacitor/camera which calls the OS native camera directly.
|
||||
* No WebView permission popup, no getUserMedia, no dialog needed.
|
||||
* The user sees the full native camera UI, then returns here with the photo.
|
||||
*
|
||||
* On browser (desktop / mobile web):
|
||||
* → Falls back to getUserMedia stream inside this dialog component.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -10,13 +22,42 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Camera,
|
||||
X,
|
||||
RotateCcw,
|
||||
Check,
|
||||
Loader2,
|
||||
SwitchCamera,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ─── Capacitor detection ────────────────────────────────────────────────────
|
||||
// We dynamically import Capacitor to avoid breaking the web build.
|
||||
// On native this module exists; on web it throws/returns isNativePlatform=false.
|
||||
|
||||
async function isNative(): Promise<boolean> {
|
||||
try {
|
||||
const { Capacitor } = await import('@capacitor/core');
|
||||
return Capacitor.isNativePlatform();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function captureNative(): Promise<File | null> {
|
||||
const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera');
|
||||
const photo = await Camera.getPhoto({
|
||||
resultType: CameraResultType.DataUrl,
|
||||
source: CameraSource.Camera,
|
||||
quality: 90,
|
||||
correctOrientation: true,
|
||||
});
|
||||
if (!photo.dataUrl) return null;
|
||||
const res = await fetch(photo.dataUrl);
|
||||
const blob = await res.blob();
|
||||
return new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CameraCaptureProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -24,6 +65,8 @@ interface CameraCaptureProps {
|
|||
isUploading?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function CameraCapture({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -39,41 +82,57 @@ export function CameraCapture({
|
|||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment');
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isCapturingNative, setIsCapturingNative] = useState(false);
|
||||
|
||||
// Start the camera
|
||||
// ── Native capture (Capacitor) ────────────────────────────────────────────
|
||||
const handleNativeCapture = useCallback(async () => {
|
||||
setIsCapturingNative(true);
|
||||
try {
|
||||
const file = await captureNative();
|
||||
if (file) {
|
||||
onCapture(file);
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// User cancelled — not an error
|
||||
if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('user cancel')) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: 'Camera error',
|
||||
description: 'Could not open the camera. Please check app permissions in Settings.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsCapturingNative(false);
|
||||
}
|
||||
}, [onCapture, onOpenChange, toast]);
|
||||
|
||||
// ── Browser camera ────────────────────────────────────────────────────────
|
||||
const startCamera = useCallback(async (facing: 'user' | 'environment' = facingMode) => {
|
||||
setIsStarting(true);
|
||||
try {
|
||||
// Stop any existing stream first
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: facing,
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: facing, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||||
audio: false,
|
||||
};
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
|
||||
setHasPermission(true);
|
||||
setFacingMode(facing);
|
||||
} catch (error) {
|
||||
console.error('Camera access error:', error);
|
||||
} catch {
|
||||
setHasPermission(false);
|
||||
toast({
|
||||
title: 'Camera access denied',
|
||||
description: 'Please allow camera access in your browser settings to take photos.',
|
||||
description: 'Please allow camera access in your browser settings.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -81,85 +140,90 @@ export function CameraCapture({
|
|||
}
|
||||
}, [facingMode, toast]);
|
||||
|
||||
// Stop the camera
|
||||
const stopCamera = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
streamRef.current?.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
if (videoRef.current) videoRef.current.srcObject = null;
|
||||
}, []);
|
||||
|
||||
// Start camera when dialog opens
|
||||
// On native: immediately trigger the native camera when the dialog opens
|
||||
// On web: start the stream
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
startCamera();
|
||||
} else {
|
||||
if (!open) {
|
||||
stopCamera();
|
||||
setCapturedImage(null);
|
||||
setHasPermission(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
isNative().then((native) => {
|
||||
if (cancelled) return;
|
||||
if (native) {
|
||||
handleNativeCapture();
|
||||
} else {
|
||||
startCamera();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopCamera();
|
||||
};
|
||||
}, [open, startCamera, stopCamera]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// Switch camera (front/back)
|
||||
const switchCamera = () => {
|
||||
const newFacing = facingMode === 'user' ? 'environment' : 'user';
|
||||
const next = facingMode === 'user' ? 'environment' : 'user';
|
||||
setCapturedImage(null);
|
||||
startCamera(newFacing);
|
||||
startCamera(next);
|
||||
};
|
||||
|
||||
// Capture photo
|
||||
const capturePhoto = () => {
|
||||
if (!videoRef.current || !canvasRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas dimensions to video dimensions
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
// Draw the video frame to the canvas
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Get the data URL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
setCapturedImage(dataUrl);
|
||||
setCapturedImage(canvas.toDataURL('image/jpeg', 0.9));
|
||||
};
|
||||
|
||||
// Retake photo
|
||||
const retakePhoto = () => {
|
||||
setCapturedImage(null);
|
||||
};
|
||||
|
||||
// Confirm and upload photo
|
||||
const confirmPhoto = async () => {
|
||||
if (!capturedImage || !canvasRef.current) return;
|
||||
|
||||
// Convert data URL to Blob then to File
|
||||
const response = await fetch(capturedImage);
|
||||
const blob = await response.blob();
|
||||
const res = await fetch(capturedImage);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||||
|
||||
onCapture(file);
|
||||
};
|
||||
|
||||
// Handle close
|
||||
const handleClose = () => {
|
||||
stopCamera();
|
||||
setCapturedImage(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// ── Native in-progress screen ─────────────────────────────────────────────
|
||||
// While native camera is active this dialog acts as a thin loading screen.
|
||||
if (isCapturingNative) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[340px] bg-black border-[#ff0040]/50">
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-[#ff0040]/20 flex items-center justify-center">
|
||||
<Camera className="h-8 w-8 text-[#ff0040]" />
|
||||
</div>
|
||||
<p className="text-white font-bold text-center">Opening camera…</p>
|
||||
<p className="text-gray-400 text-sm text-center">Use your phone camera, then return here</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Browser camera dialog ─────────────────────────────────────────────────
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px] p-0 overflow-hidden bg-black border-[#ff0040]/50">
|
||||
|
|
@ -171,10 +235,9 @@ export function CameraCapture({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="relative aspect-[4/3] bg-black">
|
||||
{/* Hidden canvas for capturing */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{/* Loading state */}
|
||||
{/* Loading */}
|
||||
{isStarting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
||||
<div className="text-center">
|
||||
|
|
@ -184,21 +247,41 @@ export function CameraCapture({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* No permission state */}
|
||||
{/* No permission */}
|
||||
{hasPermission === false && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
||||
<div className="text-center p-6">
|
||||
<Camera className="h-12 w-12 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-white font-bold mb-2">Camera Access Required</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Please allow camera access in your browser to take photos.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => startCamera()}
|
||||
className="bg-[#ff0040] hover:bg-[#ff0040]/90"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
<div className="text-center p-6 space-y-4">
|
||||
<Camera className="h-12 w-12 text-gray-600 mx-auto" />
|
||||
<div>
|
||||
<h3 className="text-white font-bold mb-2">Camera Access Required</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Allow camera access in your browser settings, or use the file picker below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button onClick={() => startCamera()} className="bg-[#ff0040] hover:bg-[#ff0040]/90 gap-2">
|
||||
<Camera className="h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<label className="cursor-pointer">
|
||||
<Button variant="outline" className="gap-2 w-full border-gray-700" asChild>
|
||||
<span>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload from Gallery
|
||||
</span>
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) { onCapture(file); handleClose(); }
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -217,16 +300,12 @@ export function CameraCapture({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Captured image preview */}
|
||||
{/* Captured preview */}
|
||||
{capturedImage && (
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt="Captured"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<img src={capturedImage} alt="Captured" className="w-full h-full object-cover" />
|
||||
)}
|
||||
|
||||
{/* Camera switch button */}
|
||||
{/* Switch camera button */}
|
||||
{!capturedImage && hasPermission && !isStarting && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -238,10 +317,9 @@ export function CameraCapture({
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{/* Crosshairs/guides */}
|
||||
{/* Grid lines */}
|
||||
{!capturedImage && hasPermission && !isStarting && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Grid lines */}
|
||||
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3">
|
||||
<div className="border-r border-white/20" />
|
||||
<div className="border-r border-white/20" />
|
||||
|
|
@ -280,7 +358,7 @@ export function CameraCapture({
|
|||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={retakePhoto}
|
||||
onClick={() => setCapturedImage(null)}
|
||||
disabled={isUploading}
|
||||
className="gap-2 border-gray-700 text-gray-400 hover:bg-gray-900"
|
||||
>
|
||||
|
|
@ -293,15 +371,9 @@ export function CameraCapture({
|
|||
className="gap-2 bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Uploading...</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Use Photo
|
||||
</>
|
||||
<><Check className="h-4 w-4" />Use Photo</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -59,9 +59,9 @@ interface LayoutProps {
|
|||
}
|
||||
|
||||
// Nav item keys that map to translation keys
|
||||
// HOME removed — the map IS the homepage now
|
||||
const navItemKeys = [
|
||||
{ href: '/', labelKey: 'nav.home', icon: Home },
|
||||
{ href: '/map', labelKey: 'nav.map', icon: MapPin },
|
||||
{ href: '/', labelKey: 'nav.map', icon: MapPin },
|
||||
{ href: '/lab', labelKey: 'nav.lab', icon: GraduationCap },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: Calendar },
|
||||
];
|
||||
|
|
@ -346,7 +346,8 @@ export function Layout({ children }: LayoutProps) {
|
|||
const userAvatar = author.data?.metadata?.picture;
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
// Both / and /map are the "spots" page
|
||||
if (path === '/') return location.pathname === '/' || location.pathname === '/map';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -541,8 +541,44 @@ Instructions:
|
|||
}
|
||||
}, [userInput, isThinking, chatMessages, user, cvm, appendMessage, cvmConnected]);
|
||||
|
||||
// Don't render if no message for this page
|
||||
if (!message) return null;
|
||||
// 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]);
|
||||
|
||||
// Show if we have a message prop OR if we've been opened externally
|
||||
const shouldShow = !!message || isVisible;
|
||||
if (!shouldShow) return null;
|
||||
|
||||
const inputDisabled = isThinking;
|
||||
|
||||
|
|
|
|||
425
src/components/SkateGlobe.tsx
Normal file
425
src/components/SkateGlobe.tsx
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
/**
|
||||
* SkateGlobe — 3D interactive globe using pure Three.js
|
||||
* No h3-js dependency. Uses WebGL for hardware-accelerated rendering.
|
||||
*
|
||||
* Features:
|
||||
* - Spherical Earth with night-side texture
|
||||
* - Atmospheric glow (neon red ring)
|
||||
* - Spot/shop pins on the globe surface
|
||||
* - Click pin → zoom camera to it
|
||||
* - Drag to rotate, scroll/pinch to zoom
|
||||
* - Auto-rotation when idle
|
||||
* - ResizeObserver for responsive canvas
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { SkateSpot, SkateShop } from '@/lib/types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GlobeMarker {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
type: 'spot' | 'shop';
|
||||
color: string;
|
||||
spot?: SkateSpot;
|
||||
shop?: SkateShop;
|
||||
}
|
||||
|
||||
interface SkateGlobeProps {
|
||||
spots: SkateSpot[];
|
||||
shops: SkateShop[];
|
||||
showSpots: boolean;
|
||||
showShops: boolean;
|
||||
selectedMarkerId?: string | null;
|
||||
onMarkerClick: (marker: GlobeMarker) => void;
|
||||
onBackgroundClick?: () => void;
|
||||
}
|
||||
|
||||
// ─── Colour palette ───────────────────────────────────────────────────────────
|
||||
const SPOT_COLOR = new THREE.Color('#ff0040');
|
||||
const SHOP_COLOR = new THREE.Color('#ccff00');
|
||||
const GLOBE_RADIUS = 1.0;
|
||||
|
||||
// ─── Lat/Lng → 3D sphere position ─────────────────────────────────────────────
|
||||
function latLngToVec3(lat: number, lng: number, radius: number): THREE.Vector3 {
|
||||
const phi = (90 - lat) * (Math.PI / 180);
|
||||
const theta = (lng + 180) * (Math.PI / 180);
|
||||
return new THREE.Vector3(
|
||||
-radius * Math.sin(phi) * Math.cos(theta),
|
||||
radius * Math.cos(phi),
|
||||
radius * Math.sin(phi) * Math.sin(theta)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Geohash decode ───────────────────────────────────────────────────────────
|
||||
function decodeGeohash(g: string): { lat: number; lng: number } | null {
|
||||
if (!g || g.length < 4) return null;
|
||||
try {
|
||||
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
|
||||
let minLat = -90, maxLat = 90, minLng = -180, maxLng = 180;
|
||||
let isEven = true;
|
||||
for (const c of g.toLowerCase()) {
|
||||
const idx = BASE32.indexOf(c);
|
||||
if (idx < 0) break;
|
||||
for (let bits = 4; bits >= 0; bits--) {
|
||||
const bit = (idx >> bits) & 1;
|
||||
if (isEven) {
|
||||
const mid = (minLng + maxLng) / 2;
|
||||
if (bit) minLng = mid; else maxLng = mid;
|
||||
} else {
|
||||
const mid = (minLat + maxLat) / 2;
|
||||
if (bit) minLat = mid; else maxLat = mid;
|
||||
}
|
||||
isEven = !isEven;
|
||||
}
|
||||
}
|
||||
return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCoords(tags: string[][]): { lat: number; lng: number } | null {
|
||||
const g = tags.find(([k]) => k === 'g')?.[1];
|
||||
if (g) { const c = decodeGeohash(g); if (c) return c; }
|
||||
const lat = parseFloat(tags.find(([k]) => k === 'lat')?.[1] ?? '');
|
||||
const lng = parseFloat(tags.find(([k]) => k === 'lon')?.[1] ?? '');
|
||||
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SkateGlobe({
|
||||
spots,
|
||||
shops,
|
||||
showSpots,
|
||||
showShops,
|
||||
selectedMarkerId,
|
||||
onMarkerClick,
|
||||
onBackgroundClick,
|
||||
}: SkateGlobeProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const frameRef = useRef<number>(0);
|
||||
const globeGroupRef = useRef<THREE.Group | null>(null);
|
||||
const markersGroupRef = useRef<THREE.Group | null>(null);
|
||||
const raycasterRef = useRef(new THREE.Raycaster());
|
||||
const mouseRef = useRef(new THREE.Vector2());
|
||||
|
||||
// Interaction state
|
||||
const isDraggingRef = useRef(false);
|
||||
const lastMouseRef = useRef({ x: 0, y: 0 });
|
||||
const rotationRef = useRef({ x: 0.3, y: 0 });
|
||||
const rotationVelRef = useRef({ x: 0, y: 0.002 }); // auto-rotate
|
||||
const zoomRef = useRef(2.5);
|
||||
const touchStartDistRef = useRef(0);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [markerMap, setMarkerMap] = useState<Map<THREE.Object3D, GlobeMarker>>(new Map());
|
||||
|
||||
// Build marker data
|
||||
const markers = useMemo<GlobeMarker[]>(() => {
|
||||
const result: GlobeMarker[] = [];
|
||||
if (showSpots) {
|
||||
for (const spot of spots) {
|
||||
const coords = extractCoords(spot.tags);
|
||||
if (!coords) continue;
|
||||
const name = spot.tags.find(([k]) => k === 'title')?.[1]
|
||||
?? spot.tags.find(([k]) => k === 'name')?.[1]
|
||||
?? 'Unnamed Spot';
|
||||
result.push({ id: `spot-${spot.id}`, lat: coords.lat, lng: coords.lng, label: name, type: 'spot', color: '#ff0040', spot });
|
||||
}
|
||||
}
|
||||
if (showShops) {
|
||||
for (const shop of shops) {
|
||||
const coords = extractCoords(shop.tags);
|
||||
if (!coords) continue;
|
||||
const name = shop.tags.find(([k]) => k === 'title')?.[1]
|
||||
?? shop.tags.find(([k]) => k === 'name')?.[1]
|
||||
?? 'Skate Shop';
|
||||
result.push({ id: `shop-${shop.id}`, lat: coords.lat, lng: coords.lng, label: name, type: 'shop', color: '#ccff00', shop });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [spots, shops, showSpots, showShops]);
|
||||
|
||||
// ── Scene setup ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !containerRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
const w = container.clientWidth || 800;
|
||||
const h = container.clientHeight || 600;
|
||||
|
||||
// Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setSize(w, h);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene();
|
||||
sceneRef.current = scene;
|
||||
|
||||
// Camera
|
||||
const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100);
|
||||
camera.position.z = zoomRef.current;
|
||||
cameraRef.current = camera;
|
||||
|
||||
// Starfield
|
||||
const starGeom = new THREE.BufferGeometry();
|
||||
const starCount = 2000;
|
||||
const starPositions = new Float32Array(starCount * 3);
|
||||
for (let i = 0; i < starCount * 3; i++) {
|
||||
starPositions[i] = (Math.random() - 0.5) * 80;
|
||||
}
|
||||
starGeom.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
|
||||
const starMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.08, transparent: true, opacity: 0.6 });
|
||||
scene.add(new THREE.Points(starGeom, starMat));
|
||||
|
||||
// Globe group
|
||||
const globeGroup = new THREE.Group();
|
||||
globeGroupRef.current = globeGroup;
|
||||
scene.add(globeGroup);
|
||||
|
||||
// Globe sphere — use a procedural texture for the night-side look
|
||||
const globeGeom = new THREE.SphereGeometry(GLOBE_RADIUS, 64, 64);
|
||||
|
||||
// Load Earth texture from CDN
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const earthTex = textureLoader.load(
|
||||
'https://unpkg.com/three-globe/example/img/earth-night.jpg',
|
||||
() => setReady(true),
|
||||
undefined,
|
||||
() => setReady(true) // fallback if texture fails
|
||||
);
|
||||
const bumpTex = textureLoader.load('https://unpkg.com/three-globe/example/img/earth-topology.png');
|
||||
|
||||
const globeMat = new THREE.MeshPhongMaterial({
|
||||
map: earthTex,
|
||||
bumpMap: bumpTex,
|
||||
bumpScale: 0.005,
|
||||
specular: new THREE.Color(0x333333),
|
||||
shininess: 10,
|
||||
});
|
||||
const globe = new THREE.Mesh(globeGeom, globeMat);
|
||||
globeGroup.add(globe);
|
||||
|
||||
// Atmosphere glow
|
||||
const atmosGeom = new THREE.SphereGeometry(GLOBE_RADIUS * 1.06, 32, 32);
|
||||
const atmosMat = new THREE.MeshPhongMaterial({
|
||||
color: new THREE.Color('#ff0040'),
|
||||
transparent: true,
|
||||
opacity: 0.08,
|
||||
side: THREE.FrontSide,
|
||||
});
|
||||
globeGroup.add(new THREE.Mesh(atmosGeom, atmosMat));
|
||||
|
||||
// Lighting
|
||||
const ambientLight = new THREE.AmbientLight(0x404040, 1.5);
|
||||
scene.add(ambientLight);
|
||||
const sunLight = new THREE.DirectionalLight(0xffffff, 2.0);
|
||||
sunLight.position.set(5, 3, 5);
|
||||
scene.add(sunLight);
|
||||
|
||||
// Markers group
|
||||
const markersGroup = new THREE.Group();
|
||||
markersGroupRef.current = markersGroup;
|
||||
globeGroup.add(markersGroup);
|
||||
|
||||
// Animation loop
|
||||
let animFrame: number;
|
||||
const animate = () => {
|
||||
animFrame = requestAnimationFrame(animate);
|
||||
|
||||
// Apply rotation velocity
|
||||
rotationRef.current.x += rotationVelRef.current.x * 0.05;
|
||||
rotationRef.current.y += rotationVelRef.current.y;
|
||||
|
||||
// Decay auto-rotation velocity when dragging stops
|
||||
if (!isDraggingRef.current) {
|
||||
rotationVelRef.current.x *= 0.92;
|
||||
// Restore gentle auto-rotate
|
||||
rotationVelRef.current.y = rotationVelRef.current.y * 0.98 + 0.002 * 0.02;
|
||||
}
|
||||
|
||||
globeGroup.rotation.x = rotationRef.current.x;
|
||||
globeGroup.rotation.y = rotationRef.current.y;
|
||||
|
||||
// Smooth zoom
|
||||
const targetZ = zoomRef.current;
|
||||
camera.position.z += (targetZ - camera.position.z) * 0.1;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
frameRef.current = animFrame!;
|
||||
|
||||
// ResizeObserver
|
||||
const ro = new ResizeObserver(() => {
|
||||
const nw = container.clientWidth;
|
||||
const nh = container.clientHeight;
|
||||
if (nw > 0 && nh > 0) {
|
||||
renderer.setSize(nw, nh);
|
||||
camera.aspect = nw / nh;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animFrame);
|
||||
ro.disconnect();
|
||||
renderer.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Update markers when data changes ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const mg = markersGroupRef.current;
|
||||
if (!mg) return;
|
||||
|
||||
// Clear existing pins
|
||||
while (mg.children.length) mg.remove(mg.children[0]);
|
||||
const newMap = new Map<THREE.Object3D, GlobeMarker>();
|
||||
|
||||
for (const marker of markers) {
|
||||
const pos = latLngToVec3(marker.lat, marker.lng, GLOBE_RADIUS + 0.012);
|
||||
const isSelected = marker.id === selectedMarkerId;
|
||||
const color = marker.type === 'spot' ? SPOT_COLOR : SHOP_COLOR;
|
||||
|
||||
// Pin dot
|
||||
const geom = new THREE.SphereGeometry(isSelected ? 0.022 : 0.014, 8, 8);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: isSelected ? new THREE.Color('#ffffff') : color });
|
||||
const mesh = new THREE.Mesh(geom, mat);
|
||||
mesh.position.copy(pos);
|
||||
mg.add(mesh);
|
||||
newMap.set(mesh, marker);
|
||||
|
||||
// Outer ring for selected
|
||||
if (isSelected) {
|
||||
const ringGeom = new THREE.RingGeometry(0.028, 0.034, 16);
|
||||
const ringMat = new THREE.MeshBasicMaterial({ color: new THREE.Color('#ffffff'), side: THREE.DoubleSide, transparent: true, opacity: 0.7 });
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.position.copy(pos);
|
||||
ring.lookAt(new THREE.Vector3(0, 0, 0).add(pos));
|
||||
mg.add(ring);
|
||||
}
|
||||
}
|
||||
|
||||
setMarkerMap(newMap);
|
||||
}, [markers, selectedMarkerId]);
|
||||
|
||||
// ── Mouse / touch interaction ────────────────────────────────────────────────
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
isDraggingRef.current = false;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
canvasRef.current?.setPointerCapture(e.pointerId);
|
||||
// Stop auto-rotate velocity
|
||||
rotationVelRef.current = { x: 0, y: 0 };
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!(e.buttons & 1)) return;
|
||||
const dx = e.clientX - lastMouseRef.current.x;
|
||||
const dy = e.clientY - lastMouseRef.current.y;
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) isDraggingRef.current = true;
|
||||
rotationVelRef.current.y = dx * 0.004;
|
||||
rotationVelRef.current.x = dy * 0.004;
|
||||
rotationRef.current.y += dx * 0.004;
|
||||
rotationRef.current.x += dy * 0.004;
|
||||
rotationRef.current.x = Math.max(-Math.PI / 2.2, Math.min(Math.PI / 2.2, rotationRef.current.x));
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
canvasRef.current?.releasePointerCapture(e.pointerId);
|
||||
if (!isDraggingRef.current) {
|
||||
// Click — raycast
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
mouseRef.current.set(
|
||||
((e.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((e.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current!);
|
||||
const meshes = Array.from(markerMap.keys()).filter(o => o instanceof THREE.Mesh) as THREE.Mesh[];
|
||||
const hits = raycasterRef.current.intersectObjects(meshes);
|
||||
if (hits.length > 0) {
|
||||
const marker = markerMap.get(hits[0].object);
|
||||
if (marker) {
|
||||
// Zoom to marker
|
||||
zoomRef.current = 1.4;
|
||||
// Rotate globe so pin faces camera
|
||||
const target = latLngToVec3(marker.lat, marker.lng, 1);
|
||||
rotationRef.current.y = -Math.atan2(target.x, target.z);
|
||||
rotationRef.current.x = Math.asin(target.y);
|
||||
onMarkerClick(marker);
|
||||
}
|
||||
} else {
|
||||
zoomRef.current = 2.5;
|
||||
onBackgroundClick?.();
|
||||
}
|
||||
}
|
||||
isDraggingRef.current = false;
|
||||
}, [markerMap, onMarkerClick, onBackgroundClick]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
zoomRef.current = Math.max(1.3, Math.min(5, zoomRef.current + e.deltaY * 0.005));
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
touchStartDistRef.current = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const dist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
const delta = touchStartDistRef.current - dist;
|
||||
zoomRef.current = Math.max(1.3, Math.min(5, zoomRef.current + delta * 0.01));
|
||||
touchStartDistRef.current = dist;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative overflow-hidden bg-black globe-container">
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black z-10 pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="w-14 h-14 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400 text-xs font-bold uppercase tracking-widest">Rendering Globe</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/index.css
118
src/index.css
|
|
@ -420,6 +420,124 @@ html {
|
|||
}
|
||||
}
|
||||
|
||||
/* ─── Global UI/UX Polish ────────────────────────────────────────────────── */
|
||||
|
||||
/* Smooth page transitions */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.page-enter {
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Focus rings — accessible, on-brand ────────────────────────────────── */
|
||||
:focus-visible {
|
||||
outline: 2px solid #ff0040;
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Card hover micro-interactions ─────────────────────────────────────── */
|
||||
.card-hover {
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(255,0,64,0.12), 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* ── Button ripple feel ─────────────────────────────────────────────────── */
|
||||
button, [role="button"], a {
|
||||
transition-property: color, background-color, border-color, box-shadow, transform, opacity;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
/* ── Modal / dialog backdrop improvements ──────────────────────────────── */
|
||||
[data-radix-portal] [data-state="open"][data-overlay] {
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ── Sheet / drawer slide animations ───────────────────────────────────── */
|
||||
[data-radix-dialog-content],
|
||||
[data-radix-sheet-content] {
|
||||
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
/* ── Toast notification improvements ──────────────────────────────────── */
|
||||
[data-radix-toast-root] {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* ── Skeleton loading shimmer ──────────────────────────────────────────── */
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.03) 0%,
|
||||
rgba(255,255,255,0.08) 50%,
|
||||
rgba(255,255,255,0.03) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* ── Improved empty state cards ────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
border: 2px dashed rgba(255,255,255,0.08);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,0.01);
|
||||
}
|
||||
|
||||
/* ── Better dropdown / select styling ─────────────────────────────────── */
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2.5rem;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* ── Map container full height ─────────────────────────────────────────── */
|
||||
.map-full-height {
|
||||
height: calc(100dvh - 4rem);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.map-full-height {
|
||||
height: calc(100dvh - 3.5rem - env(safe-area-inset-bottom, 0px) - 56px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Globe container ───────────────────────────────────────────────────── */
|
||||
.globe-container canvas {
|
||||
touch-action: none; /* prevents scroll interference on mobile */
|
||||
}
|
||||
|
||||
/* ── Typography hierarchy helpers ─────────────────────────────────────── */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
.text-pretty {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* ── Accessibility — reduced motion ────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── SkateSpots Rider Card ──────────────────────────────────────────────── */
|
||||
|
||||
/* Credit-card aspect ratio: 85.6mm × 54mm = 1.586:1 */
|
||||
|
|
|
|||
5
src/lib/stubs/node-fs.ts
Normal file
5
src/lib/stubs/node-fs.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Stub for node:fs — not available in browser
|
||||
// Used by h3-js (dependency of react-globe.gl)
|
||||
export default {};
|
||||
export const readFileSync = () => '';
|
||||
export const existsSync = () => false;
|
||||
12
src/lib/stubs/node-path.ts
Normal file
12
src/lib/stubs/node-path.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Stub for node:path — not available in browser
|
||||
// Used by h3-js (dependency of react-globe.gl)
|
||||
export default {
|
||||
join: (...parts: string[]) => parts.join('/'),
|
||||
resolve: (...parts: string[]) => parts.join('/'),
|
||||
dirname: (p: string) => p.split('/').slice(0, -1).join('/'),
|
||||
basename: (p: string) => p.split('/').pop() || '',
|
||||
};
|
||||
export const join = (...parts: string[]) => parts.join('/');
|
||||
export const resolve = (...parts: string[]) => parts.join('/');
|
||||
export const dirname = (p: string) => p.split('/').slice(0, -1).join('/');
|
||||
export const basename = (p: string) => p.split('/').pop() || '';
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
AlertCircle,
|
||||
Download,
|
||||
Smartphone,
|
||||
MessageCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function AboutPage() {
|
||||
|
|
@ -286,6 +287,26 @@ Version: v0.1.0`;
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sasquatch — Ask me anything */}
|
||||
<Card className="bg-gradient-to-br from-amber-950/30 to-[#ff6600]/10 border-2 border-amber-500/40 mb-8">
|
||||
<CardContent className="p-6 flex flex-col sm:flex-row items-center gap-5">
|
||||
<div className="text-5xl flex-shrink-0">🦧</div>
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h3 className="text-xl font-black text-white uppercase mb-1">Still have questions?</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
The Sasquatch is our AI guide — powered by live SkateSpots data. Ask him about spots, events, coaches, gear, or how anything works.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('sasquatch:ask', { detail: { question: 'Tell me about SkateSpots' } }))}
|
||||
className="bg-amber-500 hover:bg-amber-400 text-black font-bold gap-2 flex-shrink-0 rounded-full px-6"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Ask The Sasquatch
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Powered By */}
|
||||
<div className="text-center mt-8 pb-6 px-4">
|
||||
<p className="text-gray-400 mb-3 uppercase tracking-wide text-sm">Powered by</p>
|
||||
|
|
|
|||
|
|
@ -1,301 +1,142 @@
|
|||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
/**
|
||||
* FAQPage — now powered by The Sasquatch 🦧
|
||||
* Instead of a static wall of text, we surface the AI guide
|
||||
* with FAQ context pre-loaded so users can ask anything.
|
||||
*/
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
category: 'Getting Started',
|
||||
questions: [
|
||||
{
|
||||
q: 'What is SkateSpots?',
|
||||
a: 'SkateSpots is a decentralized app for skateboarders to discover spots, buy/sell gear, connect with coaches and filmers, join events, and build community. Built on Nostr, your contributions can\'t be deleted by any company.',
|
||||
},
|
||||
{
|
||||
q: 'Is SkateSpots free to use?',
|
||||
a: 'Yes! 100% free. Browse spots, add locations, buy/sell gear, connect with coaches/filmers, join events - all free. Zaps (Bitcoin tips) are completely optional.',
|
||||
},
|
||||
{
|
||||
q: 'What is Nostr?',
|
||||
a: 'Nostr is a simple protocol for creating decentralized social apps. Instead of one company controlling everything, your data lives on multiple servers. You own your account - not a platform.',
|
||||
},
|
||||
{
|
||||
q: 'Do I need Bitcoin to use SkateSpots?',
|
||||
a: 'No! Bitcoin is completely optional. You only need it if you want to send/receive zaps (tips) or use the built-in Cashu wallet. Everything else works without any Bitcoin.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Spots & Map',
|
||||
questions: [
|
||||
{
|
||||
q: 'How do I add a skate spot?',
|
||||
a: 'Login to SkateSpots, go to the map page, tap the Filters button (on mobile) or click "Add Spot". Fill in the details (name, location, type), and publish. Your spot will appear on the map for everyone.',
|
||||
},
|
||||
{
|
||||
q: 'Can spots be deleted?',
|
||||
a: 'Creators can delete their own spots by publishing a deletion event. However, because Nostr is decentralized, some relays may keep the content. This is a feature - it prevents censorship.',
|
||||
},
|
||||
{
|
||||
q: 'What\'s the difference between Public, Private, and Ask Locals?',
|
||||
a: 'Public spots are open to everyone (parks, plazas). Private spots need permission from the owner (backyard ramps). Ask Locals means respect the spot and check with local skaters first.',
|
||||
},
|
||||
{
|
||||
q: 'What are the map filters?',
|
||||
a: 'You can filter the map to show: Spots (skate parks & street spots), Shops (local skate shops), Gear (items for sale), Tours (local guides), and Bitcoin merchants (places that accept sats).',
|
||||
},
|
||||
{
|
||||
q: 'How do I find spots near me?',
|
||||
a: 'The map automatically centers on your location if you allow it. You can also search by city name or use the "Near Me" sort option to see closest spots first.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Gear Swap & Marketplace',
|
||||
questions: [
|
||||
{
|
||||
q: 'How do I sell gear?',
|
||||
a: 'Go to the map, open filters, and tap "Sell Gear" - or visit a spot page and click "List Gear". You\'ll need NIP-05 verification. Add photos, price, condition, and link it to a spot for meetups.',
|
||||
},
|
||||
{
|
||||
q: 'Why do I need NIP-05 to sell gear?',
|
||||
a: 'NIP-05 verification helps prevent spam and scams. It proves you\'re a real person with a verifiable identity. You can get free NIP-05 from nostr.directory or nip05.social.',
|
||||
},
|
||||
{
|
||||
q: 'What\'s the anti-spam fee for new sellers?',
|
||||
a: 'New sellers (NIP-05 verified but no spots/listings yet) pay a small 1000 sats fee for their first listing. This prevents spam. Once you\'ve created spots or have 2+ listings, no fee is required.',
|
||||
},
|
||||
{
|
||||
q: 'What currencies can I use for pricing?',
|
||||
a: 'You can price items in sats (Bitcoin), or any major fiat currency (USD, EUR, GBP, etc.). The currency symbol and code are shown to buyers so there\'s no confusion.',
|
||||
},
|
||||
{
|
||||
q: 'How do I meet up with buyers/sellers?',
|
||||
a: 'Listings are linked to spots - you agree to meet at a public skate spot for safe exchanges. Use the encrypted DMs feature to coordinate timing.',
|
||||
},
|
||||
{
|
||||
q: 'What is My Stash?',
|
||||
a: 'My Stash is your private encrypted inventory. Track gear you own, create wishlists, and easily list items for sale. Your stash is encrypted - only you can see it.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Coaches & Filmers (The Lab)',
|
||||
questions: [
|
||||
{
|
||||
q: 'How do I become a coach?',
|
||||
a: 'Go to The Lab (/lab) and click "Become a Coach". You\'ll need NIP-05 verification and a connected Lightning wallet. Add your skills, rates, and select spots where you coach.',
|
||||
},
|
||||
{
|
||||
q: 'How do I become a filmer?',
|
||||
a: 'Go to The Lab and click "Become a Filmer". Same requirements as coaching - NIP-05 and Lightning wallet. List your services, equipment, rates, and spots where you film.',
|
||||
},
|
||||
{
|
||||
q: 'What spots can coaches/filmers select?',
|
||||
a: 'When creating your profile, you can select up to 5 spots where you offer services. Your profile will appear on those spot pages under "Services at this Spot".',
|
||||
},
|
||||
{
|
||||
q: 'How do coaches and filmers get paid?',
|
||||
a: 'Payments go directly to you via Lightning zaps or however you arrange with clients. SkateSpots doesn\'t take any cut - we just connect you with skaters.',
|
||||
},
|
||||
{
|
||||
q: 'Why do I need a wallet connected?',
|
||||
a: 'A Lightning wallet (via NWC - Nostr Wallet Connect) lets students/clients pay you instantly with Bitcoin. Popular wallets: Alby, Minibits, Zeus, Coinos.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Local Tours',
|
||||
questions: [
|
||||
{
|
||||
q: 'What are Local Tours?',
|
||||
a: 'Local Tours let experienced skaters offer to show visitors around their city\'s best spots. Guides set their own rates and availability.',
|
||||
},
|
||||
{
|
||||
q: 'How do I offer a tour?',
|
||||
a: 'On the map, open filters and tap "Offer Tour" (or find it in Quick Actions). Set your city, meeting point, price, duration, and what types of spots you\'ll show.',
|
||||
},
|
||||
{
|
||||
q: 'How do tours work?',
|
||||
a: 'Visitors browse tours on the map, contact guides via encrypted DM, agree on details, and pay via Lightning. The guide shows them the best local spots.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Wallet & Payments',
|
||||
questions: [
|
||||
{
|
||||
q: 'What are zaps?',
|
||||
a: 'Zaps are instant Bitcoin Lightning tips. Like a spot? Zap the creator. Appreciate a coach? Zap them. Tips go directly to recipients - no platform cut.',
|
||||
},
|
||||
{
|
||||
q: 'What is the Cashu wallet?',
|
||||
a: 'SkateSpots has a built-in Cashu ecash wallet. You can receive Lightning payments, store sats, and spend them - all within the app. It\'s custodial but convenient.',
|
||||
},
|
||||
{
|
||||
q: 'What is NWC (Nostr Wallet Connect)?',
|
||||
a: 'NWC lets you connect your external Lightning wallet (Alby, Minibits, Zeus, etc.) to SkateSpots for one-click zaps. Your keys stay in your wallet.',
|
||||
},
|
||||
{
|
||||
q: 'How much should I zap?',
|
||||
a: 'Whatever feels right! Common amounts: 100-1000 sats (~$0.04-$0.40) for appreciation, more for serious contributions. Even small zaps show support.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Privacy & Data',
|
||||
questions: [
|
||||
{
|
||||
q: 'What data is PUBLIC and visible to everyone?',
|
||||
a: 'Almost everything you publish on SkateSpots is public and permanent on the Nostr network:\n\n• Your profile (name, picture, bio, website, Lightning address)\n• All spots you create (including the location/geohash)\n• All events you create\n• All comments and reviews you post\n• All zaps you send (amount, recipient, and message)\n• Your relay list (where your data is stored)\n• Your follow list (who you follow)\n• User statuses ("I\'m skating at X spot")\n• Marketplace listings (gear for sale)\n• Coach/filmer profiles\n• Shop listings\n• Tour offerings\n\nThink of it like posting on a public billboard - once published, anyone can see it and relays may keep copies indefinitely.',
|
||||
},
|
||||
{
|
||||
q: 'What data is PRIVATE and encrypted?',
|
||||
a: 'Only two things in SkateSpots are encrypted and private:\n\n1. Direct Messages (DMs) - Use NIP-44 end-to-end encryption. Only you and the recipient can read them. Not even relay operators can see the content.\n\n2. My Stash - Your personal gear inventory is encrypted to yourself using NIP-44. Only you can decrypt and view your stash contents.\n\nOther content like spots, profiles, and listings are public because they\'re meant to be discovered by the community. Nostr supports encryption for any data - SkateSpots uses it where privacy matters most.',
|
||||
},
|
||||
{
|
||||
q: 'What location data does SkateSpots collect?',
|
||||
a: 'SkateSpots uses location data extensively:\n\n• Creating spots: The spot\'s geohash (encoded lat/lng) is published publicly to Nostr forever\n• "I\'m Here" status: Broadcasts your real-time location to anyone on the network\n• Weather data: Your coordinates are sent to Open-Meteo API servers\n• Proximity sorting: Your location is used locally (not sent anywhere)\n• BTC Map: Your location is sent to btcmap.org to find Bitcoin merchants\n\n⚠️ WARNING: Never create spots at or near your home address. The geohash permanently reveals the location. The "I\'m Here" status tells everyone exactly where you are in real-time.',
|
||||
},
|
||||
{
|
||||
q: 'What third-party services receive my data?',
|
||||
a: 'SkateSpots connects to external services that can see your IP address and some data:\n\n• api.open-meteo.com - Receives your latitude/longitude for weather data\n• air-quality-api.open-meteo.com - Receives your coordinates for air quality\n• blossom.primal.net - Receives all photos you upload (and your IP)\n• proxy.shakespeare.diy - CORS proxy that sees some API requests\n• api.relatr.xyz - Receives Nostr pubkeys for trust score calculations\n• btcmap.org - Receives your location to show Bitcoin merchants\n• Nostr relays (various) - Store all your public events\n\nThese services can potentially log your IP address, approximate location, and usage patterns. Use a VPN if this concerns you.',
|
||||
},
|
||||
{
|
||||
q: 'What is stored in my browser (localStorage)?',
|
||||
a: 'The following data is stored locally in your browser:\n\n• Cashu wallet mnemonic (⚠️ SENSITIVE - controls your funds!)\n• Login session state\n• Language preference\n• App configuration settings\n• Amber signer pending operations\n• Sasquatch guide state\n• Cached spot data for offline viewing\n\nAnyone with physical access to your device could potentially read this data. The Cashu mnemonic is especially sensitive - if someone gets it, they can steal your funds.',
|
||||
},
|
||||
{
|
||||
q: 'Do uploaded photos contain location data?',
|
||||
a: 'Photos from your phone often contain EXIF metadata including:\n\n• GPS coordinates where the photo was taken\n• Date and time\n• Device model\n• Camera settings\n\nSkateSpots does NOT automatically strip this metadata. When you upload to Blossom servers, this data may be preserved. If privacy is critical, use an EXIF removal tool before uploading photos.',
|
||||
},
|
||||
{
|
||||
q: 'Can I delete my data?',
|
||||
a: 'Partially. You can publish deletion requests (kind 5 events) for your content, and well-behaved relays will honor them. However:\n\n• Some relays may ignore deletion requests\n• Other users may have already copied your data\n• Archived/cached copies may exist\n• This is intentional - it prevents censorship\n\nOnce something is published to Nostr, consider it potentially permanent. Think before you post.',
|
||||
},
|
||||
{
|
||||
q: 'How do I protect my secret key (nsec)?',
|
||||
a: 'Your nsec is the master key to your identity. If someone gets it, they can:\n\n• Post as you\n• Access your encrypted DMs\n• Access your encrypted Stash\n• Spend funds if linked to wallets\n\nBest practices:\n• Use a browser extension signer (Alby, nos2x) instead of pasting nsec directly\n• Use Amber app on Android for mobile signing\n• Never share your nsec with anyone\n• Never enter nsec on suspicious websites\n• Back up your nsec offline (paper, password manager)\n• There is NO password reset - lose it and you lose the account forever',
|
||||
},
|
||||
{
|
||||
q: 'Is the Cashu wallet private?',
|
||||
a: 'Cashu ecash provides good transaction privacy:\n\n• Mints use blinded signatures - they don\'t know who pays whom\n• Better privacy than on-chain Bitcoin for small amounts\n• Tokens are bearer instruments (whoever has them, owns them)\n\nHowever:\n• The mint knows your IP when you connect\n• If the mint colludes with recipients, privacy can be reduced\n• Your mnemonic in localStorage could be accessed by malware\n• It\'s still custodial - the mint could theoretically run off with funds',
|
||||
},
|
||||
{
|
||||
q: 'What is NIP-05 verification?',
|
||||
a: 'NIP-05 is like a verified checkmark - a Nostr address (like you@example.com) that proves identity. It\'s required for selling gear, coaching, filming, and creating events.\n\nPrivacy note: Your NIP-05 domain may reveal your real identity or be linkable to other accounts. Choose a provider wisely if anonymity matters to you.',
|
||||
},
|
||||
{
|
||||
q: 'Can SkateSpots see my private key?',
|
||||
a: 'No. SkateSpots never accesses or transmits your private key:\n\n• Browser extension signers (Alby, nos2x): Your key stays in the extension, SkateSpots only requests signatures\n• Amber (Android): Your key stays in Amber app\n• Mnemonic signup: The key is generated in your browser and stored in localStorage - SkateSpots does not access or send it anywhere\n\nSkateSpots is open source, so you can verify this yourself. For additional peace of mind, you can use a hardware signer or browser extension.',
|
||||
},
|
||||
{
|
||||
q: 'Privacy recommendations summary',
|
||||
a: '1. Using SkateSpots from home is fine, but for extra privacy consider using Tor or a VPN to hide your IP address from relays and third-party services\n\n2. Don\'t create spots at your home address - the location coordinates are stored on relays\n\n3. Be careful with "I\'m Here" status - it broadcasts your real-time location to relays\n\n4. Use a browser extension signer (Alby, nos2x) instead of pasting nsec directly\n\n5. Back up your Cashu mnemonic securely - it controls your funds\n\n6. Strip EXIF data from photos before uploading if location privacy matters\n\n7. Remember: everything except DMs and Stash is stored on relays you connect to\n\n8. Choose your relays wisely - they are responsible for storing and serving your data. You can manage relays in Settings > Relays',
|
||||
},
|
||||
{
|
||||
q: 'Who is responsible for my data?',
|
||||
a: 'In Nostr, relays are responsible for storing and serving your data - not SkateSpots. Here\'s how it works:\n\n• When you publish content, it goes to the relays you\'re connected to\n• Each relay operator decides their own policies (what to store, how long, who can access)\n• Different relays may have different data retention and privacy policies\n• You can choose which relays to use in Settings > Relays\n• Your data exists on multiple independent relays - no single entity controls it\n\nSkateSpots is just a client (viewer/publisher) - we don\'t store your Nostr events. The relays do. This is the beauty of decentralization: you\'re not dependent on any single company, but it also means you should choose relays you trust.\n\nDefault relays include goskatespots.nostr1.com (community relay) and standard Nostr relays. You can add/remove relays anytime.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Shops & Events',
|
||||
questions: [
|
||||
{
|
||||
q: 'How do I add a skate shop?',
|
||||
a: 'On the map, click "Add Shop" (desktop) or find it in Quick Actions. You\'ll need NIP-05 verification. Add shop details, hours, and location.',
|
||||
},
|
||||
{
|
||||
q: 'Can I claim a shop listing?',
|
||||
a: 'Yes! If you own a shop that someone else listed, you can claim it with NIP-05 verification. This lets you manage the listing.',
|
||||
},
|
||||
{
|
||||
q: 'How do I create an event?',
|
||||
a: 'Go to the Events page and click "Create Event". Add name, date, time, location, and description. Events appear on the calendar for the community.',
|
||||
},
|
||||
],
|
||||
},
|
||||
import { useEffect } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { HelpCircle, MessageCircle, MapPin, Zap, Shield, Package } from 'lucide-react';
|
||||
|
||||
// Quick FAQ chips — prime the conversation
|
||||
const FAQ_CHIPS = [
|
||||
{ label: 'What is SkateSpots?', q: 'What is SkateSpots and how does it work?' },
|
||||
{ label: 'How do I add a spot?', q: 'How do I add a skate spot to the map?' },
|
||||
{ label: 'What are zaps?', q: 'What are zaps?' },
|
||||
{ label: 'Is it free?', q: 'Is SkateSpots free to use?' },
|
||||
{ label: 'What is My Stash?', q: 'What is My Stash?' },
|
||||
{ label: 'How do I login?', q: 'How do I log in or create an account?' },
|
||||
{ label: 'What is Nostr?', q: 'What is Nostr?' },
|
||||
{ label: 'Privacy & Data', q: 'What data is public and what data is private on SkateSpots?' },
|
||||
{ label: 'How to sell gear?', q: 'How do I sell gear on SkateSpots?' },
|
||||
{ label: 'What is The Lab?', q: 'What is The Lab?' },
|
||||
];
|
||||
|
||||
const QUICK_FACTS = [
|
||||
{ icon: MapPin, color: '#ff0040', label: 'Skate spots worldwide', value: 'Discover & add' },
|
||||
{ icon: Zap, color: '#f97316', label: 'Bitcoin Lightning zaps', value: '100% free' },
|
||||
{ icon: Shield, color: '#22c55e', label: 'Decentralized on Nostr', value: 'You own your data' },
|
||||
{ icon: Package, color: '#a855f7', label: 'Private gear inventory', value: 'Encrypted Stash' },
|
||||
];
|
||||
|
||||
export default function FAQPage() {
|
||||
useSeoMeta({
|
||||
title: 'FAQ - SkateSpots',
|
||||
description: 'Frequently asked questions about SkateSpots and Nostr.',
|
||||
description: 'Ask The Sasquatch anything about SkateSpots.',
|
||||
});
|
||||
|
||||
// Open Sasquatch with FAQ context when this page loads
|
||||
useEffect(() => {
|
||||
// Dispatch a custom event that SasquatchGuide listens for
|
||||
const event = new CustomEvent('sasquatch:open', {
|
||||
detail: {
|
||||
context: 'faq',
|
||||
greeting: "Hey! I'm The Sasquatch 🦧 — ask me anything about SkateSpots. Spots, events, gear, wallets, privacy, Nostr — I've got you. What do you want to know?",
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
|
||||
const askSasquatch = (question: string) => {
|
||||
const event = new CustomEvent('sasquatch:ask', { detail: { question } });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-12 max-w-4xl">
|
||||
<div className="container px-4 py-12 max-w-3xl">
|
||||
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-[#ff0040] to-[#ccff00] mb-4">
|
||||
<HelpCircle className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 rounded-3xl bg-gradient-to-br from-[#ff0040] via-[#ff6600] to-[#ccff00] mb-5 shadow-2xl shadow-[#ff0040]/40">
|
||||
<span className="text-5xl">🦧</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-6xl font-black uppercase mb-4" style={{
|
||||
fontFamily: 'Impact, "Arial Black", sans-serif',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
<span className="gradient-text">FREQUENTLY ASKED QUESTIONS</span>
|
||||
<h1 className="text-4xl md:text-5xl font-black uppercase mb-3 tracking-tight">
|
||||
<span className="text-white">Got Questions?</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
|
||||
Everything you need to know about SkateSpots and Nostr
|
||||
<p className="text-xl text-gray-400 max-w-lg mx-auto leading-relaxed">
|
||||
Ask The Sasquatch — he knows everything about SkateSpots, Nostr, Bitcoin, and skateboarding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* FAQ Sections */}
|
||||
<div className="space-y-8">
|
||||
{faqs.map((section, idx) => (
|
||||
<div key={idx}>
|
||||
<h2 className="text-2xl font-black uppercase mb-4 text-[#ff0040]" style={{
|
||||
fontFamily: 'Impact, "Arial Black", sans-serif'
|
||||
}}>
|
||||
{section.category}
|
||||
</h2>
|
||||
|
||||
<Card className="bg-black/90 border-2 border-gray-700">
|
||||
<CardContent className="p-6">
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{section.questions.map((faq, qIdx) => (
|
||||
<AccordionItem key={qIdx} value={`${idx}-${qIdx}`}>
|
||||
<AccordionTrigger className="text-left text-white hover:text-[#ff0040] font-bold">
|
||||
{faq.q}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-gray-400 leading-relaxed">
|
||||
{faq.a}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* CTA Card */}
|
||||
<Card className="bg-gradient-to-br from-[#ff0040]/15 to-[#ccff00]/10 border-2 border-[#ff0040]/40 mb-8 shadow-xl shadow-[#ff0040]/10">
|
||||
<CardContent className="p-6 text-center">
|
||||
<MessageCircle className="h-10 w-10 text-[#ff0040] mx-auto mb-3" />
|
||||
<h2 className="text-2xl font-black text-white mb-2 uppercase">Ask The Sasquatch</h2>
|
||||
<p className="text-gray-400 mb-5">
|
||||
The Sasquatch is open in the bottom-left corner of your screen — just click it and type your question.
|
||||
He has live access to spots, events, coaches, and weather.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => askSasquatch('What can you help me with on SkateSpots?')}
|
||||
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold px-8 py-3 rounded-full shadow-lg shadow-[#ff0040]/30 text-base gap-2"
|
||||
>
|
||||
<span className="text-lg">🦧</span>
|
||||
Open The Sasquatch
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick question chips */}
|
||||
<div className="mb-10">
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Common questions — tap to ask
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FAQ_CHIPS.map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
onClick={() => askSasquatch(chip.q)}
|
||||
className="px-4 py-2 rounded-full bg-white/5 border border-white/10 hover:border-[#ff0040]/50 hover:bg-[#ff0040]/10 text-sm text-gray-300 hover:text-white transition-all font-medium active:scale-95"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick facts grid */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-10">
|
||||
{QUICK_FACTS.map(({ icon: Icon, color, label, value }) => (
|
||||
<Card
|
||||
key={label}
|
||||
className="bg-black/60 border border-white/10 hover:border-white/20 transition-all"
|
||||
>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: color + '22', border: `1px solid ${color}44` }}
|
||||
>
|
||||
<Icon className="h-5 w-5" style={{ color }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 truncate">{label}</p>
|
||||
<p className="text-sm font-bold text-white truncate">{value}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Still Have Questions? */}
|
||||
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50 mt-12">
|
||||
<CardContent className="p-8 text-center">
|
||||
<h3 className="text-2xl font-black uppercase text-[#ccff00] mb-3">Still Have Questions?</h3>
|
||||
<p className="text-gray-300 mb-6">
|
||||
Check out our detailed onboarding guide for step-by-step instructions
|
||||
</p>
|
||||
<Link to="/onboarding">
|
||||
<button className="px-8 py-4 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase shadow-[0_0_20px_rgba(204,255,0,0.5)] transition-all">
|
||||
View Onboarding Guide
|
||||
</button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Hint */}
|
||||
<p className="text-center text-xs text-gray-600">
|
||||
The Sasquatch uses live Nostr data from the SkateSpots relay + AI to answer your questions. 🛹
|
||||
</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
Plus,
|
||||
List,
|
||||
Map,
|
||||
Globe,
|
||||
X,
|
||||
ShoppingBag,
|
||||
Route,
|
||||
|
|
@ -68,8 +69,10 @@ import { CameraCapture } from '@/components/CameraCapture';
|
|||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GlobeMarker } from '@/components/SkateGlobe';
|
||||
const SkateGlobe = lazy(() => import('@/components/SkateGlobe').then(m => ({ default: m.SkateGlobe })));
|
||||
|
||||
type ViewMode = 'map' | 'list';
|
||||
type ViewMode = 'map' | 'list' | 'globe';
|
||||
|
||||
// Compact stash item card for the drawer
|
||||
function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) {
|
||||
|
|
@ -346,6 +349,7 @@ export default function MapPage() {
|
|||
const focusGear = viewParam === 'gear';
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
||||
const [selectedGlobeMarker, setSelectedGlobeMarker] = useState<GlobeMarker | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null);
|
||||
|
|
@ -643,7 +647,7 @@ export default function MapPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* View toggle - compact on mobile */}
|
||||
{/* View toggle - Map / List / Globe */}
|
||||
<div className="flex items-center rounded-lg border bg-muted p-0.5 flex-shrink-0">
|
||||
<Button
|
||||
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
||||
|
|
@ -663,6 +667,19 @@ export default function MapPage() {
|
|||
<List className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-2">{t('mapPage.listView')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'globe' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-9 w-9 sm:w-auto sm:px-3 p-0',
|
||||
viewMode === 'globe' && 'bg-[#ff0040] text-white hover:bg-[#ff0040]/90'
|
||||
)}
|
||||
onClick={() => setViewMode('globe')}
|
||||
title="3D Globe View"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-2">Globe</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Filters button that opens sheet */}
|
||||
|
|
@ -1083,7 +1100,104 @@ export default function MapPage() {
|
|||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 relative">
|
||||
{viewMode === 'map' ? (
|
||||
{viewMode === 'globe' ? (
|
||||
/* ── 3D Globe View ─────────────────────────────────────────── */
|
||||
<div className="h-full relative bg-black">
|
||||
<Suspense fallback={
|
||||
<div className="h-full flex items-center justify-center bg-black">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-gray-400 text-sm uppercase tracking-widest font-bold">Loading Globe...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<SkateGlobe
|
||||
spots={spots || []}
|
||||
shops={shops || []}
|
||||
showSpots={showSpots}
|
||||
showShops={showShops}
|
||||
selectedMarkerId={selectedGlobeMarker?.id ?? null}
|
||||
onMarkerClick={(marker) => {
|
||||
setSelectedGlobeMarker(marker);
|
||||
if (marker.spot) setSelectedSpot(marker.spot);
|
||||
}}
|
||||
onBackgroundClick={() => {
|
||||
setSelectedGlobeMarker(null);
|
||||
setSelectedSpot(null);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* Globe legend overlay */}
|
||||
<div className="absolute top-4 left-4 flex flex-col gap-1.5 pointer-events-none">
|
||||
<div className="flex items-center gap-2 bg-black/70 backdrop-blur px-3 py-1.5 rounded-full border border-white/10 text-xs font-bold text-white">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#ff0040]" />
|
||||
Spots ({(spots || []).length})
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-black/70 backdrop-blur px-3 py-1.5 rounded-full border border-white/10 text-xs font-bold text-white">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#ccff00]" />
|
||||
Shops ({(shops || []).length})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected spot info card */}
|
||||
{selectedGlobeMarker && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 w-[min(380px,90vw)] bg-black/90 backdrop-blur border border-[#ff0040]/40 rounded-2xl p-4 shadow-2xl shadow-[#ff0040]/20 z-10">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={cn(
|
||||
'text-xs font-bold uppercase px-2 py-0.5 rounded-full',
|
||||
selectedGlobeMarker.type === 'spot'
|
||||
? 'bg-[#ff0040]/20 text-[#ff0040]'
|
||||
: 'bg-[#ccff00]/20 text-[#ccff00]'
|
||||
)}>
|
||||
{selectedGlobeMarker.type === 'spot' ? '🛹 Spot' : '🏪 Shop'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-white text-base truncate">{selectedGlobeMarker.label}</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{selectedGlobeMarker.lat.toFixed(4)}°, {selectedGlobeMarker.lng.toFixed(4)}°
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 flex-shrink-0">
|
||||
{selectedGlobeMarker.spot && (
|
||||
<Link
|
||||
to={`/spot/${selectedGlobeMarker.spot.pubkey}/${selectedGlobeMarker.spot.tags.find(([k]) => k === 'd')?.[1] ?? ''}`}
|
||||
className="text-xs bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold px-3 py-1.5 rounded-full transition-colors"
|
||||
>
|
||||
View Spot →
|
||||
</Link>
|
||||
)}
|
||||
{selectedGlobeMarker.shop && (
|
||||
<Link
|
||||
to={`/shop/${selectedGlobeMarker.shop.pubkey}/${selectedGlobeMarker.shop.tags.find(([k]) => k === 'd')?.[1] ?? ''}`}
|
||||
className="text-xs bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold px-3 py-1.5 rounded-full transition-colors"
|
||||
>
|
||||
View Shop →
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setSelectedGlobeMarker(null); setSelectedSpot(null); }}
|
||||
className="text-xs text-gray-500 hover:text-white transition-colors text-center"
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint overlay */}
|
||||
{!selectedGlobeMarker && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 pointer-events-none">
|
||||
<p className="text-xs text-gray-500 bg-black/60 px-3 py-1.5 rounded-full border border-white/10">
|
||||
Drag to rotate • Scroll to zoom • Click a pin to explore
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'map' ? (
|
||||
<>
|
||||
<Suspense fallback={
|
||||
<div className="h-full bg-black/50 flex items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { EditProfileForm } from '@/components/EditProfileForm';
|
||||
import { RelayListManager } from '@/components/RelayListManager';
|
||||
|
|
@ -36,6 +37,8 @@ import {
|
|||
RefreshCw,
|
||||
AlertCircle,
|
||||
Database,
|
||||
Package,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Fallback relays for fetching profile data
|
||||
|
|
@ -509,6 +512,26 @@ function WalletTab() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* My Stash — gear inventory */}
|
||||
<Link to="/stash" className="block group">
|
||||
<Card className="bg-gradient-to-br from-purple-950/30 to-[#ff6600]/10 border-2 border-purple-500/40 hover:border-purple-500/80 transition-all cursor-pointer group-hover:shadow-lg group-hover:shadow-purple-500/10">
|
||||
<CardContent className="py-5 px-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="h-6 w-6 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-white text-base">My Stash</p>
|
||||
<p className="text-sm text-gray-400 mt-0.5">
|
||||
Private encrypted gear inventory — track decks, trucks, shoes, and more.
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-purple-400 flex-shrink-0 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* NWC Quick Connect */}
|
||||
<Card className="bg-purple-950/30 border-2 border-purple-500/50">
|
||||
<CardHeader>
|
||||
|
|
|
|||
Loading…
Reference in a new issue