feat(map): show community confidence on spot cards
- New `useBatchSpotCommunityScores(spots)` — ONE query for many spots (same signals/weights as the single-spot hook; counts distinct accounts, drops the mapper, caps per account; skip WoT/activity weighting at this scale). - SpotCard takes an optional `community` prop and shows a small "Community" (green) or "Seen" chip in the footer. - MapPage scores the visible spot cards (shops excluded — different kind) and passes each card its score.
This commit is contained in:
parent
77a3d592ea
commit
b285db907d
6 changed files with 177 additions and 15 deletions
|
|
@ -11,10 +11,11 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { MapPin, Store, Bookmark, MoreVertical, Flag } from 'lucide-react';
|
import { MapPin, Store, Bookmark, MoreVertical, Flag, ShieldCheck } from 'lucide-react';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
import { useVenuesWithPasses } from '@/hooks/useRiderCard';
|
||||||
|
import type { SpotCommunityMini } from '@/hooks/useBatchSpotCommunityScores';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
|
|
@ -34,9 +35,11 @@ interface SpotCardProps {
|
||||||
spot: SkateSpot;
|
spot: SkateSpot;
|
||||||
/** Optional community status, passed in batched from the parent list/map. */
|
/** Optional community status, passed in batched from the parent list/map. */
|
||||||
status?: AggregatedSpotStatus | null;
|
status?: AggregatedSpotStatus | null;
|
||||||
|
/** Optional community confidence, passed in batched from the parent list/map. */
|
||||||
|
community?: SpotCommunityMini;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpotCard({ spot, status }: SpotCardProps) {
|
export function SpotCard({ spot, status, community }: SpotCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const author = useAuthor(spot.pubkey);
|
const author = useAuthor(spot.pubkey);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
|
|
@ -192,6 +195,20 @@ export function SpotCard({ spot, status }: SpotCardProps) {
|
||||||
<TrustBadge pubkey={spot.pubkey} size="sm" />
|
<TrustBadge pubkey={spot.pubkey} size="sm" />
|
||||||
{/* Official badge — only shown for verified founder */}
|
{/* Official badge — only shown for verified founder */}
|
||||||
<OfficialBadge pubkey={spot.pubkey} size="sm" />
|
<OfficialBadge pubkey={spot.pubkey} size="sm" />
|
||||||
|
{/* Community confidence — batched in by the parent list/map. */}
|
||||||
|
{community?.level === 'community' && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase text-emerald-400"
|
||||||
|
title={t('spotUi.communityVerifiedTitle')}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-2.5 w-2.5" /> {t('spotUi.communityChip')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{community?.level === 'seen' && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-white/5 px-1.5 py-0.5 text-[9px] font-bold uppercase text-gray-500">
|
||||||
|
{t('spotUi.seenChip')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
||||||
|
|
|
||||||
124
src/hooks/useBatchSpotCommunityScores.ts
Normal file
124
src/hooks/useBatchSpotCommunityScores.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* Batch community confidence for many spots (map grid / lists). One relay query
|
||||||
|
* for all coords, then a distinct-account, tier-weighted level per spot.
|
||||||
|
*
|
||||||
|
* ⚠️ Unlike the single-spot `useSpotCommunityScore`, the batch does NOT apply
|
||||||
|
* web-of-trust / activity weighting — resolving that for hundreds of actors
|
||||||
|
* would be its own fan-out. It still counts distinct accounts, drops the
|
||||||
|
* mapper's own signals, and caps each account's contribution.
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, LAB_KINDS } from '@/lib/types';
|
||||||
|
import {
|
||||||
|
WEIGHT,
|
||||||
|
PER_ACCOUNT_CAP,
|
||||||
|
emptyBreakdown,
|
||||||
|
classifySignal,
|
||||||
|
communityLevel,
|
||||||
|
type AccountSignals,
|
||||||
|
type SpotCommunityBreakdown,
|
||||||
|
type SpotCommunityLevel,
|
||||||
|
} from '@/hooks/useSpotCommunityScore';
|
||||||
|
|
||||||
|
export interface SpotCommunityMini {
|
||||||
|
level: SpotCommunityLevel;
|
||||||
|
breakdown: SpotCommunityBreakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blankSignals = (): AccountSignals => ({
|
||||||
|
photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, crews: 0, labRefs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useBatchSpotCommunityScores(
|
||||||
|
spots: Array<{ id: string; pubkey: string; identifier: string }>,
|
||||||
|
) {
|
||||||
|
const { querySkateContent } = useSkateRelays();
|
||||||
|
|
||||||
|
const refs = useMemo(
|
||||||
|
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
|
||||||
|
[spots],
|
||||||
|
);
|
||||||
|
const mapperByRef = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const s of spots) m.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s.pubkey);
|
||||||
|
return m;
|
||||||
|
}, [spots]);
|
||||||
|
|
||||||
|
return useQuery<Map<string, SpotCommunityMini>>({
|
||||||
|
queryKey: ['batch-spot-community', refs.slice().sort().join(',')],
|
||||||
|
enabled: refs.length > 0,
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
const out = new Map<string, SpotCommunityMini>();
|
||||||
|
if (refs.length === 0) return out;
|
||||||
|
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[
|
||||||
|
{ kinds: [1063], '#a': refs, limit: 400 },
|
||||||
|
{ kinds: [2259], '#a': refs, limit: 400 },
|
||||||
|
{ kinds: [1111], '#A': refs, limit: 400 },
|
||||||
|
{ kinds: [7], '#a': refs, limit: 400 },
|
||||||
|
{ kinds: [9735], '#a': refs, limit: 400 },
|
||||||
|
{ kinds: [30003], '#a': refs, limit: 400 },
|
||||||
|
{ kinds: [SKATE_CREW_KIND], '#a': refs, limit: 300 },
|
||||||
|
{ kinds: [...LAB_KINDS], '#a': refs, limit: 400 },
|
||||||
|
],
|
||||||
|
{ signal },
|
||||||
|
).catch(() => [] as NostrEvent[]);
|
||||||
|
|
||||||
|
// coord → (account → signals)
|
||||||
|
const perCoord = new Map<string, Map<string, AccountSignals>>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (seen.has(ev.id)) continue;
|
||||||
|
seen.add(ev.id);
|
||||||
|
const ref = ev.tags.find(([n, v]) => n === 'a' && v?.startsWith(`${SKATE_SPOT_KIND}:`))?.[1];
|
||||||
|
if (!ref || !mapperByRef.has(ref)) continue;
|
||||||
|
if (ev.pubkey === mapperByRef.get(ref)) continue; // the mapper's own signals never count
|
||||||
|
const key = classifySignal(ev);
|
||||||
|
if (!key) continue;
|
||||||
|
let accounts = perCoord.get(ref);
|
||||||
|
if (!accounts) { accounts = new Map(); perCoord.set(ref, accounts); }
|
||||||
|
const acc = accounts.get(ev.pubkey) ?? blankSignals();
|
||||||
|
acc[key] += 1;
|
||||||
|
accounts.set(ev.pubkey, acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ref, accounts] of perCoord) {
|
||||||
|
const b = emptyBreakdown();
|
||||||
|
let score = 0;
|
||||||
|
for (const a of accounts.values()) {
|
||||||
|
b.photos += a.photos;
|
||||||
|
b.statusReports += a.statusReports;
|
||||||
|
b.comments += a.comments;
|
||||||
|
b.reactions += a.reactions;
|
||||||
|
b.zaps += a.zaps;
|
||||||
|
b.zapSats += a.zapSats;
|
||||||
|
b.collections += a.collections;
|
||||||
|
b.tagSuggestions += a.tagSuggestions;
|
||||||
|
b.crews += a.crews;
|
||||||
|
b.labRefs += a.labRefs;
|
||||||
|
const raw =
|
||||||
|
a.photos * WEIGHT.photo +
|
||||||
|
a.statusReports * WEIGHT.status +
|
||||||
|
a.zaps * WEIGHT.zap +
|
||||||
|
a.comments * WEIGHT.comment +
|
||||||
|
a.reactions * WEIGHT.reaction +
|
||||||
|
a.collections * WEIGHT.collection +
|
||||||
|
a.tagSuggestions * WEIGHT.tagSuggestion +
|
||||||
|
a.crews * WEIGHT.crew +
|
||||||
|
a.labRefs * WEIGHT.labRef;
|
||||||
|
score += Math.min(raw, PER_ACCOUNT_CAP);
|
||||||
|
}
|
||||||
|
b.skaters = accounts.size;
|
||||||
|
out.set(ref, { level: communityLevel(b, score), breakdown: b });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ export interface SpotCommunityScore {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-signal weight. Costly signals weigh more than cheap ones. */
|
/** Per-signal weight. Costly signals weigh more than cheap ones. */
|
||||||
const WEIGHT = {
|
export const WEIGHT = {
|
||||||
photo: 2,
|
photo: 2,
|
||||||
status: 2,
|
status: 2,
|
||||||
zap: 3, // real money
|
zap: 3, // real money
|
||||||
|
|
@ -66,11 +66,11 @@ const WEIGHT = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** One account can contribute at most this much, so a single fan can't carry it. */
|
/** One account can contribute at most this much, so a single fan can't carry it. */
|
||||||
const PER_ACCOUNT_CAP = 3;
|
export const PER_ACCOUNT_CAP = 3;
|
||||||
/** Weighted score needed to reach "community" (with ≥2 distinct skaters). */
|
/** Weighted score needed to reach "community" (with ≥2 distinct skaters). */
|
||||||
const COMMUNITY_THRESHOLD = 2;
|
const COMMUNITY_THRESHOLD = 2;
|
||||||
|
|
||||||
interface AccountSignals {
|
export interface AccountSignals {
|
||||||
photos: number;
|
photos: number;
|
||||||
statusReports: number;
|
statusReports: number;
|
||||||
comments: number;
|
comments: number;
|
||||||
|
|
@ -83,13 +83,13 @@ interface AccountSignals {
|
||||||
labRefs: number;
|
labRefs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyBreakdown = (): SpotCommunityBreakdown => ({
|
export 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,
|
crews: 0, labRefs: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
function classify(ev: NostrEvent): keyof AccountSignals | null {
|
export function classifySignal(ev: NostrEvent): keyof AccountSignals | null {
|
||||||
switch (ev.kind) {
|
switch (ev.kind) {
|
||||||
case PHOTO_KIND: return 'photos';
|
case PHOTO_KIND: return 'photos';
|
||||||
case STATUS_KIND: return 'statusReports';
|
case STATUS_KIND: return 'statusReports';
|
||||||
|
|
@ -114,6 +114,13 @@ function zapSatsFrom(ev: NostrEvent): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function communityLevel(breakdown: SpotCommunityBreakdown, score: number): SpotCommunityLevel {
|
||||||
|
if (breakdown.skaters === 0) return 'unverified';
|
||||||
|
const strong = breakdown.zaps > 0 || breakdown.crews > 0;
|
||||||
|
if ((breakdown.skaters >= 2 && score >= COMMUNITY_THRESHOLD) || strong) return 'community';
|
||||||
|
return 'seen';
|
||||||
|
}
|
||||||
|
|
||||||
export function useSpotCommunityScore(
|
export function useSpotCommunityScore(
|
||||||
spot: Pick<{ pubkey: string; identifier: string; id: string }, 'pubkey' | 'identifier' | 'id'> | null | undefined,
|
spot: Pick<{ pubkey: string; identifier: string; id: string }, 'pubkey' | 'identifier' | 'id'> | null | undefined,
|
||||||
) {
|
) {
|
||||||
|
|
@ -155,7 +162,7 @@ export function useSpotCommunityScore(
|
||||||
if (ev.pubkey === spot.pubkey) continue; // the mapper's own signals never count
|
if (ev.pubkey === spot.pubkey) continue; // the mapper's own signals never count
|
||||||
if (seen.has(ev.id)) continue;
|
if (seen.has(ev.id)) continue;
|
||||||
seen.add(ev.id);
|
seen.add(ev.id);
|
||||||
const key = classify(ev);
|
const key = classifySignal(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, crews: 0, labRefs: 0,
|
photos: 0, statusReports: 0, comments: 0, reactions: 0, zaps: 0, zapSats: 0, collections: 0, tagSuggestions: 0, crews: 0, labRefs: 0,
|
||||||
|
|
@ -212,12 +219,10 @@ export function useSpotCommunityScore(
|
||||||
return total;
|
return total;
|
||||||
}, [byAccount, trust, activity]);
|
}, [byAccount, trust, activity]);
|
||||||
|
|
||||||
const level: SpotCommunityLevel = useMemo(() => {
|
const level: SpotCommunityLevel = useMemo(
|
||||||
if (breakdown.skaters === 0) return 'unverified';
|
() => communityLevel(breakdown, score),
|
||||||
const strong = breakdown.zaps > 0 || breakdown.crews > 0;
|
[breakdown, score],
|
||||||
if ((breakdown.skaters >= 2 && score >= COMMUNITY_THRESHOLD) || strong) return 'community';
|
);
|
||||||
return 'seen';
|
|
||||||
}, [breakdown, score]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
level,
|
level,
|
||||||
|
|
|
||||||
|
|
@ -1143,6 +1143,8 @@ spotUi: {
|
||||||
verifyNudgeTitle: 'Be the first to confirm this spot',
|
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.",
|
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",
|
verifyNudgeStatus: "I've skated here",
|
||||||
|
communityChip: 'Community',
|
||||||
|
seenChip: 'Seen',
|
||||||
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',
|
||||||
|
|
|
||||||
|
|
@ -1143,6 +1143,8 @@ spotUi: {
|
||||||
verifyNudgeTitle: 'Sé el primero en confirmar este spot',
|
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.',
|
verifyNudgeBody: 'Una foto o un «he patinado aquí» es como se verifica un spot: ayuda al siguiente skater.',
|
||||||
verifyNudgeStatus: 'He patinado aquí',
|
verifyNudgeStatus: 'He patinado aquí',
|
||||||
|
communityChip: 'Comunidad',
|
||||||
|
seenChip: 'Visto',
|
||||||
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',
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
|
import { useBatchSpotCommunityScores } from '@/hooks/useBatchSpotCommunityScores';
|
||||||
import { useRelayStatus } from '@/hooks/useRelayStatus';
|
import { useRelayStatus } from '@/hooks/useRelayStatus';
|
||||||
import { useAutoHiddenSpots } from '@/hooks/useAutoHiddenSpots';
|
import { useAutoHiddenSpots } from '@/hooks/useAutoHiddenSpots';
|
||||||
import { useOnlineStatus } from '@/hooks/useOfflineCache';
|
import { useOnlineStatus } from '@/hooks/useOfflineCache';
|
||||||
|
|
@ -46,7 +47,7 @@ import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { SPOT_CATEGORIES, SHELTER_INFO, SKATE_RELAY } from '@/lib/types';
|
import { SPOT_CATEGORIES, SHELTER_INFO, SKATE_RELAY, SKATE_SPOT_KIND } from '@/lib/types';
|
||||||
import type { SkateSpot } from '@/lib/types';
|
import type { SkateSpot } from '@/lib/types';
|
||||||
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
import { spotTypeLabel } from '@/lib/spotTypeLabel';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
|
|
@ -1018,6 +1019,16 @@ export default function MapPage() {
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, getTrustScore, trustScores, sortBy, userLocation, getCommentCount, getZapTotal]);
|
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, getTrustScore, trustScores, sortBy, userLocation, getCommentCount, getZapTotal]);
|
||||||
|
|
||||||
|
// Community confidence for the cards (batched, one query). Shops use a
|
||||||
|
// different kind, so only spots are scored.
|
||||||
|
const communitySpots = useMemo(
|
||||||
|
() => filteredSpots
|
||||||
|
.filter((s) => !s.isShop)
|
||||||
|
.map((s) => ({ id: s.id, pubkey: s.pubkey, identifier: s.identifier })),
|
||||||
|
[filteredSpots],
|
||||||
|
);
|
||||||
|
const { data: communityScores } = useBatchSpotCommunityScores(communitySpots);
|
||||||
|
|
||||||
// Spots shown as MAP MARKERS. Hide spots the community has confirmed as
|
// Spots shown as MAP MARKERS. Hide spots the community has confirmed as
|
||||||
// "gone" (demolished) so they don't clutter the map — they still appear
|
// "gone" (demolished) so they don't clutter the map — they still appear
|
||||||
// (dimmed, with a badge) in the card list below so the record isn't lost.
|
// (dimmed, with a badge) in the card list below so the record isn't lost.
|
||||||
|
|
@ -2064,6 +2075,7 @@ export default function MapPage() {
|
||||||
key={spot.id}
|
key={spot.id}
|
||||||
spot={spot}
|
spot={spot}
|
||||||
status={spot.isShop ? null : getSpotStatus(spot.pubkey, spot.identifier)}
|
status={spot.isShop ? null : getSpotStatus(spot.pubkey, spot.identifier)}
|
||||||
|
community={communityScores?.get(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue