Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
459d7c91f7 Release 202506091901 2025-06-09 19:01:08 +00:00
pluja
b8b2dee4a4 Release 202506091433 2025-06-09 14:33:57 +00:00
pluja
eb0af871e1 Release 202506091118 2025-06-09 11:18:06 +00:00
pluja
3ccd7fd395 Release 202506091053 2025-06-09 10:53:52 +00:00
21 changed files with 340 additions and 318 deletions

View File

@@ -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(

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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) => {

View File

@@ -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 />

View File

@@ -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)"
/>
</>
)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
),
{

View File

@@ -57,7 +57,6 @@ Some users are **verified**, this means that the moderators have confirmed that
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us.
## 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

View File

@@ -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>

View File

@@ -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

View File

@@ -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