Release 202507031107
This commit is contained in:
@@ -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
18
web/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}”`
|
||||||
|
|||||||
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 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'
|
||||||
|
|||||||
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'
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user