Compare commits
4 Commits
release-60
...
release-64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
459d7c91f7 | ||
|
|
b8b2dee4a4 | ||
|
|
eb0af871e1 | ||
|
|
3ccd7fd395 |
@@ -19,6 +19,7 @@ import {
|
||||
type ServiceVisibility,
|
||||
ServiceSuggestionType,
|
||||
KycLevelClarification,
|
||||
VerificationStepStatus,
|
||||
} from '@prisma/client'
|
||||
import { omit, uniqBy } from 'lodash-es'
|
||||
import { generateUsername } from 'unique-username-generator'
|
||||
@@ -610,6 +611,10 @@ const generateFakeService = (users: User[]) => {
|
||||
const name = faker.helpers.arrayElement(serviceNames)
|
||||
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
||||
|
||||
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
|
||||
probability: 0.8,
|
||||
})
|
||||
|
||||
return {
|
||||
name,
|
||||
slug,
|
||||
@@ -643,6 +648,19 @@ const generateFakeService = (users: User[]) => {
|
||||
},
|
||||
verificationProofMd:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
||||
verificationSteps:
|
||||
(status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED') && faker.datatype.boolean(0.75)
|
||||
? {
|
||||
create: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
|
||||
title: faker.lorem.sentence(),
|
||||
description: faker.lorem.paragraph(),
|
||||
status: faker.helpers.arrayElement(Object.values(VerificationStepStatus)),
|
||||
evidenceMd: faker.lorem.paragraph(),
|
||||
createdAt: faker.date.recent(),
|
||||
updatedAt: faker.date.recent(),
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
referral: faker.helpers.arrayElement([
|
||||
`?ref=${faker.string.alphanumeric(6)}`,
|
||||
`/ref/${faker.string.alphanumeric(6)}`,
|
||||
@@ -661,8 +679,10 @@ const generateFakeService = (users: User[]) => {
|
||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||
listedAt: faker.date.past(),
|
||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
||||
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
||||
tosReviewAt: faker.date.past(),
|
||||
tosReview,
|
||||
tosReviewAt: tosReview
|
||||
? faker.date.recent()
|
||||
: faker.helpers.maybe(() => faker.date.recent(), { probability: 0.5 }),
|
||||
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
||||
userSentimentAt: faker.date.recent(),
|
||||
internalNotes: faker.helpers.maybe(
|
||||
|
||||
@@ -135,6 +135,17 @@ BEGIN
|
||||
SET "isRecentlyListed" = FALSE
|
||||
WHERE id = service_id;
|
||||
END IF;
|
||||
|
||||
-- Apply penalty if ToS cannot be analyzed
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "Service"
|
||||
WHERE id = service_id
|
||||
AND "tosReviewAt" IS NOT NULL
|
||||
AND "tosReview" IS NULL
|
||||
) THEN
|
||||
trust_score := trust_score - 3;
|
||||
END IF;
|
||||
|
||||
-- Calculate final trust score (base 100)
|
||||
trust_score := trust_score + verification_factor + attributes_score;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#0080FF" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#00bfff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#3BDB78" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
|
||||
|
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 619 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#0080FF" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#FF0040" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
|
||||
|
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 616 B |
8
web/public/favicon-dev-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#FF0040" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 613 B |
7
web/public/favicon-dev-lightmode.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill="#fff" d="M4 4h24v24H4z" />
|
||||
<path fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 607 B |
@@ -1,13 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<style>
|
||||
@media (prefers-color-scheme: light) {
|
||||
.a {
|
||||
fill: #0080ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path fill="#ff0040" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#00ffff" class="a" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
|
||||
|
Before Width: | Height: | Size: 741 B After Width: | Height: | Size: 626 B |
8
web/public/favicon-stage-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#0080ff" class="a" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 623 B |
7
web/public/favicon-stage-lightmode.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill="#fff" class="b" d="M4 4h24v24H4z" />
|
||||
<path fill="#0080ff" class="a" fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
@@ -1,18 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<style>
|
||||
@media (prefers-color-scheme: light) {
|
||||
.a {
|
||||
fill: #0080ff;
|
||||
}
|
||||
|
||||
.b {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path fill="#040505" class="b" d="M4 4h24v24H4z" />
|
||||
<path fill="#00ffff" class="" fill-rule="evenodd"
|
||||
<path fill="#00ffff" class="a" fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 793 B After Width: | Height: | Size: 642 B |
6
web/public/notification-icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill-rule="evenodd"
|
||||
d="M30 26a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h20a4 4 0 0 1 4 4v20ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 566 B |
@@ -307,7 +307,7 @@ export const adminServiceActions = {
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: z.string().url(),
|
||||
value: zodContactMethod,
|
||||
serviceId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
|
||||
@@ -100,7 +100,7 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
|
||||
<!-- PWA -->
|
||||
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
|
||||
{pwaAssetsHead.links.map((link) => <link {...link} />)}
|
||||
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
|
||||
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
||||
|
||||
<DynamicFavicon />
|
||||
|
||||
@@ -25,10 +25,11 @@ function addBadgeIfUnread(href: string) {
|
||||
{
|
||||
DEPLOYMENT_MODE === 'production' && (
|
||||
<>
|
||||
<link rel="icon" type="image/svg+xml" href={addBadgeIfUnread('/favicon.svg')} />
|
||||
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon.svg')} />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
sizes="any"
|
||||
href={addBadgeIfUnread('/favicon-lightmode.svg')}
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
@@ -37,12 +38,30 @@ function addBadgeIfUnread(href: string) {
|
||||
}
|
||||
{
|
||||
DEPLOYMENT_MODE === 'development' && (
|
||||
<link rel="icon" type="image/svg+xml" href={addBadgeIfUnread('/favicon-dev.svg')} />
|
||||
<>
|
||||
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-dev.svg')} />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
sizes="any"
|
||||
href={addBadgeIfUnread('/favicon-dev-lightmode.svg')}
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
DEPLOYMENT_MODE === 'staging' && (
|
||||
<link rel="icon" type="image/svg+xml" href={addBadgeIfUnread('/favicon-stage.svg')} />
|
||||
<>
|
||||
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-stage.svg')} />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
sizes="any"
|
||||
href={addBadgeIfUnread('/favicon-stage-lightmode.svg')}
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export function makeNonDbAttributes(
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
createdAt: true
|
||||
tosReviewAt: true
|
||||
tosReview: true
|
||||
}
|
||||
}>,
|
||||
{ filter = false }: { filter?: boolean } = {}
|
||||
@@ -156,6 +158,17 @@ export function makeNonDbAttributes(
|
||||
trustPoints: -5,
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: "Can't analyse ToS",
|
||||
show: service.tosReviewAt !== null && service.tosReview === null,
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description:
|
||||
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -3,
|
||||
links: [],
|
||||
},
|
||||
]
|
||||
|
||||
if (filter) return nonDbAttributes.filter(({ show }) => show)
|
||||
|
||||
@@ -14,7 +14,7 @@ export function makeNotificationOptions(
|
||||
body: 'You have a new notification',
|
||||
lang: 'en-US',
|
||||
icon: '/favicon.svg',
|
||||
badge: '/favicon.svg',
|
||||
badge: '/notification-icon.svg',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
actions: options.removeActions
|
||||
|
||||
@@ -21,7 +21,7 @@ const cleanUrl = (input: unknown) => {
|
||||
|
||||
export const zodUrlOptionalProtocol = z.preprocess(
|
||||
cleanUrl,
|
||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||
z.string().refine((value) => /^(https?:\/\/)?[a-z0-9]+(\.[a-z0-9])*(\.[a-z0-9]{2,}).*$/i.test(value), {
|
||||
message: 'Invalid URL',
|
||||
})
|
||||
)
|
||||
@@ -42,7 +42,7 @@ export const zodContactMethod = z.preprocess(
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
/^((https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
|
||||
/^((https?:\/\/)?[a-z0-9]+(\.[a-z0-9])*(\.[a-z0-9]{2,}).*|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
|
||||
value
|
||||
),
|
||||
{
|
||||
|
||||
@@ -57,7 +57,6 @@ Some users are **verified**, this means that the moderators have confirmed that
|
||||
|
||||
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us.
|
||||
|
||||
|
||||
## Listings
|
||||
|
||||
### Suggesting a new listing
|
||||
@@ -141,7 +140,7 @@ You can view all available attributes on the [Attributes page](/attributes).
|
||||
Attributes are classified into two main types:
|
||||
|
||||
1. **Privacy Attributes** – Related to data protection and anonymity.
|
||||
2. **Trust Attributes** – Related to reliability and security.
|
||||
1. **Trust Attributes** – Related to reliability and security.
|
||||
|
||||
These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**.
|
||||
|
||||
@@ -154,31 +153,32 @@ Scores are calculated **automatically** using clear, fixed rules. We do not chan
|
||||
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
|
||||
|
||||
1. **Base Score:** Every service starts with a neutral score of 50 points.
|
||||
2. **KYC Level:** Adjusts the score based on the level of identity verification required:
|
||||
1. **KYC Level:** Adjusts the score based on the level of identity verification required:
|
||||
- KYC Level 0 (No KYC): **+25 points**
|
||||
- KYC Level 1 (Minimal KYC): **+10 points**
|
||||
- KYC Level 2 (Moderate KYC): **-5 points**
|
||||
- KYC Level 3 (More KYC): **-15 points**
|
||||
- KYC Level 4 (Full mandatory KYC): **-25 points**
|
||||
3. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
|
||||
4. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
|
||||
5. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
|
||||
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
|
||||
7. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
1. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
|
||||
1. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
|
||||
1. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
|
||||
1. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
|
||||
1. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
|
||||
##### Trust Score
|
||||
|
||||
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
|
||||
|
||||
1. **Base Score:** Every service begins with a neutral score of 50 points.
|
||||
2. **Verification Status Adjustment:**
|
||||
1. **Verification Status:**
|
||||
- **Verification Success:** +10 points
|
||||
- **Approved:** +5 points
|
||||
- **Community Contributed:** 0 points
|
||||
- **Verification Failed (SCAM):** -50 points
|
||||
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
|
||||
4. **Recently Listed Penalty & Flag:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
|
||||
5. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
1. **Recently Listed:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
|
||||
1. **Can't Analyze ToS:** If a service's Terms of Service cannot be analyzed by our AI (usually due to captchas, client-side rendering, DDoS protections, or non-text format), a penalty of -3 points is applied to the trust score.
|
||||
1. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
|
||||
1. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
|
||||
##### Overall Score
|
||||
|
||||
|
||||
@@ -1141,8 +1141,21 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
<input type="hidden" name="serviceId" value={service.id} />
|
||||
|
||||
<InputText
|
||||
label="Override Label (Optional)"
|
||||
label="Value"
|
||||
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
|
||||
name="value"
|
||||
inputProps={{
|
||||
required: true,
|
||||
value: method.value,
|
||||
placeholder: 'contact@example.com',
|
||||
}}
|
||||
error={contactMethodUpdateInputErrors.value}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Label"
|
||||
name="label"
|
||||
description="Leave empty to auto-generate"
|
||||
inputProps={{
|
||||
value: method.label,
|
||||
placeholder: contactMethodInfo.formattedValue,
|
||||
@@ -1150,16 +1163,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
error={contactMethodUpdateInputErrors.label}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Value (with protocol)"
|
||||
name="value"
|
||||
inputProps={{
|
||||
value: method.value,
|
||||
placeholder: 'e.g., mailto:contact@example.com or https://t.me/example',
|
||||
}}
|
||||
error={contactMethodUpdateInputErrors.value}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
|
||||
</form>
|
||||
</div>
|
||||
@@ -1174,8 +1177,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
<form method="POST" action={actions.admin.service.contactMethod.add} class="space-y-2">
|
||||
<input type="hidden" name="serviceId" value={service.id} />
|
||||
|
||||
<InputText label="Override Label" name="label" />
|
||||
|
||||
<InputText
|
||||
label="Value"
|
||||
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
|
||||
@@ -1187,6 +1188,13 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
error={contactMethodAddInputErrors.value}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Label"
|
||||
description="Leave empty to auto-generate"
|
||||
name="label"
|
||||
inputProps={{ placeholder: 'Auto-generated' }}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
|
||||
</form>
|
||||
</FormSubSection>
|
||||
|
||||
@@ -333,6 +333,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
serviceVisibility: true,
|
||||
verificationStatus: true,
|
||||
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
||||
(typeof sortOptions)[number]['orderBy']['key'],
|
||||
true
|
||||
@@ -354,10 +356,18 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
sortBy(unsortedServices, 'id'),
|
||||
[
|
||||
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||
(service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : 0),
|
||||
(service) => (service.serviceVisibility === 'ARCHIVED' ? 1 : 0),
|
||||
selectedSort.orderBy.key,
|
||||
() => rng(),
|
||||
],
|
||||
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||
[
|
||||
...(filters.q ? (['desc'] as const) : ([] as const)),
|
||||
'asc',
|
||||
'asc',
|
||||
selectedSort.orderBy.direction,
|
||||
'asc',
|
||||
]
|
||||
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
|
||||
|
||||
const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
|
||||
@@ -410,11 +420,19 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
unsortedServicesWithInfo,
|
||||
[
|
||||
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||
(service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : 0),
|
||||
(service) => (service.serviceVisibility === 'ARCHIVED' ? 1 : 0),
|
||||
selectedSort.orderBy.key,
|
||||
// Now we can shuffle indeternimistically, because the pagination was already applied
|
||||
() => Math.random(),
|
||||
],
|
||||
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||
[
|
||||
...(filters.q ? (['desc'] as const) : ([] as const)),
|
||||
'asc',
|
||||
'asc',
|
||||
selectedSort.orderBy.direction,
|
||||
'asc',
|
||||
]
|
||||
)
|
||||
|
||||
return [sortedServicesWithInfo, totalServices] as const
|
||||
|
||||
@@ -9,7 +9,6 @@ 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'
|
||||
@@ -411,6 +410,8 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
|
||||
const activeAlertOrWarningEvents = service.events.filter(
|
||||
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
|
||||
)
|
||||
const activeEventToShow =
|
||||
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -512,27 +513,23 @@ const activeAlertOrWarningEvents = service.events.filter(
|
||||
]}
|
||||
>
|
||||
{
|
||||
activeAlertOrWarningEvents.length > 0 && (
|
||||
!!activeEventToShow && (
|
||||
<a
|
||||
href="#events"
|
||||
class={cn(
|
||||
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
|
||||
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
|
||||
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
|
||||
'group mb-4 block rounded-md px-3 py-2 text-sm transition-colors duration-200',
|
||||
activeEventToShow.type === EventType.ALERT
|
||||
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60'
|
||||
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||
? 'ri:alert-fill'
|
||||
: 'ri:alarm-warning-fill'
|
||||
}
|
||||
name={activeEventToShow.type === EventType.ALERT ? 'ri:alert-fill' : 'ri:alarm-warning-fill'}
|
||||
class="me-1.5 inline-block size-4 align-[-0.15em]"
|
||||
/>
|
||||
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||
? 'There is an active alert for this service. Click to see details.'
|
||||
: 'There is an active warning for this service. Click to see details.'}
|
||||
<span class="font-bold">{activeEventToShow.title}</span> — {activeEventToShow.content}
|
||||
{activeAlertOrWarningEvents.length >= 2 && <>+{activeAlertOrWarningEvents.length - 1} more events.</>}
|
||||
<span class="underline">Go to events</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1081,105 +1078,119 @@ const activeAlertOrWarningEvents = service.events.filter(
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
service.tosReviewAt !== null ? (
|
||||
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 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>
|
||||
|
||||
<p class="text-day-400 mt-4 text-center text-xs">
|
||||
{!!service.tosReviewAt && (
|
||||
<>
|
||||
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
|
||||
</>
|
||||
<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">
|
||||
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
|
||||
{service.tosUrls.length > 0 && 'from'}
|
||||
{service.tosUrls.map((url) => (
|
||||
<a href={url} class="cursor-pointer hover:underline">
|
||||
{urlDomain(url)}
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
<p class="text-day-600 hover:text-day-400 mb-2 text-center text-xs duration-400">
|
||||
ToS reviews are AI-generated and should be used as a reference only.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div class="text-day-400 my-12 text-center">
|
||||
<p>Can't analyze ToS with AI</p>
|
||||
<p class="text-day-500 mt-1 text-sm text-balance">
|
||||
Maybe due to captchas, client side rendering, DDoS protections, or non-text format.
|
||||
</p>
|
||||
{service.tosUrls.length > 0 && (
|
||||
<p class="mt-2 text-xs">
|
||||
{service.tosUrls.map((url) => (
|
||||
<a href={url} class="hover:underline">
|
||||
{urlDomain(url)}
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{!!service.tosReviewAt && !!service.tosUrls.length && 'from'}
|
||||
{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>
|
||||
)
|
||||
) : (
|
||||
<div class="text-day-400 my-12 text-center">
|
||||
<p>Not reviewed yet</p>
|
||||
@@ -1332,77 +1343,6 @@ const activeAlertOrWarningEvents = service.events.filter(
|
||||
</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">
|
||||
@@ -1456,89 +1396,64 @@ const activeAlertOrWarningEvents = service.events.filter(
|
||||
</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>
|
||||
{
|
||||
service.verificationProofMd && (
|
||||
<>
|
||||
<h3 class="font-title mt-4 text-lg font-semibold">Verification proof</h3>
|
||||
<div class="prose prose-invert prose-sm max-w-none">
|
||||
<Markdown content={service.verificationProofMd} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
service.verificationSteps.length > 0 && (
|
||||
<>
|
||||
<h3 class="font-title text-md mt-6 mb-2 font-semibold">Verification Steps</h3>
|
||||
<ul class="mb-8 space-y-2">
|
||||
{service.verificationSteps.map((step) => {
|
||||
const statusInfo = getVerificationStepStatusInfo(step.status)
|
||||
|
||||
return (
|
||||
<li class="border-night-500 bg-night-700/50 flex-grow rounded-md border p-2.5 shadow-sm">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm leading-tight font-semibold text-white">
|
||||
<BadgeSmall
|
||||
text={statusInfo.text}
|
||||
icon={statusInfo.icon}
|
||||
color={statusInfo.color}
|
||||
inlineIcon={statusInfo.icon === 'ri:loader-4-line'}
|
||||
classNames={{
|
||||
icon: cn(statusInfo.icon === 'ri:loader-4-line' && 'animate-spin'),
|
||||
}}
|
||||
class="me-1"
|
||||
/>
|
||||
|
||||
{step.title}
|
||||
</div>
|
||||
<p class="text-day-300 mt-1.5 text-xs">{step.description}</p>
|
||||
</div>
|
||||
<p class="text-2xs text-day-400 shrink-0 pt-0.5 whitespace-nowrap">
|
||||
<TimeFormatted date={step.updatedAt} prefix={false} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step.evidenceMd && (
|
||||
<div class="prose prose-invert prose-xs mt-2 block max-w-none text-xs leading-relaxed">
|
||||
<Markdown content={step.evidenceMd} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<h2 class="font-title border-day-500 text-day-200 mt-2 mb-3 border-b text-lg font-bold" id="comments">
|
||||
Comments
|
||||
|
||||