SkateSpots/src/hooks/useCrews.ts
SkateSpots f73a360983 fix(lab): complete offer-a-service audit — data preservation, rendering, stats, input handling
- Preserve the geo 'g' tag and paid 'featured' tag when editing any Lab
  profile (all 7 craft roles via CraftProfileForm + 5 bespoke pages).
- Render previously collected-but-hidden fields on the Lab profile page:
  judge 'events_judged', organizer 'events_hosted', editor hardware/software,
  and spot references.
- Include Event Organizer (35013) in Community Stats + counts.
- Cap Title inputs at 100 (was 120) so profiles no longer fail the parser
  and silently vanish; MIN_TITLE_LEN 2 -> 1 for 1-char display names.
- Normalize bare-domain URLs (example.com) to https:// before storing instead
  of dropping/hiding them (new normalizeUrlInput helper; fixes extra url fields).
- No crew strip for judging/organizing (was wrongly showing Photo & Film);
  scrollable Offer Services FAB; live bio counters.
2026-09-27 09:38:03 +07:00

182 lines
5.6 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import {
SKATE_CREW_KIND,
parseSkateCrew,
CREW_FOCUS_TAG,
type SkateCrew,
type CrewFocus,
} from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useCrews(limit = 200) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[
{
kinds: [SKATE_CREW_KIND],
'#t': ['skatecrew'],
limit,
},
],
{ signal }
);
const crews: SkateCrew[] = [];
for (const event of events) {
const crew = parseSkateCrew(event);
if (crew) crews.push(crew);
}
return crews.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}
/**
* Query crews that belong to a specific Lab scene (builder / photo / coaching).
* Filters at the relay level via the crew-focus `t` tag, so The Lab pages can
* show a "Crews in this scene" strip without scanning every crew.
*/
export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'> | null, limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const focusTag = focus ? CREW_FOCUS_TAG[focus] : '';
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()],
enabled: !!focus,
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[
{
kinds: [SKATE_CREW_KIND],
'#t': [focusTag],
limit,
},
],
{ signal }
);
const crews: SkateCrew[] = [];
for (const event of events) {
const crew = parseSkateCrew(event);
// Double-check focus after parsing (guards against loose relay matches).
if (crew && crew.focus === focus) crews.push(crew);
}
return crews.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}
export function useCrew(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()],
select: (crew: SkateCrew | null) =>
crew && hidden.has({ coord: `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`, id: crew.id })
? null
: crew,
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const pickMostRecent = (events: { created_at: number }[]) =>
events.length
? events.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest
)
: undefined;
// 1) Precise addressable lookup: kind + d-tag (+ author when known).
const filter: {
kinds: number[];
'#d': string[];
authors?: string[];
limit: number;
} = {
kinds: [SKATE_CREW_KIND],
'#d': [identifier],
limit: 1,
};
if (pubkey) filter.authors = [pubkey];
let events = await querySkateContent([filter], { signal });
// 2) Fallback: some relays don't index the `d` tag reliably (or choke on
// combined author + tag filters). If nothing came back and we know the
// author, fetch that author's crews and match the identifier locally.
if (events.length === 0 && pubkey) {
const byAuthor = await querySkateContent(
[{ kinds: [SKATE_CREW_KIND], authors: [pubkey], limit: 200 }],
{ signal }
);
events = byAuthor.filter((e) =>
e.tags.some(([name, value]) => name === 'd' && value === identifier)
);
}
const mostRecent = pickMostRecent(events);
if (!mostRecent) return null;
return parseSkateCrew(mostRecent as typeof events[number]);
},
enabled: !!identifier,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}
export interface UserCrewMembership {
crew: SkateCrew;
/** How the user is attached to this crew. */
role: 'founder' | 'member';
}
/**
* The crews a given pubkey belongs to — as founder or listed member.
*
* Powers the "Crews" section on a user's public profile. Founder crews sort
* first, then member crews, newest first within each group.
*/
export function useUserCrews(pubkey?: string) {
const { data: crews, isLoading, error } = useCrews(200);
const memberships = useMemo<UserCrewMembership[]>(() => {
if (!pubkey || !crews) return [];
return crews
.filter((c) => c.pubkey === pubkey || c.members.includes(pubkey))
.map((crew) => ({
crew,
role: crew.pubkey === pubkey ? ('founder' as const) : ('member' as const),
}))
.sort((a, b) => {
if (a.role !== b.role) return a.role === 'founder' ? -1 : 1;
return b.crew.createdAt - a.crew.createdAt;
});
}, [pubkey, crews]);
return { memberships, isLoading, error };
}