Files
kycnotme/web/src/pages/service/[slug].astro
2025-05-23 11:52:16 +00:00

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>