Compare commits

..

3 Commits

Author SHA1 Message Date
pluja
9285d952a5 Release 202507281850 2025-07-28 18:50:07 +00:00
pluja
fd5c7ab475 Release 202507240432 2025-07-24 04:32:40 +00:00
pluja
9a78a9b377 Release 202507231133 2025-07-23 11:33:11 +00:00
17 changed files with 721 additions and 484 deletions

43
web/package-lock.json generated
View File

@@ -28,6 +28,8 @@
"astro-seo-schema": "5.0.0",
"canvas": "3.1.2",
"clsx": "2.1.1",
"countries-list": "3.1.1",
"country-flag-icons": "1.5.19",
"he": "1.2.0",
"htmx.org": "2.0.6",
"javascript-time-ago": "2.5.11",
@@ -2680,19 +2682,32 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
"integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.14.0",
"@eslint/core": "^0.15.1",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@faker-js/faker": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz",
@@ -7884,6 +7899,18 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/countries-list": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/countries-list/-/countries-list-3.1.1.tgz",
"integrity": "sha512-nPklKJ5qtmY5MdBKw1NiBAoyx5Sa7p2yPpljZyQ7gyCN1m+eMFs9I6CT37Mxt8zvR5L3VzD3DJBE4WQzX3WF4A==",
"license": "MIT"
},
"node_modules/country-flag-icons": {
"version": "1.5.19",
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.19.tgz",
"integrity": "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g==",
"license": "MIT"
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
@@ -10064,14 +10091,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {

View File

@@ -44,6 +44,8 @@
"astro-seo-schema": "5.0.0",
"canvas": "3.1.2",
"clsx": "2.1.1",
"countries-list": "3.1.1",
"country-flag-icons": "1.5.19",
"he": "1.2.0",
"htmx.org": "2.0.6",
"javascript-time-ago": "2.5.11",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "registrationCountryCode" VARCHAR(2);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "registeredCompanyName" TEXT;

View File

@@ -345,60 +345,64 @@ model ServiceSuggestionMessage {
}
model Service {
id Int @id @default(autoincrement())
name String
slug String @unique
previousSlugs String[] @default([])
description String
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE)
id Int @id @default(autoincrement())
name String
slug String @unique
previousSlugs String[] @default([])
description String
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE)
/// Date only, no time.
operatingSince DateTime? @db.Date
overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
operatingSince DateTime? @db.Date
overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
/// Computed via trigger. Do not update through prisma.
averageUserRating Float?
serviceVisibility ServiceVisibility @default(PUBLIC)
serviceInfoBanner ServiceInfoBanner @default(NONE)
serviceInfoBannerNotes String?
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
verificationSummary String?
verificationRequests ServiceVerificationRequest[]
verificationProofMd String?
averageUserRating Float?
serviceVisibility ServiceVisibility @default(PUBLIC)
serviceInfoBanner ServiceInfoBanner @default(NONE)
serviceInfoBannerNotes String?
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
verificationSummary String?
verificationRequests ServiceVerificationRequest[]
verificationProofMd String?
/// [UserSentiment]
userSentiment Json?
userSentimentAt DateTime?
referral String?
acceptedCurrencies Currency[] @default([])
serviceUrls String[]
tosUrls String[] @default([])
onionUrls String[] @default([])
i2pUrls String[] @default([])
imageUrl String?
userSentiment Json?
userSentimentAt DateTime?
referral String?
acceptedCurrencies Currency[] @default([])
serviceUrls String[]
tosUrls String[] @default([])
onionUrls String[] @default([])
i2pUrls String[] @default([])
imageUrl String?
/// ISO 3166-1 alpha-2 country code where the service company is registered
registrationCountryCode String? @db.VarChar(2)
/// Official name of the registered company
registeredCompanyName String?
/// [TosReview]
tosReview Json?
tosReviewAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
tosReview Json?
tosReviewAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
listedAt DateTime?
listedAt DateTime?
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
approvedAt DateTime?
approvedAt DateTime?
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
verifiedAt DateTime?
verifiedAt DateTime?
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
spamAt DateTime?
spamAt DateTime?
/// Computed via trigger. Do not update through prisma.
isRecentlyApproved Boolean @default(false)
comments Comment[]
events Event[]
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
attributes ServiceAttribute[]
verificationSteps VerificationStep[]
suggestions ServiceSuggestion[]
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
isRecentlyApproved Boolean @default(false)
comments Comment[]
events Event[]
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
attributes ServiceAttribute[]
verificationSteps VerificationStep[]
suggestions ServiceSuggestion[]
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")

View File

@@ -28,6 +28,7 @@ import { generateUsername } from 'unique-username-generator'
import { kycLevels } from '../src/constants/kycLevels'
import { undefinedIfEmpty } from '../src/lib/arrays'
import { transformCase } from '../src/lib/strings'
import { countries } from '../src/constants/countries'
// Exit if not in development mode
if (process.env.NODE_ENV === 'production') {
@@ -697,6 +698,12 @@ const generateFakeService = (users: User[]) => {
{ count: { min: 0, max: 2 } }
),
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
registrationCountryCode: faker.helpers.maybe(() => faker.helpers.arrayElement(countries).code, {
probability: 0.7,
}),
registeredCompanyName: faker.helpers.maybe(() => faker.company.name(), {
probability: 0.6,
}),
listedAt:
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
? faker.date.recent({ days: 30 })

View File

@@ -96,6 +96,7 @@ DECLARE
recently_approved_factor INT := 0;
tos_penalty_factor INT := 0;
operating_since_factor INT := 0;
legally_registered_factor INT := 0;
BEGIN
-- Get verification status factor
SELECT
@@ -160,9 +161,17 @@ BEGIN
INTO operating_since_factor
FROM "Service"
WHERE id = service_id;
-- Check for legal registration (country code or company name)
IF EXISTS (
SELECT 1 FROM "Service"
WHERE id = service_id AND ("registrationCountryCode" IS NOT NULL OR "registeredCompanyName" IS NOT NULL)
) THEN
legally_registered_factor := 2;
END IF;
-- Calculate final trust score (base 100)
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor;
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor + legally_registered_factor;
-- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score));

