feat(trust): attribute the badge and add a /trust explainer
Legibility pass: a trust verdict has to be explainable and sourced. - TrustBadge tooltips now attribute the claim (from your network / the community provider / the provider you chose) and link to /trust. - New /trust page: the three states, why absence is not distrust, who computes it, presence-is-not-endorsement, and point-of-view. Public, linked from the badge; added to the smoke-test route list. - New trustUi.* keys and four walletUi.trust attribution keys (en/es).
This commit is contained in:
parent
2d1144bb7e
commit
d1fae2688c
6 changed files with 197 additions and 0 deletions
|
|
@ -143,6 +143,7 @@ const ROUTES = [
|
||||||
'/stash',
|
'/stash',
|
||||||
'/collections',
|
'/collections',
|
||||||
'/stats',
|
'/stats',
|
||||||
|
'/trust',
|
||||||
'/mints',
|
'/mints',
|
||||||
'/app-shop',
|
'/app-shop',
|
||||||
'/diy',
|
'/diy',
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ const StashPage = lazyWithRetry(() => import("./pages/StashPage"));
|
||||||
const NIP19Page = lazyWithRetry(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
const NIP19Page = lazyWithRetry(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||||
const LicensePage = lazyWithRetry(() => import("./pages/LicensePage"));
|
const LicensePage = lazyWithRetry(() => import("./pages/LicensePage"));
|
||||||
const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage"));
|
const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage"));
|
||||||
|
const TrustPage = lazyWithRetry(() => import("./pages/TrustPage"));
|
||||||
const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage"));
|
const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage"));
|
||||||
const DonatePage = lazyWithRetry(() => import("./pages/DonatePage"));
|
const DonatePage = lazyWithRetry(() => import("./pages/DonatePage"));
|
||||||
const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
|
const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
|
||||||
|
|
@ -140,6 +141,7 @@ export function AppRouter() {
|
||||||
<Route path="/trades" element={<TradesPage />} />
|
<Route path="/trades" element={<TradesPage />} />
|
||||||
<Route path="/stats" element={<CommunityStatsPage />} />
|
<Route path="/stats" element={<CommunityStatsPage />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
|
<Route path="/trust" element={<TrustPage />} />
|
||||||
<Route path="/faq" element={<FAQPage />} />
|
<Route path="/faq" element={<FAQPage />} />
|
||||||
<Route path="/food" element={<FoodPage />} />
|
<Route path="/food" element={<FoodPage />} />
|
||||||
<Route path="/wallet" element={<WalletPage />} />
|
<Route path="/wallet" element={<WalletPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { CheckCircle2, HelpCircle, Flag } from 'lucide-react';
|
import { CheckCircle2, HelpCircle, Flag } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useTrustBucket } from '@/hooks/useTrustBucket';
|
import { useTrustBucket } from '@/hooks/useTrustBucket';
|
||||||
import { bucketDescKey, bucketLabelKey, type TrustBucket } from '@/lib/trustLadder';
|
import { bucketDescKey, bucketLabelKey, type TrustBucket } from '@/lib/trustLadder';
|
||||||
|
import { HOUSE_TRUST_PROVIDER } from '@/lib/nip85';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
|
@ -52,6 +54,13 @@ export function TrustBadge({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { bucket, assertion, directFollow, isLoading } = useTrustBucket(pubkey);
|
const { bucket, assertion, directFollow, isLoading } = useTrustBucket(pubkey);
|
||||||
|
|
||||||
|
// Always attribute the claim: a verdict with no source is not trustworthy.
|
||||||
|
const sourceKey = assertion
|
||||||
|
? assertion.provider === HOUSE_TRUST_PROVIDER.pubkey
|
||||||
|
? 'walletUi.trust.attributedProviderHouse'
|
||||||
|
: 'walletUi.trust.attributedProviderCustom'
|
||||||
|
: 'walletUi.trust.attributedNetwork';
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
// A direct follow (or yourself) is already known to the viewer — a "Verified"
|
// A direct follow (or yourself) is already known to the viewer — a "Verified"
|
||||||
// mark there is redundant. Flagged is a safety signal and is never suppressed.
|
// mark there is redundant. Flagged is a safety signal and is never suppressed.
|
||||||
|
|
@ -88,11 +97,17 @@ export function TrustBadge({
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-bold">{t(bucketLabelKey(bucket))}</p>
|
<p className="font-bold">{t(bucketLabelKey(bucket))}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t(bucketDescKey(bucket))}</p>
|
<p className="text-xs text-muted-foreground">{t(bucketDescKey(bucket))}</p>
|
||||||
|
{bucket !== 'unknown' && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t(sourceKey)}</p>
|
||||||
|
)}
|
||||||
{bucket === 'flagged' && assertion?.reporters !== undefined && assertion.reporters > 0 && (
|
{bucket === 'flagged' && assertion?.reporters !== undefined && assertion.reporters > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('walletUi.trust.reportedByTrusted', { count: assertion.reporters })}
|
{t('walletUi.trust.reportedByTrusted', { count: assertion.reporters })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<Link to="/trust" className="inline-block text-xs text-[#ccff00] underline">
|
||||||
|
{t('walletUi.trust.howItWorks')}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -6143,6 +6143,10 @@ walletUi: {
|
||||||
top10Hint: 'Reach the top 10 supporters',
|
top10Hint: 'Reach the top 10 supporters',
|
||||||
},
|
},
|
||||||
trust: {
|
trust: {
|
||||||
|
attributedNetwork: 'From people in your network',
|
||||||
|
attributedProviderHouse: 'From the community trust provider',
|
||||||
|
attributedProviderCustom: 'From the provider you chose',
|
||||||
|
howItWorks: 'How trust works',
|
||||||
flaggedNoticeTitle: 'Reported by trusted skaters',
|
flaggedNoticeTitle: 'Reported by trusted skaters',
|
||||||
flaggedNoticeDesc: 'Take care. Trusted people in the network have reported this account.',
|
flaggedNoticeDesc: 'Take care. Trusted people in the network have reported this account.',
|
||||||
flaggedReporterFollow: '{{count}} you follow',
|
flaggedReporterFollow: '{{count}} you follow',
|
||||||
|
|
@ -7382,6 +7386,28 @@ walletUi: {
|
||||||
badge: 'Passes',
|
badge: 'Passes',
|
||||||
sellerHint: 'This seller’s venue issues access passes',
|
sellerHint: 'This seller’s venue issues access passes',
|
||||||
},
|
},
|
||||||
|
trustUi: {
|
||||||
|
seoTitle: 'How trust works',
|
||||||
|
seoDescription: 'How SkateSpots shows trust: three states, where scores come from, and why absence is not distrust.',
|
||||||
|
title: 'How trust works',
|
||||||
|
intro: 'Trust here is a signal, not a verdict. It comes from who you know, or from a provider you choose. We do not rank people globally.',
|
||||||
|
statesTitle: 'Three states',
|
||||||
|
verifiedTitle: 'Verified',
|
||||||
|
verifiedDesc: 'Someone in your network vouches for this account: a friend-of-a-friend, or your chosen provider.',
|
||||||
|
unknownTitle: 'Unknown',
|
||||||
|
unknownDesc: 'Nobody has vouched yet. This is where every new account starts.',
|
||||||
|
flaggedTitle: 'Flagged',
|
||||||
|
flaggedDesc: 'Trusted people reported this account. It is a warning to look closer, not proof of anything.',
|
||||||
|
absenceTitle: 'No badge is not distrust',
|
||||||
|
absenceDesc: 'Most accounts show nothing at all. That means no one in your network has vouched for them yet, not that something is wrong. Never read a missing mark as a negative.',
|
||||||
|
sourceTitle: 'Who computes it',
|
||||||
|
sourceDesc: 'By default we use your own network. You can also choose a trust provider that publishes signed scores over Nostr (NIP-85), or turn the display off. Flagged always shows, because it is a safety signal.',
|
||||||
|
endorsementTitle: 'Presence is not endorsement',
|
||||||
|
endorsementDesc: 'A verified mark means someone vouched, not that the person is good. A flag means trusted people reported them, not that they are guilty. Check the details before you decide.',
|
||||||
|
povTitle: 'It depends on the point of view',
|
||||||
|
povDesc: 'A score is relative to who is looking. A rank from one provider or one person is not comparable to another, and we never present one as an objective fact.',
|
||||||
|
settingsCta: 'Manage in Settings',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { ...en, ...batch3En, ...batch4En, ...legacyEn };
|
export default { ...en, ...batch3En, ...batch4En, ...legacyEn };
|
||||||
|
|
|
||||||
|
|
@ -6143,6 +6143,10 @@ walletUi: {
|
||||||
top10Hint: 'Entra en el top 10 de patrocinadores',
|
top10Hint: 'Entra en el top 10 de patrocinadores',
|
||||||
},
|
},
|
||||||
trust: {
|
trust: {
|
||||||
|
attributedNetwork: 'De personas de tu red',
|
||||||
|
attributedProviderHouse: 'Del proveedor de confianza de la comunidad',
|
||||||
|
attributedProviderCustom: 'Del proveedor que elegiste',
|
||||||
|
howItWorks: 'Cómo funciona la confianza',
|
||||||
flaggedNoticeTitle: 'Reportada por patinadores de confianza',
|
flaggedNoticeTitle: 'Reportada por patinadores de confianza',
|
||||||
flaggedNoticeDesc: 'Ten cuidado. Personas de confianza de la red han reportado esta cuenta.',
|
flaggedNoticeDesc: 'Ten cuidado. Personas de confianza de la red han reportado esta cuenta.',
|
||||||
flaggedReporterFollow: '{{count}} que sigues',
|
flaggedReporterFollow: '{{count}} que sigues',
|
||||||
|
|
@ -7382,6 +7386,28 @@ walletUi: {
|
||||||
badge: 'Pases',
|
badge: 'Pases',
|
||||||
sellerHint: 'El local de este vendedor emite pases de acceso',
|
sellerHint: 'El local de este vendedor emite pases de acceso',
|
||||||
},
|
},
|
||||||
|
trustUi: {
|
||||||
|
seoTitle: 'Cómo funciona la confianza',
|
||||||
|
seoDescription: 'Cómo muestra SkateSpots la confianza: tres estados, de dónde vienen las puntuaciones y por qué la ausencia no es desconfianza.',
|
||||||
|
title: 'Cómo funciona la confianza',
|
||||||
|
intro: 'Aquí la confianza es una señal, no un veredicto. Viene de a quién conoces o de un proveedor que elijas. No clasificamos a las personas a nivel global.',
|
||||||
|
statesTitle: 'Tres estados',
|
||||||
|
verifiedTitle: 'Verificado',
|
||||||
|
verifiedDesc: 'Alguien de tu red respalda esta cuenta: un amigo de un amigo, o el proveedor que elegiste.',
|
||||||
|
unknownTitle: 'Desconocido',
|
||||||
|
unknownDesc: 'Nadie la ha respaldado todavía. Aquí empieza toda cuenta nueva.',
|
||||||
|
flaggedTitle: 'Marcado',
|
||||||
|
flaggedDesc: 'Personas de confianza han reportado esta cuenta. Es una advertencia para mirar más de cerca, no una prueba de nada.',
|
||||||
|
absenceTitle: 'Sin distintivo no es desconfianza',
|
||||||
|
absenceDesc: 'La mayoría de las cuentas no muestran nada. Significa que nadie de tu red las ha respaldado todavía, no que haya algo malo. Nunca interpretes la ausencia como algo negativo.',
|
||||||
|
sourceTitle: 'Quién lo calcula',
|
||||||
|
sourceDesc: 'Por defecto usamos tu propia red. También puedes elegir un proveedor de confianza que publique puntuaciones firmadas en Nostr (NIP-85), o desactivar la visualización. Marcado siempre aparece, porque es una señal de seguridad.',
|
||||||
|
endorsementTitle: 'Aparecer no es un aval',
|
||||||
|
endorsementDesc: 'Un distintivo verificado significa que alguien respaldó, no que la persona sea buena. Un marcado significa que personas de confianza la reportaron, no que sea culpable. Revisa los detalles antes de decidir.',
|
||||||
|
povTitle: 'Depende del punto de vista',
|
||||||
|
povDesc: 'Una puntuación es relativa a quién mira. Una clasificación de un proveedor o una persona no es comparable con otra, y nunca la presentamos como un hecho objetivo.',
|
||||||
|
settingsCta: 'Gestionar en Ajustes',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { ...es, ...batch3Es, ...batch4Es, ...legacyEs };
|
export default { ...es, ...batch3Es, ...batch4Es, ...legacyEs };
|
||||||
|
|
|
||||||
127
src/pages/TrustPage.tsx
Normal file
127
src/pages/TrustPage.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Shield, CheckCircle2, HelpCircle, Flag, Users, Scale, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon: Icon,
|
||||||
|
color,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||||
|
color: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-white/8 bg-white/3 p-5">
|
||||||
|
<div className="mb-3 flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl"
|
||||||
|
style={{ background: `${color}1a`, border: `1px solid ${color}40` }}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" style={{ color }} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-black uppercase tracking-wide text-white">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 text-sm leading-relaxed text-gray-400">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Point({
|
||||||
|
icon: Icon,
|
||||||
|
color,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||||
|
color: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0" style={{ color }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white">{title}</p>
|
||||||
|
<p>{desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public explainer for the trust buckets. The badge has to be legible and
|
||||||
|
* attributable, and absence has to be explained (it is the common case), or the
|
||||||
|
* whole feature reads as an opaque verdict.
|
||||||
|
*/
|
||||||
|
export default function TrustPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useSeoMeta({
|
||||||
|
title: t('trustUi.seoTitle'),
|
||||||
|
description: t('trustUi.seoDescription'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-3xl space-y-5 py-10">
|
||||||
|
<header className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield className="h-8 w-8 text-[#ccff00]" />
|
||||||
|
<h1 className="text-3xl font-black uppercase tracking-wide text-white">
|
||||||
|
{t('trustUi.title')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400">{t('trustUi.intro')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Section icon={Scale} color="#13d2e5" title={t('trustUi.statesTitle')}>
|
||||||
|
<Point
|
||||||
|
icon={CheckCircle2}
|
||||||
|
color="#22c55e"
|
||||||
|
title={t('trustUi.verifiedTitle')}
|
||||||
|
desc={t('trustUi.verifiedDesc')}
|
||||||
|
/>
|
||||||
|
<Point
|
||||||
|
icon={HelpCircle}
|
||||||
|
color="#8c929e"
|
||||||
|
title={t('trustUi.unknownTitle')}
|
||||||
|
desc={t('trustUi.unknownDesc')}
|
||||||
|
/>
|
||||||
|
<Point
|
||||||
|
icon={Flag}
|
||||||
|
color="#ef4444"
|
||||||
|
title={t('trustUi.flaggedTitle')}
|
||||||
|
desc={t('trustUi.flaggedDesc')}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section icon={AlertTriangle} color="#f59e0b" title={t('trustUi.absenceTitle')}>
|
||||||
|
<p>{t('trustUi.absenceDesc')}</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section icon={Users} color="#6366f1" title={t('trustUi.sourceTitle')}>
|
||||||
|
<p>{t('trustUi.sourceDesc')}</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section icon={Flag} color="#ef4444" title={t('trustUi.endorsementTitle')}>
|
||||||
|
<p>{t('trustUi.endorsementDesc')}</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section icon={Scale} color="#13d2e5" title={t('trustUi.povTitle')}>
|
||||||
|
<p>{t('trustUi.povDesc')}</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-2">
|
||||||
|
<Link to="/settings">
|
||||||
|
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
|
||||||
|
{t('trustUi.settingsCta')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue