Release 202507031107

This commit is contained in:
pluja
2025-07-03 11:07:41 +00:00
parent a545726abf
commit 5a54352d95
11 changed files with 453 additions and 157 deletions

View File

@@ -14,6 +14,8 @@ import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables' import { getServerEnvVariable } from './src/lib/serverEnvVariables'
const SITE_URL = getServerEnvVariable('SITE_URL') const SITE_URL = getServerEnvVariable('SITE_URL')
const ONION_ADDRESS = getServerEnvVariable('ONION_ADDRESS')
const I2P_ADDRESS = getServerEnvVariable('I2P_ADDRESS')
export default defineConfig({ export default defineConfig({
site: SITE_URL, site: SITE_URL,
@@ -95,6 +97,18 @@ export default defineConfig({
server: { server: {
open: false, open: false,
allowedHosts: [new URL(SITE_URL).hostname], 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: { image: {
domains: [new URL(SITE_URL).hostname], domains: [new URL(SITE_URL).hostname],

18
web/package-lock.json generated
View File

@@ -28,6 +28,7 @@
"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",
"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",
"libphonenumber-js": "1.12.9", "libphonenumber-js": "1.12.9",
@@ -59,6 +60,7 @@
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/he": "1.2.3",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.8", "@types/react": "19.1.8",
@@ -4763,6 +4765,13 @@
"@types/unist": "*" "@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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -10820,6 +10829,15 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/hex-rgb": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",

View File

@@ -44,6 +44,7 @@
"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",
"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",
"libphonenumber-js": "1.12.9", "libphonenumber-js": "1.12.9",
@@ -75,6 +76,7 @@
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/he": "1.2.3",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.8", "@types/react": "19.1.8",

View File

@@ -1,6 +1,7 @@
--- ---
import LoadingIndicator from 'astro-loading-indicator/component' import LoadingIndicator from 'astro-loading-indicator/component'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
import { ONION_ADDRESS } from 'astro:env/server'
import { ClientRouter } from 'astro:transitions' import { ClientRouter } from 'astro:transitions'
import { pwaAssetsHead } from 'virtual:pwa-assets/head' import { pwaAssetsHead } from 'virtual:pwa-assets/head'
import { pwaInfo } from 'virtual:pwa-info' import { pwaInfo } from 'virtual:pwa-info'
@@ -78,30 +79,32 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url) const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
--- ---
<!-- Primary Meta Tags --> {/* Primary Meta Tags */}
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<meta name="description" content={description} /> <meta name="description" content={description} />
<title>{fullTitle}</title> <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:type" content="website" />
<meta property="og:url" content={Astro.url} /> <meta property="og:url" content={Astro.url} />
<meta property="og:title" content={fullTitle} /> <meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />} {!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
<!-- Twitter --> {/* Twitter */}
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} /> <meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={fullTitle} /> <meta property="twitter:title" content={fullTitle} />
<meta property="twitter:description" content={description} /> <meta property="twitter:description" content={description} />
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />} {!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
<!-- Other --> {/* Other */}
<link rel="sitemap" href="/sitemap-index.xml" /> <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.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)} {pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />} {pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
@@ -115,10 +118,10 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<TailwindJsPluggin /> <TailwindJsPluggin />
{htmx && <HtmxScript />} {htmx && <HtmxScript />}
<!-- JSON-LD Schemas --> {/* JSON-LD Schemas */}
{schemas?.map((item) => <Schema item={item} />)} {schemas?.map((item) => <Schema item={item} />)}
<!-- Breadcrumbs --> {/* Breadcrumbs */}
{ {
breadcrumbLists.map((breadcrumbList) => ( breadcrumbLists.map((breadcrumbList) => (
<Schema <Schema

View File

@@ -10,7 +10,8 @@ import { transformCase } from '../lib/strings'
import ServiceFiltersPill from './ServiceFiltersPill.astro' 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 { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'

View File

@@ -1,15 +1,15 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { kycLevels } from '../constants/kycLevels'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore' 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 Button from './Button.astro'
import PillsRadioGroup from './PillsRadioGroup.astro' import PillsRadioGroup from './PillsRadioGroup.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
export type Props = HTMLAttributes<'form'> & { export type Props = HTMLAttributes<'form'> & {
@@ -111,7 +111,7 @@ const {
<legend class="font-title mb-3 leading-none text-green-500">Type</legend> <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"> <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}> <li data-show-always={category.showAlways ? '' : undefined}>
<label class="flex cursor-pointer items-center gap-2 text-sm text-white"> <label class="flex cursor-pointer items-center gap-2 text-sm text-white">
<input <input
@@ -252,7 +252,7 @@ const {
</div> </div>
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs"> <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"> <span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
{level.value} {level.value}
<Icon name={level.icon} class="ms-1 size-3 shrink-0" /> <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"> <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"> <fieldset class="relative flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
<legend class="sr-only"> <legend class="sr-only">
{attribute.title} ({attribute._count?.services}) {attribute.title} ({attribute._count.services})
</legend> </legend>
<input <input
type="radio" type="radio"
@@ -417,7 +417,7 @@ const {
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{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>
</label> </label>
<label <label
for={emptyId} for={emptyId}
@@ -432,7 +432,7 @@ const {
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{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>
</label> </label>
</fieldset> </fieldset>
</li> </li>

View File

@@ -15,7 +15,8 @@ import Button from './Button.astro'
import ServiceCard from './ServiceCard.astro' import ServiceCard from './ServiceCard.astro'
import ServiceFiltersPillsRow from './ServiceFiltersPillsRow.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 { Prisma } from '@prisma/client'
import type { ComponentProps, HTMLAttributes } from 'astro/types' 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 = (() => { const searchTitle = (() => {
if (filters.q) { if (filters.q) {
return `Search results for “${filters.q}”` return `Search results for “${filters.q}”`

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

View File

@@ -9,9 +9,7 @@ import Pagination from '../components/Pagination.astro'
import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.astro' import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.astro'
import ServicesFilters from '../components/ServicesFilters.astro' import ServicesFilters from '../components/ServicesFilters.astro'
import ServicesSearchResults from '../components/ServicesSearchResults.astro' import ServicesSearchResults from '../components/ServicesSearchResults.astro'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { currencies, currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies'
import { networks } from '../constants/networks' import { networks } from '../constants/networks'
import { import {
verificationStatuses, verificationStatuses,
@@ -25,89 +23,18 @@ import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects' import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import {
defaultSortOption,
makeSearchFiltersOptions,
modeOptions,
sortOptions,
} from '../lib/searchFiltersOptions'
import { makeSortSeed } from '../lib/sortSeed' import { makeSortSeed } from '../lib/sortSeed'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
const MIN_CATEGORIES_TO_SHOW = 8
const MIN_ATTRIBUTES_TO_SHOW = 8
const PAGE_SIZE = 30 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 = { export type AttributeOption = {
value: string value: string
prefix: string prefix: string
@@ -478,62 +405,11 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
], ],
]) ])
const attributesByCategory = orderBy( const filtersOptions = makeSearchFiltersOptions({
Object.entries( filters,
groupBy( categories,
attributes.map((attr) => { attributes,
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 searchResultsId = 'search-results'
const showFiltersId = 'show-filters' const showFiltersId = 'show-filters'

View 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 []
}
}

View File

@@ -1,14 +1,15 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
const getRobotsTxt = (sitemapURL: URL) => ` const getRobotsTxt = (sitemaps: `/${string}`[], siteUrl: URL) => `
User-agent: * User-agent: *
Allow: / Allow: /
Disallow: /admin/ Disallow: /admin/
Sitemap: ${sitemapURL.href} ${sitemaps.map((sitemap) => `Sitemap: ${new URL(sitemap, siteUrl).href}`).join('\n')}
` `
export const GET: APIRoute = ({ site }) => { export const GET: APIRoute = ({ site }) => {
const sitemapURL = new URL('sitemap-index.xml', site) if (!site) return new Response('Site URL not configured', { status: 500 })
return new Response(getRobotsTxt(sitemapURL))
return new Response(getRobotsTxt(['/sitemap-index.xml', '/sitemaps/search.xml'], site))
} }