View File

@@ -4,12 +4,14 @@ import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es'
import slugify from 'slugify'
import { countriesZodEnumById } from '../../constants/countries'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma'
import { separateServiceUrlsByType } from '../../lib/urls'
import {
imageFileSchema,
stringListOfContactMethodsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
zodContactMethod,
@@ -44,6 +46,7 @@ const serviceSchemaBase = z.object({
description: z.string().min(1),
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
contactMethods: stringListOfContactMethodsSchema,
kycLevel: z.coerce.number().int().min(0).max(4),
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
attributes: z.array(z.coerce.number().int().positive()),
@@ -51,7 +54,7 @@ const serviceSchemaBase = z.object({
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
referral: z
.string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
@@ -59,8 +62,15 @@ const serviceSchemaBase = z.object({
.nullable()
.default(null),
operatingSince: z.coerce.date().optional().nullable(),
registrationCountryCode: z
.union([countriesZodEnumById, z.literal('')])
.optional()
.nullable()
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
message: 'Country code must be a valid 2-character code or empty',
}),
registeredCompanyName: z.string().trim().max(100).optional().nullable(),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
strictCommentingEnabled: z.boolean().optional().default(false),
@@ -135,7 +145,6 @@ export const adminServiceActions = {
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
categories: {
connect: input.categories.map((id) => ({ id })),
},
@@ -146,6 +155,11 @@ export const adminServiceActions = {
},
})),
},
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
imageUrl,
internalNotes: input.internalNote
? {
@@ -156,6 +170,8 @@ export const adminServiceActions = {
}
: undefined,
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
},
select: {
id: true,
@@ -257,7 +273,6 @@ export const adminServiceActions = {
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
previousSlugs:
existingService.slug !== input.slug
? {
@@ -283,6 +298,8 @@ export const adminServiceActions = {
})),
},
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
},
})
@@ -343,7 +360,6 @@ export const adminServiceActions = {
await prisma.serviceContactMethod.delete({
where: { id: input.id },
})
return { success: true }
},
}),
},
@@ -469,7 +485,6 @@ export const adminServiceActions = {
input: evidenceImageDeleteSchema,
handler: async (input) => {
await deleteFileLocally(input.fileUrl)
return { success: true }
},
}),
},

View File

