Fix 78 pre-existing TypeScript errors surfaced by npm test

- useDirectMessages: correct nip44 getConversationKey (Uint8Array) + finalizeEvent EventTemplate (remove pubkey/unused ephemeralPubkey/bytesToHex)
- Access current-user metadata via useCurrentUser() spread instead of user.metadata across ~10 files (rename to userMetadata where author metadata already bound)
- useCrewGoal: explicit UseQueryResult<CrewGoal|null> return type (fixes CrewForm/CrewTreasuryCard {} inference)
- New useCommentCounts hook; fix EventsPage/MapPage misuse of useComments(string[])
- CoachDetailPage: typed pget/pbool accessors for craft-specific union fields
- MapPage: shop entries include tags/category to satisfy SkateSpot
- SkateGlobe: read event.tags; LabPage: remove duplicate serviceType; LabCrewsStrip: add 'judging'
- Icon prop widened to accept style; ReportDialog subtype guard; SecuritySettings/TourCard/EventsPage comparisons; CashuWallet onClick; ConversationPage recipients; useLoggedInAccounts NLoginType import; LicensePage icon; TourDetailPage ZapButton target + real npub link; TestApp useSkateRelay

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 21:18:02 +07:00
parent 635e52df4a
commit aea66d11e2
31 changed files with 142 additions and 81 deletions

View file

