Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
9a78a9b377 Release 202507231133 2025-07-23 11:33:11 +00:00
pluja
9e0193fc3c Release 202507092007 2025-07-09 20:07:42 +00:00
pluja
a68523fc73 Release 202507090857 2025-07-09 08:57:55 +00:00
pluja
a465849a76 Release 202507080951 2025-07-08 09:51:46 +00:00
19 changed files with 664 additions and 352 deletions

View File

@@ -1 +1 @@
23
24

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ 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'
@@ -59,6 +60,14 @@ 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),
@@ -283,6 +292,8 @@ export const adminServiceActions = {
})),
},
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
},
})

View File

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

View File

@@ -350,21 +350,19 @@ const searchTitle = (() => {
<>
<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) => (
<li>
<ServiceCard
inlineIcons={inlineIcons}
service={service}
data-hx-search-results-card
{...(i === services.length - 1 && currentPage < totalPages
? {
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
'hx-trigger': 'revealed',
'hx-swap': 'afterend',
'hx-select': '[data-hx-search-results-card]',
'hx-indicator': '#infinite-scroll-indicator',
}
: {})}
/>
<li
data-hx-search-results-card
{...(i === services.length - 1 && currentPage < totalPages
? {
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
'hx-trigger': 'revealed',
'hx-swap': 'afterend',
'hx-select': '[data-hx-search-results-card]',
'hx-indicator': '#infinite-scroll-indicator',
}
: {})}
>
<ServiceCard inlineIcons={inlineIcons} service={service} />
</li>
))}
</ol>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,6 +96,8 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
createdAt: true,
acceptedCurrencies: true,
operatingSince: true,
registrationCountryCode: true,
registeredCompanyName: true,
tosReview: true,
tosReviewAt: true,
userSentiment: true,
@@ -426,6 +428,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
@@ -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>
<ul class="mb-8 space-y-2">
{service.verificationSteps.map((step) => {
{sortedVerificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (