1506 lines
54 KiB
Plaintext
1506 lines
54 KiB
Plaintext
---
|
|
import { VerificationStepStatus } from '@prisma/client'
|
|
import { Icon } from 'astro-icon/components'
|
|
import { Markdown } from 'astro-remote'
|
|
import { Schema } from 'astro-seo-schema'
|
|
import { actions } from 'astro:actions'
|
|
import { head, orderBy, pick, shuffle, sortBy, tail } from 'lodash-es'
|
|
|
|
import AdminOnly from '../../components/AdminOnly.astro'
|
|
import BadgeSmall from '../../components/BadgeSmall.astro'
|
|
import BadgeStandard from '../../components/BadgeStandard.astro'
|
|
import Button from '../../components/Button.astro'
|
|
import CommentSection from '../../components/CommentSection.astro'
|
|
import CommentSummary from '../../components/CommentSummary.astro'
|
|
import DropdownButton from '../../components/DropdownButton.astro'
|
|
import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro'
|
|
import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro'
|
|
import FormatTimeInterval from '../../components/FormatTimeInterval.astro'
|
|
import MyPicture from '../../components/MyPicture.astro'
|
|
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage'
|
|
import ScoreGauge from '../../components/ScoreGauge.astro'
|
|
import ScoreSquare from '../../components/ScoreSquare.astro'
|
|
import ServiceLinkButton from '../../components/ServiceLinkButton.astro'
|
|
import TimeFormatted from '../../components/TimeFormatted.astro'
|
|
import Tooltip from '../../components/Tooltip.astro'
|
|
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
|
|
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
|
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
|
import { formatContactMethod } from '../../constants/contactMethods'
|
|
import { currencies, getCurrencyInfo } from '../../constants/currencies'
|
|
import { getEventTypeInfo } from '../../constants/eventTypes'
|
|
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
|
|
import { serviceVisibilitiesById } from '../../constants/serviceVisibility'
|
|
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
|
|
import { getUserSentimentInfo } from '../../constants/userSentiment'
|
|
import { getVerificationStatusInfo, verificationStatusesByValue } from '../../constants/verificationStatus'
|
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
import { someButNotAll, undefinedIfEmpty } from '../../lib/arrays'
|
|
import { makeNonDbAttributes, sortAttributes } from '../../lib/attributes'
|
|
import { cn } from '../../lib/cn'
|
|
import { getOrCreateNotificationPreferences } from '../../lib/notificationPreferences'
|
|
import { formatNumber, type FormatNumberOptions } from '../../lib/numbers'
|
|
import { pluralize } from '../../lib/pluralize'
|
|
import { prisma } from '../../lib/prisma'
|
|
import { makeLoginUrl } from '../../lib/redirectUrls'
|
|
import { KYCNOTME_SCHEMA_MINI } from '../../lib/schema'
|
|
import { transformCase } from '../../lib/strings'
|
|
import { urlDomain } from '../../lib/urls'
|
|
|
|
import type { BreadcrumArray } from '../../components/BaseHead.astro'
|
|
import type { Prisma } from '@prisma/client'
|
|
import type { ContactPoint, ListItem, Organization, Review, WebPage, WithContext } from 'schema-dts'
|
|
|
|
const { slug } = Astro.params
|
|
|
|
const user = Astro.locals.user
|
|
|
|
const result = Astro.getActionResult(actions.service.requestVerification)
|
|
|
|
const now = new Date()
|
|
|
|
const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany([
|
|
[
|
|
'Error fetching service',
|
|
async () =>
|
|
prisma.service.findUnique({
|
|
where: { slug },
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
name: true,
|
|
description: true,
|
|
kycLevel: true,
|
|
overallScore: true,
|
|
privacyScore: true,
|
|
trustScore: true,
|
|
verificationStatus: true,
|
|
serviceVisibility: true,
|
|
verificationSummary: true,
|
|
verificationProofMd: true,
|
|
tosUrls: true,
|
|
serviceUrls: true,
|
|
onionUrls: true,
|
|
i2pUrls: true,
|
|
referral: true,
|
|
imageUrl: true,
|
|
listedAt: true,
|
|
createdAt: true,
|
|
acceptedCurrencies: true,
|
|
tosReview: true,
|
|
tosReviewAt: true,
|
|
userSentiment: true,
|
|
userSentimentAt: true,
|
|
averageUserRating: true,
|
|
isRecentlyListed: true,
|
|
contactMethods: {
|
|
select: {
|
|
value: true,
|
|
label: true,
|
|
},
|
|
},
|
|
attributes: {
|
|
select: {
|
|
attribute: {
|
|
select: {
|
|
id: true,
|
|
type: true,
|
|
category: true,
|
|
title: true,
|
|
description: true,
|
|
privacyPoints: true,
|
|
trustPoints: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
categories: {
|
|
select: {
|
|
icon: true,
|
|
name: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
events: {
|
|
where: {
|
|
visible: true,
|
|
},
|
|
select: {
|
|
title: true,
|
|
content: true,
|
|
type: true,
|
|
startedAt: true,
|
|
endedAt: true,
|
|
source: true,
|
|
},
|
|
},
|
|
suggestions: {
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
},
|
|
where: {
|
|
userId: user?.id,
|
|
},
|
|
},
|
|
verificationRequests: {
|
|
select: {
|
|
id: true,
|
|
userId: true,
|
|
},
|
|
},
|
|
verificationSteps: {
|
|
select: {
|
|
title: true,
|
|
description: true,
|
|
status: true,
|
|
evidenceMd: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
},
|
|
_count: {
|
|
select: {
|
|
comments: {
|
|
where: {
|
|
ratingActive: true,
|
|
status: {
|
|
in: ['APPROVED', 'VERIFIED'],
|
|
},
|
|
parentId: null,
|
|
suspicious: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
null,
|
|
],
|
|
[
|
|
'Error while fetching notification preferences',
|
|
() =>
|
|
user
|
|
? getOrCreateNotificationPreferences(user.id, {
|
|
id: true,
|
|
onRootCommentCreatedForServices: {
|
|
where: { slug },
|
|
select: { id: true },
|
|
},
|
|
onEventCreatedForServices: {
|
|
where: { slug },
|
|
select: { id: true },
|
|
},
|
|
onVerificationChangeForServices: {
|
|
where: { slug },
|
|
select: { id: true },
|
|
},
|
|
})
|
|
: null,
|
|
null,
|
|
],
|
|
])
|
|
|
|
const makeWatchingDetails = (
|
|
dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{
|
|
select: {
|
|
onRootCommentCreatedForServices: {
|
|
select: { id: true }
|
|
}
|
|
onEventCreatedForServices: {
|
|
select: { id: true }
|
|
}
|
|
onVerificationChangeForServices: {
|
|
select: { id: true }
|
|
}
|
|
}
|
|
}> | null,
|
|
serviceId: number | undefined
|
|
) => {
|
|
if (!dbNotificationPreferences || !serviceId) return null
|
|
|
|
const comments = dbNotificationPreferences.onRootCommentCreatedForServices.some(
|
|
({ id }) => id === serviceId
|
|
)
|
|
const events = dbNotificationPreferences.onEventCreatedForServices.some(({ id }) => id === serviceId)
|
|
const verification = dbNotificationPreferences.onVerificationChangeForServices.some(
|
|
({ id }) => id === serviceId
|
|
)
|
|
const all = comments && events && verification
|
|
|
|
return {
|
|
comments,
|
|
events,
|
|
verification,
|
|
all: all ? true : someButNotAll(comments, events, verification) ? null : false,
|
|
} as const
|
|
}
|
|
|
|
const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.id)
|
|
|
|
if (!service) return Astro.rewrite('/404')
|
|
|
|
if (service.serviceVisibility !== 'PUBLIC' && service.serviceVisibility !== 'UNLISTED') {
|
|
return Astro.rewrite('/404')
|
|
}
|
|
|
|
const statusIcon = {
|
|
...verificationStatusesByValue,
|
|
APPROVED: undefined,
|
|
}[service.verificationStatus]
|
|
|
|
const shuffledLinks = {
|
|
clearnet: shuffle(service.serviceUrls),
|
|
onion: shuffle(service.onionUrls),
|
|
i2p: shuffle(service.i2pUrls),
|
|
}
|
|
const shownLinks = [head(shuffledLinks.clearnet), head(shuffledLinks.onion), head(shuffledLinks.i2p)].filter(
|
|
(url) => url !== undefined
|
|
)
|
|
const hiddenLinks = [
|
|
...tail(shuffledLinks.clearnet),
|
|
...tail(shuffledLinks.onion),
|
|
...tail(shuffledLinks.i2p),
|
|
]
|
|
|
|
const kycLevelInfo = getKycLevelInfo(`${service.kycLevel}`)
|
|
const userSentiment = service.userSentiment
|
|
? { ...service.userSentiment, info: getUserSentimentInfo(service.userSentiment.sentiment) }
|
|
: null
|
|
|
|
const attributes = sortAttributes([
|
|
...makeNonDbAttributes(service, { filter: true }),
|
|
...service.attributes.map(({ attribute }) => ({
|
|
...attribute,
|
|
links:
|
|
attribute.type === 'GOOD' || attribute.type === 'INFO'
|
|
? [
|
|
{
|
|
url: `/?attr-${attribute.id}=yes`,
|
|
label: 'Search with this',
|
|
icon: 'ri:search-line',
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
url: `/?attr-${attribute.id}=yes`,
|
|
label: 'With this',
|
|
icon: 'ri:search-line',
|
|
},
|
|
{
|
|
url: `/?attr-${attribute.id}=no`,
|
|
label: 'Without this',
|
|
icon: 'ri:search-line',
|
|
},
|
|
],
|
|
})),
|
|
]).map((attribute) => ({
|
|
...attribute,
|
|
categoryInfo: getAttributeCategoryInfo(attribute.category),
|
|
typeInfo: getAttributeTypeInfo(attribute.type),
|
|
}))
|
|
|
|
const statusInfo = getVerificationStatusInfo(service.verificationStatus)
|
|
|
|
const hasRequestedVerification =
|
|
!!user && service.verificationRequests.some((request) => request.userId === user.id)
|
|
|
|
const VerificationRequestElement = user ? 'button' : 'a'
|
|
|
|
const getVerificationStepStatusInfo = (status: VerificationStepStatus) => {
|
|
switch (status) {
|
|
case VerificationStepStatus.PENDING:
|
|
return {
|
|
text: 'Pending',
|
|
icon: 'ri:loader-2-line',
|
|
color: 'gray',
|
|
timelineIconClass: 'text-gray-400',
|
|
} as const
|
|
case VerificationStepStatus.IN_PROGRESS:
|
|
return {
|
|
text: 'In Progress',
|
|
icon: 'ri:loader-4-line',
|
|
color: 'blue',
|
|
timelineIconClass: 'text-blue-400 animate-spin',
|
|
} as const
|
|
case VerificationStepStatus.PASSED:
|
|
return {
|
|
text: 'Passed',
|
|
icon: 'ri:check-line',
|
|
color: 'green',
|
|
timelineIconClass: 'text-green-400',
|
|
} as const
|
|
case VerificationStepStatus.FAILED:
|
|
return {
|
|
text: 'Failed',
|
|
icon: 'ri:close-line',
|
|
color: 'red',
|
|
timelineIconClass: 'text-red-400',
|
|
} as const
|
|
default:
|
|
return {
|
|
text: 'Unknown',
|
|
icon: 'ri:question-mark',
|
|
color: 'gray',
|
|
timelineIconClass: 'text-gray-500',
|
|
} as const
|
|
}
|
|
}
|
|
const itemReviewedId = new URL(`/service/${service.slug}`, Astro.url).href
|
|
|
|
const ogImageTemplateData = {
|
|
template: 'service',
|
|
title: service.name,
|
|
description: service.description,
|
|
categories: service.categories.map((category) => pick(category, ['name', 'icon'])),
|
|
score: service.overallScore,
|
|
imageUrl: service.imageUrl,
|
|
} satisfies OgImageAllTemplatesWithProps
|
|
---
|
|
|
|
<BaseLayout
|
|
pageTitle={service.name}
|
|
description={service.description ||
|
|
'View details about this service including privacy score, features, currencies accepted, and user reviews.'}
|
|
ogImage={ogImageTemplateData}
|
|
widthClassName="max-w-screen-lg"
|
|
htmx
|
|
schemas={[
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Organization',
|
|
'@id': itemReviewedId,
|
|
name: service.name,
|
|
sameAs: undefinedIfEmpty([...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls]),
|
|
description: service.description || undefined,
|
|
image: service.imageUrl ?? undefined,
|
|
mainEntityOfPage: new URL(`/service/${service.slug}`, Astro.url).href,
|
|
contactPoint: undefinedIfEmpty(
|
|
service.contactMethods
|
|
.map((method) => ({
|
|
...method,
|
|
info: formatContactMethod(method.value),
|
|
}))
|
|
.map<ContactPoint | null>(({ info, label }) => {
|
|
switch (info.type) {
|
|
case 'telephone': {
|
|
return {
|
|
'@type': 'ContactPoint',
|
|
telephone: info.formattedValue,
|
|
name: label ?? info.label,
|
|
}
|
|
}
|
|
case 'email': {
|
|
return {
|
|
'@type': 'ContactPoint',
|
|
email: info.formattedValue,
|
|
name: label ?? info.label,
|
|
}
|
|
}
|
|
default: {
|
|
return null
|
|
}
|
|
}
|
|
})
|
|
.filter((value) => value !== null)
|
|
),
|
|
} satisfies WithContext<Organization>,
|
|
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebPage',
|
|
name: service.name,
|
|
url: Astro.url.href,
|
|
mainEntity: {
|
|
'@id': itemReviewedId,
|
|
},
|
|
datePublished: service.listedAt?.toISOString(),
|
|
dateCreated: service.createdAt.toISOString(),
|
|
image: makeOgImageUrl(ogImageTemplateData, Astro.url),
|
|
} satisfies WithContext<WebPage>,
|
|
]}
|
|
breadcrumbs={[
|
|
...service.categories.map(
|
|
(category) =>
|
|
[
|
|
{
|
|
name: 'Services',
|
|
url: '/',
|
|
},
|
|
{
|
|
name: category.name,
|
|
url: `/service/?categories=${category.slug}`,
|
|
},
|
|
{
|
|
name: service.name,
|
|
url: `/service/${service.slug}`,
|
|
},
|
|
] satisfies BreadcrumArray
|
|
),
|
|
...service.acceptedCurrencies.map((currency) => {
|
|
const currencyInfo = getCurrencyInfo(currency)
|
|
return [
|
|
{
|
|
name: 'Services',
|
|
url: '/',
|
|
},
|
|
{
|
|
name: currencyInfo.name,
|
|
url: `/service/?currencies=${currencyInfo.slug}`,
|
|
},
|
|
{
|
|
name: service.name,
|
|
url: `/service/${service.slug}`,
|
|
},
|
|
] satisfies BreadcrumArray
|
|
}),
|
|
]}
|
|
>
|
|
{
|
|
service.serviceVisibility === 'UNLISTED' && (
|
|
<div class={cn('mb-4 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400')}>
|
|
<Icon
|
|
name={serviceVisibilitiesById.UNLISTED.icon}
|
|
class={cn('me-1.5 inline-block size-4 align-[-0.15em]', serviceVisibilitiesById.UNLISTED.iconClass)}
|
|
/>
|
|
Unlisted service, only accessible via direct link and won't appear in searches.
|
|
</div>
|
|
)
|
|
}
|
|
<VerificationWarningBanner service={service} />
|
|
|
|
<div class="flex items-center gap-4">
|
|
{
|
|
!!service.imageUrl && (
|
|
<MyPicture
|
|
src={service.imageUrl}
|
|
alt={service.name || "Service's logo"}
|
|
class="size-12 shrink-0 rounded-sm object-contain"
|
|
width={48}
|
|
height={48}
|
|
/>
|
|
)
|
|
}
|
|
<h1 class="font-title flex-1 text-2xl leading-tight font-bold tracking-wider text-white">
|
|
{service.name}{
|
|
!!statusIcon && (
|
|
<Tooltip text={statusIcon.label} position="right">
|
|
<Icon
|
|
name={statusIcon.icon}
|
|
class={cn('ms-2 box-content inline-block size-6 shrink-0 pb-0.5', statusIcon.classNames.icon)}
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
</h1>
|
|
|
|
<div class="flex items-center justify-center gap-2 sm:gap-4">
|
|
{
|
|
user ? (
|
|
<DropdownButton
|
|
label="Watch"
|
|
icon="ri:eye-line"
|
|
classNames={{
|
|
label: 'hidden sm:inline-block',
|
|
arrow: 'hidden sm:inline-block',
|
|
button: 'px-2 sm:px-4',
|
|
}}
|
|
>
|
|
<DropdownButtonItemForm
|
|
action={actions.notification.preferences.watchService}
|
|
method="post"
|
|
data={{ serviceId: service.id, watchType: 'all', value: !watchingDetails?.all }}
|
|
label="All"
|
|
icon="ri:alert-line"
|
|
class="font-bold"
|
|
>
|
|
{!!watchingDetails && (
|
|
<slot slot="end">
|
|
<input
|
|
type="checkbox"
|
|
disabled
|
|
checked={watchingDetails.all}
|
|
data-mark-indeterminate={watchingDetails.all === null ? 'true' : 'false'}
|
|
class="pointer-events-none"
|
|
/>
|
|
</slot>
|
|
)}
|
|
</DropdownButtonItemForm>
|
|
|
|
<DropdownButtonItemForm
|
|
action={actions.notification.preferences.watchService}
|
|
method="post"
|
|
data={{ serviceId: service.id, watchType: 'comments', value: !watchingDetails?.comments }}
|
|
label="Comments"
|
|
icon="ri:chat-1-line"
|
|
>
|
|
{!!watchingDetails && (
|
|
<slot slot="end">
|
|
<input
|
|
type="checkbox"
|
|
disabled
|
|
checked={watchingDetails.comments}
|
|
class="pointer-events-none"
|
|
/>
|
|
</slot>
|
|
)}
|
|
</DropdownButtonItemForm>
|
|
|
|
<DropdownButtonItemForm
|
|
action={actions.notification.preferences.watchService}
|
|
method="post"
|
|
data={{ serviceId: service.id, watchType: 'events', value: !watchingDetails?.events }}
|
|
label="Events"
|
|
icon="ri:calendar-line"
|
|
>
|
|
{!!watchingDetails && (
|
|
<slot slot="end">
|
|
<input
|
|
type="checkbox"
|
|
disabled
|
|
checked={watchingDetails.events}
|
|
class="pointer-events-none"
|
|
/>
|
|
</slot>
|
|
)}
|
|
</DropdownButtonItemForm>
|
|
|
|
<DropdownButtonItemForm
|
|
action={actions.notification.preferences.watchService}
|
|
method="post"
|
|
data={{
|
|
serviceId: service.id,
|
|
watchType: 'verification',
|
|
value: !watchingDetails?.verification,
|
|
}}
|
|
label="Verification"
|
|
icon="ri:check-line"
|
|
>
|
|
{!!watchingDetails && (
|
|
<slot slot="end">
|
|
<input
|
|
type="checkbox"
|
|
disabled
|
|
checked={watchingDetails.verification}
|
|
class="pointer-events-none"
|
|
/>
|
|
</slot>
|
|
)}
|
|
</DropdownButtonItemForm>
|
|
</DropdownButton>
|
|
) : (
|
|
<a
|
|
href={makeLoginUrl(Astro.url)}
|
|
data-astro-reload
|
|
class="border-night-500 bg-night-800 hover:bg-night-900 focus-visible:bg-night-900 focus-visible:text-day-200 hover:text-day-200 inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm shadow-sm transition-colors duration-200"
|
|
aria-haspopup="true"
|
|
>
|
|
<Icon name="ri:eye-line" class="size-4" />
|
|
<span class="hidden sm:inline-block">Watch</span>
|
|
</a>
|
|
)
|
|
}
|
|
|
|
<DropdownButton
|
|
label="Contribute"
|
|
icon="ri:edit-line"
|
|
class="2xs:block hidden"
|
|
classNames={{
|
|
label: 'hidden md:inline-block',
|
|
arrow: 'hidden md:inline-block',
|
|
button: 'px-2 md:px-4',
|
|
}}
|
|
>
|
|
<DropdownButtonItemLink
|
|
href={`/service-suggestion/edit?serviceId=${service.id}`}
|
|
label="Edit"
|
|
icon="ri:edit-line"
|
|
/>
|
|
<DropdownButtonItemLink
|
|
href={`/service/${service.slug}#comments`}
|
|
label="Review"
|
|
icon="ri:star-line"
|
|
/>
|
|
<AdminOnly>
|
|
<DropdownButtonItemLink
|
|
href={`/admin/services/${service.slug}/edit`}
|
|
label="Edit (Admin)"
|
|
icon="ri:edit-box-line"
|
|
/>
|
|
</AdminOnly>
|
|
</DropdownButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class={cn('mt-2 flex flex-row items-start gap-2', {
|
|
'xs:flex-row xs:items-start flex-col-reverse items-center': service.categories.length >= 3,
|
|
})}
|
|
>
|
|
<ul
|
|
class={cn('flex flex-1 flex-wrap justify-start gap-2', {
|
|
'xs:justify-start justify-center': service.categories.length >= 3,
|
|
})}
|
|
aria-label="Categories"
|
|
>
|
|
{
|
|
service.categories.map((category) => (
|
|
<BadgeStandard as="li" icon={category.icon} text={category.name} />
|
|
))
|
|
}
|
|
</ul>
|
|
|
|
<div class="flex px-1 py-0.5">
|
|
{
|
|
currencies.map((currency) => {
|
|
const isAccepted = service.acceptedCurrencies.includes(currency.id)
|
|
|
|
return (
|
|
<Tooltip text={currency.name}>
|
|
<Icon
|
|
name={currency.icon}
|
|
class={cn('text-day-600 box-content size-4 p-1', {
|
|
'text-white': isAccepted,
|
|
})}
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
})
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="prose prose-sm prose-invert text-day-400 mt-2 text-pretty">
|
|
<Markdown content={service.description} />
|
|
</div>
|
|
|
|
{
|
|
shownLinks.length + hiddenLinks.length > 0 && (
|
|
<ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2">
|
|
{shownLinks.map((url) => (
|
|
<li>
|
|
<ServiceLinkButton
|
|
url={url}
|
|
referral={service.referral}
|
|
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
|
|
/>
|
|
</li>
|
|
))}
|
|
|
|
<input
|
|
type="checkbox"
|
|
class="peer sr-only checked:hidden"
|
|
id="show-more-links"
|
|
checked={hiddenLinks.length === 0}
|
|
/>
|
|
|
|
{hiddenLinks.length > 0 && (
|
|
<li class="peer-focus-visible:ring-offset-night-700 rounded-full peer-checked:hidden peer-focus-visible:ring-4 peer-focus-visible:ring-orange-500 peer-focus-visible:ring-offset-2">
|
|
<label
|
|
for="show-more-links"
|
|
class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 text-day-100 bg-day-800 hover:bg-day-900 inline-flex h-6 cursor-pointer items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap transition-colors duration-200"
|
|
>
|
|
+{hiddenLinks.length.toLocaleString()} more
|
|
</label>
|
|
</li>
|
|
)}
|
|
|
|
{hiddenLinks.map((url) => (
|
|
<li class="hidden peer-checked:block">
|
|
<ServiceLinkButton
|
|
url={url}
|
|
referral={service.referral}
|
|
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
{
|
|
service.contactMethods.length > 0 && (
|
|
<ul class="xs:justify-start mt-4 inline-flex flex-wrap justify-center">
|
|
{service.contactMethods.map((method) => {
|
|
const methodInfo = formatContactMethod(method.value)
|
|
|
|
return (
|
|
<li>
|
|
<a
|
|
href={method.value}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-day-300 hover:text-day-200 flex items-center gap-1 px-2 py-1 hover:underline"
|
|
>
|
|
<Icon name={methodInfo.icon} class="text-day-200 h-5 w-5" />
|
|
<span>{method.label ?? methodInfo.formattedValue}</span>
|
|
</a>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="scores">
|
|
Scores
|
|
</h2>
|
|
<div class="xs:gap-8 flex flex-row flex-wrap items-stretch justify-center gap-4 sm:justify-between">
|
|
<div class="flex max-w-84 flex-grow flex-row items-center justify-evenly gap-4">
|
|
<ScoreSquare score={service.overallScore} label="Overall" itemReviewedId={itemReviewedId} />
|
|
|
|
<div class="bg-day-800 h-12 w-px flex-shrink-0 self-center"></div>
|
|
|
|
<ScoreGauge score={service.privacyScore} label="Privacy" itemReviewedId={itemReviewedId} />
|
|
|
|
<ScoreGauge score={service.trustScore} label="Trust" itemReviewedId={itemReviewedId} />
|
|
</div>
|
|
<div
|
|
class="@container flex max-w-112 flex-shrink-0 flex-grow-100 basis-64 flex-row items-start rounded-lg bg-white px-3 py-2 text-black"
|
|
>
|
|
<Schema
|
|
item={{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Review',
|
|
itemReviewed: { '@id': itemReviewedId },
|
|
reviewAspect: 'KYC Level',
|
|
name: kycLevelInfo.name,
|
|
reviewBody: kycLevelInfo.description,
|
|
reviewRating: {
|
|
'@type': 'Rating',
|
|
ratingValue: kycLevelInfo.value,
|
|
bestRating: kycLevels.length - 1,
|
|
worstRating: 0,
|
|
},
|
|
author: KYCNOTME_SCHEMA_MINI,
|
|
} satisfies WithContext<Review>}
|
|
/>
|
|
<div class="mr-2 flex h-full flex-col items-center justify-around @sm:mr-4">
|
|
<Icon name={kycLevelInfo.icon} class="block size-13 @sm:size-16" />
|
|
<div class="text-day-600 h-4 text-center text-xs leading-none whitespace-nowrap @sm:text-sm">
|
|
<span class="font-title">lvl.</span>
|
|
<span class="text-day-700 text-base leading-none font-bold tracking-wider @sm:text-lg"
|
|
>{kycLevelInfo.value}</span
|
|
><span class="text-day-500 tracking-wider">/<span>{kycLevels.length - 1}</span></span>
|
|
</div>
|
|
</div>
|
|
|
|
<dl class="flex-grow-5 basis-0">
|
|
<dt class="text-base font-bold text-pretty">{kycLevelInfo.name}</dt>
|
|
<dd class="text-day-700 mt-1 font-sans text-sm text-pretty">
|
|
{kycLevelInfo.description}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
{
|
|
attributes.length > 0 && (
|
|
<ul class="mt-4 grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-2 md:gap-3">
|
|
{attributes.map((attribute) => {
|
|
const baseFormatOptions = {
|
|
roundDigits: 2,
|
|
showSign: true,
|
|
removeTrailingZeros: true,
|
|
} as const satisfies FormatNumberOptions
|
|
const weights = [
|
|
{
|
|
type: 'privacy',
|
|
label: 'Privacy',
|
|
value: attribute.privacyPoints,
|
|
formatOptions: baseFormatOptions,
|
|
},
|
|
{
|
|
type: 'trust',
|
|
label: 'Trust',
|
|
value: attribute.trustPoints,
|
|
formatOptions: baseFormatOptions,
|
|
},
|
|
] as const satisfies {
|
|
type: string
|
|
label: string
|
|
value: number
|
|
formatOptions: FormatNumberOptions
|
|
}[]
|
|
|
|
return (
|
|
<li>
|
|
<details class="group/attribute">
|
|
<summary
|
|
class={cn(
|
|
'bg-night-400 font-title flex cursor-pointer items-center rounded-md p-2 text-sm text-pretty select-none',
|
|
attribute.typeInfo.classNames.container,
|
|
attribute.typeInfo.classNames.text
|
|
)}
|
|
>
|
|
<Icon
|
|
name={attribute.categoryInfo.icon}
|
|
class={cn('mr-2 size-4 flex-shrink-0', attribute.typeInfo.classNames.icon)}
|
|
/>
|
|
<span>{attribute.title}</span>
|
|
<Icon
|
|
name="ri:arrow-down-s-line"
|
|
class={cn(
|
|
'ml-auto size-5 group-open/attribute:rotate-180',
|
|
attribute.typeInfo.classNames.icon
|
|
)}
|
|
/>
|
|
</summary>
|
|
|
|
<div
|
|
class={cn(
|
|
'text-day-300 border-night-400 bg-night-400/20 mx-2 rounded-b-lg border-2 border-t-0 p-3 pt-2 text-xs',
|
|
attribute.typeInfo.classNames.subcontainer,
|
|
attribute.typeInfo.classNames.text
|
|
)}
|
|
>
|
|
<div class="prose prose-sm prose-invert prose-p:my-1 prose-p:last:mb-0 prose-p:first:mt-0 text-xs leading-normal text-pretty text-current">
|
|
<Markdown content={attribute.description} />
|
|
</div>
|
|
|
|
<div class="mt-1 flex justify-evenly gap-4">
|
|
{weights.map((w) => (
|
|
<div class="text-day-200 inline-flex basis-12 flex-col items-center">
|
|
<span
|
|
class={cn('text-base', attribute.typeInfo.classNames.textLight, {
|
|
'text-red-400': w.value < 0,
|
|
'text-green-400': w.value > 0,
|
|
'opacity-50': w.value === 0,
|
|
})}
|
|
>
|
|
{formatNumber(w.value, w.formatOptions)}
|
|
</span>
|
|
<span
|
|
class={cn(
|
|
'text-2xs font-bold text-white uppercase',
|
|
attribute.typeInfo.classNames.textLight
|
|
)}
|
|
>
|
|
{w.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div class="mt-2 flex flex-row gap-2">
|
|
{attribute.links.map((link) => (
|
|
<a
|
|
href={link.url}
|
|
class={cn(
|
|
'flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 font-semibold whitespace-nowrap transition-colors',
|
|
attribute.typeInfo.classNames.button
|
|
)}
|
|
>
|
|
<Icon name={link.icon} class="size-4" />
|
|
{link.label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
<div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs">
|
|
<a
|
|
href="/about#service-scores"
|
|
class="text-day-400 hover:text-day-200 inline-flex items-center gap-1 transition-colors hover:underline"
|
|
>
|
|
<Icon name="ri:information-line" class="size-3" />
|
|
Learn about scores
|
|
</a>
|
|
<a
|
|
href="/attributes"
|
|
class="text-day-400 hover:text-day-200 inline-flex items-center gap-1 transition-colors hover:underline"
|
|
>
|
|
<Icon name="ri:information-line" class="size-3" />
|
|
Attributes list
|
|
</a>
|
|
</div>
|
|
|
|
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold">
|
|
Terms of Service Review
|
|
</h2>
|
|
{
|
|
service.verificationStatus === 'VERIFICATION_SUCCESS' || service.verificationStatus === 'APPROVED' ? (
|
|
service.tosReview ? (
|
|
<>
|
|
<Schema
|
|
item={
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Review',
|
|
itemReviewed: { '@id': itemReviewedId },
|
|
reviewAspect: 'Terms of Service',
|
|
name: 'Terms of Service Review',
|
|
reviewBody: service.tosReview.summary,
|
|
datePublished: service.tosReviewAt?.toISOString(),
|
|
author: KYCNOTME_SCHEMA_MINI,
|
|
positiveNotes: service.tosReview.highlights
|
|
.filter((h) => h.rating === 'positive')
|
|
.map(
|
|
(h, i) =>
|
|
({
|
|
'@type': 'ListItem',
|
|
position: i + 1,
|
|
name: h.title,
|
|
description: h.content,
|
|
}) satisfies ListItem
|
|
),
|
|
negativeNotes: service.tosReview.highlights
|
|
.filter((h) => h.rating === 'negative')
|
|
.map(
|
|
(h, i) =>
|
|
({
|
|
'@type': 'ListItem',
|
|
position: i + 1,
|
|
name: h.title,
|
|
description: h.content,
|
|
}) satisfies ListItem
|
|
),
|
|
} satisfies WithContext<Review>
|
|
}
|
|
/>
|
|
|
|
<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">
|
|
{service.tosReview.summary ? (
|
|
<Markdown content={service.tosReview.summary} />
|
|
) : (
|
|
<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>
|
|
|
|
<p class="text-day-400 mt-4 text-center text-xs">
|
|
{!!service.tosReviewAt && (
|
|
<>
|
|
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
|
|
</>
|
|
)}
|
|
{!!service.tosReviewAt && !!service.tosUrls.length && '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>Not reviewed yet</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>
|
|
)}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div class="text-day-400 my-8 text-center">
|
|
<p class="mb-4">Not available on {transformCase(statusInfo.label, 'lower')} services</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>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
<div class="border-day-500 mt-6 mb-3 flex items-center justify-between border-b" id="events">
|
|
<h2 class="font-title text-day-100 text-lg font-bold">Events</h2>
|
|
<a
|
|
href="/events"
|
|
class="text-day-500 hover:text-day-200 inline-flex items-center gap-1 text-xs leading-none transition-colors hover:underline"
|
|
>
|
|
View all
|
|
<Icon name="ri:arrow-right-s-line" class="size-4" />
|
|
</a>
|
|
</div>
|
|
{
|
|
service.events.length > 0 ? (
|
|
<ol class="-mx-4 -mt-1 mb-2 flex items-start overflow-x-auto px-4 pt-1 lg:mask-x-from-[calc(100%-1rem)]">
|
|
{orderBy(
|
|
service.events.map((event) => ({
|
|
...event,
|
|
actualEndedAt: event.endedAt ?? now,
|
|
})),
|
|
['actualEndedAt', 'startedAt'],
|
|
'desc'
|
|
).map((event) => {
|
|
const typeInfo = getEventTypeInfo(event.type)
|
|
|
|
return (
|
|
<li class="relative w-xs max-w-[calc(100vw-3rem)] flex-shrink-0">
|
|
<div class="flex items-center gap-2">
|
|
<div
|
|
class={cn(
|
|
'bg-night-400 ring-night-400/50 z-10 flex size-5 shrink-0 items-center justify-center rounded-full ring-3',
|
|
typeInfo.classNames.dot
|
|
)}
|
|
>
|
|
<Icon name={typeInfo.icon} class="size-3" />
|
|
</div>
|
|
<FormatTimeInterval
|
|
as="p"
|
|
start={event.startedAt}
|
|
end={event.endedAt}
|
|
now={now}
|
|
class="text-day-300 text-sm leading-none font-normal text-balance"
|
|
/>
|
|
<div class="bg-day-700 mr-2 flex h-0.5 flex-1" />
|
|
</div>
|
|
|
|
<div class="mt-3 max-w-md pe-8">
|
|
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
|
|
{event.title}
|
|
</h3>
|
|
|
|
<p class="text-day-400 text-base font-normal text-pretty">{event.content}</p>
|
|
{event.source && (
|
|
<a
|
|
href={event.source}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="mt-1 inline-block text-xs text-blue-400 hover:underline"
|
|
>
|
|
Source
|
|
</a>
|
|
)}
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
<li
|
|
class="mt-2.25 flex h-0.5 w-full flex-1 self-start bg-linear-[to_right,var(--color-day-700)_50%,transparent_50%] mask-r-from-25% bg-size-[1rem]"
|
|
aria-hidden
|
|
/>
|
|
</ol>
|
|
) : (
|
|
<p class="text-day-400 my-12 text-center">No events reported</p>
|
|
)
|
|
}
|
|
|
|
<div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs">
|
|
<a
|
|
href="/about#events"
|
|
class="text-day-400 hover:text-day-200 inline-flex items-center gap-1 transition-colors hover:underline"
|
|
>
|
|
<Icon name="ri:information-line" class="size-3" />
|
|
Learn about events
|
|
</a>
|
|
</div>
|
|
|
|
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="verification">
|
|
Verification
|
|
</h2>
|
|
<div class={cn('mb-6 rounded-lg p-4', statusInfo.classNames.containerBg)}>
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div class="flex-1">
|
|
<div class="mb-2 flex items-center gap-2">
|
|
<div
|
|
class={cn(
|
|
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-sm',
|
|
statusInfo.classNames.badgeBig
|
|
)}
|
|
>
|
|
<Icon name={statusInfo.icon} class="size-4" />
|
|
<span>{statusInfo.label}</span>
|
|
</div>
|
|
|
|
<p class="text-sm text-current/60">
|
|
{statusInfo.description}
|
|
</p>
|
|
</div>
|
|
|
|
{
|
|
!!service.verificationSummary && (
|
|
<div class="prose prose-invert prose-sm max-w-none">
|
|
<Markdown content={service.verificationSummary} />
|
|
</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 class="flex flex-col gap-2">
|
|
{
|
|
service.verificationStatus !== 'VERIFICATION_SUCCESS' &&
|
|
service.verificationStatus !== 'VERIFICATION_FAILED' && (
|
|
<form
|
|
method="POST"
|
|
action={actions.service.requestVerification}
|
|
id="request-verification-form"
|
|
hx-post={`${Astro.url.pathname}${actions.service.requestVerification}`}
|
|
hx-target="#request-verification-form"
|
|
hx-select="#request-verification-form"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<input type="hidden" name="serviceId" value={service.id} />
|
|
<input
|
|
type="hidden"
|
|
name="action"
|
|
value={hasRequestedVerification ? 'withdraw' : 'request'}
|
|
/>
|
|
<VerificationRequestElement
|
|
type={VerificationRequestElement === 'button' ? 'submit' : undefined}
|
|
href={
|
|
VerificationRequestElement === 'a'
|
|
? makeLoginUrl(
|
|
(() => {
|
|
const url = new URL(Astro.url)
|
|
url.hash = '#request-verification-form'
|
|
return url
|
|
})()
|
|
)
|
|
: undefined
|
|
}
|
|
class="border-night-400 bg-night-800 hover:bg-night-900 flex w-full items-center gap-2 rounded-lg border px-4 py-2 text-sm shadow-sm transition-colors duration-200"
|
|
>
|
|
<Icon
|
|
name={hasRequestedVerification ? 'ri:chat-delete-line' : 'ri:chat-check-line'}
|
|
class="htmx-request:hidden size-4"
|
|
/>
|
|
<Icon name="ri:loader-4-line" class="htmx-request:block hidden size-4 animate-spin" />
|
|
<span>{hasRequestedVerification ? 'Withdraw request' : 'Request verification'}</span>
|
|
</VerificationRequestElement>
|
|
|
|
<p class="text-day-500 mt-1 text-center text-xs">
|
|
{service.verificationRequests.length.toLocaleString()}{' '}
|
|
{pluralize('request', service.verificationRequests.length)}
|
|
</p>
|
|
{result?.error && <p class="mt-1 text-center text-xs text-red-500">{result.error.message}</p>}
|
|
</form>
|
|
)
|
|
}
|
|
|
|
{
|
|
service.verificationProofMd && (
|
|
<Button
|
|
as="label"
|
|
for="show-verification-proof"
|
|
icon="ri:file-text-line"
|
|
label="Verification audit"
|
|
color="white"
|
|
/>
|
|
)
|
|
}
|
|
</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>
|
|
|
|
<h2 class="font-title border-day-500 text-day-200 mt-2 mb-3 border-b text-lg font-bold" id="comments">
|
|
Comments
|
|
</h2>
|
|
<div
|
|
class='grid grid-cols-1 gap-8 [grid-template-areas:"about""rating""ai"] sm:grid-cols-[1fr_1fr] sm:gap-4 sm:[grid-template-areas:"about_ai""rating_ai"]'
|
|
>
|
|
<div class="relative rounded-md bg-orange-400/10 p-2 px-2.5 text-xs text-orange-100/70 [grid-area:about]">
|
|
<p class="font-bold">About comments</p>
|
|
<ul class="list-disc pl-4">
|
|
<li>Comments are visible after approval.</li>
|
|
<li>Moderation is light.</li>
|
|
<li>Double-check before trusting.</li>
|
|
</ul>
|
|
<div class="absolute inset-y-2 right-2 flex flex-col justify-center">
|
|
<Icon name="ri:alert-line" class="xs:opacity-20 h-full max-h-16 w-auto opacity-10" />
|
|
</div>
|
|
</div>
|
|
<CommentSummary
|
|
serviceId={service.id}
|
|
averageUserRating={service.averageUserRating}
|
|
class="flex-grow [grid-area:rating]"
|
|
itemReviewedId={itemReviewedId}
|
|
/>
|
|
|
|
{
|
|
userSentiment && service.averageUserRating !== null && (
|
|
<Schema
|
|
item={
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Review',
|
|
itemReviewed: { '@id': itemReviewedId },
|
|
reviewAspect: 'AI Summary',
|
|
name: 'AI Summary',
|
|
reviewBody: userSentiment.summary,
|
|
datePublished: service.userSentimentAt?.toISOString(),
|
|
author: {
|
|
'@type': 'Organization',
|
|
name: 'KYCnot.me Community',
|
|
},
|
|
reviewRating: {
|
|
'@type': 'Rating',
|
|
ratingValue: service.averageUserRating,
|
|
worstRating: 1,
|
|
bestRating: 5,
|
|
},
|
|
positiveNotes: userSentiment.whatUsersLike.map(
|
|
(item, index) =>
|
|
({
|
|
'@type': 'ListItem',
|
|
position: index + 1,
|
|
name: item,
|
|
}) satisfies ListItem
|
|
),
|
|
negativeNotes: userSentiment.whatUsersDislike.map(
|
|
(item, index) =>
|
|
({
|
|
'@type': 'ListItem',
|
|
position: index + 1,
|
|
name: item,
|
|
}) satisfies ListItem
|
|
),
|
|
} satisfies WithContext<Review>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
<div class="bg-night-800 flex flex-1 flex-grow-3 basis-64 flex-col gap-2 rounded-lg p-3 [grid-area:ai]">
|
|
<div class="flex flex-row justify-between">
|
|
<h3 class="font-title leading-none font-bold">
|
|
<Icon name="ri:sparkling-2-line" class="me-1 inline-block size-4 align-[-0.1em]" />AI Summary
|
|
</h3>
|
|
{
|
|
service.userSentimentAt && (
|
|
<div class="text-day-500 text-right text-xs">
|
|
Updated
|
|
<TimeFormatted date={service.userSentimentAt} hourPrecision />
|
|
</div>
|
|
)
|
|
}
|
|
</div>
|
|
|
|
{
|
|
userSentiment ? (
|
|
<>
|
|
<div class="prose prose-invert prose-sm max-w-none text-sm">
|
|
<Markdown content={userSentiment.summary} />
|
|
</div>
|
|
|
|
<div class="flex flex-grow items-center justify-evenly">
|
|
<ul class="text-day-300 inline-grid list-inside list-disc place-items-center text-sm marker:text-green-400 marker:content-['+_']">
|
|
{userSentiment.whatUsersLike.map((item) => (
|
|
<li class="w-full">
|
|
<span>{item}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<ul class="text-day-300 inline-grid list-inside list-disc place-items-center text-sm marker:text-red-400 marker:content-['-_']">
|
|
{userSentiment.whatUsersDislike.map((item) => (
|
|
<li class="w-full">
|
|
<span>{item}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p class="text-day-400 my-2 flex flex-1 items-center justify-center text-center text-xs">
|
|
Not reviewed yet
|
|
</p>
|
|
)
|
|
}
|
|
</div>
|
|
</div>
|
|
<CommentSection itemReviewedId={itemReviewedId} service={service} />
|
|
</BaseLayout>
|
|
|
|
<script>
|
|
document.addEventListener('astro:page-load', () => {
|
|
const checkboxes = document.querySelectorAll<HTMLInputElement>('input[data-mark-indeterminate]')
|
|
checkboxes.forEach((checkbox) => {
|
|
checkbox.indeterminate = checkbox.dataset.markIndeterminate === 'true'
|
|
})
|
|
})
|
|
</script>
|