--- import { z } from 'astro:schema' import { groupBy, omit, orderBy, sortBy, uniq } from 'lodash-es' import seedrandom from 'seedrandom' import Button from '../components/Button.astro' import InputText from '../components/InputText.astro' import Pagination from '../components/Pagination.astro' import ServiceFiltersPill from '../components/ServiceFiltersPill.astro' import ServicesFilters from '../components/ServicesFilters.astro' import ServicesSearchResults from '../components/ServicesSearchResults.astro' import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeTypeInfo } from '../constants/attributeTypes' import { currencies, currenciesZodEnumBySlug, currencySlugToId, getCurrencyInfo, } from '../constants/currencies' import { getNetworkInfo, networks } from '../constants/networks' import { getVerificationStatusInfo, verificationStatuses, verificationStatusesZodEnumBySlug, verificationStatusSlugToId, } from '../constants/verificationStatus' import BaseLayout from '../layouts/BaseLayout.astro' import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays' import { findServicesBySimilarity } from '../lib/findServicesBySimilarity' import { parseIntWithFallback } from '../lib/numbers' import { areEqualObjectsWithoutOrder } from '../lib/objects' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { prisma } from '../lib/prisma' import { makeSortSeed } from '../lib/sortSeed' import { transformCase } from '../lib/strings' import type { Prisma } from '@prisma/client' const MIN_CATEGORIES_TO_SHOW = 8 const MIN_ATTRIBUTES_TO_SHOW = 8 const PAGE_SIZE = 30 const sortOptions = [ { value: 'score-desc', label: 'Score (High → Low)', orderBy: { key: 'overallScore', direction: 'desc', }, }, { value: 'score-asc', label: 'Score (Low → High)', orderBy: { key: 'overallScore', direction: 'asc', }, }, { value: 'name-asc', label: 'Name (A → Z)', orderBy: { key: 'name', direction: 'asc', }, }, { value: 'name-desc', label: 'Name (Z → A)', orderBy: { key: 'name', direction: 'desc', }, }, { value: 'recent', label: 'Date listed (New → Old)', orderBy: { key: 'listedAt', direction: 'desc', }, }, { value: 'oldest', label: 'Date listed (Old → New)', orderBy: { key: 'listedAt', direction: 'asc', }, }, ] as const satisfies { value: string label: string orderBy: { key: keyof Prisma.ServiceSelect direction: 'asc' | 'desc' } }[] const defaultSortOption = sortOptions[0] const modeOptions = [ { value: 'or', label: 'OR', }, { value: 'and', label: 'AND', }, ] as const satisfies { value: string label: string }[] const attributeOptions = [ { value: 'yes', prefix: 'Has', }, { value: 'no', prefix: 'Not', }, { value: '', prefix: '', }, ] as const satisfies { value: string prefix: string }[] const ignoredKeysForDefaultData = ['sort-seed'] const { data: filters, hasDefaultData: hasDefaultFilters, defaultData: defaultFilters, redirectUrl, } = zodParseQueryParamsStoringErrors( { categories: z.array(z.string()), verification: z .array(verificationStatusesZodEnumBySlug.transform((slug) => verificationStatusSlugToId(slug))) .default( verificationStatuses .filter((verification) => verification.default) .map((verification) => verification.slug) ), 'min-score': z.coerce.number().default(0), 'user-rating': z.coerce.number().int().min(0).max(5).default(0), q: z.string().default(''), currencies: z.array(currenciesZodEnumBySlug.transform((slug) => currencySlugToId(slug))), 'currency-mode': zodEnumFromConstant(modeOptions, 'value').default('or'), 'attribute-mode': zodEnumFromConstant(modeOptions, 'value').default('or'), sort: zodEnumFromConstant(sortOptions, 'value').default(defaultSortOption.value), 'sort-seed': z .string() .transform((seed) => (Astro.url.searchParams.has('page') ? seed : makeSortSeed())) .default(makeSortSeed()), 'max-kyc': z.coerce.number().int().min(0).max(4).default(4), networks: z.array(zodEnumFromConstant(networks, 'slug')), attr: z .record(z.coerce.number().int().positive(), zodEnumFromConstant(attributeOptions, 'value')) .optional(), page: z.coerce.number().int().min(1).default(1), }, Astro, { ignoredKeysForDefaultData, cleanUrl: { removeUneededObjectParams: true, removeParams: { verification: { if: 'default' }, q: { if: 'default' }, sort: { if: 'default' }, 'currency-mode': { if: 'another-is-unset', prop: 'currencies' }, 'attribute-mode': { if: 'another-is-unset', prop: 'attr' }, 'min-score': { if: 'default' }, 'user-rating': { if: 'default' }, 'max-kyc': { if: 'default' }, 'sort-seed': { if: 'another-is-unset', prop: 'page' }, }, }, } ) const hasDefaultFiltersIgnoringQ = areEqualObjectsWithoutOrder( omit(filters, [...ignoredKeysForDefaultData, 'q']), omit(defaultFilters, [...ignoredKeysForDefaultData, 'q']) ) if (redirectUrl) return Astro.redirect(redirectUrl.toString()) const includeScams = !!filters.q && (areEqualArraysWithoutOrder(filters.verification, ['VERIFICATION_SUCCESS', 'APPROVED']) || areEqualArraysWithoutOrder(filters.verification, [ 'VERIFICATION_SUCCESS', 'APPROVED', 'COMMUNITY_CONTRIBUTED', ])) export type ServicesFiltersObject = typeof filters const groupedAttributes = groupBy( Object.entries(filters.attr ?? {}).flatMap(([key, value]) => { const id = parseIntWithFallback(key) if (id === null) return [] return [{ id, value }] }), 'value' ) const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null const where = { id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined, categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined, verificationStatus: { in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification, }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, overallScore: { gte: filters['min-score'] }, acceptedCurrencies: filters.currencies.length ? filters['currency-mode'] === 'and' ? { hasEvery: filters.currencies } : { hasSome: filters.currencies } : undefined, kycLevel: { lte: filters['max-kyc'], }, AND: [ ...(filters['user-rating'] > 0 ? [ { averageUserRating: { gte: filters['user-rating'], }, } satisfies Prisma.ServiceWhereInput, ] : []), ...(filters.networks.length ? [ { OR: [ ...(filters.networks.includes('onion') ? [{ onionUrls: { isEmpty: false } }] : []), ...(filters.networks.includes('i2p') ? [{ i2pUrls: { isEmpty: false } }] : []), ...(filters.networks.includes('clearnet') ? [{ serviceUrls: { isEmpty: false } }] : []), ], } satisfies Prisma.ServiceWhereInput, ] : []), ...(filters.attr && (groupedAttributes.yes?.length ?? 0) + (groupedAttributes.no?.length ?? 0) > 0 ? [ { AND: [ ...(groupedAttributes.yes && groupedAttributes.yes.length > 0 ? [ { [filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.yes.map( ({ id }) => ({ attributes: { some: { attribute: { id, }, }, }, }) satisfies Prisma.ServiceWhereInput ), }, ] : []), ...(groupedAttributes.no && groupedAttributes.no.length > 0 ? [ { [filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.no.map( ({ id }) => ({ attributes: { none: { attribute: { id, }, }, }, }) satisfies Prisma.ServiceWhereInput ), }, ] : []), ], }, ] : []), ], } as const satisfies Prisma.ServiceWhereInput const [categories, [services, totalServices], countCommunityOnly, attributes] = await Astro.locals.banners.tryMany([ [ 'Unable to load category filters.', () => prisma.category.findMany({ select: { name: true, slug: true, icon: true, _count: { select: { services: { where: { serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, }, }, }, }, }, }), ], [ 'Unable to load services.', async () => { const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount( { where, select: { id: true, serviceVisibility: true, verificationStatus: true, ...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record< (typeof sortOptions)[number]['orderBy']['key'], true >), }, } ) const unsortedServices = unsortedServicesMissingSimilarityScore.map((service) => ({ ...service, similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0, })) const rng = seedrandom(filters['sort-seed']) const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption const sortedServices = orderBy( // NOTE: We do a first sort by id to make the seeded sort deterministic sortBy(unsortedServices, 'id'), [ ...(filters.q ? (['similarityScore'] as const) : ([] as const)), (service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : 0), (service) => (service.serviceVisibility === 'ARCHIVED' ? 1 : 0), selectedSort.orderBy.key, () => rng(), ], [ ...(filters.q ? (['desc'] as const) : ([] as const)), 'asc', 'asc', selectedSort.orderBy.direction, 'asc', ] ).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE) const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({ where: { id: { in: sortedServices.map((service) => service.id), }, }, select: { id: true, name: true, slug: true, description: true, overallScore: true, privacyScore: true, trustScore: true, kycLevel: true, imageUrl: true, verificationStatus: true, acceptedCurrencies: true, serviceVisibility: true, attributes: { select: { attribute: { select: { id: true, slug: true, title: true, category: true, type: true, }, }, }, }, categories: { select: { name: true, icon: true, }, }, }, }) const unsortedServicesWithInfo = unsortedServicesWithInfoMissingSimilarityScore.map((service) => ({ ...service, similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0, })) const sortedServicesWithInfo = orderBy( unsortedServicesWithInfo, [ ...(filters.q ? (['similarityScore'] as const) : ([] as const)), (service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : 0), (service) => (service.serviceVisibility === 'ARCHIVED' ? 1 : 0), selectedSort.orderBy.key, // Now we can shuffle indeternimistically, because the pagination was already applied () => Math.random(), ], [ ...(filters.q ? (['desc'] as const) : ([] as const)), 'asc', 'asc', selectedSort.orderBy.direction, 'asc', ] ) return [sortedServicesWithInfo, totalServices] as const }, [[] as [], 0, false] as const, ], [ 'Unable to load count if including community.', () => areEqualArraysWithoutOrder(filters.verification, ['VERIFICATION_SUCCESS', 'APPROVED']) || areEqualArraysWithoutOrder(filters.verification, [ 'VERIFICATION_SUCCESS', 'APPROVED', 'VERIFICATION_FAILED', ]) ? prisma.service.count({ where: { ...where, verificationStatus: 'COMMUNITY_CONTRIBUTED', }, }) : null, null, ], [ 'Unable to load attribute filters.', () => prisma.attribute.findMany({ select: { id: true, slug: true, title: true, category: true, type: true, _count: { select: { services: true, }, }, }, orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }], }), [], ], ]) const attributesByCategory = orderBy( Object.entries( groupBy( attributes.map((attr) => { return { typeInfo: getAttributeTypeInfo(attr.type), ...attr, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing value: filters.attr?.[attr.id] || undefined, } }), 'category' ) ).map(([category, attributes]) => ({ category, categoryInfo: getAttributeCategoryInfo(category), attributes: orderBy( attributes, ['value', 'type', '_count.services', 'title'], ['asc', 'asc', 'desc', 'asc'] ).map((attr, i) => ({ ...attr, showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined, })), })), ['category'], ['asc'] ) const categoriesSorted = orderBy( categories?.map((category) => { const checked = filters.categories.includes(category.slug) return { ...category, checked, } }), ['checked', '_count.services', 'name'], ['desc', 'desc', 'asc'] ).map((category, i) => ({ ...category, showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked, })) const filtersOptions = { currencies, categories: categoriesSorted, sort: sortOptions, modeOptions, network: networks, verification: verificationStatuses, attributesByCategory, } as const export type ServicesFiltersOptions = typeof filtersOptions const searchResultsId = 'search-results' const showFiltersId = 'show-filters' ---
{ hasDefaultFilters || hasDefaultFiltersIgnoringQ ? (
) : (
{filters.q && ( )} {!areEqualArraysWithoutOrder( filters.verification, filtersOptions.verification .filter((verification) => verification.default) .map((verification) => verification.value) ) && filters.verification.map((verificationStatus) => { const verificationStatusInfo = getVerificationStatusInfo(verificationStatus) return ( ) })} {filters.categories.map((categorySlug) => { const category = categories?.find((c) => c.slug === categorySlug) if (!category) return null return ( ) })} {filters.currencies.map((currencyId) => { const currency = getCurrencyInfo(currencyId) return ( ) })} {filters.networks.map((network) => { const networkOption = getNetworkInfo(network) return ( ) })} {filters['max-kyc'] < 4 && ( )} {filters['user-rating'] > 0 && ( )} {filters['min-score'] > 0 && ( )} {filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && ( )} {filters.attr && Object.entries(filters.attr) .filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no') .map(([attributeId, attributeValue]) => { const attribute = attributes.find((attr) => String(attr.id) === attributeId) if (!attribute) return null const valueInfo = attributeOptions.find((option) => option.value === attributeValue) const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title') return ( ) })}
) }
{ totalServices > PAGE_SIZE && ( ) }