From 77a3d592ea9a2ed0763ce2fab25ea973b0c80c6c Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Sat, 3 Oct 2026 18:48:19 +0700
Subject: [PATCH] feat(spots): crews + Lab links count as strong signals; add a
verify nudge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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.
---
src/hooks/useSpotCommunityScore.ts | 25 ++++++++++++++++++++-----
src/i18n/en.ts | 5 +++++
src/i18n/es.ts | 5 +++++
src/pages/SpotDetailPage.tsx | 16 ++++++++++++++++
4 files changed, 46 insertions(+), 5 deletions(-)
diff --git a/src/hooks/useSpotCommunityScore.ts b/src/hooks/useSpotCommunityScore.ts
index 3418c67..8809b23 100644
--- a/src/hooks/useSpotCommunityScore.ts
+++ b/src/hooks/useSpotCommunityScore.ts
@@ -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]);
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index d7c49ac..8159798 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -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',
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index a3905d5..b570e8d 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -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',
diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx
index 406a896..db9eba0 100644
--- a/src/pages/SpotDetailPage.tsx
+++ b/src/pages/SpotDetailPage.tsx
@@ -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() {
)}
+ {/* Verify nudge — a logged-in non-owner on an unconfirmed spot. */}
+ {!isShop && user && !isOwner && !communityVerified && !staffVerified && (
+
+
{t('spotUi.verifyNudgeTitle')}
+
{t('spotUi.verifyNudgeBody')}
+
+
+
+
+
+ )}
+
{/* Reported as not a skate spot — visible to everyone, not just admins. */}
{notASpotReporters > 0 && (