Release 202506091901

This commit is contained in:
pluja
2025-06-09 19:01:08 +00:00
parent b8b2dee4a4
commit 73b4826bf3
5 changed files with 232 additions and 273 deletions

View File

@@ -19,6 +19,7 @@ import {
type ServiceVisibility, type ServiceVisibility,
ServiceSuggestionType, ServiceSuggestionType,
KycLevelClarification, KycLevelClarification,
VerificationStepStatus,
} from '@prisma/client' } from '@prisma/client'
import { omit, uniqBy } from 'lodash-es' import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
@@ -610,6 +611,10 @@ const generateFakeService = (users: User[]) => {
const name = faker.helpers.arrayElement(serviceNames) const name = faker.helpers.arrayElement(serviceNames)
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
probability: 0.8,
})
return { return {
name, name,
slug, slug,
@@ -643,6 +648,19 @@ const generateFakeService = (users: User[]) => {
}, },
verificationProofMd: verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null, status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
verificationSteps:
(status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED') && faker.datatype.boolean(0.75)
? {
create: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
title: faker.lorem.sentence(),
description: faker.lorem.paragraph(),
status: faker.helpers.arrayElement(Object.values(VerificationStepStatus)),
evidenceMd: faker.lorem.paragraph(),
createdAt: faker.date.recent(),
updatedAt: faker.date.recent(),
})),
}
: undefined,
referral: faker.helpers.arrayElement([ referral: faker.helpers.arrayElement([
`?ref=${faker.string.alphanumeric(6)}`, `?ref=${faker.string.alphanumeric(6)}`,
`/ref/${faker.string.alphanumeric(6)}`, `/ref/${faker.string.alphanumeric(6)}`,
@@ -661,8 +679,10 @@ const generateFakeService = (users: User[]) => {
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`, imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(), listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null, verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples), tosReview,
tosReviewAt: faker.date.past(), tosReviewAt: tosReview
? faker.date.recent()
: faker.helpers.maybe(() => faker.date.recent(), { probability: 0.5 }),
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }), userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(), userSentimentAt: faker.date.recent(),
internalNotes: faker.helpers.maybe( internalNotes: faker.helpers.maybe(

View File

@@ -135,6 +135,17 @@ BEGIN
SET "isRecentlyListed" = FALSE SET "isRecentlyListed" = FALSE
WHERE id = service_id; WHERE id = service_id;
END IF; END IF;
-- Apply penalty if ToS cannot be analyzed
IF EXISTS (
SELECT 1
FROM "Service"
WHERE id = service_id
AND "tosReviewAt" IS NOT NULL
AND "tosReview" IS NULL
) THEN
trust_score := trust_score - 3;
END IF;
-- Calculate final trust score (base 100) -- Calculate final trust score (base 100)
trust_score := trust_score + verification_factor + attributes_score; trust_score := trust_score + verification_factor + attributes_score;

View File

@@ -41,6 +41,8 @@ export function makeNonDbAttributes(
isRecentlyListed: true isRecentlyListed: true
listedAt: true listedAt: true
createdAt: true createdAt: true
tosReviewAt: true
tosReview: true
} }
}>, }>,
{ filter = false }: { filter?: boolean } = {} { filter = false }: { filter?: boolean } = {}
@@ -156,6 +158,17 @@ export function makeNonDbAttributes(
trustPoints: -5, trustPoints: -5,
links: [], links: [],
}, },
{
title: "Can't analyse ToS",
show: service.tosReviewAt !== null && service.tosReview === null,
type: 'WARNING',
category: 'TRUST',
description:
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
privacyPoints: 0,
trustPoints: -3,
links: [],
},
] ]
if (filter) return nonDbAttributes.filter(({ show }) => show) if (filter) return nonDbAttributes.filter(({ show }) => show)

View File

@@ -57,7 +57,6 @@ Some users are **verified**, this means that the moderators have confirmed that
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us. Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us.
## Listings ## Listings
### Suggesting a new listing ### Suggesting a new listing
@@ -141,7 +140,7 @@ You can view all available attributes on the [Attributes page](/attributes).
Attributes are classified into two main types: Attributes are classified into two main types:
1. **Privacy Attributes** Related to data protection and anonymity. 1. **Privacy Attributes** Related to data protection and anonymity.
2. **Trust Attributes** Related to reliability and security. 1. **Trust Attributes** Related to reliability and security.
These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**. These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**.
@@ -154,31 +153,32 @@ Scores are calculated **automatically** using clear, fixed rules. We do not chan
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach: The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
1. **Base Score:** Every service starts with a neutral score of 50 points. 1. **Base Score:** Every service starts with a neutral score of 50 points.
2. **KYC Level:** Adjusts the score based on the level of identity verification required: 1. **KYC Level:** Adjusts the score based on the level of identity verification required:
- KYC Level 0 (No KYC): **+25 points** - KYC Level 0 (No KYC): **+25 points**
- KYC Level 1 (Minimal KYC): **+10 points** - KYC Level 1 (Minimal KYC): **+10 points**
- KYC Level 2 (Moderate KYC): **-5 points** - KYC Level 2 (Moderate KYC): **-5 points**
- KYC Level 3 (More KYC): **-15 points** - KYC Level 3 (More KYC): **-15 points**
- KYC Level 4 (Full mandatory KYC): **-25 points** - KYC Level 4 (Full mandatory KYC): **-25 points**
3. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL. 1. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
4. **I2P URL:** **+5 points** if the service offers at least one I2P URL. 1. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
5. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method. 1. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes). 1. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
7. **Final Score Range:** The final score is always kept between 0 and 100. 1. **Final Score Range:** The final score is always kept between 0 and 100.
##### Trust Score ##### Trust Score
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria. The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
1. **Base Score:** Every service begins with a neutral score of 50 points. 1. **Base Score:** Every service begins with a neutral score of 50 points.
2. **Verification Status Adjustment:** 1. **Verification Status:**
- **Verification Success:** +10 points - **Verification Success:** +10 points
- **Approved:** +5 points - **Approved:** +5 points
- **Community Contributed:** 0 points - **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 points - **Verification Failed (SCAM):** -50 points
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes). 1. **Recently Listed:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
4. **Recently Listed Penalty & Flag:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed. 1. **Can't Analyze ToS:** If a service's Terms of Service cannot be analyzed by our AI (usually due to captchas, client-side rendering, DDoS protections, or non-text format), a penalty of -3 points is applied to the trust score.
5. **Final Score Range:** The final score is always kept between 0 and 100. 1. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
1. **Final Score Range:** The final score is always kept between 0 and 100.
##### Overall Score ##### Overall Score

View File

@@ -9,7 +9,6 @@ import { head, orderBy, pick, shuffle, sortBy, tail } from 'lodash-es'
import AdminOnly from '../../components/AdminOnly.astro' import AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
import BadgeStandard from '../../components/BadgeStandard.astro' import BadgeStandard from '../../components/BadgeStandard.astro'
import Button from '../../components/Button.astro'
import CommentSection from '../../components/CommentSection.astro' import CommentSection from '../../components/CommentSection.astro'
import CommentSummary from '../../components/CommentSummary.astro' import CommentSummary from '../../components/CommentSummary.astro'
import DropdownButton from '../../components/DropdownButton.astro' import DropdownButton from '../../components/DropdownButton.astro'
@@ -411,6 +410,8 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
const activeAlertOrWarningEvents = service.events.filter( const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now) (event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
) )
const activeEventToShow =
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
--- ---
<BaseLayout <BaseLayout
@@ -512,27 +513,23 @@ const activeAlertOrWarningEvents = service.events.filter(
]} ]}
> >
{ {
activeAlertOrWarningEvents.length > 0 && ( !!activeEventToShow && (
<a <a
href="#events" href="#events"
class={cn( class={cn(
'mb-4 block rounded-md px-3 py-2 text-sm font-medium', 'group mb-4 block rounded-md px-3 py-2 text-sm transition-colors duration-200',
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT) activeEventToShow.type === EventType.ALERT
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60' ? 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60' : 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60'
)} )}
> >
<Icon <Icon
name={ name={activeEventToShow.type === EventType.ALERT ? 'ri:alert-fill' : 'ri:alarm-warning-fill'}
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'ri:alert-fill'
: 'ri:alarm-warning-fill'
}
class="me-1.5 inline-block size-4 align-[-0.15em]" class="me-1.5 inline-block size-4 align-[-0.15em]"
/> />
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT) <span class="font-bold">{activeEventToShow.title}</span> — {activeEventToShow.content}
? 'There is an active alert for this service. Click to see details.' {activeAlertOrWarningEvents.length >= 2 && <>+{activeAlertOrWarningEvents.length - 1} more events.</>}
: 'There is an active warning for this service. Click to see details.'} <span class="underline">Go to events</span>
</a> </a>
) )
} }
@@ -1081,105 +1078,119 @@ const activeAlertOrWarningEvents = service.events.filter(
</h2> </h2>
{ {
service.verificationStatus === 'VERIFICATION_SUCCESS' || service.verificationStatus === 'APPROVED' ? ( service.verificationStatus === 'VERIFICATION_SUCCESS' || service.verificationStatus === 'APPROVED' ? (
service.tosReview ? ( service.tosReviewAt !== null ? (
<> service.tosReview ? (
<Schema <>
item={ <Schema
{ item={
'@context': 'https://schema.org', {
'@type': 'Review', '@context': 'https://schema.org',
itemReviewed: { '@id': itemReviewedId }, '@type': 'Review',
reviewAspect: 'Terms of Service', itemReviewed: { '@id': itemReviewedId },
name: 'Terms of Service Review', reviewAspect: 'Terms of Service',
reviewBody: service.tosReview.summary, name: 'Terms of Service Review',
datePublished: service.tosReviewAt?.toISOString(), reviewBody: service.tosReview.summary,
author: KYCNOTME_SCHEMA_MINI, datePublished: service.tosReviewAt.toISOString(),
positiveNotes: service.tosReview.highlights author: KYCNOTME_SCHEMA_MINI,
.filter((h) => h.rating === 'positive') positiveNotes: service.tosReview.highlights
.map( .filter((h) => h.rating === 'positive')
(h, i) => .map(
({ (h, i) =>
'@type': 'ListItem', ({
position: i + 1, '@type': 'ListItem',
name: h.title, position: i + 1,
description: h.content, name: h.title,
}) satisfies ListItem description: h.content,
), }) satisfies ListItem
negativeNotes: service.tosReview.highlights ),
.filter((h) => h.rating === 'negative') negativeNotes: service.tosReview.highlights
.map( .filter((h) => h.rating === 'negative')
(h, i) => .map(
({ (h, i) =>
'@type': 'ListItem', ({
position: i + 1, '@type': 'ListItem',
name: h.title, position: i + 1,
description: h.content, name: h.title,
}) satisfies ListItem description: h.content,
), }) satisfies ListItem
} satisfies WithContext<Review> ),
} } satisfies WithContext<Review>
/> }
/>
<div class="flex flex-col sm:flex-row sm:gap-6"> <div class="flex flex-col sm:flex-row sm:gap-6">
<div class="prose prose-invert prose-sm bg-night-800 max-w-none flex-1 rounded-lg px-4 py-2 text-pretty sm:px-6 sm:py-4"> <div class="prose prose-invert prose-sm bg-night-800 max-w-none flex-1 rounded-lg px-4 py-2 text-pretty sm:px-6 sm:py-4">
{service.tosReview.summary ? ( {service.tosReview.summary ? (
<Markdown content={service.tosReview.summary} /> <Markdown content={service.tosReview.summary} />
) : ( ) : (
<p>No summary available</p> <p>No summary available</p>
)}
</div>
</div>
<div class="mt-6 grid gap-4 sm:grid-cols-2 sm:gap-6">
{sortBy(
service.tosReview.highlights.map((highlight) => ({
...highlight,
ratingInfo: getTosHighlightRatingInfo(highlight.rating),
})),
'ratingInfo.order'
).map((highlight) => (
<div
class={cn(
'border-l-2 border-l-transparent pl-2 text-pretty',
highlight.ratingInfo.classNames.borderColor
)}
>
<h3 class="font-title font-bold">
<Icon
name={highlight.ratingInfo.icon}
class={cn(
highlight.ratingInfo.classNames.icon,
'me-0.5 inline-block size-4 align-[-0.1em]'
)}
/>
<span>{highlight.title}</span>
</h3>
{!!highlight.content && (
<div class="prose prose-invert prose-sm">
<Markdown content={highlight.content} />
</div>
)} )}
</div> </div>
))} </div>
</div>
<p class="text-day-400 mt-4 text-center text-xs"> <div class="mt-6 grid gap-4 sm:grid-cols-2 sm:gap-6">
{!!service.tosReviewAt && ( {sortBy(
<> service.tosReview.highlights.map((highlight) => ({
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision /> ...highlight,
</> ratingInfo: getTosHighlightRatingInfo(highlight.rating),
})),
'ratingInfo.order'
).map((highlight) => (
<div
class={cn(
'border-l-2 border-l-transparent pl-2 text-pretty',
highlight.ratingInfo.classNames.borderColor
)}
>
<h3 class="font-title font-bold">
<Icon
name={highlight.ratingInfo.icon}
class={cn(
highlight.ratingInfo.classNames.icon,
'me-0.5 inline-block size-4 align-[-0.1em]'
)}
/>
<span>{highlight.title}</span>
</h3>
{!!highlight.content && (
<div class="prose prose-invert prose-sm">
<Markdown content={highlight.content} />
</div>
)}
</div>
))}
</div>
<p class="text-day-400 mt-4 text-center text-xs">
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
{service.tosUrls.length > 0 && 'from'}
{service.tosUrls.map((url) => (
<a href={url} class="cursor-pointer hover:underline">
{urlDomain(url)}
</a>
))}
</p>
<p class="text-day-600 hover:text-day-400 mb-2 text-center text-xs duration-400">
ToS reviews are AI-generated and should be used as a reference only.
</p>
</>
) : (
<div class="text-day-400 my-12 text-center">
<p>Can't analyze ToS with AI</p>
<p class="text-day-500 mt-1 text-sm text-balance">
Maybe due to captchas, client side rendering, DDoS protections, or non-text format.
</p>
{service.tosUrls.length > 0 && (
<p class="mt-2 text-xs">
{service.tosUrls.map((url) => (
<a href={url} class="hover:underline">
{urlDomain(url)}
</a>
))}
</p>
)} )}
{!!service.tosReviewAt && !!service.tosUrls.length && 'from'} </div>
{service.tosUrls.map((url) => ( )
<a href={url} class="cursor-pointer hover:underline">
{urlDomain(url)}
</a>
))}
</p>
<p class="text-day-600 hover:text-day-400 mb-2 text-center text-xs duration-400">
ToS reviews are AI-generated and should be used as a reference only.
</p>
</>
) : ( ) : (
<div class="text-day-400 my-12 text-center"> <div class="text-day-400 my-12 text-center">
<p>Not reviewed yet</p> <p>Not reviewed yet</p>
@@ -1332,77 +1343,6 @@ const activeAlertOrWarningEvents = service.events.filter(
</div> </div>
) )
} }
{
service.verificationSteps.length > 0 && (
<div class="border-night-500 mt-2 border-t pt-3">
<h3 class="font-title mb-2 flex items-center gap-1 text-sm font-semibold">
<Icon name="ri:checkbox-multiple-line" class="size-4" />
Verification Steps
</h3>
<ul class="grid gap-2 text-xs">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li>
<details class="group/step">
<summary class="border-night-600 hover:bg-night-700/50 relative flex cursor-pointer items-center gap-2 rounded p-1.5 select-none">
<div
class={cn('h-1.5 w-1.5 rounded-full', {
'bg-gray-400 shadow-[0_0_3px_1px_rgba(156,163,175,0.7)]':
step.status === VerificationStepStatus.PENDING,
'bg-blue-400 shadow-[0_0_4px_1px_rgba(96,165,250,0.8)]':
step.status === VerificationStepStatus.IN_PROGRESS,
'bg-green-400 shadow-[0_0_4px_1px_rgba(34,197,94,0.8)]':
step.status === VerificationStepStatus.PASSED,
'bg-red-400 shadow-[0_0_4px_1px_rgba(248,113,113,0.8)]':
step.status === VerificationStepStatus.FAILED,
})}
/>
<div class="flex flex-1 items-center">
<strong class="mr-1 font-medium text-white">{step.title}</strong>
<span class="text-day-400 mr-1">•</span>
<span
class={cn('mr-2 font-bold', {
'text-gray-400': step.status === VerificationStepStatus.PENDING,
'text-blue-400': step.status === VerificationStepStatus.IN_PROGRESS,
'text-green-400': step.status === VerificationStepStatus.PASSED,
'text-red-400': step.status === VerificationStepStatus.FAILED,
})}
>
{statusInfo.text}
</span>
<Icon
name="ri:arrow-down-s-line"
class="text-day-400 size-3.5 transition-transform group-open/step:rotate-180"
/>
</div>
</summary>
<div class="border-night-600 mt-1 ml-3.5 border-l pb-1 pl-3">
<p class="text-day-400 text-2xs mb-1 font-medium">
Added
<TimeFormatted date={step.createdAt} hourPrecision />
</p>
{step.description && (
<div class="text-day-300 my-1 text-xs">{step.description}</div>
)}
{step.evidenceMd && (
<div class="bg-night-900 mt-2 rounded p-2">
<div class="text-day-400 text-2xs mb-1 font-medium">Evidence:</div>
<div class="prose prose-invert prose-xs prose-img:max-w-full text-[11px] leading-relaxed">
<Markdown content={step.evidenceMd} />
</div>
</div>
)}
</div>
</details>
</li>
)
})}
</ul>
</div>
)
}
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -1456,89 +1396,64 @@ const activeAlertOrWarningEvents = service.events.filter(
</form> </form>
) )
} }
{
service.verificationProofMd && (
<Button
as="label"
for="show-verification-proof"
icon="ri:file-text-line"
label="Verification audit"
color="white"
/>
)
}
</div> </div>
</div> </div>
{
service.verificationProofMd && (
<>
<input type="checkbox" id="show-verification-proof" class="peer sr-only" />
<div class="bg-night-800 mt-4 hidden rounded-md p-4 peer-checked:block" id="verification-proof">
<div class="flex items-center justify-between">
<h3 class="font-title text-lg font-semibold">Verification proof</h3>
<Button as="label" for="show-verification-proof" label="Close" icon="ri:close-line" size="sm" />
</div>
<div class="prose prose-invert prose-sm mt-4 max-w-none">
<Markdown content={service.verificationProofMd} />
</div>
{service.verificationSteps.length > 0 && (
<div class="mt-6">
<h4 class="font-title text-md mb-3 font-semibold">Verification Steps</h4>
<ul class="-mb-4">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li class="flex pb-4">
<div class="border-night-500 bg-night-700/50 flex-grow rounded-md border p-2.5 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center gap-1.5">
<BadgeSmall
text={statusInfo.text}
icon={statusInfo.icon}
color={statusInfo.color}
inlineIcon={statusInfo.icon === 'ri:loader-4-line'}
class={cn(
{ 'animate-spin': statusInfo.icon === 'ri:loader-4-line' },
'shrink-0'
)}
/>
<strong class="text-sm leading-tight font-semibold text-white">
{step.title}
</strong>
</div>
<p class="text-day-300 mt-1.5 text-xs">{step.description}</p>
</div>
<p class="text-2xs text-day-400 shrink-0 pt-0.5 whitespace-nowrap">
<TimeFormatted date={step.updatedAt} prefix={false} />
</p>
</div>
{step.evidenceMd && (
<details class="group/evidence mt-2 text-xs">
<summary class="text-day-400 hover:text-day-200 cursor-pointer py-0.5 text-[11px] font-medium">
Show Evidence
</summary>
<div class="prose prose-invert prose-xs bg-night-900 mt-1.5 rounded p-2 text-[11px] leading-relaxed group-open/evidence:block">
<Markdown content={step.evidenceMd} />
</div>
</details>
)}
</div>
</li>
)
})}
</ul>
</div>
)}
</div>
</>
)
}
</div> </div>
{
service.verificationProofMd && (
<>
<h3 class="font-title mt-4 text-lg font-semibold">Verification proof</h3>
<div class="prose prose-invert prose-sm max-w-none">
<Markdown content={service.verificationProofMd} />
</div>
</>
)
}
{
service.verificationSteps.length > 0 && (
<>
<h3 class="font-title text-md mt-6 mb-2 font-semibold">Verification Steps</h3>
<ul class="mb-8 space-y-2">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li class="border-night-500 bg-night-700/50 flex-grow rounded-md border p-2.5 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col">
<div class="text-sm leading-tight font-semibold text-white">
<BadgeSmall
text={statusInfo.text}
icon={statusInfo.icon}
color={statusInfo.color}
inlineIcon={statusInfo.icon === 'ri:loader-4-line'}
classNames={{
icon: cn(statusInfo.icon === 'ri:loader-4-line' && 'animate-spin'),
}}
class="me-1"
/>
{step.title}
</div>
<p class="text-day-300 mt-1.5 text-xs">{step.description}</p>
</div>
<p class="text-2xs text-day-400 shrink-0 pt-0.5 whitespace-nowrap">
<TimeFormatted date={step.updatedAt} prefix={false} />
</p>
</div>
{step.evidenceMd && (
<div class="prose prose-invert prose-xs mt-2 block max-w-none text-xs leading-relaxed">
<Markdown content={step.evidenceMd} />
</div>
)}
</li>
)
})}
</ul>
</>
)
}
<h2 class="font-title border-day-500 text-day-200 mt-2 mb-3 border-b text-lg font-bold" id="comments"> <h2 class="font-title border-day-500 text-day-200 mt-2 mb-3 border-b text-lg font-bold" id="comments">
Comments Comments