Compare commits

...

7 Commits

Author SHA1 Message Date
pluja
a5d1fb9a5d Release 202507061906 2025-07-06 19:06:17 +00:00
pluja
28b84a7d9b Release 202507061859 2025-07-06 18:59:23 +00:00
pluja
7a294cb0a1 Release 202507061803 2025-07-06 18:03:45 +00:00
pluja
349c26a4df Release 202507031546 2025-07-03 15:46:21 +00:00
pluja
86b1afb2c7 Release 202507031255 2025-07-03 12:55:03 +00:00
pluja
99bc1f4e0f Release 202507031129 2025-07-03 11:29:46 +00:00
pluja
3166349dfb Release 202507031117 2025-07-03 11:17:39 +00:00
24 changed files with 458 additions and 306 deletions

View File

@@ -7,7 +7,8 @@
"golang.go", "golang.go",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"craigrbroughton.htmx-attributes", "craigrbroughton.htmx-attributes",
"nefrob.vscode-just-syntax" "nefrob.vscode-just-syntax",
"prisma.prisma"
], ],
"unwantedRecommendations": [] "unwantedRecommendations": []
} }

50
web/package-lock.json generated
View File

@@ -11,7 +11,7 @@
"@astrojs/check": "0.9.4", "@astrojs/check": "0.9.4",
"@astrojs/db": "0.15.0", "@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0", "@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2", "@astrojs/node": "9.3.0",
"@astrojs/rss": "4.0.12", "@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8", "@fontsource-variable/space-grotesk": "5.2.8",
@@ -22,7 +22,7 @@
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"astro": "5.10.1", "astro": "5.11.0",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4", "astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0", "astro-seo-schema": "5.0.0",
@@ -51,8 +51,8 @@
"web-push": "3.6.7" "web-push": "3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.30.0", "@eslint/js": "9.30.1",
"@faker-js/faker": "9.8.0", "@faker-js/faker": "9.9.0",
"@iconify-json/material-symbols": "1.2.28", "@iconify-json/material-symbols": "1.2.28",
"@iconify-json/mdi": "1.2.3", "@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5", "@iconify-json/ri": "1.2.5",
@@ -72,12 +72,12 @@
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"esbuild": "0.25.5", "esbuild": "0.25.5",
"eslint": "9.30.0", "eslint": "9.30.1",
"eslint-import-resolver-typescript": "4.4.4", "eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-astro": "1.3.1", "eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.2.0", "globals": "16.3.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-astro": "0.14.1", "prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.13", "prettier-plugin-tailwindcss": "0.6.13",
@@ -297,9 +297,9 @@
} }
}, },
"node_modules/@astrojs/node": { "node_modules/@astrojs/node": {
"version": "9.2.2", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.2.2.tgz", "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.3.0.tgz",
"integrity": "sha512-PtLPuuojmcl9O3CEvXqL/D+wB4x5DlbrGOvP0MeTAh/VfKFprYAzgw1+45xsnTO+QvPWb26l1cT+ZQvvohmvMw==", "integrity": "sha512-IV8NzGStHAsKBz1ljxxD8PBhBfnw/BEx/PZfsncTNXg9D4kQtZbSy+Ak0LvDs+rPmK0VeXLNn0HAdWuHCVg8cw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@astrojs/internal-helpers": "0.6.1", "@astrojs/internal-helpers": "0.6.1",
@@ -2641,9 +2641,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.30.0", "version": "9.30.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz",
"integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==", "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2678,9 +2678,9 @@
} }
}, },
"node_modules/@faker-js/faker": { "node_modules/@faker-js/faker": {
"version": "9.8.0", "version": "9.9.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.8.0.tgz", "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz",
"integrity": "sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==", "integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -6333,9 +6333,9 @@
} }
}, },
"node_modules/astro": { "node_modules/astro": {
"version": "5.10.1", "version": "5.11.0",
"resolved": "https://registry.npmjs.org/astro/-/astro-5.10.1.tgz", "resolved": "https://registry.npmjs.org/astro/-/astro-5.11.0.tgz",
"integrity": "sha512-DJVmt+51jU1xmgmAHCDwuUgcG/5aVFSU+tcX694acAZqPVt8EMUAmUZcJDX36Z7/EztnPph9HR3pm72jS2EgHQ==", "integrity": "sha512-MEICntERthUxJPSSDsDiZuwiCMrsaYy3fnDhp4c6ScUfldCB8RBnB/myYdpTFXpwYBy6SgVsHQ1H4MuuA7ro/Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@astrojs/compiler": "^2.12.2", "@astrojs/compiler": "^2.12.2",
@@ -9039,9 +9039,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.30.0", "version": "9.30.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz",
"integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==", "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9051,7 +9051,7 @@
"@eslint/config-helpers": "^0.3.0", "@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.14.0", "@eslint/core": "^0.14.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.30.0", "@eslint/js": "9.30.1",
"@eslint/plugin-kit": "^0.3.1", "@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
@@ -10401,9 +10401,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "16.2.0", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
"integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@@ -27,7 +27,7 @@
"@astrojs/check": "0.9.4", "@astrojs/check": "0.9.4",
"@astrojs/db": "0.15.0", "@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0", "@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2", "@astrojs/node": "9.3.0",
"@astrojs/rss": "4.0.12", "@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8", "@fontsource-variable/space-grotesk": "5.2.8",
@@ -38,7 +38,7 @@
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"astro": "5.10.1", "astro": "5.11.0",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4", "astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0", "astro-seo-schema": "5.0.0",
@@ -67,8 +67,8 @@
"web-push": "3.6.7" "web-push": "3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.30.0", "@eslint/js": "9.30.1",
"@faker-js/faker": "9.8.0", "@faker-js/faker": "9.9.0",
"@iconify-json/material-symbols": "1.2.28", "@iconify-json/material-symbols": "1.2.28",
"@iconify-json/mdi": "1.2.3", "@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5", "@iconify-json/ri": "1.2.5",
@@ -88,12 +88,12 @@
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"esbuild": "0.25.5", "esbuild": "0.25.5",
"eslint": "9.30.0", "eslint": "9.30.1",
"eslint-import-resolver-typescript": "4.4.4", "eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-astro": "1.3.1", "eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.2.0", "globals": "16.3.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-astro": "0.14.1", "prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.13", "prettier-plugin-tailwindcss": "0.6.13",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "strictCommentingEnabled" BOOLEAN NOT NULL DEFAULT false;

View File

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

View File

@@ -406,6 +406,9 @@ model Service {
Notification Notification[] Notification Notification[]
affiliatedUsers ServiceUser[] @relation("ServiceUsers") affiliatedUsers ServiceUser[] @relation("ServiceUsers")
strictCommentingEnabled Boolean @default(false)
commentSectionMessage String?
@@index([listedAt]) @@index([listedAt])
@@index([approvedAt]) @@index([approvedAt])
@@index([verifiedAt]) @@index([verifiedAt])

View File

@@ -720,6 +720,8 @@ const generateFakeService = (users: User[]) => {
}), }),
{ probability: 0.33 } { probability: 0.33 }
), ),
strictCommentingEnabled: faker.datatype.boolean(0.33333),
commentSectionMessage: faker.helpers.maybe(() => faker.lorem.paragraph(), { probability: 0.3 }),
} as const satisfies Prisma.ServiceCreateInput } as const satisfies Prisma.ServiceCreateInput
} }

