--- 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 --- ({ ...method, info: formatContactMethod(method.value), })) .map(({ 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, { '@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, ]} 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' && (
Unlisted service, only accessible via direct link and won't appear in searches.
) }
{ !!service.imageUrl && ( ) }

{service.name}{ !!statusIcon && ( ) }

{ user ? ( {!!watchingDetails && ( )} {!!watchingDetails && ( )} {!!watchingDetails && ( )} {!!watchingDetails && ( )} ) : ( ) }
= 3, })} >
    = 3, })} aria-label="Categories" > { service.categories.map((category) => ( )) }
{ currencies.map((currency) => { const isAccepted = service.acceptedCurrencies.includes(currency.id) return ( ) }) }
{ shownLinks.length + hiddenLinks.length > 0 && (
    {shownLinks.map((url) => (
  • 0} />
  • ))} {hiddenLinks.length > 0 && (
  • )} {hiddenLinks.map((url) => ( ))}
) } { service.contactMethods.length > 0 && ( ) }

Scores

} />
lvl. {kycLevelInfo.value}/{kycLevels.length - 1}
{kycLevelInfo.name}
{kycLevelInfo.description}
{ attributes.length > 0 && (
    {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 (
  • {attribute.title}
    {weights.map((w) => (
    0, 'opacity-50': w.value === 0, })} > {formatNumber(w.value, w.formatOptions)} {w.label}
    ))}
    {attribute.links.map((link) => ( {link.label} ))}
  • ) })}
) }

Terms of Service Review

{ service.verificationStatus === 'VERIFICATION_SUCCESS' || service.verificationStatus === 'APPROVED' ? ( service.tosReview ? ( <> 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 } />
{service.tosReview.summary ? ( ) : (

No summary available

)}
{sortBy( service.tosReview.highlights.map((highlight) => ({ ...highlight, ratingInfo: getTosHighlightRatingInfo(highlight.rating), })), 'ratingInfo.order' ).map((highlight) => (

{highlight.title}

{!!highlight.content && (
)}
))}

{!!service.tosReviewAt && ( <> Reviewed )} {!!service.tosReviewAt && !!service.tosUrls.length && 'from'} {service.tosUrls.map((url) => ( {urlDomain(url)} ))}

ToS reviews are AI-generated and should be used as a reference only.

) : (

Not reviewed yet

{service.tosUrls.length > 0 && (

{service.tosUrls.map((url) => ( {urlDomain(url)} ))}

)}
) ) : (

Not available on {transformCase(statusInfo.label, 'lower')} services

{service.tosUrls.length > 0 && (

{service.tosUrls.map((url) => ( {urlDomain(url)} ))}

)}
) }

Events

View all
{ service.events.length > 0 ? (
    {orderBy( service.events.map((event) => ({ ...event, actualEndedAt: event.endedAt ?? now, })), ['actualEndedAt', 'startedAt'], 'desc' ).map((event) => { const typeInfo = getEventTypeInfo(event.type) return (
  1. {event.title}

    {event.content}

    {event.source && ( Source )}
  2. ) })}
) : (

No events reported

) }

Verification

{statusInfo.label}

{statusInfo.description}

{ !!service.verificationSummary && (
) } { service.verificationSteps.length > 0 && (

Verification Steps

    {service.verificationSteps.map((step) => { const statusInfo = getVerificationStepStatusInfo(step.status) return (
  • {step.title} {statusInfo.text}

    Added

    {step.description && (
    {step.description}
    )} {step.evidenceMd && (
    Evidence:
    )}
  • ) })}
) }
{ service.verificationStatus !== 'VERIFICATION_SUCCESS' && service.verificationStatus !== 'VERIFICATION_FAILED' && (
{ 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" >

{service.verificationRequests.length.toLocaleString()}{' '} {pluralize('request', service.verificationRequests.length)}

{result?.error &&

{result.error.message}

}
) } { service.verificationProofMd && (
{ service.verificationProofMd && ( <> ) }

Comments

About comments

  • Comments are visible after approval.
  • Moderation is light.
  • Double-check before trusting.
{ userSentiment && service.averageUserRating !== null && ( ({ '@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 } /> ) }

AI Summary

{ service.userSentimentAt && (
Updated
) }
{ userSentiment ? ( <>
    {userSentiment.whatUsersLike.map((item) => (
  • {item}
  • ))}
    {userSentiment.whatUsersDislike.map((item) => (
  • {item}
  • ))}
) : (

Not reviewed yet

) }