@@ -3,6 +3,7 @@ import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns'
import { countriesZodEnumById } from '../constants/countries'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
@@ -184,6 +185,14 @@ export const serviceSuggestionActions = {
}),
}),
operatingSince: z.coerce.date().optional(),
registrationCountryCode: z
.union([countriesZodEnumById, z.literal('')])
.optional()
.nullable()
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
message: 'Country code must be a valid 2-character code or empty',
}),
registeredCompanyName: z.string().trim().max(100).optional(),
/** @deprecated Honey pot field, do not use */
message: z.unknown().optional(),
skipDuplicateCheck: z
@@ -282,6 +291,8 @@ export const serviceSuggestionActions = {
slug: input.slug,
description: input.description,
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls,

View File

@@ -9,6 +9,7 @@ import defaultOGImage from '../assets/ogimage.png'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { urlWithParams } from '../lib/urls'
import type { VerificationStatus } from '@prisma/client'
import type { APIContext } from 'astro'
import type { Prettify } from 'ts-essentials'
@@ -107,6 +108,7 @@ export const ogImageTemplates = {
categories,
score,
imageUrl,
verificationStatus,
}: {
title: string
description: string
@@ -116,6 +118,7 @@ export const ogImageTemplates = {
}[]
score: number
imageUrl: string | null
verificationStatus: VerificationStatus | null
},
context
) => {
@@ -272,6 +275,37 @@ export const ogImageTemplates = {
>
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg>
{verificationStatus === 'VERIFICATION_FAILED' && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
transform: 'rotate(-20deg)',
fontSize: 200,
fontWeight: 'bold',
color: 'red',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
boxShadow: '0 0 15px 30px rgba(0, 0, 0, 0.5)',
border: '15px solid red',
borderRadius: 15,
padding: '10px 50px',
textAlign: 'center',
}}
>
SCAM
</div>
</div>
)}
</div>
),
defaultOptions

View File

