Revert "fix: resolve Zod compatibility issues for clean build"

This reverts commit be47816c64.


Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-15 21:24:25 +07:00
parent be47816c64
commit e5f26f4b1f
248 changed files with 60432 additions and 3 deletions

View file

@ -72,6 +72,7 @@
"lucide-react": "^0.462.0",
"ngeohash": "^0.6.3",
"nostr-tools": "^2.13.0",
"npubcash-sdk": "^0.2.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
@ -84,7 +85,7 @@
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.3",
"zod": "^3.23.8"
"zod": "^3.25.71"
},
"devDependencies": {
"@eslint/js": "^9.9.0",

View file

@ -14,7 +14,7 @@ interface AppProviderProps {
// Zod schema for RelayMetadata validation
const RelayMetadataSchema = z.object({
relays: z.array(z.object({
url: z.string().url(),
url: z.url(),
read: z.boolean(),
write: z.boolean(),
})),
@ -23,7 +23,7 @@ const RelayMetadataSchema = z.object({
// Zod schema for BlossomServerMetadata validation
const BlossomServerMetadataSchema = z.object({
servers: z.array(z.string().url()),
servers: z.array(z.url()),
updatedAt: z.number(),
}) satisfies z.ZodType<BlossomServerMetadata>;

8
src/src/App.test.tsx Normal file
View file

@ -0,0 +1,8 @@
import { render } from '@testing-library/react';
import { test } from 'vitest';
import App from './App';
test('App', () => {
render(<App />);
})

79
src/src/App.tsx Normal file
View file

@ -0,0 +1,79 @@
// NOTE: This file should normally not be modified unless you are adding a new provider.
// To add new routes, edit the AppRouter.tsx file.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createHead, UnheadProvider } from '@unhead/react/client';
import { InferSeoMetaPlugin } from '@unhead/addons';
import { Suspense } from 'react';
import NostrProvider from '@/components/NostrProvider';
import { NostrSync } from '@/components/NostrSync';
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { NostrLoginProvider } from '@nostrify/react/login';
import { AppProvider } from '@/components/AppProvider';
import { NWCProvider } from '@/contexts/NWCContext';
import { LanguageProvider } from '@/contexts/LanguageContext';
import { AppConfig } from '@/contexts/AppContext';
import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
import { AmberCallbackHandler } from '@/components/auth/AmberCallbackHandler';
import AppRouter from './AppRouter';
const head = createHead({
plugins: [
InferSeoMetaPlugin(),
],
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 60000, // 1 minute
gcTime: Infinity,
},
},
});
const defaultConfig: AppConfig = {
theme: "dark",
relayMetadata: {
relays: [
{ url: 'wss://relay.ditto.pub', read: true, write: true },
{ url: 'wss://goskatespots.nostr1.com', read: true, write: true },
{ url: 'wss://wotr.relatr.xyz', read: true, write: true },
{ url: 'wss://purplepag.es', read: true, write: false }, // Profile aggregator - read only
],
updatedAt: 0,
},
useSkateRelay: true, // Use community relay by default for skate content
};
export function App() {
return (
<UnheadProvider head={head}>
<LanguageProvider>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<NostrSync />
<AmberCallbackHandler />
<NWCProvider>
<TooltipProvider>
<Toaster />
<PWAInstallPrompt />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
</LanguageProvider>
</UnheadProvider>
);
}
export default App;

95
src/src/AppRouter.tsx Normal file
View file

@ -0,0 +1,95 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop";
import Index from "./pages/Index";
import MapPage from "./pages/MapPage";
import EventsPage from "./pages/EventsPage";
import FoodPage from "./pages/FoodPage";
import WalletPage from "./pages/WalletPage";
import SettingsPage from "./pages/SettingsPage";
import CreateSpotPage from "./pages/CreateSpotPage";
import CreateShopPage from "./pages/CreateShopPage";
import CreateEventPage from "./pages/CreateEventPage";
import CreateListingPage from "./pages/CreateListingPage";
import CreateTourPage from "./pages/CreateTourPage";
import EditSpotPage from "./pages/EditSpotPage";
import EditShopPage from "./pages/EditShopPage";
import EditEventPage from "./pages/EditEventPage";
import GearSwapPage from "./pages/GearSwapPage";
import SpotDetailPage from "./pages/SpotDetailPage";
import ShopDetailPage from "./pages/ShopDetailPage";
import EventDetailPage from "./pages/EventDetailPage";
import OnboardingPage from "./pages/OnboardingPage";
import PyramidPage from "./pages/PyramidPage";
import LabPage from "./pages/LabPage";
import BecomeCoachPage from "./pages/BecomeCoachPage";
import BecomeFilmerPage from "./pages/BecomeFilmerPage";
import CoachDetailPage from "./pages/CoachDetailPage";
import TourDetailPage from "./pages/TourDetailPage";
import AboutPage from "./pages/AboutPage";
import FAQPage from "./pages/FAQPage";
import CommunityStatsPage from "./pages/CommunityStatsPage";
import SpotCollectionsPage from "./pages/SpotCollectionsPage";
import CollectionDetailPage from "./pages/CollectionDetailPage";
import DirectMessagesPage from "./pages/DirectMessagesPage";
import ConversationPage from "./pages/ConversationPage";
import BountyLeaderboardPage from "./pages/BountyLeaderboardPage";
import SignersPage from "./pages/SignersPage";
import StashPage from "./pages/StashPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
export function AppRouter() {
return (
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/map" element={<MapPage />} />
<Route path="/events" element={<EventsPage />} />
<Route path="/lab" element={<LabPage />} />
{/* Legacy route redirect */}
<Route path="/coaches" element={<LabPage />} />
<Route path="/become-coach" element={<BecomeCoachPage />} />
<Route path="/become-filmer" element={<BecomeFilmerPage />} />
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/pyramid" element={<PyramidPage />} />
<Route path="/marketplace" element={<GearSwapPage />} />
<Route path="/stats" element={<CommunityStatsPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/faq" element={<FAQPage />} />
<Route path="/food" element={<FoodPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/create-spot" element={<CreateSpotPage />} />
<Route path="/create-shop" element={<CreateShopPage />} />
<Route path="/create-event" element={<CreateEventPage />} />
<Route path="/create-listing" element={<CreateListingPage />} />
<Route path="/create-tour" element={<CreateTourPage />} />
<Route path="/edit-spot/:pubkey/:identifier" element={<EditSpotPage />} />
<Route path="/edit-shop/:pubkey/:identifier" element={<EditShopPage />} />
<Route path="/edit-event/:pubkey/:identifier" element={<EditEventPage />} />
<Route path="/spot/:pubkey/:identifier" element={<SpotDetailPage />} />
<Route path="/shop/:pubkey/:identifier" element={<ShopDetailPage />} />
<Route path="/event/:pubkey/:identifier" element={<EventDetailPage />} />
<Route path="/tour/:pubkey/:identifier" element={<TourDetailPage />} />
<Route path="/collections" element={<SpotCollectionsPage />} />
<Route path="/collections/:pubkey/:identifier" element={<CollectionDetailPage />} />
<Route path="/messages" element={<DirectMessagesPage />} />
<Route path="/messages/:npub" element={<ConversationPage />} />
{/* Bounty System */}
<Route path="/bounties" element={<BountyLeaderboardPage />} />
{/* Security & Signers */}
<Route path="/signers" element={<SignersPage />} />
{/* Stash - Private Inventory */}
<Route path="/stash" element={<StashPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
export default AppRouter;

View file

@ -0,0 +1,234 @@
import { useState } from 'react';
import { Camera, Upload, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useQueryClient } from '@tanstack/react-query';
import { PHOTO_CONTRIBUTION_KIND } from '@/hooks/useSpotContributions';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { LoginArea } from '@/components/auth/LoginArea';
interface AddPhotoToSpotProps {
spotPubkey: string;
spotIdentifier: string;
spotTitle: string;
}
export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPhotoToSpotProps) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [caption, setCaption] = useState('');
const [uploadedImage, setUploadedImage] = useState<{ url: string; mimeType?: string } | null>(null);
const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`;
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const file = files[0];
const tags = await uploadFile(file);
const url = tags.find(([name]) => name === 'url')?.[1];
const mimeType = tags.find(([name]) => name === 'm')?.[1];
if (url) {
setUploadedImage({ url, mimeType });
toast({
title: 'Photo uploaded',
description: 'Add a caption and submit to contribute!',
});
}
} catch (error) {
toast({
title: 'Upload failed',
description: error instanceof Error ? error.message : 'Failed to upload photo',
variant: 'destructive',
});
}
e.target.value = '';
};
const handleSubmit = async () => {
if (!uploadedImage) {
toast({
title: 'No photo',
description: 'Please upload a photo first.',
variant: 'destructive',
});
return;
}
try {
// Create kind 1063 file metadata event
const eventTags: string[][] = [
['url', uploadedImage.url],
['a', spotRef],
['alt', `Community photo for ${spotTitle}`],
];
if (uploadedImage.mimeType) {
eventTags.push(['m', uploadedImage.mimeType]);
}
await publishEvent({
kind: PHOTO_CONTRIBUTION_KIND,
content: caption.trim(),
tags: eventTags,
});
toast({
title: 'Photo contributed!',
description: 'Your photo has been added to this spot.',
});
// Reset and close
setCaption('');
setUploadedImage(null);
setOpen(false);
// Invalidate the photos query
queryClient.invalidateQueries({ queryKey: ['spot-photos', spotPubkey, spotIdentifier] });
} catch (error) {
toast({
title: 'Failed to contribute',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleClose = () => {
setOpen(false);
setCaption('');
setUploadedImage(null);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Camera className="h-4 w-4" />
Add Photo
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-[#ff0040]" />
Add Photo to Spot
</DialogTitle>
<DialogDescription>
Contribute a photo to help others discover this spot.
</DialogDescription>
</DialogHeader>
{!user ? (
<div className="py-6 text-center">
<p className="text-muted-foreground mb-4">Log in to contribute photos</p>
<LoginArea className="justify-center" />
</div>
) : (
<div className="space-y-4">
{/* Photo upload area */}
{uploadedImage ? (
<div className="relative">
<img
src={uploadedImage.url}
alt="Uploaded"
className="w-full aspect-video object-cover rounded-lg"
/>
<Button
variant="destructive"
size="icon"
className="absolute top-2 right-2 h-8 w-8"
onClick={() => setUploadedImage(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Card className="border-dashed">
<CardContent className="py-8">
<label className="flex flex-col items-center cursor-pointer">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<>
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">Uploading...</span>
</>
) : (
<>
<Upload className="h-10 w-10 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">Click to upload a photo</span>
<span className="text-xs text-muted-foreground mt-1">JPG, PNG, WebP</span>
</>
)}
</label>
</CardContent>
</Card>
)}
{/* Caption */}
<div className="space-y-2">
<Textarea
placeholder="Add a caption (optional) - describe what's in the photo, when it was taken, etc."
value={caption}
onChange={(e) => setCaption(e.target.value)}
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!uploadedImage || isPublishing}
className="bg-[#ff0040] hover:bg-[#ff0040]/90"
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Contributing...
</>
) : (
<>
<Camera className="h-4 w-4 mr-2" />
Contribute Photo
</>
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,181 @@
import { useState } from 'react';
import { Tags, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { TagInput } from '@/components/TagInput';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useQueryClient } from '@tanstack/react-query';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { LoginArea } from '@/components/auth/LoginArea';
interface AddTagsToSpotProps {
spotPubkey: string;
spotIdentifier: string;
spotTitle: string;
existingTags?: string[];
}
export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingTags = [] }: AddTagsToSpotProps) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [tags, setTags] = useState<string[]>([]);
const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`;
const handleSubmit = async () => {
if (tags.length === 0) {
toast({
title: 'No tags',
description: 'Please add at least one tag.',
variant: 'destructive',
});
return;
}
try {
// Create kind 1111 comment with tag suggestions
const eventTags: string[][] = [
// Reference the spot with 'a' tag (NIP-22 comment target)
['a', spotRef],
// Root marker for comment threading
['K', String(SKATE_SPOT_KIND)],
// Mark this as a tag contribution
['t', 'skatespot-tags'],
];
// Add all suggested tags
for (const tag of tags) {
eventTags.push(['t', tag]);
}
await publishEvent({
kind: 1111,
content: `Suggested tags for ${spotTitle}: ${tags.map(t => `#${t}`).join(' ')}`,
tags: eventTags,
});
toast({
title: 'Tags suggested!',
description: 'Your tag suggestions have been submitted.',
});
// Reset and close
setTags([]);
setOpen(false);
// Invalidate the tags query
queryClient.invalidateQueries({ queryKey: ['spot-tag-suggestions', spotPubkey, spotIdentifier] });
} catch (error) {
toast({
title: 'Failed to suggest tags',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleClose = () => {
setOpen(false);
setTags([]);
};
// Filter out tags that already exist on the spot
const handleTagChange = (newTags: string[]) => {
// Remove any tags that are already on the spot
const filteredTags = newTags.filter(tag => !existingTags.includes(tag));
setTags(filteredTags);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Tags className="h-4 w-4" />
Suggest Tags
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Tags className="h-5 w-5 text-[#ccff00]" />
Suggest Tags
</DialogTitle>
<DialogDescription>
Help categorize this spot by suggesting relevant tags.
</DialogDescription>
</DialogHeader>
{!user ? (
<div className="py-6 text-center">
<p className="text-muted-foreground mb-4">Log in to suggest tags</p>
<LoginArea className="justify-center" />
</div>
) : (
<div className="space-y-4">
{/* Existing tags display */}
{existingTags.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">Current tags on this spot:</p>
<div className="flex flex-wrap gap-1">
{existingTags.map(tag => (
<span key={tag} className="px-2 py-0.5 text-xs bg-gray-800 rounded-full text-gray-400">
#{tag}
</span>
))}
</div>
</div>
)}
{/* Tag input */}
<div>
<p className="text-sm font-medium mb-2">Add new tags:</p>
<TagInput
tags={tags}
onChange={handleTagChange}
placeholder="Type to search or add tags..."
maxTags={10}
/>
</div>
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={tags.length === 0 || isPublishing}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90"
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting...
</>
) : (
<>
<Tags className="h-4 w-4 mr-2" />
Suggest {tags.length} Tag{tags.length !== 1 ? 's' : ''}
</>
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,267 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/hooks/useToast';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
useMySpotCollections,
useCreateSpotCollection,
useAddSpotToCollection,
} from '@/hooks/useSpotCollections';
import { SKATE_SPOT_KIND, type SkateSpot, type SpotCollection } from '@/lib/types';
import { FolderPlus, Plus, Check, Loader2, FolderOpen, MapPin } from 'lucide-react';
import { cn } from '@/lib/utils';
interface AddToCollectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
spot: SkateSpot;
}
export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollectionDialogProps) {
const { user } = useCurrentUser();
const { data: collections, isLoading } = useMySpotCollections();
const { createCollection, isPending: isCreating } = useCreateSpotCollection();
const { addSpotToCollection, isPending: isAdding } = useAddSpotToCollection();
const { toast } = useToast();
const [showNewForm, setShowNewForm] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState('');
const [addingToId, setAddingToId] = useState<string | null>(null);
const spotRef = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
const handleAddToCollection = async (collection: SpotCollection) => {
if (collection.spotRefs.includes(spotRef)) {
toast({
title: 'Already saved',
description: `"${spot.title}" is already in "${collection.title}"`,
});
return;
}
setAddingToId(collection.id);
try {
await addSpotToCollection({
collection,
spotPubkey: spot.pubkey,
spotIdentifier: spot.identifier,
});
toast({
title: 'Spot saved!',
description: `Added "${spot.title}" to "${collection.title}"`,
});
onOpenChange(false);
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
} finally {
setAddingToId(null);
}
};
const handleCreateCollection = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
try {
await createCollection({
title: newTitle.trim(),
description: newDescription.trim() || undefined,
spotRefs: [spotRef], // Add the current spot to the new collection
});
toast({
title: 'Collection created!',
description: `"${spot.title}" added to your new collection "${newTitle}"`,
});
setNewTitle('');
setNewDescription('');
setShowNewForm(false);
onOpenChange(false);
} catch (error) {
toast({
title: 'Failed to create collection',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
if (!user) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-black border-2 border-[#ff0040]/50">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
Save to Collection
</DialogTitle>
</DialogHeader>
<div className="py-8 text-center">
<p className="text-gray-400">Please log in to save spots to collections</p>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-black border-2 border-[#ff0040]/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
Save to Collection
</DialogTitle>
<DialogDescription className="text-gray-400 text-sm">
Save "{spot.title}" to one of your collections
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
{/* Existing Collections */}
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : collections && collections.length > 0 ? (
<div className="space-y-2 max-h-48 sm:max-h-64 overflow-y-auto pr-1 sm:pr-2 -mr-1 sm:-mr-2">
{collections.map((collection) => {
const isInCollection = collection.spotRefs.includes(spotRef);
const isAddingThis = addingToId === collection.id;
return (
<button
key={collection.id}
onClick={() => handleAddToCollection(collection)}
disabled={isAdding || isInCollection}
className={cn(
'w-full p-2.5 sm:p-3 rounded-lg border-2 transition-all text-left flex items-center gap-2.5 sm:gap-3 touch-manipulation active:scale-[0.98]',
isInCollection
? 'border-[#ccff00]/50 bg-[#ccff00]/10 cursor-default'
: 'border-gray-700 hover:border-[#ccff00]/50 hover:bg-[#ccff00]/5'
)}
>
<div className={cn(
'w-9 h-9 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center flex-shrink-0',
isInCollection ? 'bg-[#ccff00]/20' : 'bg-gray-800'
)}>
{isAddingThis ? (
<Loader2 className="h-4 w-4 sm:h-5 sm:w-5 animate-spin text-[#ccff00]" />
) : isInCollection ? (
<Check className="h-4 w-4 sm:h-5 sm:w-5 text-[#ccff00]" />
) : (
<FolderOpen className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
)}
</div>
<div className="flex-1 min-w-0">
<p className={cn(
'font-bold truncate text-sm sm:text-base',
isInCollection ? 'text-[#ccff00]' : 'text-white'
)}>
{collection.title}
</p>
<p className="text-[10px] sm:text-xs text-gray-500 flex items-center gap-1">
<MapPin className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
{collection.spotRefs.length} spot{collection.spotRefs.length !== 1 ? 's' : ''}
{isInCollection && ' · Already saved'}
</p>
</div>
</button>
);
})}
</div>
) : !showNewForm ? (
<div className="text-center py-4 sm:py-6 text-gray-400">
<FolderOpen className="h-10 w-10 sm:h-12 sm:w-12 mx-auto mb-2 sm:mb-3 opacity-50" />
<p className="text-sm sm:text-base">You don't have any collections yet</p>
<p className="text-xs sm:text-sm">Create your first one below!</p>
</div>
) : null}
{/* Create New Collection Form */}
{showNewForm ? (
<form onSubmit={handleCreateCollection} className="space-y-3 sm:space-y-4 pt-3 sm:pt-4 border-t border-gray-800">
<div className="space-y-1.5 sm:space-y-2">
<Label htmlFor="title" className="text-white text-sm">Collection Name</Label>
<Input
id="title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Barcelona Trip 2024"
className="bg-black border-gray-700 text-white h-11"
autoFocus
/>
</div>
<div className="space-y-1.5 sm:space-y-2">
<Label htmlFor="description" className="text-white text-sm">Description (optional)</Label>
<Textarea
id="description"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="My favorite spots from my Barcelona trip..."
className="bg-black border-gray-700 text-white resize-none"
rows={2}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setShowNewForm(false)}
className="flex-1 border-gray-700 h-11"
>
Cancel
</Button>
<Button
type="submit"
disabled={!newTitle.trim() || isCreating}
className="flex-1 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold h-11"
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<span className="hidden sm:inline">Creating...</span>
<span className="sm:hidden">...</span>
</>
) : (
<>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Create & Save</span>
<span className="sm:hidden">Create</span>
</>
)}
</Button>
</div>
</form>
) : (
<Button
onClick={() => setShowNewForm(true)}
variant="outline"
className="w-full border-dashed border-gray-600 hover:border-[#ccff00]/50 hover:bg-[#ccff00]/5 h-11"
>
<Plus className="h-4 w-4 mr-2" />
Create New Collection
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,149 @@
import { ReactNode, useEffect } from 'react';
import { z } from 'zod';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext';
interface AppProviderProps {
children: ReactNode;
/** Application storage key */
storageKey: string;
/** Default app configuration */
defaultConfig: AppConfig;
}
// Zod schema for RelayMetadata validation
const RelayMetadataSchema = z.object({
relays: z.array(z.object({
url: z.string().url(),
read: z.boolean(),
write: z.boolean(),
})),
updatedAt: z.number(),
}) satisfies z.ZodType<RelayMetadata>;
// Zod schema for AppConfig validation
const AppConfigSchema = z.object({
theme: z.enum(['dark', 'light', 'system']),
relayMetadata: RelayMetadataSchema,
}) satisfies z.ZodType<AppConfig>;
export function AppProvider(props: AppProviderProps) {
const {
children,
storageKey,
defaultConfig,
} = props;
// App configuration state with localStorage persistence
const [rawConfig, setConfig] = useLocalStorage<Partial<AppConfig>>(
storageKey,
{},
{
serialize: JSON.stringify,
deserialize: (value: string) => {
const parsed = JSON.parse(value);
return AppConfigSchema.partial().parse(parsed);
}
}
);
// Generic config updater with callback pattern
const updateConfig = (updater: (currentConfig: Partial<AppConfig>) => Partial<AppConfig>) => {
setConfig(updater);
};
// Merge configs with special handling for relay metadata
const config = { ...defaultConfig, ...rawConfig };
// Force migration to correct relays and dark theme
useEffect(() => {
let needsUpdate = false;
const updates: Partial<AppConfig> = {};
// Check relay configuration
if (config.relayMetadata.relays) {
const currentRelayUrls = config.relayMetadata.relays.map(r => r.url).sort().join(',');
const expectedRelayUrls = defaultConfig.relayMetadata.relays.map(r => r.url).sort().join(',');
if (currentRelayUrls !== expectedRelayUrls) {
console.log('Migrating relays to:', defaultConfig.relayMetadata.relays.map(r => r.url));
updates.relayMetadata = defaultConfig.relayMetadata;
needsUpdate = true;
}
}
// Force dark theme (app is dark-only now)
if (config.theme !== 'dark') {
console.log('Forcing dark theme');
updates.theme = 'dark';
needsUpdate = true;
}
if (needsUpdate) {
updateConfig((current) => ({
...current,
...updates,
}));
}
}, []);
// Ensure relay list is never empty - fallback to default if no relays configured
if (!config.relayMetadata.relays || config.relayMetadata.relays.length === 0) {
config.relayMetadata = defaultConfig.relayMetadata;
}
const appContextValue: AppContextType = {
config,
updateConfig,
};
// Apply theme effects to document
useApplyTheme(config.theme);
return (
<AppContext.Provider value={appContextValue}>
{children}
</AppContext.Provider>
);
}
/**
* Hook to apply theme changes to the document root
*/
function useApplyTheme(theme: Theme) {
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
// Handle system theme changes when theme is set to "system"
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
root.classList.add(systemTheme);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
}

View file

@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
import { AlertTriangle, X, Download, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useTranslation } from '@/contexts/LanguageContext';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
const BACKUP_REMINDER_KEY = 'backup-reminder-dismissed';
const BACKUP_CONFIRMED_KEY = 'backup-confirmed';
/**
* Persistent backup reminder that shows after signup
* Encourages users to backup their recovery phrase or nsec
* Uses a bottom sheet for better mobile experience
*/
export function BackupReminder() {
const { user } = useCurrentUser();
const [dismissed, setDismissed] = useLocalStorage(BACKUP_REMINDER_KEY, false);
const [confirmed, setConfirmed] = useLocalStorage(BACKUP_CONFIRMED_KEY, false);
const [showReminder, setShowReminder] = useState(false);
const { t } = useTranslation();
useEffect(() => {
if (!user) {
setShowReminder(false);
return;
}
// Show reminder if:
// 1. User is logged in
// 2. Not dismissed
// 3. Not confirmed they backed up
// Add a small delay so it doesn't immediately pop up on login
const timer = setTimeout(() => {
const shouldShow = !dismissed && !confirmed;
setShowReminder(shouldShow);
}, 2000);
return () => clearTimeout(timer);
}, [user, dismissed, confirmed]);
const handleDismiss = () => {
setDismissed(true);
setShowReminder(false);
};
const handleConfirmBackup = () => {
setConfirmed(true);
setShowReminder(false);
};
const handleBackupNow = () => {
window.location.href = '/settings?tab=security';
};
if (!showReminder) return null;
return (
<Sheet open={showReminder} onOpenChange={setShowReminder}>
<SheetContent
side="bottom"
className="rounded-t-2xl border-t-4 border-amber-500 bg-gradient-to-b from-amber-950/90 to-black/95 backdrop-blur-lg"
onInteractOutside={(e) => e.preventDefault()}
>
<SheetHeader className="text-left">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-amber-500/20 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 sm:h-6 sm:w-6 text-amber-500" />
</div>
<div className="flex-1">
<SheetTitle className="text-lg sm:text-xl font-bold text-amber-400">
{t('backup.title')}
</SheetTitle>
<SheetDescription className="text-xs sm:text-sm text-gray-400">
{t('backup.subtitle')}
</SheetDescription>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDismiss}
className="ml-2 -mt-1 text-gray-500 hover:text-gray-300"
>
<X className="h-5 w-5" />
</Button>
</div>
</SheetHeader>
<div className="mt-4 sm:mt-6 space-y-4">
{/* Warning message */}
<div className="p-3 sm:p-4 rounded-xl bg-amber-500/10 border border-amber-500/30">
<p className="text-sm sm:text-base text-gray-200 leading-relaxed">
{t('backup.message')}
</p>
<p className="text-sm sm:text-base text-gray-300 mt-2">
⚠️ {t('backup.warning')}
</p>
</div>
{/* Action buttons - stacked on mobile */}
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={handleBackupNow}
className="flex-1 h-12 sm:h-11 text-sm sm:text-base font-semibold bg-amber-500 hover:bg-amber-600 text-black"
size="lg"
>
<Download className="mr-2 h-5 w-5" />
{t('backup.backupNow')}
</Button>
<Button
onClick={handleConfirmBackup}
variant="outline"
className="flex-1 h-12 sm:h-11 text-sm sm:text-base border-amber-500/50 text-amber-400 hover:bg-amber-500/20"
size="lg"
>
<CheckCircle className="mr-2 h-5 w-5" />
{t('backup.confirmed')}
</Button>
</div>
{/* Dismiss link */}
<div className="text-center pt-1">
<button
onClick={handleDismiss}
className="text-xs text-gray-500 hover:text-gray-400 underline"
>
{t('backup.remindLater')}
</button>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,313 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import {
Camera,
X,
RotateCcw,
Check,
Loader2,
SwitchCamera,
} from 'lucide-react';
interface CameraCaptureProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCapture: (file: File) => void;
isUploading?: boolean;
}
export function CameraCapture({
open,
onOpenChange,
onCapture,
isUploading,
}: CameraCaptureProps) {
const { toast } = useToast();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment');
const [isStarting, setIsStarting] = useState(false);
// Start the 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 },
},
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);
setHasPermission(false);
toast({
title: 'Camera access denied',
description: 'Please allow camera access in your browser settings to take photos.',
variant: 'destructive',
});
} finally {
setIsStarting(false);
}
}, [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;
}
}, []);
// Start camera when dialog opens
useEffect(() => {
if (open) {
startCamera();
} else {
stopCamera();
setCapturedImage(null);
setHasPermission(null);
}
return () => {
stopCamera();
};
}, [open, startCamera, stopCamera]);
// Switch camera (front/back)
const switchCamera = () => {
const newFacing = facingMode === 'user' ? 'environment' : 'user';
setCapturedImage(null);
startCamera(newFacing);
};
// 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);
};
// 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 file = new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' });
onCapture(file);
};
// Handle close
const handleClose = () => {
stopCamera();
setCapturedImage(null);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px] p-0 overflow-hidden bg-black border-[#ff0040]/50">
<DialogHeader className="p-4 border-b border-gray-800">
<DialogTitle className="flex items-center gap-2 text-white">
<Camera className="h-5 w-5 text-[#ff0040]" />
Take a Photo
</DialogTitle>
</DialogHeader>
<div className="relative aspect-[4/3] bg-black">
{/* Hidden canvas for capturing */}
<canvas ref={canvasRef} className="hidden" />
{/* Loading state */}
{isStarting && (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-[#ccff00] mx-auto mb-2" />
<p className="text-gray-400 text-sm">Starting camera...</p>
</div>
</div>
)}
{/* No permission state */}
{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>
</div>
)}
{/* Video feed */}
{!capturedImage && (
<video
ref={videoRef}
autoPlay
playsInline
muted
className={cn(
'w-full h-full object-cover',
(isStarting || hasPermission === false) && 'hidden'
)}
/>
)}
{/* Captured image preview */}
{capturedImage && (
<img
src={capturedImage}
alt="Captured"
className="w-full h-full object-cover"
/>
)}
{/* Camera switch button */}
{!capturedImage && hasPermission && !isStarting && (
<Button
variant="ghost"
size="icon"
onClick={switchCamera}
className="absolute top-2 right-2 bg-black/50 hover:bg-black/70 text-white"
>
<SwitchCamera className="h-5 w-5" />
</Button>
)}
{/* Crosshairs/guides */}
{!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" />
<div />
<div className="border-r border-t border-white/20" />
<div className="border-r border-t border-white/20" />
<div className="border-t border-white/20" />
<div className="border-r border-t border-white/20" />
<div className="border-r border-t border-white/20" />
<div className="border-t border-white/20" />
</div>
</div>
)}
</div>
{/* Controls */}
<div className="p-4 bg-black border-t border-gray-800">
{!capturedImage ? (
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
onClick={handleClose}
className="border-gray-700 text-gray-400 hover:bg-gray-900"
>
Cancel
</Button>
<Button
onClick={capturePhoto}
disabled={!hasPermission || isStarting}
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white px-8 h-12 rounded-full"
>
<Camera className="h-5 w-5" />
</Button>
</div>
) : (
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
onClick={retakePhoto}
disabled={isUploading}
className="gap-2 border-gray-700 text-gray-400 hover:bg-gray-900"
>
<RotateCcw className="h-4 w-4" />
Retake
</Button>
<Button
onClick={confirmPhoto}
disabled={isUploading}
className="gap-2 bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold"
>
{isUploading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Uploading...
</>
) : (
<>
<Check className="h-4 w-4" />
Use Photo
</>
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,373 @@
import React, { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Loader2, Upload } from 'lucide-react';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
export const EditProfileForm: React.FC = () => {
const queryClient = useQueryClient();
const { user, metadata } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Initialize the form with default values
const form = useForm<NostrMetadata>({
resolver: zodResolver(n.metadata()),
defaultValues: {
name: '',
about: '',
picture: '',
banner: '',
website: '',
nip05: '',
bot: false,
},
});
// Update form values when user data is loaded
useEffect(() => {
if (metadata) {
form.reset({
name: metadata.name || '',
about: metadata.about || '',
picture: metadata.picture || '',
banner: metadata.banner || '',
website: metadata.website || '',
nip05: metadata.nip05 || '',
bot: metadata.bot || false,
});
}
}, [metadata, form]);
// Handle file uploads for profile picture and banner
const uploadPicture = async (file: File, field: 'picture' | 'banner') => {
try {
// The first tuple in the array contains the URL
const [[_, url]] = await uploadFile(file);
form.setValue(field, url);
toast({
title: 'Success',
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
});
} catch (error) {
console.error(`Failed to upload ${field}:`, error);
toast({
title: 'Error',
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
variant: 'destructive',
});
}
};
const onSubmit = async (values: NostrMetadata) => {
if (!user) {
toast({
title: 'Error',
description: 'You must be logged in to update your profile',
variant: 'destructive',
});
return;
}
try {
// Combine existing metadata with new values
const data = { ...metadata, ...values };
// Clean up empty values
for (const key in data) {
if (data[key] === '') {
delete data[key];
}
}
// Publish the metadata event (kind 0) with retry logic
let attempts = 0;
let lastError;
while (attempts < 3) {
try {
await publishEvent({
kind: 0,
content: JSON.stringify(data),
});
// Success! Break out of retry loop
break;
} catch (err) {
lastError = err;
attempts++;
// Wait a bit before retrying
if (attempts < 3) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
// If all attempts failed, throw the last error
if (attempts === 3 && lastError) {
throw lastError;
}
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['logins'] });
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
toast({
title: 'Success',
description: 'Your profile has been updated',
});
} catch (error) {
console.error('Failed to update profile:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
toast({
title: 'Error',
description: 'Failed to publish profile. Check your relay connections in Settings > Relays',
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormDescription>
This is your display name that will be displayed to others.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="about"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell others about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
A short description about yourself.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="picture"
render={({ field }) => (
<ImageUploadField
field={field}
label="Profile Picture"
placeholder="https://example.com/profile.jpg"
description="URL to your profile picture. You can upload an image or provide a URL."
previewType="square"
onUpload={(file) => uploadPicture(file, 'picture')}
/>
)}
/>
<FormField
control={form.control}
name="banner"
render={({ field }) => (
<ImageUploadField
field={field}
label="Banner Image"
placeholder="https://example.com/banner.jpg"
description="URL to a wide banner image for your profile. You can upload an image or provide a URL."
previewType="wide"
onUpload={(file) => uploadPicture(file, 'banner')}
/>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>
<FormControl>
<Input placeholder="https://yourwebsite.com" {...field} />
</FormControl>
<FormDescription>
Your personal website or social media link.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nip05"
render={({ field }) => (
<FormItem>
<FormLabel>NIP-05 Identifier</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormDescription>
Your verified Nostr identifier.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="bot"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Bot Account</FormLabel>
<FormDescription>
Mark this account as automated or a bot.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="submit"
className="w-full md:w-auto"
disabled={isPending || isUploading}
>
{(isPending || isUploading) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Profile
</Button>
</form>
</Form>
);
};
// Reusable component for image upload fields
interface ImageUploadFieldProps {
field: {
value: string | undefined;
onChange: (value: string) => void;
name: string;
onBlur: () => void;
};
label: string;
placeholder: string;
description: string;
previewType: 'square' | 'wide';
onUpload: (file: File) => void;
}
const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
field,
label,
placeholder,
description,
previewType,
onUpload,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex flex-col gap-2">
<FormControl>
<Input
placeholder={placeholder}
name={field.name}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value)}
onBlur={field.onBlur}
/>
</FormControl>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onUpload(file);
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Upload Image
</Button>
{field.value && (
<div className={`h-10 ${previewType === 'square' ? 'w-10' : 'w-24'} rounded overflow-hidden`}>
<img
src={field.value}
alt={`${label} preview`}
className="h-full w-full object-cover"
/>
</div>
)}
</div>
</div>
<FormDescription>
{description}
</FormDescription>
<FormMessage />
</FormItem>
);
};

View file

@ -0,0 +1,113 @@
import { Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by ErrorBoundary:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full space-y-4">
<div className="text-center">
<h2 className="text-2xl font-bold text-foreground mb-2">
Something went wrong
</h2>
<p className="text-muted-foreground">
An unexpected error occurred. The error has been reported.
</p>
</div>
<div className="bg-muted p-4 rounded-lg">
<details className="text-sm">
<summary className="cursor-pointer font-medium text-foreground">
Error details
</summary>
<div className="mt-2 space-y-2">
<div>
<strong className="text-foreground">Message:</strong>
<p className="text-muted-foreground mt-1">
{this.state.error?.message}
</p>
</div>
{this.state.error?.stack && (
<div>
<strong className="text-foreground">Stack trace:</strong>
<pre className="text-xs text-muted-foreground mt-1 overflow-auto max-h-32">
{this.state.error.stack}
</pre>
</div>
)}
</div>
</details>
</div>
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 transition-colors"
>
Reload page
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,308 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Calendar, Clock, MapPin, MoreVertical, Flag } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { ZapButton } from '@/components/ZapButton';
import { TrustBadge } from '@/components/TrustBadge';
import { ReportDialog } from '@/components/ReportDialog';
import type { SkateEvent } from '@/lib/types';
import { EVENT_TYPE_LABELS } from '@/lib/types';
interface EventCardProps {
event: SkateEvent;
compact?: boolean;
}
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
});
}
function getRelativeTime(timestamp: number): string {
const now = Date.now() / 1000;
const diff = timestamp - now;
if (diff < 0) return 'Past';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
if (days > 0) {
return days === 1 ? 'Tomorrow' : `In ${days} days`;
}
if (hours > 0) {
return `In ${hours} hour${hours > 1 ? 's' : ''}`;
}
return 'Starting soon';
}
export function EventCard({ event, compact = false }: EventCardProps) {
const author = useAuthor(event.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(event.pubkey);
const [showReportDialog, setShowReportDialog] = useState(false);
const relativeTime = getRelativeTime(event.start);
const isUpcoming = event.start > Date.now() / 1000;
const isOwner = user?.pubkey === event.pubkey;
if (compact) {
return (
<Link to={`/event/${event.pubkey}/${event.identifier}`}>
<Card className="group hover:shadow-2xl hover:shadow-[#ccff00]/20 transition-all duration-300 border-2 border-[#ccff00]/20 hover:border-[#ccff00]/60 bg-black/90 backdrop-blur">
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Date Badge */}
<div className="flex-shrink-0 w-16 text-center">
<div className="bg-[#ccff00]/10 border-2 border-[#ccff00]/50 rounded-lg p-2">
<div className="text-xs font-bold text-[#ccff00] uppercase">
{new Date(event.start * 1000).toLocaleDateString('en-US', { month: 'short' })}
</div>
<div className="text-2xl font-black text-white">
{new Date(event.start * 1000).getDate()}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="font-bold text-white group-hover:text-[#ccff00] transition-colors line-clamp-1 uppercase tracking-wide">
{event.title}
</h3>
{isUpcoming && (
<Badge className="flex-shrink-0 text-xs bg-[#ff0040] text-white border-0 font-bold uppercase">
{relativeTime}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3 text-[#ccff00]" />
{formatTime(event.start)}
</span>
{event.location && (
<span className="flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 flex-shrink-0 text-[#ff0040]" />
<span className="truncate">{event.location}</span>
</span>
)}
<TrustBadge pubkey={event.pubkey} size="sm" />
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
);
}
return (
<Link to={`/event/${event.pubkey}/${event.identifier}`}>
<Card className="group overflow-hidden hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30">
{/* Image */}
<div className="relative aspect-[16/9] overflow-hidden bg-muted">
{event.image ? (
<img
src={event.image}
alt={event.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/20 to-accent/20">
<Calendar className="h-12 w-12 text-primary/40" />
</div>
)}
{/* Date overlay */}
<div className="absolute top-3 left-3">
<div className="bg-background/95 backdrop-blur-sm rounded-lg px-3 py-2 text-center shadow-lg">
<div className="text-xs font-medium text-primary uppercase">
{new Date(event.start * 1000).toLocaleDateString('en-US', { month: 'short' })}
</div>
<div className="text-xl font-bold">
{new Date(event.start * 1000).getDate()}
</div>
</div>
</div>
{/* Type badges */}
{event.types.length > 0 && (
<div className="absolute top-3 right-3 flex flex-wrap gap-1">
{event.types.slice(0, 2).map((type) => (
<Badge
key={type}
className="bg-primary text-primary-foreground"
>
{EVENT_TYPE_LABELS[type] || type}
</Badge>
))}
</div>
)}
</div>
<CardHeader className="pb-2">
{/* Name Display at Top */}
<div className="mb-3 pb-2 border-b border-[#ccff00]/20">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8 border border-[#ccff00]/30">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="text-xs bg-[#ccff00]/20 text-[#ccff00]">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-bold text-white text-sm truncate">{displayName}</h4>
<TrustBadge pubkey={event.pubkey} size="sm" />
</div>
{metadata?.nip05 && (
<div className="flex items-center gap-1 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-[#ccff00]" />
<span className="text-[#ccff00] font-bold truncate">{metadata.nip05}</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-start justify-between gap-2">
<h3 className="font-bold text-lg leading-tight text-white group-hover:text-[#ccff00] transition-colors line-clamp-2 uppercase">
{event.title}
</h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4 text-[#ccff00]" />
{formatDate(event.start)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4 text-[#ff6600]" />
{formatTime(event.start)}
</span>
</div>
{event.location && (
<p className="text-sm text-gray-400 flex items-center gap-1 line-clamp-1">
<MapPin className="h-4 w-4 flex-shrink-0 text-[#ff0040]" />
{event.location}
</p>
)}
</CardHeader>
<CardContent className="pb-4">
{event.description && (
<p className="text-sm text-gray-400 line-clamp-2">
{event.description}
</p>
)}
<div className="flex items-center justify-end mt-4 pt-4 border-t border-[#ccff00]/20">
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
<ZapButton target={event.event} className="text-gray-400 hover:text-[#ccff00]" />
{/* More options menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-400 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{!isOwner && (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowReportDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType="event"
targetPubkey={event.pubkey}
targetEventId={event.id}
targetTitle={event.title}
/>
</Link>
);
}
export function EventCardSkeleton({ compact = false }: { compact?: boolean }) {
if (compact) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="w-14 h-16 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="overflow-hidden">
<Skeleton className="aspect-[16/9] w-full" />
<CardHeader className="pb-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</CardHeader>
<CardContent className="pb-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,276 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { useFollowing, useFollowers, useFollowActions } from '@/hooks/useFollowing';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { Users, UserPlus, UserMinus, Loader2, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
function FollowingUserCard({ pubkey, onUnfollow }: { pubkey: string; onUnfollow: (pubkey: string) => void }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const [isUnfollowing, setIsUnfollowing] = useState(false);
const handleUnfollow = async () => {
setIsUnfollowing(true);
await onUnfollow(pubkey);
setIsUnfollowing(false);
};
return (
<div className="flex items-center justify-between p-3 bg-black/50 rounded-lg border border-gray-700 hover:border-[#ff0040]/50 transition-all">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Avatar className="h-10 w-10 border-2 border-[#ff0040]/30">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-black text-[#ff0040] text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="font-bold text-white truncate">{displayName}</div>
{metadata?.nip05 && (
<div className="flex items-center gap-1 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-[#ccff00]" />
<span className="text-[#ccff00] truncate">{metadata.nip05}</span>
</div>
)}
{!metadata?.nip05 && (
<div className="text-xs text-gray-500 truncate font-mono">
{pubkey.slice(0, 8)}...{pubkey.slice(-8)}
</div>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleUnfollow}
disabled={isUnfollowing}
className="gap-1 border-gray-600 hover:border-red-500 hover:text-red-500"
>
{isUnfollowing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<UserMinus className="h-3 w-3" />
)}
Unfollow
</Button>
</div>
);
}
function FollowerUserCard({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
return (
<div className="flex items-center gap-3 p-3 bg-black/50 rounded-lg border border-gray-700">
<Avatar className="h-10 w-10 border-2 border-[#ccff00]/30">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-black text-[#ccff00] text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="font-bold text-white truncate">{displayName}</div>
{metadata?.nip05 && (
<div className="flex items-center gap-1 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-[#ccff00]" />
<span className="text-[#ccff00] truncate">{metadata.nip05}</span>
</div>
)}
{!metadata?.nip05 && (
<div className="text-xs text-gray-500 truncate font-mono">
{pubkey.slice(0, 8)}...{pubkey.slice(-8)}
</div>
)}
</div>
</div>
);
}
export function FollowListManager() {
const { user } = useCurrentUser();
const { data: following = [], isLoading: followingLoading } = useFollowing();
const { data: followers = [], isLoading: followersLoading } = useFollowers();
const { unfollow, followByIdentifier, isFollowing, isUnfollowing } = useFollowActions();
const [followInput, setFollowInput] = useState('');
const handleFollow = async (e: React.FormEvent) => {
e.preventDefault();
if (!followInput.trim()) return;
await followByIdentifier(followInput.trim());
setFollowInput('');
};
if (!user) {
return null;
}
return (
<Card className="bg-gradient-to-br from-purple-950/30 to-blue-950/30 border-2 border-purple-500/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-purple-400">
<Users className="h-5 w-5" />
Your Nostr Network
</CardTitle>
<CardDescription className="text-gray-300">
Manage who you follow. Following users helps build your Web of Trust and determines which spots show trust badges.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="following" className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="following" className="gap-2">
<UserPlus className="h-4 w-4" />
Following ({following.length})
</TabsTrigger>
<TabsTrigger value="followers" className="gap-2">
<Users className="h-4 w-4" />
Followers ({followers.length})
</TabsTrigger>
</TabsList>
<TabsContent value="following" className="space-y-4">
{/* Add Follow Input */}
<form onSubmit={handleFollow} className="space-y-2">
<div className="flex gap-2">
<Input
placeholder="npub1... or user@domain.com"
value={followInput}
onChange={(e) => setFollowInput(e.target.value)}
className="flex-1"
disabled={isFollowing}
/>
<Button
type="submit"
disabled={!followInput.trim() || isFollowing}
className="gap-2 bg-[#ff0040] hover:bg-[#ff0040]/90"
>
{isFollowing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<UserPlus className="h-4 w-4" />
)}
Follow
</Button>
</div>
<p className="text-xs text-gray-500">
💡 Paste a Nostr pubkey (npub1...), NIP-05 address (user@domain.com), or hex pubkey
</p>
</form>
{/* Following List */}
{followingLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="p-3 bg-black/50 rounded-lg border border-gray-700">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1 flex-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
))}
</div>
) : following.length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto">
{following.map((pubkey) => (
<FollowingUserCard
key={pubkey}
pubkey={pubkey}
onUnfollow={unfollow}
/>
))}
</div>
) : (
<div className="text-center py-8 border-2 border-dashed border-gray-700 rounded-lg">
<Users className="h-12 w-12 mx-auto mb-3 text-gray-600" />
<p className="text-gray-400 mb-2">You're not following anyone yet</p>
<p className="text-sm text-gray-500">
Follow skaters to see their spots with ✅ VERIFIED badges
</p>
</div>
)}
{/* Trust Info */}
{following.length > 0 && (
<div className="p-4 rounded-lg bg-green-950/30 border border-green-500/30 text-sm">
<p className="text-green-400">
<strong>✅ Trust Badges Active:</strong> Spots created by people you follow will now show VERIFIED badges.
Spots by their friends will show TRUSTED badges.
</p>
</div>
)}
</TabsContent>
<TabsContent value="followers" className="space-y-4">
{followersLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="p-3 bg-black/50 rounded-lg border border-gray-700">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1 flex-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
))}
</div>
) : followers.length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto">
{followers.map((pubkey) => (
<FollowerUserCard key={pubkey} pubkey={pubkey} />
))}
</div>
) : (
<div className="text-center py-8 border-2 border-dashed border-gray-700 rounded-lg">
<Users className="h-12 w-12 mx-auto mb-3 text-gray-600" />
<p className="text-gray-400 mb-2">No followers yet</p>
<p className="text-sm text-gray-500">
Share your Nostr profile to gain followers!
</p>
</div>
)}
</TabsContent>
</Tabs>
{/* Info Card */}
<Card className="bg-blue-950/30 border-2 border-blue-500/50 mt-6">
<CardHeader>
<CardTitle className="text-sm text-blue-400">💡 How Following Affects Trust Badges</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300">
<p>
<strong className="text-white">✅ VERIFIED (Green):</strong> Spots created by people you directly follow
</p>
<p>
<strong className="text-white">🟡 TRUSTED (Yellow):</strong> Spots created by people your friends follow (2 hops)
</p>
<p>
<strong className="text-white">⚪ Unverified:</strong> Spots by users with no connection to your network
</p>
<p className="text-xs text-blue-400 mt-4">
💡 Tip: Follow active skateboarders to see more verified spots and build your trust network!
</p>
</CardContent>
</Card>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,320 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useFollowActions, useIsFollowing } from '@/hooks/useFollowing';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
Heart,
CheckCircle2,
Sparkles,
UserPlus,
Crown,
Zap,
} from 'lucide-react';
// The founder's npub - decode to hex pubkey
const FOUNDER_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
const HOLD_DURATION = 3000; // 3 seconds
interface FounderFollowProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
showBadge?: boolean;
}
export function FounderFollow({ className, size = 'md', showBadge = true }: FounderFollowProps) {
const { user } = useCurrentUser();
const { follow } = useFollowActions();
const isFollowing = useIsFollowing(FOUNDER_PUBKEY);
const author = useAuthor(FOUNDER_PUBKEY);
const { toast } = useToast();
const [isHolding, setIsHolding] = useState(false);
const [progress, setProgress] = useState(0);
const [showConfetti, setShowConfetti] = useState(false);
const [showDialog, setShowDialog] = useState(false);
const holdTimer = useRef<NodeJS.Timeout | null>(null);
const progressInterval = useRef<NodeJS.Timeout | null>(null);
const startTime = useRef<number>(0);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(FOUNDER_PUBKEY);
const avatar = metadata?.picture;
const sizeClasses = {
sm: 'h-10 w-10',
md: 'h-14 w-14',
lg: 'h-20 w-20',
};
const cleanup = useCallback(() => {
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
if (progressInterval.current) {
clearInterval(progressInterval.current);
progressInterval.current = null;
}
setIsHolding(false);
setProgress(0);
}, []);
const handleHoldComplete = useCallback(async () => {
cleanup();
if (!user) {
toast({
title: 'Login Required',
description: 'Please log in to follow the founder',
variant: 'destructive',
});
return;
}
if (isFollowing) {
toast({
title: 'Already Following',
description: `You're already following ${displayName}!`,
});
return;
}
try {
await follow(FOUNDER_PUBKEY);
setShowConfetti(true);
setShowDialog(true);
// Hide confetti after animation
setTimeout(() => setShowConfetti(false), 3000);
} catch (error) {
console.error('[FounderFollow] Failed to follow:', error);
}
}, [user, isFollowing, follow, displayName, cleanup, toast]);
const handleHoldStart = useCallback((e: React.TouchEvent | React.MouseEvent) => {
e.preventDefault();
if (isFollowing) {
toast({
title: 'Already Following',
description: `You're already following ${displayName}!`,
});
return;
}
setIsHolding(true);
startTime.current = Date.now();
// Progress animation
progressInterval.current = setInterval(() => {
const elapsed = Date.now() - startTime.current;
const newProgress = Math.min((elapsed / HOLD_DURATION) * 100, 100);
setProgress(newProgress);
if (newProgress >= 100) {
handleHoldComplete();
}
}, 16); // ~60fps
// Timeout as backup
holdTimer.current = setTimeout(handleHoldComplete, HOLD_DURATION);
}, [isFollowing, displayName, handleHoldComplete, toast]);
const handleHoldEnd = useCallback(() => {
cleanup();
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return cleanup;
}, [cleanup]);
return (
<>
<div className={cn('relative inline-block', className)}>
{/* Confetti effect */}
{showConfetti && (
<div className="absolute inset-0 pointer-events-none overflow-visible z-50">
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
className="absolute animate-confetti"
style={{
left: '50%',
top: '50%',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: ['#ff0040', '#ccff00', '#ff6600', '#00ccff', '#9933ff'][i % 5],
animationDelay: `${i * 0.05}s`,
transform: `rotate(${i * 18}deg) translateY(-40px)`,
}}
/>
))}
</div>
)}
{/* Avatar with hold effect */}
<div
onMouseDown={handleHoldStart}
onMouseUp={handleHoldEnd}
onMouseLeave={handleHoldEnd}
onTouchStart={handleHoldStart}
onTouchEnd={handleHoldEnd}
onTouchCancel={handleHoldEnd}
className={cn(
'relative cursor-pointer transition-all duration-200 select-none',
isHolding && 'scale-110'
)}
>
{/* Progress ring */}
{isHolding && (
<svg
className="absolute -inset-1 z-10 -rotate-90"
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="#ff6600"
strokeWidth="4"
strokeDasharray={`${progress * 2.89} 289`}
className="drop-shadow-[0_0_8px_#ff6600]"
/>
</svg>
)}
{/* Glow effect */}
<div
className={cn(
'absolute -inset-2 rounded-full bg-gradient-to-r from-[#ff6600] to-[#ff0040] opacity-0 blur-lg transition-opacity duration-300',
isHolding && 'opacity-60',
isFollowing && 'opacity-30'
)}
/>
<Avatar
className={cn(
sizeClasses[size],
'border-2 transition-all duration-200',
isFollowing ? 'border-[#ccff00]' : 'border-[#ff6600]/50',
isHolding && 'border-[#ff6600] shadow-[0_0_20px_rgba(255,102,0,0.5)]'
)}
>
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-gradient-to-br from-[#ff6600] to-[#ff0040] text-white font-black">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Following indicator */}
{isFollowing && showBadge && (
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-[#ccff00] border-2 border-black flex items-center justify-center">
<CheckCircle2 className="h-3 w-3 text-black" />
</div>
)}
{/* Crown for founder */}
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<Crown className="h-4 w-4 text-yellow-400 drop-shadow-[0_0_4px_rgba(250,204,21,0.8)]" />
</div>
</div>
{/* Hold hint text */}
{!isFollowing && !isHolding && (
<p className="text-[10px] text-center text-muted-foreground mt-1.5 max-w-[80px]">
Hold to follow founder
</p>
)}
{/* Holding progress text */}
{isHolding && (
<p className="text-[10px] text-center text-[#ff6600] mt-1.5 font-bold animate-pulse">
{Math.floor(progress)}% Hold...
</p>
)}
</div>
{/* Success Dialog */}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="bg-black/95 border-2 border-[#ccff00]/50 max-w-sm">
<DialogHeader>
<DialogTitle className="text-center text-2xl font-black text-[#ccff00] flex items-center justify-center gap-2">
<Sparkles className="h-6 w-6" />
Welcome!
<Sparkles className="h-6 w-6" />
</DialogTitle>
<DialogDescription className="text-center text-gray-300 pt-2">
You're now following the SkateSpots founder!
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center py-6 space-y-4">
<Avatar className="h-24 w-24 border-4 border-[#ccff00] shadow-[0_0_30px_rgba(204,255,0,0.3)]">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-gradient-to-br from-[#ff6600] to-[#ff0040] text-white text-2xl font-black">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-center">
<h3 className="font-black text-xl text-white">{displayName}</h3>
<Badge className="mt-2 bg-gradient-to-r from-[#ff6600] to-[#ff0040] text-white">
<Crown className="h-3 w-3 mr-1" />
Founder
</Badge>
</div>
<div className="text-center text-sm text-muted-foreground max-w-xs">
<p>Stay connected to get updates on new features, community events, and skate spot discoveries!</p>
</div>
<Button
onClick={() => setShowDialog(false)}
className="w-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
>
<Zap className="h-4 w-4 mr-2" />
Let's Go!
</Button>
</div>
</DialogContent>
</Dialog>
{/* CSS for confetti animation */}
<style>{`
@keyframes confetti {
0% {
transform: rotate(var(--rotation)) translateY(0) scale(1);
opacity: 1;
}
100% {
transform: rotate(var(--rotation)) translateY(-100px) scale(0);
opacity: 0;
}
}
.animate-confetti {
animation: confetti 1s ease-out forwards;
}
`}</style>
</>
);
}
export default FounderFollow;

View file

@ -0,0 +1,296 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, formatPrice, type MarketplaceItem } from '@/lib/types';
import {
MessageCircle,
MapPin,
Search,
Tag,
CheckCircle,
ShieldCheck,
AlertTriangle,
MoreVertical,
Flag,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ReportDialog } from '@/components/ReportDialog';
import { formatDistanceToNow } from 'date-fns';
interface GearSwapItemCardProps {
item: MarketplaceItem;
trustScore?: number;
salesCount: number;
isFollowing?: boolean;
}
export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }: GearSwapItemCardProps) {
const { user } = useCurrentUser();
const navigate = useNavigate();
const author = useAuthor(item.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(item.pubkey);
const avatar = metadata?.picture;
const [showReportDialog, setShowReportDialog] = useState(false);
const npub = nip19.npubEncode(item.pubkey);
const isOwnListingCheck = user?.pubkey === item.pubkey;
// Format price display
const priceDisplay = formatPrice(item.price, item.priceType);
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
const primaryImage = item.images[0];
const isWTB = item.listingType === 'wtb';
// Get condition info
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
// Get category label
const categoryLabel = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES])
? GEAR_CATEGORIES[item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]) as keyof typeof GEAR_CATEGORIES]
: null;
// Get price type label
const priceTypeLabel = item.priceType ? PRICE_TYPES[item.priceType]?.label : null;
// Skateboard rating for seller
const sellerRating = getSkateboardRating(salesCount);
// Trust level
const trustLevel = getTrustLevel(trustScore);
const trustBadge = SELLER_TRUST_BADGES[trustLevel];
const isOwnListing = user?.pubkey === item.pubkey;
return (
<Card
className={`bg-black/90 border-2 group cursor-pointer transition-all hover:scale-[1.02] ${
isWTB
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]'
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-[0_0_30px_rgba(255,102,0,0.2)]'
}`}
onClick={() => navigate(`/messages/${npub}`)}
>
<CardContent className="p-0">
{/* Image */}
<div className="relative aspect-square overflow-hidden rounded-t-lg">
{primaryImage ? (
<img
src={primaryImage}
alt={item.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
) : (
<div
className={`w-full h-full flex items-center justify-center ${
isWTB ? 'bg-blue-950/50' : 'bg-orange-950/50'
}`}
>
<span className="text-6xl">{isWTB ? '🔍' : '🛹'}</span>
</div>
)}
{/* WTB Badge */}
{isWTB && (
<Badge className="absolute top-2 left-2 bg-blue-500 text-white border-0 shadow-lg">
<Search className="h-3 w-3 mr-1" />
WTB
</Badge>
)}
{/* Price Badge */}
<div className="absolute top-2 right-2">
<Badge
className={`text-sm font-bold border-0 shadow-lg ${
item.priceType === 'free'
? 'bg-green-500 text-white'
: isWTB
? 'bg-blue-500 text-white'
: 'bg-orange-500 text-white'
}`}
>
{priceDisplay}
</Badge>
</div>
{/* Condition Badge (bottom left) */}
{conditionInfo && (
<Badge
variant="outline"
className="absolute bottom-2 left-2 bg-black/80 border-gray-600 text-xs backdrop-blur-sm"
>
{conditionInfo.emoji} {conditionInfo.label}
</Badge>
)}
{/* More options menu */}
{!isOwnListingCheck && (
<div className="absolute bottom-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 bg-black/80 backdrop-blur-sm text-gray-400 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowReportDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{/* Content */}
<div className="p-4">
{/* Title */}
<h3
className={`font-bold text-sm uppercase mb-1 line-clamp-1 ${
isWTB ? 'text-blue-400' : 'text-orange-400'
}`}
>
{item.title}
</h3>
{/* Category + Size */}
<div className="flex flex-wrap gap-1 mb-3">
{categoryLabel && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
{categoryLabel}
</Badge>
)}
{item.size && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
{item.size}
</Badge>
)}
</div>
{/* Location */}
{item.location && (
<div className="flex items-center gap-1 text-xs text-gray-500 mb-3">
<MapPin className="h-3 w-3 text-orange-500" />
<span className="truncate">{item.location}</span>
</div>
)}
{/* Divider */}
<div className="border-t border-gray-800 pt-3">
{/* Seller Info */}
<div className="flex items-center gap-2 mb-2">
<Avatar className="h-6 w-6 border border-gray-700">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-gray-800 text-gray-400 text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-xs text-gray-400 truncate flex-1">{displayName}</span>
{/* Trust Badge */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={`text-[10px] px-1.5 gap-0.5 ${trustBadge.className}`}
>
{trustLevel === 'verified' && <CheckCircle className="h-3 w-3" />}
{trustLevel === 'trusted' && <ShieldCheck className="h-3 w-3" />}
{trustLevel === 'new' && <AlertTriangle className="h-3 w-3" />}
{trustBadge.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">{trustBadge.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Skateboard Rating */}
<div className="flex items-center justify-between">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
{sellerRating.rating > 0 ? (
<span className="text-sm">{sellerRating.display}</span>
) : (
<span className="text-xs text-gray-500">🛹 New seller</span>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="text-center">
<p className="font-bold">{salesCount} sale{salesCount !== 1 ? 's' : ''}</p>
<p className="text-xs text-muted-foreground">{sellerRating.label}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="text-[10px] text-gray-600">{timeAgo}</span>
</div>
</div>
{/* Contact Button */}
<Button
variant="outline"
size="sm"
className={`w-full mt-3 gap-2 ${
isWTB
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
}`}
onClick={(e) => {
e.stopPropagation();
navigate(`/messages/${npub}`);
}}
>
<MessageCircle className="h-4 w-4" />
{isWTB ? 'I Have This!' : 'Contact Seller'}
</Button>
</div>
</CardContent>
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType="listing"
targetPubkey={item.pubkey}
targetEventId={item.id}
targetTitle={item.title}
/>
</Card>
);
}

View file

@ -0,0 +1,122 @@
import { Languages } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTranslation, type Language } from '@/contexts/LanguageContext';
import { cn } from '@/lib/utils';
interface LanguageOption {
code: Language;
name: string;
nativeName: string;
flag: string;
}
const languages: LanguageOption[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
];
interface LanguageToggleProps {
/** Show only the flag icon (compact mode) */
compact?: boolean;
/** Additional class names */
className?: string;
/** Variant style */
variant?: 'default' | 'outline' | 'ghost';
}
/**
* Language toggle dropdown for switching between English and Spanish
*/
export function LanguageToggle({ compact = false, className, variant = 'ghost' }: LanguageToggleProps) {
const { language, setLanguage, t } = useTranslation();
const currentLanguage = languages.find(l => l.code === language) || languages[0];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant}
size={compact ? 'icon' : 'default'}
className={cn(
'gap-2',
compact ? 'w-10 h-10' : 'px-3',
className
)}
>
<span className="text-lg">{currentLanguage.flag}</span>
{!compact && (
<>
<span className="hidden sm:inline">{currentLanguage.nativeName}</span>
<Languages className="h-4 w-4 sm:hidden" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLanguage(lang.code)}
className={cn(
'flex items-center gap-3 cursor-pointer',
language === lang.code && 'bg-primary/10 text-primary'
)}
>
<span className="text-lg">{lang.flag}</span>
<div className="flex flex-col">
<span className="font-medium">{lang.nativeName}</span>
{lang.nativeName !== lang.name && (
<span className="text-xs text-muted-foreground">{lang.name}</span>
)}
</div>
{language === lang.code && (
<span className="ml-auto text-primary">✓</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* Simple language selector for settings page
*/
export function LanguageSelector() {
const { language, setLanguage, t } = useTranslation();
return (
<div className="space-y-3">
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
<div className="grid grid-cols-2 gap-3">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => setLanguage(lang.code)}
className={cn(
'flex items-center gap-3 p-4 rounded-xl border-2 transition-all',
language === lang.code
? 'border-primary bg-primary/10 shadow-lg shadow-primary/20'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
)}
>
<span className="text-2xl">{lang.flag}</span>
<div className="text-left">
<div className="font-bold">{lang.nativeName}</div>
{lang.nativeName !== lang.name && (
<div className="text-xs text-muted-foreground">{lang.name}</div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,769 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { LoginArea } from '@/components/auth/LoginArea';
import { SasquatchGuide } from '@/components/SasquatchGuide';
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
import { BackupReminder } from '@/components/BackupReminder';
import { LanguageToggle } from '@/components/LanguageToggle';
import { useCashu } from '@/hooks/wallet/useCashu';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/LanguageContext';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import {
MapPin,
Calendar,
Menu,
Home,
Wallet,
Settings,
GraduationCap,
BarChart3,
MoreHorizontal,
Info,
Bookmark,
MessageCircle,
Gift,
LogOut,
User,
LogIn,
Plus,
CheckCircle2,
Database,
Package,
Zap,
} from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
import { SetStatusDialog } from '@/components/SetStatusDialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
interface LayoutProps {
children: React.ReactNode;
}
// Nav item keys that map to translation keys
const navItemKeys = [
{ href: '/', labelKey: 'nav.home', icon: Home },
{ href: '/map', labelKey: 'nav.map', icon: MapPin },
{ href: '/lab', labelKey: 'nav.lab', icon: GraduationCap },
{ href: '/events', labelKey: 'nav.events', icon: Calendar },
];
// Format sats for display (e.g., 1234 -> "1.2k", 12345 -> "12k")
function formatSatsCompact(sats: number): string {
if (sats >= 1000000) {
return `${(sats / 1000000).toFixed(1)}M`;
}
if (sats >= 1000) {
return `${(sats / 1000).toFixed(sats >= 10000 ? 0 : 1)}k`;
}
return sats.toString();
}
function NavLink({
href,
label,
icon: Icon,
isActive,
onClick,
}: {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
isActive: boolean;
onClick?: () => void;
}) {
return (
<Link
to={href}
onClick={onClick}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200',
isActive
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
)}
>
<Icon className="h-5 w-5" />
<span className="font-medium">{label}</span>
</Link>
);
}
// Mobile header user menu - shows when logged in with dropdown for actions
function MobileHeaderUserMenu({
pubkey,
onLogout,
onSetStatus,
}: {
pubkey: string;
onLogout: () => void;
onSetStatus: () => void;
}) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const avatar = metadata?.picture;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="touch-manipulation relative flex items-center gap-2 px-2 py-1.5 rounded-full bg-green-500/10 border border-green-500/30 active:scale-95 transition-transform">
<Avatar className="h-7 w-7 border-2 border-green-500/50">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Status indicator */}
<UserStatusIndicator pubkey={pubkey} size="sm" />
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
<CheckCircle2 className="h-3 w-3 inline mr-1" />
In
</span>
{/* Green online indicator dot */}
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 p-2">
{/* User info header */}
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
<div className="flex items-center gap-3">
<div className="relative">
<Avatar className="h-10 w-10 border-2 border-green-500/50">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-green-500/20 text-green-400 text-sm font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<UserStatusIndicator pubkey={pubkey} size="sm" className="absolute -bottom-1 -right-1" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm truncate">{displayName}</p>
<p className="text-xs text-green-500 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Logged In
</p>
</div>
</div>
</div>
{/* Set Status - highlighted */}
<DropdownMenuItem
onClick={onSetStatus}
className="flex items-center gap-2 cursor-pointer mb-1 bg-green-500/10 border border-green-500/30 rounded-lg"
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-green-400 font-bold">Set Status</span>
<span className="ml-auto text-xs text-green-500">🛹</span>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
<Plus className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00] font-medium">Add Spot</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings" className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
My Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings?tab=mydata" className="flex items-center gap-2 cursor-pointer">
<Database className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">My Data</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-purple-400" />
<span className="text-purple-400">My Stash</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
<Wallet className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">Wallet</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onLogout}
className="flex items-center gap-2 cursor-pointer text-red-400"
>
<LogOut className="h-4 w-4" />
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function MobileNav({
isActive,
cashuBalance,
isLoggedIn,
userPubkey,
userAvatar,
userName,
t,
}: {
isActive: (path: string) => boolean;
cashuBalance: number;
isLoggedIn: boolean;
userPubkey?: string;
userAvatar?: string;
userName?: string;
t: (key: string) => string;
}) {
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden safe-area-bottom">
<div className="glass border-t border-border safe-area-left safe-area-right">
<div className="flex items-center justify-around px-1 py-2">
{navItemKeys.map((item) => (
<Link
key={item.href}
to={item.href}
className={cn(
'flex flex-col items-center justify-center gap-0.5 py-2 px-2 rounded-xl transition-all duration-200 min-w-[48px] min-h-[48px] touch-manipulation active:scale-95',
isActive(item.href)
? 'text-primary'
: 'text-muted-foreground'
)}
>
<item.icon className={cn(
'h-5 w-5 transition-transform',
isActive(item.href) && 'scale-110'
)} />
<span className="text-[10px] font-medium leading-tight">{t(item.labelKey)}</span>
</Link>
))}
{/* Wallet in mobile nav */}
{isLoggedIn && (
<Link
to="/wallet"
className={cn(
'flex flex-col items-center justify-center gap-0.5 py-2 px-2 rounded-xl transition-all duration-200 min-w-[48px] min-h-[48px] relative touch-manipulation active:scale-95',
isActive('/wallet')
? 'text-orange-500'
: 'text-muted-foreground'
)}
>
<div className="relative">
<Wallet className={cn(
'h-5 w-5 transition-transform',
isActive('/wallet') && 'scale-110'
)} />
{cashuBalance > 0 && (
<span className="absolute -top-1 -right-2 min-w-[16px] h-4 px-1 text-[8px] font-bold bg-orange-500 text-white rounded-full flex items-center justify-center">
{formatSatsCompact(cashuBalance)}
</span>
)}
</div>
<span className="text-[10px] font-medium leading-tight">{t('nav.wallet')}</span>
</Link>
)}
{/* Profile/Login in mobile nav */}
<Link
to={isLoggedIn ? "/settings" : "/settings"}
className={cn(
'flex flex-col items-center justify-center gap-0.5 py-2 px-2 rounded-xl transition-all duration-200 min-w-[48px] min-h-[48px] relative touch-manipulation active:scale-95',
isActive('/settings')
? 'text-primary'
: 'text-muted-foreground'
)}
>
{isLoggedIn && userPubkey ? (
<div className="relative">
<Avatar className={cn(
'h-6 w-6 border-2 transition-transform',
isActive('/settings') ? 'border-primary scale-110' : 'border-gray-600'
)}>
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-[8px] font-bold">
{(userName || '??').slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Green online indicator dot */}
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full" />
</div>
) : (
<User className="h-5 w-5" />
)}
<span className="text-[10px] font-medium leading-tight">
{isLoggedIn ? 'Profile' : 'Log In'}
</span>
</Link>
</div>
</div>
{/* Safe area spacer for notched devices */}
<div className="h-safe-area-bottom bg-background" />
</nav>
);
}
export function Layout({ children }: LayoutProps) {
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showStatusDialog, setShowStatusDialog] = useState(false);
const { currentMessage, dismissCurrentMessage } = useSasquatchGuide();
const { user } = useCurrentUser();
const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu();
const { t } = useTranslation();
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
const author = useAuthor(user?.pubkey || '');
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
const userAvatar = author.data?.metadata?.picture;
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
return (
<div className="min-h-screen bg-black text-white">
{/* Desktop Header */}
<header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur">
<div className="container flex items-center justify-between h-16 px-4">
<Link to="/" className="flex items-center gap-3 group">
<div className="relative">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center shadow-lg">
<MapPin className="h-5 w-5 text-white" />
</div>
</div>
<span className="text-xl font-black uppercase tracking-wider neon-red" style={{
textShadow: '0 0 10px #ff0040, 0 0 20px #ff0040'
}}>SKATESPOTS</span>
</Link>
<nav className="flex items-center gap-1">
{navItemKeys.map((item) => (
<Link
key={item.href}
to={item.href}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide',
isActive(item.href)
? 'text-red-500'
: 'text-gray-400 hover:text-red-400'
)}
>
<item.icon className="h-4 w-4" />
{t(item.labelKey)}
</Link>
))}
{/* More Menu - Desktop */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide h-auto',
(isActive('/bounties') || isActive('/stats') || isActive('/about'))
? 'text-red-500'
: 'text-gray-400 hover:text-red-400'
)}
>
<MoreHorizontal className="h-4 w-4" />
{t('nav.more')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{user && (
<DropdownMenuItem asChild>
<Link to="/collections" className="flex items-center gap-2 cursor-pointer">
<Bookmark className="h-4 w-4" />
My Collections
</Link>
</DropdownMenuItem>
)}
{user && (
<DropdownMenuItem asChild>
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-purple-400" />
<span className="text-purple-400">My Stash</span>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/bounties" className="flex items-center gap-2 cursor-pointer">
<Gift className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">Map & Earn</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/stats" className="flex items-center gap-2 cursor-pointer">
<BarChart3 className="h-4 w-4" />
{t('nav.stats')}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/about" className="flex items-center gap-2 cursor-pointer">
<Info className="h-4 w-4" />
{t('nav.about')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</nav>
<div className="flex items-center gap-3">
{/* Messages Quick Access - Desktop */}
{user && (
<Link to="/messages">
<Button
variant="ghost"
size="icon"
className={cn(
isActive('/messages')
? "text-[#ff0040] bg-[#ff0040]/10"
: "text-gray-400 hover:text-[#ff0040] hover:bg-[#ff0040]/10"
)}
title="Messages"
>
<MessageCircle className="h-5 w-5" />
</Button>
</Link>
)}
{/* Collections Quick Access - Desktop */}
{user && (
<Link to="/collections">
<Button
variant="ghost"
size="icon"
className={cn(
isActive('/collections')
? "text-[#ccff00] bg-[#ccff00]/10"
: "text-gray-400 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
)}
title="My Collections"
>
<Bookmark className="h-5 w-5" />
</Button>
</Link>
)}
{/* Wallet Quick Access - Desktop */}
{user && (
<Link to="/wallet">
<Button
variant="ghost"
className={cn(
"gap-2 px-3",
isActive('/wallet')
? "text-orange-500 bg-orange-500/10"
: "text-gray-400 hover:text-orange-400 hover:bg-orange-500/10"
)}
>
<Wallet className="h-5 w-5" />
{hasCashu && cashuBalance > 0 && (
<span className="text-sm font-bold">
{formatSatsCompact(cashuBalance)}
</span>
)}
</Button>
</Link>
)}
{/* Status Quick Access - Desktop */}
{user && (
<Button
variant="outline"
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
onClick={() => setShowStatusDialog(true)}
title="Set Your Status"
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-sm font-bold">Status</span>
</Button>
)}
{/* Offline Indicator */}
<OfflineIndicator />
{/* Language Toggle */}
<LanguageToggle compact className="text-gray-400 hover:text-white" />
<Link to="/settings">
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</Link>
<LoginArea className="max-w-60" />
</div>
</div>
</header>
{/* Mobile Header */}
<header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top">
<div className="flex items-center justify-between h-14 px-3 gap-3">
<Link to="/" className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center flex-shrink-0 shadow-lg">
<MapPin className="h-4 w-4 text-white" />
</div>
<span className="text-lg font-black uppercase neon-red" style={{
textShadow: '0 0 8px #ff0040'
}}>SKATESPOTS</span>
</Link>
<div className="flex items-center gap-1 flex-shrink-0">
{/* User menu when logged in / Login button when not */}
{user ? (
<MobileHeaderUserMenu
pubkey={user.pubkey}
onLogout={() => removeLogin(currentAccount?.id || '')}
onSetStatus={() => setShowStatusDialog(true)}
/>
) : (
<LoginArea compact className="flex flex-row gap-1" />
)}
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 touch-manipulation active:scale-95 transition-transform">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80">
<div className="flex flex-col gap-6 mt-6">
{/* Wallet Card - Mobile Menu */}
{user && (
<Link
to="/wallet"
onClick={() => setMobileMenuOpen(false)}
className="block"
>
<div className={cn(
"p-4 rounded-xl border-2 transition-all",
isActive('/wallet')
? "bg-orange-500/20 border-orange-500/50"
: "bg-orange-500/10 border-orange-500/30 hover:bg-orange-500/20"
)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<Wallet className="h-5 w-5 text-orange-500" />
</div>
<div>
<p className="font-bold text-sm">{t('wallet.cashu.title')}</p>
<p className="text-xs text-muted-foreground">Ecash</p>
</div>
</div>
{hasCashu && (
<div className="text-right">
<p className="font-bold text-orange-500">
{cashuBalance.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">sats</p>
</div>
)}
</div>
</div>
</Link>
)}
<div className="flex flex-col gap-2">
{navItemKeys.map((item) => (
<NavLink
key={item.href}
href={item.href}
label={t(item.labelKey)}
icon={item.icon}
isActive={isActive(item.href)}
onClick={() => setMobileMenuOpen(false)}
/>
))}
{user && (
<NavLink
href="/messages"
label="Messages"
icon={MessageCircle}
isActive={isActive('/messages')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/collections"
label="My Collections"
icon={Bookmark}
isActive={isActive('/collections')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/stash"
label="My Stash"
icon={Package}
isActive={isActive('/stash')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
<NavLink
href="/bounties"
label="Map & Earn"
icon={Gift}
isActive={isActive('/bounties')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/stats"
label={t('nav.stats')}
icon={BarChart3}
isActive={isActive('/stats')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/about"
label={t('nav.about')}
icon={Info}
isActive={isActive('/about')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/settings"
label={t('nav.settings')}
icon={Settings}
isActive={isActive('/settings')}
onClick={() => setMobileMenuOpen(false)}
/>
</div>
{/* Language Toggle in Mobile Menu */}
<div className="py-3 border-t">
<LanguageToggle className="w-full justify-start" variant="ghost" />
</div>
{/* Login/Logout Section */}
<div className="pt-4 border-t space-y-3">
{user ? (
<>
{/* User Profile Card */}
<div className="p-4 rounded-xl bg-gradient-to-br from-green-500/10 to-green-500/5 border border-green-500/20">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 border-2 border-green-500/50">
<AvatarImage src={author.data?.metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-sm font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-bold text-base truncate">{displayName}</p>
<p className="text-sm text-green-500 flex items-center gap-1.5">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Logged In
</p>
</div>
</div>
</div>
{/* Logout Button - Big and Clear */}
<Button
variant="outline"
size="lg"
className="w-full justify-center gap-3 text-red-400 border-red-500/50 hover:bg-red-500/20 hover:text-red-300 hover:border-red-500 h-12 text-base font-bold"
onClick={() => {
removeLogin(currentAccount?.id || '');
setMobileMenuOpen(false);
}}
>
<LogOut className="h-5 w-5" />
Log Out
</Button>
</>
) : (
<>
{/* Not Logged In - Clear CTA */}
<div className="p-4 rounded-xl bg-gradient-to-br from-[#ff0040]/10 to-[#ff0040]/5 border border-[#ff0040]/20 text-center">
<User className="h-10 w-10 mx-auto mb-2 text-[#ff0040]/50" />
<p className="font-bold text-base mb-1">Not Logged In</p>
<p className="text-sm text-muted-foreground mb-4">
Log in to add spots, zap skaters, and save your favorites
</p>
<LoginArea className="w-full justify-center" />
</div>
</>
)}
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
{/* Backup Reminder */}
<BackupReminder />
{/* Main Content */}
<main className="pb-20 md:pb-6">
{children}
</main>
{/* Footer - Desktop */}
<footer className="hidden md:block border-t border-gray-800 bg-black/95 py-6">
<div className="container px-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
<span>Made with <span className="text-red-500">❤️</span> for the skateboarding community</span>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>Not sponsored by</span>
<span className="inline-flex items-center gap-1.5">
<img
src="https://opensats.org/favicon.ico"
alt="OpenSats"
className="h-4 w-4"
/>
<span className="font-medium text-gray-400">OpenSats</span>
</span>
</div>
</div>
</div>
</footer>
{/* No mobile footer - bottom nav provides navigation, keeps UI clean */}
{/* Mobile Bottom Navigation */}
<MobileNav
isActive={isActive}
cashuBalance={cashuBalance}
isLoggedIn={!!user}
userPubkey={user?.pubkey}
userAvatar={userAvatar}
userName={displayName}
t={t}
/>
{/* Sasquatch Guide (Desktop only) */}
<div className="hidden md:block">
<SasquatchGuide
message={currentMessage}
onDismiss={dismissCurrentMessage}
/>
</div>
{/* Set Status Dialog */}
<SetStatusDialog
open={showStatusDialog}
onOpenChange={setShowStatusDialog}
/>
</div>
);
}

View file

@ -0,0 +1,564 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCashu } from '@/hooks/wallet/useCashu';
import { useToast } from '@/hooks/useToast';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useListingReports } from '@/hooks/useListingReports';
import { useSellerInfo } from '@/hooks/useSellerInfo';
import { useTrustScore } from '@/hooks/useTrustScore';
import { genUserName } from '@/lib/genUserName';
import { TrustBadge } from '@/components/TrustBadge';
import {
MessageCircle,
Clock,
Search,
Tag,
Flag,
MoreVertical,
ShieldCheck,
ShieldAlert,
Calendar,
Package,
AlertTriangle,
CheckCircle,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import type { MarketplaceItem } from '@/lib/types';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
interface MarketplaceItemCardProps {
item: MarketplaceItem;
compact?: boolean;
}
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
const { user } = useCurrentUser();
const { toast } = useToast();
const navigate = useNavigate();
const { mutateAsync: publishEvent } = useNostrPublish();
const author = useAuthor(item.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(item.pubkey);
const avatar = metadata?.picture;
const npub = nip19.npubEncode(item.pubkey);
// Fetch reports for this listing
const { data: reportData } = useListingReports(item.id, item.pubkey);
// Fetch seller info
const { data: sellerInfo } = useSellerInfo(item.pubkey);
// Fetch trust score
const { data: trustScore } = useTrustScore(item.pubkey);
// Cashu wallet integration
const { totalBalance: cashuBalance, isInitialized: hasCashu, coco } = useCashu();
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
const [showReportDialog, setShowReportDialog] = useState(false);
const [reportReason, setReportReason] = useState<string>('');
const [reportDetails, setReportDetails] = useState('');
const [isReporting, setIsReporting] = useState(false);
const [isPaying, setIsPaying] = useState(false);
// Format price display
const priceDisplay = item.price
? `${formatPrice(item.price, item.priceType)}${item.price.frequency ? `/${item.price.frequency}` : ''}`
: formatPrice(undefined, item.priceType);
const timeAgo = formatDistanceToNow(new Date(item.createdAt * 1000), { addSuffix: true });
const primaryImage = item.images[0];
const isWTB = item.listingType === 'wtb';
// Expiration is stored as unix timestamp in seconds
const isExpired = item.expiration ? item.expiration < Math.floor(Date.now() / 1000) : false;
// Get condition info
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
// Get category label
const categoryLabel = item.category?.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES])
? GEAR_CATEGORIES[item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]) as keyof typeof GEAR_CATEGORIES]
: null;
// Get price type label
const priceTypeLabel = item.priceType ? PRICE_TYPES[item.priceType]?.label : null;
// Calculate price in sats (rough estimate: $1 = 2500 sats at $40k/BTC)
const priceInSats = item.price?.amount ? Math.round(parseFloat(item.price.amount) * 2500) : 0;
const canPayWithCashu = hasCashu && cashuBalance >= priceInSats && priceInSats > 0;
// Seller verification status
const isVerifiedSeller = sellerInfo?.hasNip05 || false;
const isHighTrust = (trustScore || 0) >= 60;
const accountAgeDays = sellerInfo?.accountAge || 0;
const previousListings = sellerInfo?.listingCount || 0;
// Report status
const reportCount = reportData?.count || 0;
const shouldHide = reportData?.shouldHide || false;
const handleCashuPayment = async () => {
if (!coco || !user) return;
setIsPaying(true);
try {
toast({
title: 'Payment Feature Coming Soon',
description: `Cashu peer-to-peer payments will be available soon! For now, please contact the seller directly.`,
});
setShowPaymentDialog(false);
} catch (error) {
toast({
variant: 'destructive',
title: 'Payment Failed',
description: error instanceof Error ? error.message : 'Failed to process payment',
});
} finally {
setIsPaying(false);
}
};
const handleReport = async () => {
if (!user || !reportReason) {
toast({ title: 'Please select a reason', variant: 'destructive' });
return;
}
setIsReporting(true);
try {
// Publish NIP-56 report event (kind 1984)
await publishEvent({
kind: 1984,
content: reportDetails.trim(),
tags: [
['e', item.id, '', reportReason],
['p', item.pubkey],
['L', 'skate-spots.report'],
['l', reportReason, 'skate-spots.report'],
],
});
toast({
title: 'Report submitted',
description: 'Thank you for helping keep the marketplace safe.',
});
setShowReportDialog(false);
setReportReason('');
setReportDetails('');
} catch (error) {
toast({
variant: 'destructive',
title: 'Failed to submit report',
description: error instanceof Error ? error.message : 'Please try again',
});
} finally {
setIsReporting(false);
}
};
// Don't render expired or heavily reported listings
if (isExpired || shouldHide) {
return null;
}
const isOwnListing = user?.pubkey === item.pubkey;
return (
<Card
className={`bg-black/90 border-2 ${
isWTB
? 'border-blue-500/20 hover:border-blue-500/60 hover:shadow-blue-500/10'
: 'border-orange-500/20 hover:border-orange-500/60 hover:shadow-orange-500/10'
} hover:shadow-2xl transition-all relative`}
>
{/* Report warning badge */}
{reportCount > 0 && reportCount < 3 && (
<div className="absolute top-2 right-2 z-10">
<Badge variant="destructive" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
{reportCount} report{reportCount > 1 ? 's' : ''}
</Badge>
</div>
)}
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Product Image */}
<div className="flex-shrink-0">
{primaryImage ? (
<div
className={`w-20 h-20 rounded-lg overflow-hidden border-2 ${
isWTB ? 'border-blue-500/30' : 'border-orange-500/30'
}`}
>
<img src={primaryImage} alt={item.title} className="w-full h-full object-cover" />
</div>
) : (
<div
className={`w-20 h-20 rounded-lg border-2 border-dashed ${
isWTB ? 'border-blue-500/30 bg-blue-500/5' : 'border-orange-500/30 bg-orange-500/5'
} flex items-center justify-center`}
>
<span className="text-3xl">{isWTB ? '🔍' : '🛹'}</span>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Listing Type Badge + Title */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
{isWTB && (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/50 mb-1 text-xs">
<Search className="h-3 w-3 mr-1" />
WANT TO BUY
</Badge>
)}
<h3
className={`font-black text-base uppercase ${
isWTB ? 'text-blue-400' : 'text-orange-400'
} leading-tight`}
>
{item.title}
</h3>
</div>
<div className="text-right flex items-start gap-1">
<div>
<span
className={`text-lg font-bold ${
isWTB ? 'text-blue-500' : 'text-orange-500'
} whitespace-nowrap`}
>
{priceDisplay}
</span>
{priceTypeLabel && priceTypeLabel !== 'Fixed Price' && (
<div className="text-xs text-gray-400">{priceTypeLabel}</div>
)}
</div>
{/* Actions dropdown */}
{user && !isOwnListing && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-gray-400">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setShowReportDialog(true)}
className="text-red-400 focus:text-red-400"
>
<Flag className="h-4 w-4 mr-2" />
Report Listing
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Condition, Size, Category Badges */}
<div className="flex flex-wrap gap-1 mb-2">
{conditionInfo && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{conditionInfo.emoji} {conditionInfo.label}
</Badge>
)}
{item.size && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{item.size}
</Badge>
)}
{categoryLabel && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
<Tag className="h-3 w-3 mr-1" />
{categoryLabel}
</Badge>
)}
</div>
{/* Description */}
{(item.summary || item.description) && !compact && (
<p className="text-sm text-gray-300 line-clamp-2 mb-3">
{item.summary || item.description}
</p>
)}
{/* Seller Info with enhanced trust indicators */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Avatar className="h-6 w-6 border border-orange-500/30">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-orange-500/20 text-orange-400 text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 text-xs text-gray-400">
<span className="font-medium text-gray-300">{displayName}</span>
<span className="mx-1">•</span>
<span>{timeAgo}</span>
</div>
{/* Trust indicators */}
<div className="flex items-center gap-1">
{isVerifiedSeller && (
<Badge variant="outline" className="text-xs border-green-500/50 text-green-400 bg-green-500/10 gap-1">
<CheckCircle className="h-3 w-3" />
Verified
</Badge>
)}
{isHighTrust && !isVerifiedSeller && (
<Badge variant="outline" className="text-xs border-blue-500/50 text-blue-400 bg-blue-500/10 gap-1">
<ShieldCheck className="h-3 w-3" />
Trusted
</Badge>
)}
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} />
</div>
</div>
{/* Seller stats (account age, listings) */}
{!compact && sellerInfo && (
<div className="flex items-center gap-3 text-xs text-gray-500 mb-2">
{accountAgeDays > 0 && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>
{accountAgeDays < 30
? `${accountAgeDays}d old`
: accountAgeDays < 365
? `${Math.floor(accountAgeDays / 30)}mo old`
: `${Math.floor(accountAgeDays / 365)}y old`}
</span>
</div>
)}
{previousListings > 1 && (
<div className="flex items-center gap-1">
<Package className="h-3 w-3" />
<span>{previousListings} listings</span>
</div>
)}
</div>
)}
{/* Availability */}
{item.availability && (
<div className="flex items-center gap-1 text-xs text-gray-400 mb-3">
<Clock className="h-3 w-3 text-orange-500" />
<span>Usually here {item.availability}</span>
</div>
)}
{/* Status Badge */}
{item.status && item.status !== 'active' && (
<Badge
variant="secondary"
className={`text-xs mb-2 ${
item.status === 'sold' ? 'bg-gray-700 text-gray-400' : 'bg-yellow-900/50 text-yellow-400'
}`}
>
{item.status.toUpperCase()}
</Badge>
)}
{/* Action Buttons */}
<div className="space-y-2">
{/* Pay with Cashu button */}
{user && !isOwnListing && canPayWithCashu && item.status === 'active' && !isWTB && (
<Button
onClick={() => setShowPaymentDialog(true)}
size="sm"
className="w-full gap-2 bg-orange-500 hover:bg-orange-600 text-white"
>
🥜 Pay {priceInSats.toLocaleString()} sats with Cashu
</Button>
)}
{/* Contact button */}
<Button
variant="outline"
size="sm"
className={`w-full gap-2 ${
isWTB
? 'border-blue-500/50 text-blue-400 hover:bg-blue-500/10'
: 'border-orange-500/50 text-orange-400 hover:bg-orange-500/10'
}`}
onClick={() => navigate(`/messages/${npub}`)}
>
<MessageCircle className="h-4 w-4" />
{isWTB ? 'I Have This!' : 'Contact Seller'}
</Button>
</div>
</div>
</div>
</CardContent>
{/* Payment Confirmation Dialog */}
<Dialog open={showPaymentDialog} onOpenChange={setShowPaymentDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Payment</DialogTitle>
<DialogDescription>Review the payment details before confirming</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Item details */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted">
{primaryImage && (
<img src={primaryImage} alt={item.title} className="w-16 h-16 rounded object-cover" />
)}
<div className="flex-1">
<h4 className="font-bold">{item.title}</h4>
<p className="text-sm text-muted-foreground">Sold by {displayName}</p>
</div>
</div>
{/* Payment details */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Price:</span>
<span className="font-bold">{priceDisplay}</span>
</div>
<div className="flex justify-between text-sm">
<span>Amount (sats):</span>
<span className="font-bold">{priceInSats.toLocaleString()} sats</span>
</div>
<div className="flex justify-between text-sm">
<span>Your Cashu Balance:</span>
<span className="font-bold">{cashuBalance.toLocaleString()} sats</span>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span>After Payment:</span>
<span>{(cashuBalance - priceInSats).toLocaleString()} sats</span>
</div>
</div>
<Card className="bg-yellow-950/30 border-yellow-500/30">
<CardContent className="p-3">
<p className="text-xs text-yellow-200">
⚠️ <strong>Peer-to-peer payments are coming soon!</strong> This feature will allow
you to send Cashu tokens directly to the seller. For now, please contact the seller
to arrange payment.
</p>
</CardContent>
</Card>
<div className="flex gap-2">
<Button onClick={() => setShowPaymentDialog(false)} variant="outline" className="flex-1">
Cancel
</Button>
<Button
onClick={handleCashuPayment}
disabled={isPaying}
className="flex-1 bg-orange-500 hover:bg-orange-600"
>
{isPaying ? 'Processing...' : 'Confirm Payment'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Report Dialog */}
<Dialog open={showReportDialog} onOpenChange={setShowReportDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-400" />
Report Listing
</DialogTitle>
<DialogDescription>
Help keep the marketplace safe by reporting inappropriate or misleading listings.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Reason for report *</Label>
<div className="grid grid-cols-1 gap-2">
{Object.entries(LISTING_REPORT_REASONS).map(([key, { label, description }]) => (
<Button
key={key}
type="button"
variant={reportReason === key ? 'default' : 'outline'}
className={`h-auto py-2 px-3 justify-start text-left ${
reportReason === key
? 'bg-red-500 hover:bg-red-600'
: 'border-gray-600 hover:bg-gray-800'
}`}
onClick={() => setReportReason(key)}
>
<div>
<div className="font-medium">{label}</div>
<div className="text-xs opacity-70">{description}</div>
</div>
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="report-details">Additional details (optional)</Label>
<Textarea
id="report-details"
placeholder="Provide any additional context that might help..."
value={reportDetails}
onChange={(e) => setReportDetails(e.target.value)}
rows={3}
className="bg-gray-800 border-gray-700"
/>
</div>
<Card className="bg-gray-800/50 border-gray-700">
<CardContent className="p-3">
<p className="text-xs text-gray-400">
Reports are published to Nostr and help the community identify problematic listings.
Listings with multiple reports may be automatically hidden.
</p>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowReportDialog(false)}>
Cancel
</Button>
<Button
onClick={handleReport}
disabled={isReporting || !reportReason}
className="bg-red-500 hover:bg-red-600"
>
{isReporting ? 'Submitting...' : 'Submit Report'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View file

@ -0,0 +1,792 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { LoginArea } from '@/components/auth/LoginArea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { toast } from '@/hooks/useToast';
import {
Database,
Download,
Trash2,
MapPin,
Calendar,
Store,
MessageSquare,
FileJson,
Clock,
ExternalLink,
AlertTriangle,
ShoppingBag,
GraduationCap,
FolderHeart,
Video,
Heart,
Zap,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
// Kind constants
const KINDS = {
POST: 1,
REACTION: 7,
ZAP_RECEIPT: 9735,
COMMENT: 1111,
SPOT: 30818,
EVENT: 31923,
SHOP: 30017,
LISTING: 30402,
COLLECTION: 30003,
COACH: 35001,
FILMER: 35002,
};
// Event kind labels and icons
const KIND_CONFIG: Record<number, { label: string; icon: typeof MapPin; color: string; category: string }> = {
[KINDS.POST]: { label: 'Post', icon: MessageSquare, color: 'text-blue-400', category: 'posts' },
[KINDS.REACTION]: { label: 'Reaction', icon: Heart, color: 'text-pink-400', category: 'reactions' },
[KINDS.COMMENT]: { label: 'Comment', icon: MessageSquare, color: 'text-gray-400', category: 'comments' },
[KINDS.SPOT]: { label: 'Spot', icon: MapPin, color: 'text-[#ccff00]', category: 'spots' },
[KINDS.EVENT]: { label: 'Event', icon: Calendar, color: 'text-[#ff0040]', category: 'events' },
[KINDS.SHOP]: { label: 'Shop', icon: Store, color: 'text-purple-400', category: 'shops' },
[KINDS.LISTING]: { label: 'Listing', icon: ShoppingBag, color: 'text-orange-400', category: 'listings' },
[KINDS.COLLECTION]: { label: 'Collection', icon: FolderHeart, color: 'text-pink-400', category: 'collections' },
[KINDS.COACH]: { label: 'Coach Profile', icon: GraduationCap, color: 'text-green-400', category: 'labProfiles' },
[KINDS.FILMER]: { label: 'Filmer Profile', icon: Video, color: 'text-cyan-400', category: 'labProfiles' },
};
interface DataStats {
spots: number;
events: number;
shops: number;
listings: number;
posts: number;
comments: number;
collections: number;
labProfiles: number;
reactions: number;
zapsReceived: number;
total: number;
}
function StatCard({
icon: Icon,
label,
count,
color,
isLoading,
subtitle,
}: {
icon: typeof MapPin;
label: string;
count: number;
color: string;
isLoading: boolean;
subtitle?: string;
}) {
return (
<div className="flex flex-col items-center justify-center p-2 sm:p-4 rounded-lg sm:rounded-xl bg-black/50 border border-gray-800 hover:border-gray-700 transition-colors min-w-0">
<Icon className={`h-4 w-4 sm:h-5 sm:w-5 mb-1 sm:mb-1.5 ${color}`} />
{isLoading ? (
<Skeleton className="h-5 sm:h-7 w-8 sm:w-10 mb-0.5" />
) : (
<span className="text-base sm:text-xl font-black text-white">{count}</span>
)}
<span className="text-[9px] sm:text-[10px] text-gray-400 uppercase tracking-wide text-center leading-tight">{label}</span>
{subtitle && <span className="text-[8px] sm:text-[9px] text-gray-500 leading-tight">{subtitle}</span>}
</div>
);
}
function EventRow({
event,
onDelete
}: {
event: NostrEvent;
onDelete: (event: NostrEvent) => void;
}) {
const config = KIND_CONFIG[event.kind] || { label: `Kind ${event.kind}`, icon: FileJson, color: 'text-gray-400', category: 'other' };
const Icon = config.icon;
// Get title/identifier for the event
const getEventTitle = () => {
// For reactions, show what was reacted to
if (event.kind === KINDS.REACTION) {
const emoji = event.content || '❤️';
return `${emoji} reaction`;
}
// Check for title tag
const titleTag = event.tags.find(([k]) => k === 'title')?.[1];
if (titleTag) return titleTag;
// Check for d tag (identifier)
const dTag = event.tags.find(([k]) => k === 'd')?.[1];
if (dTag) return dTag;
// For posts/comments, use truncated content
if (event.content) {
return event.content.length > 50 ? event.content.slice(0, 50) + '...' : event.content;
}
return `Event ${event.id.slice(0, 8)}...`;
};
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-black/30 border border-gray-800 hover:border-gray-700 transition-colors group">
<div className={`p-2 rounded-lg bg-gray-900 ${config.color}`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{getEventTitle()}</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={config.color}>{config.label}</span>
<span>•</span>
<Clock className="h-3 w-3" />
<span>{formatDistanceToNow(event.created_at * 1000, { addSuffix: true })}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(event)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
// Helper to get event title
function getEventTitle(event: NostrEvent): string {
// For reactions, show what was reacted to
if (event.kind === KINDS.REACTION) {
const emoji = event.content || '❤️';
return `${emoji} reaction`;
}
// Check for title tag
const titleTag = event.tags.find(([k]) => k === 'title')?.[1];
if (titleTag) return titleTag;
// Check for name tag (for shops)
const nameTag = event.tags.find(([k]) => k === 'name')?.[1];
if (nameTag) return nameTag;
// Check for d tag (identifier)
const dTag = event.tags.find(([k]) => k === 'd')?.[1];
if (dTag && dTag.length < 40) return dTag;
// For posts/comments, use truncated content
if (event.content) {
const cleaned = event.content.replace(/\n/g, ' ').trim();
return cleaned.length > 40 ? cleaned.slice(0, 40) + '...' : cleaned;
}
return `Event ${event.id.slice(0, 8)}...`;
}
function SelectiveDeletionDialog({
userEvents,
onDeleteSelected,
isDeleting,
}: {
userEvents: NostrEvent[];
onDeleteSelected: (events: NostrEvent[]) => Promise<void>;
isDeleting: boolean;
}) {
const [open, setOpen] = useState(false);
const [selectedEvents, setSelectedEvents] = useState<Set<string>>(new Set());
const [filterKind, setFilterKind] = useState<number | 'all'>('all');
// Group events by kind for the filter tabs
const eventsByKind = userEvents.reduce((acc, event) => {
if (!acc[event.kind]) acc[event.kind] = [];
acc[event.kind].push(event);
return acc;
}, {} as Record<number, NostrEvent[]>);
// Get filtered events
const filteredEvents = filterKind === 'all'
? userEvents
: userEvents.filter(e => e.kind === filterKind);
const toggleEvent = (eventId: string) => {
const newSelected = new Set(selectedEvents);
if (newSelected.has(eventId)) {
newSelected.delete(eventId);
} else {
newSelected.add(eventId);
}
setSelectedEvents(newSelected);
};
const selectAllVisible = () => {
const newSelected = new Set(selectedEvents);
for (const event of filteredEvents) {
newSelected.add(event.id);
}
setSelectedEvents(newSelected);
};
const selectNone = () => {
if (filterKind === 'all') {
setSelectedEvents(new Set());
} else {
// Only deselect visible items
const newSelected = new Set(selectedEvents);
for (const event of filteredEvents) {
newSelected.delete(event.id);
}
setSelectedEvents(newSelected);
}
};
const handleDelete = async () => {
const eventsToDelete = userEvents.filter(e => selectedEvents.has(e.id));
await onDeleteSelected(eventsToDelete);
setOpen(false);
setSelectedEvents(new Set());
};
const selectedCount = selectedEvents.size;
// Available filter tabs
const filterTabs: { kind: number | 'all'; label: string; icon: typeof MapPin; color: string }[] = [
{ kind: 'all', label: 'All', icon: Database, color: 'text-white' },
...(eventsByKind[KINDS.SPOT]?.length ? [{ kind: KINDS.SPOT, label: 'Spots', icon: MapPin, color: 'text-[#ccff00]' }] : []),
...(eventsByKind[KINDS.EVENT]?.length ? [{ kind: KINDS.EVENT, label: 'Events', icon: Calendar, color: 'text-[#ff0040]' }] : []),
...(eventsByKind[KINDS.SHOP]?.length ? [{ kind: KINDS.SHOP, label: 'Shops', icon: Store, color: 'text-purple-400' }] : []),
...(eventsByKind[KINDS.LISTING]?.length ? [{ kind: KINDS.LISTING, label: 'Listings', icon: ShoppingBag, color: 'text-orange-400' }] : []),
...(eventsByKind[KINDS.COLLECTION]?.length ? [{ kind: KINDS.COLLECTION, label: 'Collections', icon: FolderHeart, color: 'text-pink-400' }] : []),
...((eventsByKind[KINDS.COACH]?.length || eventsByKind[KINDS.FILMER]?.length) ? [{ kind: KINDS.COACH, label: 'Lab', icon: GraduationCap, color: 'text-green-400' }] : []),
...((eventsByKind[KINDS.POST]?.length || eventsByKind[KINDS.COMMENT]?.length) ? [{ kind: KINDS.POST, label: 'Posts', icon: MessageSquare, color: 'text-blue-400' }] : []),
...(eventsByKind[KINDS.REACTION]?.length ? [{ kind: KINDS.REACTION, label: 'Reactions', icon: Heart, color: 'text-pink-400' }] : []),
];
// Handle special filter cases (Lab = Coach + Filmer, Posts = Post + Comment)
const getFilteredEventsForTab = () => {
if (filterKind === 'all') return userEvents;
if (filterKind === KINDS.COACH) return userEvents.filter(e => e.kind === KINDS.COACH || e.kind === KINDS.FILMER);
if (filterKind === KINDS.POST) return userEvents.filter(e => e.kind === KINDS.POST || e.kind === KINDS.COMMENT);
return userEvents.filter(e => e.kind === filterKind);
};
const displayEvents = getFilteredEventsForTab();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
disabled={!userEvents?.length || isDeleting}
className="flex-1 border-red-500/50 text-red-400 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting ? 'Deleting...' : 'Request Deletion'}
</Button>
</DialogTrigger>
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-white">
<Trash2 className="h-5 w-5 text-red-400" />
Select Content to Delete
</DialogTitle>
<DialogDescription className="text-gray-400">
Select the specific items you want to request deletion for.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col space-y-3 py-2">
{/* Filter tabs */}
<div className="flex gap-1 overflow-x-auto pb-2 flex-shrink-0">
{filterTabs.map((tab) => {
const Icon = tab.icon;
const isActive = filterKind === tab.kind;
const count = tab.kind === 'all'
? userEvents.length
: tab.kind === KINDS.COACH
? (eventsByKind[KINDS.COACH]?.length || 0) + (eventsByKind[KINDS.FILMER]?.length || 0)
: tab.kind === KINDS.POST
? (eventsByKind[KINDS.POST]?.length || 0) + (eventsByKind[KINDS.COMMENT]?.length || 0)
: eventsByKind[tab.kind]?.length || 0;
return (
<button
key={String(tab.kind)}
onClick={() => setFilterKind(tab.kind)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
isActive
? 'bg-gray-700 text-white'
: 'bg-gray-800/50 text-gray-400 hover:bg-gray-800'
}`}
>
<Icon className={`h-3 w-3 ${isActive ? tab.color : ''}`} />
{tab.label}
<span className={`${isActive ? 'text-gray-300' : 'text-gray-500'}`}>({count})</span>
</button>
);
})}
</div>
{/* Quick actions */}
<div className="flex items-center justify-between flex-shrink-0">
<div className="flex gap-2 text-xs">
<button onClick={selectAllVisible} className="text-[#ccff00] hover:underline">
Select All {filterKind !== 'all' ? 'Visible' : ''}
</button>
<span className="text-gray-600">|</span>
<button onClick={selectNone} className="text-gray-400 hover:underline">Clear</button>
</div>
<span className="text-xs text-gray-500">
{selectedCount} selected
</span>
</div>
{/* Event list */}
<div className="flex-1 overflow-y-auto space-y-1.5 pr-1 min-h-0">
{displayEvents.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No items in this category
</div>
) : (
displayEvents.map((event) => {
const config = KIND_CONFIG[event.kind] || { label: `Kind ${event.kind}`, icon: FileJson, color: 'text-gray-400', category: 'other' };
const Icon = config.icon;
const isChecked = selectedEvents.has(event.id);
return (
<label
key={event.id}
className={`flex items-center gap-2 p-2.5 rounded-lg border transition-colors cursor-pointer ${
isChecked
? 'bg-red-950/30 border-red-500/50'
: 'bg-black/30 border-gray-800 hover:border-gray-700'
}`}
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleEvent(event.id)}
className="border-gray-600 data-[state=checked]:bg-red-500 data-[state=checked]:border-red-500 flex-shrink-0"
/>
<div className={`p-1.5 rounded ${config.color} bg-gray-800 flex-shrink-0`}>
<Icon className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{getEventTitle(event)}</p>
<p className="text-[10px] text-gray-500">
{config.label} • {formatDistanceToNow(event.created_at * 1000, { addSuffix: true })}
</p>
</div>
</label>
);
})
)}
</div>
{/* Warning */}
<div className="p-2.5 rounded-lg bg-yellow-950/50 border border-yellow-500/30 flex-shrink-0">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0 mt-0.5" />
<p className="text-yellow-200 text-xs">
<strong>Important:</strong> Nostr is decentralized. Relays <em>should</em> honor deletion requests,
but some may not. This is not a guaranteed permanent deletion.
</p>
</div>
</div>
</div>
<DialogFooter className="flex-shrink-0 flex-col sm:flex-row gap-2 pt-2 border-t border-gray-800">
<Button
variant="ghost"
onClick={() => setOpen(false)}
className="border-gray-700"
>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={selectedCount === 0 || isDeleting}
className="bg-red-500 hover:bg-red-600 text-white"
>
{isDeleting ? (
'Deleting...'
) : (
<>
Delete {selectedCount} {selectedCount === 1 ? 'Item' : 'Items'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function MyDataTab() {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const [isDeleting, setIsDeleting] = useState(false);
const [showAllActivity, setShowAllActivity] = useState(false);
// Fetch all user events
const { data: userEvents, isLoading, refetch } = useQuery({
queryKey: ['my-data', user?.pubkey],
queryFn: async ({ signal }) => {
if (!user?.pubkey) return [];
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(15000)]);
// Query for all event kinds the user might have published
const events = await nostr.query([
{
authors: [user.pubkey],
kinds: [
KINDS.POST,
KINDS.REACTION,
KINDS.COMMENT,
KINDS.SPOT,
KINDS.EVENT,
KINDS.SHOP,
KINDS.LISTING,
KINDS.COLLECTION,
KINDS.COACH,
KINDS.FILMER
],
limit: 500
}
], { signal: querySignal });
// Sort by created_at descending
return events.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!user?.pubkey,
staleTime: 60000,
});
// Fetch zaps received (where user is the recipient)
const { data: zapsReceived } = useQuery({
queryKey: ['my-zaps-received', user?.pubkey],
queryFn: async ({ signal }) => {
if (!user?.pubkey) return [];
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
// Query for zap receipts where user is tagged
const events = await nostr.query([
{
kinds: [KINDS.ZAP_RECEIPT],
'#p': [user.pubkey],
limit: 100
}
], { signal: querySignal });
return events;
},
enabled: !!user?.pubkey,
staleTime: 60000,
});
// Calculate stats
const stats: DataStats = {
spots: userEvents?.filter(e => e.kind === KINDS.SPOT).length || 0,
events: userEvents?.filter(e => e.kind === KINDS.EVENT).length || 0,
shops: userEvents?.filter(e => e.kind === KINDS.SHOP).length || 0,
listings: userEvents?.filter(e => e.kind === KINDS.LISTING).length || 0,
posts: userEvents?.filter(e => e.kind === KINDS.POST).length || 0,
comments: userEvents?.filter(e => e.kind === KINDS.COMMENT).length || 0,
collections: userEvents?.filter(e => e.kind === KINDS.COLLECTION).length || 0,
labProfiles: userEvents?.filter(e => e.kind === KINDS.COACH || e.kind === KINDS.FILMER).length || 0,
reactions: userEvents?.filter(e => e.kind === KINDS.REACTION).length || 0,
zapsReceived: zapsReceived?.length || 0,
total: userEvents?.length || 0,
};
// Export all data as JSON
const handleExportData = () => {
if (!userEvents || userEvents.length === 0) {
toast({
title: 'No data to export',
description: 'You haven\'t published any events yet.',
variant: 'destructive',
});
return;
}
const exportData = {
exportedAt: new Date().toISOString(),
pubkey: user?.pubkey,
stats,
eventCount: userEvents.length,
zapsReceivedCount: zapsReceived?.length || 0,
events: userEvents,
zapsReceived: zapsReceived || [],
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `skatespots-data-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: 'Data exported!',
description: `Downloaded ${userEvents.length} events as JSON.`,
});
};
// Delete a single event
const handleDeleteEvent = async (event: NostrEvent) => {
try {
const isAddressable = event.kind >= 30000 && event.kind < 40000;
const tags: string[][] = [['k', String(event.kind)]];
if (isAddressable) {
const dTag = event.tags.find(([k]) => k === 'd')?.[1] || '';
tags.push(['a', `${event.kind}:${event.pubkey}:${dTag}`]);
} else {
tags.push(['e', event.id]);
}
await publishEvent({
kind: 5,
content: 'Deletion requested via SkateSpots',
tags,
});
toast({
title: 'Deletion requested',
description: 'The deletion request has been published to relays.',
});
refetch();
} catch (error) {
console.error('Failed to delete event:', error);
toast({
title: 'Deletion failed',
description: 'Could not publish deletion request. Please try again.',
variant: 'destructive',
});
}
};
// Delete selected events
const handleDeleteSelected = async (eventsToDelete: NostrEvent[]) => {
if (!eventsToDelete || eventsToDelete.length === 0) return;
setIsDeleting(true);
try {
const deletionTags: string[][] = [];
const kindsToDelete = new Set<number>();
for (const event of eventsToDelete) {
kindsToDelete.add(event.kind);
const isAddressable = event.kind >= 30000 && event.kind < 40000;
if (isAddressable) {
const dTag = event.tags.find(([k]) => k === 'd')?.[1] || '';
deletionTags.push(['a', `${event.kind}:${event.pubkey}:${dTag}`]);
} else {
deletionTags.push(['e', event.id]);
}
}
for (const kind of kindsToDelete) {
deletionTags.push(['k', String(kind)]);
}
await publishEvent({
kind: 5,
content: 'Selective deletion requested via SkateSpots - user exercising right to delete their data',
tags: deletionTags,
});
toast({
title: 'Deletion requested',
description: `Requested deletion of ${eventsToDelete.length} items. Note: Some relays may not honor this request.`,
});
queryClient.invalidateQueries({ queryKey: ['my-data'] });
refetch();
} catch (error) {
console.error('Failed to delete events:', error);
toast({
title: 'Deletion failed',
description: 'Could not publish deletion request. Please try again.',
variant: 'destructive',
});
} finally {
setIsDeleting(false);
}
};
if (!user) {
return (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Login Required</h3>
<p className="text-muted-foreground mb-6">
Log in to view and manage your data.
</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
);
}
const displayedEvents = showAllActivity ? userEvents : userEvents?.slice(0, 10);
return (
<div className="space-y-6">
{/* Header Card */}
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/5 border-2 border-[#ccff00]/30">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-white">
<Database className="h-5 w-5 text-[#ccff00]" />
My Data
</CardTitle>
<CardDescription className="text-gray-300">
Your content on the Nostr network. You own this data - export it anytime or request deletion.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 sm:space-y-6">
{/* Stats Grid - responsive 2x4 on mobile, 4x2 on desktop */}
<div className="space-y-2 sm:space-y-3">
{/* Row 1: Main content */}
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
<StatCard icon={MapPin} label="Spots" count={stats.spots} color="text-[#ccff00]" isLoading={isLoading} />
<StatCard icon={Calendar} label="Events" count={stats.events} color="text-[#ff0040]" isLoading={isLoading} />
<StatCard icon={Store} label="Shops" count={stats.shops} color="text-purple-400" isLoading={isLoading} />
<StatCard icon={ShoppingBag} label="Listings" count={stats.listings} color="text-orange-400" isLoading={isLoading} />
</div>
{/* Row 2: Social & Lab */}
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
<StatCard icon={MessageSquare} label="Posts" count={stats.posts + stats.comments} color="text-blue-400" isLoading={isLoading} />
<StatCard icon={GraduationCap} label="Lab" count={stats.labProfiles} color="text-green-400" isLoading={isLoading} subtitle="Coach/Filmer" />
<StatCard icon={Heart} label="Reactions" count={stats.reactions} color="text-pink-400" isLoading={isLoading} />
<StatCard icon={Zap} label="Zaps" count={stats.zapsReceived} color="text-yellow-400" isLoading={isLoading} subtitle="Received" />
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={handleExportData}
disabled={isLoading || !userEvents?.length}
className="flex-1 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
>
<Download className="h-4 w-4 mr-2" />
Export All Data
</Button>
<SelectiveDeletionDialog
userEvents={userEvents || []}
onDeleteSelected={handleDeleteSelected}
isDeleting={isDeleting}
/>
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<Card className="bg-black/50 border-gray-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg text-white">Recent Activity</CardTitle>
{userEvents && userEvents.length > 10 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllActivity(!showAllActivity)}
className="text-[#ccff00] hover:text-[#ccff00]/80"
>
{showAllActivity ? 'Show Less' : `View All (${userEvents.length})`}
<ExternalLink className="h-3 w-3 ml-1" />
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : !userEvents?.length ? (
<div className="text-center py-8">
<Database className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-gray-400">No data published yet</p>
<p className="text-gray-500 text-sm mt-1">
Start by <Link to="/create-spot" className="text-[#ccff00] hover:underline">adding a spot</Link>!
</p>
</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2">
{displayedEvents?.map((event) => (
<EventRow key={event.id} event={event} onDelete={handleDeleteEvent} />
))}
</div>
)}
</CardContent>
</Card>
{/* Data Ownership Info */}
<Card className="bg-blue-950/30 border-blue-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-blue-400 text-base">About Your Data</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-gray-300">
<p>
<strong className="text-white">You own your data.</strong> All content is signed with your cryptographic keys
and stored on Nostr relays. No company controls it.
</p>
<ul className="space-y-2 ml-4">
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Export anytime</strong> - Download all your events as JSON</span>
</li>
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Portable</strong> - Use your keys in any Nostr app</span>
</li>
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Selective deletion</strong> - Choose exactly what to delete</span>
</li>
<li className="flex items-start gap-2">
<span className="text-yellow-400">⚠</span>
<span><strong>Decentralized caveat</strong> - Some relays may retain data despite deletion requests</span>
</li>
</ul>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,113 @@
import React, { useEffect, useRef } from 'react';
import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify';
import { NostrContext } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { SKATE_RELAY } from '@/lib/types';
interface NostrProviderProps {
children: React.ReactNode;
}
// Default fallback relays - used if no relays are configured
const DEFAULT_RELAYS = [
{ url: 'wss://relay.ditto.pub', read: true, write: true },
{ url: SKATE_RELAY, read: true, write: true },
{ url: 'wss://wotr.relatr.xyz', read: true, write: true },
{ url: 'wss://purplepag.es', read: true, write: false }, // Profile aggregator
];
const NostrProvider: React.FC<NostrProviderProps> = (props) => {
const { children } = props;
const { config } = useAppContext();
const queryClient = useQueryClient();
// Create NPool instance only once
const pool = useRef<NPool | undefined>(undefined);
// Use refs so the pool always has the latest data
const relayMetadata = useRef(config.relayMetadata);
const useSkateRelay = useRef(config.useSkateRelay ?? true);
// Invalidate Nostr queries when relay metadata changes
useEffect(() => {
relayMetadata.current = config.relayMetadata;
useSkateRelay.current = config.useSkateRelay ?? true;
queryClient.invalidateQueries({ queryKey: ['nostr'] });
}, [config.relayMetadata, config.useSkateRelay, queryClient]);
// Initialize NPool only once
if (!pool.current) {
pool.current = new NPool({
open(url: string) {
return new NRelay1(url);
},
reqRouter(filters: NostrFilter[]) {
const routes = new Map<string, NostrFilter[]>();
// Get relays from metadata, fallback to defaults if empty
const configuredRelays = relayMetadata.current.relays.length > 0
? relayMetadata.current.relays
: DEFAULT_RELAYS;
// Route to all read relays
const readRelays = configuredRelays
.filter(r => r.read)
.map(r => r.url);
// Ensure we always have at least one relay
if (readRelays.length === 0) {
console.warn('[NostrProvider] No read relays configured, using defaults');
for (const relay of DEFAULT_RELAYS) {
routes.set(relay.url, filters);
}
} else {
console.log('[NostrProvider] Routing query to read relays:', readRelays);
for (const url of readRelays) {
routes.set(url, filters);
}
}
return routes;
},
eventRouter(event: NostrEvent) {
// Get relays from metadata, fallback to defaults if empty
const configuredRelays = relayMetadata.current.relays.length > 0
? relayMetadata.current.relays
: DEFAULT_RELAYS;
// Get write relays from metadata
const writeRelays = configuredRelays
.filter(r => r.write)
.map(r => r.url);
const allRelays = new Set<string>(writeRelays);
// Include community relay if setting is enabled (default: true)
// This ensures skate content is published to the community relay for discoverability
if (useSkateRelay.current) {
allRelays.add(SKATE_RELAY);
}
// Ensure we always have at least one relay
if (allRelays.size === 0) {
console.warn('No write relays configured, using defaults');
DEFAULT_RELAYS.forEach(r => allRelays.add(r.url));
}
console.log(`[NostrProvider] Publishing kind ${event.kind} to relays:`, [...allRelays]);
return [...allRelays];
},
});
}
return (
<NostrContext.Provider value={{ nostr: pool.current }}>
{children}
</NostrContext.Provider>
);
};
export default NostrProvider;

View file

@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useQueryClient } from '@tanstack/react-query';
import { NSchema as n } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
// Default relays for new users
const NEW_USER_RELAYS = [
{ url: 'wss://relay.ditto.pub', read: true, write: true },
{ url: 'wss://goskatespots.nostr1.com', read: true, write: true },
{ url: 'wss://wotr.relatr.xyz', read: true, write: true },
{ url: 'wss://purplepag.es', read: true, write: false }, // Profile aggregator
];
// Fallback relays for fetching profile data (aggregators that index many profiles)
const FALLBACK_RELAYS = [
'wss://purplepag.es',
'wss://relay.nostr.band',
'wss://relay.damus.io',
'wss://nos.lol',
];
/**
* NostrSync - Syncs user's Nostr data on login
*
* This component runs globally to sync various Nostr data when the user logs in.
* It fetches data and populates TanStack Query cache so other hooks don't need to refetch.
*
* Currently syncs:
* - NIP-65 relay list (kind 10002)
* - Profile metadata (kind 0) - cached for useAuthor hook
*
* New users (without a published relay list) get default relays instead of
* inheriting from previous browser sessions.
*/
export function NostrSync() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { config, updateConfig } = useAppContext();
const { toast } = useToast();
const queryClient = useQueryClient();
const syncedUsers = useRef<Set<string>>(new Set());
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
useEffect(() => {
if (!user) return;
// Only sync once per user per session
if (syncedUsers.current.has(user.pubkey)) return;
const syncWithRetry = async <T,>(
queryFn: () => Promise<T>,
maxRetries: number = 5,
initialDelayMs: number = 1000
): Promise<T | null> => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await queryFn();
} catch (error) {
console.warn(`[NostrSync] Attempt ${attempt}/${maxRetries} failed:`, error);
if (attempt < maxRetries) {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const delay = initialDelayMs * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
return null;
};
const syncUserData = async () => {
setSyncStatus('syncing');
console.log('[NostrSync] Starting sync for user:', user.pubkey.slice(0, 8) + '...');
// FIX B: Invalidate any stale cached data for this user BEFORE fetching
// This ensures we don't use old cached empty results
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
// Also invalidate the logins cache so useLoggedInAccounts refetches
const loginsKey = logins.map((l) => l.id).join(';');
queryClient.invalidateQueries({ queryKey: ['nostr', 'logins', loginsKey] });
let relaysSynced = false;
let profileSynced = false;
// Sync relay list (NIP-65)
const syncRelays = async (): Promise<boolean> => {
const events = await nostr.query(
[{ kinds: [10002], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(10000) }
);
if (events.length > 0) {
const event = events[0];
// Only update if the event is newer than our stored data
if (event.created_at > config.relayMetadata.updatedAt) {
const fetchedRelays = event.tags
.filter(([name]) => name === 'r')
.map(([, url, marker]) => ({
url,
read: !marker || marker === 'read',
write: !marker || marker === 'write',
}));
if (fetchedRelays.length > 0) {
console.log('[NostrSync] Synced relay list from NIP-65:', fetchedRelays.length, 'relays');
updateConfig((current) => ({
...current,
relayMetadata: {
relays: fetchedRelays,
updatedAt: event.created_at,
},
}));
return true;
}
} else {
return true; // Already have latest
}
} else {
// No NIP-65 relay list found - this is a new user!
console.log('[NostrSync] New user - setting default relays');
updateConfig((current) => ({
...current,
relayMetadata: {
relays: NEW_USER_RELAYS,
updatedAt: 0,
},
}));
return true;
}
return false;
};
// Sync profile (kind 0) and populate useAuthor cache
const syncProfile = async (relayGroup?: ReturnType<typeof nostr.group>): Promise<boolean> => {
const querySource = relayGroup || nostr;
console.log('[NostrSync] Querying profile for pubkey:', user.pubkey.slice(0, 8) + '...');
const events = await querySource.query(
[{ kinds: [0], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(10000) }
);
console.log('[NostrSync] Profile query returned', events.length, 'events');
if (events.length > 0) {
const event = events[0];
// FIX A: Parse metadata and populate useAuthor's TanStack Query cache
try {
const metadata = n.json().pipe(n.metadata()).parse(event.content);
// Set the cache data that useAuthor expects
queryClient.setQueryData(['author', user.pubkey], {
metadata,
event
});
// FIX A+: Also update useLoggedInAccounts cache so profile shows everywhere
const loginsKey = logins.map((l) => l.id).join(';');
queryClient.setQueryData(['nostr', 'logins', loginsKey], (oldData: Array<{ id: string; pubkey: string; metadata: typeof metadata; event?: typeof event }> | undefined) => {
if (!oldData) {
// Create initial data from logins
return logins.map((login) => {
if (login.pubkey === user.pubkey) {
return { id: login.id, pubkey: login.pubkey, metadata, event };
}
return { id: login.id, pubkey: login.pubkey, metadata: {} };
});
}
// Update existing data
return oldData.map((account) => {
if (account.pubkey === user.pubkey) {
return { ...account, metadata, event };
}
return account;
});
});
console.log('[NostrSync] Profile synced and cached:', metadata.name || 'unnamed');
return true;
} catch (parseError) {
// Event exists but content couldn't be parsed - still cache the event
queryClient.setQueryData(['author', user.pubkey], { event });
console.warn('[NostrSync] Profile event found but metadata parse failed');
return true;
}
}
return false;
};
// Try main relays first with retry
const mainResult = await syncWithRetry(async () => {
const [relayResult, profileResult] = await Promise.all([
syncRelays(),
syncProfile()
]);
return { relays: relayResult, profile: profileResult };
}, 3, 1500);
if (mainResult) {
relaysSynced = mainResult.relays;
profileSynced = mainResult.profile;
}
// If profile not found on main relays, try fallback relays
if (!profileSynced) {
console.log('[NostrSync] Profile not found on main relays, trying fallbacks...');
const fallbackResult = await syncWithRetry(async () => {
const fallbackGroup = nostr.group(FALLBACK_RELAYS);
return await syncProfile(fallbackGroup);
}, 3, 2000);
if (fallbackResult) {
profileSynced = true;
}
}
// Mark sync complete for this user
syncedUsers.current.add(user.pubkey);
if (relaysSynced || profileSynced) {
setSyncStatus('success');
console.log('[NostrSync] Sync completed - relays:', relaysSynced, 'profile:', profileSynced);
} else {
setSyncStatus('error');
console.error('[NostrSync] Sync failed - could not reach relays');
toast({
title: 'Connection Issue',
description: 'Could not sync your profile. Check your internet connection.',
variant: 'destructive',
});
}
};
// FIX C: Longer initial delay to allow relay connections to establish
// This helps with external signers like Amber where PWA context may need time
// to re-establish WebSocket connections after returning from the signer app
const timeoutId = setTimeout(() => {
syncUserData();
}, 800);
return () => clearTimeout(timeoutId);
}, [user, config.relayMetadata.updatedAt, nostr, updateConfig, toast, queryClient, logins]);
return null;
}

View file

@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TestApp } from '@/test/TestApp';
import { NoteContent } from './NoteContent';
import type { NostrEvent } from '@nostrify/nostrify';
describe('NoteContent', () => {
it('linkifies URLs in kind 1 events', () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Check out this link: https://example.com',
sig: 'test-sig',
};
render(
<TestApp>
<NoteContent event={event} />
</TestApp>
);
const link = screen.getByRole('link', { name: 'https://example.com' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('target', '_blank');
});
it('linkifies URLs in kind 1111 events (comments)', () => {
const event: NostrEvent = {
id: 'test-comment-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1111,
tags: [
['a', '30040:pubkey:identifier'],
['k', '30040'],
['p', 'pubkey'],
],
content: 'I think the log events should be different kind numbers instead of having a `log-type` tag. That way you can use normal Nostr filters to filter the log types. Also, the `note` type should just b a kind 1111: https://nostrbook.dev/kinds/1111',
sig: 'test-sig',
};
render(
<TestApp>
<NoteContent event={event} />
</TestApp>
);
const link = screen.getByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://nostrbook.dev/kinds/1111');
expect(link).toHaveAttribute('target', '_blank');
});
it('handles text without URLs correctly', () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1111,
tags: [],
content: 'This is just plain text without any links.',
sig: 'test-sig',
};
render(
<TestApp>
<NoteContent event={event} />
</TestApp>
);
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders hashtags as links', () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'This is a post about #nostr and #bitcoin development.',
sig: 'test-sig',
};
render(
<TestApp>
<NoteContent event={event} />
</TestApp>
);
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' });
expect(nostrHashtag).toBeInTheDocument();
expect(bitcoinHashtag).toBeInTheDocument();
expect(nostrHashtag).toHaveAttribute('href', '/t/nostr');
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
});
it('generates deterministic names for users without metadata and styles them differently', () => {
// Use a valid npub for testing
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: `Mentioning nostr:npub1zg69v7ys40x77y352eufp27daufrg4ncjz4ummcjx3t83y9tehhsqepuh0`,
sig: 'test-sig',
};
render(
<TestApp>
<NoteContent event={event} />
</TestApp>
);
// The mention should be rendered with a deterministic name
const mention = screen.getByRole('link');
expect(mention).toBeInTheDocument();
// Should have muted styling for generated names (gray instead of blue)
expect(mention).toHaveClass('text-gray-500');
expect(mention).not.toHaveClass('text-blue-500');
// The text should start with @ and contain a generated name (not a truncated npub)
const linkText = mention.textContent;
expect(linkText).not.toMatch(/^@npub1/); // Should not be a truncated npub
expect(linkText).toEqual("@Swift Falcon");
});
});

View file

@ -0,0 +1,137 @@
import { useMemo } from 'react';
import { type NostrEvent } from '@nostrify/nostrify';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
interface NoteContentProps {
event: NostrEvent;
className?: string;
}
/** Parses content of text note events so that URLs and hashtags are linkified. */
export function NoteContent({
event,
className,
}: NoteContentProps) {
// Process the content to render mentions, links, etc.
const content = useMemo(() => {
const text = event.content;
// Regex to find URLs, Nostr references, and hashtags
const regex = /(https?:\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#\w+)/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let keyCounter = 0;
while ((match = regex.exec(text)) !== null) {
const [fullMatch, url, nostrPrefix, nostrData, hashtag] = match;
const index = match.index;
// Add text before this match
if (index > lastIndex) {
parts.push(text.substring(lastIndex, index));
}
if (url) {
// Handle URLs
parts.push(
<a
key={`url-${keyCounter++}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{url}
</a>
);
} else if (nostrPrefix && nostrData) {
// Handle Nostr references
try {
const nostrId = `${nostrPrefix}${nostrData}`;
const decoded = nip19.decode(nostrId);
if (decoded.type === 'npub') {
const pubkey = decoded.data;
parts.push(
<NostrMention key={`mention-${keyCounter++}`} pubkey={pubkey} />
);
} else {
// For other types, just show as a link
parts.push(
<Link
key={`nostr-${keyCounter++}`}
to={`/${nostrId}`}
className="text-blue-500 hover:underline"
>
{fullMatch}
</Link>
);
}
} catch {
// If decoding fails, just render as text
parts.push(fullMatch);
}
} else if (hashtag) {
// Handle hashtags
const tag = hashtag.slice(1); // Remove the #
parts.push(
<Link
key={`hashtag-${keyCounter++}`}
to={`/t/${tag}`}
className="text-blue-500 hover:underline"
>
{hashtag}
</Link>
);
}
lastIndex = index + fullMatch.length;
}
// Add any remaining text
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
// If no special content was found, just use the plain text
if (parts.length === 0) {
parts.push(text);
}
return parts;
}, [event]);
return (
<div className={cn("whitespace-pre-wrap break-words", className)}>
{content.length > 0 ? content : event.content}
</div>
);
}
// Helper component to display user mentions
function NostrMention({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const npub = nip19.npubEncode(pubkey);
const hasRealName = !!author.data?.metadata?.name;
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
return (
<Link
to={`/${npub}`}
className={cn(
"font-medium hover:underline",
hasRealName
? "text-blue-500"
: "text-gray-500 hover:text-gray-700"
)}
>
@{displayName}
</Link>
);
}

View file

@ -0,0 +1,115 @@
import { useOnlineStatus, usePendingPostsCount, useSyncPendingPosts } from '@/hooks/useOfflineCache';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Wifi, WifiOff, Cloud, CloudOff, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface OfflineIndicatorProps {
className?: string;
showWhenOnline?: boolean;
}
export function OfflineIndicator({ className, showWhenOnline = false }: OfflineIndicatorProps) {
const isOnline = useOnlineStatus();
const { data: pendingCount } = usePendingPostsCount();
const { isSyncing } = useSyncPendingPosts();
// Don't show when online (unless explicitly requested or there are pending posts)
if (isOnline && !showWhenOnline && !pendingCount && !isSyncing) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'gap-2 h-8 px-3 font-medium',
isOnline
? 'text-green-400 hover:text-green-300 hover:bg-green-500/10'
: 'text-orange-400 hover:text-orange-300 hover:bg-orange-500/10 animate-pulse',
className
)}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isOnline ? (
<Wifi className="h-4 w-4" />
) : (
<WifiOff className="h-4 w-4" />
)}
{!isOnline && <span className="text-xs">Offline</span>}
{pendingCount !== undefined && pendingCount > 0 && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs bg-orange-500/20 text-orange-400">
{pendingCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 bg-black border-gray-700">
<div className="space-y-4">
{/* Connection Status */}
<div className="flex items-center gap-3">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
isOnline ? 'bg-green-500/20' : 'bg-orange-500/20'
)}>
{isOnline ? (
<Cloud className="h-5 w-5 text-green-400" />
) : (
<CloudOff className="h-5 w-5 text-orange-400" />
)}
</div>
<div>
<p className="font-bold text-white">
{isOnline ? 'Connected' : 'Offline Mode'}
</p>
<p className="text-xs text-gray-400">
{isOnline
? 'All features available'
: 'Browsing cached spots'}
</p>
</div>
</div>
{/* Pending Posts */}
{pendingCount !== undefined && pendingCount > 0 && (
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
<div className="flex items-center justify-between">
<span className="text-sm text-orange-400 font-medium">
{pendingCount} pending post{pendingCount > 1 ? 's' : ''}
</span>
{isSyncing && (
<Badge className="bg-orange-500 text-white text-xs">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
Syncing
</Badge>
)}
</div>
<p className="text-xs text-gray-400 mt-1">
{isOnline
? 'Publishing when connection stabilizes...'
: 'Will be published when back online'}
</p>
</div>
)}
{/* Offline Features Info */}
{!isOnline && (
<div className="text-xs text-gray-400 space-y-1">
<p className="font-medium text-gray-300">Available offline:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Browse previously viewed spots</li>
<li>View cached spot details</li>
<li>Queue posts for later</li>
</ul>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,351 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useTranslation } from '@/contexts/LanguageContext';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Download, X, Share, Plus, Smartphone } from 'lucide-react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
// Detect iOS
const isIOS = () => {
if (typeof navigator === 'undefined') return false;
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
};
// Detect if running in standalone mode (PWA)
const isStandalone = () => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(display-mode: standalone)').matches ||
// iOS Safari specific check
('standalone' in window.navigator && (window.navigator as Navigator & { standalone: boolean }).standalone);
};
// Detect mobile/tablet
const isMobileOrTablet = () => {
if (typeof navigator === 'undefined') return false;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPad
};
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
const [showManualPrompt, setShowManualPrompt] = useState(false);
const [dismissed, setDismissed] = useLocalStorage('pwa-install-dismissed', false);
const [installed, setInstalled] = useLocalStorage('pwa-installed', false);
const [isIOSDevice] = useState(isIOS());
const hasPromptFiredRef = useRef(false);
const { t } = useTranslation();
// Handler for beforeinstallprompt
const handleBeforeInstallPrompt = useCallback((e: Event) => {
e.preventDefault();
hasPromptFiredRef.current = true;
setDeferredPrompt(e as BeforeInstallPromptEvent);
}, []);
// Handler for app installed
const handleAppInstalled = useCallback(() => {
setInstalled(true);
setShowPrompt(false);
setShowManualPrompt(false);
setDeferredPrompt(null);
}, [setInstalled]);
useEffect(() => {
// Don't show if already dismissed or installed
if (dismissed || installed) return;
// Check if running in standalone mode (already installed)
if (isStandalone()) {
setInstalled(true);
return;
}
// Check if mobile/tablet - only show on mobile devices
if (!isMobileOrTablet()) return;
// Add event listeners
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
// For iOS, we can't use beforeinstallprompt - show manual instructions after delay
if (isIOSDevice) {
const timer = setTimeout(() => {
setShowManualPrompt(true);
}, 5000);
return () => {
clearTimeout(timer);
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, [dismissed, installed, setInstalled, isIOSDevice, handleBeforeInstallPrompt, handleAppInstalled]);
// Separate effect to handle showing prompts based on deferredPrompt state
useEffect(() => {
if (dismissed || installed) return;
if (!isMobileOrTablet() || isIOSDevice) return;
// If we have a deferred prompt, show native install after delay
if (deferredPrompt) {
const timer = setTimeout(() => {
setShowPrompt(true);
}, 5000);
return () => clearTimeout(timer);
}
// Fallback: If beforeinstallprompt doesn't fire within 7 seconds on Android, show manual prompt
const fallbackTimer = setTimeout(() => {
if (!hasPromptFiredRef.current && !deferredPrompt) {
setShowManualPrompt(true);
}
}, 7000);
return () => clearTimeout(fallbackTimer);
}, [deferredPrompt, dismissed, installed, isIOSDevice]);
const handleInstall = async () => {
if (!deferredPrompt) return;
// Show the install prompt
await deferredPrompt.prompt();
// Wait for the user's response
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setInstalled(true);
}
// Clear the deferred prompt
setDeferredPrompt(null);
setShowPrompt(false);
};
const handleDismiss = () => {
setShowPrompt(false);
setShowManualPrompt(false);
setDismissed(true);
};
// Native install prompt (Android Chrome)
if (showPrompt && deferredPrompt) {
return (
<Sheet open={showPrompt} onOpenChange={setShowPrompt}>
<SheetContent
side="bottom"
className="rounded-t-2xl border-t-4 border-[#ff0040] bg-gradient-to-b from-[#ff0040]/10 to-black/95 backdrop-blur-lg"
onInteractOutside={(e) => e.preventDefault()}
>
<SheetHeader className="text-left">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-[#ff0040] to-[#cc0033] flex items-center justify-center shadow-lg shadow-[#ff0040]/30">
<Smartphone className="h-6 w-6 sm:h-7 sm:w-7 text-white" />
</div>
<div className="flex-1">
<SheetTitle className="text-lg sm:text-xl font-bold">
{t('pwa.installTitle')}
</SheetTitle>
<SheetDescription className="text-xs sm:text-sm text-gray-400">
{t('pwa.installSubtitle')}
</SheetDescription>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDismiss}
className="ml-2 -mt-1 text-gray-500 hover:text-gray-300"
>
<X className="h-5 w-5" />
</Button>
</div>
</SheetHeader>
<div className="mt-4 sm:mt-6">
{/* Benefits list */}
<div className="grid grid-cols-3 gap-2 mb-5">
<div className="text-center p-2 rounded-lg bg-[#ff0040]/10 border border-[#ff0040]/30">
<p className="text-lg">⚡</p>
<p className="text-[10px] sm:text-xs text-gray-400">{t('pwa.benefits.fast')}</p>
</div>
<div className="text-center p-2 rounded-lg bg-[#ff0040]/10 border border-[#ff0040]/30">
<p className="text-lg">📴</p>
<p className="text-[10px] sm:text-xs text-gray-400">{t('pwa.benefits.offline')}</p>
</div>
<div className="text-center p-2 rounded-lg bg-[#ff0040]/10 border border-[#ff0040]/30">
<p className="text-lg">🛹</p>
<p className="text-[10px] sm:text-xs text-gray-400">{t('pwa.benefits.fullAccess')}</p>
</div>
</div>
<div className="space-y-3">
<Button
onClick={handleInstall}
className="w-full h-12 sm:h-14 text-sm sm:text-base font-bold bg-[#ff0040] hover:bg-[#ff0040]/90 shadow-lg shadow-[#ff0040]/30"
size="lg"
>
<Download className="mr-2 h-5 w-5" />
{t('pwa.installApp')}
</Button>
<button
onClick={handleDismiss}
className="w-full text-center text-xs text-gray-500 hover:text-gray-400 py-2"
>
{t('pwa.maybeLater')}
</button>
</div>
</div>
</SheetContent>
</Sheet>
);
}
// Manual install instructions (iOS and fallback for other browsers)
if (showManualPrompt) {
return (
<Sheet open={showManualPrompt} onOpenChange={setShowManualPrompt}>
<SheetContent
side="bottom"
className="rounded-t-2xl border-t-4 border-[#ccff00] bg-gradient-to-b from-[#ccff00]/10 to-black/95 backdrop-blur-lg max-h-[85vh] overflow-y-auto"
onInteractOutside={(e) => e.preventDefault()}
>
<SheetHeader className="text-left">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-[#ccff00] to-[#99cc00] flex items-center justify-center shadow-lg shadow-[#ccff00]/30">
<Smartphone className="h-6 w-6 sm:h-7 sm:w-7 text-black" />
</div>
<div className="flex-1">
<SheetTitle className="text-lg sm:text-xl font-bold text-[#ccff00]">
{t('pwa.addToHomeScreen')}
</SheetTitle>
<SheetDescription className="text-xs sm:text-sm text-gray-400">
{isIOSDevice ? t('pwa.iosStep1Hint') : t('pwa.androidStep1Hint')}
</SheetDescription>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDismiss}
className="ml-2 -mt-1 text-gray-500 hover:text-gray-300"
>
<X className="h-5 w-5" />
</Button>
</div>
</SheetHeader>
<div className="mt-4 sm:mt-6 space-y-4">
{isIOSDevice ? (
// iOS Instructions
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
1
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.iosStep1')}</p>
<p className="text-xs sm:text-sm text-gray-400 flex items-center gap-1 mt-0.5">
<Share className="h-3 w-3 sm:h-4 sm:w-4" /> {t('pwa.iosStep1Hint')}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
2
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.iosStep2')}</p>
<p className="text-xs sm:text-sm text-gray-400 flex items-center gap-1 mt-0.5">
<Plus className="h-3 w-3 sm:h-4 sm:w-4" /> {t('pwa.iosStep2Hint')}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
3
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.iosStep3')}</p>
<p className="text-xs sm:text-sm text-gray-400 mt-0.5">{t('pwa.iosStep3Hint')}</p>
</div>
</div>
</div>
) : (
// Android/Other Instructions
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
1
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.androidStep1')}</p>
<p className="text-xs sm:text-sm text-gray-400 mt-0.5">{t('pwa.androidStep1Hint')}</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
2
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.androidStep2')}</p>
<p className="text-xs sm:text-sm text-gray-400 mt-0.5">{t('pwa.androidStep2Hint')}</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 sm:p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
<div className="flex-shrink-0 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-[#ccff00] text-black flex items-center justify-center font-bold text-sm sm:text-base shadow-md">
3
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-white">{t('pwa.androidStep3')}</p>
<p className="text-xs sm:text-sm text-gray-400 mt-0.5">{t('pwa.androidStep3Hint')}</p>
</div>
</div>
</div>
)}
<Button
onClick={handleDismiss}
className="w-full h-12 text-sm sm:text-base font-semibold bg-[#ccff00] text-black hover:bg-[#ccff00]/90"
size="lg"
>
{t('pwa.gotIt')}
</Button>
</div>
<div className="mt-3 text-center">
<p className="text-xs text-muted-foreground">
{t('pwa.tagline')}
</p>
</div>
</SheetContent>
</Sheet>
);
}
return null;
}

View file

@ -0,0 +1,231 @@
import { useMemo } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
Zap,
Camera,
FileText,
MapPin,
Phone,
Globe,
Clock,
Check,
Circle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export interface QualityCheckItem {
id: string;
label: string;
description?: string;
points: number;
completed: boolean;
icon: React.ComponentType<{ className?: string }>;
}
interface QualityMeterProps {
type: 'spot' | 'shop';
// Form state checks
hasLocation: boolean;
hasPhotos: boolean;
photoCount: number;
hasDescription: boolean;
descriptionLength: number;
// Shop-specific
hasPhone?: boolean;
hasWebsite?: boolean;
hasHours?: boolean;
// Spot-specific
hasSpotType?: boolean;
className?: string;
}
export function QualityMeter({
type,
hasLocation,
hasPhotos,
photoCount,
hasDescription,
descriptionLength,
hasPhone,
hasWebsite,
hasHours,
hasSpotType,
className,
}: QualityMeterProps) {
const checks = useMemo(() => {
const items: QualityCheckItem[] = [
{
id: 'location',
label: 'Location added',
points: 20,
completed: hasLocation,
icon: MapPin,
},
{
id: 'photo1',
label: 'At least 1 photo',
points: 20,
completed: hasPhotos,
icon: Camera,
},
{
id: 'photo3',
label: '3+ photos',
description: 'More angles = better',
points: 15,
completed: photoCount >= 3,
icon: Camera,
},
{
id: 'description',
label: 'Description',
points: 15,
completed: hasDescription && descriptionLength >= 20,
icon: FileText,
},
{
id: 'description-detailed',
label: 'Detailed description (100+ chars)',
points: 10,
completed: descriptionLength >= 100,
icon: FileText,
},
];
if (type === 'shop') {
items.push(
{
id: 'contact',
label: 'Contact info',
description: 'Phone or website',
points: 10,
completed: !!hasPhone || !!hasWebsite,
icon: Phone,
},
{
id: 'hours',
label: 'Opening hours',
points: 10,
completed: !!hasHours,
icon: Clock,
}
);
} else {
items.push({
id: 'spot-type',
label: 'Spot type selected',
points: 10,
completed: !!hasSpotType,
icon: MapPin,
});
}
return items;
}, [type, hasLocation, hasPhotos, photoCount, hasDescription, descriptionLength, hasPhone, hasWebsite, hasHours, hasSpotType]);
const { score, maxScore, completedItems, totalItems } = useMemo(() => {
const completed = checks.filter(c => c.completed);
const total = checks.reduce((sum, c) => sum + c.points, 0);
const current = completed.reduce((sum, c) => sum + c.points, 0);
return {
score: current,
maxScore: total,
completedItems: completed.length,
totalItems: checks.length,
};
}, [checks]);
const percentage = Math.round((score / maxScore) * 100);
const getScoreColor = () => {
if (percentage >= 80) return 'text-green-400';
if (percentage >= 50) return 'text-[#ccff00]';
if (percentage >= 30) return 'text-yellow-400';
return 'text-orange-400';
};
const getProgressColor = () => {
if (percentage >= 80) return 'bg-green-500';
if (percentage >= 50) return 'bg-[#ccff00]';
if (percentage >= 30) return 'bg-yellow-500';
return 'bg-orange-500';
};
const getScoreLabel = () => {
if (percentage >= 80) return 'Excellent!';
if (percentage >= 50) return 'Good';
if (percentage >= 30) return 'Basic';
return 'Getting started';
};
return (
<Card className={cn('bg-gradient-to-br from-[#ccff00]/5 to-transparent border-[#ccff00]/20', className)}>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-[#ccff00]" />
<span className="text-sm font-bold uppercase text-gray-300">Earning Potential</span>
</div>
<div className="flex items-center gap-2">
<span className={cn('text-sm font-bold', getScoreColor())}>
{getScoreLabel()}
</span>
<span className="text-xs text-gray-500">
{percentage}%
</span>
</div>
</div>
{/* Progress Bar */}
<div className="relative h-3 bg-gray-800 rounded-full overflow-hidden mb-4">
<div
className={cn('h-full transition-all duration-500 ease-out rounded-full', getProgressColor())}
style={{ width: `${percentage}%` }}
/>
</div>
{/* Checklist */}
<div className="space-y-2">
{checks.map((check) => {
const Icon = check.icon;
return (
<div
key={check.id}
className={cn(
'flex items-center gap-2 text-xs transition-all',
check.completed ? 'text-green-400' : 'text-gray-500'
)}
>
<div className="flex-shrink-0">
{check.completed ? (
<Check className="h-3.5 w-3.5" />
) : (
<Circle className="h-3.5 w-3.5" />
)}
</div>
<Icon className="h-3 w-3 flex-shrink-0" />
<span className={check.completed ? 'line-through opacity-70' : ''}>
{check.label}
</span>
<span className={cn(
'ml-auto text-[10px] font-bold',
check.completed ? 'text-green-400' : 'text-gray-600'
)}>
+{check.points}%
</span>
</div>
);
})}
</div>
{/* Tip */}
<div className="mt-4 pt-3 border-t border-gray-800">
<p className="text-[10px] text-gray-500">
💡 Higher quality = More zaps from the community!
</p>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,288 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useToast } from '@/hooks/useToast';
import { enableRelatr, getRelatrConfig, disableRelatr } from '@/hooks/useRelatrScore';
import { useNostr } from '@nostrify/react';
import { Shield, ExternalLink, Search, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
/**
* RelatrSettings component for configuring Relatr global trust score integration
*
* Relatr publishes trust scores as NIP-85 Trusted Assertion events (kind 30382).
* Auto-enabled by default for all users with ContextVM's official service.
*/
export function RelatrSettings() {
const { toast } = useToast();
const { nostr } = useNostr();
const config = getRelatrConfig();
const [pubkey, setPubkey] = useState(config.servicePubkey);
const [relay, setRelay] = useState(config.relay);
const [isSearching, setIsSearching] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const handleAutoDiscover = async () => {
setIsSearching(true);
toast({
title: 'Searching for Relatr service...',
description: 'Querying Nostr for Relatr profiles',
});
try {
// Search for profiles mentioning "relatr" or "contextvm"
const profiles = await nostr.query(
[{ kinds: [0], search: 'relatr', limit: 10 }],
{ signal: AbortSignal.timeout(10000) }
);
console.log('[RelatrSettings] Found profiles:', profiles);
// Look for official Relatr service
for (const profile of profiles) {
try {
const metadata = JSON.parse(profile.content);
const name = metadata.name?.toLowerCase() || '';
const about = metadata.about?.toLowerCase() || '';
const website = metadata.website?.toLowerCase() || '';
if (
name.includes('relatr') ||
about.includes('trust score') ||
website.includes('relatr.xyz')
) {
setPubkey(profile.pubkey);
toast({
title: 'Found Relatr service!',
description: `Service: ${metadata.name || 'Unknown'}`,
});
return;
}
} catch (e) {
// Skip invalid JSON
}
}
toast({
title: 'No Relatr service found',
description: 'Try entering the pubkey manually or check https://www.relatr.xyz/',
variant: 'destructive',
});
} catch (error) {
console.error('[RelatrSettings] Auto-discovery failed:', error);
toast({
title: 'Search failed',
description: 'Could not query Nostr for Relatr profiles',
variant: 'destructive',
});
} finally {
setIsSearching(false);
}
};
const handleEnable = () => {
if (!pubkey || pubkey.length !== 64) {
toast({
title: 'Invalid pubkey',
description: 'Please enter a valid 64-character hex pubkey',
variant: 'destructive',
});
return;
}
enableRelatr(pubkey, relay);
toast({
title: 'Relatr enabled!',
description: 'Global trust scores are now active.',
});
};
const handleDisable = () => {
disableRelatr();
toast({
title: 'Relatr disabled',
description: 'Trust scores are no longer being fetched.',
});
};
return (
<Card className="bg-black/90 border-2 border-[#ccff00]/30">
<CardHeader>
<CardTitle className="text-xl font-black uppercase text-[#ccff00] flex items-center gap-2">
<Shield className="h-5 w-5" />
Relatr Trust Scores
</CardTitle>
<CardDescription>
Global trust scores help identify reliable users and reduce spam
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Status - Always visible */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-bold text-gray-300">Status</Label>
<Badge variant={config.enabled ? 'default' : 'outline'} className="gap-2">
{config.enabled ? (
<>
<CheckCircle2 className="h-3 w-3" />
Active
</>
) : (
'Disabled'
)}
</Badge>
</div>
{/* Info Alert */}
<Alert className="bg-[#ccff00]/10 border-[#ccff00]/50">
<Shield className="h-4 w-4 text-[#ccff00]" />
<AlertDescription className="text-sm text-gray-300">
{config.enabled ? (
<>
<strong className="text-[#ccff00]">Relatr is active.</strong> Trust scores from ContextVM's global network analysis help identify reliable skaters and reduce spam.
</>
) : (
<>
<strong className="text-[#ccff00]">Relatr is disabled.</strong> Enable it to get trust scores from the global Nostr network.
</>
)}
</AlertDescription>
</Alert>
{/* Quick Actions */}
{config.enabled ? (
<Button
onClick={handleDisable}
variant="outline"
className="w-full gap-2 border-red-500/50 text-red-400 hover:bg-red-500/10"
>
Disable Relatr
</Button>
) : (
<Button
onClick={handleEnable}
className="w-full gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase"
disabled={!pubkey || pubkey.length !== 64}
>
<Shield className="h-4 w-4" />
Enable Relatr
</Button>
)}
</div>
{/* Advanced Settings - Collapsible */}
<Collapsible open={isAdvancedOpen} onOpenChange={setIsAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full gap-2 text-gray-400 hover:text-white"
>
{isAdvancedOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
Advanced Settings
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
{/* Service Pubkey Input */}
<div className="space-y-2">
<Label htmlFor="relatr-pubkey" className="text-sm font-bold text-gray-300">
Relatr Service Pubkey
</Label>
<div className="flex gap-2">
<Input
id="relatr-pubkey"
placeholder="Enter 64-character hex pubkey..."
value={pubkey}
onChange={(e) => setPubkey(e.target.value)}
className="font-mono text-xs"
maxLength={64}
/>
<Button
variant="outline"
size="sm"
onClick={handleAutoDiscover}
disabled={isSearching}
className="gap-2 whitespace-nowrap"
>
<Search className="h-4 w-4" />
{isSearching ? 'Searching...' : 'Auto-Discover'}
</Button>
</div>
<p className="text-xs text-gray-500">
Default: ContextVM's official Relatr service (npub1dvmc...)
</p>
</div>
{/* Relay Input */}
<div className="space-y-2">
<Label htmlFor="relatr-relay" className="text-sm font-bold text-gray-300">
Relay (where Relatr publishes scores)
</Label>
<Input
id="relatr-relay"
placeholder="wss://relay.example.com"
value={relay}
onChange={(e) => setRelay(e.target.value)}
className="font-mono text-xs"
/>
<p className="text-xs text-gray-500">
Default: wss://relay.contextvm.org (ContextVM's official relay)
</p>
</div>
{/* Apply Changes Button */}
<Button
onClick={handleEnable}
variant="outline"
className="w-full gap-2"
disabled={!pubkey || pubkey.length !== 64}
>
Apply Changes
</Button>
{/* Documentation Links */}
<div className="pt-4 border-t border-gray-700 space-y-2">
<p className="text-sm font-bold text-gray-300">Learn More:</p>
<div className="flex flex-col gap-2">
<a
href="https://www.relatr.xyz/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
Relatr Website
</a>
<a
href="https://github.com/ContextVM/relatr"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
Relatr GitHub
</a>
<a
href="https://github.com/nostr-protocol/nips/blob/master/85.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
NIP-85 Specification
</a>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,365 @@
import { useState, useEffect } from 'react';
import { Plus, X, Wifi, Settings, HelpCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface Relay {
url: string;
read: boolean;
write: boolean;
}
export function RelayListManager() {
const { config, updateConfig } = useAppContext();
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const { toast } = useToast();
const [relays, setRelays] = useState<Relay[]>(config.relayMetadata.relays);
const [newRelayUrl, setNewRelayUrl] = useState('');
// Sync local state with config when it changes (e.g., from NostrProvider sync)
useEffect(() => {
setRelays(config.relayMetadata.relays);
}, [config.relayMetadata.relays]);
const normalizeRelayUrl = (url: string): string => {
url = url.trim();
try {
return new URL(url).toString();
} catch {
try {
return new URL(`wss://${url}`).toString();
} catch {
return url;
}
}
};
const isValidRelayUrl = (url: string): boolean => {
const trimmed = url.trim();
if (!trimmed) return false;
const normalized = normalizeRelayUrl(trimmed);
try {
new URL(normalized);
return true;
} catch {
return false;
}
};
const handleAddRelay = () => {
if (!isValidRelayUrl(newRelayUrl)) {
toast({
title: 'Invalid relay URL',
description: 'Please enter a valid relay URL (e.g., wss://relay.example.com)',
variant: 'destructive',
});
return;
}
const normalized = normalizeRelayUrl(newRelayUrl);
if (relays.some(r => r.url === normalized)) {
toast({
title: 'Relay already exists',
description: 'This relay is already in your list.',
variant: 'destructive',
});
return;
}
const newRelays = [...relays, { url: normalized, read: true, write: true }];
setRelays(newRelays);
setNewRelayUrl('');
saveRelays(newRelays);
};
const handleRemoveRelay = (url: string) => {
const newRelays = relays.filter(r => r.url !== url);
setRelays(newRelays);
saveRelays(newRelays);
};
const handleToggleRead = (url: string) => {
const relay = relays.find(r => r.url === url);
if (!relay) return;
// Prevent disabling read if write is already disabled
if (relay.read && !relay.write) {
toast({
title: 'Cannot disable read',
description: 'At least one of read or write must be enabled for each relay.',
variant: 'destructive',
});
return;
}
const newRelays = relays.map(r =>
r.url === url ? { ...r, read: !r.read } : r
);
setRelays(newRelays);
saveRelays(newRelays);
};
const handleToggleWrite = (url: string) => {
const relay = relays.find(r => r.url === url);
if (!relay) return;
// Prevent disabling write if read is already disabled
if (!relay.read && relay.write) {
toast({
title: 'Cannot disable write',
description: 'At least one of read or write must be enabled for each relay.',
variant: 'destructive',
});
return;
}
const newRelays = relays.map(r =>
r.url === url ? { ...r, write: !r.write } : r
);
setRelays(newRelays);
saveRelays(newRelays);
};
const saveRelays = (newRelays: Relay[]) => {
const now = Math.floor(Date.now() / 1000);
// Update local config
updateConfig((current) => ({
...current,
relayMetadata: {
relays: newRelays,
updatedAt: now,
},
}));
// Publish to Nostr if user is logged in
if (user) {
publishNIP65RelayList(newRelays);
}
};
const publishNIP65RelayList = (relayList: Relay[]) => {
const tags = relayList.map(relay => {
if (relay.read && relay.write) {
return ['r', relay.url];
} else if (relay.read) {
return ['r', relay.url, 'read'];
} else if (relay.write) {
return ['r', relay.url, 'write'];
}
// If neither read nor write, don't include (shouldn't happen)
return null;
}).filter((tag): tag is string[] => tag !== null);
publishEvent(
{
kind: 10002,
content: '',
tags,
},
{
onSuccess: () => {
toast({
title: 'Relay list published',
description: 'Your relay list has been published to Nostr.',
});
},
onError: (error) => {
console.error('Failed to publish relay list:', error);
toast({
title: 'Failed to publish relay list',
description: 'There was an error publishing your relay list to Nostr.',
variant: 'destructive',
});
},
}
);
};
const renderRelayUrl = (url: string): string => {
try {
const parsed = new URL(url);
if (parsed.protocol === 'wss:') {
if (parsed.pathname === '/') {
return parsed.host;
} else {
return parsed.host + parsed.pathname;
}
} else {
return parsed.href;
}
} catch {
return url;
}
}
return (
<div className="space-y-4">
{/* Relay List */}
<div className="space-y-2">
{relays.map((relay) => (
<div
key={relay.url}
className="flex items-center gap-3 p-3 rounded-md border bg-muted/20"
>
<Wifi className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-mono text-sm truncate" title={relay.url}>
{renderRelayUrl(relay.url)}
</div>
{/* Read/Write status badges */}
<div className="flex gap-1.5 mt-1">
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-medium",
relay.read
? "bg-green-500/20 text-green-400 border border-green-500/30"
: "bg-gray-500/20 text-gray-500 border border-gray-500/30"
)}>
Read {relay.read ? '✓' : '✗'}
</span>
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-medium",
relay.write
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
: "bg-gray-500/20 text-gray-500 border border-gray-500/30"
)}>
Write {relay.write ? '✓' : '✗'}
</span>
</div>
</div>
{/* Settings Button */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 px-2 gap-1.5 shrink-0 text-xs"
>
<Settings className="h-3 w-3" />
Settings
</Button>
</PopoverTrigger>
<PopoverContent className="w-56" align="end">
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label htmlFor={`read-${relay.url}`} className="text-sm cursor-pointer">
Read
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">Fetch content from this relay</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
id={`read-${relay.url}`}
checked={relay.read}
onCheckedChange={() => handleToggleRead(relay.url)}
className="data-[state=checked]:bg-green-500 scale-75"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label htmlFor={`write-${relay.url}`} className="text-sm cursor-pointer">
Write
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">Publish your posts to this relay</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
id={`write-${relay.url}`}
checked={relay.write}
onCheckedChange={() => handleToggleWrite(relay.url)}
className="data-[state=checked]:bg-blue-500 scale-75"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground border-t pt-2">
At least one option must be enabled
</p>
</div>
</PopoverContent>
</Popover>
{/* Remove Button */}
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveRelay(relay.url)}
className="size-5 text-muted-foreground hover:text-destructive hover:bg-transparent shrink-0"
disabled={relays.length <= 1}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
{/* Add Relay Form */}
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="new-relay-url" className="sr-only">
Relay URL
</Label>
<Input
id="new-relay-url"
placeholder="Enter relay URL (e.g., wss://relay.example.com)"
value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddRelay();
}
}}
/>
</div>
<Button
onClick={handleAddRelay}
disabled={!newRelayUrl.trim()}
variant="outline"
size="sm"
className="h-10 shrink-0"
>
<Plus className="h-4 w-4 mr-2" />
Add Relay
</Button>
</div>
{!user && (
<p className="text-xs text-muted-foreground">
Log in to sync your relay list with Nostr
</p>
)}
</div>
);
}

View file

@ -0,0 +1,314 @@
import { useState } from 'react';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Loader2, Flag, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// NIP-56 Report types with skate-specific labels
const REPORT_REASONS = [
{
id: 'spam',
label: 'Fake/Non-existent spot',
description: 'This spot does not exist or location is wrong',
icon: '📍',
},
{
id: 'other',
label: 'Stolen/Fake photos',
description: 'Photos are stock images or stolen from elsewhere',
icon: '📸',
},
{
id: 'spam',
label: 'Spam or advertising',
description: 'Promotional content or duplicate listings',
icon: '🗑️',
subtype: 'spam-ad',
},
{
id: 'illegal',
label: 'Dangerous location',
description: 'Private property, unsafe, or illegal to access',
icon: '⚠️',
},
{
id: 'nudity',
label: 'Inappropriate content',
description: 'Contains offensive or adult content',
icon: '🔞',
},
{
id: 'impersonation',
label: 'Impersonation',
description: 'Someone pretending to be someone else',
icon: '👤',
},
{
id: 'other',
label: 'Other',
description: 'Something else not listed above',
icon: '📝',
subtype: 'other-general',
},
] as const;
interface ReportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
// What we're reporting
targetType: 'spot' | 'event' | 'tour' | 'shop' | 'user' | 'listing' | 'coach' | 'filmer';
targetPubkey: string;
targetEventId?: string;
targetTitle?: string;
// Callback after report
onReported?: () => void;
}
export function ReportDialog({
open,
onOpenChange,
targetType,
targetPubkey,
targetEventId,
targetTitle,
onReported,
}: ReportDialogProps) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast();
const [selectedReason, setSelectedReason] = useState<string>('');
const [additionalDetails, setAdditionalDetails] = useState('');
const [step, setStep] = useState<'form' | 'success'>('form');
const handleSubmit = async () => {
if (!user || !selectedReason) return;
const reason = REPORT_REASONS.find(
(r) => r.id === selectedReason || r.subtype === selectedReason
);
if (!reason) return;
try {
// Build NIP-56 report tags
const tags: string[][] = [];
// Always include the pubkey being reported
if (targetEventId) {
// Reporting an event (spot, tour, etc.)
tags.push(['e', targetEventId, reason.id]);
tags.push(['p', targetPubkey]);
} else {
// Reporting just a user/profile
tags.push(['p', targetPubkey, reason.id]);
}
// Build content with context
let content = '';
if (additionalDetails.trim()) {
content = additionalDetails.trim();
}
if (targetTitle) {
content = content
? `${content}\n\nReported: ${targetTitle} (${targetType})`
: `Reported: ${targetTitle} (${targetType})`;
}
await publishEvent({
kind: 1984, // NIP-56 Report
content,
tags,
});
setStep('success');
onReported?.();
toast({
title: 'Report submitted',
description: 'Thank you for helping keep SkateSpots safe.',
});
} catch (error) {
toast({
title: 'Failed to submit report',
description: error instanceof Error ? error.message : 'Please try again',
variant: 'destructive',
});
}
};
const handleClose = () => {
onOpenChange(false);
// Reset form after animation
setTimeout(() => {
setSelectedReason('');
setAdditionalDetails('');
setStep('form');
}, 200);
};
const getReasonKey = (reason: typeof REPORT_REASONS[number]) => {
return reason.subtype || reason.id;
};
if (!user) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-500" />
Report Content
</DialogTitle>
</DialogHeader>
<div className="text-center py-6">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" />
<p className="text-muted-foreground">
You need to be logged in to report content.
</p>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-500" />
Report Content
</DialogTitle>
{step === 'form' && (
<DialogDescription>
Help keep the community safe by reporting inappropriate content.
</DialogDescription>
)}
</DialogHeader>
{step === 'form' ? (
<div className="space-y-4">
{/* Target info */}
{targetTitle && (
<div className="p-3 rounded-lg bg-muted/50 border">
<p className="text-xs text-muted-foreground uppercase mb-1">
Reporting {targetType}
</p>
<p className="font-medium text-sm truncate">{targetTitle}</p>
</div>
)}
{/* Reason selection */}
<div className="space-y-2">
<Label>Why are you reporting this?</Label>
<RadioGroup
value={selectedReason}
onValueChange={setSelectedReason}
className="space-y-2"
>
{REPORT_REASONS.map((reason) => {
const key = getReasonKey(reason);
return (
<div
key={key}
className={cn(
'flex items-start space-x-3 p-3 rounded-lg border transition-colors cursor-pointer',
selectedReason === key
? 'border-red-500/50 bg-red-500/10'
: 'border-border hover:bg-muted/50'
)}
onClick={() => setSelectedReason(key)}
>
<RadioGroupItem value={key} id={key} className="mt-0.5" />
<div className="flex-1 min-w-0">
<Label
htmlFor={key}
className="flex items-center gap-2 cursor-pointer font-medium"
>
<span>{reason.icon}</span>
{reason.label}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{reason.description}
</p>
</div>
</div>
);
})}
</RadioGroup>
</div>
{/* Additional details */}
<div className="space-y-2">
<Label htmlFor="details">Additional details (optional)</Label>
<Textarea
id="details"
placeholder="Provide any additional context that might help..."
value={additionalDetails}
onChange={(e) => setAdditionalDetails(e.target.value)}
rows={3}
className="resize-none"
/>
</div>
{/* Submit */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedReason || isPending}
className="flex-1 bg-red-500 hover:bg-red-600"
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting...
</>
) : (
<>
<Flag className="h-4 w-4 mr-2" />
Submit Report
</>
)}
</Button>
</div>
</div>
) : (
/* Success state */
<div className="text-center py-6">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h3 className="font-bold text-lg mb-2">Report Submitted</h3>
<p className="text-muted-foreground text-sm mb-6">
Thanks for helping keep SkateSpots safe and accurate. Your report
will be reviewed by trusted community members.
</p>
<Button onClick={handleClose} className="w-full">
Done
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,451 @@
import { useState, useEffect } 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 } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils';
export type SasquatchContext = 'home' | 'onboarding' | 'map' | 'lab';
export interface GuideMessage {
id: string;
title: string;
message: string;
context?: SasquatchContext; // Context for FAQ responses
suggestedQuestions?: string[]; // Quick questions users can click
action?: {
label: string;
onClick: () => void;
};
enableChat?: boolean; // Allow Q&A on this message
}
interface SasquatchGuideProps {
message?: GuideMessage;
onDismiss?: () => void;
}
interface ChatMessage {
role: 'user' | 'sasquatch';
text: string;
timestamp: number;
}
// Context-specific FAQ responses
type FAQResponses = Record<string, string>;
// Home & Onboarding - Getting started, Nostr basics, Bitcoin basics
const HOME_ONBOARDING_FAQ: FAQResponses = {
// Nostr basics - Full explanations
'what is nostr': "Nostr stands for 'Notes and Other Stuff Transmitted by Relays'. It's a simple, open protocol for decentralized social networks. Think Twitter, but no one can ban you, censor you, or control your data. You use cryptographic keys (like a password that can't be reset) instead of usernames. Your identity is portable across all Nostr apps - one account works everywhere! No company owns Nostr, it's just a protocol like email.",
'how does nostr work': "Here's the technical overview: You get a public key (npub - your identity, shareable) and a private key (nsec - for signing, keep secret!). When you post, your content is cryptographically signed with your private key and sent to relays (simple servers). Apps like SkateSpots connect to these relays to fetch and display content. No blockchain, no mining, no tokens - just a simple, elegant protocol that puts YOU in control of your data and identity!",
'what is relay': "Relays are lightweight servers that store and forward your Nostr events (posts, profiles, etc.). Think of them like post offices - you can send mail through any post office, and it still reaches its destination. You can use any relay, switch anytime, run your own, or use multiple simultaneously. This app connects to several relays for redundancy, so your content is always available even if one relay goes down. Relays are simple, cheap to run, and keep Nostr censorship-resistant!",
// Bitcoin & Lightning - Basics for skaters
'what is bitcoin': "Bitcoin is digital money that no government or company controls. Created in 2009, it's peer-to-peer electronic cash - you can send it directly to anyone, anywhere, instantly. No banks needed! For skateboarding, it's perfect: permissionless (no one can stop you), censorship-resistant (can't be frozen), and global (works everywhere). Bitcoin is programmed to be scarce - only 21 million will ever exist - making it valuable like digital gold. Perfect for supporting skaters and content creators worldwide! 🪙",
'what are zaps': "Zaps are instant Bitcoin Lightning tips sent over Nostr! When you see a sick spot, killer trick video, or helpful content, you can zap the creator - sending them Bitcoin in milliseconds. It goes directly to their wallet, no middlemen taking cuts. Zaps are tied to your Nostr identity, so creators can see who appreciated their work. It's like hitting 'like' but with real monetary value! ⚡ Think of it as buying someone a coffee, but digital and instant.",
// Getting started - Step-by-step
'how to login': "Step-by-step login guide:\n\n1. **Browser Extension (Desktop)**: Install Alby, nos2x, or Flamingo extension. Click 'Log in' in top-right, then approve the connection request.\n\n2. **Mobile/New User**: Click 'Sign up' to generate new keys. Your browser will download your nsec (secret key) - SAVE IT SAFELY! This is your account - lose it and you lose everything.\n\n3. **Existing User**: Click 'Log in', then 'Paste nsec' if you already have Nostr keys.\n\nNew to Nostr? Choose 'Sign up' - it takes 30 seconds! 🎯",
// Privacy & Security
'is it safe': "YES! Privacy and security breakdown:\n\n✅ **Cryptography**: Nostr uses military-grade elliptic curve cryptography (same as Bitcoin). Your private key proves you own your account.\n\n✅ **Data Control**: You own your data. Posts are signed by YOUR key and stored on relays you choose.\n\n✅ **Privacy**: No email required, no phone number, no personal info needed to sign up.\n\n⚠️ **Important**: NEVER share your nsec (private key) with anyone! Use browser extensions like Alby to keep it secure. If someone gets your nsec, they control your account forever.\n\nFor more info: https://nostr.how/en/guides/sweep-your-nsec",
// Lightning & other
'what is lightning': "Lightning Network is Bitcoin's instant payment layer - like Bitcoin on steroids! Send or receive Bitcoin in milliseconds for tiny fees (fractions of a cent). Perfect for tips, small transactions, and everyday use. It works by creating payment channels between users, allowing thousands of transactions per second. For SkateSpots, Lightning enables instant zaps to reward creators! ⚡",
'what is nwc': "NWC (Nostr Wallet Connect) is like USB-C for Bitcoin wallets! It's an open protocol that lets you connect your Lightning wallet to apps like SkateSpots. Instead of copying invoices or switching between apps, NWC lets you zap directly with one click. You connect once (paste a connection string from your wallet), and boom - instant zaps forever! Works with Alby, Minibits, Zeus, Coinos, and other modern wallets. It's secure (encrypted over Nostr), you control spending limits, and you can revoke access anytime. Think of it as signing into SkateSpots with your wallet! 🔌⚡",
'nostr wallet connect': "NWC (Nostr Wallet Connect) is like USB-C for Bitcoin wallets! It's an open protocol that lets you connect your Lightning wallet to apps like SkateSpots. Instead of copying invoices or switching between apps, NWC lets you zap directly with one click. You connect once (paste a connection string from your wallet), and boom - instant zaps forever! Works with Alby, Minibits, Zeus, Coinos, and other modern wallets. It's secure (encrypted over Nostr), you control spending limits, and you can revoke access anytime. Think of it as signing into SkateSpots with your wallet! 🔌⚡",
'how to zap': "First, connect a Lightning wallet (like Alby extension, or Minibits/Zeus/Coinos with NWC). Then click the ⚡ button on any content. Enter an amount in sats (1 sat ≈ $0.0004) and confirm. The sats go directly to the creator instantly! Common amounts: 21 sats (small tip), 210 sats (nice!), 2100 sats (generous!), 21000 sats (wow!).",
'do i need bitcoin': "Nope! You can browse spots, view events, explore the map, and connect with the community - all 100% FREE without any Bitcoin. But if you want to zap creators or earn sats for contributing spots/events, you'll need a Lightning wallet. Think of Bitcoin as optional premium features, not a requirement!",
'is this free': "100% free! No subscriptions, no ads, no corporate BS. Built by skaters, for skaters. You can earn Bitcoin by contributing quality spots and events (through community zaps), but the core app is completely free to use forever!",
'what can i do': "Discover spots worldwide, connect with pros at The Lab (coaches & filmers), join events and sessions, connect with skaters, earn sats for quality contributions, zap creators you appreciate, check weather conditions before skating, and help build the decentralized skate community! Everything is free except optional zaps.",
'who owns my data': "You do! Your posts are cryptographically signed with your keys and stored on relays. You can backup your keys, switch apps anytime, and no one can delete your content without your permission. Not even SkateSpots can censor you - that's the power of Nostr!",
// Trust Scores & Relays
'what are trust scores': "Trust scores help identify reliable skaters and reduce spam! SkateSpots uses Relatr - a service that analyzes the entire Nostr network to compute global trust scores (0-100). It looks at factors like followers, activity, engagement, and zaps sent/received. Higher scores = more trusted accounts. This is auto-enabled for all users and helps protect the community from bots and spam. You can see trust scores on profiles and use them to verify coaches, shop owners, and other skaters. It's like reputation points for Nostr! 🛡️",
'trust score': "Trust scores help identify reliable skaters and reduce spam! SkateSpots uses Relatr - a service that analyzes the entire Nostr network to compute global trust scores (0-100). It looks at factors like followers, activity, engagement, and zaps sent/received. Higher scores = more trusted accounts. This is auto-enabled for all users and helps protect the community from bots and spam. You can see trust scores on profiles and use them to verify coaches, shop owners, and other skaters. It's like reputation points for Nostr! 🛡️",
'what is relatr': "Relatr is a trust scoring service powered by ContextVM that analyzes the entire Nostr network. It publishes global trust scores (NIP-85 events) to relays, helping apps like SkateSpots identify trustworthy users and filter spam. Think of it as a decentralized reputation system - instead of trusting one company's algorithm, Relatr computes scores from real network data (followers, engagement, zaps, activity). It's enabled by default in SkateSpots to protect the community! You can customize it in Settings → Advanced if you want. 🔍",
'what is nip85': "NIP-85 is a Nostr protocol for 'Trusted Assertions' - a way for services to publish trust and reputation data about users. Relatr uses NIP-85 to publish trust scores (ranks 0-100) along with metadata like follower counts, post counts, and zap amounts. Other apps can read these scores from relays to help filter content and identify trustworthy accounts. It's like a decentralized credit score, but for social networks! SkateSpots reads NIP-85 events from Relatr to show trust scores on profiles. 📊",
'how do relays work': "Relays store and forward your Nostr events! When you post, your content is sent to all your configured relays. When you browse, SkateSpots queries relays to fetch content. SkateSpots uses 2 default relays:\n\n1. 🛹 **goskatespots.nostr1.com** - Our primary skate relay (allowlisted for quality)\n2. 🛡️ **wotr.relatr.xyz** - Relatr's Web of Trust relay for trust scores + redundancy\n\nYou can manage relays in Settings → Relays. Want to run your own relay? Check out relay.tools! ⚡",
'what relays does this use': "SkateSpots uses 2 default relays:\n\n1. 🛹 **goskatespots.nostr1.com** - Our primary skate relay (allowlisted for quality)\n2. 🛡️ **wotr.relatr.xyz** - Relatr's Web of Trust relay for trust scores + redundancy\n\nYour posts go to both, so even if one is down, your content stays available! The primary relay uses an allowlist to prevent spam - DM the relay owner for access. The backup ensures your content always gets published. Want to run your own relay? Check out relay.tools! 📡",
'what is goskatespots relay': "goskatespots.nostr1.com is our primary skateboarding relay! It has custom policies for spots, events, coaches, filmers, and shops. It uses an **allowlist** for trust and spam management - your npub must be specifically approved to publish here. This keeps the quality high and spam low! If you're not on the allowlist yet, no worries - your content still publishes to the backup relay (wotr.relatr.xyz) so it's never lost. DM the relay owner to request access! 🛹",
'why cant i publish': "If you're getting publish errors, it's usually because you're not on the goskatespots.nostr1.com allowlist yet. Good news: your content STILL gets published to the backup relay (wotr.relatr.xyz), so nothing is lost! Everyone can see your spots, events, and posts. To get allowlisted on the primary relay:\n\n1. Build trust - contribute quality spots, be active in the community\n2. DM the relay owner with your npub\n3. Wait for approval\n\nIn the meantime, keep posting - the backup has you covered! 📝",
'allowlist relay': "The primary relay (goskatespots.nostr1.com) uses an allowlist to maintain quality and prevent spam. Only approved npubs can publish there. But don't worry - you have a backup relay (wotr.relatr.xyz) that accepts posts from everyone! Your content still publishes and everyone can see it. Think of the allowlist like a VIP club - it's exclusive, but you don't NEED it to participate. Request access by DMing the relay owner, or just keep using the backup! 🎫",
'how to manage relays': "Go to Settings → Relays! You'll see your 2 default relays and can add/remove relays as needed. Each relay has read/write toggles:\n\n- **Write (publish)**: Your posts go to these relays\n- **Read (fetch)**: You fetch content from these relays\n\nFor best results, enable both read+write on all relays. Want to run your own relay? Check out relay.tools to easily spin up and manage your own Nostr relay! Pro tip: If you're having issues, try removing and re-adding problematic relays. ⚙️",
// Cashu Wallet
'what is cashu': "Cashu is digital cash backed by Bitcoin Lightning! It's like holding Bitcoin tokens directly in your browser - no external wallet needed. You can send and receive instantly, it works offline at skate spots, and it's privacy-focused (mints don't track who you pay). Think of it as cash in your pocket, but digital! Cashu tokens are always redeemable for Lightning sats, so you're never stuck. Your wallet is built right into SkateSpots - go to Settings → Wallet to use it! Learn more: https://cashumints.space 🥜",
'how does cashu work': "Cashu uses ecash technology - digital tokens backed by Bitcoin held at mints (servers). When you receive Lightning, a mint converts it to tokens stored in your browser. When you send Lightning, tokens are converted back. The cool part: mints can't see who pays who (privacy!), and you can use multiple mints for redundancy (like multiple relays!). Your tokens are secured by a 12-word backup phrase. It's like having a Bitcoin wallet built into SkateSpots - works offline, instant, and private! 💰",
'cashu wallet backup': "Your Cashu wallet has a 12-word recovery phrase (seed) - just like your Nostr nsec! Go to Settings → Wallet → Cashu Wallet → View Recovery Phrase to see your words. Write them down on paper and store safely in a secure place. If you clear browser data or switch devices, you'll need these words to restore your balance. ⚠️ NEVER share your seed with anyone - it's like a password that can't be reset! Anyone with your seed controls your wallet. Keep it as secure as your Nostr keys! 🔐",
'what are mints': "Mints are servers that issue Cashu tokens backed by Bitcoin Lightning. Think of them like banks, but open and interoperable - any wallet can use any mint! When you receive Lightning, a mint converts it to tokens. When you send Lightning, a mint converts tokens back. SkateSpots uses mint.minibits.cash by default, but you can add more in Settings → Wallet → Manage Mints. Using multiple mints is RECOMMENDED for redundancy (like multiple Nostr relays!). If one mint goes down, others keep working. Check https://cashumints.space to explore all available mints. 🏦",
'how to use cashu wallet': "Go to Settings → Wallet and scroll to the Cashu Wallet section!\n\n**To receive:**\n1. Enter amount in sats\n2. Click 'Generate Invoice'\n3. Pay from any Lightning wallet\n4. Tokens appear in your balance!\n\n**To send:**\n1. Enter amount\n2. Click 'Create Token'\n3. Share the QR code or copy the token\n4. Recipient redeems it!\n\nYou can also pay Lightning invoices directly or receive Cashu tokens from other users. It's all built-in - no external app needed! 🛹⚡",
'cashu vs lightning': "Both use Bitcoin Lightning, but differently:\n\n**Lightning Wallet (NWC):**\n• External app connected to SkateSpots\n• Requires setup\n• Always online\n• More established\n\n**Cashu Wallet (Built-in):**\n• Built into SkateSpots\n• No setup needed\n• Works offline\n• Privacy-focused\n\n**Best practice:** Use BOTH for redundancy! Cashu for quick payments at skate spots, Lightning/NWC for larger amounts and long-term storage. They work together - you can move sats between them anytime! 💪",
'is cashu safe': "Yes! Cashu uses cryptography (same tech as Bitcoin) to secure your tokens. Your balance is stored locally in your browser, and you control the seed. Important notes:\n\n✅ Mints are trusted servers (like relays) that back your tokens with Bitcoin\n✅ Use multiple mints for redundancy\n✅ Always backup your seed phrase\n✅ Tokens are always redeemable for Lightning sats\n⚠️ If a mint shuts down, withdraw your sats to Lightning first\n\nThink of mints like banks - use reputable ones and spread your balance! Check https://cashumints.space for trusted mints. 💰",
};
// The Lab - Coaching, filming, and services
const LAB_FAQ: FAQResponses = {
'what is the lab': "The Lab is where skaters level up! It's our marketplace for coaching and filming services. Find experienced skaters who can teach you tricks, or connect with filmers and photographers to capture your sessions. All profiles are on Nostr, so you know they're real skaters, not fake accounts. You can filter by skills (street, vert, transitions, etc.) and location. Want to earn sats teaching? You can offer your own services too!",
'what is lab': "The Lab is where skaters level up! It's our marketplace for coaching and filming services. Find experienced skaters who can teach you tricks, or connect with filmers and photographers to capture your sessions. All profiles are on Nostr, so you know they're real skaters, not fake accounts. You can filter by skills (street, vert, transitions, etc.) and location. Want to earn sats teaching? You can offer your own services too!",
'how to find coach': "Use the filters at the top! Select the 'Coaching' tab, then pick skills you want to learn (kickflips, grinds, bowl riding, etc.). Browse coaches in your area - each profile shows their experience, specialties, rates, and bio. Click any coach to see their full profile and contact them directly through Nostr. Pro tip: Look for coaches with NIP-05 verification (the green checkmark) - they're verified skaters!",
'how to find filmer': "Switch to the 'Filming' tab at the top! You'll find photographers and videographers who specialize in skate content. Filter by service type (photos, video editing, full sessions, etc.) and search by location. Check their portfolios, rates, and experience, then reach out directly. Many filmers accept Bitcoin payments too! 📹",
'how to offer coaching': "Click 'Offer Coaching' at the top! Fill out your profile: years of experience, what you can teach (street, vert, technical tricks, etc.), your location, and rates. Add photos and a detailed bio so skaters know who they're learning from. Your profile lives on Nostr, so you own it forever. Start earning sats while helping skaters progress!",
'how to become coach': "Click 'Offer Coaching' at the top! Fill out your profile: years of experience, what you can teach (street, vert, technical tricks, etc.), your location, and rates. Add photos and a detailed bio so skaters know who they're learning from. Your profile lives on Nostr, so you own it forever. Start earning sats while helping skaters progress!",
'how to offer filming': "Click 'Offer Filming' at the top! Detail what services you provide (filming sessions, photo shoots, video editing, slow-mo, etc.), your gear setup, experience, and pricing. Upload sample work to show your style. Once published, skaters can find you and book sessions. All payment terms are between you and the client - total freedom!",
'what are skills': "Skills are what coaches can teach! Options include: 🛹 Street (ledges, rails, stairs, gaps), 🏗️ Vert (half pipes, ramps), 🌊 Transitions (quarter pipes, mini ramps), 🎪 Bowls (pool riding), 💪 Technical (flip tricks, manuals), and more. Filter by what you want to learn, and we'll match you with coaches who specialize in it!",
'how much does coaching cost': "Rates vary by coach! Each profile shows their pricing - some charge hourly, others offer package deals. You'll see rates like '$50/hour' or 'Negotiable'. Many coaches also accept Bitcoin Lightning payments. Contact coaches directly to discuss rates and book sessions. Pro tip: Some coaches offer discounts for multiple sessions!",
'can i message coaches': "Yes! Click any coach profile and you'll see their contact info. Most coaches list their Nostr pubkey, so you can message them directly through any Nostr client. Some also include email, phone, or social media. All communication happens off-platform - we just connect you!",
'nip05 verification': "The green checkmark means they have NIP-05 verification! This proves they control a domain (like theirname@skateshop.com). It's not required, but it adds credibility - you know they're a real person/business. Verified coaches and filmers are more trustworthy!",
};
// Map/Spots/Shops - Navigation, adding spots, finding shops, gear swap
const MAP_FAQ: FAQResponses = {
'how to use map': "Easy! The map shows three types of markers: 🛹 Skate Spots (parks, street spots), 🏪 Skate Shops (where to buy gear), and 🛒 Gear Swap (peer-to-peer marketplace at spots). Toggle them on/off with the buttons above the map. Click any marker to see details, or switch to list view to filter by spot type (street, park, bowl, etc.). Click anywhere on the map to add a new spot!",
'how to add a spot': "Click anywhere on the map to drop a pin at that location! Add photos of the spot, describe the features (rails, ledges, stairs, quarter pipes, etc.), tag the spot type (street, park, diy, etc.), and add access info. Hit publish and boom - it's on Nostr forever! Your contribution helps skaters worldwide find new places to shred. 🛹",
'how to add spot': "Click anywhere on the map to drop a pin at that location! Add photos of the spot, describe the features (rails, ledges, stairs, quarter pipes, etc.), tag the spot type (street, park, diy, etc.), and add access info. Hit publish and boom - it's on Nostr forever! Your contribution helps skaters worldwide find new places to shred. 🛹",
'what are spots': "Spots are skateable locations - anywhere you can ride! This includes skateparks (concrete bowls, ramps), street spots (stairs, handrails, ledges), DIY spots (built by skaters), transitions (quarter pipes, half pipes), and bowls. Each spot is geotagged on the map with photos, descriptions, and features. Think of it as Waze for skateboarding - skaters helping skaters find the best places to session! 🗺️",
'what are shops': "Skate shops are local businesses where you can buy boards, trucks, wheels, shoes, and all your skate gear. They're tagged with 🏪 on the map. Supporting your local shop supports the skate community! Many shops are run by skaters, host events, and are the heart of local skate scenes. You can add shops to the map too - just make sure to verify ownership if you're claiming a shop profile!",
'what is gear swap': "Gear Swap is a peer-to-peer marketplace where skaters can buy and sell used gear at specific spots! See something for sale? Meet at that spot for safe pickup. It's marked with 🛒 on the map. Important: Gear listings DON'T appear at shops (conflict of interest) - they only show at regular skate spots. List your old deck, wheels, or shoes and meet up at your local spot to sell them!",
'how to sell gear': "Find a spot on the map and click it, then scroll to the Gear Swap section and hit 'List Gear'. Upload photos, set your price in dollars (or mark as free), describe the condition, and add when you're usually at that spot. Buyers will see your listing when they view that spot. Meet up safely at the spot for the transaction. Keep it skater-to-skater! 💰",
'can i list gear at shop': "Nope! Gear Swap is peer-to-peer only and doesn't appear at shop locations to avoid conflicts of interest. Shops sell new gear - Gear Swap is for used gear between skaters. List your gear at regular skate spots (parks, street spots, DIY spots) instead. This keeps shops and the marketplace separate and fair for everyone! 🏪🚫",
'how to find shop': "Toggle on the 🏪 Shops filter at the top of the map (or in list view). You'll see all registered skate shops with their locations, contact info, hours, and what they carry. Click any shop to see details or get directions. Support your local shop - they're the backbone of the skate community! Many accept Bitcoin payments too. ⚡",
'how to find skate shop': "Toggle on the 🏪 Shops filter at the top of the map (or in list view). You'll see all registered skate shops with their locations, contact info, hours, and what they carry. Click any shop to see details or get directions. Support your local shop - they're the backbone of the skate community! Many accept Bitcoin payments too. ⚡",
'how to add shop': "If you're a shop owner: Go to the map and look for 'Add Shop' option. Fill in your business details, upload photos, add your hours and contact info. For verification, set up a NIP-05 identifier on your domain - this proves you own the shop! If you're NOT the owner but want to add a shop to the map, you can still add it, but owners can later claim and verify their profiles.",
'what are spot types': "Spots are tagged by their features: 🏞️ Street (stairs, rails, ledges, gaps), 🛝 Park (built skateparks with ramps), 🏗️ DIY (skater-built spots), 🌊 Bowl (concrete bowls and pools), 🎪 Vert (half pipes, mega ramps), 🛤️ Transition (quarter pipes, mini ramps), 🏛️ Plaza (open areas with multiple features), and more! Use these filters to find exactly what you want to skate.",
'spot types': "Spots are tagged by their features: 🏞️ Street (stairs, rails, ledges, gaps), 🛝 Park (built skateparks with ramps), 🏗️ DIY (skater-built spots), 🌊 Bowl (concrete bowls and pools), 🎪 Vert (half pipes, mega ramps), 🛤️ Transition (quarter pipes, mini ramps), 🏛️ Plaza (open areas with multiple features), and more! Use these filters to find exactly what you want to skate.",
'how to filter spots': "Use the filter buttons at the top! Toggle Spots 🛹, Shops 🏪, and Gear 🛒 on/off to show/hide each type. Below that, you'll see spot type filters like Street, Park, Bowl, DIY, etc. Click any to filter - click again to remove. Switch between Map and List view using the view toggle buttons. In list view, you can search by name or location too!",
'weather on map': "Toggle the ☁️ Weather button to see real-time conditions at spots! You'll see temperature, rain probability, wind, and a 'skateability score'. Filter by 'Good' or 'Perfect' conditions to find spots that are dry and rideable right now. Weather doesn't show for shops (since you're shopping indoors). This feature helps you plan sessions and avoid skating in the rain! 🌤️",
'map vs list view': "Map view shows spots visually on an interactive map - great for discovering spots near you or planning routes. List view shows all spots in a grid with photos and details - better for browsing and filtering by specific types. Toggle between them with the buttons at the top. Both views respect your filters!",
};
// Context-aware answer finder
function findAnswer(question: string, context?: SasquatchContext): string {
const q = question.toLowerCase();
// Choose FAQ based on context
let faq: FAQResponses;
switch (context) {
case 'lab':
faq = LAB_FAQ;
break;
case 'map':
faq = MAP_FAQ;
break;
case 'home':
case 'onboarding':
default:
faq = HOME_ONBOARDING_FAQ;
break;
}
// Check context-specific FAQ first
for (const [key, answer] of Object.entries(faq)) {
if (q.includes(key)) {
return answer;
}
}
// Fallback to checking all FAQs if not found in context
const allFAQs = { ...HOME_ONBOARDING_FAQ, ...LAB_FAQ, ...MAP_FAQ };
for (const [key, answer] of Object.entries(allFAQs)) {
if (q.includes(key)) {
return answer;
}
}
return "Hmm, I'm not sure about that one! Try checking the FAQ page, or ask the community on Nostr. You can also browse the map to learn by exploring!";
}
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const { user } = useCurrentUser();
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);
useEffect(() => {
if (message) {
// Delay appearance for smooth animation when first loading
const timer = setTimeout(() => setIsVisible(true), 500);
return () => clearTimeout(timer);
} else {
setIsVisible(false);
}
}, [message]);
const handleDismiss = () => {
// Just minimize instead of fully dismissing
setIsMinimized(true);
};
const handleSendMessage = () => {
if (!userInput.trim()) return;
const userMsg: ChatMessage = {
role: 'user',
text: userInput,
timestamp: Date.now(),
};
const answer = findAnswer(userInput, message?.context);
const sasquatchMsg: ChatMessage = {
role: 'sasquatch',
text: answer,
timestamp: Date.now() + 500,
};
setChatMessages([...chatMessages, userMsg, sasquatchMsg]);
setUserInput('');
};
// Don't show if no message for this page
if (!message) return null;
return (
<>
{/* Minimized button */}
{isMinimized ? (
<div
className={cn(
'fixed bottom-20 md:bottom-4 left-4 z-40 transition-all duration-300',
'opacity-100 translate-y-0' // Always visible when minimized
)}
>
<Button
onClick={() => setIsMinimized(false)}
className="flex items-center gap-2 bg-gradient-to-r from-amber-700 to-amber-900 hover:from-amber-600 hover:to-amber-800 text-white shadow-lg h-12 px-4 border-2 border-amber-500/30"
>
<img
src="/images/sasquatch-guide.webp"
alt="The Sasquatch"
className="h-6 w-6 rounded-full"
/>
<HelpCircle className="h-4 w-4" />
</Button>
</div>
) : (
/* Full guide */
<div
className={cn(
'fixed bottom-20 md:bottom-4 left-4 right-4 md:right-auto z-40 transition-all duration-300',
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
)}
>
<div className="bg-gradient-to-br from-amber-50 to-orange-50 border-2 border-amber-500/50 rounded-lg shadow-2xl max-w-sm mx-auto md:mx-0">
{/* Header with Sasquatch */}
<div className="flex items-start gap-3 p-4 pb-3 border-b border-amber-200">
<div className="relative">
<img
src="/images/sasquatch-guide.webp"
alt="The Sasquatch"
className="h-12 w-12 rounded-full border-2 border-amber-400 shadow-md"
/>
<span className="absolute -bottom-1 -right-1 flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-4 w-4 bg-green-500 border-2 border-white" />
</span>
</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm text-amber-900 flex items-center gap-2">
{message.title}
<span className="text-xs px-1.5 py-0.5 bg-amber-200 text-amber-800 rounded-full">
🛹
</span>
</div>
<div className="text-xs text-amber-700">The Sasquatch</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-amber-600 hover:text-amber-800 hover:bg-amber-100"
onClick={() => setIsMinimized(true)}
>
<Minimize2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-amber-600 hover:text-red-600 hover:bg-red-50"
onClick={handleDismiss}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* Message */}
<div className="p-4">
<p className="text-sm text-gray-800 leading-relaxed">
{message.message}
</p>
{/* Action button */}
{message.action && (
<Button
onClick={message.action.onClick}
size="sm"
className="mt-3 w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700 text-white font-bold"
>
{message.action.label}
</Button>
)}
{/* Chat feature */}
{message.enableChat && (
<div className="mt-4 pt-4 border-t border-amber-200">
{!showChat ? (
<Button
onClick={() => setShowChat(true)}
size="sm"
variant="outline"
className="w-full border-amber-400 text-amber-800 hover:bg-amber-100"
>
<HelpCircle className="h-3 w-3 mr-2" />
{message.context === 'lab' && 'Ask about Coaching & Filming'}
{message.context === 'map' && 'Ask about Spots, Shops & Gear'}
{(message.context === 'home' || message.context === 'onboarding') && 'Ask me about Nostr, Bitcoin & Zaps'}
</Button>
) : (
<div className="space-y-3">
{/* Default questions */}
{chatMessages.length === 0 && message.suggestedQuestions && (
<div className="space-y-1.5">
<div className="text-[10px] text-amber-700 font-semibold mb-2">Quick Questions:</div>
{message.suggestedQuestions.map((q) => (
<Button
key={q}
onClick={() => {
setUserInput(q);
setTimeout(() => handleSendMessage(), 100);
}}
size="sm"
variant="ghost"
className="w-full justify-start text-xs text-amber-800 hover:bg-amber-100 h-auto py-1.5"
>
{q}
</Button>
))}
</div>
)}
{/* Chat messages */}
{chatMessages.length > 0 && (
<ScrollArea className="h-48 rounded border border-amber-200 bg-white p-2">
<div className="space-y-2">
{chatMessages.map((msg, idx) => (
<div
key={idx}
className={cn(
'text-xs p-2 rounded leading-relaxed',
msg.role === 'user'
? 'bg-blue-100 text-blue-900 ml-6'
: 'bg-amber-100 text-amber-900 mr-6'
)}
>
<div className="font-bold text-[10px] mb-1 opacity-70">
{msg.role === 'user' ? '🧑 You' : '🦧 Sasquatch'}
</div>
{msg.text}
</div>
))}
</div>
</ScrollArea>
)}
{/* Input */}
<div className="flex gap-2">
<Input
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
placeholder="Type your question..."
className="text-sm border-amber-300 focus-visible:ring-amber-400"
/>
<Button
onClick={handleSendMessage}
size="sm"
disabled={!userInput.trim()}
className="bg-amber-600 hover:bg-amber-700"
>
<Send className="h-3 w-3" />
</Button>
</div>
{chatMessages.length > 0 && (
<Button
onClick={() => {
setChatMessages([]);
setShowChat(false);
}}
size="sm"
variant="ghost"
className="w-full text-xs text-amber-600 hover:text-amber-800"
>
Start over
</Button>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}

View file

@ -0,0 +1,330 @@
import { useState } from 'react';
import { Download, Copy, Eye, EyeOff, Shield, AlertTriangle, CheckCircle, ExternalLink } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { toast } from '@/hooks/useToast';
import QRCode from 'qrcode';
export function SecuritySettings() {
const { user } = useCurrentUser();
const [confirmed, setConfirmed] = useLocalStorage('backup-confirmed', false);
const [showWarning, setShowWarning] = useState(!confirmed);
if (!user) {
return (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<Shield className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Login Required</h3>
<p className="text-muted-foreground">
Log in to access security settings.
</p>
</CardContent>
</Card>
);
}
const handleConfirmBackup = () => {
setConfirmed(true);
setShowWarning(false);
toast({
title: 'Backup Confirmed! ✅',
description: 'Great! Your keys are safe.',
});
};
const handleGenerateQR = async () => {
try {
// Note: In a real implementation, you'd need access to the user's nsec/mnemonic
// This is a placeholder showing where QR generation would happen
toast({
title: 'QR Code Generation',
description: 'This feature requires accessing your stored keys. Please use the backup methods provided during signup.',
variant: 'destructive',
});
} catch {
toast({
title: 'QR Code Failed',
description: 'Could not generate QR code.',
variant: 'destructive',
});
}
};
return (
<div className="space-y-6">
{/* Backup Warning */}
{showWarning && (
<Alert className="bg-gradient-to-r from-amber-500/10 to-red-500/10 border-2 border-amber-500/50">
<AlertTriangle className="h-5 w-5 text-amber-500" />
<AlertDescription>
<div className="space-y-3">
<p className="font-bold text-amber-400">
⚠️ Have you backed up your recovery phrase or secret key?
</p>
<p className="text-sm text-gray-300">
Your keys are the ONLY way to access your account. If you lose them, you lose everything -
there's no password reset!
</p>
<Button
onClick={handleConfirmBackup}
size="sm"
className="bg-amber-500 hover:bg-amber-600 text-black font-bold"
>
<CheckCircle className="h-4 w-4 mr-2" />
I've Backed Up My Keys
</Button>
</div>
</AlertDescription>
</Alert>
)}
{/* Backup Status */}
<Card className={confirmed ? "bg-green-950/20 border-2 border-green-500/30" : "bg-red-950/20 border-2 border-red-500/30"}>
<CardHeader>
<CardTitle className={confirmed ? "text-green-400" : "text-red-400"}>
{confirmed ? '✅ Backup Confirmed' : '⚠️ Backup Not Confirmed'}
</CardTitle>
<CardDescription>
{confirmed
? 'You\'ve confirmed that your keys are safely backed up.'
: 'Make sure to backup your recovery phrase or secret key!'}
</CardDescription>
</CardHeader>
{!confirmed && (
<CardContent>
<p className="text-sm text-gray-400 mb-4">
Did you save your recovery phrase or secret key during signup?
If not, you'll need to create a new account as we cannot recover lost keys.
</p>
<Button
onClick={handleConfirmBackup}
variant="outline"
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
>
Yes, I've Backed Up My Keys
</Button>
</CardContent>
)}
</Card>
{/* Security Information */}
<Card className="bg-black/90 border-2 border-[#ccff00]/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-[#ccff00]">
<Shield className="h-5 w-5" />
Key Security Best Practices
</CardTitle>
<CardDescription>
How to keep your Nostr account safe
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<div className="text-2xl">✍️</div>
<div>
<p className="font-bold text-white">Write it down on paper</p>
<p className="text-gray-400">Physical backup is the safest. Store in a secure location like a safe.</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="text-2xl">💾</div>
<div>
<p className="font-bold text-white">Multiple backups</p>
<p className="text-gray-400">Keep copies in different secure locations (home safe, bank deposit box, etc.)</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="text-2xl">🔒</div>
<div>
<p className="font-bold text-white">Never share your keys</p>
<p className="text-gray-400">Real Nostr apps NEVER ask for your nsec or recovery phrase after initial login</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="text-2xl">🚫</div>
<div>
<p className="font-bold text-white">Don't store in cloud unencrypted</p>
<p className="text-gray-400">Avoid Google Docs, email drafts, or plain text cloud storage</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="text-2xl">🔐</div>
<div>
<p className="font-bold text-white">Consider using a browser extension</p>
<p className="text-gray-400">Alby, nos2x, or Flamingo keep your keys secure and never expose them to websites</p>
</div>
</div>
</div>
<div className="pt-4 border-t border-[#ccff00]/20">
<h4 className="font-bold text-[#ff0040] mb-2 uppercase text-sm">
⚠️ Important Reminders
</h4>
<ul className="text-xs text-gray-400 space-y-1">
<li>• If you lose your keys, you lose your account permanently</li>
<li>• No company or person can reset your password - not even us</li>
<li>• Anyone with your keys has full control of your account</li>
<li>• Your keys control all your Nostr data across ALL apps</li>
</ul>
</div>
</CardContent>
</Card>
{/* Browser Extension & Mobile App Recommendations */}
<Card className="bg-black/90 border-2 border-purple-500/30">
<CardHeader>
<CardTitle className="text-purple-400">🔌 Recommended Nostr Signers</CardTitle>
<CardDescription>
Use a Nostr signer for better security and convenience
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Amber - Android */}
<a
href="https://github.com/greenart7c3/Amber"
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg bg-gradient-to-r from-[#ccff00]/10 to-green-500/10 border border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-bold text-white flex items-center gap-2">
Amber
<span className="text-xs px-2 py-0.5 bg-[#ccff00]/20 text-[#ccff00] rounded-full">Android</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Secure Nostr signer for Android. Keeps your keys safe on your phone and signs events without exposing them.
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
</div>
</a>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg bg-gradient-to-r from-amber-500/10 to-yellow-500/10 border border-amber-500/30 hover:border-amber-500/60 transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-bold text-white flex items-center gap-2">
Alby
<span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded-full">Recommended</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Full-featured extension with Lightning wallet, WebLN, and NWC support.
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
</div>
</a>
<a
href="https://www.getflamingo.org/"
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg bg-black/50 border border-purple-500/30 hover:border-purple-500/60 transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-bold text-white">Flamingo</div>
<p className="text-xs text-gray-400 mt-1">
Simple, clean interface. Focuses on Nostr signing without wallet features.
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
</div>
</a>
<a
href="https://github.com/fiatjaf/nos2x"
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg bg-black/50 border border-purple-500/30 hover:border-purple-500/60 transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-bold text-white">nos2x</div>
<p className="text-xs text-gray-400 mt-1">
Minimal, lightweight extension for advanced users.
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
</div>
</a>
</div>
</CardContent>
</Card>
{/* Nostr Connect (Bunkers) */}
<Card className="bg-black/90 border-2 border-blue-500/30">
<CardHeader>
<CardTitle className="text-blue-400">☁️ Nostr Connect (Remote Signers)</CardTitle>
<CardDescription>
Use a remote signer to access your account from any device
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-4">
Nostr Connect (also called "bunkers") lets you store your keys on a secure server and sign in from any device.
Your keys never leave the server, but you can use your account anywhere!
</p>
<div className="space-y-3">
<a
href="https://nsec.app"
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/30 hover:border-blue-500/60 transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-bold text-white flex items-center gap-2">
nsec.app
<span className="text-xs px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded-full">Popular</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Free, secure remote signer. Create bunker:// URI and paste when logging in.
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
</div>
</a>
<a
href="https://nostrum.social"
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg bg-black/50 border border-blue-500/30 hover:border-blue-500/60 transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-bold text-white">Nostrum</div>
<p className="text-xs text-gray-400 mt-1">
Another remote signer option with mobile support.
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
</div>
</a>
</div>
<div className="mt-4 p-3 rounded-lg bg-blue-950/30 border border-blue-500/30 text-sm text-gray-300">
<p><strong className="text-blue-400">💡 How to use:</strong> Create an account on one of these services,
get your bunker:// connection string, then paste it in the "Nostr Connect" tab when logging in.</p>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,360 @@
import { useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Card, CardContent } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/hooks/useToast';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useUserStatus, useSetUserStatus, useClearUserStatus, SKATING_STATUSES, type SkatingStatusId } from '@/hooks/useUserStatus';
import type { SkateSpot } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Loader2, MapPin, Clock, X, Zap, Search } from 'lucide-react';
interface SetStatusDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
preselectedSpot?: SkateSpot;
}
export function SetStatusDialog({
open,
onOpenChange,
preselectedSpot,
}: SetStatusDialogProps) {
const { user } = useCurrentUser();
const { toast } = useToast();
const { data: currentStatus } = useUserStatus(user?.pubkey);
const setStatus = useSetUserStatus();
const clearStatus = useClearUserStatus();
const [selectedStatusId, setSelectedStatusId] = useState<SkatingStatusId | null>(null);
const [customStatus, setCustomStatus] = useState('');
const [linkedSpot, setLinkedSpot] = useState<{ pubkey: string; identifier: string; title?: string } | null>(
preselectedSpot ? { pubkey: preselectedSpot.pubkey, identifier: preselectedSpot.identifier, title: preselectedSpot.title } : null
);
const [expirationMinutes, setExpirationMinutes] = useState(60); // Default 1 hour
const [showSpotPicker, setShowSpotPicker] = useState(false);
const [spotSearchQuery, setSpotSearchQuery] = useState('');
// Get spots for picker
const { data: allSpots } = useSkateSpots(100);
const filteredSpots = useMemo(() => {
if (!allSpots) return [];
if (!spotSearchQuery) return allSpots.slice(0, 10);
const query = spotSearchQuery.toLowerCase();
return allSpots
.filter(s =>
s.title.toLowerCase().includes(query) ||
s.location?.toLowerCase().includes(query)
)
.slice(0, 10);
}, [allSpots, spotSearchQuery]);
const handleSetStatus = async () => {
try {
await setStatus.mutateAsync({
statusId: selectedStatusId || undefined,
customStatus: !selectedStatusId ? customStatus : undefined,
linkedSpot: linkedSpot || undefined,
expirationMinutes,
});
toast({
title: 'Status updated!',
description: 'Your skating status is now visible to others.',
});
onOpenChange(false);
// Reset form
setSelectedStatusId(null);
setCustomStatus('');
if (!preselectedSpot) setLinkedSpot(null);
} catch (error) {
toast({
title: 'Failed to set status',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleClearStatus = async () => {
try {
await clearStatus.mutateAsync();
toast({
title: 'Status cleared',
description: 'Your status has been removed.',
});
onOpenChange(false);
} catch (error) {
toast({
title: 'Failed to clear status',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleSpotSelected = (spot: SkateSpot) => {
setLinkedSpot({ pubkey: spot.pubkey, identifier: spot.identifier, title: spot.title });
setShowSpotPicker(false);
setSpotSearchQuery('');
};
const formatExpiration = (minutes: number) => {
if (minutes < 60) return `${minutes} min`;
if (minutes === 60) return '1 hour';
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
return `${Math.round(minutes / 1440)} day${minutes >= 2880 ? 's' : ''}`;
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-black border-[#ccff00]/50">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<Zap className="h-5 w-5 text-[#ccff00]" />
Set Your Status
</DialogTitle>
<DialogDescription>
Let others know what you're up to. Your status will be visible on your profile and at linked spots.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Current Status */}
{currentStatus && (
<Card className="bg-green-500/10 border-green-500/30">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="relative flex">
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
<span className="text-green-400 font-medium text-sm">
{currentStatus.emoji} {currentStatus.status}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClearStatus}
disabled={clearStatus.isPending}
className="h-7 px-2 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
{clearStatus.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<X className="h-3 w-3 mr-1" />
Clear
</>
)}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Quick Status Options */}
<div className="space-y-2">
<Label className="text-sm font-bold text-[#ccff00]">Quick Status</Label>
<div className="grid grid-cols-2 gap-2">
{SKATING_STATUSES.map((status) => (
<Card
key={status.id}
className={cn(
'cursor-pointer transition-all border-2',
selectedStatusId === status.id
? 'bg-[#ccff00]/20 border-[#ccff00] ring-2 ring-[#ccff00]/50'
: 'border-gray-700 hover:border-[#ccff00]/50'
)}
onClick={() => {
setSelectedStatusId(status.id);
setCustomStatus('');
}}
>
<CardContent className="p-3 flex items-center gap-2">
<span className="text-xl">{status.emoji}</span>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm text-white truncate">{status.label}</p>
</div>
{selectedStatusId === status.id && (
<div className="w-4 h-4 rounded-full bg-[#ccff00] flex items-center justify-center flex-shrink-0">
<span className="text-black text-[10px]">✓</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Custom Status */}
<div className="space-y-2">
<Label htmlFor="custom-status" className="text-sm font-bold text-gray-400">
Or write your own
</Label>
<Input
id="custom-status"
placeholder="What are you up to?"
value={customStatus}
onChange={(e) => {
setCustomStatus(e.target.value);
setSelectedStatusId(null);
}}
className="bg-black border-gray-700"
/>
</div>
{/* Link to Spot */}
<div className="space-y-2">
<Label className="text-sm font-bold text-[#ff0040] flex items-center gap-1">
<MapPin className="h-3 w-3" />
Link to a Spot (optional)
</Label>
{linkedSpot ? (
<Card className="bg-[#ff0040]/10 border-[#ff0040]/30">
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-[#ff0040]" />
<span className="text-sm font-medium text-white">
{linkedSpot.title || 'Spot selected'}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setLinkedSpot(null)}
className="h-7 px-2 text-xs"
disabled={!!preselectedSpot}
>
<X className="h-3 w-3" />
</Button>
</CardContent>
</Card>
) : showSpotPicker ? (
<Card className="bg-gray-900/50 border-gray-700">
<CardContent className="p-3 space-y-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input
placeholder="Search spots..."
value={spotSearchQuery}
onChange={(e) => setSpotSearchQuery(e.target.value)}
className="bg-gray-800 border-gray-700 pl-9 h-9"
autoFocus
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setShowSpotPicker(false);
setSpotSearchQuery('');
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="h-48">
<div className="space-y-1.5">
{filteredSpots.map((spot) => (
<button
key={`${spot.pubkey}:${spot.identifier}`}
type="button"
className="w-full flex items-center gap-2 p-2 rounded-lg text-left transition-colors text-sm hover:bg-gray-800"
onClick={() => handleSpotSelected(spot)}
>
<MapPin className="h-4 w-4 flex-shrink-0 text-[#ff0040]" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate text-white">{spot.title}</div>
{spot.location && (
<div className="text-xs text-gray-400 truncate">{spot.location}</div>
)}
</div>
</button>
))}
{filteredSpots.length === 0 && (
<p className="text-sm text-gray-400 text-center py-4">
No spots found.
</p>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setShowSpotPicker(true)}
className="w-full border-gray-700 text-gray-400"
>
<MapPin className="h-4 w-4 mr-2" />
Select a spot
</Button>
)}
</div>
{/* Auto-clear Timer */}
<div className="space-y-3">
<Label className="text-sm font-bold text-orange-400 flex items-center gap-1">
<Clock className="h-3 w-3" />
Auto-clear after: {formatExpiration(expirationMinutes)}
</Label>
<Slider
value={[expirationMinutes]}
onValueChange={([value]) => setExpirationMinutes(value)}
min={15}
max={1440}
step={15}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>15 min</span>
<span>1 hr</span>
<span>4 hrs</span>
<span>24 hrs</span>
</div>
</div>
{/* Submit Button */}
<Button
onClick={handleSetStatus}
disabled={(!selectedStatusId && !customStatus) || setStatus.isPending}
className="w-full bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold"
>
{setStatus.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Setting Status...
</>
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Set Status
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,192 @@
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
Zap,
Check,
Circle,
Camera,
FileText,
MapPin,
Phone,
Clock,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface ShopBountyCardProps {
// Current form state to calculate potential reward
hasName: boolean;
hasAddress: boolean;
hasCoordinates: boolean;
hasDescription: boolean;
hasPhone: boolean;
hasWebsite: boolean;
hasHours: boolean;
photoCount: number;
}
interface QualityCheckItem {
id: string;
label: string;
points: number;
completed: boolean;
icon: React.ComponentType<{ className?: string }>;
}
export function ShopBountyCard({
hasName,
hasAddress,
hasCoordinates,
hasDescription,
hasPhone,
hasWebsite,
hasHours,
photoCount,
}: ShopBountyCardProps) {
const checks: QualityCheckItem[] = [
{
id: 'name',
label: 'Shop name',
points: 15,
completed: hasName,
icon: MapPin,
},
{
id: 'location',
label: 'Address & coordinates',
points: 20,
completed: hasAddress && hasCoordinates,
icon: MapPin,
},
{
id: 'photo1',
label: 'At least 1 photo',
points: 15,
completed: photoCount >= 1,
icon: Camera,
},
{
id: 'photo3',
label: '3+ photos',
points: 10,
completed: photoCount >= 3,
icon: Camera,
},
{
id: 'description',
label: 'Description',
points: 15,
completed: hasDescription,
icon: FileText,
},
{
id: 'contact',
label: 'Contact info',
points: 10,
completed: hasPhone || hasWebsite,
icon: Phone,
},
{
id: 'hours',
label: 'Opening hours',
points: 15,
completed: hasHours,
icon: Clock,
},
];
const completedChecks = checks.filter(c => c.completed);
const score = completedChecks.reduce((sum, c) => sum + c.points, 0);
const maxScore = checks.reduce((sum, c) => sum + c.points, 0);
const percentage = Math.round((score / maxScore) * 100);
const getScoreColor = () => {
if (percentage >= 80) return 'text-green-400';
if (percentage >= 50) return 'text-[#ccff00]';
if (percentage >= 30) return 'text-yellow-400';
return 'text-orange-400';
};
const getProgressColor = () => {
if (percentage >= 80) return 'bg-green-500';
if (percentage >= 50) return 'bg-[#ccff00]';
if (percentage >= 30) return 'bg-yellow-500';
return 'bg-orange-500';
};
const getScoreLabel = () => {
if (percentage >= 80) return 'Excellent!';
if (percentage >= 50) return 'Good';
if (percentage >= 30) return 'Basic';
return 'Getting started';
};
return (
<Card className="bg-gradient-to-br from-[#ccff00]/5 to-transparent border-[#ccff00]/20">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-[#ccff00]" />
<span className="text-sm font-bold uppercase text-gray-300">Earning Potential</span>
</div>
<div className="flex items-center gap-2">
<span className={cn('text-sm font-bold', getScoreColor())}>
{getScoreLabel()}
</span>
<span className="text-xs text-gray-500">
{percentage}%
</span>
</div>
</div>
{/* Progress Bar */}
<div className="relative h-3 bg-gray-800 rounded-full overflow-hidden mb-4">
<div
className={cn('h-full transition-all duration-500 ease-out rounded-full', getProgressColor())}
style={{ width: `${percentage}%` }}
/>
</div>
{/* Checklist */}
<div className="space-y-2">
{checks.map((check) => {
const Icon = check.icon;
return (
<div
key={check.id}
className={cn(
'flex items-center gap-2 text-xs transition-all',
check.completed ? 'text-green-400' : 'text-gray-500'
)}
>
<div className="flex-shrink-0">
{check.completed ? (
<Check className="h-3.5 w-3.5" />
) : (
<Circle className="h-3.5 w-3.5" />
)}
</div>
<Icon className="h-3 w-3 flex-shrink-0" />
<span className={check.completed ? 'line-through opacity-70' : ''}>
{check.label}
</span>
<span className={cn(
'ml-auto text-[10px] font-bold',
check.completed ? 'text-green-400' : 'text-gray-600'
)}>
+{check.points}%
</span>
</div>
);
})}
</div>
{/* Tip */}
<div className="mt-4 pt-3 border-t border-gray-800">
<p className="text-[10px] text-gray-500">
💡 Higher quality = More zaps from the community!
</p>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,148 @@
import { ArrowUpDown, Clock, TrendingUp, MapPin, MessageCircle, Zap, Shield } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
export type SortOption =
| 'recent'
| 'trust'
| 'proximity'
| 'popular'
| 'zaps';
interface SortDropdownProps {
value: SortOption;
onChange: (value: SortOption) => void;
className?: string;
showProximity?: boolean; // Only show if geolocation available
contentType?: 'spots' | 'events' | 'coaches' | 'generic';
}
const SORT_OPTIONS = {
recent: {
label: 'Most Recent',
icon: Clock,
description: 'Newest first',
},
trust: {
label: 'Highest Trust',
icon: Shield,
description: 'Most trusted creators',
},
proximity: {
label: 'Closest to Me',
icon: MapPin,
description: 'Nearest locations',
},
popular: {
label: 'Most Popular',
icon: TrendingUp,
description: 'Most comments/reactions',
},
zaps: {
label: 'Most Zapped',
icon: Zap,
description: 'Highest value received',
},
};
export function SortDropdown({
value,
onChange,
className,
showProximity = false,
contentType = 'generic',
}: SortDropdownProps) {
const selectedOption = SORT_OPTIONS[value];
const Icon = selectedOption.icon;
// Determine which sort options to show based on content type
const availableOptions: SortOption[] = ['recent', 'trust'];
if (showProximity) {
availableOptions.push('proximity');
}
// Popular sorting available for all content types
availableOptions.push('popular');
// Zaps available for all content
availableOptions.push('zaps');
return (
<Select value={value} onValueChange={(v) => onChange(v as SortOption)}>
<SelectTrigger className={cn("w-[200px] gap-2", className)}>
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
<SelectValue>
<span className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{selectedOption.label}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableOptions.map((option) => {
const opt = SORT_OPTIONS[option];
const OptionIcon = opt.icon;
return (
<SelectItem key={option} value={option}>
<div className="flex items-center gap-2 w-full">
<OptionIcon className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium">{opt.label}</div>
<div className="text-xs text-muted-foreground">{opt.description}</div>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
/**
* Helper function to calculate popularity score for sorting
* Based on comments, reactions, and other engagement metrics
*/
export function calculatePopularity(
commentCount: number = 0,
reactionCount: number = 0,
zapCount: number = 0
): number {
// Weighted popularity: comments worth 3x, reactions 1x, zaps 5x
return (commentCount * 3) + (reactionCount * 1) + (zapCount * 5);
}
/**
* Calculate distance between two coordinates in kilometers
* Uses Haversine formula
*/
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371; // Earth's radius in km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function toRad(degrees: number): number {
return degrees * (Math.PI / 180);
}

View file

@ -0,0 +1,320 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { useNWC } from '@/hooks/useNWCContext';
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react';
import QRCode from 'qrcode';
interface SpamFeePaymentProps {
feeRecipient: string;
feeRecipientName: string;
onPaymentSuccess: () => void;
onSkip?: () => void;
}
type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error';
export function SpamFeePayment({
feeRecipient,
feeRecipientName,
onPaymentSuccess,
}: SpamFeePaymentProps) {
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const { sendPayment } = useNWC();
const [paymentState, setPaymentState] = useState<PaymentState>('idle');
const [invoice, setInvoice] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Auto-pay when wallet is connected and invoice is generated
useEffect(() => {
if (invoice && paymentState === 'waiting' && (webln || activeNWC)) {
handleAutoPay();
}
}, [invoice, paymentState, webln, activeNWC]);
const generateInvoice = async () => {
setPaymentState('generating');
setError(null);
try {
const bolt11 = await getInvoiceFromLightningAddress({
lightningAddress: feeRecipient,
amountSats: LISTING_FEE_SATS,
comment: 'SkateSpots listing fee',
});
setInvoice(bolt11);
// Generate QR code
const qr = await QRCode.toDataURL(bolt11.toUpperCase(), {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
setQrCodeUrl(qr);
setPaymentState('waiting');
// If wallet is available, try auto-pay
if (webln || activeNWC) {
await handleAutoPay();
}
} catch (err) {
console.error('Failed to generate invoice:', err);
setError(err instanceof Error ? err.message : 'Failed to generate invoice');
setPaymentState('error');
}
};
const handleAutoPay = async () => {
if (!invoice) return;
setPaymentState('paying');
try {
// Try NWC first
if (activeNWC && activeNWC.isConnected) {
await sendPayment(activeNWC, invoice);
setPaymentState('success');
toast({
title: 'Payment successful!',
description: `${LISTING_FEE_SATS} sats sent to ${feeRecipientName}`,
});
setTimeout(onPaymentSuccess, 1500);
return;
}
// Try WebLN
if (webln) {
if (webln.enable) {
await webln.enable();
}
await webln.sendPayment(invoice);
setPaymentState('success');
toast({
title: 'Payment successful!',
description: `${LISTING_FEE_SATS} sats sent to ${feeRecipientName}`,
});
setTimeout(onPaymentSuccess, 1500);
return;
}
// No auto-pay available, stay in waiting state
setPaymentState('waiting');
} catch (err) {
console.error('Auto-pay failed:', err);
// Fall back to manual payment
setPaymentState('waiting');
toast({
title: 'Auto-pay failed',
description: 'Please pay manually using the QR code or invoice',
variant: 'destructive',
});
}
};
const copyInvoice = async () => {
if (!invoice) return;
try {
await navigator.clipboard.writeText(invoice);
toast({ title: 'Invoice copied!' });
} catch {
toast({ title: 'Failed to copy', variant: 'destructive' });
}
};
const openInWallet = () => {
if (!invoice) return;
window.open(`lightning:${invoice}`, '_blank');
};
// Show success state
if (paymentState === 'success') {
return (
<Card className="bg-green-950/30 border-2 border-green-500/50">
<CardContent className="py-8 text-center">
<CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" />
<h3 className="font-bold text-green-400 text-lg mb-2">Payment Received!</h3>
<p className="text-gray-300">
Your {LISTING_FEE_SATS} sat fee has been paid. You can now create your listing.
</p>
</CardContent>
</Card>
);
}
// Show payment flow
return (
<Card className="bg-yellow-950/30 border-2 border-yellow-500/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-yellow-400">
<ShieldAlert className="h-5 w-5" />
One-Time Listing Fee
</CardTitle>
<CardDescription className="text-gray-300">
As a new member, a small fee is required for your first listing.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Why this fee exists */}
<div className="bg-black/30 rounded-lg p-4 space-y-3">
<h4 className="font-bold text-white text-sm">Why do I need to pay?</h4>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<ShieldAlert className="h-4 w-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-gray-300">
<span className="text-white font-medium">Spam prevention:</span> This fee stops bots and scammers from flooding the marketplace with fake listings.
</p>
</div>
<div className="flex items-start gap-2">
<TrendingUp className="h-4 w-4 text-green-400 mt-0.5 flex-shrink-0" />
<p className="text-gray-300">
<span className="text-white font-medium">Trust building:</span> New accounts need to prove commitment before listing. After a few successful listings, this fee is waived.
</p>
</div>
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-[#ff6600] mt-0.5 flex-shrink-0" />
<p className="text-gray-300">
<span className="text-white font-medium">Supports the community:</span> Fee goes to{' '}
<span className="font-semibold text-yellow-400">{feeRecipientName}</span>
{feeRecipient !== PLATFORM_LIGHTNING_ADDRESS && ' — the person who created this spot!'}
{feeRecipient === PLATFORM_LIGHTNING_ADDRESS && ' — helping keep SkateSpots running.'}
</p>
</div>
</div>
</div>
{/* Fee amount */}
<div className="text-center py-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
<div className="text-3xl font-black text-yellow-400">
⚡ {LISTING_FEE_SATS.toLocaleString()} sats
</div>
<p className="text-sm text-gray-400 mt-1">~${(LISTING_FEE_SATS * 0.0006).toFixed(2)} USD • One-time fee</p>
</div>
{/* Initial state - generate invoice button */}
{paymentState === 'idle' && (
<Button
onClick={generateInvoice}
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
>
<Zap className="h-4 w-4 mr-2" />
Pay {LISTING_FEE_SATS} sats to Continue
</Button>
)}
{/* Generating invoice */}
{paymentState === 'generating' && (
<div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-yellow-400" />
<p className="text-gray-400 mt-2">Generating invoice...</p>
</div>
)}
{/* Paying */}
{paymentState === 'paying' && (
<div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-yellow-400" />
<p className="text-gray-400 mt-2">Processing payment...</p>
</div>
)}
{/* Waiting for payment - show QR and manual options */}
{paymentState === 'waiting' && invoice && qrCodeUrl && (
<div className="space-y-4">
{/* QR Code */}
<div className="flex justify-center">
<div className="bg-white p-3 rounded-lg">
<img src={qrCodeUrl} alt="Payment QR Code" className="w-48 h-48" />
</div>
</div>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
onClick={copyInvoice}
className="border-yellow-500/50 hover:bg-yellow-500/10"
>
<Copy className="h-4 w-4 mr-2" />
Copy Invoice
</Button>
<Button
variant="outline"
onClick={openInWallet}
className="border-yellow-500/50 hover:bg-yellow-500/10"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Wallet
</Button>
</div>
{/* Try again with wallet */}
{(webln || activeNWC) && (
<Button
onClick={handleAutoPay}
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
>
<Zap className="h-4 w-4 mr-2" />
Pay with Connected Wallet
</Button>
)}
<p className="text-xs text-gray-400 text-center">
Scan the QR code with a Lightning wallet or use the buttons above
</p>
</div>
)}
{/* Error state */}
{paymentState === 'error' && error && (
<div className="space-y-4">
<div className="text-center py-2">
<p className="text-red-400">{error}</p>
</div>
<Button
onClick={generateInvoice}
variant="outline"
className="w-full border-yellow-500/50 hover:bg-yellow-500/10"
>
Try Again
</Button>
</div>
)}
{/* How to avoid future fees */}
<div className="bg-green-500/10 rounded-lg p-3 border border-green-500/30">
<p className="text-green-400 font-semibold text-sm mb-2">
✨ How to skip this fee in the future:
</p>
<ul className="text-gray-300 text-sm space-y-1">
<li className="flex items-center gap-2">
<span className="text-green-400">→</span>
Add a skate spot to the map
</li>
<li className="flex items-center gap-2">
<span className="text-green-400">→</span>
Complete 2-3 successful listings
</li>
<li className="flex items-center gap-2">
<span className="text-green-400">→</span>
Build your community reputation
</li>
</ul>
<p className="text-xs text-gray-400 mt-2">
Trusted members get unlimited free listings!
</p>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,160 @@
/**
* SponsorSlot - Invisible placeholder for future sponsors
*
* This component renders nothing when no sponsors are provided,
* but can be "activated" by passing sponsor data.
*
* Sizes:
* - banner: Full-width horizontal banner (homepage, tour pages)
* - card: Square/rectangular card (sidebar, grid layouts)
* - inline: Small inline mention (challenge cards, footer)
*
* Usage:
* <SponsorSlot size="banner" /> // Invisible placeholder
* <SponsorSlot size="banner" sponsors={[{name: "Vans", logo: "...", url: "..."}]} />
*/
import { cn } from '@/lib/utils';
export interface Sponsor {
name: string;
logo?: string;
url?: string;
tier?: 'title' | 'gold' | 'silver' | 'challenge' | 'partner';
}
interface SponsorSlotProps {
size: 'banner' | 'card' | 'inline';
sponsors?: Sponsor[];
title?: string; // e.g., "Powered by", "Sponsored by", "Partners"
className?: string;
// For challenge sponsors
challengeId?: string;
}
export function SponsorSlot({
size,
sponsors,
title = 'Partners',
className,
}: SponsorSlotProps) {
// If no sponsors, render nothing (invisible placeholder)
if (!sponsors || sponsors.length === 0) {
return null;
}
// Banner style - full width, horizontal layout
if (size === 'banner') {
return (
<div className={cn(
'w-full py-6 border-t border-b border-gray-800 bg-black/50',
className
)}>
<div className="container px-4">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-4 text-center">
{title}
</p>
<div className="flex items-center justify-center gap-8 flex-wrap">
{sponsors.map((sponsor) => (
<SponsorItem key={sponsor.name} sponsor={sponsor} size="banner" />
))}
</div>
</div>
</div>
);
}
// Card style - for sidebar or grid layouts
if (size === 'card') {
return (
<div className={cn(
'p-4 rounded-lg border border-gray-800 bg-black/50',
className
)}>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">
{title}
</p>
<div className="flex flex-col gap-3">
{sponsors.map((sponsor) => (
<SponsorItem key={sponsor.name} sponsor={sponsor} size="card" />
))}
</div>
</div>
);
}
// Inline style - small, compact
if (size === 'inline') {
return (
<div className={cn(
'flex items-center gap-2 text-xs text-gray-500',
className
)}>
<span>{title}:</span>
{sponsors.map((sponsor, idx) => (
<span key={sponsor.name}>
<SponsorItem sponsor={sponsor} size="inline" />
{idx < sponsors.length - 1 && ', '}
</span>
))}
</div>
);
}
return null;
}
// Individual sponsor display
function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'card' | 'inline' }) {
const content = (
<div className={cn(
'flex items-center gap-2 transition-opacity hover:opacity-80',
size === 'banner' && 'flex-col',
size === 'card' && 'flex-row',
size === 'inline' && 'inline-flex',
)}>
{sponsor.logo && (
<img
src={sponsor.logo}
alt={sponsor.name}
className={cn(
'object-contain',
size === 'banner' && 'h-10 max-w-[120px]',
size === 'card' && 'h-6 max-w-[80px]',
size === 'inline' && 'h-4 max-w-[60px]',
)}
/>
)}
<span className={cn(
'font-medium',
size === 'banner' && 'text-sm text-gray-400',
size === 'card' && 'text-sm text-gray-300',
size === 'inline' && 'text-xs text-gray-400',
!sponsor.logo && 'text-gray-300', // More visible if no logo
)}>
{sponsor.name}
</span>
{sponsor.tier === 'title' && size !== 'inline' && (
<span className="text-[10px] text-yellow-500 uppercase">Title Sponsor</span>
)}
</div>
);
if (sponsor.url) {
return (
<a
href={sponsor.url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
{content}
</a>
);
}
return content;
}
// Export types for use in tour schema
export type { Sponsor as TourSponsor };

View file

@ -0,0 +1,245 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MapPin, Store, Bookmark, MoreVertical, Flag } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { ZapButton } from '@/components/ZapButton';
import { TrustBadge } from '@/components/TrustBadge';
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
import { ReportDialog } from '@/components/ReportDialog';
import type { SkateSpot } from '@/lib/types';
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO } from '@/lib/types';
import { cn } from '@/lib/utils';
interface SpotCardProps {
spot: SkateSpot;
}
export function SpotCard({ spot }: SpotCardProps) {
const author = useAuthor(spot.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(spot.pubkey);
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
const [showReportDialog, setShowReportDialog] = useState(false);
// Determine if this is a shop or a spot
const isShop = spot.types.includes('shop');
const isOwner = user?.pubkey === spot.pubkey;
const route = isShop ? `/shop/${spot.pubkey}/${spot.identifier}` : `/spot/${spot.pubkey}/${spot.identifier}`;
return (
<Link to={route}>
<Card className={cn(
"group overflow-hidden hover:shadow-2xl transition-all duration-300 border-2 bg-black/90 backdrop-blur",
isShop
? "border-[#ccff00]/30 hover:border-[#ccff00]/70 hover:shadow-[#ccff00]/30"
: "border-[#ff0040]/20 hover:border-[#ff0040]/60 hover:shadow-[#ff0040]/30"
)}>
{/* Image */}
<div className="relative aspect-video overflow-hidden bg-black">
{spot.images[0] ? (
<img
src={spot.images[0]}
alt={spot.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 group-hover:brightness-110"
/>
) : (
<div className={cn(
"w-full h-full flex items-center justify-center bg-gradient-to-br",
isShop
? "from-[#ccff00]/20 to-[#00ff88]/20"
: "from-[#ff0040]/20 to-[#ff6600]/20"
)}>
{isShop ? (
<Store className="h-16 w-16 text-[#ccff00]/40" />
) : (
<MapPin className="h-16 w-16 text-[#ff0040]/40" />
)}
</div>
)}
{/* Shop badge or Type badges overlay */}
<div className="absolute top-3 left-3 flex flex-col gap-1">
{isShop ? (
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs">
🏪 SKATE SHOP
</Badge>
) : (
<>
{spot.accessType && (
<Badge
className="bg-black/90 backdrop-blur-sm border-2 font-bold uppercase text-[10px]"
style={{
borderColor: ACCESS_TYPE_INFO[spot.accessType].color,
color: ACCESS_TYPE_INFO[spot.accessType].color
}}
>
{ACCESS_TYPE_INFO[spot.accessType].emoji} {ACCESS_TYPE_INFO[spot.accessType].label.split(' - ')[0]}
</Badge>
)}
{spot.types.length > 0 && (
<div className="flex flex-wrap gap-1">
{spot.types.slice(0, 2).map((type) => (
<Badge
key={type}
className="bg-black/80 text-[#ccff00] backdrop-blur-sm border border-[#ccff00]/50 font-bold uppercase text-[10px]"
>
{SPOT_TYPE_LABELS[type] || type}
</Badge>
))}
</div>
)}
</>
)}
</div>
</div>
<CardHeader className="pb-2">
<h3 className={cn(
"font-bold text-lg leading-tight transition-colors line-clamp-1 uppercase tracking-wide",
isShop
? "text-white group-hover:text-[#ccff00]"
: "text-white group-hover:text-[#ff0040]"
)}>
{spot.title}
</h3>
{spot.location && (
<p className="text-sm text-gray-400 flex items-center gap-1 line-clamp-1">
{isShop ? (
<Store className="h-3 w-3 flex-shrink-0 text-[#ccff00]" />
) : (
<MapPin className="h-3 w-3 flex-shrink-0 text-[#ff0040]" />
)}
{spot.location}
</p>
)}
</CardHeader>
<CardContent className="pb-3">
{spot.description && (
<p className="text-sm text-gray-400 line-clamp-2">
{spot.description}
</p>
)}
</CardContent>
<CardFooter className="pt-0 flex items-center justify-between border-t border-[#ff0040]/20 mt-2 pt-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<Avatar className="h-6 w-6 border border-[#ff0040]/30">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="text-xs bg-black text-[#ff0040]">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-xs text-gray-400 truncate max-w-[80px]">
{displayName}
</span>
{/* Trust Badge */}
<TrustBadge pubkey={spot.pubkey} size="sm" />
</div>
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
{user && !isShop && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-400 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowCollectionDialog(true);
}}
>
<Bookmark className="h-4 w-4" />
</Button>
)}
<ZapButton target={spot.event} className="text-gray-400 hover:text-[#ccff00]" />
{/* More options menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-400 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{!isOwner && (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowReportDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardFooter>
</Card>
{/* Collection Dialog */}
{!isShop && (
<AddToCollectionDialog
open={showCollectionDialog}
onOpenChange={setShowCollectionDialog}
spot={spot}
/>
)}
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType={isShop ? 'shop' : 'spot'}
targetPubkey={spot.pubkey}
targetEventId={spot.id}
targetTitle={spot.title}
/>
</Link>
);
}
export function SpotCardSkeleton() {
return (
<Card className="overflow-hidden">
<Skeleton className="aspect-video w-full" />
<CardHeader className="pb-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent className="pb-3">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</CardContent>
<CardFooter className="pt-0">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
</CardFooter>
</Card>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,355 @@
import { useState, useMemo } from 'react';
import { UserPlus, AlertTriangle, Loader2, Crown, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useQueryClient } from '@tanstack/react-query';
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
import { LoginArea } from '@/components/auth/LoginArea';
import { nip19 } from 'nostr-tools';
// 6 months in seconds
const ABANDONED_THRESHOLD = 6 * 30 * 24 * 60 * 60;
interface SpotOwnershipProps {
spot: SkateSpot;
}
/**
* Component that handles:
* 1. Transfer ownership (owner can transfer to another user)
* 2. Claim abandoned spot (any user can claim if owner inactive 6+ months)
*/
export function SpotOwnership({ spot }: SpotOwnershipProps) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
const ownerProfile = useAuthor(spot.pubkey);
const [transferOpen, setTransferOpen] = useState(false);
const [claimOpen, setClaimOpen] = useState(false);
const [recipientNpub, setRecipientNpub] = useState('');
const isOwner = user && user.pubkey === spot.pubkey;
// Check if spot is abandoned (owner hasn't updated in 6+ months)
const isAbandoned = useMemo(() => {
const now = Math.floor(Date.now() / 1000);
return (now - spot.createdAt) > ABANDONED_THRESHOLD;
}, [spot.createdAt]);
// Calculate how long ago the spot was last updated
const lastUpdatedText = useMemo(() => {
const now = Math.floor(Date.now() / 1000);
const diff = now - spot.createdAt;
const days = Math.floor(diff / (24 * 60 * 60));
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`;
if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`;
return `${days} day${days > 1 ? 's' : ''} ago`;
}, [spot.createdAt]);
const handleTransfer = async () => {
if (!recipientNpub.trim()) {
toast({
title: 'Recipient required',
description: 'Please enter the npub of the new owner.',
variant: 'destructive',
});
return;
}
// Decode npub to get hex pubkey
let recipientPubkey: string;
try {
const decoded = nip19.decode(recipientNpub.trim());
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
recipientPubkey = decoded.data;
} catch {
toast({
title: 'Invalid npub',
description: 'Please enter a valid npub address.',
variant: 'destructive',
});
return;
}
if (recipientPubkey === spot.pubkey) {
toast({
title: 'Same owner',
description: 'Cannot transfer to the current owner.',
variant: 'destructive',
});
return;
}
try {
// Publish a transfer event (kind 1111 comment with transfer marker)
await publishEvent({
kind: 1111,
content: `Ownership transfer request for spot "${spot.title}"`,
tags: [
['a', `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`],
['K', String(SKATE_SPOT_KIND)],
['t', 'spot-transfer'],
['p', recipientPubkey, '', 'new-owner'],
],
});
toast({
title: 'Transfer requested',
description: 'The new owner will need to claim the spot to complete the transfer.',
});
setTransferOpen(false);
setRecipientNpub('');
queryClient.invalidateQueries({ queryKey: ['spot-transfers', spot.pubkey, spot.identifier] });
} catch (error) {
toast({
title: 'Transfer failed',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleClaim = async () => {
if (!user) return;
try {
// Re-publish the spot under the new owner's pubkey
// This creates a new addressable event with the same d-tag but new owner
const eventTags: string[][] = [];
// Copy all original tags except the ones we're changing
for (const tag of spot.event.tags) {
eventTags.push([...tag]);
}
// Add claim reference
eventTags.push(['claim-from', spot.pubkey]);
eventTags.push(['claim-reason', isAbandoned ? 'abandoned' : 'transfer']);
await publishEvent({
kind: SKATE_SPOT_KIND,
content: spot.description,
tags: eventTags,
});
toast({
title: 'Spot claimed!',
description: 'You are now the owner of this spot. You can edit it from your profile.',
});
setClaimOpen(false);
queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier] });
} catch (error) {
toast({
title: 'Claim failed',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
// Don't show anything for shops
if (spot.types.includes('shop')) {
return null;
}
return (
<>
{/* Owner: Transfer button */}
{isOwner && (
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<UserPlus className="h-4 w-4" />
Transfer Ownership
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-[#ff0040]" />
Transfer Spot Ownership
</DialogTitle>
<DialogDescription>
Transfer "{spot.title}" to another user. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">New Owner's npub</label>
<Input
placeholder="npub1..."
value={recipientNpub}
onChange={(e) => setRecipientNpub(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Enter the npub of the user you want to transfer this spot to.
</p>
</div>
<Card className="bg-yellow-500/10 border-yellow-500/30">
<CardContent className="p-3">
<div className="flex gap-2 text-yellow-500">
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium">Warning</p>
<p className="text-yellow-400/80">
After transfer, you will no longer be able to edit or delete this spot.
</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setTransferOpen(false)}>
Cancel
</Button>
<Button
onClick={handleTransfer}
disabled={!recipientNpub.trim() || isPublishing}
className="bg-[#ff0040] hover:bg-[#ff0040]/90"
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Transferring...
</>
) : (
'Transfer Ownership'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Non-owner: Claim abandoned spot */}
{!isOwner && isAbandoned && user && (
<Card className="bg-orange-500/10 border-2 border-orange-500/30">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Clock className="h-6 w-6 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-orange-400 uppercase text-sm mb-1">
Spot May Be Abandoned
</h3>
<p className="text-xs text-gray-400 mb-3">
Last updated {lastUpdatedText}. If the original owner is inactive,
you can claim this spot to maintain and improve it.
</p>
<Button
size="sm"
className="gap-2 bg-orange-500 hover:bg-orange-600 text-white"
onClick={() => setClaimOpen(true)}
>
<Crown className="h-4 w-4" />
Claim This Spot
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Not logged in: Show login for abandoned spots */}
{!isOwner && isAbandoned && !user && (
<Card className="bg-orange-500/10 border-2 border-orange-500/30">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Clock className="h-6 w-6 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-orange-400 uppercase text-sm mb-1">
Spot May Be Abandoned
</h3>
<p className="text-xs text-gray-400 mb-3">
Last updated {lastUpdatedText}. Log in to claim and maintain this spot.
</p>
<LoginArea className="justify-start" />
</div>
</div>
</CardContent>
</Card>
)}
{/* Claim confirmation dialog */}
<AlertDialog open={claimOpen} onOpenChange={setClaimOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Crown className="h-5 w-5 text-orange-500" />
Claim "{spot.title}"?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
You're about to claim ownership of this spot. This will create your own
version of the spot that you can edit and maintain.
</p>
<div className="bg-muted p-3 rounded-lg text-sm">
<p className="font-medium mb-1">What happens when you claim:</p>
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>You become the owner and can edit the spot</li>
<li>The original spot remains (Nostr is decentralized)</li>
<li>Your version may appear alongside the original</li>
</ul>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleClaim}
disabled={isPublishing}
className="bg-orange-500 hover:bg-orange-600"
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Claiming...
</>
) : (
'Claim Spot'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,198 @@
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { MapPin, Search, X, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SpotPickerProps {
selectedSpots: string[]; // Array of "pubkey:identifier" strings
onSpotsChange: (spots: string[]) => void;
maxSpots?: number;
label?: string;
description?: string;
}
export function SpotPicker({
selectedSpots,
onSpotsChange,
maxSpots = 5,
label = 'Spots Where You\'re Available',
description = 'Select spots where you offer your services',
}: SpotPickerProps) {
const { data: allSpots } = useSkateSpots(100);
const [searchQuery, setSearchQuery] = useState('');
const [isExpanded, setIsExpanded] = useState(false);
// Filter spots for search
const filteredSpots = useMemo(() => {
if (!allSpots) return [];
if (!searchQuery) return allSpots.slice(0, 10);
const query = searchQuery.toLowerCase();
return allSpots
.filter(s =>
s.title.toLowerCase().includes(query) ||
s.location?.toLowerCase().includes(query)
)
.slice(0, 10);
}, [allSpots, searchQuery]);
// Get selected spot details
const selectedSpotDetails = useMemo(() => {
if (!allSpots) return [];
return selectedSpots
.map(spotId => {
const [pubkey, identifier] = spotId.split(':');
return allSpots.find(s => s.pubkey === pubkey && s.identifier === identifier);
})
.filter(Boolean);
}, [allSpots, selectedSpots]);
const toggleSpot = (pubkey: string, identifier: string) => {
const spotId = `${pubkey}:${identifier}`;
if (selectedSpots.includes(spotId)) {
onSpotsChange(selectedSpots.filter(id => id !== spotId));
} else if (selectedSpots.length < maxSpots) {
onSpotsChange([...selectedSpots, spotId]);
}
};
const removeSpot = (spotId: string) => {
onSpotsChange(selectedSpots.filter(id => id !== spotId));
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-bold">{label}</Label>
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
</div>
<Badge variant="outline" className="text-xs">
{selectedSpots.length}/{maxSpots}
</Badge>
</div>
{/* Selected spots */}
{selectedSpotDetails.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedSpotDetails.map((spot) => spot && (
<Badge
key={`${spot.pubkey}:${spot.identifier}`}
variant="secondary"
className="gap-1.5 py-1.5 px-3 bg-[#ff0040]/10 border border-[#ff0040]/30 text-[#ff0040]"
>
<MapPin className="h-3 w-3" />
{spot.title}
<button
type="button"
onClick={() => removeSpot(`${spot.pubkey}:${spot.identifier}`)}
className="ml-1 hover:bg-[#ff0040]/20 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{/* Add spot button / expanded picker */}
{selectedSpots.length < maxSpots && (
<>
{!isExpanded ? (
<Button
type="button"
variant="outline"
size="sm"
className="gap-2 border-dashed"
onClick={() => setIsExpanded(true)}
>
<Plus className="h-4 w-4" />
Add Spot
</Button>
) : (
<Card className="bg-gray-900/50 border-gray-700">
<CardContent className="p-3 space-y-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input
placeholder="Search spots..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-gray-800 border-gray-700 pl-9 h-9"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsExpanded(false);
setSearchQuery('');
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="max-h-48 overflow-y-auto space-y-1.5">
{filteredSpots.map((spot) => {
const spotId = `${spot.pubkey}:${spot.identifier}`;
const isSelected = selectedSpots.includes(spotId);
return (
<button
key={spotId}
type="button"
className={cn(
'w-full flex items-center gap-2 p-2 rounded-lg text-left transition-colors text-sm',
isSelected
? 'bg-[#ff0040]/20 border border-[#ff0040]/50'
: 'hover:bg-gray-800 border border-transparent'
)}
onClick={() => toggleSpot(spot.pubkey, spot.identifier)}
disabled={!isSelected && selectedSpots.length >= maxSpots}
>
<MapPin className={cn(
'h-4 w-4 flex-shrink-0',
isSelected ? 'text-[#ff0040]' : 'text-gray-500'
)} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{spot.title}</div>
{spot.location && (
<div className="text-xs text-gray-400 truncate">{spot.location}</div>
)}
</div>
{isSelected && (
<Badge variant="outline" className="text-[10px] border-[#ff0040]/50 text-[#ff0040]">
Selected
</Badge>
)}
</button>
);
})}
{filteredSpots.length === 0 && (
<p className="text-sm text-gray-400 text-center py-4">
No spots found. Try a different search.
</p>
)}
</div>
</CardContent>
</Card>
)}
</>
)}
{selectedSpots.length === 0 && (
<p className="text-xs text-gray-400 italic">
Adding spots helps users find you when browsing specific locations
</p>
)}
</div>
);
}

View file

@ -0,0 +1,195 @@
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { X, Hash, AlertTriangle } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import {
searchTags,
normalizeTag,
isCuratedTag,
MAX_TAGS,
} from '@/lib/skateTags';
interface TagInputProps {
tags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
maxTags?: number;
className?: string;
disabled?: boolean;
}
export function TagInput({
tags,
onChange,
placeholder = 'Add tags...',
maxTags = MAX_TAGS,
className,
disabled = false,
}: TagInputProps) {
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Update suggestions when input changes
useEffect(() => {
if (inputValue.trim()) {
const results = searchTags(inputValue, 8).filter(
(tag) => !tags.includes(tag)
);
setSuggestions(results);
setShowSuggestions(results.length > 0);
setSelectedIndex(-1);
} else {
setSuggestions([]);
setShowSuggestions(false);
}
}, [inputValue, tags]);
// Close suggestions when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setShowSuggestions(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const addTag = (tag: string) => {
const normalized = normalizeTag(tag);
if (normalized && !tags.includes(normalized) && tags.length < maxTags) {
onChange([...tags, normalized]);
setInputValue('');
setShowSuggestions(false);
inputRef.current?.focus();
}
};
const removeTag = (tagToRemove: string) => {
onChange(tags.filter((tag) => tag !== tagToRemove));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
addTag(suggestions[selectedIndex]);
} else if (inputValue.trim()) {
addTag(inputValue);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
} else if (e.key === 'Escape') {
setShowSuggestions(false);
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
removeTag(tags[tags.length - 1]);
} else if (e.key === ',' || e.key === ' ') {
if (inputValue.trim()) {
e.preventDefault();
addTag(inputValue);
}
}
};
const isAtLimit = tags.length >= maxTags;
return (
<div ref={containerRef} className={cn('relative', className)}>
{/* Tags display */}
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map((tag) => {
const isCurated = isCuratedTag(tag);
return (
<Badge
key={tag}
variant={isCurated ? 'secondary' : 'outline'}
className={cn(
'pl-2 pr-1 py-1 text-sm flex items-center gap-1',
isCurated
? 'bg-[#ccff00]/20 text-[#ccff00] border-[#ccff00]/30'
: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30'
)}
>
<Hash className="h-3 w-3" />
{tag}
{!isCurated && (
<AlertTriangle className="h-3 w-3 ml-0.5 text-yellow-500" />
)}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 hover:bg-white/10 rounded p-0.5"
disabled={disabled}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
{/* Input */}
<div className="relative">
<Hash className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isAtLimit ? `Max ${maxTags} tags reached` : placeholder}
disabled={disabled || isAtLimit}
className="pl-9"
/>
</div>
{/* Tag count */}
<div className="mt-1.5">
<p className="text-xs text-muted-foreground">
{tags.length}/{maxTags} tags
{tags.some((t) => !isCuratedTag(t)) && (
<span className="text-yellow-500 ml-2">
<AlertTriangle className="h-3 w-3 inline mr-1" />
Custom tags detected
</span>
)}
</p>
</div>
{/* Autocomplete suggestions */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-black border border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{suggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover:bg-gray-800',
index === selectedIndex && 'bg-gray-800'
)}
onClick={() => addTag(suggestion)}
>
<Hash className="h-3 w-3 text-[#ccff00]" />
{suggestion}
</button>
))}
</div>
)}
</div>
);
}
export default TagInput;

View file

@ -0,0 +1,19 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTheme } from '@/hooks/useTheme';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View file

@ -0,0 +1,452 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Route, Clock, MapPin, Users, Zap, Globe, Calendar, Trophy, Skull, MoreVertical, Flag } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { ZapButton } from '@/components/ZapButton';
import { TrustBadge } from '@/components/TrustBadge';
import { ReportDialog } from '@/components/ReportDialog';
import type { SkateTour, TourCategory } from '@/lib/types';
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
import { cn } from '@/lib/utils';
interface TourCardProps {
tour: SkateTour;
compact?: boolean;
}
function getCategoryIcon(category: TourCategory) {
switch (category) {
case 'local':
return <Route className="h-4 w-4" />;
case 'national':
return <Route className="h-4 w-4" />;
case 'international':
return <Globe className="h-4 w-4" />;
case 'destruction':
return <Skull className="h-4 w-4" />;
default:
return <Route className="h-4 w-4" />;
}
}
function formatDuration(duration?: number, unit?: string): string {
if (!duration) return '';
switch (unit) {
case 'hours':
return `${duration} hr${duration > 1 ? 's' : ''}`;
case 'days':
return `${duration} day${duration > 1 ? 's' : ''}`;
case 'weeks':
return duration >= 4 ? `${Math.floor(duration / 4)} month${Math.floor(duration / 4) > 1 ? 's' : ''}` : `${duration} week${duration > 1 ? 's' : ''}`;
default:
return `${duration}`;
}
}
function formatDateRange(startDate?: number, endDate?: number): string {
if (!startDate) return '';
const start = new Date(startDate * 1000);
const startStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
if (!endDate) return startStr;
const end = new Date(endDate * 1000);
const endStr = end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return `${startStr} - ${endStr}`;
}
function getTourStatus(tour: SkateTour): { label: string; color: string } | null {
if (tour.category !== 'destruction') return null;
const now = Date.now() / 1000;
if (tour.startDate && now < tour.startDate) {
return { label: 'Registration Open', color: '#ccff00' };
} else if (tour.startDate && tour.endDate && now >= tour.startDate && now <= tour.endDate) {
return { label: 'Active', color: '#ff0040' };
} else if (tour.endDate && now > tour.endDate) {
return { label: 'Completed', color: '#888' };
}
return null;
}
export function TourCard({ tour, compact = false }: TourCardProps) {
const author = useAuthor(tour.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(tour.pubkey);
const [showReportDialog, setShowReportDialog] = useState(false);
const categoryInfo = TOUR_CATEGORIES[tour.category];
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
const tourStatus = getTourStatus(tour);
const isOwner = user?.pubkey === tour.pubkey;
// Get location text based on category
const locationText = tour.category === 'destruction' || tour.category === 'international'
? tour.countries?.join(', ') || tour.cities?.join(', ') || 'Worldwide'
: tour.location || tour.cities?.join(', ');
if (compact) {
return (
<Link to={`/tour/${tour.pubkey}/${tour.identifier}`}>
<Card
className="group hover:shadow-2xl transition-all duration-300 border-2 bg-black/90 backdrop-blur"
style={{
borderColor: `${categoryInfo.color}33`,
boxShadow: `0 0 0 0 ${categoryInfo.color}00`
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = `${categoryInfo.color}99`;
e.currentTarget.style.boxShadow = `0 10px 40px ${categoryInfo.color}33`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = `${categoryInfo.color}33`;
e.currentTarget.style.boxShadow = `0 0 0 0 ${categoryInfo.color}00`;
}}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Category Badge */}
<div
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center"
style={{ backgroundColor: `${categoryInfo.color}20`, border: `2px solid ${categoryInfo.color}80` }}
>
<span className="text-xl">{categoryInfo.emoji}</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h3
className="font-bold text-white transition-colors line-clamp-1 uppercase tracking-wide"
style={{ color: 'white' }}
>
{tour.title}
</h3>
{tourStatus && (
<Badge
className="flex-shrink-0 text-xs border-0 font-bold uppercase"
style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }}
>
{tourStatus.label}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 font-bold"
style={{ borderColor: categoryInfo.color, color: categoryInfo.color }}
>
{categoryInfo.label}
</Badge>
{tour.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" style={{ color: categoryInfo.color }} />
{formatDuration(tour.duration, tour.durationUnit)}
</span>
)}
{locationText && (
<span className="flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 flex-shrink-0 text-[#ff0040]" />
<span className="truncate">{locationText}</span>
</span>
)}
<TrustBadge pubkey={tour.pubkey} size="sm" />
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
);
}
return (
<Link to={`/tour/${tour.pubkey}/${tour.identifier}`}>
<Card
className="group overflow-hidden transition-all duration-300 border-2 bg-black/90 backdrop-blur"
style={{
borderColor: `${categoryInfo.color}33`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = `${categoryInfo.color}80`;
e.currentTarget.style.boxShadow = `0 20px 50px ${categoryInfo.color}20`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = `${categoryInfo.color}33`;
e.currentTarget.style.boxShadow = 'none';
}}
>
{/* Image */}
<div className="relative aspect-[16/9] overflow-hidden bg-muted">
{tour.image ? (
<img
src={tour.image}
alt={tour.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div
className="w-full h-full flex items-center justify-center"
style={{ background: `linear-gradient(135deg, ${categoryInfo.color}20 0%, ${categoryInfo.color}05 100%)` }}
>
{tour.category === 'destruction' ? (
<Skull className="h-16 w-16" style={{ color: `${categoryInfo.color}40` }} />
) : (
<Route className="h-16 w-16" style={{ color: `${categoryInfo.color}40` }} />
)}
</div>
)}
{/* Category overlay */}
<div className="absolute top-3 left-3">
<Badge
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
style={{
backgroundColor: `${categoryInfo.color}dd`,
color: categoryInfo.color === '#ccff00' ? 'black' : 'white'
}}
>
{categoryInfo.emoji} {categoryInfo.label}
</Badge>
</div>
{/* Status/Price overlay */}
<div className="absolute top-3 right-3 flex flex-col gap-1 items-end">
{tourStatus && (
<Badge
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
style={{
backgroundColor: `${tourStatus.color}dd`,
color: tourStatus.color === '#ccff00' ? 'black' : 'white'
}}
>
{tourStatus.label}
</Badge>
)}
{tour.prizePool && (
<Badge className="bg-orange-500/90 backdrop-blur-sm shadow-lg font-bold text-xs">
<Trophy className="h-3 w-3 mr-1" />
{tour.prizePool.toLocaleString()} sats
</Badge>
)}
</div>
</div>
<CardHeader className="pb-2">
{/* Guide/Organizer info */}
<div className="mb-3 pb-2 border-b" style={{ borderColor: `${categoryInfo.color}33` }}>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8" style={{ border: `1px solid ${categoryInfo.color}50` }}>
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="text-xs" style={{ backgroundColor: `${categoryInfo.color}20`, color: categoryInfo.color }}>
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-bold text-white text-sm truncate">{displayName}</h4>
<TrustBadge pubkey={tour.pubkey} size="sm" />
</div>
{metadata?.nip05 && (
<div className="flex items-center gap-1 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-[#ccff00]" />
<span className="text-[#ccff00] font-bold truncate">{metadata.nip05}</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-start justify-between gap-2">
<h3
className="font-bold text-lg leading-tight text-white transition-colors line-clamp-2 uppercase"
>
{tour.title}
</h3>
</div>
{/* Tour dates for destruction tours */}
{tour.category === 'destruction' && tour.startDate && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Calendar className="h-4 w-4" style={{ color: categoryInfo.color }} />
<span>{formatDateRange(tour.startDate, tour.endDate)}</span>
</div>
)}
{/* Tour meta info */}
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
{tour.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" style={{ color: categoryInfo.color }} />
{formatDuration(tour.duration, tour.durationUnit)}
</span>
)}
{tour.maxGroupSize && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#ff6600]" />
Max {tour.maxGroupSize}
</span>
)}
{tour.maxTeams && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#ff6600]" />
{tour.maxTeams} teams max
</span>
)}
{skillInfo && (
<span className="flex items-center gap-1">
<span>{skillInfo.emoji}</span>
{skillInfo.label}
</span>
)}
</div>
{locationText && (
<p className="text-sm text-gray-400 flex items-center gap-1 line-clamp-1">
<MapPin className="h-4 w-4 flex-shrink-0 text-[#ff0040]" />
{locationText}
</p>
)}
</CardHeader>
<CardContent className="pb-4">
{tour.description && (
<p className="text-sm text-gray-400 line-clamp-2">
{tour.description}
</p>
)}
{/* Price or Entry Fee */}
<div className="flex items-center justify-between mt-4 pt-4 border-t" style={{ borderColor: `${categoryInfo.color}33` }}>
<div className="flex items-center gap-2">
{tour.category === 'destruction' && tour.entryFee ? (
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
<Zap className="h-3 w-3 mr-1" />
Entry: {tour.entryFee.toLocaleString()} sats
</Badge>
) : tour.price ? (
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
<Zap className="h-3 w-3 mr-1" />
{tour.price.toLocaleString()} sats
</Badge>
) : (
<Badge variant="outline" className="border-green-500/50 text-green-400">
Contact for price
</Badge>
)}
</div>
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
<ZapButton target={tour.event} className="text-gray-400 hover:text-[#ccff00]" />
{/* More options menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-400 hover:text-white"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{!isOwner && (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowReportDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType="tour"
targetPubkey={tour.pubkey}
targetEventId={tour.id}
targetTitle={tour.title}
/>
</Link>
);
}
export function TourCardSkeleton({ compact = false }: { compact?: boolean }) {
if (compact) {
return (
<Card className="border-2 border-gray-800">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="w-14 h-14 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="overflow-hidden border-2 border-gray-800">
<Skeleton className="aspect-[16/9] w-full" />
<CardHeader className="pb-2">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-gray-800">
<Skeleton className="w-8 h-8 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
</div>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</CardHeader>
<CardContent className="pb-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-800">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,78 @@
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Shield, CheckCircle2, AlertCircle } from 'lucide-react';
import { useTrustScore, getTrustBadgeConfig } from '@/hooks/useTrustScore';
import { cn } from '@/lib/utils';
interface TrustBadgeProps {
pubkey: string | undefined;
className?: string;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
}
/**
* TrustBadge component displays a visual trust score indicator for a Nostr user.
*
* Trust levels:
* - 100: Self (your own content)
* - 90: Direct follow
* - 60: Friend-of-friend
* - 30: NIP-05 verified
* - 0: Unknown/unverified
*/
export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }: TrustBadgeProps) {
const { data: trustScore, isLoading } = useTrustScore(pubkey);
const badge = getTrustBadgeConfig(trustScore);
if (isLoading || !badge.show) {
return null;
}
const sizeClasses = {
sm: 'text-[9px] px-1.5 py-0 h-4 gap-0.5',
md: 'text-xs px-2 py-0.5 h-5 gap-1',
lg: 'text-sm px-3 py-1 h-6 gap-1.5',
};
const iconSizes = {
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-4 w-4',
};
const getTooltipText = (score: number | undefined) => {
if (score === undefined) return 'Trust score unavailable';
if (score === 100) return 'This is your content';
if (score >= 90) return 'You follow this user';
if (score >= 60) return 'Friend of a friend (2 hops)';
if (score >= 30) return 'NIP-05 verified user';
return 'Unknown user';
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={badge.variant}
className={cn(sizeClasses[size], badge.className, className, 'cursor-help select-none')}
>
{badge.icon === 'check' && <CheckCircle2 className={iconSizes[size]} />}
{badge.icon === 'shield' && <Shield className={iconSizes[size]} />}
{!badge.icon && <AlertCircle className={iconSizes[size]} />}
{showLabel && <span className="font-bold uppercase">{badge.label}</span>}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="space-y-1">
<p className="font-bold">{getTooltipText(trustScore)}</p>
<p className="text-xs text-muted-foreground">
Trust scores help identify reliable contributors in the skateboarding community.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -0,0 +1,161 @@
import { Shield, CheckCircle2, Users } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
export type TrustFilterLevel = 'all' | 'verified' | 'trusted' | 'following';
interface TrustFilterProps {
value: TrustFilterLevel;
onChange: (value: TrustFilterLevel) => void;
className?: string;
compact?: boolean;
}
const TRUST_LEVELS = [
{
value: 'all' as const,
label: 'All Users',
description: 'Show content from everyone',
icon: Users,
color: 'text-gray-400',
},
{
value: 'verified' as const,
label: 'Verified',
description: 'You follow them or they are verified (trust ≥90)',
icon: CheckCircle2,
color: 'text-green-500',
},
{
value: 'trusted' as const,
label: 'Trusted',
description: 'Friends-of-friends or NIP-05 verified (trust ≥60)',
icon: Shield,
color: 'text-yellow-500',
},
{
value: 'following' as const,
label: 'Following Only',
description: 'Only show content from people you follow',
icon: CheckCircle2,
color: 'text-green-500',
},
];
export function TrustFilter({ value, onChange, className, compact = false }: TrustFilterProps) {
const currentLevel = TRUST_LEVELS.find((l) => l.value === value) || TRUST_LEVELS[0];
const Icon = currentLevel.icon;
if (compact) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn("gap-2", className)}
>
<Icon className="h-4 w-4" />
Trust Filter
{value !== 'all' && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{value === 'verified' ? '≥90' : value === 'trusted' ? '≥60' : 'Following'}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel>Filter by Trust Level</DropdownMenuLabel>
<DropdownMenuSeparator />
{TRUST_LEVELS.map((level) => (
<DropdownMenuCheckboxItem
key={level.value}
checked={value === level.value}
onCheckedChange={() => onChange(level.value)}
className="flex flex-col items-start py-3"
>
<div className="flex items-center gap-2 mb-1">
<level.icon className={cn("h-4 w-4", level.color)} />
<span className="font-bold">{level.label}</span>
</div>
<p className="text-xs text-muted-foreground pl-6">{level.description}</p>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<div className={cn("space-y-3", className)}>
<label className="text-sm font-bold text-gray-300 uppercase tracking-wide flex items-center gap-2">
<Shield className="h-4 w-4 text-[#ccff00]" />
Trust Filter
</label>
<div className="grid gap-2">
{TRUST_LEVELS.map((level) => {
const Icon = level.icon;
const isActive = value === level.value;
return (
<button
key={level.value}
onClick={() => onChange(level.value)}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border-2 transition-all text-left",
isActive
? "bg-black/50 border-[#ccff00] shadow-[0_0_10px_rgba(204,255,0,0.3)]"
: "bg-black/20 border-white/10 hover:border-white/30"
)}
>
<Icon className={cn("h-5 w-5 flex-shrink-0 mt-0.5", isActive ? level.color : "text-gray-500")} />
<div className="flex-1">
<div className="font-bold text-white mb-1">{level.label}</div>
<p className="text-xs text-gray-400">{level.description}</p>
</div>
{isActive && (
<CheckCircle2 className="h-5 w-5 text-[#ccff00] flex-shrink-0" />
)}
</button>
);
})}
</div>
</div>
);
}
/**
* Helper function to filter items by trust score
*/
export function filterByTrustLevel<T extends { pubkey: string }>(
items: T[],
level: TrustFilterLevel,
getTrustScore: (pubkey: string) => number | undefined
): T[] {
if (level === 'all') return items;
return items.filter((item) => {
const score = getTrustScore(item.pubkey);
if (score === undefined) return false;
switch (level) {
case 'verified':
return score >= 90;
case 'trusted':
return score >= 60;
case 'following':
return score === 90 || score === 100;
default:
return true;
}
});
}

View file

@ -0,0 +1,94 @@
import { useUserStatus, SKATING_STATUSES } from '@/hooks/useUserStatus';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface UserStatusIndicatorProps {
pubkey: string;
className?: string;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
}
export function UserStatusIndicator({
pubkey,
className,
showLabel = false,
size = 'sm',
}: UserStatusIndicatorProps) {
const { data: status, isLoading } = useUserStatus(pubkey);
if (isLoading || !status) return null;
const preset = status.statusId
? SKATING_STATUSES.find(s => s.id === status.statusId)
: null;
const emoji = status.emoji || preset?.emoji || '🛹';
const label = preset?.label || status.status;
const sizeClasses = {
sm: 'text-xs px-1.5 py-0',
md: 'text-sm px-2 py-0.5',
lg: 'text-base px-3 py-1',
};
const dotSizeClasses = {
sm: 'w-2 h-2',
md: 'w-2.5 h-2.5',
lg: 'w-3 h-3',
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('inline-flex items-center gap-1', className)}>
{/* Pulsing dot indicator */}
<span className="relative flex">
<span
className={cn(
'animate-ping absolute inline-flex rounded-full bg-green-400 opacity-75',
dotSizeClasses[size]
)}
/>
<span
className={cn(
'relative inline-flex rounded-full bg-green-500',
dotSizeClasses[size]
)}
/>
</span>
{showLabel && (
<Badge
variant="outline"
className={cn(
'border-green-500/50 text-green-400 font-medium',
sizeClasses[size]
)}
>
{emoji} {label}
</Badge>
)}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px]">
<div className="text-center">
<div className="text-lg mb-1">{emoji}</div>
<p className="font-bold">{label}</p>
{status.linkedSpot && (
<p className="text-xs text-muted-foreground mt-1">
📍 At a spot
</p>
)}
{status.expiration && (
<p className="text-xs text-muted-foreground mt-1">
⏱️ Auto-clears in {Math.round((status.expiration - Date.now() / 1000) / 60)} min
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -0,0 +1,404 @@
import { useState, forwardRef } from 'react';
import { Wallet, Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
DrawerClose,
} from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast';
import { useIsMobile } from '@/hooks/useIsMobile';
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
import type { WebLNProvider } from "@webbtc/webln-types";
interface WalletModalProps {
children?: React.ReactNode;
className?: string;
}
// Extracted AddWalletContent to prevent re-renders
const AddWalletContent = forwardRef<HTMLDivElement, {
alias: string;
setAlias: (value: string) => void;
connectionUri: string;
setConnectionUri: (value: string) => void;
}>(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => (
<div className="space-y-4 px-4" ref={ref}>
<div>
<Label htmlFor="alias">Wallet Name (optional)</Label>
<Input
id="alias"
placeholder="My Lightning Wallet"
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
</div>
<div>
<Label htmlFor="connection-uri">Connection URI</Label>
<Textarea
id="connection-uri"
placeholder="nostr+walletconnect://..."
value={connectionUri}
onChange={(e) => setConnectionUri(e.target.value)}
rows={3}
/>
</div>
</div>
));
AddWalletContent.displayName = 'AddWalletContent';
// Recommended wallets for NWC
const RECOMMENDED_WALLETS = [
{ name: 'Alby', url: 'https://getalby.com', type: 'extension' },
{ name: 'Minibits', url: 'https://www.minibits.cash', type: 'mobile' },
{ name: 'Zeus', url: 'https://zeusln.com', type: 'mobile' },
{ name: 'Coinos', url: 'https://coinos.io', type: 'web' },
];
// Extracted WalletContent to prevent re-renders
const WalletContent = forwardRef<HTMLDivElement, {
webln: WebLNProvider | null;
hasNWC: boolean;
connections: NWCConnection[];
connectionInfo: Record<string, NWCInfo>;
activeConnection: string | null;
handleSetActive: (cs: string) => void;
handleRemoveConnection: (cs: string) => void;
setAddDialogOpen: (open: boolean) => void;
}>(({
webln,
hasNWC,
connections,
connectionInfo,
activeConnection,
handleSetActive,
handleRemoveConnection,
setAddDialogOpen
}, ref) => (
<div className="space-y-4 sm:space-y-6 px-0 sm:px-4 pb-4" ref={ref}>
{/* NWC First - Primary Option */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm sm:text-base">Nostr Wallet Connect</h3>
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Wallet
</Button>
</div>
{/* Connected Wallets List */}
{connections.length > 0 ? (
<div className="space-y-2">
{connections.map((connection) => {
const info = connectionInfo[connection.connectionString];
const isActive = activeConnection === connection.connectionString;
return (
<div key={connection.connectionString} className={`flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary bg-primary/5' : ''}`}>
<div className="flex items-center gap-3">
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{connection.alias || info?.alias || 'Lightning Wallet'}
</p>
<p className="text-xs text-muted-foreground">
NWC Connection
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isActive && <CheckCircle className="h-4 w-4 text-green-600" />}
{!isActive && (
<Button
size="sm"
variant="ghost"
onClick={() => handleSetActive(connection.connectionString)}
>
<Zap className="h-3 w-3" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveConnection(connection.connectionString)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-4 px-3 border border-dashed rounded-lg">
<WalletMinimal className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-3">
Paste your NWC connection string to send zaps
</p>
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Connect Wallet
</Button>
</div>
)}
</div>
<Separator />
{/* WebLN - Secondary Option */}
<div className="space-y-2">
<div className="flex items-center justify-between p-2 sm:p-3 border rounded-lg">
<div className="flex items-center gap-2 sm:gap-3">
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs sm:text-sm font-medium">Browser Extension (WebLN)</p>
<p className="text-[10px] sm:text-xs text-muted-foreground">Auto-detected if installed</p>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
{webln && <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-600" />}
<Badge variant={webln ? "default" : "secondary"} className="text-[10px] sm:text-xs">
{webln ? "Ready" : "Not Found"}
</Badge>
</div>
</div>
</div>
{/* Recommended Wallets - Show when no wallet connected */}
{!webln && connections.length === 0 && (
<>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">Get NWC from:</h4>
<div className="grid gap-2">
{RECOMMENDED_WALLETS.map((wallet) => (
<a
key={wallet.name}
href={wallet.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-2 border rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-500" />
<span className="text-sm font-medium">{wallet.name}</span>
</div>
<Badge variant="outline" className="text-[10px]">
{wallet.type}
</Badge>
</a>
))}
</div>
</div>
</>
)}
</div>
));
WalletContent.displayName = 'WalletContent';
export function WalletModal({ children, className }: WalletModalProps) {
const [open, setOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [connectionUri, setConnectionUri] = useState('');
const [alias, setAlias] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const isMobile = useIsMobile();
const {
connections,
activeConnection,
connectionInfo,
addConnection,
removeConnection,
setActiveConnection
} = useNWC();
const { webln } = useWallet();
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
const { toast } = useToast();
const handleAddConnection = async () => {
if (!connectionUri.trim()) {
toast({
title: 'Connection URI required',
description: 'Please enter a valid NWC connection URI.',
variant: 'destructive',
});
return;
}
setIsConnecting(true);
try {
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
if (success) {
setConnectionUri('');
setAlias('');
setAddDialogOpen(false);
}
} finally {
setIsConnecting(false);
}
};
const handleRemoveConnection = (connectionString: string) => {
removeConnection(connectionString);
};
const handleSetActive = (connectionString: string) => {
setActiveConnection(connectionString);
toast({
title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.',
});
};
const walletContentProps = {
webln,
hasNWC,
connections,
connectionInfo,
activeConnection,
handleSetActive,
handleRemoveConnection,
setAddDialogOpen,
};
const addWalletDialog = (
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">Connect NWC Wallet</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Enter your connection string from a compatible wallet.
</DialogDescription>
</DialogHeader>
<AddWalletContent
alias={alias}
setAlias={setAlias}
connectionUri={connectionUri}
setConnectionUri={setConnectionUri}
/>
<DialogFooter className="px-0 sm:px-4">
<Button
onClick={handleAddConnection}
disabled={isConnecting || !connectionUri.trim()}
className="w-full"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
if (isMobile) {
return (
<>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
{children || (
<Button variant="outline" size="sm" className={className}>
<Wallet className="h-4 w-4 mr-2" />
Wallet Settings
</Button>
)}
</DrawerTrigger>
<DrawerContent className="h-full">
<DrawerHeader className="text-center relative">
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="absolute right-4 top-4">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</DrawerClose>
<DrawerTitle className="flex items-center justify-center gap-2 pt-2">
<Wallet className="h-5 w-5" />
Lightning Wallet
</DrawerTitle>
<DrawerDescription>
Connect your lightning wallet to send zaps instantly.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto">
<WalletContent {...walletContentProps} />
</div>
</DrawerContent>
</Drawer>
{/* Render Add Wallet as a separate Drawer for mobile */}
<Drawer open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Connect NWC Wallet</DrawerTitle>
<DrawerDescription>
Enter your connection string from a compatible wallet.
</DrawerDescription>
</DrawerHeader>
<AddWalletContent
alias={alias}
setAlias={setAlias}
connectionUri={connectionUri}
setConnectionUri={setConnectionUri}
/>
<div className="p-4">
<Button
onClick={handleAddConnection}
disabled={isConnecting || !connectionUri.trim()}
className="w-full"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
</div>
</DrawerContent>
</Drawer>
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className={className}>
<Wallet className="h-4 w-4 mr-2" />
Wallet Settings
</Button>
)}
</DialogTrigger>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[500px] max-h-[85vh] max-h-[85dvh] overflow-y-auto p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Wallet className="h-5 w-5" />
Lightning Wallet
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Connect your lightning wallet to send zaps instantly.
</DialogDescription>
</DialogHeader>
<WalletContent {...walletContentProps} />
</DialogContent>
</Dialog>
{addWalletDialog}
</>
);
}

View file

@ -0,0 +1,36 @@
import { Button } from '@/components/ui/button';
import { Cloud, CloudOff } from 'lucide-react';
import { cn } from '@/lib/utils';
interface WeatherToggleProps {
enabled: boolean;
onToggle: () => void;
className?: string;
}
export function WeatherToggle({ enabled, onToggle, className }: WeatherToggleProps) {
return (
<Button
variant={enabled ? 'default' : 'outline'}
size="sm"
onClick={onToggle}
className={cn(
'gap-2 transition-all',
enabled && 'bg-blue-500 hover:bg-blue-600 border-blue-500',
className
)}
>
{enabled ? (
<>
<Cloud className="h-4 w-4" />
<span className="hidden sm:inline">Weather On</span>
</>
) : (
<>
<CloudOff className="h-4 w-4" />
<span className="hidden sm:inline">Weather Off</span>
</>
)}
</Button>
);
}

View file

@ -0,0 +1,138 @@
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { useWhosSkatingAtSpot, SKATING_STATUSES } from '@/hooks/useUserStatus';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { formatDistanceToNow } from 'date-fns';
import { Users, MessageCircle, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
interface WhosSkatingProps {
spotPubkey: string;
spotIdentifier: string;
className?: string;
}
function SkaterCard({ pubkey, status, emoji }: { pubkey: string; status: string; emoji?: string }) {
const { data: authorData, isLoading } = useAuthor(pubkey);
const metadata = authorData?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const npub = nip19.npubEncode(pubkey);
if (isLoading) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-black/50">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-24 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
</div>
);
}
return (
<Link
to={`/${npub}`}
className="block"
>
<div className="flex items-center gap-3 p-3 rounded-lg bg-black/50 hover:bg-black/70 transition-colors border border-transparent hover:border-[#ccff00]/30">
<div className="relative">
<Avatar className="h-10 w-10 border-2 border-green-500/50">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-green-500/20 text-green-400 font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Online indicator */}
<span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3.5 w-3.5 bg-green-500 border-2 border-black" />
</span>
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-white text-sm truncate">{displayName}</p>
<p className="text-xs text-green-400 truncate">
{emoji} {status}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-gray-400 hover:text-[#ccff00]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Navigate to DM
window.location.href = `/messages/${npub}`;
}}
>
<MessageCircle className="h-4 w-4" />
</Button>
</div>
</Link>
);
}
export function WhosSkating({ spotPubkey, spotIdentifier, className }: WhosSkatingProps) {
const { data: skatersAtSpot, isLoading } = useWhosSkatingAtSpot(spotPubkey, spotIdentifier);
// Don't show if no one is skating
if (!isLoading && (!skatersAtSpot || skatersAtSpot.length === 0)) {
return null;
}
return (
<Card className={cn('bg-gradient-to-br from-green-500/10 to-[#ccff00]/10 border-2 border-green-500/30', className)}>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<span className="relative flex">
<span className="animate-ping absolute inline-flex h-3 w-3 rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500" />
</span>
<Users className="h-5 w-5 text-green-400" />
Who's Skating
{skatersAtSpot && skatersAtSpot.length > 0 && (
<Badge className="bg-green-500/20 text-green-400 border-green-500/50">
{skatersAtSpot.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<>
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
</>
) : skatersAtSpot && skatersAtSpot.length > 0 ? (
<>
{skatersAtSpot.slice(0, 5).map((skater) => {
const preset = skater.statusId
? SKATING_STATUSES.find(s => s.id === skater.statusId)
: null;
return (
<SkaterCard
key={skater.pubkey}
pubkey={skater.pubkey}
status={preset?.label || skater.status}
emoji={skater.emoji || preset?.emoji || '🛹'}
/>
);
})}
{skatersAtSpot.length > 5 && (
<p className="text-xs text-gray-400 text-center pt-2">
+{skatersAtSpot.length - 5} more
</p>
)}
</>
) : null}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,58 @@
import { ZapDialog } from '@/components/ZapDialog';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { Zap } from 'lucide-react';
import type { Event } from 'nostr-tools';
interface ZapButtonProps {
target: Event;
className?: string;
showCount?: boolean;
zapData?: { count: number; totalSats: number; isLoading?: boolean };
}
export function ZapButton({
target,
className = "text-xs ml-1",
showCount = true,
zapData: externalZapData
}: ZapButtonProps) {
const { user } = useCurrentUser();
const { data: author } = useAuthor(target?.pubkey || '');
const { webln, activeNWC } = useWallet();
// Only fetch data if not provided externally
const { totalSats: fetchedTotalSats, isLoading } = useZaps(
externalZapData ? [] : target ?? [], // Empty array prevents fetching if external data provided
webln,
activeNWC
);
// Don't show zap button if user is not logged in, is the author, or author has no lightning address
if (!user || !target || user.pubkey === target.pubkey || (!author?.metadata?.lud16 && !author?.metadata?.lud06)) {
return null;
}
// Use external data if provided, otherwise use fetched data
const totalSats = externalZapData?.totalSats ?? fetchedTotalSats;
const showLoading = externalZapData?.isLoading || isLoading;
return (
<ZapDialog target={target}>
<div className={`flex items-center gap-1 ${className}`}>
<Zap className="h-4 w-4" />
<span className="text-xs">
{showLoading ? (
'...'
) : showCount && totalSats > 0 ? (
`${totalSats.toLocaleString()}`
) : (
'Zap'
)}
</span>
</div>
</ZapDialog>
);
}

View file

@ -0,0 +1,700 @@
import { useState, useEffect, useRef, forwardRef } from 'react';
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X, Wallet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
DrawerClose,
} from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useCashu } from '@/hooks/wallet/useCashu';
import { useLightningOperations } from '@/hooks/wallet/useLightningOperations';
import { WalletModal } from '@/components/WalletModal';
import type { Event } from 'nostr-tools';
import QRCode from 'qrcode';
import type { WebLNProvider } from "@webbtc/webln-types";
interface ZapDialogProps {
target: Event;
children?: React.ReactNode;
className?: string;
}
const presetAmounts = [
{ amount: 1, icon: Sparkle },
{ amount: 50, icon: Sparkles },
{ amount: 100, icon: Zap },
{ amount: 250, icon: Star },
{ amount: 1000, icon: Rocket },
];
interface ZapContentProps {
invoice: string | null;
amount: number | string;
comment: string;
isZapping: boolean;
qrCodeUrl: string;
copied: boolean;
webln: WebLNProvider | null;
handleZap: () => void;
handleCopy: () => void;
openInWallet: () => void;
setAmount: (amount: number | string) => void;
setComment: (comment: string) => void;
inputRef: React.RefObject<HTMLInputElement>;
zap: (amount: number, comment: string) => void;
paymentMethod?: 'lightning' | 'cashu';
setPaymentMethod?: (method: 'lightning' | 'cashu') => void;
cashuBalance?: number;
hasCashu?: boolean;
}
// Wallet Setup Screen - shown when user has no wallet connected
const WalletSetupContent = forwardRef<HTMLDivElement, {
onContinue: () => void;
}>(({ onContinue }, ref) => (
<div className="px-4 py-6 space-y-6" ref={ref}>
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/20 flex items-center justify-center">
<Wallet className="h-8 w-8 text-orange-400" />
</div>
<div>
<h3 className="font-bold text-lg mb-2">Connect Your Wallet First</h3>
<p className="text-sm text-muted-foreground">
To send zaps (Bitcoin tips), you need to connect a Lightning wallet.
</p>
</div>
</div>
<Card className="bg-blue-950/30 border-blue-500/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="text-2xl">💡</div>
<div className="flex-1">
<p className="text-sm font-medium mb-1">Quick Setup (30 seconds)</p>
<p className="text-xs text-muted-foreground">
Connect your existing Lightning wallet with Nostr Wallet Connect (NWC). No extra accounts needed.
</p>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-3">
<WalletModal>
<Button className="w-full gap-2 bg-orange-500 hover:bg-orange-600" size="lg">
<Wallet className="h-4 w-4" />
Connect Wallet with NWC
</Button>
</WalletModal>
<a
href="https://planb.academy/en/tutorials/wallet"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Button variant="outline" className="w-full gap-2" size="lg">
<ExternalLink className="h-4 w-4" />
Don't have a wallet? Learn how
</Button>
</a>
<Button
variant="ghost"
onClick={onContinue}
className="w-full text-muted-foreground hover:text-foreground"
size="sm"
>
Skip for now (manual invoice)
</Button>
</div>
<Card className="bg-yellow-950/30 border-yellow-500/30">
<CardContent className="p-3">
<p className="text-xs text-gray-300">
<strong className="text-yellow-400">ℹ️ Note:</strong> You can still create an invoice without connecting a wallet, but you'll need to copy it manually to pay.
</p>
</CardContent>
</Card>
</div>
));
WalletSetupContent.displayName = 'WalletSetupContent';
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
invoice,
amount,
comment,
isZapping,
qrCodeUrl,
copied,
webln,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
zap,
paymentMethod,
setPaymentMethod,
cashuBalance,
hasCashu,
}, ref) => (
<div ref={ref}>
{invoice ? (
<div className="flex flex-col h-full min-h-0">
{/* Payment amount display */}
<div className="text-center pt-4">
<div className="text-2xl font-bold">{amount} sats</div>
</div>
<Separator className="my-4" />
<div className="flex flex-col justify-center min-h-0 flex-1 px-2">
{/* QR Code */}
<div className="flex justify-center">
<Card className="p-3 [@media(max-height:680px)]:max-w-[65vw] max-w-[95vw] mx-auto">
<CardContent className="p-0 flex justify-center">
{qrCodeUrl ? (
<img
src={qrCodeUrl}
alt="Lightning Invoice QR Code"
className="w-full h-auto aspect-square max-w-full object-contain"
/>
) : (
<div className="w-full aspect-square bg-muted animate-pulse rounded" />
)}
</CardContent>
</Card>
</div>
{/* Invoice input */}
<div className="space-y-2 mt-4">
<Label htmlFor="invoice">Lightning Invoice</Label>
<div className="flex gap-2 min-w-0">
<Input
id="invoice"
value={invoice}
readOnly
className="font-mono text-xs min-w-0 flex-1 overflow-hidden text-ellipsis"
onClick={(e) => e.currentTarget.select()}
/>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Payment buttons */}
<div className="space-y-3 mt-4">
{webln && (
<Button
onClick={() => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
}}
disabled={isZapping}
className="w-full"
size="lg"
>
<Zap className="h-4 w-4 mr-2" />
{isZapping ? "Processing..." : "Pay with WebLN"}
</Button>
)}
<Button
variant="outline"
onClick={openInWallet}
className="w-full"
size="lg"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open in Lightning Wallet
</Button>
<div className="text-xs sm:text-[.65rem] text-muted-foreground text-center">
Scan the QR code or copy the invoice to pay with any Lightning wallet.
</div>
</div>
</div>
</div>
) : (
<>
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
{/* Cashu Balance Banner - Show prominently if user has Cashu funds */}
{hasCashu && cashuBalance !== undefined && cashuBalance > 0 && setPaymentMethod && (
<Card className={cn(
"border-2 transition-all cursor-pointer",
paymentMethod === 'cashu'
? "bg-orange-500/20 border-orange-500/50"
: "bg-orange-500/10 border-orange-500/30 hover:bg-orange-500/15"
)}
onClick={() => setPaymentMethod('cashu')}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<span className="text-xl">🥜</span>
</div>
<div>
<p className="text-sm font-bold text-orange-400">Cashu Wallet</p>
<p className="text-xs text-muted-foreground">Pay instantly with your built-in wallet</p>
</div>
</div>
<div className="text-right">
<p className="text-lg font-bold text-orange-400">{cashuBalance.toLocaleString()}</p>
<p className="text-[10px] text-muted-foreground uppercase">sats available</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Payment Method Selector - show if user has Cashu but might want Lightning too */}
{hasCashu && cashuBalance !== undefined && setPaymentMethod && (
<>
<div className="flex items-center gap-2">
<Button
type="button"
variant={paymentMethod === 'cashu' ? 'default' : 'ghost'}
size="sm"
onClick={() => setPaymentMethod('cashu')}
className={cn(
"flex-1 gap-2 h-9",
paymentMethod === 'cashu' && "bg-orange-500 hover:bg-orange-600"
)}
>
<span>🥜</span>
<span className="text-xs">Cashu</span>
</Button>
<Button
type="button"
variant={paymentMethod === 'lightning' ? 'default' : 'ghost'}
size="sm"
onClick={() => setPaymentMethod('lightning')}
className="flex-1 gap-2 h-9"
>
<Zap className="h-4 w-4" />
<span className="text-xs">Lightning</span>
</Button>
</div>
<Separator />
</>
)}
<ToggleGroup
type="single"
value={String(amount)}
onValueChange={(value) => {
if (value) {
setAmount(parseInt(value, 10));
}
}}
className="grid grid-cols-5 gap-1 w-full"
>
{presetAmounts.map(({ amount: presetAmount, icon: Icon }) => (
<ToggleGroupItem
key={presetAmount}
value={String(presetAmount)}
className="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
>
<Icon className="h-4 w-4 mb-1" />
<span className="truncate">{presetAmount}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<div className="h-px flex-1 bg-muted" />
</div>
<Input
ref={inputRef}
id="custom-amount"
type="number"
placeholder="Custom amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full text-sm"
/>
<Textarea
id="custom-comment"
placeholder="Add a comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full resize-none text-sm"
rows={2}
/>
</div>
<div className="px-4 pb-4 space-y-2">
{/* Show insufficient balance warning for Cashu */}
{paymentMethod === 'cashu' && cashuBalance !== undefined && (
typeof amount === 'number' && amount > cashuBalance ? (
<Card className="bg-yellow-950/30 border-yellow-500/30">
<CardContent className="p-3">
<p className="text-xs text-yellow-200">
⚠️ Insufficient Cashu balance. You need {(amount - cashuBalance).toLocaleString()} more sats.
Switch to Lightning or top up your Cashu wallet.
</p>
</CardContent>
</Card>
) : null
)}
<Button
onClick={handleZap}
className="w-full"
disabled={
isZapping ||
(paymentMethod === 'cashu' && typeof amount === 'number' && cashuBalance !== undefined && amount > cashuBalance)
}
size="default"
>
{isZapping ? (
'Creating invoice...'
) : (
<>
{paymentMethod === 'cashu' ? '🥜' : <Zap className="h-4 w-4 mr-2" />}
Zap {amount} sats {paymentMethod === 'cashu' ? 'with Cashu' : ''}
</>
)}
</Button>
</div>
</>
)}
</div>
));
ZapContent.displayName = 'ZapContent';
export function ZapDialog({ target, children, className }: ZapDialogProps) {
const [open, setOpen] = useState(false);
const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const [amount, setAmount] = useState<number | string>(100);
const [comment, setComment] = useState<string>('');
const [copied, setCopied] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const [showWalletSetup, setShowWalletSetup] = useState(false);
const [hasShownSetup, setHasShownSetup] = useState(false);
const [paymentMethod, setPaymentMethod] = useState<'lightning' | 'cashu'>('lightning');
const inputRef = useRef<HTMLInputElement>(null);
const isMobile = useIsMobile();
// Cashu wallet integration
const { totalBalance: cashuBalance, isInitialized: hasCashu, coco, mints } = useCashu();
// Check if user has any wallet connected
const hasWallet = !!webln || !!activeNWC;
const hasAnyCashuBalance = hasCashu && cashuBalance > 0;
useEffect(() => {
if (target) {
setComment('Zapped with Engorile!');
}
}, [target]);
// Generate QR code
useEffect(() => {
let isCancelled = false;
const generateQR = async () => {
if (!invoice) {
setQrCodeUrl('');
return;
}
try {
const url = await QRCode.toDataURL(invoice.toUpperCase(), {
width: 512,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
if (!isCancelled) {
setQrCodeUrl(url);
}
} catch (err) {
if (!isCancelled) {
console.error('Failed to generate QR code:', err);
}
}
};
generateQR();
return () => {
isCancelled = true;
};
}, [invoice]);
const handleCopy = async () => {
if (invoice) {
await navigator.clipboard.writeText(invoice);
setCopied(true);
toast({
title: 'Invoice copied',
description: 'Lightning invoice copied to clipboard',
});
setTimeout(() => setCopied(false), 2000);
}
};
const openInWallet = () => {
if (invoice) {
const lightningUrl = `lightning:${invoice}`;
window.open(lightningUrl, '_blank');
}
};
useEffect(() => {
if (open) {
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
// Show wallet setup screen if no wallet connected and haven't shown it yet
if (!hasWallet && !hasShownSetup) {
setShowWalletSetup(true);
} else {
setShowWalletSetup(false);
}
} else {
// Clean up state when dialog closes
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
setShowWalletSetup(false);
}
}, [open, setInvoice, hasWallet, hasShownSetup]);
const handleZap = async () => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
// If Cashu payment method is selected and user has invoice to pay
if (paymentMethod === 'cashu' && hasCashu && coco && invoice) {
try {
// Get first active mint
const activeMint = mints.find(m => m.url);
if (!activeMint) {
throw new Error('No active mint found');
}
// Pay the Lightning invoice using Cashu (create and pay melt quote)
const meltQuote = await coco.quotes.createMeltQuote(activeMint.url, invoice);
await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote);
toast({
title: 'Zap sent!',
description: `Successfully zapped ${finalAmount} sats from your Cashu wallet`,
});
setOpen(false);
setInvoice(null);
} catch (error) {
console.error('Cashu payment error:', error);
toast({
variant: 'destructive',
title: 'Payment failed',
description: error instanceof Error ? error.message : 'Failed to pay with Cashu wallet',
});
}
} else {
// Use regular Lightning/NWC zap flow
zap(finalAmount, comment);
}
};
const handleContinueWithoutWallet = () => {
setShowWalletSetup(false);
setHasShownSetup(true);
};
const contentProps = {
invoice,
amount,
comment,
isZapping,
qrCodeUrl,
copied,
webln,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
zap,
paymentMethod,
setPaymentMethod,
cashuBalance,
hasCashu,
};
if (!user || user.pubkey === target.pubkey || !author?.metadata?.lud06 && !author?.metadata?.lud16) {
return null;
}
if (isMobile) {
// Use drawer for entire mobile flow, make it full-screen when showing invoice
return (
<Drawer
open={open}
onOpenChange={(newOpen) => {
// Reset invoice when closing
if (!newOpen) {
setInvoice(null);
setQrCodeUrl('');
}
setOpen(newOpen);
}}
dismissible={true} // Always allow dismissal via drag
snapPoints={invoice ? [0.5, 0.75, 0.98] : [0.98]}
activeSnapPoint={invoice ? 0.98 : 0.98}
modal={true}
shouldScaleBackground={false}
fadeFromIndex={0}
>
<DrawerTrigger asChild>
<div className={`cursor-pointer ${className || ''}`}>
{children}
</div>
</DrawerTrigger>
<DrawerContent
key={invoice ? 'payment' : 'form'}
className={cn(
"transition-all duration-300",
invoice ? "h-full max-h-screen" : "max-h-[98vh]"
)}
data-testid="zap-modal"
>
<DrawerHeader className="text-center relative">
{/* Back button when showing invoice */}
{invoice && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setInvoice(null);
setQrCodeUrl('');
}}
className="absolute left-4 top-4 flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{/* Close button */}
<DrawerClose asChild>
<Button
variant="ghost"
size="sm"
className="absolute right-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</DrawerClose>
<DrawerTitle className="text-lg break-words pt-2">
{showWalletSetup ? 'Connect Wallet' : invoice ? 'Lightning Payment' : 'Send a Zap'}
</DrawerTitle>
<DrawerDescription className="text-sm break-words text-center">
{showWalletSetup ? (
'Connect your Lightning wallet to send zaps'
) : invoice ? (
'Pay with Bitcoin Lightning Network'
) : (
'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!'
)}
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
{showWalletSetup ? (
<WalletSetupContent onContinue={handleContinueWithoutWallet} />
) : (
<ZapContent {...contentProps} />
)}
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<div className={`cursor-pointer ${className || ''}`}>
{children}
</div>
</DialogTrigger>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg break-words">
{showWalletSetup ? 'Connect Wallet' : invoice ? 'Lightning Payment' : 'Send a Zap'}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm text-center break-words">
{showWalletSetup ? (
'Connect your Lightning wallet to send zaps'
) : invoice ? (
'Pay with Bitcoin Lightning Network'
) : (
<>
Zaps are small Bitcoin payments that support creators. Consider sending a zap!
</>
)}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto -mx-4 sm:-mx-6 px-4 sm:px-6">
{showWalletSetup ? (
<WalletSetupContent onContinue={handleContinueWithoutWallet} />
) : (
<ZapContent {...contentProps} />
)}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,107 @@
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import { ChevronDown, LogOut, UserIcon, UserPlus, Wallet } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
import { WalletModal } from '@/components/WalletModal';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { genUserName } from '@/lib/genUserName';
interface AccountSwitcherProps {
onAddAccountClick: () => void;
}
export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
const { currentUser, otherUsers, setLogin, removeLogin } = useLoggedInAccounts();
if (!currentUser) return null;
const getDisplayName = (account: Account): string => {
return account.metadata.name ?? genUserName(account.pubkey);
}
const getNip05 = (account: Account): string | undefined => {
return account.metadata.nip05;
}
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button className='flex items-center gap-3 p-3 rounded-full hover:bg-accent transition-all w-full text-foreground'>
<div className="relative">
<Avatar className='w-10 h-10'>
<AvatarImage src={currentUser.metadata.picture} alt={getDisplayName(currentUser)} />
<AvatarFallback>{getDisplayName(currentUser).charAt(0)}</AvatarFallback>
</Avatar>
{/* Green online indicator dot */}
<span className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 border-2 border-background rounded-full" />
</div>
<div className='flex-1 text-left hidden md:block truncate'>
<p className='font-medium text-sm truncate'>{getDisplayName(currentUser)}</p>
{getNip05(currentUser) && (
<p className='text-xs text-muted-foreground truncate'>
{getNip05(currentUser)}
</p>
)}
</div>
<ChevronDown className='w-4 h-4 text-muted-foreground' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56 p-2 animate-scale-in'>
<div className='font-medium text-sm px-2 py-1.5'>Switch Account</div>
{otherUsers.map((user) => (
<DropdownMenuItem
key={user.id}
onClick={() => setLogin(user.id)}
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
>
<Avatar className='w-8 h-8'>
<AvatarImage src={user.metadata.picture} alt={getDisplayName(user)} />
<AvatarFallback>{getDisplayName(user)?.charAt(0) || <UserIcon />}</AvatarFallback>
</Avatar>
<div className='flex-1 truncate'>
<p className='text-sm font-medium'>{getDisplayName(user)}</p>
{getNip05(user) && (
<p className='text-xs text-muted-foreground truncate'>
{getNip05(user)}
</p>
)}
</div>
{user.id === currentUser.id && <div className='w-2 h-2 rounded-full bg-primary'></div>}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<WalletModal>
<DropdownMenuItem
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
onSelect={(e) => e.preventDefault()}
>
<Wallet className='w-4 h-4' />
<span>Wallet Settings</span>
</DropdownMenuItem>
</WalletModal>
<DropdownMenuItem
onClick={onAddAccountClick}
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
>
<UserPlus className='w-4 h-4' />
<span>Add another account</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => removeLogin(currentUser.id)}
className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500'
>
<LogOut className='w-4 h-4' />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,229 @@
/**
* AmberCallbackHandler
*
* This component handles Amber (NIP-55) callbacks at the app level.
* It processes results from Amber after signing operations and login.
*
* For web apps, Amber communication works via URL redirects:
* 1. App opens nostrsigner: URL to request signing
* 2. Amber opens and user approves/rejects
* 3. Amber redirects back to callbackUrl with result in query params
* 4. This component processes the result
*
* PWA NOTE: PWAs can lose localStorage when redirecting to external apps.
* This handler uses multiple fallback strategies:
* 1. Check localStorage, sessionStorage, and IndexedDB for pending operation
* 2. If all storage is lost but result looks like a pubkey, assume it's a login
*/
import { useEffect, useRef, useState } from 'react';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import {
isAmberCallback,
getAmberCallbackResult,
getAmberPendingOperationAsync,
clearAmberPendingOperation,
cleanAmberCallbackUrl,
} from '@/lib/amber';
export function AmberCallbackHandler() {
const login = useLoginActions();
const { toast } = useToast();
const hasProcessed = useRef(false);
const [debugInfo, setDebugInfo] = useState<string | null>(null);
useEffect(() => {
// Log on mount for debugging
console.log('[AmberCallback] Component mounted');
console.log('[AmberCallback] URL:', window.location.href);
console.log('[AmberCallback] Search:', window.location.search);
console.log('[AmberCallback] Pre-captured event:', sessionStorage.getItem('amber_callback_event'));
// Check if this is an Amber callback
const isCallback = isAmberCallback();
console.log('[AmberCallback] isAmberCallback():', isCallback);
if (!isCallback) {
return;
}
// Only process once per page load
if (hasProcessed.current) {
console.log('[AmberCallback] Already processed, skipping');
return;
}
hasProcessed.current = true;
console.log('[AmberCallback] Processing Amber callback...');
// Show debug info temporarily
setDebugInfo('Processing Amber login...');
// Process async to allow IndexedDB lookup
processAmberCallback();
async function processAmberCallback() {
const result = getAmberCallbackResult();
console.log('[AmberCallback] Result:', result);
setDebugInfo(`Got result: ${result ? result.substring(0, 16) + '...' : 'null'}`);
// Clean up URL immediately to prevent re-processing on refresh
cleanAmberCallbackUrl();
if (!result) {
// User cancelled or Amber failed
clearAmberPendingOperation();
setDebugInfo('No result from Amber');
toast({
title: 'Amber',
description: 'No result received from Amber. Operation may have been cancelled.',
variant: 'destructive',
});
return;
}
// Try to get pending operation from any storage
const pendingOp = await getAmberPendingOperationAsync();
console.log('[AmberCallback] Pending operation:', pendingOp);
// FALLBACK: If no pending operation found, but result looks like a valid pubkey,
// assume this is a login callback (PWA storage was cleared during redirect)
const pubkeyRegex = /^[0-9a-fA-F]{64}$/;
if (!pendingOp) {
console.warn('[AmberCallback] No pending operation found - storage may have been cleared');
// Check if result looks like a pubkey (login callback)
if (pubkeyRegex.test(result)) {
console.log('[AmberCallback] Result looks like pubkey, treating as login callback');
setDebugInfo('Valid pubkey! Logging in...');
try {
login.amber(result.toLowerCase());
setDebugInfo('Login successful!');
toast({
title: 'Logged in',
description: 'Successfully logged in with Amber!',
});
// Clear debug after success
setTimeout(() => setDebugInfo(null), 2000);
return;
} catch (error) {
console.error('[AmberCallback] Amber login failed:', error);
setDebugInfo(`Login error: ${error}`);
toast({
title: 'Login failed',
description: 'Failed to complete Amber login. Please try again.',
variant: 'destructive',
});
return;
}
}
// Not a pubkey - can't determine what operation this was for
setDebugInfo(`Invalid result format: ${result.substring(0, 20)}...`);
toast({
title: 'Amber',
description: 'Session data was lost. Please try again.',
variant: 'destructive',
});
return;
}
// Process based on operation type
switch (pendingOp.type) {
case 'get_public_key': {
console.log('[AmberCallback] Processing get_public_key...');
if (pubkeyRegex.test(result)) {
try {
login.amber(result.toLowerCase());
clearAmberPendingOperation();
console.log('[AmberCallback] Login successful!');
toast({
title: 'Logged in',
description: 'Successfully logged in with Amber!',
});
} catch (error) {
console.error('[AmberCallback] Amber login failed:', error);
clearAmberPendingOperation();
toast({
title: 'Login failed',
description: 'Failed to complete Amber login. Please try again.',
variant: 'destructive',
});
}
} else {
console.warn('[AmberCallback] Invalid pubkey format:', result);
clearAmberPendingOperation();
toast({
title: 'Login failed',
description: 'Invalid pubkey received from Amber.',
variant: 'destructive',
});
}
break;
}
case 'sign_event': {
// Store the signed event result for the caller to retrieve
// This is tricky because the original caller's promise can't be resolved
// after a page redirect. For now, we'll store it and show a toast.
try {
const signedEvent = JSON.parse(result);
// Store in sessionStorage for potential retrieval
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
clearAmberPendingOperation();
toast({
title: 'Event signed',
description: 'Your event has been signed by Amber.',
});
// Reload the page to re-submit the event
// This is a workaround since we can't resolve the original promise
window.location.reload();
} catch (e) {
console.error('Failed to parse signed event:', e);
clearAmberPendingOperation();
toast({
title: 'Signing failed',
description: 'Failed to process signed event from Amber.',
variant: 'destructive',
});
}
break;
}
case 'nip04_encrypt':
case 'nip44_encrypt':
case 'nip04_decrypt':
case 'nip44_decrypt': {
// Store encryption/decryption results
sessionStorage.setItem('amber_crypto_result', result);
clearAmberPendingOperation();
toast({
title: 'Operation complete',
description: `${pendingOp.type.replace('_', ' ')} completed.`,
});
break;
}
default: {
console.warn('Unknown Amber operation type:', pendingOp.type);
clearAmberPendingOperation();
}
}
}
}, [login, toast]);
// Show debug overlay when processing Amber callback
if (debugInfo) {
return (
<div className="fixed top-0 left-0 right-0 z-[9999] bg-yellow-500 text-black px-4 py-2 text-center text-sm font-mono">
🔐 Amber: {debugInfo}
</div>
);
}
return null;
}

View file

@ -0,0 +1,116 @@
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import { useState } from 'react';
import { User, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button.tsx';
import LoginDialog from './LoginDialog';
import SignupDialog from './SignupDialog';
import SignupMethodDialog from './SignupMethodDialog';
import MnemonicSignupDialog from './MnemonicSignupDialog';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { AccountSwitcher } from './AccountSwitcher';
import { cn } from '@/lib/utils';
export interface LoginAreaProps {
className?: string;
/** Compact mode for mobile headers - smaller buttons without text */
compact?: boolean;
}
export function LoginArea({ className, compact }: LoginAreaProps) {
const { currentUser } = useLoggedInAccounts();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signupMethodDialogOpen, setSignupMethodDialogOpen] = useState(false);
const [mnemonicSignupDialogOpen, setMnemonicSignupDialogOpen] = useState(false);
const [nsecSignupDialogOpen, setNsecSignupDialogOpen] = useState(false);
const handleLogin = () => {
setLoginDialogOpen(false);
setSignupMethodDialogOpen(false);
setMnemonicSignupDialogOpen(false);
setNsecSignupDialogOpen(false);
};
return (
<div className={cn("inline-flex items-center justify-center", className)}>
{currentUser ? (
<AccountSwitcher onAddAccountClick={() => setLoginDialogOpen(true)} />
) : compact ? (
// Compact mode for mobile headers - icon only
<div className="flex gap-1 justify-center">
<Button
onClick={() => setSignupMethodDialogOpen(true)}
size="icon"
className='h-9 w-9 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
title="Sign Up"
>
<UserPlus className='w-4 h-4' />
</Button>
<Button
onClick={() => setLoginDialogOpen(true)}
variant="outline"
size="icon"
className="h-9 w-9 rounded-full border border-gray-600 hover:border-[#ff0040]"
title="Log In"
>
<User className='w-4 h-4' />
</Button>
</div>
) : (
// Default full-size buttons
<div className="flex gap-3 justify-center w-full">
<Button
onClick={() => setSignupMethodDialogOpen(true)}
className='flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold transition-all flex-1 sm:flex-none'
>
<UserPlus className='w-4 h-4' />
<span>Sign Up</span>
</Button>
<Button
onClick={() => setLoginDialogOpen(true)}
variant="outline"
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-medium transition-all border-2 border-gray-600 hover:border-[#ff0040] flex-1 sm:flex-none"
>
<User className='w-4 h-4' />
<span className='truncate'>Log In</span>
</Button>
</div>
)}
<LoginDialog
isOpen={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
onLogin={handleLogin}
onSignup={() => setSignupMethodDialogOpen(true)}
/>
<SignupMethodDialog
isOpen={signupMethodDialogOpen}
onClose={() => setSignupMethodDialogOpen(false)}
onSelectMnemonic={() => {
setSignupMethodDialogOpen(false);
setMnemonicSignupDialogOpen(true);
}}
onSelectNsec={() => {
setSignupMethodDialogOpen(false);
setNsecSignupDialogOpen(true);
}}
/>
<MnemonicSignupDialog
isOpen={mnemonicSignupDialogOpen}
onClose={() => setMnemonicSignupDialogOpen(false)}
onSwitchToNsec={() => {
setMnemonicSignupDialogOpen(false);
setNsecSignupDialogOpen(true);
}}
/>
<SignupDialog
isOpen={nsecSignupDialogOpen}
onClose={() => setNsecSignupDialogOpen(false)}
/>
</div>
);
}

View file

@ -0,0 +1,501 @@
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import React, { useState, useEffect } from 'react';
import { Shield, AlertTriangle, UserPlus, Sparkles, Cloud, Smartphone, ClipboardPaste, Check, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface LoginDialogProps {
isOpen: boolean;
onClose: () => void;
onLogin: () => void;
onSignup?: () => void;
}
const validateBunkerUri = (uri: string) => {
return uri.startsWith('bunker://');
};
const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
const [isLoading, setIsLoading] = useState(false);
const [bunkerUri, setBunkerUri] = useState('');
const [errors, setErrors] = useState<{
bunker?: string;
extension?: string;
amber?: string;
}>({});
const [amberStep, setAmberStep] = useState<'initial' | 'waiting' | 'success'>('initial');
const [amberPubkeyInput, setAmberPubkeyInput] = useState('');
const login = useLoginActions();
const { toast } = useToast();
// Reset all state when dialog opens/closes
useEffect(() => {
if (isOpen) {
// Reset state when dialog opens
setIsLoading(false);
setBunkerUri('');
setErrors({});
setAmberStep('initial');
setAmberPubkeyInput('');
}
}, [isOpen]);
const handleExtensionLogin = async () => {
setIsLoading(true);
setErrors(prev => ({ ...prev, extension: undefined }));
try {
if (!('nostr' in window)) {
throw new Error('Nostr extension not found. Please install a NIP-07 extension.');
}
await login.extension();
onLogin();
onClose();
} catch (e: unknown) {
const error = e as Error;
console.error('Bunker login failed:', error);
console.error('Nsec login failed:', error);
console.error('Extension login failed:', error);
setErrors(prev => ({
...prev,
extension: error instanceof Error ? error.message : 'Extension login failed'
}));
} finally {
setIsLoading(false);
}
};
const handleAmberLogin = () => {
// For PWA: Open Amber WITHOUT callback URL so it copies to clipboard
// Then user can paste the pubkey
setAmberStep('waiting');
setErrors(prev => ({ ...prev, amber: undefined }));
// Open Amber without callback - it will copy pubkey to clipboard
const amberUrl = 'nostrsigner:?compressionType=none&returnType=signature&type=get_public_key';
window.location.href = amberUrl;
};
const handleAmberPaste = async () => {
setErrors(prev => ({ ...prev, amber: undefined }));
try {
// Try to read from clipboard
const text = await navigator.clipboard.readText();
const pubkey = text.trim().toLowerCase();
// Validate pubkey format (64 hex characters)
const pubkeyRegex = /^[0-9a-f]{64}$/;
if (!pubkeyRegex.test(pubkey)) {
setErrors(prev => ({
...prev,
amber: 'Invalid pubkey format. Make sure Amber copied the pubkey to clipboard.'
}));
return;
}
// Complete login
login.amber(pubkey);
setAmberStep('success');
toast({
title: 'Logged in',
description: 'Successfully logged in with Amber!',
});
setTimeout(() => {
onLogin();
onClose();
}, 500);
} catch (error) {
console.error('Clipboard read failed:', error);
setErrors(prev => ({
...prev,
amber: 'Could not read clipboard. Please paste your pubkey manually below.'
}));
}
};
const handleAmberManualInput = () => {
const pubkey = amberPubkeyInput.trim().toLowerCase();
// Validate pubkey format (64 hex characters)
const pubkeyRegex = /^[0-9a-f]{64}$/;
if (!pubkeyRegex.test(pubkey)) {
setErrors(prev => ({
...prev,
amber: 'Invalid pubkey. Must be 64 hexadecimal characters.'
}));
return;
}
// Complete login
login.amber(pubkey);
setAmberStep('success');
toast({
title: 'Logged in',
description: 'Successfully logged in with Amber!',
});
setTimeout(() => {
onLogin();
onClose();
}, 500);
};
const handleBunkerLogin = async () => {
if (!bunkerUri.trim()) {
setErrors(prev => ({ ...prev, bunker: 'Please enter a bunker URI' }));
return;
}
if (!validateBunkerUri(bunkerUri)) {
setErrors(prev => ({ ...prev, bunker: 'Invalid bunker URI format. Must start with bunker://' }));
return;
}
setIsLoading(true);
setErrors(prev => ({ ...prev, bunker: undefined }));
try {
await login.bunker(bunkerUri);
onLogin();
onClose();
// Clear the URI from memory
setBunkerUri('');
} catch {
setErrors(prev => ({
...prev,
bunker: 'Failed to connect to bunker. Please check the URI.'
}));
} finally {
setIsLoading(false);
}
};
const handleSignupClick = () => {
onClose();
if (onSignup) {
onSignup();
}
};
// Detect device type for better UX
const isAndroid = /Android/i.test(navigator.userAgent);
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
const isMobile = isAndroid || isIOS;
const hasExtension = typeof window !== 'undefined' && 'nostr' in window;
// Detect best default tab based on device and browser capabilities
const getDefaultTab = () => {
// Android - Amber is the best option
if (isAndroid) return 'amber';
// iOS - Bunker is recommended (no Amber support)
if (isIOS) return 'bunker';
// Desktop with extension - use extension
if (hasExtension) return 'extension';
// Desktop without extension - show extension tab with install prompt
return 'extension';
};
const defaultTab = getDefaultTab();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}
>
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1 relative')}>
<DialogDescription className="text-center text-sm">
Sign up or log in to continue
</DialogDescription>
</DialogHeader>
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Prominent Sign Up Section */}
<div className='relative p-6 rounded-2xl bg-black border-2 border-[#ccff00]/50 overflow-hidden'>
{/* Neon glow effect */}
<div className="absolute inset-0 bg-[#ccff00]/5 blur-xl"></div>
<div className='relative z-10 text-center space-y-3'>
<div className='flex justify-center items-center gap-2 mb-2'>
<Sparkles className='w-5 h-5 text-[#ccff00]' />
<span className='font-bold text-[#ccff00] uppercase tracking-wide' style={{
textShadow: '0 0 10px #ccff00'
}}>
New here?
</span>
</div>
<p className='text-sm text-gray-300'>
Create a free account in seconds. No email required.
</p>
<Button
onClick={handleSignupClick}
className='w-full rounded-full py-4 text-base font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 transform transition-all duration-200 hover:scale-105 shadow-[0_0_20px_rgba(204,255,0,0.5)] border-0 uppercase tracking-wide'
>
<UserPlus className='w-4 h-4 mr-2' />
<span>Create Account</span>
</Button>
</div>
</div>
{/* Divider */}
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
</div>
<div className='relative flex justify-center text-sm'>
<span className='px-3 bg-background text-muted-foreground'>
<span>Or log in</span>
</span>
</div>
</div>
{/* Login Methods */}
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-black/50 border border-[#ff0040]/30 rounded-lg mb-4 h-auto p-1">
<TabsTrigger value="extension" className="flex flex-col sm:flex-row items-center gap-0.5 sm:gap-1 text-[10px] sm:text-sm py-1.5 sm:py-2 px-1 sm:px-3">
<Shield className="w-4 h-4" />
<span className="hidden sm:inline">Extension</span>
<span className="sm:hidden">Ext</span>
</TabsTrigger>
<TabsTrigger value="amber" className="flex flex-col sm:flex-row items-center gap-0.5 sm:gap-1 text-[10px] sm:text-sm py-1.5 sm:py-2 px-1 sm:px-3">
<Smartphone className="w-4 h-4" />
<span>Amber</span>
</TabsTrigger>
<TabsTrigger value="bunker" className="flex flex-col sm:flex-row items-center gap-0.5 sm:gap-1 text-[10px] sm:text-sm py-1.5 sm:py-2 px-1 sm:px-3">
<Cloud className="w-4 h-4" />
<span className="hidden sm:inline">Bunker</span>
<span className="sm:hidden">Bnk</span>
</TabsTrigger>
</TabsList>
<TabsContent value='extension' className='space-y-3'>
{errors.extension && (
<Alert variant="destructive" className="bg-red-950/50 border-red-500/50">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
<strong>No extension detected.</strong> Try using the <strong>Create Account</strong> button above{isAndroid ? ', or use Amber on Android' : isIOS ? ', or use Bunker for iOS' : ''}.
</AlertDescription>
</Alert>
)}
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ff0040]/30'>
<Shield className='w-12 h-12 mx-auto mb-3 text-[#ff0040]' />
<p className='text-sm text-gray-300 mb-2 font-bold'>
{isMobile ? 'Browser Extension' : 'One-Click Login (Desktop)'}
</p>
<p className='text-xs text-gray-400 mb-4'>
{isMobile
? 'Works with mobile browsers that have Nostr extensions (e.g., Kiwi Browser + Alby).'
: 'Best for desktop browsers with Alby, nos2x, or similar extensions installed.'
}
</p>
<div className="flex justify-center">
<Button
className='w-full rounded-full py-4 bg-[#ff0040] hover:bg-[#ff0040]/90 border-2 border-[#ff0040] shadow-[0_0_20px_rgba(255,0,64,0.5)] font-bold uppercase'
onClick={handleExtensionLogin}
disabled={isLoading}
>
{isLoading ? 'Logging in...' : 'Login with Extension'}
</Button>
</div>
</div>
</TabsContent>
<TabsContent value='amber' className='space-y-3'>
{isIOS && (
<Alert className="bg-yellow-950/50 border-yellow-500/50">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
<AlertDescription className="text-sm text-yellow-200">
Amber is Android-only. For iOS, use <strong>Bunker</strong> (nsec.app or Nostrum).
</AlertDescription>
</Alert>
)}
{/* Step 1: Open Amber */}
{amberStep === 'initial' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30'>
<Smartphone className='w-12 h-12 mx-auto mb-3 text-[#ccff00]' />
<p className='text-sm text-gray-300 mb-2 font-bold'>
Login with Amber (Android)
</p>
<p className='text-xs text-gray-400 mb-4'>
Tap the button below to open Amber. Select your profile and approve the request. Then return here to complete login.
</p>
<div className="flex justify-center">
<Button
className='w-full rounded-full py-4 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 border-2 border-[#ccff00] shadow-[0_0_20px_rgba(204,255,0,0.5)] font-bold uppercase'
onClick={handleAmberLogin}
disabled={isLoading || isIOS}
>
{isIOS ? 'Not available on iOS' : '1. Open Amber'}
</Button>
</div>
<p className='text-xs text-gray-400 mt-3'>
Don't have Amber? <a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer" className="text-[#ccff00] hover:underline">Download from GitHub</a> or F-Droid
</p>
</div>
)}
{/* Step 2: Paste from clipboard */}
{amberStep === 'waiting' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-[#ccff00]/30'>
<ClipboardPaste className='w-12 h-12 mx-auto mb-3 text-[#ccff00]' />
<p className='text-sm text-gray-300 mb-2 font-bold'>
Step 2: Complete Login
</p>
<p className='text-xs text-gray-400 mb-4'>
After approving in Amber, your pubkey was copied to clipboard. Tap the button below to paste it and complete login.
</p>
{errors.amber && (
<Alert variant="destructive" className="mb-4 bg-red-950/50 border-red-500/50 text-left">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">{errors.amber}</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<Button
className='w-full rounded-full py-4 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 border-2 border-[#ccff00] shadow-[0_0_20px_rgba(204,255,0,0.5)] font-bold uppercase'
onClick={handleAmberPaste}
>
<ClipboardPaste className="w-4 h-4 mr-2" />
2. Paste from Clipboard
</Button>
<div className="text-xs text-gray-500">or paste manually:</div>
<div className="flex gap-2">
<Input
value={amberPubkeyInput}
onChange={(e) => setAmberPubkeyInput(e.target.value)}
placeholder="Paste your pubkey (64 hex chars)"
className="flex-1 text-xs"
/>
<Button
onClick={handleAmberManualInput}
disabled={!amberPubkeyInput.trim()}
className="bg-[#ccff00]/20 text-[#ccff00] hover:bg-[#ccff00]/30"
>
Go
</Button>
</div>
<Button
variant="ghost"
className="w-full text-xs text-gray-500"
onClick={() => setAmberStep('initial')}
>
← Back to Step 1
</Button>
</div>
</div>
)}
{/* Success state */}
{amberStep === 'success' && (
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-green-500/30'>
<Check className='w-12 h-12 mx-auto mb-3 text-green-500' />
<p className='text-sm text-green-400 mb-2 font-bold'>
Successfully logged in!
</p>
<Loader2 className="w-6 h-6 mx-auto animate-spin text-green-500" />
</div>
)}
</TabsContent>
<TabsContent value='bunker' className='space-y-4'>
{isIOS && (
<Alert className="bg-blue-950/50 border-blue-500/50">
<Cloud className="h-4 w-4 text-blue-400" />
<AlertDescription className="text-sm text-blue-200">
<strong>Recommended for iOS!</strong> Use nsec.app for the best experience on iPhone/iPad.
</AlertDescription>
</Alert>
)}
<div className='text-center p-6 rounded-lg bg-black/50 border-2 border-blue-500/30'>
<Cloud className='w-12 h-12 mx-auto mb-3 text-blue-400' />
<p className='text-sm text-gray-300 mb-2 font-bold'>
Login with Nostr Connect {isIOS && '(Best for iOS)'}
</p>
<p className='text-xs text-gray-400 mb-4'>
{isIOS
? 'Works on any device! Get a bunker:// connection string from nsec.app and paste it below.'
: 'Access your account from any device using a remote signer (bunker). Your keys stay safe on the server.'
}
</p>
<div className='space-y-2'>
<label htmlFor='bunkerUri' className='text-sm font-medium block text-left'>
Bunker Connection String
</label>
<Input
id='bunkerUri'
value={bunkerUri}
onChange={(e) => {
setBunkerUri(e.target.value);
if (errors.bunker) setErrors(prev => ({ ...prev, bunker: undefined }));
}}
className={`rounded-lg ${
errors.bunker ? 'border-red-500' : ''
}`}
placeholder='bunker://...'
autoComplete="off"
/>
{errors.bunker && (
<p className="text-sm text-red-500">{errors.bunker}</p>
)}
</div>
<div className="flex justify-center mt-4">
<Button
className='w-full rounded-full py-4 bg-blue-500 hover:bg-blue-600 border-2 border-blue-500 shadow-[0_0_20px_rgba(59,130,246,0.5)] font-bold uppercase'
onClick={handleBunkerLogin}
disabled={isLoading || !bunkerUri.trim()}
>
{isLoading ? 'Connecting...' : 'Connect to Bunker'}
</Button>
</div>
<div className='mt-4 pt-4 border-t border-gray-700'>
<p className='text-xs text-gray-500 mb-2'>Get a bunker connection from:</p>
<div className="flex flex-wrap gap-2 justify-center">
<a
href="https://nsec.app"
target="_blank"
rel="noopener noreferrer"
className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30 transition-colors"
>
nsec.app →
</a>
<a
href="https://nostrum.social"
target="_blank"
rel="noopener noreferrer"
className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30 transition-colors"
>
Nostrum →
</a>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
};
export default LoginDialog;

View file

@ -0,0 +1,453 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Download, Copy, CheckCircle, Eye, EyeOff, ArrowRight, RefreshCw, Shield, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent } from '@/components/ui/card';
import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions';
import { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
import {
generateMnemonicPhrase,
privateKeyFromMnemonic,
mnemonicToWords,
wordsToMnemonic
} from '@/lib/nip06';
import QRCode from 'qrcode';
interface MnemonicSignupDialogProps {
isOpen: boolean;
onClose: () => void;
onSwitchToNsec: () => void;
}
const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
isOpen,
onClose,
onSwitchToNsec
}) => {
const [step, setStep] = useState<'generate' | 'verify' | 'complete'>('generate');
const [mnemonic, setMnemonic] = useState('');
const [words, setWords] = useState<string[]>([]);
const [showMnemonic, setShowMnemonic] = useState(true);
const [verifyIndices, setVerifyIndices] = useState<number[]>([]);
const [verifyInputs, setVerifyInputs] = useState<Record<number, string>>({});
const [isVerified, setIsVerified] = useState(false);
const login = useLoginActions();
// Generate new mnemonic
const handleGenerate = () => {
const newMnemonic = generateMnemonicPhrase(128); // 12 words
setMnemonic(newMnemonic);
setWords(mnemonicToWords(newMnemonic));
// Pick 3 random words for verification
const indices: number[] = [];
while (indices.length < 3) {
const rand = Math.floor(Math.random() * 12);
if (!indices.includes(rand)) indices.push(rand);
}
setVerifyIndices(indices.sort((a, b) => a - b));
toast({
title: 'Recovery Phrase Generated! 🎉',
description: 'Write down these 12 words in order. They\'re the key to your account!',
});
};
// Download mnemonic as text file
const handleDownload = () => {
try {
const blob = new Blob([mnemonic], { type: 'text/plain; charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'nostr-recovery-phrase.txt';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
toast({
title: 'Recovery Phrase Saved!',
description: 'Keep this file in a secure location.',
});
} catch {
toast({
title: 'Download failed',
description: 'Could not download the file. Please copy manually.',
variant: 'destructive',
});
}
};
// Copy to clipboard
const handleCopy = () => {
navigator.clipboard.writeText(mnemonic);
toast({
title: 'Copied to clipboard!',
description: 'Your recovery phrase has been copied.',
});
};
// Generate QR code
const handleQR = async () => {
try {
const qrDataUrl = await QRCode.toDataURL(mnemonic, {
width: 300,
margin: 2,
});
// Create a new window with the QR code
const win = window.open('', '_blank');
if (win) {
win.document.write(`
<html>
<head>
<title>Recovery Phrase QR Code</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
font-family: system-ui, sans-serif;
background: #000;
color: #fff;
}
h1 { margin-bottom: 1rem; }
img { border: 8px solid #fff; border-radius: 8px; }
p { margin-top: 1rem; color: #ff0040; font-weight: bold; }
</style>
</head>
<body>
<h1>🔐 Your Recovery Phrase QR Code</h1>
<img src="${qrDataUrl}" alt="Recovery Phrase QR Code" />
<p>⚠️ Keep this QR code private and secure!</p>
<button onclick="window.print()" style="margin-top: 1rem; padding: 0.5rem 1rem; font-size: 16px; cursor: pointer;">
Print QR Code
</button>
</body>
</html>
`);
win.document.close();
}
} catch {
toast({
title: 'QR Code failed',
description: 'Could not generate QR code.',
variant: 'destructive',
});
}
};
// Proceed to verification
const handleProceedToVerify = () => {
setStep('verify');
};
// Verify the words
const handleVerify = () => {
const isCorrect = verifyIndices.every((idx) => {
return verifyInputs[idx]?.toLowerCase().trim() === words[idx];
});
if (isCorrect) {
setIsVerified(true);
// Log in with the mnemonic
try {
const privateKey = privateKeyFromMnemonic(mnemonic);
const nsec = nip19.nsecEncode(privateKey);
login.nsec(nsec);
setStep('complete');
toast({
title: 'Account Created! 🎉',
description: 'You\'re all set! Your recovery phrase is secure.',
});
// Mark signup as completed
localStorage.setItem('signup_completed', Date.now().toString());
setTimeout(() => {
onClose();
}, 2000);
} catch {
toast({
title: 'Login Failed',
description: 'Failed to create account. Please try again.',
variant: 'destructive',
});
}
} else {
toast({
title: 'Verification Failed',
description: 'The words don\'t match. Please check and try again.',
variant: 'destructive',
});
}
};
// Reset and start over
const handleStartOver = () => {
setMnemonic('');
setWords([]);
setVerifyInputs({});
setIsVerified(false);
setStep('generate');
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-gradient-to-br from-gray-900 to-black border-2 border-[#ccff00]/30">
<DialogHeader>
<DialogTitle className="text-2xl font-black uppercase text-center">
<span className="neon-orange">Create Account with</span>{' '}
<span className="neon-lime">Recovery Phrase</span>
</DialogTitle>
</DialogHeader>
{/* STEP 1: Generate & Backup */}
{step === 'generate' && (
<div className="space-y-6">
{!mnemonic ? (
<Card className="bg-black/50 border-[#ccff00]/20">
<CardContent className="p-8 text-center space-y-4">
<div className="text-6xl mb-4">🔑</div>
<h3 className="text-xl font-bold text-[#ccff00]">
12-Word Recovery Phrase
</h3>
<p className="text-gray-400 text-sm max-w-md mx-auto">
Your recovery phrase is like a master password. Write it down on paper and keep it safe.
You'll need it to access your account from other devices or if you clear your browser data.
</p>
<Button
onClick={handleGenerate}
className="w-full max-w-xs bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:from-[#ff0040]/90 hover:to-[#ff6600]/90 font-bold uppercase"
size="lg"
>
Generate Recovery Phrase
</Button>
<div className="pt-4">
<button
onClick={onSwitchToNsec}
className="text-xs text-gray-500 hover:text-gray-300 underline"
>
Use advanced nsec key instead
</button>
</div>
</CardContent>
</Card>
) : (
<>
{/* Recovery phrase display */}
<Card className="bg-black/50 border-[#ccff00]/30">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-[#ccff00] uppercase">Your Recovery Phrase</h3>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowMnemonic(!showMnemonic)}
className="text-gray-400 hover:text-white"
>
{showMnemonic ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleStartOver}
className="text-gray-400 hover:text-white"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
{words.map((word, idx) => (
<div
key={idx}
className={cn(
"flex items-center gap-2 p-3 rounded-lg border-2",
"bg-gray-900 border-[#ccff00]/20"
)}
>
<span className="text-xs text-gray-500 font-mono">{idx + 1}.</span>
<span className="font-mono text-sm text-white">
{showMnemonic ? word : '••••••'}
</span>
</div>
))}
</div>
<div className="flex flex-wrap gap-2 justify-center">
<Button
onClick={handleCopy}
variant="outline"
size="sm"
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button
onClick={handleDownload}
variant="outline"
size="sm"
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
<Button
onClick={handleQR}
variant="outline"
size="sm"
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
QR Code
</Button>
</div>
</CardContent>
</Card>
{/* Warning */}
<Card className="bg-[#ff0040]/10 border-2 border-[#ff0040]/50">
<CardContent className="p-6">
<h3 className="font-bold text-[#ff0040] mb-3 uppercase flex items-center gap-2">
<span>⚠️</span> Critical Security Warning
</h3>
<ul className="space-y-2 text-sm text-gray-300">
<li>✍️ <strong>Write it down</strong> on paper and store it safely</li>
<li>🔒 <strong>Never share</strong> your recovery phrase with anyone</li>
<li>💾 <strong>Keep multiple backups</strong> in different secure locations</li>
<li>❌ <strong>Anyone with this phrase</strong> controls your account forever</li>
<li>🚫 <strong>If you lose it</strong>, you lose your account permanently</li>
</ul>
</CardContent>
</Card>
{/* Continue button */}
<Button
onClick={handleProceedToVerify}
className="w-full bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase"
size="lg"
>
I've Saved It Securely
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</>
)}
</div>
)}
{/* STEP 2: Verify */}
{step === 'verify' && (
<div className="space-y-6">
<Card className="bg-black/50 border-[#ccff00]/30">
<CardContent className="p-6">
<h3 className="font-bold text-[#ccff00] mb-4 uppercase text-center">
Verify Your Recovery Phrase
</h3>
<p className="text-gray-400 text-sm mb-6 text-center">
To make sure you've saved it correctly, please enter these words:
</p>
<div className="space-y-4 max-w-md mx-auto">
{verifyIndices.map((idx) => (
<div key={idx} className="space-y-2">
<label className="text-sm text-gray-300 font-mono">
Word #{idx + 1}
</label>
<input
type="text"
value={verifyInputs[idx] || ''}
onChange={(e) => setVerifyInputs({ ...verifyInputs, [idx]: e.target.value })}
placeholder={`Enter word #${idx + 1}`}
className="w-full px-4 py-3 rounded-lg bg-gray-900 border-2 border-[#ccff00]/20 text-white placeholder-gray-600 focus:border-[#ccff00]/50 focus:outline-none font-mono"
/>
</div>
))}
</div>
</CardContent>
</Card>
<div className="flex gap-3">
<Button
onClick={() => setStep('generate')}
variant="outline"
className="flex-1 border-gray-600 text-gray-400 hover:bg-gray-800"
>
Go Back
</Button>
<Button
onClick={handleVerify}
disabled={!verifyIndices.every((idx) => verifyInputs[idx]?.trim())}
className="flex-1 bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase"
>
Verify & Create Account
</Button>
</div>
</div>
)}
{/* STEP 3: Complete */}
{step === 'complete' && (
<div className="py-6 space-y-6">
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/10 border-2 border-[#ccff00]/50">
<CardContent className="p-8 text-center space-y-4">
<div className="text-6xl mb-2">✅</div>
<h3 className="text-2xl font-black uppercase text-[#ccff00]">
Account Created!
</h3>
<p className="text-gray-300">
Your Nostr account is ready to go!
</p>
<CheckCircle className="h-12 w-12 mx-auto text-[#00ff00]" />
</CardContent>
</Card>
{/* Next Steps - Signer Recommendation */}
<Card className="bg-blue-950/30 border-2 border-blue-500/30">
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20">
<Shield className="h-5 w-5 text-blue-400" />
</div>
<div>
<h4 className="font-bold text-blue-400">Recommended: Set Up a Signer App</h4>
<p className="text-xs text-gray-400">Keep your keys extra secure</p>
</div>
</div>
<p className="text-sm text-gray-300">
For better security, use a dedicated signer app instead of pasting your recovery phrase everywhere.
Signer apps keep your keys safe while letting you use any Nostr app.
</p>
<Link to="/signers" onClick={onClose}>
<Button className="w-full bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 border border-blue-500/50">
<ExternalLink className="h-4 w-4 mr-2" />
Learn About Signer Apps
</Button>
</Link>
<button
onClick={onClose}
className="w-full text-xs text-gray-500 hover:text-gray-400 py-2"
>
Skip for now
</button>
</CardContent>
</Card>
</div>
)}
</DialogContent>
</Dialog>
);
};
export default MnemonicSignupDialog;

View file

@ -0,0 +1,845 @@
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Download, Key, UserPlus, FileText, Shield, User, Sparkles, LogIn, CheckCircle, Upload, Globe, Eye, EyeOff, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent } from '@/components/ui/card';
import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
interface SignupDialogProps {
isOpen: boolean;
onClose: () => void;
onComplete?: () => void;
}
const sanitizeFilename = (filename: string) => {
return filename.replace(/[^a-z0-9_.-]/gi, '_');
}
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete }) => {
const [step, setStep] = useState<'welcome' | 'generate' | 'download' | 'profile' | 'done'>('welcome');
const [isLoading, setIsLoading] = useState(false);
const [nsec, setNsec] = useState('');
const [showSparkles, setShowSparkles] = useState(false);
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
const [showNsec, setShowNsec] = useState(false);
const [showNpub, setShowNpub] = useState(false);
const [profileData, setProfileData] = useState({
name: '',
about: '',
picture: ''
});
const login = useLoginActions();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const avatarFileInputRef = useRef<HTMLInputElement>(null);
// Generate a proper nsec key using nostr-tools
const generateKey = () => {
setIsLoading(true);
setShowSparkles(true);
// Add a dramatic pause for the key generation effect
setTimeout(() => {
try {
// Generate a new secret key
const sk = generateSecretKey();
// Convert to nsec format
setNsec(nip19.nsecEncode(sk));
setStep('download');
toast({
title: 'Your Secret Key is Ready!',
description: 'A new secret key has been generated for you.',
});
} catch {
toast({
title: 'Error',
description: 'Failed to generate key. Please try again.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
setShowSparkles(false);
}
}, 2000);
};
const downloadKey = () => {
try {
// Create a blob with the key text
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
const url = globalThis.URL.createObjectURL(blob);
// Sanitize filename
const filename = sanitizeFilename('nostr-nsec-key.txt');
// Create a temporary link element and trigger download
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// Clean up immediately
globalThis.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Mark as secured
setKeySecured('downloaded');
toast({
title: 'Secret Key Saved!',
description: 'Your key has been safely stored.',
});
} catch {
toast({
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
variant: 'destructive',
});
}
};
const finishKeySetup = () => {
try {
login.nsec(nsec);
setStep('profile');
} catch {
toast({
title: 'Login Failed',
description: 'Failed to login with the generated key. Please try again.',
variant: 'destructive',
});
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset file input
e.target.value = '';
// Validate file type
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file for your avatar.',
variant: 'destructive',
});
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Avatar image must be smaller than 5MB.',
variant: 'destructive',
});
return;
}
try {
const tags = await uploadFile(file);
// Get the URL from the first tag
const url = tags[0]?.[1];
if (url) {
setProfileData(prev => ({ ...prev, picture: url }));
toast({
title: 'Avatar uploaded!',
description: 'Your avatar has been uploaded successfully.',
});
}
} catch {
toast({
title: 'Upload failed',
description: 'Failed to upload avatar. Please try again.',
variant: 'destructive',
});
}
};
const finishSignup = async (skipProfile = false) => {
// Mark signup completion time for fallback welcome modal
localStorage.setItem('signup_completed', Date.now().toString());
try {
// Publish profile if user provided information
if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) {
const metadata: Record<string, string> = {};
if (profileData.name) metadata.name = profileData.name;
if (profileData.about) metadata.about = profileData.about;
if (profileData.picture) metadata.picture = profileData.picture;
await publishEvent({
kind: 0,
content: JSON.stringify(metadata),
});
toast({
title: 'Profile Created!',
description: 'Your profile has been set up.',
});
}
// Show the done step with signer recommendation
setStep('done');
} catch {
toast({
title: 'Profile Setup Failed',
description: 'Your account was created but profile setup failed. You can update it later.',
variant: 'destructive',
});
// Still proceed to completion even if profile failed
setStep('done');
}
};
const handleFinishComplete = () => {
onClose();
if (onComplete) {
setTimeout(() => {
onComplete();
}, 600);
} else {
toast({
title: 'Welcome!',
description: 'Your account is ready.',
});
}
};
const getTitle = () => {
if (step === 'welcome') return (
<span className="flex items-center justify-center gap-2">
Create Your Account
</span>
);
if (step === 'generate') return (
<span className="flex items-center justify-center gap-2">
Generating Your Key
</span>
);
if (step === 'download') return (
<span className="flex items-center justify-center gap-2">
Secret Key
</span>
);
if (step === 'profile') return (
<span className="flex items-center justify-center gap-2">
Create Your Profile
</span>
);
return (
<span className="flex items-center justify-center gap-2">
Welcome!
</span>
);
};
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setStep('welcome');
setIsLoading(false);
setNsec('');
setShowSparkles(false);
setKeySecured('none');
setShowNsec(false);
setShowNpub(false);
setProfileData({ name: '', about: '', picture: '' });
}
}, [isOpen]);
// Add sparkle animation effect
useEffect(() => {
if (showSparkles) {
const interval = setInterval(() => {
// This will trigger re-renders for sparkle animation
}, 100);
return () => clearInterval(interval);
}
}, [showSparkles]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl flex flex-col")}
>
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1 relative flex-shrink-0')}>
<DialogTitle className={cn('font-semibold text-center text-base sm:text-lg')}>
{getTitle()}
</DialogTitle>
</DialogHeader>
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Welcome Step - New engaging introduction */}
{step === 'welcome' && (
<div className='text-center space-y-4'>
{/* Hero illustration */}
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50'>
<div className='flex justify-center items-center space-x-4 mb-3'>
<div className='relative'>
<UserPlus className='w-12 h-12 text-blue-600' />
<Sparkles className='w-4 h-4 text-yellow-500 absolute -top-1 -right-1 animate-pulse' />
</div>
<Globe className='w-16 h-16 text-blue-700 animate-spin-slow' />
<div className='relative'>
<FileText className='w-12 h-12 text-blue-600' />
<Sparkles className='w-4 h-4 text-yellow-500 absolute -top-1 -left-1 animate-pulse' style={{animationDelay: '0.3s'}} />
</div>
</div>
{/* Benefits */}
<div className='grid grid-cols-1 gap-2 text-sm'>
<div className='flex items-center justify-center gap-2 text-blue-700 dark:text-blue-300'>
<Shield className='w-4 h-4' />
Community-run, skater-owned
</div>
<div className='flex items-center justify-center gap-2 text-blue-700 dark:text-blue-300'>
<User className='w-4 h-4' />
You control your account
</div>
<div className='flex items-center justify-center gap-2 text-blue-700 dark:text-blue-300'>
<Globe className='w-4 h-4' />
Connect with skaters worldwide
</div>
</div>
</div>
<div className='space-y-3'>
<Button
className='w-full rounded-full py-6 text-lg font-bold uppercase bg-[#ccff00] text-black hover:bg-[#ccff00]/90 transform transition-all duration-200 hover:scale-105 shadow-[0_0_20px_rgba(204,255,0,0.5)]'
onClick={() => setStep('generate')}
>
<LogIn className='w-5 h-5 mr-2' />
Get Started
</Button>
<a href="/onboarding" onClick={onClose}>
<Button
variant="outline"
className='w-full rounded-full py-3 border-2 border-[#ff0040]/50 hover:bg-[#ff0040]/10 text-[#ff0040] font-bold uppercase'
>
<Sparkles className='w-4 h-4 mr-2' />
Need Help? View Guide
</Button>
</a>
</div>
</div>
)}
{/* Generate Step - Enhanced with animations */}
{step === 'generate' && (
<div className='text-center space-y-4'>
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-purple-100 dark:from-blue-950/50 dark:to-purple-950/50 overflow-hidden'>
{/* Animated background elements */}
{showSparkles && (
<div className='absolute inset-0'>
{[...Array(12)].map((_, i) => (
<Sparkles
key={i}
className={`absolute w-4 h-4 text-yellow-400 animate-ping`}
style={{
left: `${Math.random() * 80 + 10}%`,
top: `${Math.random() * 80 + 10}%`,
animationDelay: `${Math.random() * 2}s`
}}
/>
))}
</div>
)}
<div className='relative z-10'>
{isLoading ? (
<div className='space-y-3'>
<div className='relative'>
<Key className='w-20 h-20 text-primary mx-auto animate-pulse' />
<div className='absolute inset-0 flex items-center justify-center'>
<div className='w-24 h-24 border-4 border-yellow-400 border-t-transparent rounded-full animate-spin'></div>
</div>
</div>
<div className='space-y-2'>
<p className='text-lg font-semibold text-primary flex items-center justify-center gap-2'>
<Sparkles className='w-5 h-5' />
Generating your secret key...
</p>
<p className='text-sm text-muted-foreground'>
Creating your secure key
</p>
</div>
</div>
) : (
<div className='space-y-3'>
<Key className='w-20 h-20 text-primary mx-auto' />
<div className='space-y-2'>
<p className='text-lg font-semibold'>
Ready to generate your secret key?
</p>
<p className='text-sm text-muted-foreground px-5'>
This key will be your password to access applications within the Nostr network.
</p>
</div>
</div>
)}
</div>
</div>
{!isLoading && (
<Button
className='w-full rounded-full py-6 text-lg font-semibold bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 transform transition-all duration-200 hover:scale-105 shadow-lg'
onClick={generateKey}
disabled={isLoading}
>
<Sparkles className='w-5 h-5 mr-2' />
Generate My Secret Key
</Button>
)}
</div>
)}
{/* Download Step - Whimsical and magical */}
{step === 'download' && (
<div className='text-center space-y-4'>
{/* Key reveal */}
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 overflow-hidden'>
{/* Sparkles */}
<div className='absolute inset-0 pointer-events-none'>
<Sparkles className='absolute top-3 left-4 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '0s'}} />
<Sparkles className='absolute top-6 right-6 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '0.5s'}} />
<Sparkles className='absolute bottom-4 left-6 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '1s'}} />
<Sparkles className='absolute bottom-3 right-4 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '1.5s'}} />
</div>
<div className='relative z-10 flex justify-center items-center mb-3'>
<div className='relative'>
<div className='w-16 h-16 bg-gradient-to-br from-blue-200 to-indigo-300 rounded-full flex items-center justify-center shadow-lg animate-pulse'>
<Key className='w-8 h-8 text-indigo-800' />
</div>
<div className='absolute -top-1 -right-1 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center animate-bounce'>
<Sparkles className='w-3 h-3 text-white' />
</div>
</div>
</div>
<div className='relative z-10 space-y-2'>
<p className='text-base font-semibold'>
Your secret key has been generated!
</p>
{/* Warning */}
<div className='relative mx-auto max-w-sm'>
<div className='p-3 bg-gradient-to-r from-amber-100 via-yellow-50 to-amber-100 dark:from-amber-950/40 dark:via-yellow-950/20 dark:to-amber-950/40 rounded-lg border-2 border-amber-300 dark:border-amber-700 shadow-md'>
<div className='flex items-center gap-2 mb-1'>
<FileText className='w-3 h-3 text-amber-700' />
<span className='text-xs font-bold text-amber-800 dark:text-amber-200'>
Important Warning
</span>
</div>
<p className='text-xs text-red-700 dark:text-amber-300 italic'>
This key is your primary and only means of accessing your account. Store it safely and securely.
</p>
</div>
</div>
</div>
</div>
{/* Your Keys - nsec and npub with copy buttons and privacy controls */}
<Card className="bg-black/50 border-[#ff0040]/30">
<CardContent className="p-4 space-y-3">
{/* nsec (secret key) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold uppercase text-[#ff0040]">🔐 Secret Key (nsec)</label>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-auto py-1 px-2 text-xs hover:bg-[#ff0040]/10 text-[#ff0040] font-bold"
onClick={() => setShowNsec(!showNsec)}
title={showNsec ? "Hide secret key" : "Show secret key"}
>
{showNsec ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-auto py-1 px-2 text-xs hover:bg-[#ff0040]/10 text-[#ff0040] font-bold"
onClick={() => {
navigator.clipboard.writeText(nsec);
toast({ title: 'Copied!', description: 'Secret key copied to clipboard' });
}}
>
Copy
</Button>
</div>
</div>
<div className="p-2 bg-black rounded border border-[#ff0040]/20 font-mono text-xs break-all text-white">
{showNsec ? nsec : '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••'}
</div>
<p className="text-xs text-yellow-400">⚠️ This is your PASSWORD - keep it secret and safe!</p>
</div>
{/* npub (public key) */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold uppercase text-[#ccff00]">👤 Public Key (npub)</label>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-auto py-1 px-2 text-xs hover:bg-[#ccff00]/10 text-[#ccff00] font-bold"
onClick={() => setShowNpub(!showNpub)}
title={showNpub ? "Hide public key" : "Show public key"}
>
{showNpub ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-auto py-1 px-2 text-xs hover:bg-[#ccff00]/10 text-[#ccff00] font-bold"
onClick={() => {
try {
const { data } = nip19.decode(nsec);
const pk = getPublicKey(data as Uint8Array);
const npub = nip19.npubEncode(pk);
navigator.clipboard.writeText(npub);
toast({ title: 'Copied!', description: 'Public key copied to clipboard' });
} catch (error) {
toast({ title: 'Error', description: 'Failed to copy public key', variant: 'destructive' });
}
}}
>
Copy
</Button>
</div>
</div>
<div className="p-2 bg-black rounded border border-[#ccff00]/20 font-mono text-xs break-all text-white">
{showNpub ? (() => {
try {
const { data } = nip19.decode(nsec);
const pk = getPublicKey(data as Uint8Array);
return nip19.npubEncode(pk);
} catch {
return 'Error generating npub';
}
})() : '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••'}
</div>
<p className="text-xs text-gray-400">✓ This is your USERNAME - safe to share publicly</p>
</div>
<div className="pt-2 text-xs text-gray-300 border-t border-gray-700">
<p className="mb-1"><strong>What's the difference?</strong></p>
<ul className="space-y-1 ml-4 list-disc">
<li><strong className="text-[#ff0040]">nsec</strong> = Your password. Never share this!</li>
<li><strong className="text-[#ccff00]">npub</strong> = Your username. Share this with friends!</li>
</ul>
</div>
</CardContent>
</Card>
{/* Security options */}
<div className='space-y-3'>
<p className="text-sm text-center text-gray-300 font-bold">Now download your secret key to keep it safe:</p>
<div className='grid grid-cols-1 gap-2'>
{/* Download Option */}
<Card className={`cursor-pointer transition-all duration-200 border-2 ${
keySecured === 'downloaded'
? 'ring-2 ring-green-500 bg-green-950/20 border-green-500/50'
: 'hover:bg-[#ff0040]/5 hover:border-[#ff0040]/20 border-[#ff0040]/20'
}`}>
<CardContent className='p-3'>
<Button
variant="ghost"
className='w-full h-auto p-0 justify-start hover:bg-transparent'
onClick={downloadKey}
>
<div className='flex items-center gap-3 w-full'>
<div className={`p-1.5 rounded-lg border-2 ${
keySecured === 'downloaded'
? 'bg-green-900 border-green-500'
: 'bg-[#ff0040]/10 border-[#ff0040]/30'
}`}>
{keySecured === 'downloaded' ? (
<CheckCircle className='w-4 h-4 text-green-400' />
) : (
<Download className='w-4 h-4 text-[#ff0040]' />
)}
</div>
<div className='flex-1 text-left'>
<div className='font-bold text-sm uppercase text-white'>
Download as File
</div>
<div className='text-xs text-gray-400'>
Save as nostr-nsec-key.txt file
</div>
</div>
{keySecured === 'downloaded' && (
<div className='text-xs font-bold text-green-400 uppercase'>
✓ Downloaded
</div>
)}
</div>
</Button>
</CardContent>
</Card>
</div>
{/* Continue button */}
<Button
className={`w-full rounded-full py-6 text-base font-bold uppercase transform transition-all duration-200 shadow-lg ${
keySecured === 'downloaded'
? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90 border-2 border-[#ccff00] shadow-[0_0_20px_rgba(204,255,0,0.5)] hover:scale-105'
: 'bg-gray-700 text-gray-400 cursor-not-allowed border-2 border-gray-600'
}`}
onClick={finishKeySetup}
disabled={keySecured !== 'downloaded'}
>
<LogIn className='w-5 h-5 mr-2 flex-shrink-0' />
<span className="text-center leading-tight">
{keySecured === 'none' ? (
<>
Download Your Key First
</>
) : (
<>
My Key is Safe - Continue
</>
)}
</span>
</Button>
</div>
</div>
)}
{/* Profile Step - Optional profile setup */}
{step === 'profile' && (
<div className='text-center space-y-4'>
{/* Profile setup illustration */}
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 overflow-hidden'>
{/* Sparkles */}
<div className='absolute inset-0 pointer-events-none'>
<Sparkles className='absolute top-3 left-4 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '0s'}} />
<Sparkles className='absolute top-6 right-6 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '0.5s'}} />
<Sparkles className='absolute bottom-4 left-6 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '1s'}} />
</div>
<div className='relative z-10 flex justify-center items-center mb-3'>
<div className='relative'>
<div className='w-16 h-16 bg-gradient-to-br from-blue-200 to-indigo-300 rounded-full flex items-center justify-center shadow-lg'>
<User className='w-8 h-8 text-blue-800' />
</div>
<div className='absolute -top-1 -right-1 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center animate-bounce'>
<Sparkles className='w-3 h-3 text-white' />
</div>
</div>
</div>
<div className='relative z-10 space-y-2'>
<p className='text-base font-semibold'>
Almost there! Let's set up your profile
</p>
<p className='text-sm text-muted-foreground'>
Your profile is your identity on Nostr.
</p>
</div>
</div>
{/* Publishing status indicator */}
{isPublishing && (
<div className='relative p-4 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800'>
<div className='flex items-center justify-center gap-3'>
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
<span className='text-sm font-medium text-blue-700 dark:text-blue-300'>
Publishing your profile...
</span>
</div>
</div>
)}
{/* Profile form */}
<div className={`space-y-4 text-left ${isPublishing ? 'opacity-50 pointer-events-none' : ''}`}>
<div className='space-y-2'>
<label htmlFor='profile-name' className='text-sm font-medium'>
Display Name
</label>
<Input
id='profile-name'
value={profileData.name}
onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))}
placeholder='Your name'
className='rounded-lg'
disabled={isPublishing}
/>
</div>
<div className='space-y-2'>
<label htmlFor='profile-about' className='text-sm font-medium'>
Bio
</label>
<Textarea
id='profile-about'
value={profileData.about}
onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))}
placeholder='Tell others about yourself...'
className='rounded-lg resize-none'
rows={3}
disabled={isPublishing}
/>
</div>
<div className='space-y-2'>
<label htmlFor='profile-picture' className='text-sm font-medium'>
Avatar
</label>
<div className='flex gap-2'>
<Input
id='profile-picture'
value={profileData.picture}
onChange={(e) => setProfileData(prev => ({ ...prev, picture: e.target.value }))}
placeholder='https://example.com/your-avatar.jpg'
className='rounded-lg flex-1'
disabled={isPublishing}
/>
<input
type='file'
accept='image/*'
className='hidden'
ref={avatarFileInputRef}
onChange={handleAvatarUpload}
/>
<Button
type='button'
variant='outline'
size='icon'
onClick={() => avatarFileInputRef.current?.click()}
disabled={isUploading || isPublishing}
className='rounded-lg shrink-0'
title='Upload avatar image'
>
{isUploading ? (
<div className='w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin' />
) : (
<Upload className='w-4 h-4' />
)}
</Button>
</div>
</div>
</div>
{/* Action buttons */}
<div className='space-y-3'>
<Button
className='w-full rounded-full py-4 text-base font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 transform transition-all duration-200 hover:scale-105 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none'
onClick={() => finishSignup(false)}
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
Creating Profile...
</>
) : (
<>
<User className='w-4 h-4 mr-2' />
Create Profile & Finish
</>
)}
</Button>
<Button
variant='outline'
className='w-full rounded-full py-3 disabled:opacity-50 disabled:cursor-not-allowed'
onClick={() => finishSignup(true)}
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
Setting up account...
</>
) : (
'Skip for now'
)}
</Button>
</div>
</div>
)}
{/* Done Step - Account created with signer recommendation */}
{step === 'done' && (
<div className='py-4 space-y-4'>
<Card className='bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/10 border-2 border-[#ccff00]/50'>
<CardContent className='p-6 text-center space-y-3'>
<div className='text-5xl mb-2'>✅</div>
<h3 className='text-xl font-black uppercase text-[#ccff00]'>
Account Created!
</h3>
<p className='text-gray-300 text-sm'>
Your Nostr account is ready to go!
</p>
<CheckCircle className='h-10 w-10 mx-auto text-[#00ff00]' />
</CardContent>
</Card>
{/* Next Steps - Signer Recommendation */}
<Card className='bg-blue-950/30 border-2 border-blue-500/30'>
<CardContent className='p-5 space-y-3'>
<div className='flex items-center gap-3'>
<div className='p-2 rounded-lg bg-blue-500/20'>
<Shield className='h-5 w-5 text-blue-400' />
</div>
<div>
<h4 className='font-bold text-blue-400'>Recommended: Set Up a Signer App</h4>
<p className='text-xs text-gray-400'>Keep your keys extra secure</p>
</div>
</div>
<p className='text-sm text-gray-300'>
For better security, use a dedicated signer app instead of pasting your secret key everywhere.
Signer apps keep your keys safe while letting you use any Nostr app.
</p>
<Link to='/signers' onClick={handleFinishComplete}>
<Button className='w-full bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 border border-blue-500/50'>
<ExternalLink className='h-4 w-4 mr-2' />
Learn About Signer Apps
</Button>
</Link>
<button
onClick={handleFinishComplete}
className='w-full text-xs text-gray-500 hover:text-gray-400 py-2'
>
Skip for now
</button>
</CardContent>
</Card>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default SignupDialog;

View file

@ -0,0 +1,141 @@
import React from 'react';
import { Key, FileText, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent } from '@/components/ui/card';
interface SignupMethodDialogProps {
isOpen: boolean;
onClose: () => void;
onSelectMnemonic: () => void;
onSelectNsec: () => void;
}
const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
isOpen,
onClose,
onSelectMnemonic,
onSelectNsec
}) => {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl bg-gradient-to-br from-gray-900 to-black border-2 border-[#ccff00]/30">
<DialogHeader>
<DialogTitle className="text-3xl font-black uppercase text-center mb-2">
<span className="neon-orange">Choose Your</span>{' '}
<span className="neon-lime">Signup Method</span>
</DialogTitle>
<p className="text-center text-gray-400 text-sm">
Both methods are secure. Choose what works best for you!
</p>
</DialogHeader>
<div className="grid md:grid-cols-2 gap-6 mt-4">
{/* Mnemonic Option (Recommended) */}
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/5 border-2 border-[#ccff00]/50 hover:border-[#ccff00] transition-all cursor-pointer group"
onClick={onSelectMnemonic}>
<CardContent className="p-6 space-y-4">
<div className="flex items-center justify-between">
<FileText className="h-12 w-12 text-[#ccff00]" />
<span className="text-xs font-bold uppercase px-2 py-1 bg-[#ccff00]/20 text-[#ccff00] rounded-full">
Recommended
</span>
</div>
<div>
<h3 className="text-2xl font-black uppercase text-[#ccff00] mb-2">
Recovery Phrase
</h3>
<p className="text-sm text-gray-400 mb-3">
12 easy-to-remember words. Industry standard used by Bitcoin, Ethereum, and most crypto wallets.
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-2">
<span className="text-[#00ff00] text-lg">✓</span>
<span className="text-xs text-gray-300">Easy to write down on paper</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#00ff00] text-lg">✓</span>
<span className="text-xs text-gray-300">Works with other Nostr apps (NIP-06)</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#00ff00] text-lg">✓</span>
<span className="text-xs text-gray-300">Can generate multiple accounts</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#00ff00] text-lg">✓</span>
<span className="text-xs text-gray-300">Familiar to crypto users</span>
</div>
</div>
<Button
className="w-full bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase group-hover:scale-105 transition-transform"
>
Use Recovery Phrase
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
{/* Nsec Option (Advanced) */}
<Card className="bg-gradient-to-br from-[#ff0040]/5 to-[#ff6600]/5 border-2 border-[#ff0040]/30 hover:border-[#ff0040]/60 transition-all cursor-pointer group"
onClick={onSelectNsec}>
<CardContent className="p-6 space-y-4">
<div className="flex items-center justify-between">
<Key className="h-12 w-12 text-[#ff0040]" />
<span className="text-xs font-bold uppercase px-2 py-1 bg-[#ff0040]/20 text-[#ff0040] rounded-full">
Advanced
</span>
</div>
<div>
<h3 className="text-2xl font-black uppercase text-[#ff0040] mb-2">
Secret Key (nsec)
</h3>
<p className="text-sm text-gray-400 mb-3">
Raw cryptographic key. More technical but gives you direct access to your Nostr identity.
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-2">
<span className="text-[#ff6600] text-lg">✓</span>
<span className="text-xs text-gray-300">Direct cryptographic control</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#ff6600] text-lg">✓</span>
<span className="text-xs text-gray-300">Compatible with all Nostr apps</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#ff6600] text-lg">✓</span>
<span className="text-xs text-gray-300">Single string to backup</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#ff6600] text-lg">✓</span>
<span className="text-xs text-gray-300">No derivation layer</span>
</div>
</div>
<Button
className="w-full bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:from-[#ff0040]/90 hover:to-[#ff6600]/90 text-white font-bold uppercase group-hover:scale-105 transition-transform"
>
Use Secret Key
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
<div className="text-center mt-4">
<p className="text-xs text-gray-500">
🔒 Both methods are equally secure. Choose based on your preference!
</p>
</div>
</DialogContent>
</Dialog>
);
};
export default SignupMethodDialog;

View file

@ -0,0 +1,174 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { NostrEvent } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { useComments } from '@/hooks/useComments';
import { CommentForm } from './CommentForm';
import { NoteContent } from '@/components/NoteContent';
import { ReportDialog } from '@/components/ReportDialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { MessageSquare, ChevronDown, ChevronRight, MoreHorizontal, Flag } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { genUserName } from '@/lib/genUserName';
interface CommentProps {
root: NostrEvent | URL;
comment: NostrEvent;
depth?: number;
maxDepth?: number;
limit?: number;
}
export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: CommentProps) {
const [showReplyForm, setShowReplyForm] = useState(false);
const [showReplies, setShowReplies] = useState(depth < 2); // Auto-expand first 2 levels
const [showReportDialog, setShowReportDialog] = useState(false);
const author = useAuthor(comment.pubkey);
const { data: commentsData } = useComments(root, limit);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(comment.pubkey)
const timeAgo = formatDistanceToNow(new Date(comment.created_at * 1000), { addSuffix: true });
// Get direct replies to this comment
const replies = commentsData?.getDirectReplies(comment.id) || [];
const hasReplies = replies.length > 0;
return (
<div className={`space-y-3 ${depth > 0 ? 'ml-6 border-l-2 border-muted pl-4' : ''}`}>
<Card className="bg-card/50">
<CardContent className="p-4">
<div className="space-y-3">
{/* Comment Header */}
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<Link to={`/${nip19.npubEncode(comment.pubkey)}`}>
<Avatar className="h-8 w-8 hover:ring-2 hover:ring-primary/30 transition-all cursor-pointer">
<AvatarImage src={metadata?.picture} />
<AvatarFallback className="text-xs">
{displayName.charAt(0)}
</AvatarFallback>
</Avatar>
</Link>
<div>
<Link
to={`/${nip19.npubEncode(comment.pubkey)}`}
className="font-medium text-sm hover:text-primary transition-colors"
>
{displayName}
</Link>
<p className="text-xs text-muted-foreground">{timeAgo}</p>
</div>
</div>
</div>
{/* Comment Content */}
<div className="text-sm">
<NoteContent event={comment} className="text-sm" />
</div>
{/* Comment Actions */}
<div className="flex items-center justify-between pt-2">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowReplyForm(!showReplyForm)}
className="h-8 px-2 text-xs"
>
<MessageSquare className="h-3 w-3 mr-1" />
Reply
</Button>
{hasReplies && (
<Collapsible open={showReplies} onOpenChange={setShowReplies}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
{showReplies ? (
<ChevronDown className="h-3 w-3 mr-1" />
) : (
<ChevronRight className="h-3 w-3 mr-1" />
)}
{replies.length} {replies.length === 1 ? 'reply' : 'replies'}
</Button>
</CollapsibleTrigger>
</Collapsible>
)}
</div>
{/* Comment menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
aria-label="Comment options"
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={() => setShowReportDialog(true)}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
{/* Reply Form */}
{showReplyForm && (
<div className="ml-6">
<CommentForm
root={root}
reply={comment}
onSuccess={() => setShowReplyForm(false)}
placeholder="Write a reply..."
compact
/>
</div>
)}
{/* Replies */}
{hasReplies && (
<Collapsible open={showReplies} onOpenChange={setShowReplies}>
<CollapsibleContent className="space-y-3">
{replies.map((reply) => (
<Comment
key={reply.id}
root={root}
comment={reply}
depth={depth + 1}
maxDepth={maxDepth}
limit={limit}
/>
))}
</CollapsibleContent>
</Collapsible>
)}
{/* Report Dialog */}
<ReportDialog
open={showReportDialog}
onOpenChange={setShowReportDialog}
targetType="user"
targetPubkey={comment.pubkey}
targetEventId={comment.id}
targetTitle={comment.content.slice(0, 50) + (comment.content.length > 50 ? '...' : '')}
/>
</div>
);
}

View file

@ -0,0 +1,90 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { usePostComment } from '@/hooks/usePostComment';
import { LoginArea } from '@/components/auth/LoginArea';
import { NostrEvent } from '@nostrify/nostrify';
import { MessageSquare, Send } from 'lucide-react';
interface CommentFormProps {
root: NostrEvent | URL;
reply?: NostrEvent | URL;
onSuccess?: () => void;
placeholder?: string;
compact?: boolean;
}
export function CommentForm({
root,
reply,
onSuccess,
placeholder = "Write a comment...",
compact = false
}: CommentFormProps) {
const [content, setContent] = useState('');
const { user } = useCurrentUser();
const { mutate: postComment, isPending } = usePostComment();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim() || !user) return;
postComment(
{ content: content.trim(), root, reply },
{
onSuccess: () => {
setContent('');
onSuccess?.();
},
}
);
};
if (!user) {
return (
<Card className={compact ? "border-dashed" : ""}>
<CardContent className={compact ? "p-4" : "p-6"}>
<div className="text-center space-y-4">
<div className="flex items-center justify-center space-x-2 text-muted-foreground">
<MessageSquare className="h-5 w-5" />
<span>Sign in to {reply ? 'reply' : 'comment'}</span>
</div>
<LoginArea />
</div>
</CardContent>
</Card>
);
}
return (
<Card className={compact ? "border-dashed" : ""}>
<CardContent className={compact ? "p-4" : "p-6"}>
<form onSubmit={handleSubmit} className="space-y-4">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
className={compact ? "min-h-[80px]" : "min-h-[100px]"}
disabled={isPending}
/>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">
{reply ? 'Replying to comment' : 'Adding to the discussion'}
</span>
<Button
type="submit"
disabled={!content.trim() || isPending}
size={compact ? "sm" : "default"}
>
<Send className="h-4 w-4 mr-2" />
{isPending ? 'Posting...' : (reply ? 'Reply' : 'Comment')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,100 @@
import { useComments } from '@/hooks/useComments';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { MessageSquare } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
import { CommentForm } from './CommentForm';
import { Comment } from './Comment';
interface CommentsSectionProps {
root: NostrEvent | URL;
title?: string;
emptyStateMessage?: string;
emptyStateSubtitle?: string;
className?: string;
limit?: number;
}
export function CommentsSection({
root,
title = "Comments",
emptyStateMessage = "No comments yet",
emptyStateSubtitle = "Be the first to share your thoughts!",
className,
limit = 500,
}: CommentsSectionProps) {
const { data: commentsData, isLoading, error } = useComments(root, limit);
const comments = commentsData?.topLevelComments || [];
if (error) {
return (
<Card className="rounded-none sm:rounded-lg mx-0 sm:mx-0">
<CardContent className="px-2 py-6 sm:p-6">
<div className="text-center text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Failed to load comments</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card className={cn("rounded-none sm:rounded-lg mx-0 sm:mx-0", className)}>
<CardHeader className="px-2 pt-6 pb-4 sm:p-6">
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5" />
<span>{title}</span>
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground">
({comments.length})
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-6 pt-4 sm:p-6 sm:pt-0 space-y-6">
{/* Comment Form */}
<CommentForm root={root} />
{/* Comments List */}
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Card key={i} className="bg-card/50">
<CardContent className="p-4">
<div className="space-y-3">
<div className="flex items-center space-x-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<Skeleton className="h-16 w-full" />
</div>
</CardContent>
</Card>
))}
</div>
) : comments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium mb-2">{emptyStateMessage}</p>
<p className="text-sm">{emptyStateSubtitle}</p>
</div>
) : (
<div className="space-y-4">
{comments.map((comment) => (
<Comment
key={comment.id}
root={root}
comment={comment}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,307 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import {
Globe,
Lock,
Unlock,
Key,
ChevronRight,
ChevronLeft,
CheckCircle2,
AlertTriangle,
Store,
Users,
Shield,
} from 'lucide-react';
interface RelayEducationProps {
onComplete: () => void;
onSkip?: () => void;
}
export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
const [currentStep, setCurrentStep] = useState(1);
const totalSteps = 3;
const nextStep = () => {
if (currentStep < totalSteps) {
setCurrentStep(currentStep + 1);
} else {
onComplete();
}
};
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
return (
<Card className="w-full max-w-lg mx-auto border-2 border-purple-500/30 bg-gradient-to-br from-purple-500/5 to-blue-500/5">
<CardContent className="p-6">
{/* Progress */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-gray-400 uppercase tracking-wide">
Understanding Nostr
</span>
<span className="text-xs text-gray-500">
Step {currentStep} of {totalSteps}
</span>
</div>
<Progress value={(currentStep / totalSteps) * 100} className="h-1.5" />
</div>
{/* Step 1: Relay Concept */}
{currentStep === 1 && (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-500/20 mb-4">
<Globe className="h-8 w-8 text-purple-500" />
</div>
<h2 className="text-xl font-black uppercase mb-2">Understanding Relays</h2>
<p className="text-gray-400 text-sm">
Think of relays like skate shops that share news about spots
</p>
</div>
{/* Visual Diagram */}
<div className="flex items-center justify-center gap-2 py-4">
<div className="flex flex-col items-center gap-1">
<div className="w-12 h-12 rounded-lg bg-red-500/20 flex items-center justify-center">
<span className="text-2xl">🛹</span>
</div>
<span className="text-xs text-gray-500">You</span>
</div>
<ChevronRight className="h-5 w-5 text-gray-600" />
<div className="flex flex-col items-center gap-1">
<div className="flex gap-1">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Store className="h-5 w-5 text-purple-500" />
</div>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Store className="h-5 w-5 text-purple-500" />
</div>
</div>
<span className="text-xs text-gray-500">Relays</span>
</div>
<ChevronRight className="h-5 w-5 text-gray-600" />
<div className="flex flex-col items-center gap-1">
<div className="w-12 h-12 rounded-lg bg-green-500/20 flex items-center justify-center">
<Users className="h-6 w-6 text-green-500" />
</div>
<span className="text-xs text-gray-500">Skaters</span>
</div>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<span className="font-bold text-green-400">No company owns your account</span>
<p className="text-gray-400 text-xs mt-0.5">Your identity lives on multiple relay servers</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<span className="font-bold text-green-400">Your posts can't be deleted by others</span>
<p className="text-gray-400 text-xs mt-0.5">Data is distributed across the network</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div>
<span className="font-bold text-amber-400">Relay operators can see public posts</span>
<p className="text-gray-400 text-xs mt-0.5">Think of it like posting on a bulletin board</p>
</div>
</div>
</div>
</div>
)}
{/* Step 2: Public vs Private */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/20 mb-4">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-xl font-black uppercase mb-2">Public vs Private</h2>
<p className="text-gray-400 text-sm">
Know what's visible and what's encrypted
</p>
</div>
<div className="grid gap-4">
{/* Public */}
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<div className="flex items-center gap-2 mb-3">
<Unlock className="h-5 w-5 text-red-400" />
<span className="font-bold text-red-400 uppercase text-sm">Public (Anyone Can See)</span>
</div>
<div className="space-y-2 text-sm text-gray-300">
<div className="flex items-center gap-2">
<span className="text-red-400">•</span>
<span>Spots you add to the map</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-400">•</span>
<span>Reviews and comments</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-400">•</span>
<span>Your profile info (name, picture)</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-400">•</span>
<span>Who you follow</span>
</div>
</div>
</div>
{/* Private */}
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<div className="flex items-center gap-2 mb-3">
<Lock className="h-5 w-5 text-green-400" />
<span className="font-bold text-green-400 uppercase text-sm">Private (Encrypted)</span>
</div>
<div className="space-y-2 text-sm text-gray-300">
<div className="flex items-center gap-2">
<span className="text-green-400">•</span>
<span>Direct messages</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-400">•</span>
<span>Wallet communications</span>
</div>
</div>
</div>
</div>
<div className="p-3 rounded-lg bg-gray-800/50 border border-gray-700">
<p className="text-xs text-gray-400 text-center">
<span className="text-amber-400 font-bold">Note:</span> Posts may be stored permanently on relays.
Think before you post - just like real life!
</p>
</div>
</div>
)}
{/* Step 3: Key Backup */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-500/20 mb-4">
<Key className="h-8 w-8 text-amber-500" />
</div>
<h2 className="text-xl font-black uppercase mb-2">Your Keys = Your Account</h2>
<p className="text-gray-400 text-sm">
This is the most important thing to understand
</p>
</div>
<div className="space-y-4">
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/30">
<h3 className="font-bold text-amber-400 mb-2 flex items-center gap-2">
<Key className="h-4 w-4" />
Your Secret Key (nsec)
</h3>
<p className="text-sm text-gray-300 mb-3">
Think of it like your skateboard - never lend it to anyone.
It proves you own your account.
</p>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2 text-red-400">
<span className="font-bold">⚠️</span>
<span>If lost, your account can't be recovered</span>
</div>
<div className="flex items-start gap-2 text-red-400">
<span className="font-bold">⚠️</span>
<span>No "forgot password" - nobody can reset it</span>
</div>
<div className="flex items-start gap-2 text-green-400">
<span className="font-bold">✓</span>
<span>Back it up and store it safely</span>
</div>
</div>
</div>
<div className="p-4 rounded-lg bg-purple-500/10 border border-purple-500/30">
<h3 className="font-bold text-purple-400 mb-2 flex items-center gap-2">
<Globe className="h-4 w-4" />
Your Public Key (npub)
</h3>
<p className="text-sm text-gray-300">
This is like your username - share it freely so others can find you!
</p>
</div>
</div>
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-center">
<p className="text-sm text-green-400 font-bold">
🔐 You're in control - no company can lock you out!
</p>
</div>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-800">
<div>
{currentStep > 1 ? (
<Button
variant="ghost"
onClick={prevStep}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
Back
</Button>
) : onSkip ? (
<Button
variant="ghost"
onClick={onSkip}
className="text-gray-500"
>
Skip
</Button>
) : (
<div />
)}
</div>
<Button
onClick={nextStep}
className={cn(
"gap-1",
currentStep === totalSteps
? "bg-green-500 hover:bg-green-600"
: "bg-purple-500 hover:bg-purple-600"
)}
>
{currentStep === totalSteps ? (
<>
Got It!
<CheckCircle2 className="h-4 w-4" />
</>
) : (
<>
Next
<ChevronRight className="h-4 w-4" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button-variants"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View file

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View file

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View file

@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full object-cover", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -0,0 +1,21 @@
import { cva } from "class-variance-authority"
export const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)

View file

@ -0,0 +1,17 @@
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { badgeVariants } from "./badge-variants"
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge }

View file

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View file

@ -0,0 +1,30 @@
import { cva } from "class-variance-authority"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

View file

@ -0,0 +1,28 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { buttonVariants } from "./button-variants"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

View file

@ -0,0 +1,64 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button-variants";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View file

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View file

@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View file

@ -0,0 +1,363 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View file

@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View file

@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -0,0 +1,153 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
type CommandDialogProps = DialogProps
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View file

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-4 sm:p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg max-h-[calc(100vh-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-3 top-3 sm:right-4 sm:top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View file

@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View file

@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View file

@ -0,0 +1,48 @@
import * as React from "react"
import {
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form"
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
export const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
type FormItemContextValue = {
id: string
}
export const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
export const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}

View file

@ -0,0 +1,133 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { FormFieldContext, FormItemContext, useFormField } from "./form-utils"
const Form = FormProvider
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View file

@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View file

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View file

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View file

@ -0,0 +1,234 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View file

@ -0,0 +1,5 @@
import { cva } from "class-variance-authority"
export const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)

View file

@ -0,0 +1,123 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { navigationMenuTriggerStyle } from "./navigation-menu-variants"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View file

@ -0,0 +1,118 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps } from "@/components/ui/button"
import { buttonVariants } from "@/components/ui/button-variants"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View file

@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View file

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View file

@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View file

@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

Some files were not shown because too many files have changed in this diff Show more