View File

@@ -283,7 +283,7 @@ BEGIN
-- and ensure it wasn't already APPROVED. -- and ensure it wasn't already APPROVED.
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
-- Fetch service details for the description -- Fetch service details for the description
SELECT name, visibility INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId"; SELECT name, "serviceVisibility" INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId";
-- Only award karma if the service is public -- Only award karma if the service is public
IF service_visibility = 'PUBLIC' THEN IF service_visibility = 'PUBLIC' THEN

View File

@@ -63,6 +63,8 @@ const serviceSchemaBase = z.object({
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility), serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(), internalNote: z.string().optional(),
strictCommentingEnabled: z.boolean().optional().default(false),
commentSectionMessage: z.string().trim().min(3).max(1000).optional().nullable().default(null),
}) })
// Define schema for the create action input // Define schema for the create action input
@@ -127,6 +129,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
strictCommentingEnabled: input.strictCommentingEnabled,
commentSectionMessage: input.commentSectionMessage,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null, referral: input.referral || null,
serviceVisibility: input.serviceVisibility, serviceVisibility: input.serviceVisibility,
@@ -247,6 +251,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
strictCommentingEnabled: input.strictCommentingEnabled,
commentSectionMessage: input.commentSectionMessage,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null, referral: input.referral || null,
serviceVisibility: input.serviceVisibility, serviceVisibility: input.serviceVisibility,

View File

