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:
parent
1a9a5ae967
commit
77a3d592ea
4 changed files with 46 additions and 5 deletions
|
|
@ -17,7 +17,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
import { useBatchActivity } from '@/hooks/useTradeIntegrity';
|
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';
|
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
||||||
|
|
||||||
const PHOTO_KIND = 1063; // NIP-94 file metadata (community photo)
|
const PHOTO_KIND = 1063; // NIP-94 file metadata (community photo)
|
||||||
|
|
@ -38,6 +38,8 @@ export interface SpotCommunityBreakdown {
|
||||||
zapSats: number;
|
zapSats: number;
|
||||||
collections: number;
|
collections: number;
|
||||||
tagSuggestions: number;
|
tagSuggestions: number;
|
||||||
|
crews: number;
|
||||||
|
labRefs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpotCommunityLevel = 'unverified' | 'seen' | 'community';
|
export type SpotCommunityLevel = 'unverified' | 'seen' | 'community';
|
||||||
|
|
@ -55,6 +57,8 @@ const WEIGHT = {
|
||||||
photo: 2,
|
photo: 2,
|
||||||
status: 2,
|
status: 2,
|
||||||
zap: 3, // real money
|
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,
|
comment: 1,
|
||||||
reaction: 1,
|
reaction: 1,
|
||||||
collection: 1,
|
collection: 1,
|
||||||
|
|
@ -75,11 +79,14 @@ interface AccountSignals {
|
||||||
zapSats: number;
|
zapSats: number;
|
||||||
collections: number;
|
collections: number;
|
||||||
tagSuggestions: number;
|
tagSuggestions: number;
|
||||||
|
crews: number;
|
||||||
|
labRefs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyBreakdown = (): SpotCommunityBreakdown => ({
|
const emptyBreakdown = (): SpotCommunityBreakdown => ({
|
||||||
skaters: 0, photos: 0, statusReports: 0, comments: 0,
|
skaters: 0, photos: 0, statusReports: 0, comments: 0,
|
||||||
reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0,
|
reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0,
|
||||||
|
crews: 0, labRefs: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
function classify(ev: NostrEvent): keyof AccountSignals | null {
|
function classify(ev: NostrEvent): keyof AccountSignals | null {
|
||||||
|
|
@ -91,7 +98,9 @@ function classify(ev: NostrEvent): keyof AccountSignals | null {
|
||||||
case REACTION_KIND: return 'reactions';
|
case REACTION_KIND: return 'reactions';
|
||||||
case ZAP_KIND: return 'zaps';
|
case ZAP_KIND: return 'zaps';
|
||||||
case COLLECTION_KIND: return 'collections';
|
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], '#a': [coord], limit: 100 },
|
||||||
{ kinds: [ZAP_KIND], '#e': [spot.id], limit: 100 },
|
{ kinds: [ZAP_KIND], '#e': [spot.id], limit: 100 },
|
||||||
{ kinds: [COLLECTION_KIND], '#a': [coord], 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 },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
@ -147,7 +158,7 @@ export function useSpotCommunityScore(
|
||||||
const key = classify(ev);
|
const key = classify(ev);
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const acc = map.get(ev.pubkey) ?? {
|
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;
|
acc[key] += 1;
|
||||||
if (key === 'zaps') acc.zapSats += zapSatsFrom(ev);
|
if (key === 'zaps') acc.zapSats += zapSatsFrom(ev);
|
||||||
|
|
@ -167,6 +178,8 @@ export function useSpotCommunityScore(
|
||||||
b.zapSats += a.zapSats;
|
b.zapSats += a.zapSats;
|
||||||
b.collections += a.collections;
|
b.collections += a.collections;
|
||||||
b.tagSuggestions += a.tagSuggestions;
|
b.tagSuggestions += a.tagSuggestions;
|
||||||
|
b.crews += a.crews;
|
||||||
|
b.labRefs += a.labRefs;
|
||||||
}
|
}
|
||||||
b.skaters = byAccount.size;
|
b.skaters = byAccount.size;
|
||||||
return b;
|
return b;
|
||||||
|
|
@ -187,7 +200,9 @@ export function useSpotCommunityScore(
|
||||||
a.comments * WEIGHT.comment +
|
a.comments * WEIGHT.comment +
|
||||||
a.reactions * WEIGHT.reaction +
|
a.reactions * WEIGHT.reaction +
|
||||||
a.collections * WEIGHT.collection +
|
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);
|
const capped = Math.min(raw, PER_ACCOUNT_CAP);
|
||||||
// Trust (0..100) and activity both raise the multiplier; unknown → neutral 1.
|
// 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;
|
const trustFactor = trust ? 0.4 + 0.6 * ((trust.get(pk) ?? 0) / 100) : 1;
|
||||||
|
|
@ -199,7 +214,7 @@ export function useSpotCommunityScore(
|
||||||
|
|
||||||
const level: SpotCommunityLevel = useMemo(() => {
|
const level: SpotCommunityLevel = useMemo(() => {
|
||||||
if (breakdown.skaters === 0) return 'unverified';
|
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';
|
if ((breakdown.skaters >= 2 && score >= COMMUNITY_THRESHOLD) || strong) return 'community';
|
||||||
return 'seen';
|
return 'seen';
|
||||||
}, [breakdown, score]);
|
}, [breakdown, score]);
|
||||||
|
|
|
||||||
|
|
@ -1138,6 +1138,11 @@ spotUi: {
|
||||||
breakdownCollections: '{{count}} save(s)',
|
breakdownCollections: '{{count}} save(s)',
|
||||||
breakdownZaps: '{{count}} zap(s)',
|
breakdownZaps: '{{count}} zap(s)',
|
||||||
breakdownTags: '{{count}} tag suggestion(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',
|
noGearListedYet: 'No gear listed yet',
|
||||||
noMessagesYet: 'No messages yet in this local channel. Say hi — "anyone skating tonight?"',
|
noMessagesYet: 'No messages yet in this local channel. Say hi — "anyone skating tonight?"',
|
||||||
noPhoto: 'No photo',
|
noPhoto: 'No photo',
|
||||||
|
|
|
||||||
|
|
@ -1138,6 +1138,11 @@ spotUi: {
|
||||||
breakdownCollections: '{{count}} guardado(s)',
|
breakdownCollections: '{{count}} guardado(s)',
|
||||||
breakdownZaps: '{{count}} zap(s)',
|
breakdownZaps: '{{count}} zap(s)',
|
||||||
breakdownTags: '{{count}} etiqueta(s) sugerida(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',
|
noGearListedYet: 'Aún no hay material publicado',
|
||||||
noMessagesYet: 'Aún no hay mensajes en este canal local. Saluda: "¿alguien patina esta noche?"',
|
noMessagesYet: 'Aún no hay mensajes en este canal local. Saluda: "¿alguien patina esta noche?"',
|
||||||
noPhoto: 'Sin foto',
|
noPhoto: 'Sin foto',
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,8 @@ export default function SpotDetailPage() {
|
||||||
if (b.collections) parts.push(t('spotUi.breakdownCollections', { count: b.collections }));
|
if (b.collections) parts.push(t('spotUi.breakdownCollections', { count: b.collections }));
|
||||||
if (b.zaps) parts.push(t('spotUi.breakdownZaps', { count: b.zaps }));
|
if (b.zaps) parts.push(t('spotUi.breakdownZaps', { count: b.zaps }));
|
||||||
if (b.tagSuggestions) parts.push(t('spotUi.breakdownTags', { count: b.tagSuggestions }));
|
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(' · ');
|
return parts.join(' · ');
|
||||||
}, [community.breakdown, t]);
|
}, [community.breakdown, t]);
|
||||||
|
|
||||||
|
|
@ -595,6 +597,20 @@ export default function SpotDetailPage() {
|
||||||
</p>
|
</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. */}
|
{/* Reported as not a skate spot — visible to everyone, not just admins. */}
|
||||||
{notASpotReporters > 0 && (
|
{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">
|
<div className="mt-3 flex items-start gap-2.5 rounded-xl border border-red-500/40 bg-red-500/10 p-3.5">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue