Release 202507031107
This commit is contained in:
@@ -14,6 +14,8 @@ import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
const ONION_ADDRESS = getServerEnvVariable('ONION_ADDRESS')
|
||||
const I2P_ADDRESS = getServerEnvVariable('I2P_ADDRESS')
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
@@ -95,6 +97,18 @@ export default defineConfig({
|
||||
server: {
|
||||
open: false,
|
||||
allowedHosts: [new URL(SITE_URL).hostname],
|
||||
headers: {
|
||||
'Onion-Location': ONION_ADDRESS,
|
||||
'X-I2P-Location': I2P_ADDRESS,
|
||||
'X-Frame-Options': 'DENY',
|
||||
// Astro is working on this feature, when it's stable use it instead of this.
|
||||
// https://astro.build/blog/astro-590/#experimental-content-security-policy-support
|
||||
'Content-Security-Policy':
|
||||
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;',
|
||||
},
|
||||
},
|
||||
image: {
|
||||
domains: [new URL(SITE_URL).hostname],
|
||||
|
||||
18
web/package-lock.json
generated
18
web/package-lock.json
generated
@@ -28,6 +28,7 @@
|
||||
"astro-seo-schema": "5.0.0",
|
||||
"canvas": "3.1.2",
|
||||
"clsx": "2.1.1",
|
||||
"he": "1.2.0",
|
||||
"htmx.org": "2.0.6",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
@@ -59,6 +60,7 @@
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/he": "1.2.3",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.8",
|
||||
@@ -4763,6 +4765,13 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/he": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
|
||||
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -10820,6 +10829,15 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hex-rgb": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"astro-seo-schema": "5.0.0",
|
||||
"canvas": "3.1.2",
|
||||
"clsx": "2.1.1",
|
||||
"he": "1.2.0",
|
||||
"htmx.org": "2.0.6",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
@@ -75,6 +76,7 @@
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/he": "1.2.3",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.8",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import LoadingIndicator from 'astro-loading-indicator/component'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { ONION_ADDRESS } from 'astro:env/server'
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
|
||||
import { pwaInfo } from 'virtual:pwa-info'
|
||||
@@ -78,30 +79,32 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
|
||||
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
---
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
{/* Primary Meta Tags */}
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="description" content={description} />
|
||||
<title>{fullTitle}</title>
|
||||
<!-- {canonicalUrl && <link rel="canonical" href={canonicalUrl} />} -->
|
||||
{/* canonicalUrl && <link rel="canonical" href={canonicalUrl} /> */}
|
||||
<meta http-equiv="onion-location" content={ONION_ADDRESS} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||
|
||||
<!-- Twitter -->
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={fullTitle} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
|
||||
|
||||
<!-- Other -->
|
||||
{/* Other */}
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="sitemap" href="/sitemaps/search.xml" />
|
||||
|
||||
<!-- PWA -->
|
||||
{/* PWA */}
|
||||
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
|
||||
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
|
||||
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
||||
@@ -115,10 +118,10 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
<TailwindJsPluggin />
|
||||
{htmx && <HtmxScript />}
|
||||
|
||||
<!-- JSON-LD Schemas -->
|
||||
{/* JSON-LD Schemas */}
|
||||
{schemas?.map((item) => <Schema item={item} />)}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
{/* Breadcrumbs */}
|
||||
{
|
||||
breadcrumbLists.map((breadcrumbList) => (
|
||||
<Schema
|
||||
|
||||
@@ -10,7 +10,8 @@ import { transformCase } from '../lib/strings'
|
||||
|
||||
import ServiceFiltersPill from './ServiceFiltersPill.astro'
|
||||
|
||||
import type { AttributeOption, ServicesFiltersObject, ServicesFiltersOptions } from '../pages/index.astro'
|
||||
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
|
||||
import type { AttributeOption, ServicesFiltersObject } from '../pages/index.astro'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro'
|
||||
import { type ServicesFiltersObject } from '../pages/index.astro'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import PillsRadioGroup from './PillsRadioGroup.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'form'> & {
|
||||
@@ -111,7 +111,7 @@ const {
|
||||
<legend class="font-title mb-3 leading-none text-green-500">Type</legend>
|
||||
<ul class="[&:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden">
|
||||
{
|
||||
options.categories?.map((category) => (
|
||||
options.categories.map((category) => (
|
||||
<li data-show-always={category.showAlways ? '' : undefined}>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
@@ -252,7 +252,7 @@ const {
|
||||
</div>
|
||||
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
|
||||
{
|
||||
kycLevels.map((level) => (
|
||||
options.kycLevels.map((level) => (
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
{level.value}
|
||||
<Icon name={level.icon} class="ms-1 size-3 shrink-0" />
|
||||
@@ -334,7 +334,7 @@ const {
|
||||
<li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer">
|
||||
<fieldset class="relative flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
|
||||
<legend class="sr-only">
|
||||
{attribute.title} ({attribute._count?.services})
|
||||
{attribute.title} ({attribute._count.services})
|
||||
</legend>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -417,7 +417,7 @@ const {
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{attribute.title}
|
||||
</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>
|
||||
</label>
|
||||
<label
|
||||
for={emptyId}
|
||||
@@ -432,7 +432,7 @@ const {
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{attribute.title}
|
||||
</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>
|
||||
</label>
|
||||
</fieldset>
|
||||
</li>
|
||||
|
||||
@@ -15,7 +15,8 @@ import Button from './Button.astro'
|
||||
import ServiceCard from './ServiceCard.astro'
|
||||
import ServiceFiltersPillsRow from './ServiceFiltersPillsRow.astro'
|
||||
|
||||
import type { AttributeOption, ServicesFiltersObject, ServicesFiltersOptions } from '../pages/index.astro'
|
||||
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
|
||||
import type { AttributeOption, ServicesFiltersObject } from '../pages/index.astro'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
@@ -82,6 +83,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
]),
|
||||
})
|
||||
|
||||
// NOTE: If you make changes to this function, remember to update the sitemap: src/pages/sitemaps/search.xml.ts
|
||||
const searchTitle = (() => {
|
||||
if (filters.q) {
|
||||
return `Search results for “${filters.q}”`
|
||||
|
||||
187
web/src/lib/searchFiltersOptions.ts
Normal file
187
web/src/lib/searchFiltersOptions.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { orderBy, groupBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { networks } from '../constants/networks'
|
||||
import { verificationStatuses } from '../constants/verificationStatus'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const MIN_CATEGORIES_TO_SHOW = 8
|
||||
const MIN_ATTRIBUTES_TO_SHOW = 8
|
||||
|
||||
export 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'
|
||||
}
|
||||
}[]
|
||||
|
||||
export const defaultSortOption = sortOptions[0]
|
||||
|
||||
export const modeOptions = [
|
||||
{
|
||||
value: 'or',
|
||||
label: 'OR',
|
||||
},
|
||||
{
|
||||
value: 'and',
|
||||
label: 'AND',
|
||||
},
|
||||
] as const satisfies {
|
||||
value: string
|
||||
label: string
|
||||
}[]
|
||||
|
||||
export function makeSearchFiltersOptions({
|
||||
filters,
|
||||
categories,
|
||||
attributes,
|
||||
}: {
|
||||
filters: {
|
||||
categories: string[]
|
||||
attr: Record<number, '' | 'no' | 'yes'> | undefined
|
||||
} | null
|
||||
categories: Prisma.CategoryGetPayload<{
|
||||
select: {
|
||||
name: true
|
||||
namePluralLong: true
|
||||
slug: true
|
||||
icon: true
|
||||
_count: {
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}>[]
|
||||
attributes: Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
slug: true
|
||||
title: true
|
||||
category: true
|
||||
type: true
|
||||
_count: {
|
||||
select: {
|
||||
services: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}>[]
|
||||
}) {
|
||||
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) ?? false
|
||||
|
||||
return {
|
||||
...category,
|
||||
checked,
|
||||
}
|
||||
}),
|
||||
['checked', '_count.services', 'name'],
|
||||
['desc', 'desc', 'asc']
|
||||
).map((category, i) => ({
|
||||
...category,
|
||||
showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked,
|
||||
}))
|
||||
|
||||
return {
|
||||
currencies,
|
||||
categories: categoriesSorted,
|
||||
sort: sortOptions,
|
||||
modeOptions,
|
||||
network: networks,
|
||||
verification: verificationStatuses,
|
||||
attributesByCategory,
|
||||
kycLevels,
|
||||
} as const
|
||||
}
|
||||
|
||||
export type ServicesFiltersOptions = ReturnType<typeof makeSearchFiltersOptions>
|
||||
@@ -9,9 +9,7 @@ import Pagination from '../components/Pagination.astro'
|
||||
import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.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 } from '../constants/currencies'
|
||||
import { currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies'
|
||||
import { networks } from '../constants/networks'
|
||||
import {
|
||||
verificationStatuses,
|
||||
@@ -25,89 +23,18 @@ import { parseIntWithFallback } from '../lib/numbers'
|
||||
import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import {
|
||||
defaultSortOption,
|
||||
makeSearchFiltersOptions,
|
||||
modeOptions,
|
||||
sortOptions,
|
||||
} from '../lib/searchFiltersOptions'
|
||||
import { makeSortSeed } from '../lib/sortSeed'
|
||||
|
||||
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
|
||||
}[]
|
||||
|
||||
export type AttributeOption = {
|
||||
value: string
|
||||
prefix: string
|
||||
@@ -478,62 +405,11 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
],
|
||||
])
|
||||
|
||||
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 filtersOptions = makeSearchFiltersOptions({
|
||||
filters,
|
||||
categories,
|
||||
attributes,
|
||||
})
|
||||
|
||||
const searchResultsId = 'search-results'
|
||||
const showFiltersId = 'show-filters'
|
||||
|
||||
192
web/src/pages/sitemaps/search.xml.ts
Normal file
192
web/src/pages/sitemaps/search.xml.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/* eslint-disable import/no-named-as-default-member */
|
||||
import he from 'he'
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeSearchFiltersOptions } from '../../lib/searchFiltersOptions'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
if (!site) {
|
||||
return new Response('Site URL not configured', { status: 500 })
|
||||
}
|
||||
|
||||
const searchUrls = await generateSEOSitemapUrls(site.href)
|
||||
|
||||
const result = `
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${searchUrls.map((url) => `<url><loc>${he.encode(url)}</loc></url>`).join('\n')}
|
||||
</urlset>
|
||||
`.trim()
|
||||
|
||||
return new Response(result, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function generateSEOSitemapUrls(siteUrl: string) {
|
||||
try {
|
||||
const [categories, attributes] = await Promise.all([
|
||||
prisma.category.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
namePluralLong: true,
|
||||
slug: true,
|
||||
icon: true,
|
||||
_count: {
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.attribute.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
category: true,
|
||||
type: true,
|
||||
_count: {
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
service: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const filtersOptions = makeSearchFiltersOptions({
|
||||
filters: null,
|
||||
categories,
|
||||
attributes,
|
||||
})
|
||||
|
||||
const byCategory = filtersOptions.categories.map(
|
||||
(category) =>
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
})
|
||||
)
|
||||
|
||||
const byVerificationStatus = filtersOptions.verification.map(
|
||||
(status) =>
|
||||
new URLSearchParams({
|
||||
verification: status.slug,
|
||||
})
|
||||
)
|
||||
|
||||
const byKycLevel = filtersOptions.kycLevels.map(
|
||||
(level) =>
|
||||
new URLSearchParams({
|
||||
'max-kyc': level.id,
|
||||
})
|
||||
)
|
||||
|
||||
const byCurrency = filtersOptions.currencies.map(
|
||||
(currency) =>
|
||||
new URLSearchParams({
|
||||
currencies: currency.slug,
|
||||
})
|
||||
)
|
||||
|
||||
const withOneAttribute = filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.map(
|
||||
(attribute) =>
|
||||
new URLSearchParams({
|
||||
[`attr-${attribute.id.toString()}`]: 'yes',
|
||||
})
|
||||
)
|
||||
const withoutOneAttribute = filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.map(
|
||||
(attribute) =>
|
||||
new URLSearchParams({
|
||||
[`attr-${attribute.id.toString()}`]: 'no',
|
||||
})
|
||||
)
|
||||
|
||||
const byCategoryAndCurrency = filtersOptions.categories.flatMap((category) =>
|
||||
filtersOptions.currencies.map(
|
||||
(currency) =>
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
currencies: currency.slug,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const byCategoryAndAttributes = filtersOptions.categories.flatMap((category) =>
|
||||
filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.flatMap((attribute) => [
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
[`attr-${attribute.id.toString()}`]: 'yes',
|
||||
}),
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
[`attr-${attribute.id.toString()}`]: 'no',
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
const relevantCurrencies = [
|
||||
'xmr',
|
||||
'btc',
|
||||
] as const satisfies (typeof filtersOptions.currencies)[number]['slug'][]
|
||||
|
||||
const byCategoryAndAttributesAndRelevantCurrency = filtersOptions.categories.flatMap((category) =>
|
||||
filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.flatMap((attribute) =>
|
||||
relevantCurrencies.map(
|
||||
(currency) =>
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
currencies: currency,
|
||||
[`attr-${attribute.id.toString()}`]:
|
||||
attribute.type === 'GOOD' || attribute.type === 'INFO' ? 'yes' : 'no',
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const allQueryParams = [
|
||||
...byCategory,
|
||||
...byVerificationStatus,
|
||||
...byKycLevel,
|
||||
...byCurrency,
|
||||
...withOneAttribute,
|
||||
...withoutOneAttribute,
|
||||
|
||||
...byCategoryAndCurrency,
|
||||
...byCategoryAndAttributes,
|
||||
...byCategoryAndAttributesAndRelevantCurrency,
|
||||
] satisfies URLSearchParams[]
|
||||
|
||||
return allQueryParams.map((queryParams) => {
|
||||
const url = new URL(siteUrl)
|
||||
url.search = queryParams.toString()
|
||||
return url.href
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to generate SEO sitemap URLs:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
const getRobotsTxt = (sitemapURL: URL) => `
|
||||
const getRobotsTxt = (sitemaps: `/${string}`[], siteUrl: URL) => `
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
|
||||
Sitemap: ${sitemapURL.href}
|
||||
${sitemaps.map((sitemap) => `Sitemap: ${new URL(sitemap, siteUrl).href}`).join('\n')}
|
||||
`
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const sitemapURL = new URL('sitemap-index.xml', site)
|
||||
return new Response(getRobotsTxt(sitemapURL))
|
||||
if (!site) return new Response('Site URL not configured', { status: 500 })
|
||||
|
||||
return new Response(getRobotsTxt(['/sitemap-index.xml', '/sitemaps/search.xml'], site))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user