@@ -7,13 +7,21 @@ import { cn } from '../lib/cn'
import type { HTMLAttributes } from 'astro/types'
type Props = Omit<HTMLAttributes<'a'>, 'href' | 'rel' | 'target'> & {
type Props = Omit<HTMLAttributes<'a' | 'span'>, 'href' | 'rel' | 'target'> & {
url: string
referral: string | null
enableMinWidth?: boolean
isScam?: boolean
}
const { url: baseUrl, referral, class: className, enableMinWidth = false, ...htmlProps } = Astro.props
const {
url: baseUrl,
referral,
class: className,
enableMinWidth = false,
isScam = false,
...htmlProps
} = Astro.props
function makeLink(url: string, referral: string | null) {
const hostname = new URL(url).hostname
@@ -124,28 +132,39 @@ const link = makeLink(baseUrl, referral)
if (!z.string().url().safeParse(link.url).success) {
console.error(`Invalid service URL with referral: ${link.url}`)
}
const Tag = isScam ? 'span' : 'a'
---
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
<Tag
href={isScam ? undefined : link.url}
target={isScam ? undefined : '_blank'}
rel={isScam ? undefined : 'noopener noreferrer'}
class={cn(
'2xs:text-sm 2xs:h-8 2xs:gap-2 focus-visible:ring-offset-night-700 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black focus-visible:ring-4 focus-visible:ring-orange-500 focus-visible:ring-offset-2 focus-visible:outline-none',
isScam && 'bg-day-800 cursor-not-allowed text-red-300',
className
)}
title={link.url}
{...htmlProps}
>
<Icon name={link.icon} class="2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4" />
<Icon
name={isScam ? 'ri:alert-line' : link.icon}
class={cn('2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4', isScam && 'text-red-400')}
/>
<span class={cn('font-title font-bold', { 'min-w-[29ch]': enableMinWidth })}>
{
link.textBits.map((textBit) => (
<span class={cn(textBit.style === 'irrelevant' && 'text-zinc-500')}>{textBit.text}</span>
<span class={cn(textBit.style === 'irrelevant' && 'opacity-60')}>{textBit.text}</span>
))
}
</span>
<Icon
name="ri:arrow-right-line"
class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white"
/>
</a>
{
!isScam && (
<Icon
name="ri:arrow-right-line"
class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white"
/>
)
}
</Tag>

View File

@@ -0,0 +1,76 @@
import { countries as countriesData, type TCountryCode } from 'countries-list'
import { countries as flagCountries } from 'country-flag-icons'
import getUnicodeFlagIcon from 'country-flag-icons/unicode'
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
type CountryInfo<T extends string | null | undefined = string> = {
code: T
name: string
flag: string
slug: string
order: number
}
// Convert countries-list data to our format, ensuring we only use countries that have flags
const countriesArray = Object.entries(countriesData)
.filter(([code]) => flagCountries.includes(code as TCountryCode))
.map(([code, data]) => ({
code: code as TCountryCode,
name: data.name,
flag: getUnicodeFlagIcon(code) || '🏳️',
slug: code.toLowerCase(),
order: data.name.charCodeAt(0), // Sort alphabetically by first letter
}))
// Pre-sort the array alphabetically by name for performance
.sort((a, b) => a.name.localeCompare(b.name))
// Create a map for efficient lookups
const countriesMap = new Map(countriesArray.map((country) => [country.code, country]))
export const {
dataArray: countries,
dataObject: countriesById,
getFn: getCountryInfo,
getFnSlug: getCountryInfoBySlug,
zodEnumBySlug: countriesZodEnumBySlug,
zodEnumById: countriesZodEnumById,
keyToSlug: countryCodeToSlug,
slugToKey: countrySlugToCode,
} = makeHelpersForOptions(
'code',
(code): CountryInfo<typeof code> => {
// For null, undefined, or empty string, return a default "No Country" object
if (!code) {
return {
code,
name: 'No Country',
flag: '🏳️',
slug: '',
order: 999,
} as CountryInfo<typeof code>
}
// Try to find the country in our pre-built map
const country = countriesMap.get(code as TCountryCode)
// If found, return it; otherwise, return a default "Unknown Country" object
if (country) {
return country as CountryInfo
} else {
return {
code,
name: 'Unknown Country',
flag: '🏳️',
slug: code.toLowerCase(),
order: 999,
} as CountryInfo
}
},
countriesArray
)
// Helper function to validate country code
export const isValidCountryCode = (code: string): code is TCountryCode => {
return code in countriesData && flagCountries.includes(code as TCountryCode)
}

View File

@@ -1,8 +1,10 @@
import { differenceInMonths, differenceInYears } from 'date-fns'
import he from 'he'
import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { getCountryInfo } from '../constants/countries'
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
import { kycLevels } from '../constants/kycLevels'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
@@ -47,6 +49,8 @@ type NonDbAttributeFull = NonDbAttribute & {
kycLevel: true
kycLevelClarification: true
operatingSince: true
registrationCountryCode: true
registeredCompanyName: true
}
}>
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
@@ -306,6 +310,42 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
}
},
},
{
slug: 'legally-registered',
title: 'Legally registered',
type: 'INFO',
category: 'TRUST',
description: 'This service is legally registered as a company.',
privacyPoints: 0,
trustPoints: 2,
links: [],
customize: (service) => {
const countryCode = service.registrationCountryCode
const companyName = service.registeredCompanyName
if (!countryCode && !companyName) {
return { show: false }
}
const countryInfo = countryCode ? getCountryInfo(countryCode) : null
const flagTitle = countryInfo ? `${countryInfo.flag} Legally registered` : 'Legally registered'
let description = 'Legally registered.'
if (companyName && countryCode && countryInfo) {
description = `Legally registered as **${he.escape(companyName)}** in **${countryInfo.name}**.`
} else if (companyName) {
description = `Legally registered as **${he.escape(companyName)}**.`
} else if (countryCode && countryInfo) {
description = `Legally registered in **${countryInfo.name}**.`
}
return {
show: true,
title: flagTitle,
description,
}
},
},
]
export function sortAttributes<

View File

