Compare commits
10 Commits
release-97
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9285d952a5 | ||
|
|
fd5c7ab475 | ||
|
|
9a78a9b377 | ||
|
|
9e0193fc3c | ||
|
|
a68523fc73 | ||
|
|
a465849a76 | ||
|
|
25f6dba3eb | ||
|
|
7e7046e7d2 | ||
|
|
a5d1fb9a5d | ||
|
|
28b84a7d9b |
@@ -1 +1 @@
|
||||
23
|
||||
24
|
||||
|
||||
@@ -107,7 +107,8 @@ export default defineConfig({
|
||||
SITE_URL === 'http://localhost:4321'
|
||||
? "frame-ancestors 'none'; upgrade-insecure-requests"
|
||||
: "default-src 'self'; img-src 'self' *; frame-ancestors 'none'; upgrade-insecure-requests",
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubdomains; preload;',
|
||||
'Strict-Transport-Security':
|
||||
SITE_URL === 'http://localhost:4321' ? undefined : 'max-age=31536000; includeSubdomains; preload;',
|
||||
},
|
||||
},
|
||||
image: {
|
||||
|
||||
601
web/package-lock.json
generated
601
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,17 +33,19 @@
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
"@fontsource/inter": "5.2.6",
|
||||
"@fontsource/space-grotesk": "5.2.8",
|
||||
"@prisma/client": "6.10.1",
|
||||
"@prisma/client": "6.11.1",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.11.0",
|
||||
"@vercel/og": "0.7.2",
|
||||
"astro": "5.9.0",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"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",
|
||||
@@ -54,7 +56,7 @@
|
||||
"pg": "8.16.3",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.5.6",
|
||||
"redis": "5.6.0",
|
||||
"schema-dts": "1.1.5",
|
||||
"seedrandom": "3.0.5",
|
||||
"sharp": "0.34.2",
|
||||
@@ -69,7 +71,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.1",
|
||||
"@faker-js/faker": "9.9.0",
|
||||
"@iconify-json/material-symbols": "1.2.28",
|
||||
"@iconify-json/material-symbols": "1.2.29",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
"@stylistic/eslint-plugin": "5.1.0",
|
||||
@@ -82,12 +84,12 @@
|
||||
"@types/react": "19.1.8",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
"@typescript-eslint/parser": "8.36.0",
|
||||
"@vite-pwa/assets-generator": "1.0.0",
|
||||
"@vite-pwa/astro": "1.1.0",
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
"esbuild": "0.25.5",
|
||||
"esbuild": "0.25.6",
|
||||
"eslint": "9.30.1",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
@@ -97,13 +99,13 @@
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.13",
|
||||
"prisma": "6.10.1",
|
||||
"prisma-json-types-generator": "3.5.0",
|
||||
"prisma": "6.11.1",
|
||||
"prisma-json-types-generator": "3.5.1",
|
||||
"tailwind-htmx": "0.1.2",
|
||||
"ts-essentials": "10.1.1",
|
||||
"ts-toolbelt": "9.6.0",
|
||||
"tsx": "4.20.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"typescript-eslint": "8.36.0",
|
||||
"vite-plugin-devtools-json": "0.2.1",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "commentSectionMessage" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "registrationCountryCode" VARCHAR(2);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "registeredCompanyName" TEXT;
|
||||
@@ -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")
|
||||
@@ -407,6 +411,7 @@ model Service {
|
||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||
|
||||
strictCommentingEnabled Boolean @default(false)
|
||||
commentSectionMessage String?
|
||||
|
||||
@@index([listedAt])
|
||||
@@index([approvedAt])
|
||||
|
||||
@@ -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 })
|
||||
@@ -721,6 +728,7 @@ const generateFakeService = (users: User[]) => {
|
||||
{ probability: 0.33 }
|
||||
),
|
||||
strictCommentingEnabled: faker.datatype.boolean(0.33333),
|
||||
commentSectionMessage: faker.helpers.maybe(() => faker.lorem.paragraph(), { probability: 0.3 }),
|
||||
} as const satisfies Prisma.ServiceCreateInput
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,11 +62,19 @@ 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),
|
||||
commentSectionMessage: z.string().trim().min(3).max(1000).optional().nullable().default(null),
|
||||
})
|
||||
|
||||
// Define schema for the create action input
|
||||
@@ -129,11 +140,11 @@ export const adminServiceActions = {
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||
commentSectionMessage: input.commentSectionMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
@@ -144,6 +155,11 @@ export const adminServiceActions = {
|
||||
},
|
||||
})),
|
||||
},
|
||||
contactMethods: {
|
||||
create: input.contactMethods.map((value) => ({
|
||||
value,
|
||||
})),
|
||||
},
|
||||
imageUrl,
|
||||
internalNotes: input.internalNote
|
||||
? {
|
||||
@@ -154,6 +170,8 @@ export const adminServiceActions = {
|
||||
}
|
||||
: undefined,
|
||||
operatingSince: input.operatingSince,
|
||||
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||
registeredCompanyName: input.registeredCompanyName,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -250,11 +268,11 @@ export const adminServiceActions = {
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||
commentSectionMessage: input.commentSectionMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
previousSlugs:
|
||||
existingService.slug !== input.slug
|
||||
? {
|
||||
@@ -280,6 +298,8 @@ export const adminServiceActions = {
|
||||
})),
|
||||
},
|
||||
operatingSince: input.operatingSince,
|
||||
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||
registeredCompanyName: input.registeredCompanyName,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -340,7 +360,6 @@ export const adminServiceActions = {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -466,7 +485,6 @@ export const adminServiceActions = {
|
||||
input: evidenceImageDeleteSchema,
|
||||
handler: async (input) => {
|
||||
await deleteFileLocally(input.fileUrl)
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { CommentStatus, Prisma } from '@prisma/client'
|
||||
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
|
||||
const MAX_COMMENTS_PER_WINDOW = 1
|
||||
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
|
||||
export const COMMENT_ORDER_ID_MAX_LENGTH = 600
|
||||
|
||||
export const commentActions = {
|
||||
vote: defineProtectedAction({
|
||||
@@ -103,7 +104,7 @@ export const commentActions = {
|
||||
issueFundsBlocked: z.coerce.boolean().optional(),
|
||||
issueScam: z.coerce.boolean().optional(),
|
||||
issueDetails: z.string().max(120).optional(),
|
||||
orderId: z.string().max(100).optional(),
|
||||
orderId: z.string().max(COMMENT_ORDER_ID_MAX_LENGTH).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.rating && data.parentId) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,7 @@ const Tag = announcement.link ? 'a' : 'div'
|
||||
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
|
||||
className
|
||||
)}
|
||||
aria-label="Announcement banner"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import { COMMENT_ORDER_ID_MAX_LENGTH } from '../actions/comment'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||
|
||||
@@ -21,6 +23,7 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
||||
parentId?: number
|
||||
commentId?: number
|
||||
strictCommentingEnabled?: boolean
|
||||
commentSectionMessage?: string | null
|
||||
activeRatingComment?: Prisma.CommentGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
@@ -35,6 +38,7 @@ const {
|
||||
commentId,
|
||||
activeRatingComment,
|
||||
strictCommentingEnabled,
|
||||
commentSectionMessage,
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
@@ -97,70 +101,83 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
||||
</div>
|
||||
|
||||
{!parentId ? (
|
||||
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<InputRating name="rating" label="Rating" />
|
||||
<>
|
||||
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<InputRating name="rating" label="Rating" />
|
||||
|
||||
<InputWrapper label="I experienced..." name="tags">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
<Icon name="ri:user-forbid-fill" class="size-3" />
|
||||
KYC Issue
|
||||
</span>
|
||||
</label>
|
||||
<InputWrapper label="I experienced..." name="tags">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
<Icon name="ri:user-forbid-fill" class="size-3" />
|
||||
KYC Issue
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-orange-400">
|
||||
<Icon name="ri:wallet-3-fill" class="size-3" />
|
||||
Funds Blocked
|
||||
</span>
|
||||
</label>
|
||||
</InputWrapper>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-orange-400">
|
||||
<Icon name="ri:wallet-3-fill" class="size-3" />
|
||||
Funds Blocked
|
||||
</span>
|
||||
</label>
|
||||
</InputWrapper>
|
||||
|
||||
<InputText
|
||||
label="Order ID"
|
||||
name="orderId"
|
||||
inputProps={{
|
||||
maxlength: 100,
|
||||
placeholder: 'Order ID / URL / Proof',
|
||||
class: 'bg-night-800',
|
||||
required: strictCommentingEnabled,
|
||||
}}
|
||||
descriptionLabel="Only visible to admins, to verify your comment"
|
||||
class="grow"
|
||||
/>
|
||||
</div>
|
||||
<InputText
|
||||
label="Order ID"
|
||||
name="orderId"
|
||||
inputProps={{
|
||||
maxlength: COMMENT_ORDER_ID_MAX_LENGTH,
|
||||
placeholder: 'Order ID / URL / Proof',
|
||||
class: 'bg-night-800',
|
||||
required: strictCommentingEnabled,
|
||||
}}
|
||||
descriptionLabel="Only visible to admins, to verify your comment"
|
||||
class="grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-start justify-end gap-2">
|
||||
{!!activeRatingComment?.rating && (
|
||||
<div
|
||||
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
||||
data-show-if-rating
|
||||
>
|
||||
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
|
||||
<a
|
||||
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<div class="mt-4 flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
|
||||
{!!activeRatingComment?.rating && (
|
||||
<div
|
||||
class="mt-1 rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
||||
data-show-if-rating
|
||||
>
|
||||
Your previous rating
|
||||
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
||||
</a>
|
||||
of
|
||||
{[
|
||||
activeRatingComment.rating.toLocaleString(),
|
||||
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
|
||||
]}
|
||||
won't count for the total.
|
||||
</div>
|
||||
)}
|
||||
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
|
||||
<a
|
||||
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Your previous rating
|
||||
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
||||
</a>
|
||||
of
|
||||
{[
|
||||
activeRatingComment.rating.toLocaleString(),
|
||||
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
|
||||
]}
|
||||
won't count for the total.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
|
||||
<div class="flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
|
||||
{!!commentSectionMessage && (
|
||||
<div class="flex items-start gap-1 pt-1.5">
|
||||
<Icon name="ri:information-line" class="mt-1.25 inline size-3.5" />
|
||||
<div class="prose prose-invert prose-sm text-day-200 max-w-none grow">
|
||||
<Markdown content={commentSectionMessage} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button type="submit" label="Reply" icon="ri:reply-line" />
|
||||
|
||||
@@ -36,6 +36,7 @@ type Props = {
|
||||
description: true
|
||||
createdAt: true
|
||||
strictCommentingEnabled: true
|
||||
commentSectionMessage: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
@@ -178,6 +179,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
||||
serviceId={service.id}
|
||||
activeRatingComment={activeRatingComment}
|
||||
strictCommentingEnabled={service.strictCommentingEnabled}
|
||||
commentSectionMessage={service.commentSectionMessage}
|
||||
class="xs:mb-4 mb-2"
|
||||
/>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
||||
}
|
||||
)}
|
||||
transition:name="header-container"
|
||||
aria-label="Header"
|
||||
>
|
||||
<nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}>
|
||||
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ import Tooltip from './Tooltip.astro'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'a'> & {
|
||||
type Props = HTMLAttributes<'article'> & {
|
||||
inlineIcons?: boolean
|
||||
withoutLink?: boolean
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
@@ -57,7 +57,7 @@ const {
|
||||
},
|
||||
class: className,
|
||||
withoutLink = false,
|
||||
...aProps
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const statusIcon = {
|
||||
@@ -70,127 +70,129 @@ const Element = withoutLink ? 'div' : 'a'
|
||||
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
---
|
||||
|
||||
<Element
|
||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||
{...aProps}
|
||||
class={cn(
|
||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<!-- Header with Icon and Title -->
|
||||
<div class="flex items-center gap-(--gap)">
|
||||
<MyPicture
|
||||
src={imageUrl}
|
||||
fallback="service"
|
||||
alt={name || 'Service logo'}
|
||||
class={cn(
|
||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||
)}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<article {...htmlProps}>
|
||||
<Element
|
||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||
aria-label={Element === 'a' ? name : undefined}
|
||||
class={cn(
|
||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<!-- Header with Icon and Title -->
|
||||
<div class="flex items-center gap-(--gap)">
|
||||
<MyPicture
|
||||
src={imageUrl}
|
||||
fallback="service"
|
||||
alt="Logo"
|
||||
class={cn(
|
||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||
)}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
||||
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||
{name}{
|
||||
statusIcon && (
|
||||
<Tooltip
|
||||
text={statusIcon.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
||||
>
|
||||
{[
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
||||
<h1 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||
{name}{
|
||||
statusIcon && (
|
||||
<Tooltip
|
||||
text={statusIcon.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
||||
>
|
||||
{[
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={statusIcon.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
||||
statusIcon.classNames.icon
|
||||
)}
|
||||
/>,
|
||||
verificationStatus === 'VERIFICATION_FAILED' && (
|
||||
<span class="text-sm font-bold text-red-500">SCAM</span>
|
||||
),
|
||||
]}
|
||||
</Tooltip>
|
||||
)
|
||||
}{
|
||||
serviceVisibility === 'ARCHIVED' && (
|
||||
<Tooltip
|
||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={statusIcon.icon}
|
||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
||||
statusIcon.classNames.icon
|
||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||
)}
|
||||
/>,
|
||||
verificationStatus === 'VERIFICATION_FAILED' && (
|
||||
<span class="text-sm font-bold text-red-500">SCAM</span>
|
||||
),
|
||||
]}
|
||||
</Tooltip>
|
||||
)
|
||||
}{
|
||||
serviceVisibility === 'ARCHIVED' && (
|
||||
<Tooltip
|
||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h3>
|
||||
<div class="max-h-2 flex-1"></div>
|
||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
<div class="max-h-2 flex-1" aria-hidden="true"></div>
|
||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
||||
{
|
||||
categories.map((category) => (
|
||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||
<span>{category.name}</span>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start">
|
||||
<Tooltip
|
||||
class={cn(
|
||||
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
|
||||
overallScoreInfo.classNameBg
|
||||
)}
|
||||
text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
|
||||
>
|
||||
{overallScoreInfo.formattedScore}
|
||||
</Tooltip>
|
||||
|
||||
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
||||
KYC {kycLevel.toLocaleString()}
|
||||
</span>
|
||||
|
||||
<div class="-m-1 ml-auto flex">
|
||||
{
|
||||
categories.map((category) => (
|
||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||
<span>{category.name}</span>
|
||||
</span>
|
||||
))
|
||||
currencies.map((currency) => {
|
||||
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||
|
||||
return (
|
||||
<Tooltip text={currency.name}>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={currency.icon}
|
||||
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start">
|
||||
<Tooltip
|
||||
class={cn(
|
||||
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
|
||||
overallScoreInfo.classNameBg
|
||||
)}
|
||||
text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
|
||||
>
|
||||
{overallScoreInfo.formattedScore}
|
||||
</Tooltip>
|
||||
|
||||
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
||||
KYC {kycLevel.toLocaleString()}
|
||||
</span>
|
||||
|
||||
<div class="-m-1 ml-auto flex">
|
||||
{
|
||||
currencies.map((currency) => {
|
||||
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||
|
||||
return (
|
||||
<Tooltip text={currency.name}>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={currency.icon}
|
||||
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Element>
|
||||
</Element>
|
||||
</article>
|
||||
|
||||
@@ -39,6 +39,7 @@ const makeUrlWithoutFilter = (filter: string, value?: string) => {
|
||||
'bg-night-800 hover:bg-night-900 border-night-400 flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm text-white',
|
||||
className
|
||||
)}
|
||||
aria-label={`Remove filter: ${text}`}
|
||||
>
|
||||
{icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
|
||||
{text}
|
||||
|
||||
@@ -32,6 +32,7 @@ type Props = HTMLAttributes<'div'> & {
|
||||
}
|
||||
}>[]
|
||||
attributeOptions: AttributeOption[]
|
||||
inlineIcons?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -41,6 +42,7 @@ const {
|
||||
categories,
|
||||
attributes,
|
||||
attributeOptions,
|
||||
inlineIcons = true,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
---
|
||||
@@ -50,11 +52,17 @@ const {
|
||||
'not-pointer-coarse:no-scrollbar -ml-4 flex shrink grow items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4',
|
||||
className
|
||||
)}
|
||||
aria-label="Applied filters"
|
||||
{...divProps}
|
||||
>
|
||||
{
|
||||
filters.q && (
|
||||
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
|
||||
<ServiceFiltersPill
|
||||
text={`"${filters.q}"`}
|
||||
searchParamName="q"
|
||||
searchParamValue={filters.q}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,6 +77,7 @@ const {
|
||||
icon={category.icon}
|
||||
searchParamName="categories"
|
||||
searchParamValue={categorySlug}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -83,6 +92,7 @@ const {
|
||||
searchParamName="currencies"
|
||||
searchParamValue={currency.slug}
|
||||
icon={currency.icon}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -97,6 +107,7 @@ const {
|
||||
icon={networkOption.icon}
|
||||
searchParamName="networks"
|
||||
searchParamValue={network}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -107,6 +118,7 @@ const {
|
||||
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
||||
icon="ri:shield-keyhole-line"
|
||||
searchParamName="max-kyc"
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -116,6 +128,7 @@ const {
|
||||
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
||||
icon="ri:star-fill"
|
||||
searchParamName="user-rating"
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -125,6 +138,7 @@ const {
|
||||
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
||||
icon="ri:medal-line"
|
||||
searchParamName="min-score"
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -135,6 +149,7 @@ const {
|
||||
icon="ri:filter-3-line"
|
||||
searchParamName="attribute-mode"
|
||||
searchParamValue="and"
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -152,6 +167,7 @@ const {
|
||||
text={`${prefix}: ${attribute.title}`}
|
||||
searchParamName={`attr-${attributeId}`}
|
||||
searchParamValue={attributeValue}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -176,6 +192,7 @@ const {
|
||||
iconClass={verificationStatusInfo.classNames.icon}
|
||||
searchParamName="verification"
|
||||
searchParamValue={verificationStatusInfo.slug}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -414,7 +414,10 @@ const {
|
||||
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<span
|
||||
class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title={attribute.title}
|
||||
>
|
||||
{attribute.title}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
|
||||
@@ -429,7 +432,10 @@ const {
|
||||
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<span
|
||||
class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title={attribute.title}
|
||||
>
|
||||
{attribute.title}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Icon } from 'astro-icon/components'
|
||||
import { uniq, orderBy } from 'lodash-es'
|
||||
|
||||
import { getCurrencyInfo } from '../constants/currencies'
|
||||
import { getKycLevelInfo } from '../constants/kycLevels'
|
||||
import { verificationStatusesByValue, getVerificationStatusInfo } from '../constants/verificationStatus'
|
||||
import { areEqualArraysWithoutOrder } from '../lib/arrays'
|
||||
import { cn } from '../lib/cn'
|
||||
@@ -160,8 +159,7 @@ const searchTitle = (() => {
|
||||
}
|
||||
|
||||
if (filters['max-kyc'] === 0) {
|
||||
const kycLevelInfo = getKycLevelInfo(String(filters['max-kyc']))
|
||||
kycLevel = `with ${kycLevelInfo.name}`
|
||||
kycLevel = 'without KYC'
|
||||
prefix = ''
|
||||
} else if (filters['max-kyc'] <= 3) {
|
||||
kycLevel = `with KYC level ${filters['max-kyc']} or better`
|
||||
@@ -192,6 +190,7 @@ const searchTitle = (() => {
|
||||
categories={categories}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -203,6 +202,7 @@ const searchTitle = (() => {
|
||||
name="ri:loader-4-line"
|
||||
id="search-indicator"
|
||||
class="htmx-request:opacity-100 xs:-mx-1.5 -mx-1 inline-block size-4 animate-spin text-white opacity-0 transition-opacity duration-500 sm:-mx-3"
|
||||
aria-hidden="true"
|
||||
is:inline={inlineIcons}
|
||||
/>
|
||||
|
||||
@@ -348,11 +348,9 @@ const searchTitle = (() => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
|
||||
<ol class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
|
||||
{services.map((service, i) => (
|
||||
<ServiceCard
|
||||
inlineIcons={inlineIcons}
|
||||
service={service}
|
||||
<li
|
||||
data-hx-search-results-card
|
||||
{...(i === services.length - 1 && currentPage < totalPages
|
||||
? {
|
||||
@@ -363,9 +361,11 @@ const searchTitle = (() => {
|
||||
'hx-indicator': '#infinite-scroll-indicator',
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
>
|
||||
<ServiceCard inlineIcons={inlineIcons} service={service} />
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ol>
|
||||
|
||||
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
|
||||
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
|
||||
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
enabled && (
|
||||
<span
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
class={cn(
|
||||
'pointer-events-none hidden select-none group-hover/tooltip:flex',
|
||||
'ease-out-cubic scale-75 opacity-0 transition-all transition-discrete duration-100 group-hover/tooltip:scale-100 group-hover/tooltip:opacity-100 starting:group-hover/tooltip:scale-75 starting:group-hover/tooltip:opacity-0',
|
||||
|
||||
76
web/src/constants/countries.ts
Normal file
76
web/src/constants/countries.ts
Normal 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)
|
||||
}
|
||||
@@ -58,9 +58,13 @@ const ogImageTemplateData = {
|
||||
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
|
||||
>
|
||||
<h1 class="mb-0!">{frontmatter.title}</h1>
|
||||
<p class="mt-2! opacity-70">
|
||||
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />}
|
||||
</p>
|
||||
{
|
||||
!!frontmatter.updatedAt && (
|
||||
<p class="mt-2! opacity-70">
|
||||
Updated <TimeFormatted date={new Date(frontmatter.updatedAt)} />
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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"
|
||||
@@ -553,6 +584,17 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
descriptionInline="Require proof of being a client for comments."
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Comment Section Message"
|
||||
name="commentSectionMessage"
|
||||
value={service.commentSectionMessage ?? ''}
|
||||
description="Markdown supported"
|
||||
inputProps={{
|
||||
rows: 4,
|
||||
}}
|
||||
error={serviceInputErrors.commentSectionMessage}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
|
||||
</form>
|
||||
</FormSection>
|
||||
|
||||
@@ -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,360 +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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -9,11 +9,15 @@ icon: 'ri:image-line'
|
||||
|
||||
import PressAssets from '../components/PressAssets.astro'
|
||||
|
||||
## How you can use our logo?
|
||||
|
||||
**You are not allowed to list us as partners**. We don't accept partnerships. You should make sure it's clear that you're adding our logo **voluntarily**. Examples of correct use: "Rate us on", "Listed on" sections.
|
||||
|
||||
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
|
||||
|
||||
<PressAssets />
|
||||
|
||||
Review service link format: `https://kycnot.me/service/[slug]/review`
|
||||
> You can link to a service's review section with: `https://kycnot.me/service/[slug]/review`
|
||||
|
||||
## Brand design
|
||||
|
||||
|
||||
@@ -467,6 +467,7 @@ const showFiltersId = 'show-filters'
|
||||
categories={categories}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
inlineIcons={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -492,6 +493,7 @@ const showFiltersId = 'show-filters'
|
||||
/>
|
||||
<div
|
||||
class="bg-night-700 fixed top-0 left-0 z-50 hidden h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 max-sm:peer-checked:block sm:relative sm:z-auto sm:block sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0"
|
||||
aria-label="Search filters"
|
||||
>
|
||||
<ServicesFilters
|
||||
searchResultsId={searchResultsId}
|
||||
@@ -519,6 +521,7 @@ const showFiltersId = 'show-filters'
|
||||
filtersOptions={filtersOptions}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
aria-label="Search results"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
@@ -103,6 +105,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
averageUserRating: true,
|
||||
isRecentlyApproved: true,
|
||||
strictCommentingEnabled: true,
|
||||
commentSectionMessage: true,
|
||||
contactMethods: {
|
||||
select: {
|
||||
value: true,
|
||||
@@ -295,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),
|
||||
@@ -413,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)
|
||||
@@ -425,6 +427,30 @@ const activeAlertOrWarningEvents = service.events
|
||||
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
|
||||
const activeEventToShow =
|
||||
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
|
||||
|
||||
// Sort verification steps: failed first, then warnings, then others, newest first within each group
|
||||
const getVerificationStepPriority = (status: VerificationStepStatus) => {
|
||||
switch (status) {
|
||||
case VerificationStepStatus.FAILED:
|
||||
return 0 // Highest priority
|
||||
case VerificationStepStatus.WARNING:
|
||||
return 1
|
||||
case VerificationStepStatus.IN_PROGRESS:
|
||||
return 2
|
||||
case VerificationStepStatus.PENDING:
|
||||
return 3
|
||||
case VerificationStepStatus.PASSED:
|
||||
return 4 // Lowest priority
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
const sortedVerificationSteps = orderBy(
|
||||
service.verificationSteps,
|
||||
[(step) => getVerificationStepPriority(step.status), (step) => step.updatedAt],
|
||||
['asc', 'desc'] // Priority ascending (failed first), date descending (newest first)
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -766,18 +792,12 @@ const activeEventToShow =
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -801,18 +821,12 @@ const activeEventToShow =
|
||||
|
||||
{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>
|
||||
@@ -1475,11 +1489,11 @@ const activeEventToShow =
|
||||
)
|
||||
}
|
||||
{
|
||||
service.verificationSteps.length > 0 && (
|
||||
sortedVerificationSteps.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) => {
|
||||
{sortedVerificationSteps.map((step) => {
|
||||
const statusInfo = getVerificationStepStatusInfo(step.status)
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user