@@ -17,6 +17,7 @@ import type { CommentStatus, Prisma } from '@prisma/client'
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2 const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
const MAX_COMMENTS_PER_WINDOW = 1 const MAX_COMMENTS_PER_WINDOW = 1
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10 const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
export const COMMENT_ORDER_ID_MAX_LENGTH = 600
export const commentActions = { export const commentActions = {
vote: defineProtectedAction({ vote: defineProtectedAction({
@@ -103,7 +104,7 @@ export const commentActions = {
issueFundsBlocked: z.coerce.boolean().optional(), issueFundsBlocked: z.coerce.boolean().optional(),
issueScam: z.coerce.boolean().optional(), issueScam: z.coerce.boolean().optional(),
issueDetails: z.string().max(120).optional(), issueDetails: z.string().max(120).optional(),
orderId: z.string().max(100).optional(), orderId: z.string().max(COMMENT_ORDER_ID_MAX_LENGTH).optional(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.rating && data.parentId) { if (data.rating && data.parentId) {

View File

@@ -85,7 +85,7 @@ const Tag = announcement.link ? 'a' : 'div'
</div> </div>
{ {
!!announcement.linkText && ( !!announcement.link && !!announcement.linkText && (
<div class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]"> <div class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]">
<span class="2xs:inline-block hidden">{announcement.linkText}</span> <span class="2xs:inline-block hidden">{announcement.linkText}</span>
<Icon <Icon

View File

@@ -33,6 +33,7 @@ type Props = HTMLAttributes<'div'> & {
highlightedCommentId: number | null highlightedCommentId: number | null
serviceSlug: string serviceSlug: string
itemReviewedId: string itemReviewedId: string
strictCommentingEnabled?: boolean
} }
const { const {
@@ -42,6 +43,7 @@ const {
highlightedCommentId = null, highlightedCommentId = null,
serviceSlug, serviceSlug,
itemReviewedId, itemReviewedId,
strictCommentingEnabled,
class: className, class: className,
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
@@ -492,6 +494,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
serviceId={comment.serviceId} serviceId={comment.serviceId}
parentId={comment.id} parentId={comment.id}
commentId={comment.id} commentId={comment.id}
strictCommentingEnabled={strictCommentingEnabled}
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block" class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
/> />
</> </>

View File

@@ -1,7 +1,9 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { COMMENT_ORDER_ID_MAX_LENGTH } from '../actions/comment'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeLoginUrl } from '../lib/redirectUrls' import { makeLoginUrl } from '../lib/redirectUrls'
@@ -20,6 +22,8 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
serviceId: number serviceId: number
parentId?: number parentId?: number
commentId?: number commentId?: number
strictCommentingEnabled?: boolean
commentSectionMessage?: string | null
activeRatingComment?: Prisma.CommentGetPayload<{ activeRatingComment?: Prisma.CommentGetPayload<{
select: { select: {
id: true id: true
@@ -28,7 +32,16 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
}> | null }> | null
} }
const { serviceId, parentId, commentId, activeRatingComment, class: className, ...htmlProps } = Astro.props const {
serviceId,
parentId,
commentId,
activeRatingComment,
strictCommentingEnabled,
commentSectionMessage,
class: className,
...htmlProps
} = Astro.props
const MIN_COMMENT_LENGTH = parentId ? 10 : 30 const MIN_COMMENT_LENGTH = parentId ? 10 : 30
@@ -88,69 +101,83 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
</div> </div>
{!parentId ? ( {!parentId ? (
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden"> <>
<div class="flex flex-wrap gap-4"> <div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
<InputRating name="rating" label="Rating" /> <div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" />
<InputWrapper label="I experienced..." name="tags"> <InputWrapper label="I experienced..." name="tags">
<label class="flex cursor-pointer items-center gap-2"> <label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" /> <input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400"> <span class="flex items-center gap-1 text-xs text-red-400">
<Icon name="ri:user-forbid-fill" class="size-3" /> <Icon name="ri:user-forbid-fill" class="size-3" />
KYC Issue KYC Issue
</span> </span>
</label> </label>
<label class="flex cursor-pointer items-center gap-2"> <label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" /> <input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
<span class="flex items-center gap-1 text-xs text-orange-400"> <span class="flex items-center gap-1 text-xs text-orange-400">
<Icon name="ri:wallet-3-fill" class="size-3" /> <Icon name="ri:wallet-3-fill" class="size-3" />
Funds Blocked Funds Blocked
</span> </span>
</label> </label>
</InputWrapper> </InputWrapper>
<InputText <InputText
label="Order ID" label="Order ID"
name="orderId" name="orderId"
inputProps={{ inputProps={{
maxlength: 100, maxlength: COMMENT_ORDER_ID_MAX_LENGTH,
placeholder: 'Order ID / URL / Proof', placeholder: 'Order ID / URL / Proof',
class: 'bg-night-800', class: 'bg-night-800',
}} required: strictCommentingEnabled,
descriptionLabel="Only visible to admins, to verify your comment" }}
class="grow" descriptionLabel="Only visible to admins, to verify your comment"
/> class="grow"
</div> />
</div>
<div class="mt-4 flex items-start justify-end gap-2"> <div class="mt-4 flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
{!!activeRatingComment?.rating && ( {!!activeRatingComment?.rating && (
<div <div
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400" class="mt-1 rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
data-show-if-rating data-show-if-rating
>
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
<a
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
class="inline-flex items-center gap-1 underline"
target="_blank"
rel="noopener noreferrer"
> >
Your previous rating <Icon name="ri:information-line" class="mr-1 inline size-3.5" />
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" /> <a
</a> href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
of class="inline-flex items-center gap-1 underline"
{[ target="_blank"
activeRatingComment.rating.toLocaleString(), rel="noopener noreferrer"
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />, >
]} Your previous rating
won't count for the total. <Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
</div> </a>
)} of
{[
activeRatingComment.rating.toLocaleString(),
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
]}
won't count for the total.
</div>
)}
<Button type="submit" label="Send" icon="ri:send-plane-2-line" /> <div class="flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
{!!commentSectionMessage && (
<div class="flex items-start gap-1 pt-1.5">
<Icon name="ri:information-line" class="mt-1.25 inline size-3.5" />
<div class="prose prose-invert prose-sm text-day-200 max-w-none grow">
<Markdown content={commentSectionMessage} />
</div>
</div>
)}
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
</div>
</div>
</div> </div>
</div> </>
) : ( ) : (
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<Button type="submit" label="Reply" icon="ri:reply-line" /> <Button type="submit" label="Reply" icon="ri:reply-line" />

View File

@@ -35,6 +35,8 @@ type Props = {
name: true name: true
description: true description: true
createdAt: true createdAt: true
strictCommentingEnabled: true
commentSectionMessage: true
} }
}> }>
} }
@@ -173,7 +175,13 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
comment: comments.map(makeReplySchema), comment: comments.map(makeReplySchema),
} as WithContext<DiscussionForumPosting>} } as WithContext<DiscussionForumPosting>}
/> />
<CommentReply serviceId={service.id} activeRatingComment={activeRatingComment} class="xs:mb-4 mb-2" /> <CommentReply
serviceId={service.id}
activeRatingComment={activeRatingComment}
strictCommentingEnabled={service.strictCommentingEnabled}
commentSectionMessage={service.commentSectionMessage}
class="xs:mb-4 mb-2"
/>
<div class="mb-6 flex flex-wrap items-center justify-between gap-2"> <div class="mb-6 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center"> <div class="flex items-center">
@@ -258,6 +266,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
showPending={params.showPending} showPending={params.showPending}
serviceSlug={service.slug} serviceSlug={service.slug}
itemReviewedId={itemReviewedId} itemReviewedId={itemReviewedId}
strictCommentingEnabled={service.strictCommentingEnabled}
/> />
)) ))
) : ( ) : (

View File

@@ -7,6 +7,8 @@ import type { ComponentProps } from 'astro/types'
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & { type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
disabled?: boolean disabled?: boolean
checked?: boolean
descriptionInline?: string
id?: string id?: string
} & ( } & (
| { | {
@@ -19,13 +21,11 @@ type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'requi
} }
) )
const { disabled, name, required, error, id, label } = Astro.props const { disabled, name, required, error, id, label, checked, descriptionInline } = Astro.props
const hasError = !!error && error.length > 0 const hasError = !!error && error.length > 0
--- ---
{}
<div> <div>
<label <label
class={cn( class={cn(
@@ -41,9 +41,11 @@ const hasError = !!error && error.length > 0
name={name} name={name}
required={required} required={required}
disabled={disabled} disabled={disabled}
checked={checked}
class={cn(disabled && 'opacity-50')} class={cn(disabled && 'opacity-50')}
/> />
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span> <span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
{descriptionInline && <p class="text-day-400 text-xs">{descriptionInline}</p>}
</label> </label>
{ {

View File

@@ -414,7 +414,10 @@ const {
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)} class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span
class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
title={attribute.title}
>
{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>
@@ -429,7 +432,10 @@ const {
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)} class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span
class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
title={attribute.title}
>
{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>

View File

@@ -3,7 +3,6 @@ import { Icon } from 'astro-icon/components'
import { uniq, orderBy } from 'lodash-es' import { uniq, orderBy } from 'lodash-es'
import { getCurrencyInfo } from '../constants/currencies' import { getCurrencyInfo } from '../constants/currencies'
import { getKycLevelInfo } from '../constants/kycLevels'
import { verificationStatusesByValue, getVerificationStatusInfo } from '../constants/verificationStatus' import { verificationStatusesByValue, getVerificationStatusInfo } from '../constants/verificationStatus'
import { areEqualArraysWithoutOrder } from '../lib/arrays' import { areEqualArraysWithoutOrder } from '../lib/arrays'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -160,8 +159,7 @@ const searchTitle = (() => {
} }
if (filters['max-kyc'] === 0) { if (filters['max-kyc'] === 0) {
const kycLevelInfo = getKycLevelInfo(String(filters['max-kyc'])) kycLevel = 'without KYC'
kycLevel = `with ${kycLevelInfo.name}`
prefix = '' prefix = ''
} else if (filters['max-kyc'] <= 3) { } else if (filters['max-kyc'] <= 3) {
kycLevel = `with KYC level ${filters['max-kyc']} or better` kycLevel = `with KYC level ${filters['max-kyc']} or better`

View File

@@ -0,0 +1,33 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
type ReadStatusInfo<T extends string | null | undefined = string> = {
id: T
label: string
readValue: boolean
}
export const {
dataArray: readStatuses,
getFn: getReadStatus,
zodEnumById: readStatusZodEnum,
} = makeHelpersForOptions(
'id',
(id): ReadStatusInfo<typeof id> => ({
id,
label: id ? transformCase(id, 'title') : String(id),
readValue: false,
}),
[
{
id: 'unread',
label: 'Unread',
readValue: false,
},
{
id: 'read',
label: 'Read',
readValue: true,
},
] as const satisfies ReadStatusInfo[]
)

View File

@@ -5,6 +5,7 @@ import { Icon } from 'astro-icon/components'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
import CommentModeration from '../../components/CommentModeration.astro' import CommentModeration from '../../components/CommentModeration.astro'
import MyPicture from '../../components/MyPicture.astro' import MyPicture from '../../components/MyPicture.astro'
import Pagination from '../../components/Pagination.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import UserBadge from '../../components/UserBadge.astro' import UserBadge from '../../components/UserBadge.astro'
import { import {
@@ -27,7 +28,7 @@ if (!user || (!user.admin && !user.moderator)) {
const { data: params } = zodParseQueryParamsStoringErrors( const { data: params } = zodParseQueryParamsStoringErrors(
{ {
status: commentStatusFiltersZodEnum.default('all'), status: commentStatusFiltersZodEnum.default('all'),
page: z.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),
}, },
Astro Astro
) )
@@ -241,29 +242,5 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
</div> </div>
<!-- Pagination --> <!-- Pagination -->
{ {totalPages > 1 && <Pagination currentPage={params.page} totalPages={totalPages} class="mt-8" />}
totalPages > 1 && (
<div class="mt-8 flex justify-center gap-2">
{params.page > 1 && (
<a
href={urlWithParams(Astro.url, { page: params.page - 1 })}
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
>
Previous
</a>
)}
<span class="font-title px-3 py-1 text-sm">
Page {params.page} of {totalPages}
</span>
{params.page < totalPages && (
<a
href={urlWithParams(Astro.url, { page: params.page + 1 })}
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
>
Next
</a>
)}
</div>
)
}
</BaseLayout> </BaseLayout>

View File

@@ -10,6 +10,7 @@ import Button from '../../../../components/Button.astro'
import FormSection from '../../../../components/FormSection.astro' import FormSection from '../../../../components/FormSection.astro'
import FormSubSection from '../../../../components/FormSubSection.astro' import FormSubSection from '../../../../components/FormSubSection.astro'
import InputCardGroup from '../../../../components/InputCardGroup.astro' import InputCardGroup from '../../../../components/InputCardGroup.astro'
import InputCheckbox from '../../../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro' import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
import InputImageFile from '../../../../components/InputImageFile.astro' import InputImageFile from '../../../../components/InputImageFile.astro'
import InputSelect from '../../../../components/InputSelect.astro' import InputSelect from '../../../../components/InputSelect.astro'
@@ -545,6 +546,24 @@ const apiCalls = await Astro.locals.banners.try(
cardSize="sm" cardSize="sm"
/> />
<InputCheckbox
label="Strict Commenting"
name="strictCommentingEnabled"
checked={service.strictCommentingEnabled}
descriptionInline="Require proof of being a client for comments."
/>
<InputTextArea
label="Comment Section Message"
name="commentSectionMessage"
value={service.commentSectionMessage ?? ''}
description="Markdown supported"
inputProps={{
rows: 4,
}}
error={serviceInputErrors.commentSectionMessage}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel /> <InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form> </form>
</FormSection> </FormSection>

View File

@@ -3,6 +3,7 @@ import { AttributeCategory, Currency, VerificationStatus } from '@prisma/client'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import InputCheckbox from '../../../components/InputCheckbox.astro'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn' import { cn } from '../../../lib/cn'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
@@ -368,6 +369,35 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
} }
</div> </div>
<InputCheckbox
label="Strict Commenting"
name="strictCommentingEnabled"
checked={false}
descriptionInline="Require proof of being a client for comments."
/>
<div>
<label for="commentSectionMessage" class="font-title mb-2 block text-sm text-green-500"
>Comment Section Message</label
>
<div class="space-y-2">
<textarea
transition:persist
name="commentSectionMessage"
id="commentSectionMessage"
rows={4}
placeholder="Markdown supported. This message will be displayed in the comment section for root comments."
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
set:text=""
/>
</div>
{
inputErrors.commentSectionMessage && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.commentSectionMessage.join(', ')}</p>
)
}
</div>
<button <button
type="submit" type="submit"
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"

View File

@@ -5,10 +5,12 @@ import { actions } from 'astro:actions'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import CopyButton from '../components/CopyButton.astro' import CopyButton from '../components/CopyButton.astro'
import Pagination from '../components/Pagination.astro'
import PushNotificationBanner from '../components/PushNotificationBanner.astro' import PushNotificationBanner from '../components/PushNotificationBanner.astro'
import TimeFormatted from '../components/TimeFormatted.astro' import TimeFormatted from '../components/TimeFormatted.astro'
import Tooltip from '../components/Tooltip.astro' import Tooltip from '../components/Tooltip.astro'
import { getNotificationTypeInfo } from '../constants/notificationTypes' import { getNotificationTypeInfo } from '../constants/notificationTypes'
import { getReadStatus, readStatusZodEnum } from '../constants/readStatus'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences' import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
@@ -16,6 +18,10 @@ import { makeNotificationActions, makeNotificationContent, makeNotificationTitle
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { makeLoginUrl } from '../lib/redirectUrls' import { makeLoginUrl } from '../lib/redirectUrls'
import { transformCase } from '../lib/strings'
import { urlWithParams } from '../lib/urls'
import type { Prisma } from '@prisma/client'
const user = Astro.locals.user const user = Astro.locals.user
if (!user) return Astro.redirect(makeLoginUrl(Astro.url)) if (!user) return Astro.redirect(makeLoginUrl(Astro.url))
@@ -25,20 +31,26 @@ const PAGE_SIZE = 20
const { data: params } = zodParseQueryParamsStoringErrors( const { data: params } = zodParseQueryParamsStoringErrors(
{ {
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
readStatus: readStatusZodEnum.optional(),
}, },
Astro Astro
) )
const skip = (params.page - 1) * PAGE_SIZE const skip = (params.page - 1) * PAGE_SIZE
const readStatusInfo = params.readStatus ? getReadStatus(params.readStatus) : undefined
const notificationWhereClause: Prisma.NotificationWhereInput = {
userId: user.id,
read: readStatusInfo?.readValue,
}
const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] = const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] =
await Astro.locals.banners.tryMany([ await Astro.locals.banners.tryMany([
[ [
'Error while fetching notifications', 'Error while fetching notifications',
() => () =>
prisma.notification.findMany({ prisma.notification.findMany({
where: { where: notificationWhereClause,
userId: user.id,
},
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
@@ -153,7 +165,7 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
], ],
[ [
'Error while fetching total notifications', 'Error while fetching total notifications',
() => prisma.notification.count({ where: { userId: user.id } }), () => prisma.notification.count({ where: notificationWhereClause }),
0, 0,
], ],
[ [
@@ -227,13 +239,18 @@ const notifications = dbNotifications.map((notification) => ({
) )
} }
<div class="mb-4 flex items-center justify-between"> <div class="xs:flex-row xs:flex-wrap mb-4 flex flex-col items-center justify-between gap-2">
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider"> <h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" /> <Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
Notifications
{transformCase(`${readStatusInfo?.label ?? ''} notifications`.trim(), 'sentence')}
</h1> </h1>
<form method="POST" action={actions.notification.updateReadStatus}> <form
method="POST"
action={actions.notification.updateReadStatus}
class="xs:justify-start flex flex-1 flex-row-reverse flex-wrap items-center justify-center gap-2"
>
<input type="hidden" name="notificationId" value="all" /> <input type="hidden" name="notificationId" value="all" />
<input type="hidden" name="read" value="true" /> <input type="hidden" name="read" value="true" />
<Button <Button
@@ -243,6 +260,25 @@ const notifications = dbNotifications.map((notification) => ({
disabled={notifications.length === 0} disabled={notifications.length === 0}
color="white" color="white"
/> />
{
params.readStatus === 'unread' ? (
<Button
as="a"
href={urlWithParams(Astro.url, { readStatus: null }, { clearExisting: true })}
label="See all"
icon="ri:eye-line"
color="black"
/>
) : (
<Button
as="a"
href={urlWithParams(Astro.url, { readStatus: 'unread' }, { clearExisting: true })}
label="See unread only"
icon="ri:eye-off-line"
color="black"
/>
)
}
</form> </form>
</div> </div>
@@ -323,31 +359,12 @@ const notifications = dbNotifications.map((notification) => ({
))} ))}
{totalPages > 1 && ( {totalPages > 1 && (
<div class="mt-8 flex justify-center gap-4"> <Pagination
<form method="GET" action="/notifications" class="inline"> currentPage={params.page}
<input type="hidden" name="page" value={params.page - 1} /> totalPages={totalPages}
<button currentUrl={Astro.url}
type="submit" class="mt-8"
class="rounded-md border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors duration-200 hover:bg-zinc-700" />
disabled={params.page <= 1}
>
Previous
</button>
</form>
<span class="inline-flex items-center px-2 text-sm text-zinc-400">
Page {params.page} of {totalPages}
</span>
<form method="GET" action="/notifications" class="inline">
<input type="hidden" name="page" value={params.page + 1} />
<button
type="submit"
class="rounded-md border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors duration-200 hover:bg-zinc-700"
disabled={params.page >= totalPages}
>
Next
</button>
</form>
</div>
)} )}
</div> </div>
) )

View File

@@ -102,6 +102,8 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
userSentimentAt: true, userSentimentAt: true,
averageUserRating: true, averageUserRating: true,
isRecentlyApproved: true, isRecentlyApproved: true,
strictCommentingEnabled: true,
commentSectionMessage: true,
contactMethods: { contactMethods: {
select: { select: {
value: true, value: true,
@@ -1533,6 +1535,20 @@ const activeEventToShow =
<li>Moderation is light.</li> <li>Moderation is light.</li>
<li>Double-check before trusting.</li> <li>Double-check before trusting.</li>
</ul> </ul>
{
service.strictCommentingEnabled && (
<p class="mt-2">
<Icon
name="ri:verified-badge-fill"
class="me-0.5 inline-block size-4 align-[-0.3em] text-orange-100/95"
/>
<span class="font-medium text-orange-100/95">Proof of being a client required</span>, for this
service.
</p>
)
}
<div class="absolute inset-y-2 right-2 flex flex-col justify-center"> <div class="absolute inset-y-2 right-2 flex flex-col justify-center">
<Icon name="ri:alert-line" class="xs:opacity-20 h-full max-h-16 w-auto opacity-10" /> <Icon name="ri:alert-line" class="xs:opacity-20 h-full max-h-16 w-auto opacity-10" />
</div> </div>

View File

@@ -7,186 +7,184 @@ import { makeSearchFiltersOptions } from '../../lib/searchFiltersOptions'
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
export const GET: APIRoute = async ({ site }) => { export const GET: APIRoute = async ({ site }) => {
if (!site) { if (!site) return new Response('Site URL not configured', { status: 500 })
return new Response('Site URL not configured', { status: 500 })
}
const searchUrls = await generateSEOSitemapUrls(site.href) try {
const searchUrls = await generateSEOSitemapUrls(site.href)
const result = ` const result = `
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${searchUrls.map((url) => `<url><loc>${he.encode(url)}</loc></url>`).join('\n')} ${searchUrls.map((url) => `<url><loc>${he.encode(url)}</loc></url>`).join('\n')}
</urlset> </urlset>
`.trim() `.trim()
return new Response(result, { return new Response(result, {
headers: { headers: {
'Content-Type': 'application/xml', 'Content-Type': 'application/xml',
}, },
}) })
} catch (error) {
console.error('Failed to generate SEO sitemap URLs:', error)
return new Response('Failed to generate SEO sitemap URLs', { status: 500 })
}
} }
async function generateSEOSitemapUrls(siteUrl: string) { async function generateSEOSitemapUrls(siteUrl: string) {
try { const [categories, attributes] = await Promise.all([
const [categories, attributes] = await Promise.all([ prisma.category.findMany({
prisma.category.findMany({ select: {
select: { name: true,
name: true, namePluralLong: true,
namePluralLong: true, slug: true,
slug: true, icon: true,
icon: true, _count: {
_count: { select: {
select: { services: {
services: { where: {
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'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
}, },
}, },
}, },
}, },
}, },
}), },
prisma.attribute.findMany({ orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
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({ const filtersOptions = makeSearchFiltersOptions({
filters: null, filters: null,
categories, categories,
attributes, attributes,
}) })
const byCategory = filtersOptions.categories.map( const byCategory = filtersOptions.categories.map(
(category) => (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({ new URLSearchParams({
categories: category.slug, [`attr-${attribute.id.toString()}`]: 'yes',
})
)
const withoutOneAttribute = filtersOptions.attributesByCategory
.flatMap(({ attributes }) => attributes)
.map(
(attribute) =>
new URLSearchParams({
[`attr-${attribute.id.toString()}`]: 'no',
}) })
) )
const byVerificationStatus = filtersOptions.verification.map( const byCategoryAndCurrency = filtersOptions.categories.flatMap((category) =>
(status) => filtersOptions.currencies.map(
new URLSearchParams({
verification: status.slug,
})
)
const byKycLevel = filtersOptions.kycLevels.map(
(level) =>
new URLSearchParams({
'max-kyc': level.id,
})
)
const byCurrency = filtersOptions.currencies.map(
(currency) => (currency) =>
new URLSearchParams({ new URLSearchParams({
categories: category.slug,
currencies: currency.slug, currencies: currency.slug,
}) })
) )
)
const withOneAttribute = filtersOptions.attributesByCategory const byCategoryAndAttributes = filtersOptions.categories.flatMap((category) =>
filtersOptions.attributesByCategory
.flatMap(({ attributes }) => attributes) .flatMap(({ attributes }) => attributes)
.map( .flatMap((attribute) => [
(attribute) => new URLSearchParams({
new URLSearchParams({ categories: category.slug,
[`attr-${attribute.id.toString()}`]: 'yes', [`attr-${attribute.id.toString()}`]: 'yes',
}) }),
) new URLSearchParams({
const withoutOneAttribute = filtersOptions.attributesByCategory 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(({ attributes }) => attributes)
.map( .flatMap((attribute) =>
(attribute) => relevantCurrencies.map(
new URLSearchParams({ (currency) =>
[`attr-${attribute.id.toString()}`]: 'no', new URLSearchParams({
}) categories: category.slug,
) currencies: currency,
[`attr-${attribute.id.toString()}`]:
const byCategoryAndCurrency = filtersOptions.categories.flatMap((category) => attribute.type === 'GOOD' || attribute.type === 'INFO' ? 'yes' : 'no',
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 = [ const allQueryParams = [
...byCategory, ...byCategory,
...byVerificationStatus, ...byVerificationStatus,
...byKycLevel, ...byKycLevel,
...byCurrency, ...byCurrency,
...withOneAttribute, ...withOneAttribute,
...withoutOneAttribute, ...withoutOneAttribute,
...byCategoryAndCurrency, ...byCategoryAndCurrency,
...byCategoryAndAttributes, ...byCategoryAndAttributes,
...byCategoryAndAttributesAndRelevantCurrency, ...byCategoryAndAttributesAndRelevantCurrency,
] satisfies URLSearchParams[] ] satisfies URLSearchParams[]
return allQueryParams.map((queryParams) => { return allQueryParams.map((queryParams) => {
const url = new URL(siteUrl) const url = new URL(siteUrl)
url.search = queryParams.toString() url.search = queryParams.toString()
return url.href return url.href
}) })
} catch (error) {
console.error('Failed to generate SEO sitemap URLs:', error)
return []
}
} }