Compare commits
11 Commits
release-92
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a68523fc73 | ||
|
|
a465849a76 | ||
|
|
25f6dba3eb | ||
|
|
7e7046e7d2 | ||
|
|
a5d1fb9a5d | ||
|
|
28b84a7d9b | ||
|
|
7a294cb0a1 | ||
|
|
349c26a4df | ||
|
|
86b1afb2c7 | ||
|
|
99bc1f4e0f | ||
|
|
3166349dfb |
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -7,7 +7,8 @@
|
||||
"golang.go",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"craigrbroughton.htmx-attributes",
|
||||
"nefrob.vscode-just-syntax"
|
||||
"nefrob.vscode-just-syntax",
|
||||
"prisma.prisma"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ export default defineConfig({
|
||||
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;',
|
||||
'Strict-Transport-Security':
|
||||
SITE_URL === 'http://localhost:4321' ? undefined : 'max-age=31536000; includeSubdomains; preload;',
|
||||
},
|
||||
},
|
||||
image: {
|
||||
|
||||
50
web/package-lock.json
generated
50
web/package-lock.json
generated
@@ -11,7 +11,7 @@
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@astrojs/db": "0.15.0",
|
||||
"@astrojs/mdx": "4.3.0",
|
||||
"@astrojs/node": "9.2.2",
|
||||
"@astrojs/node": "9.3.0",
|
||||
"@astrojs/rss": "4.0.12",
|
||||
"@astrojs/sitemap": "3.4.1",
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
@@ -22,7 +22,7 @@
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.10.1",
|
||||
"astro": "5.11.0",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"astro-seo-schema": "5.0.0",
|
||||
@@ -51,8 +51,8 @@
|
||||
"web-push": "3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.0",
|
||||
"@faker-js/faker": "9.8.0",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@faker-js/faker": "9.9.0",
|
||||
"@iconify-json/material-symbols": "1.2.28",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
@@ -72,12 +72,12 @@
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint": "9.30.0",
|
||||
"eslint": "9.30.1",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"globals": "16.2.0",
|
||||
"globals": "16.3.0",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.13",
|
||||
@@ -297,9 +297,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/node": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.2.2.tgz",
|
||||
"integrity": "sha512-PtLPuuojmcl9O3CEvXqL/D+wB4x5DlbrGOvP0MeTAh/VfKFprYAzgw1+45xsnTO+QvPWb26l1cT+ZQvvohmvMw==",
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.3.0.tgz",
|
||||
"integrity": "sha512-IV8NzGStHAsKBz1ljxxD8PBhBfnw/BEx/PZfsncTNXg9D4kQtZbSy+Ak0LvDs+rPmK0VeXLNn0HAdWuHCVg8cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/internal-helpers": "0.6.1",
|
||||
@@ -2641,9 +2641,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz",
|
||||
"integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==",
|
||||
"version": "9.30.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz",
|
||||
"integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2678,9 +2678,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@faker-js/faker": {
|
||||
"version": "9.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.8.0.tgz",
|
||||
"integrity": "sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==",
|
||||
"version": "9.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz",
|
||||
"integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -6333,9 +6333,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/astro": {
|
||||
"version": "5.10.1",
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.10.1.tgz",
|
||||
"integrity": "sha512-DJVmt+51jU1xmgmAHCDwuUgcG/5aVFSU+tcX694acAZqPVt8EMUAmUZcJDX36Z7/EztnPph9HR3pm72jS2EgHQ==",
|
||||
"version": "5.11.0",
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.11.0.tgz",
|
||||
"integrity": "sha512-MEICntERthUxJPSSDsDiZuwiCMrsaYy3fnDhp4c6ScUfldCB8RBnB/myYdpTFXpwYBy6SgVsHQ1H4MuuA7ro/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/compiler": "^2.12.2",
|
||||
@@ -9039,9 +9039,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.30.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz",
|
||||
"integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==",
|
||||
"version": "9.30.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz",
|
||||
"integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9051,7 +9051,7 @@
|
||||
"@eslint/config-helpers": "^0.3.0",
|
||||
"@eslint/core": "^0.14.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.30.0",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@eslint/plugin-kit": "^0.3.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
@@ -10401,9 +10401,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz",
|
||||
"integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
|
||||
"integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@astrojs/db": "0.15.0",
|
||||
"@astrojs/mdx": "4.3.0",
|
||||
"@astrojs/node": "9.2.2",
|
||||
"@astrojs/node": "9.3.0",
|
||||
"@astrojs/rss": "4.0.12",
|
||||
"@astrojs/sitemap": "3.4.1",
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
@@ -38,7 +38,7 @@
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.10.1",
|
||||
"astro": "5.11.0",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"astro-seo-schema": "5.0.0",
|
||||
@@ -67,8 +67,8 @@
|
||||
"web-push": "3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.0",
|
||||
"@faker-js/faker": "9.8.0",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@faker-js/faker": "9.9.0",
|
||||
"@iconify-json/material-symbols": "1.2.28",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
@@ -88,12 +88,12 @@
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint": "9.30.0",
|
||||
"eslint": "9.30.1",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"globals": "16.2.0",
|
||||
"globals": "16.3.0",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.13",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "strictCommentingEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "commentSectionMessage" TEXT;
|
||||
@@ -406,6 +406,9 @@ model Service {
|
||||
Notification Notification[]
|
||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||
|
||||
strictCommentingEnabled Boolean @default(false)
|
||||
commentSectionMessage String?
|
||||
|
||||
@@index([listedAt])
|
||||
@@index([approvedAt])
|
||||
@@index([verifiedAt])
|
||||
|
||||
@@ -720,6 +720,8 @@ const generateFakeService = (users: User[]) => {
|
||||
}),
|
||||
{ probability: 0.33 }
|
||||
),
|
||||
strictCommentingEnabled: faker.datatype.boolean(0.33333),
|
||||
commentSectionMessage: faker.helpers.maybe(() => faker.lorem.paragraph(), { probability: 0.3 }),
|
||||
} as const satisfies Prisma.ServiceCreateInput
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ BEGIN
|
||||
-- and ensure it wasn't already APPROVED.
|
||||
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
||||
-- 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
|
||||
IF service_visibility = 'PUBLIC' THEN
|
||||
|
||||
@@ -63,6 +63,8 @@ const serviceSchemaBase = z.object({
|
||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
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
|
||||
@@ -127,6 +129,8 @@ export const adminServiceActions = {
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||
commentSectionMessage: input.commentSectionMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
@@ -247,6 +251,8 @@ export const adminServiceActions = {
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||
commentSectionMessage: input.commentSectionMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { CommentStatus, Prisma } from '@prisma/client'
|
||||
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
|
||||
const MAX_COMMENTS_PER_WINDOW = 1
|
||||
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
|
||||
export const COMMENT_ORDER_ID_MAX_LENGTH = 600
|
||||
|
||||
export const commentActions = {
|
||||
vote: defineProtectedAction({
|
||||
@@ -103,7 +104,7 @@ export const commentActions = {
|
||||
issueFundsBlocked: z.coerce.boolean().optional(),
|
||||
issueScam: z.coerce.boolean().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) => {
|
||||
if (data.rating && data.parentId) {
|
||||
|
||||
@@ -43,6 +43,7 @@ const Tag = announcement.link ? 'a' : 'div'
|
||||
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
|
||||
className
|
||||
)}
|
||||
aria-label="Announcement banner"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
@@ -85,7 +86,7 @@ const Tag = announcement.link ? 'a' : '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]">
|
||||
<span class="2xs:inline-block hidden">{announcement.linkText}</span>
|
||||
<Icon
|
||||
|
||||
@@ -33,6 +33,7 @@ type Props = HTMLAttributes<'div'> & {
|
||||
highlightedCommentId: number | null
|
||||
serviceSlug: string
|
||||
itemReviewedId: string
|
||||
strictCommentingEnabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -42,6 +43,7 @@ const {
|
||||
highlightedCommentId = null,
|
||||
serviceSlug,
|
||||
itemReviewedId,
|
||||
strictCommentingEnabled,
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
@@ -492,6 +494,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
serviceId={comment.serviceId}
|
||||
parentId={comment.id}
|
||||
commentId={comment.id}
|
||||
strictCommentingEnabled={strictCommentingEnabled}
|
||||
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import { COMMENT_ORDER_ID_MAX_LENGTH } from '../actions/comment'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||
|
||||
@@ -20,6 +22,8 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
||||
serviceId: number
|
||||
parentId?: number
|
||||
commentId?: number
|
||||
strictCommentingEnabled?: boolean
|
||||
commentSectionMessage?: string | null
|
||||
activeRatingComment?: Prisma.CommentGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
@@ -28,7 +32,16 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
||||
}> | 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
|
||||
|
||||
@@ -88,69 +101,83 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
||||
</div>
|
||||
|
||||
{!parentId ? (
|
||||
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<InputRating name="rating" label="Rating" />
|
||||
<>
|
||||
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<InputRating name="rating" label="Rating" />
|
||||
|
||||
<InputWrapper label="I experienced..." name="tags">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
<Icon name="ri:user-forbid-fill" class="size-3" />
|
||||
KYC Issue
|
||||
</span>
|
||||
</label>
|
||||
<InputWrapper label="I experienced..." name="tags">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
<Icon name="ri:user-forbid-fill" class="size-3" />
|
||||
KYC Issue
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-orange-400">
|
||||
<Icon name="ri:wallet-3-fill" class="size-3" />
|
||||
Funds Blocked
|
||||
</span>
|
||||
</label>
|
||||
</InputWrapper>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-orange-400">
|
||||
<Icon name="ri:wallet-3-fill" class="size-3" />
|
||||
Funds Blocked
|
||||
</span>
|
||||
</label>
|
||||
</InputWrapper>
|
||||
|
||||
<InputText
|
||||
label="Order ID"
|
||||
name="orderId"
|
||||
inputProps={{
|
||||
maxlength: 100,
|
||||
placeholder: 'Order ID / URL / Proof',
|
||||
class: 'bg-night-800',
|
||||
}}
|
||||
descriptionLabel="Only visible to admins, to verify your comment"
|
||||
class="grow"
|
||||
/>
|
||||
</div>
|
||||
<InputText
|
||||
label="Order ID"
|
||||
name="orderId"
|
||||
inputProps={{
|
||||
maxlength: COMMENT_ORDER_ID_MAX_LENGTH,
|
||||
placeholder: 'Order ID / URL / Proof',
|
||||
class: 'bg-night-800',
|
||||
required: strictCommentingEnabled,
|
||||
}}
|
||||
descriptionLabel="Only visible to admins, to verify your comment"
|
||||
class="grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-start justify-end gap-2">
|
||||
{!!activeRatingComment?.rating && (
|
||||
<div
|
||||
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
||||
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"
|
||||
<div class="mt-4 flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
|
||||
{!!activeRatingComment?.rating && (
|
||||
<div
|
||||
class="mt-1 rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
||||
data-show-if-rating
|
||||
>
|
||||
Your previous rating
|
||||
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
||||
</a>
|
||||
of
|
||||
{[
|
||||
activeRatingComment.rating.toLocaleString(),
|
||||
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
|
||||
]}
|
||||
won't count for the total.
|
||||
</div>
|
||||
)}
|
||||
<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:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
||||
</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 class="flex items-center justify-end gap-2">
|
||||
<Button type="submit" label="Reply" icon="ri:reply-line" />
|
||||
|
||||
@@ -35,6 +35,8 @@ type Props = {
|
||||
name: true
|
||||
description: true
|
||||
createdAt: true
|
||||
strictCommentingEnabled: true
|
||||
commentSectionMessage: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
@@ -173,7 +175,13 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
||||
comment: comments.map(makeReplySchema),
|
||||
} 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="flex items-center">
|
||||
@@ -258,6 +266,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
||||
showPending={params.showPending}
|
||||
serviceSlug={service.slug}
|
||||
itemReviewedId={itemReviewedId}
|
||||
strictCommentingEnabled={service.strictCommentingEnabled}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -37,6 +37,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
||||
}
|
||||
)}
|
||||
transition:name="header-container"
|
||||
aria-label="Header"
|
||||
>
|
||||
<nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}>
|
||||
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
|
||||
disabled?: boolean
|
||||
checked?: boolean
|
||||
descriptionInline?: 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
|
||||
---
|
||||
|
||||
{}
|
||||
|
||||
<div>
|
||||
<label
|
||||
class={cn(
|
||||
@@ -41,9 +41,11 @@ const hasError = !!error && error.length > 0
|
||||
name={name}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
class={cn(disabled && 'opacity-50')}
|
||||
/>
|
||||
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
|
||||
{descriptionInline && <p class="text-day-400 text-xs">{descriptionInline}</p>}
|
||||
</label>
|
||||
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ import Tooltip from './Tooltip.astro'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'a'> & {
|
||||
type Props = HTMLAttributes<'article'> & {
|
||||
inlineIcons?: boolean
|
||||
withoutLink?: boolean
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
@@ -57,7 +57,7 @@ const {
|
||||
},
|
||||
class: className,
|
||||
withoutLink = false,
|
||||
...aProps
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const statusIcon = {
|
||||
@@ -70,127 +70,129 @@ const Element = withoutLink ? 'div' : 'a'
|
||||
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
---
|
||||
|
||||
<Element
|
||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||
{...aProps}
|
||||
class={cn(
|
||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<!-- Header with Icon and Title -->
|
||||
<div class="flex items-center gap-(--gap)">
|
||||
<MyPicture
|
||||
src={imageUrl}
|
||||
fallback="service"
|
||||
alt={name || 'Service logo'}
|
||||
class={cn(
|
||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||
)}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<article {...htmlProps}>
|
||||
<Element
|
||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||
aria-label={Element === 'a' ? name : undefined}
|
||||
class={cn(
|
||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<!-- Header with Icon and Title -->
|
||||
<div class="flex items-center gap-(--gap)">
|
||||
<MyPicture
|
||||
src={imageUrl}
|
||||
fallback="service"
|
||||
alt="Logo"
|
||||
class={cn(
|
||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||
)}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
||||
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||
{name}{
|
||||
statusIcon && (
|
||||
<Tooltip
|
||||
text={statusIcon.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
||||
>
|
||||
{[
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
||||
<h1 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||
{name}{
|
||||
statusIcon && (
|
||||
<Tooltip
|
||||
text={statusIcon.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
||||
>
|
||||
{[
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={statusIcon.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
||||
statusIcon.classNames.icon
|
||||
)}
|
||||
/>,
|
||||
verificationStatus === 'VERIFICATION_FAILED' && (
|
||||
<span class="text-sm font-bold text-red-500">SCAM</span>
|
||||
),
|
||||
]}
|
||||
</Tooltip>
|
||||
)
|
||||
}{
|
||||
serviceVisibility === 'ARCHIVED' && (
|
||||
<Tooltip
|
||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={statusIcon.icon}
|
||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
||||
statusIcon.classNames.icon
|
||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||
)}
|
||||
/>,
|
||||
verificationStatus === 'VERIFICATION_FAILED' && (
|
||||
<span class="text-sm font-bold text-red-500">SCAM</span>
|
||||
),
|
||||
]}
|
||||
</Tooltip>
|
||||
)
|
||||
}{
|
||||
serviceVisibility === 'ARCHIVED' && (
|
||||
<Tooltip
|
||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h3>
|
||||
<div class="max-h-2 flex-1"></div>
|
||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
<div class="max-h-2 flex-1" aria-hidden="true"></div>
|
||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
||||
{
|
||||
categories.map((category) => (
|
||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||
<span>{category.name}</span>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start">
|
||||
<Tooltip
|
||||
class={cn(
|
||||
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
|
||||
overallScoreInfo.classNameBg
|
||||
)}
|
||||
text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
|
||||
>
|
||||
{overallScoreInfo.formattedScore}
|
||||
</Tooltip>
|
||||
|
||||
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
||||
KYC {kycLevel.toLocaleString()}
|
||||
</span>
|
||||
|
||||
<div class="-m-1 ml-auto flex">
|
||||
{
|
||||
categories.map((category) => (
|
||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||
<span>{category.name}</span>
|
||||
</span>
|
||||
))
|
||||
currencies.map((currency) => {
|
||||
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||
|
||||
return (
|
||||
<Tooltip text={currency.name}>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={currency.icon}
|
||||
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start">
|
||||
<Tooltip
|
||||
class={cn(
|
||||
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
|
||||
overallScoreInfo.classNameBg
|
||||
)}
|
||||
text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
|
||||
>
|
||||
{overallScoreInfo.formattedScore}
|
||||
</Tooltip>
|
||||
|
||||
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
||||
KYC {kycLevel.toLocaleString()}
|
||||
</span>
|
||||
|
||||
<div class="-m-1 ml-auto flex">
|
||||
{
|
||||
currencies.map((currency) => {
|
||||
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||
|
||||
return (
|
||||
<Tooltip text={currency.name}>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={currency.icon}
|
||||
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Element>
|
||||
</Element>
|
||||
</article>
|
||||
|
||||
@@ -39,6 +39,7 @@ const makeUrlWithoutFilter = (filter: string, value?: string) => {
|
||||
'bg-night-800 hover:bg-night-900 border-night-400 flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm text-white',
|
||||
className
|
||||
)}
|
||||
aria-label={`Remove filter: ${text}`}
|
||||
>
|
||||
{icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
|
||||
{text}
|
||||
|
||||
@@ -32,6 +32,7 @@ type Props = HTMLAttributes<'div'> & {
|
||||
}
|
||||
}>[]
|
||||
attributeOptions: AttributeOption[]
|
||||
inlineIcons?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -41,6 +42,7 @@ const {
|
||||
categories,
|
||||
attributes,
|
||||
attributeOptions,
|
||||
inlineIcons = true,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
---
|
||||
@@ -50,11 +52,17 @@ const {
|
||||
'not-pointer-coarse:no-scrollbar -ml-4 flex shrink grow items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4',
|
||||
className
|
||||
)}
|
||||
aria-label="Applied filters"
|
||||
{...divProps}
|
||||
>
|
||||
{
|
||||
filters.q && (
|
||||
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
|
||||
<ServiceFiltersPill
|
||||
text={`"${filters.q}"`}
|
||||
searchParamName="q"
|
||||
searchParamValue={filters.q}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,6 +77,7 @@ const {
|
||||
icon={category.icon}
|
||||
searchParamName="categories"
|
||||
searchParamValue={categorySlug}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -83,6 +92,7 @@ const {
|
||||
searchParamName="currencies"
|
||||
searchParamValue={currency.slug}
|
||||
icon={currency.icon}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -97,6 +107,7 @@ const {
|
||||
icon={networkOption.icon}
|
||||
searchParamName="networks"
|
||||
searchParamValue={network}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -107,6 +118,7 @@ const {
|
||||
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
||||
icon="ri:shield-keyhole-line"
|
||||
searchParamName="max-kyc"
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -116,6 +128,7 @@ const {
|
||||
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
||||
icon="ri:star-fill"
|
||||
searchParamName="user-rating"
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -125,6 +138,7 @@ const {
|
||||
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
||||
icon="ri:medal-line"
|
||||
searchParamName="min-score"
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -135,6 +149,7 @@ const {
|
||||
icon="ri:filter-3-line"
|
||||
searchParamName="attribute-mode"
|
||||
searchParamValue="and"
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -152,6 +167,7 @@ const {
|
||||
text={`${prefix}: ${attribute.title}`}
|
||||
searchParamName={`attr-${attributeId}`}
|
||||
searchParamValue={attributeValue}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -176,6 +192,7 @@ const {
|
||||
iconClass={verificationStatusInfo.classNames.icon}
|
||||
searchParamName="verification"
|
||||
searchParamValue={verificationStatusInfo.slug}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -414,7 +414,10 @@ const {
|
||||
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
|
||||
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}
|
||||
</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)}
|
||||
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}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Icon } from 'astro-icon/components'
|
||||
import { uniq, orderBy } from 'lodash-es'
|
||||
|
||||
import { getCurrencyInfo } from '../constants/currencies'
|
||||
import { getKycLevelInfo } from '../constants/kycLevels'
|
||||
import { verificationStatusesByValue, getVerificationStatusInfo } from '../constants/verificationStatus'
|
||||
import { areEqualArraysWithoutOrder } from '../lib/arrays'
|
||||
import { cn } from '../lib/cn'
|
||||
@@ -160,8 +159,7 @@ const searchTitle = (() => {
|
||||
}
|
||||
|
||||
if (filters['max-kyc'] === 0) {
|
||||
const kycLevelInfo = getKycLevelInfo(String(filters['max-kyc']))
|
||||
kycLevel = `with ${kycLevelInfo.name}`
|
||||
kycLevel = 'without KYC'
|
||||
prefix = ''
|
||||
} else if (filters['max-kyc'] <= 3) {
|
||||
kycLevel = `with KYC level ${filters['max-kyc']} or better`
|
||||
@@ -192,6 +190,7 @@ const searchTitle = (() => {
|
||||
categories={categories}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
inlineIcons={inlineIcons}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -203,6 +202,7 @@ const searchTitle = (() => {
|
||||
name="ri:loader-4-line"
|
||||
id="search-indicator"
|
||||
class="htmx-request:opacity-100 xs:-mx-1.5 -mx-1 inline-block size-4 animate-spin text-white opacity-0 transition-opacity duration-500 sm:-mx-3"
|
||||
aria-hidden="true"
|
||||
is:inline={inlineIcons}
|
||||
/>
|
||||
|
||||
@@ -348,11 +348,9 @@ const searchTitle = (() => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
|
||||
<ol class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
|
||||
{services.map((service, i) => (
|
||||
<ServiceCard
|
||||
inlineIcons={inlineIcons}
|
||||
service={service}
|
||||
<li
|
||||
data-hx-search-results-card
|
||||
{...(i === services.length - 1 && currentPage < totalPages
|
||||
? {
|
||||
@@ -363,9 +361,11 @@ const searchTitle = (() => {
|
||||
'hx-indicator': '#infinite-scroll-indicator',
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
>
|
||||
<ServiceCard inlineIcons={inlineIcons} service={service} />
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ol>
|
||||
|
||||
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
|
||||
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
|
||||
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
enabled && (
|
||||
<span
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
class={cn(
|
||||
'pointer-events-none hidden select-none group-hover/tooltip:flex',
|
||||
'ease-out-cubic scale-75 opacity-0 transition-all transition-discrete duration-100 group-hover/tooltip:scale-100 group-hover/tooltip:opacity-100 starting:group-hover/tooltip:scale-75 starting:group-hover/tooltip:opacity-0',
|
||||
|
||||
33
web/src/constants/readStatus.ts
Normal file
33
web/src/constants/readStatus.ts
Normal 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[]
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import { Icon } from 'astro-icon/components'
|
||||
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||
import CommentModeration from '../../components/CommentModeration.astro'
|
||||
import MyPicture from '../../components/MyPicture.astro'
|
||||
import Pagination from '../../components/Pagination.astro'
|
||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||
import UserBadge from '../../components/UserBadge.astro'
|
||||
import {
|
||||
@@ -27,7 +28,7 @@ if (!user || (!user.admin && !user.moderator)) {
|
||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
status: commentStatusFiltersZodEnum.default('all'),
|
||||
page: z.number().int().positive().default(1),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
@@ -241,29 +242,5 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{
|
||||
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>
|
||||
)
|
||||
}
|
||||
{totalPages > 1 && <Pagination currentPage={params.page} totalPages={totalPages} class="mt-8" />}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from '../../../../components/Button.astro'
|
||||
import FormSection from '../../../../components/FormSection.astro'
|
||||
import FormSubSection from '../../../../components/FormSubSection.astro'
|
||||
import InputCardGroup from '../../../../components/InputCardGroup.astro'
|
||||
import InputCheckbox from '../../../../components/InputCheckbox.astro'
|
||||
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
|
||||
import InputImageFile from '../../../../components/InputImageFile.astro'
|
||||
import InputSelect from '../../../../components/InputSelect.astro'
|
||||
@@ -545,6 +546,24 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
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 />
|
||||
</form>
|
||||
</FormSection>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AttributeCategory, Currency, VerificationStatus } from '@prisma/client'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
|
||||
import InputCheckbox from '../../../components/InputCheckbox.astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../../lib/cn'
|
||||
import { prisma } from '../../../lib/prisma'
|
||||
@@ -368,6 +369,35 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
}
|
||||
</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
|
||||
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"
|
||||
|
||||
@@ -467,6 +467,7 @@ const showFiltersId = 'show-filters'
|
||||
categories={categories}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
inlineIcons={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -492,6 +493,7 @@ const showFiltersId = 'show-filters'
|
||||
/>
|
||||
<div
|
||||
class="bg-night-700 fixed top-0 left-0 z-50 hidden h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 max-sm:peer-checked:block sm:relative sm:z-auto sm:block sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0"
|
||||
aria-label="Search filters"
|
||||
>
|
||||
<ServicesFilters
|
||||
searchResultsId={searchResultsId}
|
||||
@@ -519,6 +521,7 @@ const showFiltersId = 'show-filters'
|
||||
filtersOptions={filtersOptions}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
aria-label="Search results"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -5,10 +5,12 @@ import { actions } from 'astro:actions'
|
||||
|
||||
import Button from '../components/Button.astro'
|
||||
import CopyButton from '../components/CopyButton.astro'
|
||||
import Pagination from '../components/Pagination.astro'
|
||||
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
|
||||
import TimeFormatted from '../components/TimeFormatted.astro'
|
||||
import Tooltip from '../components/Tooltip.astro'
|
||||
import { getNotificationTypeInfo } from '../constants/notificationTypes'
|
||||
import { getReadStatus, readStatusZodEnum } from '../constants/readStatus'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import { cn } from '../lib/cn'
|
||||
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
||||
@@ -16,6 +18,10 @@ import { makeNotificationActions, makeNotificationContent, makeNotificationTitle
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
import { prisma } from '../lib/prisma'
|
||||
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
|
||||
if (!user) return Astro.redirect(makeLoginUrl(Astro.url))
|
||||
@@ -25,20 +31,26 @@ const PAGE_SIZE = 20
|
||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
readStatus: readStatusZodEnum.optional(),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
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] =
|
||||
await Astro.locals.banners.tryMany([
|
||||
[
|
||||
'Error while fetching notifications',
|
||||
() =>
|
||||
prisma.notification.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
where: notificationWhereClause,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
@@ -153,7 +165,7 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
|
||||
],
|
||||
[
|
||||
'Error while fetching total notifications',
|
||||
() => prisma.notification.count({ where: { userId: user.id } }),
|
||||
() => prisma.notification.count({ where: notificationWhereClause }),
|
||||
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">
|
||||
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
|
||||
Notifications
|
||||
|
||||
{transformCase(`${readStatusInfo?.label ?? ''} notifications`.trim(), 'sentence')}
|
||||
</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="read" value="true" />
|
||||
<Button
|
||||
@@ -243,6 +260,25 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
disabled={notifications.length === 0}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -323,31 +359,12 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
))}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div class="mt-8 flex justify-center gap-4">
|
||||
<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 <= 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>
|
||||
<Pagination
|
||||
currentPage={params.page}
|
||||
totalPages={totalPages}
|
||||
currentUrl={Astro.url}
|
||||
class="mt-8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -102,6 +102,8 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
userSentimentAt: true,
|
||||
averageUserRating: true,
|
||||
isRecentlyApproved: true,
|
||||
strictCommentingEnabled: true,
|
||||
commentSectionMessage: true,
|
||||
contactMethods: {
|
||||
select: {
|
||||
value: true,
|
||||
@@ -1533,6 +1535,20 @@ const activeEventToShow =
|
||||
<li>Moderation is light.</li>
|
||||
<li>Double-check before trusting.</li>
|
||||
</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">
|
||||
<Icon name="ri:alert-line" class="xs:opacity-20 h-full max-h-16 w-auto opacity-10" />
|
||||
</div>
|
||||
|
||||
@@ -7,186 +7,184 @@ 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 })
|
||||
}
|
||||
if (!site) 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"?>
|
||||
<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',
|
||||
},
|
||||
})
|
||||
return new Response(result, {
|
||||
headers: {
|
||||
'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) {
|
||||
try {
|
||||
const [categories, attributes] = await Promise.all([
|
||||
prisma.category.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
namePluralLong: true,
|
||||
slug: true,
|
||||
icon: true,
|
||||
_count: {
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
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'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
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' }],
|
||||
}),
|
||||
])
|
||||
},
|
||||
orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const filtersOptions = makeSearchFiltersOptions({
|
||||
filters: null,
|
||||
categories,
|
||||
attributes,
|
||||
})
|
||||
const filtersOptions = makeSearchFiltersOptions({
|
||||
filters: null,
|
||||
categories,
|
||||
attributes,
|
||||
})
|
||||
|
||||
const byCategory = filtersOptions.categories.map(
|
||||
(category) =>
|
||||
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({
|
||||
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(
|
||||
(status) =>
|
||||
new URLSearchParams({
|
||||
verification: status.slug,
|
||||
})
|
||||
)
|
||||
|
||||
const byKycLevel = filtersOptions.kycLevels.map(
|
||||
(level) =>
|
||||
new URLSearchParams({
|
||||
'max-kyc': level.id,
|
||||
})
|
||||
)
|
||||
|
||||
const byCurrency = filtersOptions.currencies.map(
|
||||
const byCategoryAndCurrency = filtersOptions.categories.flatMap((category) =>
|
||||
filtersOptions.currencies.map(
|
||||
(currency) =>
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
currencies: currency.slug,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const withOneAttribute = filtersOptions.attributesByCategory
|
||||
const byCategoryAndAttributes = filtersOptions.categories.flatMap((category) =>
|
||||
filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.map(
|
||||
(attribute) =>
|
||||
new URLSearchParams({
|
||||
[`attr-${attribute.id.toString()}`]: 'yes',
|
||||
})
|
||||
)
|
||||
const withoutOneAttribute = filtersOptions.attributesByCategory
|
||||
.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)
|
||||
.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',
|
||||
})
|
||||
)
|
||||
.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,
|
||||
const allQueryParams = [
|
||||
...byCategory,
|
||||
...byVerificationStatus,
|
||||
...byKycLevel,
|
||||
...byCurrency,
|
||||
...withOneAttribute,
|
||||
...withoutOneAttribute,
|
||||
|
||||
...byCategoryAndCurrency,
|
||||
...byCategoryAndAttributes,
|
||||
...byCategoryAndAttributesAndRelevantCurrency,
|
||||
] satisfies URLSearchParams[]
|
||||
...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 []
|
||||
}
|
||||
return allQueryParams.map((queryParams) => {
|
||||
const url = new URL(siteUrl)
|
||||
url.search = queryParams.toString()
|
||||
return url.href
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user