Compare commits
7 Commits
release-98
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a78a9b377 | ||
|
|
9e0193fc3c | ||
|
|
a68523fc73 | ||
|
|
a465849a76 | ||
|
|
25f6dba3eb | ||
|
|
7e7046e7d2 | ||
|
|
a5d1fb9a5d |
@@ -1 +1 @@
|
|||||||
23
|
24
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ export default defineConfig({
|
|||||||
SITE_URL === 'http://localhost:4321'
|
SITE_URL === 'http://localhost:4321'
|
||||||
? "frame-ancestors 'none'; upgrade-insecure-requests"
|
? "frame-ancestors 'none'; upgrade-insecure-requests"
|
||||||
: "default-src 'self'; img-src 'self' *; 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: {
|
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-variable/space-grotesk": "5.2.8",
|
||||||
"@fontsource/inter": "5.2.6",
|
"@fontsource/inter": "5.2.6",
|
||||||
"@fontsource/space-grotesk": "5.2.8",
|
"@fontsource/space-grotesk": "5.2.8",
|
||||||
"@prisma/client": "6.10.1",
|
"@prisma/client": "6.11.1",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/pg": "8.15.4",
|
"@types/pg": "8.15.4",
|
||||||
"@vercel/og": "0.6.8",
|
"@vercel/og": "0.7.2",
|
||||||
"astro": "5.11.0",
|
"astro": "5.9.0",
|
||||||
"astro-loading-indicator": "0.7.0",
|
"astro-loading-indicator": "0.7.0",
|
||||||
"astro-remote": "0.3.4",
|
"astro-remote": "0.3.4",
|
||||||
"astro-seo-schema": "5.0.0",
|
"astro-seo-schema": "5.0.0",
|
||||||
"canvas": "3.1.2",
|
"canvas": "3.1.2",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"countries-list": "3.1.1",
|
||||||
|
"country-flag-icons": "1.5.19",
|
||||||
"he": "1.2.0",
|
"he": "1.2.0",
|
||||||
"htmx.org": "2.0.6",
|
"htmx.org": "2.0.6",
|
||||||
"javascript-time-ago": "2.5.11",
|
"javascript-time-ago": "2.5.11",
|
||||||
@@ -54,7 +56,7 @@
|
|||||||
"pg": "8.16.3",
|
"pg": "8.16.3",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"redis": "5.5.6",
|
"redis": "5.6.0",
|
||||||
"schema-dts": "1.1.5",
|
"schema-dts": "1.1.5",
|
||||||
"seedrandom": "3.0.5",
|
"seedrandom": "3.0.5",
|
||||||
"sharp": "0.34.2",
|
"sharp": "0.34.2",
|
||||||
@@ -69,7 +71,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.30.1",
|
"@eslint/js": "9.30.1",
|
||||||
"@faker-js/faker": "9.9.0",
|
"@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/mdi": "1.2.3",
|
||||||
"@iconify-json/ri": "1.2.5",
|
"@iconify-json/ri": "1.2.5",
|
||||||
"@stylistic/eslint-plugin": "5.1.0",
|
"@stylistic/eslint-plugin": "5.1.0",
|
||||||
@@ -82,12 +84,12 @@
|
|||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/seedrandom": "3.0.8",
|
"@types/seedrandom": "3.0.8",
|
||||||
"@types/web-push": "3.6.4",
|
"@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/assets-generator": "1.0.0",
|
||||||
"@vite-pwa/astro": "1.1.0",
|
"@vite-pwa/astro": "1.1.0",
|
||||||
"astro-icon": "1.1.5",
|
"astro-icon": "1.1.5",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"esbuild": "0.25.5",
|
"esbuild": "0.25.6",
|
||||||
"eslint": "9.30.1",
|
"eslint": "9.30.1",
|
||||||
"eslint-import-resolver-typescript": "4.4.4",
|
"eslint-import-resolver-typescript": "4.4.4",
|
||||||
"eslint-plugin-astro": "1.3.1",
|
"eslint-plugin-astro": "1.3.1",
|
||||||
@@ -97,13 +99,13 @@
|
|||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"prettier-plugin-astro": "0.14.1",
|
"prettier-plugin-astro": "0.14.1",
|
||||||
"prettier-plugin-tailwindcss": "0.6.13",
|
"prettier-plugin-tailwindcss": "0.6.13",
|
||||||
"prisma": "6.10.1",
|
"prisma": "6.11.1",
|
||||||
"prisma-json-types-generator": "3.5.0",
|
"prisma-json-types-generator": "3.5.1",
|
||||||
"tailwind-htmx": "0.1.2",
|
"tailwind-htmx": "0.1.2",
|
||||||
"ts-essentials": "10.1.1",
|
"ts-essentials": "10.1.1",
|
||||||
"ts-toolbelt": "9.6.0",
|
"ts-toolbelt": "9.6.0",
|
||||||
"tsx": "4.20.3",
|
"tsx": "4.20.3",
|
||||||
"typescript-eslint": "8.35.1",
|
"typescript-eslint": "8.36.0",
|
||||||
"vite-plugin-devtools-json": "0.2.1",
|
"vite-plugin-devtools-json": "0.2.1",
|
||||||
"workbox-core": "7.3.0",
|
"workbox-core": "7.3.0",
|
||||||
"workbox-precaching": "7.3.0"
|
"workbox-precaching": "7.3.0"
|
||||||
|
|||||||
@@ -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 {
|
model Service {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
previousSlugs String[] @default([])
|
previousSlugs String[] @default([])
|
||||||
description String
|
description String
|
||||||
categories Category[] @relation("ServiceToCategory")
|
categories Category[] @relation("ServiceToCategory")
|
||||||
kycLevel Int @default(4)
|
kycLevel Int @default(4)
|
||||||
kycLevelClarification KycLevelClarification @default(NONE)
|
kycLevelClarification KycLevelClarification @default(NONE)
|
||||||
/// Date only, no time.
|
/// Date only, no time.
|
||||||
operatingSince DateTime? @db.Date
|
operatingSince DateTime? @db.Date
|
||||||
overallScore Int @default(0)
|
overallScore Int @default(0)
|
||||||
privacyScore Int @default(0)
|
privacyScore Int @default(0)
|
||||||
trustScore Int @default(0)
|
trustScore Int @default(0)
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
averageUserRating Float?
|
averageUserRating Float?
|
||||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||||
serviceInfoBannerNotes String?
|
serviceInfoBannerNotes String?
|
||||||
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
||||||
verificationSummary String?
|
verificationSummary String?
|
||||||
verificationRequests ServiceVerificationRequest[]
|
verificationRequests ServiceVerificationRequest[]
|
||||||
verificationProofMd String?
|
verificationProofMd String?
|
||||||
/// [UserSentiment]
|
/// [UserSentiment]
|
||||||
userSentiment Json?
|
userSentiment Json?
|
||||||
userSentimentAt DateTime?
|
userSentimentAt DateTime?
|
||||||
referral String?
|
referral String?
|
||||||
acceptedCurrencies Currency[] @default([])
|
acceptedCurrencies Currency[] @default([])
|
||||||
serviceUrls String[]
|
serviceUrls String[]
|
||||||
tosUrls String[] @default([])
|
tosUrls String[] @default([])
|
||||||
onionUrls String[] @default([])
|
onionUrls String[] @default([])
|
||||||
i2pUrls String[] @default([])
|
i2pUrls String[] @default([])
|
||||||
imageUrl String?
|
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]
|
||||||
tosReview Json?
|
tosReview Json?
|
||||||
tosReviewAt DateTime?
|
tosReviewAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
|
/// 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.
|
/// 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.
|
/// 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.
|
/// 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.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
isRecentlyApproved Boolean @default(false)
|
isRecentlyApproved Boolean @default(false)
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
events Event[]
|
events Event[]
|
||||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||||
attributes ServiceAttribute[]
|
attributes ServiceAttribute[]
|
||||||
verificationSteps VerificationStep[]
|
verificationSteps VerificationStep[]
|
||||||
suggestions ServiceSuggestion[]
|
suggestions ServiceSuggestion[]
|
||||||
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
||||||
|
|
||||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { generateUsername } from 'unique-username-generator'
|
|||||||
import { kycLevels } from '../src/constants/kycLevels'
|
import { kycLevels } from '../src/constants/kycLevels'
|
||||||
import { undefinedIfEmpty } from '../src/lib/arrays'
|
import { undefinedIfEmpty } from '../src/lib/arrays'
|
||||||
import { transformCase } from '../src/lib/strings'
|
import { transformCase } from '../src/lib/strings'
|
||||||
|
import { countries } from '../src/constants/countries'
|
||||||
|
|
||||||
// Exit if not in development mode
|
// Exit if not in development mode
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@@ -697,6 +698,12 @@ const generateFakeService = (users: User[]) => {
|
|||||||
{ count: { min: 0, max: 2 } }
|
{ count: { min: 0, max: 2 } }
|
||||||
),
|
),
|
||||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
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:
|
listedAt:
|
||||||
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
|
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
|
||||||
? faker.date.recent({ days: 30 })
|
? faker.date.recent({ days: 30 })
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ DECLARE
|
|||||||
recently_approved_factor INT := 0;
|
recently_approved_factor INT := 0;
|
||||||
tos_penalty_factor INT := 0;
|
tos_penalty_factor INT := 0;
|
||||||
operating_since_factor INT := 0;
|
operating_since_factor INT := 0;
|
||||||
|
legally_registered_factor INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get verification status factor
|
-- Get verification status factor
|
||||||
SELECT
|
SELECT
|
||||||
@@ -160,9 +161,17 @@ BEGIN
|
|||||||
INTO operating_since_factor
|
INTO operating_since_factor
|
||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE id = service_id;
|
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)
|
-- 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)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ActionError } from 'astro:actions'
|
|||||||
import { uniq } from 'lodash-es'
|
import { uniq } from 'lodash-es'
|
||||||
import slugify from 'slugify'
|
import slugify from 'slugify'
|
||||||
|
|
||||||
|
import { countriesZodEnumById } from '../../constants/countries'
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
@@ -59,6 +60,14 @@ const serviceSchemaBase = z.object({
|
|||||||
.nullable()
|
.nullable()
|
||||||
.default(null),
|
.default(null),
|
||||||
operatingSince: z.coerce.date().optional().nullable(),
|
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,
|
imageFile: imageFileSchema,
|
||||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||||
@@ -283,6 +292,8 @@ export const adminServiceActions = {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
operatingSince: input.operatingSince,
|
operatingSince: input.operatingSince,
|
||||||
|
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||||
|
registeredCompanyName: input.registeredCompanyName,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'astro/zod'
|
|||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { formatDistanceStrict } from 'date-fns'
|
import { formatDistanceStrict } from 'date-fns'
|
||||||
|
|
||||||
|
import { countriesZodEnumById } from '../constants/countries'
|
||||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
@@ -184,6 +185,14 @@ export const serviceSuggestionActions = {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
operatingSince: z.coerce.date().optional(),
|
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 */
|
/** @deprecated Honey pot field, do not use */
|
||||||
message: z.unknown().optional(),
|
message: z.unknown().optional(),
|
||||||
skipDuplicateCheck: z
|
skipDuplicateCheck: z
|
||||||
@@ -282,6 +291,8 @@ export const serviceSuggestionActions = {
|
|||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
operatingSince: input.operatingSince,
|
operatingSince: input.operatingSince,
|
||||||
|
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||||
|
registeredCompanyName: input.registeredCompanyName,
|
||||||
serviceUrls,
|
serviceUrls,
|
||||||
tosUrls: input.tosUrls,
|
tosUrls: input.tosUrls,
|
||||||
onionUrls,
|
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',
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-label="Announcement banner"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
transition:name="header-container"
|
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)}>
|
<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">
|
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Tooltip from './Tooltip.astro'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
type Props = HTMLAttributes<'a'> & {
|
type Props = HTMLAttributes<'article'> & {
|
||||||
inlineIcons?: boolean
|
inlineIcons?: boolean
|
||||||
withoutLink?: boolean
|
withoutLink?: boolean
|
||||||
service: Prisma.ServiceGetPayload<{
|
service: Prisma.ServiceGetPayload<{
|
||||||
@@ -57,7 +57,7 @@ const {
|
|||||||
},
|
},
|
||||||
class: className,
|
class: className,
|
||||||
withoutLink = false,
|
withoutLink = false,
|
||||||
...aProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
const statusIcon = {
|
const statusIcon = {
|
||||||
@@ -70,127 +70,129 @@ const Element = withoutLink ? 'div' : 'a'
|
|||||||
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||||
---
|
---
|
||||||
|
|
||||||
<Element
|
<article {...htmlProps}>
|
||||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
<Element
|
||||||
{...aProps}
|
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||||
class={cn(
|
aria-label={Element === 'a' ? name : undefined}
|
||||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
class={cn(
|
||||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
className
|
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||||
)}
|
className
|
||||||
>
|
)}
|
||||||
<!-- Header with Icon and Title -->
|
>
|
||||||
<div class="flex items-center gap-(--gap)">
|
<!-- Header with Icon and Title -->
|
||||||
<MyPicture
|
<div class="flex items-center gap-(--gap)">
|
||||||
src={imageUrl}
|
<MyPicture
|
||||||
fallback="service"
|
src={imageUrl}
|
||||||
alt={name || 'Service logo'}
|
fallback="service"
|
||||||
class={cn(
|
alt="Logo"
|
||||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
class={cn(
|
||||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
)}
|
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||||
width={48}
|
)}
|
||||||
height={48}
|
width={48}
|
||||||
/>
|
height={48}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
<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">
|
<h1 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||||
{name}{
|
{name}{
|
||||||
statusIcon && (
|
statusIcon && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={statusIcon.label}
|
text={statusIcon.label}
|
||||||
position="right"
|
position="right"
|
||||||
class="-my-2 shrink-0 whitespace-nowrap"
|
class="-my-2 shrink-0 whitespace-nowrap"
|
||||||
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
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
|
<Icon
|
||||||
is:inline={inlineIcons}
|
is:inline={inlineIcons}
|
||||||
name={statusIcon.icon}
|
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||||
class={cn(
|
class={cn(
|
||||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||||
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||||
statusIcon.classNames.icon
|
|
||||||
)}
|
)}
|
||||||
/>,
|
/>
|
||||||
verificationStatus === 'VERIFICATION_FAILED' && (
|
</Tooltip>
|
||||||
<span class="text-sm font-bold text-red-500">SCAM</span>
|
)
|
||||||
),
|
}
|
||||||
]}
|
</h1>
|
||||||
</Tooltip>
|
<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)]">
|
||||||
}{
|
{
|
||||||
serviceVisibility === 'ARCHIVED' && (
|
categories.map((category) => (
|
||||||
<Tooltip
|
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||||
position="right"
|
<span>{category.name}</span>
|
||||||
class="-my-2 shrink-0 whitespace-nowrap"
|
</span>
|
||||||
>
|
))
|
||||||
<Icon
|
}
|
||||||
is:inline={inlineIcons}
|
</div>
|
||||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
</div>
|
||||||
class={cn(
|
</div>
|
||||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
|
||||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
<div class="flex-1">
|
||||||
)}
|
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||||
/>
|
{description}
|
||||||
</Tooltip>
|
</p>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
</h3>
|
<div class="flex items-center justify-start">
|
||||||
<div class="max-h-2 flex-1"></div>
|
<Tooltip
|
||||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
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) => (
|
currencies.map((currency) => {
|
||||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
|
||||||
<span>{category.name}</span>
|
return (
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</Element>
|
||||||
|
</article>
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -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',
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-label={`Remove filter: ${text}`}
|
||||||
>
|
>
|
||||||
{icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
|
{icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
}
|
}
|
||||||
}>[]
|
}>[]
|
||||||
attributeOptions: AttributeOption[]
|
attributeOptions: AttributeOption[]
|
||||||
|
inlineIcons?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -41,6 +42,7 @@ const {
|
|||||||
categories,
|
categories,
|
||||||
attributes,
|
attributes,
|
||||||
attributeOptions,
|
attributeOptions,
|
||||||
|
inlineIcons = true,
|
||||||
...divProps
|
...divProps
|
||||||
} = Astro.props
|
} = 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',
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-label="Applied filters"
|
||||||
{...divProps}
|
{...divProps}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
filters.q && (
|
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}
|
icon={category.icon}
|
||||||
searchParamName="categories"
|
searchParamName="categories"
|
||||||
searchParamValue={categorySlug}
|
searchParamValue={categorySlug}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -83,6 +92,7 @@ const {
|
|||||||
searchParamName="currencies"
|
searchParamName="currencies"
|
||||||
searchParamValue={currency.slug}
|
searchParamValue={currency.slug}
|
||||||
icon={currency.icon}
|
icon={currency.icon}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -97,6 +107,7 @@ const {
|
|||||||
icon={networkOption.icon}
|
icon={networkOption.icon}
|
||||||
searchParamName="networks"
|
searchParamName="networks"
|
||||||
searchParamValue={network}
|
searchParamValue={network}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -107,6 +118,7 @@ const {
|
|||||||
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
||||||
icon="ri:shield-keyhole-line"
|
icon="ri:shield-keyhole-line"
|
||||||
searchParamName="max-kyc"
|
searchParamName="max-kyc"
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,6 +128,7 @@ const {
|
|||||||
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
||||||
icon="ri:star-fill"
|
icon="ri:star-fill"
|
||||||
searchParamName="user-rating"
|
searchParamName="user-rating"
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -125,6 +138,7 @@ const {
|
|||||||
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
||||||
icon="ri:medal-line"
|
icon="ri:medal-line"
|
||||||
searchParamName="min-score"
|
searchParamName="min-score"
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -135,6 +149,7 @@ const {
|
|||||||
icon="ri:filter-3-line"
|
icon="ri:filter-3-line"
|
||||||
searchParamName="attribute-mode"
|
searchParamName="attribute-mode"
|
||||||
searchParamValue="and"
|
searchParamValue="and"
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -152,6 +167,7 @@ const {
|
|||||||
text={`${prefix}: ${attribute.title}`}
|
text={`${prefix}: ${attribute.title}`}
|
||||||
searchParamName={`attr-${attributeId}`}
|
searchParamName={`attr-${attributeId}`}
|
||||||
searchParamValue={attributeValue}
|
searchParamValue={attributeValue}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -176,6 +192,7 @@ const {
|
|||||||
iconClass={verificationStatusInfo.classNames.icon}
|
iconClass={verificationStatusInfo.classNames.icon}
|
||||||
searchParamName="verification"
|
searchParamName="verification"
|
||||||
searchParamValue={verificationStatusInfo.slug}
|
searchParamValue={verificationStatusInfo.slug}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -414,7 +414,10 @@ const {
|
|||||||
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
|
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
|
||||||
aria-hidden="true"
|
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}
|
{attribute.title}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-day-500 ml-2 font-normal">{attribute._count.services}</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)}
|
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
|
||||||
aria-hidden="true"
|
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}
|
{attribute.title}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
|
<span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ const searchTitle = (() => {
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
attributeOptions={attributeOptions}
|
attributeOptions={attributeOptions}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -201,6 +202,7 @@ const searchTitle = (() => {
|
|||||||
name="ri:loader-4-line"
|
name="ri:loader-4-line"
|
||||||
id="search-indicator"
|
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"
|
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}
|
is:inline={inlineIcons}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -346,11 +348,9 @@ const searchTitle = (() => {
|
|||||||
</div>
|
</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) => (
|
{services.map((service, i) => (
|
||||||
<ServiceCard
|
<li
|
||||||
inlineIcons={inlineIcons}
|
|
||||||
service={service}
|
|
||||||
data-hx-search-results-card
|
data-hx-search-results-card
|
||||||
{...(i === services.length - 1 && currentPage < totalPages
|
{...(i === services.length - 1 && currentPage < totalPages
|
||||||
? {
|
? {
|
||||||
@@ -361,9 +361,11 @@ const searchTitle = (() => {
|
|||||||
'hx-indicator': '#infinite-scroll-indicator',
|
'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="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">
|
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const {
|
|||||||
enabled && (
|
enabled && (
|
||||||
<span
|
<span
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
class={cn(
|
class={cn(
|
||||||
'pointer-events-none hidden select-none group-hover/tooltip:flex',
|
'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',
|
'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"
|
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>
|
<h1 class="mb-0!">{frontmatter.title}</h1>
|
||||||
<p class="mt-2! opacity-70">
|
{
|
||||||
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />}
|
!!frontmatter.updatedAt && (
|
||||||
</p>
|
<p class="mt-2! opacity-70">
|
||||||
|
Updated <TimeFormatted date={new Date(frontmatter.updatedAt)} />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { differenceInMonths, differenceInYears } from 'date-fns'
|
import { differenceInMonths, differenceInYears } from 'date-fns'
|
||||||
|
import he from 'he'
|
||||||
import { orderBy } from 'lodash-es'
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||||
|
import { getCountryInfo } from '../constants/countries'
|
||||||
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
|
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
|
||||||
import { kycLevels } from '../constants/kycLevels'
|
import { kycLevels } from '../constants/kycLevels'
|
||||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||||
@@ -47,6 +49,8 @@ type NonDbAttributeFull = NonDbAttribute & {
|
|||||||
kycLevel: true
|
kycLevel: true
|
||||||
kycLevelClarification: true
|
kycLevelClarification: true
|
||||||
operatingSince: true
|
operatingSince: true
|
||||||
|
registrationCountryCode: true
|
||||||
|
registeredCompanyName: true
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
) => 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<
|
export function sortAttributes<
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import UserBadge from '../../../../components/UserBadge.astro'
|
|||||||
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
|
||||||
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
|
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
|
||||||
|
import { countries } from '../../../../constants/countries'
|
||||||
import { currencies } from '../../../../constants/currencies'
|
import { currencies } from '../../../../constants/currencies'
|
||||||
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
||||||
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
|
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
|
||||||
@@ -386,6 +387,36 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<InputImageFile
|
<InputImageFile
|
||||||
label="Image"
|
label="Image"
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ icon: 'ri:image-line'
|
|||||||
|
|
||||||
import PressAssets from '../components/PressAssets.astro'
|
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.
|
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
|
||||||
|
|
||||||
<PressAssets />
|
<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
|
## Brand design
|
||||||
|
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ const showFiltersId = 'show-filters'
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
attributeOptions={attributeOptions}
|
attributeOptions={attributeOptions}
|
||||||
|
inlineIcons={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -492,6 +493,7 @@ const showFiltersId = 'show-filters'
|
|||||||
/>
|
/>
|
||||||
<div
|
<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"
|
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
|
<ServicesFilters
|
||||||
searchResultsId={searchResultsId}
|
searchResultsId={searchResultsId}
|
||||||
@@ -519,6 +521,7 @@ const showFiltersId = 'show-filters'
|
|||||||
filtersOptions={filtersOptions}
|
filtersOptions={filtersOptions}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
attributeOptions={attributeOptions}
|
attributeOptions={attributeOptions}
|
||||||
|
aria-label="Search results"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import InputCheckbox from '../../components/InputCheckbox.astro'
|
|||||||
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
||||||
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
||||||
import InputImageFile from '../../components/InputImageFile.astro'
|
import InputImageFile from '../../components/InputImageFile.astro'
|
||||||
|
import InputSelect from '../../components/InputSelect.astro'
|
||||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||||
import InputText from '../../components/InputText.astro'
|
import InputText from '../../components/InputText.astro'
|
||||||
import InputTextArea from '../../components/InputTextArea.astro'
|
import InputTextArea from '../../components/InputTextArea.astro'
|
||||||
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||||
import { contactMethodUrlTypes } from '../../constants/contactMethods'
|
import { contactMethodUrlTypes } from '../../constants/contactMethods'
|
||||||
|
import { countries } from '../../constants/countries'
|
||||||
import { currencies } from '../../constants/currencies'
|
import { currencies } from '../../constants/currencies'
|
||||||
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
|
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
|
||||||
import { kycLevels } from '../../constants/kycLevels'
|
import { kycLevels } from '../../constants/kycLevels'
|
||||||
@@ -262,6 +264,33 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<InputCardGroup
|
||||||
name="kycLevel"
|
name="kycLevel"
|
||||||
label="KYC Level"
|
label="KYC Level"
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
acceptedCurrencies: true,
|
acceptedCurrencies: true,
|
||||||
operatingSince: true,
|
operatingSince: true,
|
||||||
|
registrationCountryCode: true,
|
||||||
|
registeredCompanyName: true,
|
||||||
tosReview: true,
|
tosReview: true,
|
||||||
tosReviewAt: true,
|
tosReviewAt: true,
|
||||||
userSentiment: true,
|
userSentiment: true,
|
||||||
@@ -426,6 +428,30 @@ const activeAlertOrWarningEvents = service.events
|
|||||||
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
|
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
|
||||||
const activeEventToShow =
|
const activeEventToShow =
|
||||||
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
|
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
|
<BaseLayout
|
||||||
@@ -1476,11 +1502,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>
|
<h3 class="font-title text-md mt-6 mb-2 font-semibold">Verification Steps</h3>
|
||||||
<ul class="mb-8 space-y-2">
|
<ul class="mb-8 space-y-2">
|
||||||
{service.verificationSteps.map((step) => {
|
{sortedVerificationSteps.map((step) => {
|
||||||
const statusInfo = getVerificationStepStatusInfo(step.status)
|
const statusInfo = getVerificationStepStatusInfo(step.status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user