@@ -25,6 +25,7 @@ import UserBadge from '../../../../components/UserBadge.astro'
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
import { countries } from '../../../../constants/countries'
import { currencies } from '../../../../constants/currencies'
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
@@ -386,6 +387,36 @@ const apiCalls = await Astro.locals.banners.try(
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputText
label="Registered Company Name"
name="registeredCompanyName"
description="Official name of the registered company"
inputProps={{
value: service.registeredCompanyName,
placeholder: 'e.g. Example Corp Ltd.',
}}
error={serviceInputErrors.registeredCompanyName}
/>
<InputSelect
name="registrationCountryCode"
label="Company Registration Country"
description="Country where the service company is legally registered"
options={[
{ label: 'Not registered', value: '' },
...countries
.sort((a, b) => a.name.localeCompare(b.name))
.map((country) => ({
label: `${country.flag} ${country.name}`,
value: country.code,
}))
]}
selectedValue={service.registrationCountryCode || ''}
error={serviceInputErrors.registrationCountryCode}
/>
</div>
<div class="flex items-center justify-between gap-2">
<InputImageFile
label="Image"

View File

@@ -1,24 +1,57 @@
---
import { AttributeCategory, Currency, VerificationStatus } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { ServiceVisibility, VerificationStatus } from '@prisma/client'
import { actions, isInputError } from 'astro:actions'
import { orderBy } from 'lodash-es'
import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputCheckbox from '../../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../../components/InputCheckboxGroup.astro'
import InputImageFile from '../../../components/InputImageFile.astro'
import InputSelect from '../../../components/InputSelect.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../../constants/attributeTypes'
import { contactMethodUrlTypes } from '../../../constants/contactMethods'
import { countries } from '../../../constants/countries'
import { currencies } from '../../../constants/currencies'
import { kycLevelClarifications } from '../../../constants/kycLevelClarifications'
import { kycLevels } from '../../../constants/kycLevels'
import { serviceVisibilities } from '../../../constants/serviceVisibility'
import { verificationStatuses } from '../../../constants/verificationStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { prisma } from '../../../lib/prisma'
const categories = await Astro.locals.banners.try('Failed to fetch categories', () =>
prisma.category.findMany({
orderBy: { name: 'asc' },
})
)
const attributes = await Astro.locals.banners.try('Failed to fetch attributes', () =>
prisma.attribute.findMany({
orderBy: { category: 'asc' },
})
)
const [categories, attributes] = await Astro.locals.banners.tryMany([
[
'Failed to fetch categories',
() =>
prisma.category.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
icon: true,
},
}),
[],
],
[
'Failed to fetch attributes',
() =>
prisma.attribute.findMany({
orderBy: { category: 'asc' },
select: {
id: true,
title: true,
category: true,
type: true,
},
}),
[],
],
])
const result = Astro.getActionResult(actions.admin.service.create)
Astro.locals.banners.addIfSuccess(result, 'Service created successfully')
@@ -28,382 +61,287 @@ if (result && !result.error) {
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
---
<BaseLayout pageTitle="Create Service" widthClassName="max-w-screen-sm">
<section class="mb-8">
<div class="font-title mb-4">
<span class="text-sm text-green-500">service.create</span>
</div>
<BaseLayout
pageTitle="Create Service"
description="Create a new service for KYCnot.me"
widthClassName="max-w-screen-md"
>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Create Service</h1>
<form
method="POST"
action={actions.admin.service.create}
class="space-y-4 rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
enctype="multipart/form-data"
>
<div>
<label for="name" class="font-title mb-2 block text-sm text-green-500">name</label>
<input
transition:persist
type="text"
name="name"
id="name"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
inputErrors.name && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.name.join(', ')}</p>
)
}
</div>
<form
method="POST"
action={actions.admin.service.create}
enctype="multipart/form-data"
class="space-y-6"
>
<InputText
label="Name"
name="name"
inputProps={{
required: true,
maxlength: 40,
}}
error={inputErrors.name}
/>
<div>
<label for="description" class="font-title mb-2 block text-sm text-green-500">description</label>
<textarea
transition:persist
name="description"
id="description"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
set:text=""
/>
{
inputErrors.description && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.description.join(', ')}</p>
)
}
</div>
<InputTextArea
label="Description"
name="description"
inputProps={{
required: true,
}}
error={inputErrors.description}
/>
<div>
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="allServiceUrls"
id="allServiceUrls"
rows={3}
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
set:text=""
/>
{
inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
)
}
</div>
<div>
<label for="tosUrls" class="font-title mb-2 block text-sm text-green-500">tosUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="tosUrls"
id="tosUrls"
rows={3}
placeholder="https://example1.com/tos https://example2.com/tos"
set:text=""
/>
{
inputErrors.tosUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.tosUrls.join(', ')}</p>
)
}
</div>
<div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2">
<input
transition:persist
type="file"
name="imageFile"
id="imageFile"
accept="image/*"
required
class="font-title file:font-title block w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 file:mr-3 file:rounded-md file:border-0 file:bg-green-500/30 file:px-3 file:py-1 file:text-gray-300 focus:border-green-500 focus:ring-green-500"
/>
<p class="font-title text-xs text-gray-400">
Upload a square image for best results. Supported formats: JPG, PNG, WebP, SVG.
</p>
</div>
{
inputErrors.imageFile && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.imageFile.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="categories">categories</label>
<div class="mt-2 grid grid-cols-2 gap-2">
{
categories?.map((category) => (
<label class="inline-flex items-center">
<input
transition:persist
type="checkbox"
name="categories"
value={category.id}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title ml-2 flex items-center gap-2 text-gray-300">
<Icon class="h-3 w-3 text-green-500" name={category.icon} />
{category.name}
</span>
</label>
))
}
</div>
{
inputErrors.categories && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.categories.join(', ')}</p>
)
}
</div>
<div>
<label for="kycLevel" class="font-title mb-2 block text-sm text-green-500">kycLevel</label>
<input
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
type="number"
name="kycLevel"
id="kycLevel"
min={0}
max={4}
value={4}
required
/>
{
inputErrors.kycLevel && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.kycLevel.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="attributes">attributes</label>
<div class="space-y-4">
{
Object.values(AttributeCategory).map((category) => (
<div class="rounded-md border border-green-500/20 bg-black/30 p-4">
<h4 class="font-title mb-3 text-green-400">{category}</h4>
<div class="grid grid-cols-1 gap-2">
{attributes
?.filter((attr) => attr.category === category)
.map((attr) => (
<label class="inline-flex items-center">
<input
transition:persist
type="checkbox"
name="attributes"
value={attr.id}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title ml-2 flex items-center gap-2 text-gray-300">
{attr.title}
<span
class={cn('font-title rounded-sm px-1.5 py-0.5 text-xs', {
'border border-green-500/50 bg-green-500/20 text-green-400':
attr.type === 'GOOD',
'border border-red-500/50 bg-red-500/20 text-red-400': attr.type === 'BAD',
'border border-yellow-500/50 bg-yellow-500/20 text-yellow-400':
attr.type === 'WARNING',
'border border-blue-500/50 bg-blue-500/20 text-blue-400': attr.type === 'INFO',
})}
>
{attr.type}
</span>
</span>
</label>
))}
</div>
{inputErrors.attributes && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.attributes.join(', ')}</p>
)}
</div>
))
}
</div>
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verificationStatus"
>verificationStatus</label
>
<select
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
name="verificationStatus"
id="verificationStatus"
required
>
{Object.values(VerificationStatus).map((status) => <option value={status}>{status}</option>)}
</select>
{
inputErrors.verificationStatus && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationStatus.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verificationSummary"
>verificationSummary</label
>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="verificationSummary"
id="verificationSummary"
rows={3}
set:text=""
/>
{
inputErrors.verificationSummary && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationSummary.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verificationProofMd"
>verificationProofMd</label
>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="verificationProofMd"
id="verificationProofMd"
rows={10}
set:text=""
/>
{
inputErrors.verificationProofMd && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationProofMd.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="acceptedCurrencies"
>acceptedCurrencies</label
>
<div class="mt-2 grid grid-cols-2 gap-2">
{
Object.values(Currency).map((currency) => (
<label class="inline-flex items-center">
<input
transition:persist
type="checkbox"
name="acceptedCurrencies"
value={currency}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title ml-2 text-gray-300">{currency}</span>
</label>
))
}
</div>
{
inputErrors.acceptedCurrencies && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.acceptedCurrencies.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="overallScore">overallScore</label>
<input
transition:persist
type="number"
name="overallScore"
id="overallScore"
value={0}
min={0}
max={10}
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
/>
{
inputErrors.overallScore && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.overallScore.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral link path</label>
<input
transition:persist
type="text"
name="referral"
id="referral"
placeholder="e.g. ?ref=123 or /ref/123"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
inputErrors.referral && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.referral.join(', ')}</p>
)
}
</div>
<div>
<label for="internalNote" class="font-title mb-2 block text-sm text-green-500">internalNote</label>
<div class="space-y-2">
<textarea
transition:persist
name="internalNote"
id="internalNote"
rows={4}
placeholder="Markdown supported"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
set:text=""
/>
</div>
{
inputErrors.internalNote && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.internalNote.join(', ')}</p>
)
}
</div>
<InputCheckbox
label="Strict Commenting"
name="strictCommentingEnabled"
checked={false}
descriptionInline="Require proof of being a client for comments."
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="Service URLs"
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
placeholder: 'example1.com\nexample2.onion\nexample3.b32.i2p',
class: 'md:min-h-20 min-h-24 h-full',
required: true,
}}
class="flex flex-col self-stretch"
error={inputErrors.allServiceUrls}
/>
<div>
<label for="commentSectionMessage" class="font-title mb-2 block text-sm text-green-500"
>Comment Section Message</label
>
<div class="space-y-2">
<textarea
transition:persist
name="commentSectionMessage"
id="commentSectionMessage"
rows={4}
placeholder="Markdown supported. This message will be displayed in the comment section for root comments."
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
set:text=""
/>
</div>
{
inputErrors.commentSectionMessage && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.commentSectionMessage.join(', ')}</p>
)
}
</div>
<InputTextArea
label="Contact Methods"
description={[
'One per line.',
`Accepts: ${contactMethodUrlTypes.map((type: any) => type.labelPlural).join(', ')}`,
].join('\n')}
name="contactMethods"
inputProps={{
placeholder: 'contact@example.com\nt.me/example\n+123 456 7890',
class: 'h-full',
}}
class="flex flex-col self-stretch"
error={inputErrors.contactMethods}
/>
</div>
<button
type="submit"
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
Create Service
</button>
</form>
</section>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="ToS URLs"
description="One per line. AI review uses the first working URL only."
name="tosUrls"
inputProps={{
placeholder: 'example.com/tos',
required: true,
class: 'min-h-10',
}}
error={inputErrors.tosUrls}
/>
<InputText
label="Operating since"
name="operatingSince"
inputProps={{
type: 'date',
max: new Date().toISOString().slice(0, 10),
}}
error={inputErrors.operatingSince}
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputText
label="Registered Company Name"
name="registeredCompanyName"
description="Official name of the registered company (optional)"
inputProps={{
placeholder: 'e.g. Example Corp Ltd.',
}}
/>
<InputSelect
name="registrationCountryCode"
label="Company Registration Country"
description="Country where the service company is legally registered (optional)"
options={[
{ label: 'Not registered', value: '' },
...countries
.sort((a: any, b: any) => a.name.localeCompare(b.name))
.map((country: any) => ({
label: `${country.flag} ${country.name}`,
value: country.code,
}))
]}
selectedValue=""
/>
</div>
<InputCardGroup
name="kycLevel"
label="KYC Level"
options={kycLevels.map((kycLevel: any) => ({
label: kycLevel.name,
value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/4_`,
}))}
iconSize="md"
cardSize="md"
required
error={inputErrors.kycLevel}
/>
<InputCardGroup
name="kycLevelClarification"
label="KYC Level Clarification"
options={kycLevelClarifications.map((clarification: any) => ({
label: clarification.label,
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
}))}
selectedValue="NONE"
iconSize="sm"
cardSize="sm"
error={inputErrors.kycLevelClarification}
/>
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
<InputCheckboxGroup
name="categories"
label="Categories"
required
options={categories.map((category: any) => ({
label: category.name,
value: category.id.toString(),
icon: category.icon,
}))}
size="lg"
error={inputErrors.categories}
class="min-w-auto"
/>
<InputCheckboxGroup
name="attributes"
label="Attributes"
options={orderBy(
attributes.map((attribute: any) => ({
...attribute,
categoryInfo: getAttributeCategoryInfo(attribute.category),
typeInfo: getAttributeTypeInfo(attribute.type),
})),
['categoryInfo.order', 'typeInfo.order']
).map((attribute: any) => ({
label: attribute.title,
value: attribute.id.toString(),
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}))}
description="See list of [all attributes](/attributes) and their scoring."
error={inputErrors.attributes}
size="lg"
/>
</div>
<InputCardGroup
name="acceptedCurrencies"
label="Accepted Currencies"
options={currencies.map((currency: any) => ({
label: currency.name,
value: currency.id,
icon: currency.icon,
}))}
error={inputErrors.acceptedCurrencies}
multiple
/>
<InputImageFile
label="Service Image"
name="imageFile"
description="Square image. At least 192x192px. Transparency supported."
error={inputErrors.imageFile}
square
required
/>
<InputSelect
name="verificationStatus"
label="Verification Status"
options={Object.values(VerificationStatus).map((status) => ({
label: status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l: string) => l.toUpperCase()),
value: status,
}))}
error={inputErrors.verificationStatus}
/>
<InputTextArea
label="Verification Summary"
name="verificationSummary"
error={inputErrors.verificationSummary}
/>
<InputTextArea
label="Verification Proof (Markdown)"
name="verificationProofMd"
inputProps={{
rows: 10,
}}
error={inputErrors.verificationProofMd}
/>
<InputText
label="Referral Link Path"
name="referral"
inputProps={{
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
error={inputErrors.referral}
description="Will be appended to the service URL"
/>
<InputCardGroup
name="serviceVisibility"
label="Service Visibility"
options={serviceVisibilities.map((visibility: any) => ({
label: visibility.label,
value: visibility.value,
icon: visibility.icon,
iconClass: visibility.iconClass,
description: visibility.description,
}))}
selectedValue="PUBLIC"
error={inputErrors.serviceVisibility}
cardSize="sm"
/>
<InputTextArea
label="Internal Note"
name="internalNote"
description="Markdown supported. Internal notes for admins."
inputProps={{
rows: 4,
}}
error={inputErrors.internalNote}
/>
<InputCheckbox
label="Strict Commenting"
name="strictCommentingEnabled"
checked={false}
descriptionInline="Require proof of being a client for comments."
error={inputErrors.strictCommentingEnabled}
/>
<InputTextArea
label="Comment Section Message"
name="commentSectionMessage"
description="Markdown supported. This message will be displayed in the comment section for root comments."
inputProps={{
rows: 4,
}}
error={inputErrors.commentSectionMessage}
/>
<InputSubmitButton label="Create Service" icon="ri:add-line" hideCancel />
</form>
</FormSection>
</div>
</BaseLayout>

View File

@@ -15,12 +15,14 @@ import InputCheckbox from '../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputImageFile from '../../components/InputImageFile.astro'
import InputSelect from '../../components/InputSelect.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { contactMethodUrlTypes } from '../../constants/contactMethods'
import { countries } from '../../constants/countries'
import { currencies } from '../../constants/currencies'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
import { kycLevels } from '../../constants/kycLevels'
@@ -262,6 +264,33 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputText
label="Registered Company Name"
name="registeredCompanyName"
description="Official name of the registered company (optional)"
inputProps={{
placeholder: 'e.g. Example Corp Ltd.',
}}
/>
<InputSelect
name="registrationCountryCode"
label="Company Registration Country"
description="Country where the service company is legally registered (optional)"
options={[
{ label: 'Not registered', value: '' },
...countries
.sort((a, b) => a.name.localeCompare(b.name))
.map((country) => ({
label: `${country.flag} ${country.name}`,
value: country.code,
}))
]}
selectedValue=""
/>
</div>
<InputCardGroup
name="kycLevel"
label="KYC Level"

View File

@@ -96,6 +96,8 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
createdAt: true,
acceptedCurrencies: true,
operatingSince: true,
registrationCountryCode: true,
registeredCompanyName: true,
tosReview: true,
tosReviewAt: true,
userSentiment: true,
@@ -296,8 +298,6 @@ const statusIcon = {
APPROVED: undefined,
}[service.verificationStatus]
const isScam = service.verificationStatus === 'VERIFICATION_FAILED'
const shuffledLinks = {
clearnet: shuffle(service.serviceUrls),
onion: shuffle(service.onionUrls),
@@ -414,6 +414,7 @@ const ogImageTemplateData = {
categories: service.categories.map((category) => pick(category, ['name', 'icon'])),
score: service.overallScore,
imageUrl: service.imageUrl,
verificationStatus: service.verificationStatus,
} satisfies OgImageAllTemplatesWithProps
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
@@ -791,18 +792,12 @@ const sortedVerificationSteps = orderBy(
<ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2">
{shownLinks.map((url) => (
<li>
{isScam ? (
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
<Icon name="ri:alert-line" class="size-4 text-red-400" />
{urlDomain(url)}
</span>
) : (
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
isScam={service.verificationStatus === 'VERIFICATION_FAILED'}
/>
</li>
))}
@@ -826,18 +821,12 @@ const sortedVerificationSteps = orderBy(
{hiddenLinks.map((url) => (
<li class="hidden peer-checked:block">
{isScam ? (
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
<Icon name="ri:alert-line" class="size-4 text-red-400" />
{urlDomain(url)}
</span>
) : (
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
isScam={service.verificationStatus === 'VERIFICATION_FAILED'}
/>
</li>
))}
</ul>