feat(spots): crews + Lab links count as strong signals; add a verify nudge

- useSpotCommunityScore now also reads crews (35006, `a` ref, weight 3) and Lab
  profile links (35001-35014, `a` ref, weight 2) as effortful signals — a whole
  crew listing the spot, or a Lab pro linking it, is strong evidence. A crew
  ref alone reaches "community". (Tours/events use geohash, not a spot coord,
  so they're not counted.)
- Spot page: on an unconfirmed spot, a logged-in non-owner now sees a "Be the
  first to confirm this spot" nudge with Add-photo + "I've skated here".
- Breakdown line/tooltip include crew + Lab counts.
This commit is contained in:
SkateSpots 2026-10-03 18:48:19 +07:00
parent 1a9a5ae967
commit 77a3d592ea
4 changed files with 46 additions and 5 deletions

View file

@ -17,7 +17,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchActivity } from '@/hooks/useTradeIntegrity';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, LAB_KINDS } from '@/lib/types';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
const PHOTO_KIND = 1063; // NIP-94 file metadata (community photo)
@ -38,6 +38,8 @@ export interface SpotCommunityBreakdown {
zapSats: number;
collections: number;
tagSuggestions: number;
crews: number;
labRefs: number;
}
export type SpotCommunityLevel = 'unverified' | 'seen' | 'community';
@ -55,6 +57,8 @@ const WEIGHT = {
photo: 2,
status: 2,
zap: 3, // real money
crew: 3, // a whole crew listing it as home turf
labRef: 2, // a Lab pro (builder/coach/…) linking the spot
comment: 1,
reaction: 1,
collection: 1,
@ -75,11 +79,14 @@ interface AccountSignals {
zapSats: number;
collections: number;
tagSuggestions: number;
crews: number;
labRefs: number;
}
const emptyBreakdown = (): SpotCommunityBreakdown => ({
skaters: 0, photos: 0, statusReports: 0, comments: 0,
reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0,
crews: 0, labRefs: 0,
});
function classify(ev: NostrEvent): keyof AccountSignals | null {
@ -91,7 +98,9 @@ function classify(ev: NostrEvent): keyof AccountSignals | null {
case REACTION_KIND: return 'reactions';
case ZAP_KIND: return 'zaps';
case COLLECTION_KIND: return 'collections';
default: return null;
case SKATE_CREW_KIND: return 'crews';
default:
return (LAB_KINDS as readonly number[]).includes(ev.kind) ? 'labRefs' : null;
}
}
@ -129,6 +138,8 @@ export function useSpotCommunityScore(
{ kinds: [ZAP_KIND], '#a': [coord], limit: 100 },
{ kinds: [ZAP_KIND], '#e': [spot.id], limit: 100 },
{ kinds: [COLLECTION_KIND], '#a': [coord], limit: 100 },
{ kinds: [SKATE_CREW_KIND], '#a': [coord], limit: 50 },
{ kinds: [...LAB_KINDS], '#a': [coord], limit: 100 },
],
{ signal },
);
@ -147,7 +158,7 @@ export function useSpotCommunityScore(
const key = classify(ev);
if (!key) continue;
const acc = map.get(ev.pubkey) ?? {
photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0,
photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, crews: 0, labRefs: 0,
};
acc[key] += 1;
if (key === 'zaps') acc.zapSats += zapSatsFrom(ev);
@ -167,6 +178,8 @@ export function useSpotCommunityScore(
b.zapSats += a.zapSats;
b.collections += a.collections;
b.tagSuggestions += a.tagSuggestions;
b.crews += a.crews;
b.labRefs += a.labRefs;
}
b.skaters = byAccount.size;
return b;
@ -187,7 +200,9 @@ export function useSpotCommunityScore(
a.comments * WEIGHT.comment +
a.reactions * WEIGHT.reaction +
a.collections * WEIGHT.collection +
a.tagSuggestions * WEIGHT.tagSuggestion;
a.tagSuggestions * WEIGHT.tagSuggestion +
a.crews * WEIGHT.crew +
a.labRefs * WEIGHT.labRef;
const capped = Math.min(raw, PER_ACCOUNT_CAP);
// Trust (0..100) and activity both raise the multiplier; unknown → neutral 1.
const trustFactor = trust ? 0.4 + 0.6 * ((trust.get(pk) ?? 0) / 100) : 1;
@ -199,7 +214,7 @@ export function useSpotCommunityScore(
const level: SpotCommunityLevel = useMemo(() => {
if (breakdown.skaters === 0) return 'unverified';
const strong = breakdown.zaps > 0;
const strong = breakdown.zaps > 0 || breakdown.crews > 0;
if ((breakdown.skaters >= 2 && score >= COMMUNITY_THRESHOLD) || strong) return 'community';
return 'seen';
}, [breakdown, score]);

View file

@ -1138,6 +1138,11 @@ spotUi: {
breakdownCollections: '{{count}} save(s)',
breakdownZaps: '{{count}} zap(s)',
breakdownTags: '{{count}} tag suggestion(s)',
breakdownCrews: '{{count}} crew(s)',
breakdownLab: '{{count}} pro link(s)',
verifyNudgeTitle: 'Be the first to confirm this spot',
verifyNudgeBody: "A photo or an “I've skated here” is how a spot gets verified — help the next skater out.",
verifyNudgeStatus: "I've skated here",
noGearListedYet: 'No gear listed yet',
noMessagesYet: 'No messages yet in this local channel. Say hi — "anyone skating tonight?"',
noPhoto: 'No photo',

View file

@ -1138,6 +1138,11 @@ spotUi: {
breakdownCollections: '{{count}} guardado(s)',
breakdownZaps: '{{count}} zap(s)',
breakdownTags: '{{count}} etiqueta(s) sugerida(s)',
breakdownCrews: '{{count}} cuadrilla(s)',
breakdownLab: '{{count}} enlace(s) pro',
verifyNudgeTitle: 'Sé el primero en confirmar este spot',
verifyNudgeBody: 'Una foto o un «he patinado aquí» es como se verifica un spot: ayuda al siguiente skater.',
verifyNudgeStatus: 'He patinado aquí',
noGearListedYet: 'Aún no hay material publicado',
noMessagesYet: 'Aún no hay mensajes en este canal local. Saluda: "¿alguien patina esta noche?"',
noPhoto: 'Sin foto',

View file

@ -197,6 +197,8 @@ export default function SpotDetailPage() {
if (b.collections) parts.push(t('spotUi.breakdownCollections', { count: b.collections }));
if (b.zaps) parts.push(t('spotUi.breakdownZaps', { count: b.zaps }));
if (b.tagSuggestions) parts.push(t('spotUi.breakdownTags', { count: b.tagSuggestions }));
if (b.crews) parts.push(t('spotUi.breakdownCrews', { count: b.crews }));
if (b.labRefs) parts.push(t('spotUi.breakdownLab', { count: b.labRefs }));
return parts.join(' · ');
}, [community.breakdown, t]);
@ -595,6 +597,20 @@ export default function SpotDetailPage() {
</p>
)}
{/* Verify nudge — a logged-in non-owner on an unconfirmed spot. */}
{!isShop && user && !isOwner && !communityVerified && !staffVerified && (
<div className="mt-3 rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-3.5">
<p className="text-sm font-bold text-emerald-300">{t('spotUi.verifyNudgeTitle')}</p>
<p className="mt-0.5 text-xs text-gray-400">{t('spotUi.verifyNudgeBody')}</p>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<AddPhotoToSpot spotPubkey={spot.pubkey} spotIdentifier={spot.identifier} spotTitle={spot.title} />
<Button type="button" variant="outline" size="sm" onClick={() => setShowStatusDialog(true)}>
{t('spotUi.verifyNudgeStatus')}
</Button>
</div>
</div>
)}
{/* Reported as not a skate spot — visible to everyone, not just admins. */}
{notASpotReporters > 0 && (
<div className="mt-3 flex items-start gap-2.5 rounded-xl border border-red-500/40 bg-red-500/10 p-3.5">