@ -89,7 +89,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
} = config;
const navigate = useNavigate();
const { user } = useCurrentUser();
const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@ -252,7 +252,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
);
}
const hasNip05 = user?.metadata?.nip05;
const hasNip05 = metadata?.nip05;
const entityCoord = `${kind}:${user.pubkey}:${identifier}`;
return (

View file

@ -8,7 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
/** The Lab page's own ServiceType union (kept in sync with LabPage). */
type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music';
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging';
/** Translate the Lab page's ServiceType into a crew focus scene. */
function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> {

View file

@ -97,7 +97,7 @@ export function ReportDialog({
if (!user || !selectedReason) return;
const reason = REPORT_REASONS.find(
(r) => r.id === selectedReason || r.subtype === selectedReason
(r) => r.id === selectedReason || ('subtype' in r && r.subtype === selectedReason)
);
if (!reason) return;
@ -159,7 +159,7 @@ export function ReportDialog({
};
const getReasonKey = (reason: typeof REPORT_REASONS[number]) => {
return reason.subtype || reason.id;
return ('subtype' in reason ? reason.subtype : undefined) || reason.id;
};
if (!user) {

View file

@ -188,7 +188,7 @@ export function SecuritySettings() {
{loginType === 'bunker' && (
<>You're logged in with a <strong className="text-white">remote signer (bunker)</strong>. Your recovery code is held by your signer service, not SkateSpots. Back it up there.</>
)}
{loginType === 'extension' && (
{(loginType as string) === 'extension' && (
<>You're logged in with a <strong className="text-white">browser extension</strong>. Your recovery code lives in the extension — use its own backup/export option. SkateSpots never sees your key.</>
)}
{!loginType && (

View file

@ -130,13 +130,14 @@ export function SkateGlobe({
if (showSpots && spots) {
for (const s of spots) {
if (!s?.tags || !Array.isArray(s.tags)) continue;
const c = getCoords(s.tags);
const evTags = s?.event?.tags;
if (!evTags || !Array.isArray(evTags)) continue;
const c = getCoords(evTags);
if (!c) continue;
out.push({
id: `spot-${s.id}`,
lat: c.lat, lng: c.lng,
label: getName(s.tags, 'Unnamed Spot'),
label: s.title || getName(evTags, 'Unnamed Spot'),
type: 'spot', spot: s,
});
}
@ -144,13 +145,14 @@ export function SkateGlobe({
if (showShops && shops) {
for (const sh of shops) {
if (!sh?.tags || !Array.isArray(sh.tags)) continue;
const c = getCoords(sh.tags);
const evTags = sh?.event?.tags;
if (!evTags || !Array.isArray(evTags)) continue;
const c = getCoords(evTags);
if (!c) continue;
out.push({
id: `shop-${sh.id}`,
lat: c.lat, lng: c.lng,
label: getName(sh.tags, 'Skate Shop'),
label: sh.name || getName(evTags, 'Skate Shop'),
type: 'shop', shop: sh,
});
}

View file

@ -229,7 +229,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
style={{
backgroundColor: `${categoryInfo.color}dd`,
color: categoryInfo.color === '#ccff00' ? 'black' : 'white'
color: (categoryInfo.color as string) === '#ccff00' ? 'black' : 'white'
}}
>
{categoryInfo.emoji} {categoryInfo.label}

View file

@ -513,7 +513,7 @@ export function CashuWallet() {
className="bg-gray-800 border-gray-700 font-mono text-xs"
/>
<Button
onClick={handleAddMint}
onClick={() => handleAddMint()}
disabled={!mintUrl}
variant="outline"
size="sm"

View file

@ -0,0 +1,45 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
/**
* Batch comment counts for a list of root event IDs.
*
* Queries kind 1111 (NIP-22 comments) that reference any of the given event
* ids via the uppercase root `#E` tag (and also tallies lowercase `e` for
* resilience), returning a Map of eventId -> comment count.
*
* This replaces the previous (incorrect) usage of `useComments(string[])` —
* `useComments` only accepts a SINGLE NostrEvent|URL root, so passing an array
* silently produced a bad query and a `.filter` on the wrong shape.
*/
export function useCommentCounts(eventIds: string[]) {
const { nostr } = useNostr();
// Stable key regardless of order/dupes.
const ids = Array.from(new Set(eventIds)).sort();
return useQuery({
queryKey: ['comment-counts', ids],
enabled: ids.length > 0,
staleTime: 60 * 1000,
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const events = await nostr.query(
[{ kinds: [1111], '#E': ids, limit: 1000 }],
{ signal },
);
const counts = new Map<string, number>();
for (const ev of events) {
// Prefer the uppercase root tag; fall back to lowercase parent tag.
const root =
ev.tags.find(([n]) => n === 'E')?.[1] ??
ev.tags.find(([n]) => n === 'e')?.[1];
if (root && ids.includes(root)) {
counts.set(root, (counts.get(root) ?? 0) + 1);
}
}
return counts;
},
});
}

View file

@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { nip57 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
@ -62,7 +62,7 @@ function tallyZaps(receipts: NostrEvent[], closedAt?: number): { sats: number; c
* funds settle to the goal author's own Lightning address. SkateSpots never
* holds funds — it only reads the public tally.
*/
export function useCrewGoal(goalId?: string) {
export function useCrewGoal(goalId?: string): UseQueryResult<CrewGoal | null> {
const { nostr } = useNostr();
return useQuery({

View file

@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { nip44, nip19, generateSecretKey, getPublicKey, finalizeEvent, getEventHash } from 'nostr-tools';
import { nip44, nip19, generateSecretKey, finalizeEvent, getEventHash } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrLogin } from '@nostrify/react/login';
@ -129,21 +129,23 @@ export function useSendDirectMessage() {
content: encryptedRumor,
});
// Generate ephemeral keypair for gift wrap
// Generate ephemeral secret key for the gift wrap (finalizeEvent derives
// the matching pubkey internally).
const ephemeralSecretKey = generateSecretKey();
const ephemeralPubkey = getPublicKey(ephemeralSecretKey);
// Encrypt seal to target using ephemeral key
// Encrypt seal to target using ephemeral key.
// getConversationKey expects the raw secret key bytes (Uint8Array).
const sealJson = JSON.stringify(sealEvent);
const conversationKey = nip44.v2.utils.getConversationKey(
bytesToHex(ephemeralSecretKey),
ephemeralSecretKey,
targetPubkey
);
const encryptedSeal = nip44.v2.encrypt(sealJson, conversationKey);
// Create gift wrap event and sign with ephemeral key
// Create gift wrap event and sign with ephemeral key.
// finalizeEvent takes an EventTemplate (no pubkey — it derives it from
// the secret key) and returns a fully-signed event.
return finalizeEvent({
pubkey: ephemeralPubkey,
created_at: randomPastTimestamp(),
kind: GIFT_WRAP_KIND,
tags: [['p', targetPubkey]],

View file

@ -61,14 +61,14 @@ interface LastListingRecord {
*/
export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestrictions {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { user, metadata } = useCurrentUser();
const [lastListingRecord, setLastListingRecord] = useLocalStorage<LastListingRecord | null>(
'last-listing-record',
null
);
// Check if user has NIP-05 verification
const hasNip05 = !!user?.metadata?.nip05;
const hasNip05 = !!metadata?.nip05;
// Check if user is a platform owner (bypasses all restrictions)
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);

View file

@ -1,5 +1,5 @@
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useNostrLogin, type NLoginType } from '@nostrify/react/login';
import { useQuery } from '@tanstack/react-query';
import { NSchema as n, NostrEvent, NostrMetadata } from '@nostrify/nostrify';

View file

@ -79,7 +79,7 @@ export default function BecomeBuilderPage() {
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@ -258,7 +258,7 @@ export default function BecomeBuilderPage() {
);
}
const hasNip05 = user?.metadata?.nip05;
const hasNip05 = metadata?.nip05;
return (
<Layout>

View file

@ -37,7 +37,7 @@ export default function BecomeCoachPage() {
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@ -209,7 +209,7 @@ export default function BecomeCoachPage() {
);
}
const hasNip05 = user?.metadata?.nip05;
const hasNip05 = metadata?.nip05;
return (
<Layout>

View file

@ -38,7 +38,7 @@ export default function BecomeFilmerPage() {
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@ -225,7 +225,7 @@ export default function BecomeFilmerPage() {
);
}
const hasNip05 = user?.metadata?.nip05;
const hasNip05 = metadata?.nip05;
return (
<Layout>

View file

@ -85,7 +85,7 @@ export default function BecomePhotographerPage() {
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@ -275,7 +275,7 @@ export default function BecomePhotographerPage() {
);
}
const hasNip05 = user?.metadata?.nip05;
const hasNip05 = metadata?.nip05;
return (
<Layout>

View file

@ -138,6 +138,18 @@ export default function CoachDetailPage() {
};
const accent = ACCENTS[profile.profileType] ?? ACCENTS.coach;
// The Lab profile is a union of craft-specific shapes. Craft-specific fields
// (gear, studio, credentials, instagram, …) only exist on some members, so we
// read them through a single typed accessor that returns an optional string
// instead of scattering casts across the JSX. Presence is still gated by the
// isCoach/isBuilder/… flags before these are shown.
const pf = profile as Record<string, unknown>;
const pget = (key: string): string | undefined => {
const v = pf[key];
return typeof v === 'string' && v.length > 0 ? v : undefined;
};
const pbool = (key: string): boolean => pf[key] === true;
const TYPE_ICONS: Record<string, typeof GraduationCap> = {
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel,
@ -294,30 +306,30 @@ export default function CoachDetailPage() {
)}
{/* In the Bag — photographer / filmer gear (freeform) */}
{(isPhotographer || isFilmer || isBuilder) && 'gear' in profile && profile.gear && (
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
{isBuilder ? <HardHat className={`h-4 w-4 ${accent.text}`} /> : <Aperture className={`h-4 w-4 ${accent.text}`} />}
{isBuilder ? 'How We Work' : 'In the Bag'}
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{profile.gear}
{pget('gear')}
</p>
</div>
)}
{/* Builder — service area & insured */}
{isBuilder && 'serviceArea' in profile && (profile.serviceArea || profile.insured) && (
{isBuilder && (pget('serviceArea') || pbool('insured')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{profile.serviceArea && (
{pget('serviceArea') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<MapPin className={`h-3 w-3 ${accent.text}`} /> Service Area
</h4>
<p className="text-sm text-gray-300">{profile.serviceArea}</p>
<p className="text-sm text-gray-300">{pget('serviceArea')}</p>
</div>
)}
{profile.insured && (
{pbool('insured') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<HardHat className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-gray-200">Licensed / Insured</span>
@ -327,17 +339,17 @@ export default function CoachDetailPage() {
)}
{/* Tattoo — studio & books-open */}
{isTattoo && (('studio' in profile && profile.studio) || ('booksOpen' in profile && profile.booksOpen)) && (
{isTattoo && (pget('studio') || pbool('booksOpen')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{'studio' in profile && profile.studio && (
{pget('studio') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<MapPin className={`h-3 w-3 ${accent.text}`} /> Studio
</h4>
<p className="text-sm text-gray-300">{profile.studio}</p>
<p className="text-sm text-gray-300">{pget('studio')}</p>
</div>
)}
{'booksOpen' in profile && profile.booksOpen && (
{pbool('booksOpen') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<Brush className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-emerald-400">Books Open</span>
@ -347,17 +359,17 @@ export default function CoachDetailPage() {
)}
{/* Trainer — credentials & remote */}
{isTrainer && (('credentials' in profile && profile.credentials) || ('remote' in profile && profile.remote)) && (
{isTrainer && (pget('credentials') || pbool('remote')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{'credentials' in profile && profile.credentials && (
{pget('credentials') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<Dumbbell className={`h-3 w-3 ${accent.text}`} /> Credentials
</h4>
<p className="text-sm text-gray-300">{profile.credentials}</p>
<p className="text-sm text-gray-300">{pget('credentials')}</p>
</div>
)}
{'remote' in profile && profile.remote && (
{pbool('remote') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<Globe className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-gray-200">Remote / Online available</span>
@ -367,12 +379,12 @@ export default function CoachDetailPage() {
)}
{/* Graffiti — crew name */}
{isGraffiti && 'crew' in profile && profile.crew && (
{isGraffiti && pget('crew') && (
<div className="mb-6">
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<SprayCan className={`h-3 w-3 ${accent.text}`} /> Graffiti Crew
</h4>
<p className="text-sm text-gray-300">{profile.crew}</p>
<p className="text-sm text-gray-300">{pget('crew')}</p>
</div>
)}
@ -523,10 +535,10 @@ export default function CoachDetailPage() {
</a>
)}
{isCoach && profile.instagram && (
<a href={`https://instagram.com/${profile.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
{isCoach && pget('instagram') && (
<a href={`https://instagram.com/${pget('instagram')!.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
<Instagram className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">@{profile.instagram.replace('@', '')}</span>
<span className="truncate">@{pget('instagram')!.replace('@', '')}</span>
</a>
)}

View file

@ -72,7 +72,7 @@ function StatCard({
thisMonthDelta,
to,
}: {
icon: React.ComponentType<{ className?: string }>;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
value: number;
label: string;
sub?: string;

View file

@ -126,7 +126,7 @@ export default function ConversationPage() {
if (!newMessage.trim() || !otherPubkey || isSending) return;
sendMessage(
{ recipientPubkey: otherPubkey, content: newMessage.trim() },
{ recipients: otherPubkey, content: newMessage.trim() },
{
onSuccess: () => {
setNewMessage('');

View file

@ -75,7 +75,7 @@ export default function CreateEventPage() {
// "Book for a gig" action on a crew that has a band.
const prefillType = searchParams.get('type') ?? '';
const prefillTitle = searchParams.get('title') ?? '';
const { user } = useCurrentUser();
const { user, metadata } = useCurrentUser();
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
@ -443,7 +443,7 @@ export default function CreateEventPage() {
);
}
const hasNip05 = user?.metadata?.nip05;
const hasNip05 = metadata?.nip05;
return (
<Layout>

View file

@ -61,7 +61,7 @@ export default function CreateListingPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user } = useCurrentUser();
const { user, metadata } = useCurrentUser();
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
@ -109,7 +109,7 @@ export default function CreateListingPage() {
const [spotSearchQuery, setSpotSearchQuery] = useState('');
// Check if user has NIP-05 verification (or is platform owner who bypasses this)
const hasNip05 = !!user?.metadata?.nip05 || restrictions.isPlatformOwner;
const hasNip05 = !!metadata?.nip05 || restrictions.isPlatformOwner;
// Format cooldown time remaining
const formatCooldownTime = (ms: number | null): string => {
@ -461,7 +461,7 @@ export default function CreateListingPage() {
<CheckCircle className="h-5 w-5 text-green-400 flex-shrink-0" />
<div>
<span className="font-bold text-green-400">Verified: </span>
<span className="text-gray-300">{user.metadata?.nip05}</span>
<span className="text-gray-300">{metadata?.nip05}</span>
</div>
</div>
</CardContent>

View file

@ -15,7 +15,7 @@ import { useTours } from '@/hooks/useTours';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowing } from '@/hooks/useFollowing';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useComments } from '@/hooks/useComments';
import { useCommentCounts } from '@/hooks/useCommentCounts';
import { useTranslation } from '@/contexts/LanguageContext';
import {
EVENT_TYPE_LABELS,
@ -616,7 +616,7 @@ function FilterDrawer({
style={{
backgroundColor: isActive ? info.color : 'transparent',
borderColor: isActive ? info.color : `${info.color}40`,
color: isActive ? (info.color === '#ccff00' ? 'black' : 'white') : info.color,
color: isActive ? ((info.color as string) === '#ccff00' ? 'black' : 'white') : info.color,
}}
>
{info.emoji} {info.label}
@ -837,10 +837,9 @@ export default function EventsPage() {
const tIds = tours?.map((t) => t.event.id) || [];
return [...eIds, ...tIds];
}, [events, tours]);
const { data: allComments } = useComments(allEventIds);
const { data: commentCounts } = useCommentCounts(allEventIds);
const getCommentCount = (eventId: string) =>
allComments?.filter((c) => c.tags.find(([tag]) => tag === 'e')?.[1] === eventId).length || 0;
const getCommentCount = (eventId: string) => commentCounts?.get(eventId) ?? 0;
// ── Batch RSVP counts (one query for the whole grid, not one per card) ─────────
const eventCoords = useMemo(() => (events ?? []).map((e) => eventCoord(e)), [events]);

View file

@ -386,7 +386,6 @@ interface ToolbarProps {
sortBy: SortOption;
trustFilter: TrustFilterLevel;
onClearAll: () => void;
serviceType: ServiceType;
}
function getSkillLabelForType(skill: string, serviceType: ServiceType): string {

View file

@ -1,7 +1,7 @@
import { useSeoMeta } from '@unhead/react';
import { Layout } from '@/components/Layout';
import { Link } from 'react-router-dom';
import { Infinity, Scale, Skateboard } from 'lucide-react';
import { Infinity, Scale } from 'lucide-react';
export default function LicensePage() {
useSeoMeta({

View file

@ -33,7 +33,7 @@ import { useFollowing } from '@/hooks/useFollowing';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useMultipleSpotWeather } from '@/hooks/useSpotWeather';
import { useComments } from '@/hooks/useComments';
import { useCommentCounts } from '@/hooks/useCommentCounts';
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
import { useAuthor } from '@/hooks/useAuthor';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
@ -737,6 +737,9 @@ export default function MapPage() {
const shopList = (shops || []).map(shop => ({
...shop,
title: shop.name,
// SkateSpot-shaped fields so shops can share the unified spot list/map.
tags: [] as string[],
category: 'shop',
types: ['shop' as const],
isShop: true,
accessType: undefined,
@ -760,13 +763,10 @@ export default function MapPage() {
// Fetch comments for popularity sorting
const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]);
const { data: allComments } = useComments(spotIds);
const { data: commentCounts } = useCommentCounts(spotIds);
const getCommentCount = (eventId: string) => {
return allComments?.filter((c) => {
const eTag = c.tags.find(([tag]) => tag === 'e')?.[1];
return eTag === eventId;
}).length || 0;
return commentCounts?.get(eventId) ?? 0;
};
// Get geohashes for weather fetching (only non-shop spots)

View file

@ -21,7 +21,7 @@ function Section({
title,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
title: string;
children: React.ReactNode;

View file

@ -80,7 +80,7 @@ export default function ShopDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: shop, isLoading, error } = useShop(identifier || '', pubkey);
const author = useAuthor(shop?.pubkey);
const { user } = useCurrentUser();
const { user, metadata: userMetadata } = useCurrentUser();
const { mutateAsync: deleteEntity } = useDeleteEntity();
const { toast } = useToast();
const navigate = useNavigate();
@ -93,7 +93,7 @@ export default function ShopDetailPage() {
// Owner can delete their own shop; a SkateSpots admin can take down any shop.
const canDelete = !!user && !!shop && (isOwner || isAdmin);
const adminTakedown = isAdmin && !isOwner;
const hasNip05 = user?.metadata?.nip05;
const hasNip05 = userMetadata?.nip05;
useSeoMeta({
title: shop ? `${shop.name} - SkateSpots` : 'Skate Shop - SkateSpots',

View file

@ -123,7 +123,7 @@ export default function SpotDetailPage() {
const { data: marketplaceItems, isLoading: isLoadingMarketplace } = useMarketplaceAtSpot(pubkey || '', identifier || '');
const { data: spotServices, isLoading: isLoadingServices } = useSpotServices(pubkey, identifier);
const author = useAuthor(spot?.pubkey);
const { user } = useCurrentUser();
const { user, metadata: userMetadata } = useCurrentUser();
const { mutateAsync: deleteEntity } = useDeleteEntity();
const { toast } = useToast();
const navigate = useNavigate();
@ -140,7 +140,7 @@ export default function SpotDetailPage() {
const canDelete = !!user && !!spot && (isOwner || isAdmin);
const adminTakedown = isAdmin && !isOwner;
const isShop = spot?.types.includes('shop');
const hasNip05 = user?.metadata?.nip05;
const hasNip05 = userMetadata?.nip05;
// Community contributions
const { data: communityPhotos } = useSpotPhotos(pubkey || '', identifier || '');

View file

@ -80,7 +80,7 @@ function SectionHeader({
subtitle,
color = '#ccff00',
}: {
icon: React.ComponentType<{ className?: string }>;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
title: string;
subtitle?: string;
color?: string;

View file

@ -1,5 +1,6 @@
import { useSeoMeta } from '@unhead/react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@ -198,12 +199,12 @@ export default function TourDetailPage() {
</div>
<div className="flex items-center gap-2">
<ZapButton
event={tour.event}
target={tour.event}
variant="ghost"
size="sm"
className="text-orange-400 hover:text-orange-300 hover:bg-orange-500/10"
/>
<Link to={`/npub1${tour.pubkey.slice(0, 8)}...`}>
<Link to={`/${nip19.npubEncode(tour.pubkey)}`}>
<Button variant="outline" size="sm" className="border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/10">
View Profile
</Button>

View file

@ -23,6 +23,7 @@ export function TestApp({ children }: TestAppProps) {
const defaultConfig: AppConfig = {
theme: 'dark',
useSkateRelay: true,
relayMetadata: {
relays: [
{ url: 'wss://auth.nostr1.com', read: true, write: true },