Compare commits
6 Commits
release-95
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f6dba3eb | ||
|
|
7e7046e7d2 | ||
|
|
a5d1fb9a5d | ||
|
|
28b84a7d9b | ||
|
|
7a294cb0a1 | ||
|
|
349c26a4df |
@@ -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[]
|
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])
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-label="Announcement banner"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -85,7 +86,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
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
transition:name="header-container"
|
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)}>
|
<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">
|
<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'> & {
|
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>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Tooltip from './Tooltip.astro'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
type Props = HTMLAttributes<'a'> & {
|
type Props = HTMLAttributes<'article'> & {
|
||||||
inlineIcons?: boolean
|
inlineIcons?: boolean
|
||||||
withoutLink?: boolean
|
withoutLink?: boolean
|
||||||
service: Prisma.ServiceGetPayload<{
|
service: Prisma.ServiceGetPayload<{
|
||||||
@@ -57,7 +57,7 @@ const {
|
|||||||
},
|
},
|
||||||
class: className,
|
class: className,
|
||||||
withoutLink = false,
|
withoutLink = false,
|
||||||
...aProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
const statusIcon = {
|
const statusIcon = {
|
||||||
@@ -70,127 +70,129 @@ const Element = withoutLink ? 'div' : 'a'
|
|||||||
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||||
---
|
---
|
||||||
|
|
||||||
<Element
|
<article {...htmlProps}>
|
||||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
<Element
|
||||||
{...aProps}
|
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||||
class={cn(
|
aria-label={Element === 'a' ? name : undefined}
|
||||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
class={cn(
|
||||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
className
|
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||||
)}
|
className
|
||||||
>
|
)}
|
||||||
<!-- Header with Icon and Title -->
|
>
|
||||||
<div class="flex items-center gap-(--gap)">
|
<!-- Header with Icon and Title -->
|
||||||
<MyPicture
|
<div class="flex items-center gap-(--gap)">
|
||||||
src={imageUrl}
|
<MyPicture
|
||||||
fallback="service"
|
src={imageUrl}
|
||||||
alt={name || 'Service logo'}
|
fallback="service"
|
||||||
class={cn(
|
alt="Logo"
|
||||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
class={cn(
|
||||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
)}
|
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||||
width={48}
|
)}
|
||||||
height={48}
|
width={48}
|
||||||
/>
|
height={48}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
<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">
|
<h1 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||||
{name}{
|
{name}{
|
||||||
statusIcon && (
|
statusIcon && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={statusIcon.label}
|
text={statusIcon.label}
|
||||||
position="right"
|
position="right"
|
||||||
class="-my-2 shrink-0 whitespace-nowrap"
|
class="-my-2 shrink-0 whitespace-nowrap"
|
||||||
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
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
|
<Icon
|
||||||
is:inline={inlineIcons}
|
is:inline={inlineIcons}
|
||||||
name={statusIcon.icon}
|
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||||
class={cn(
|
class={cn(
|
||||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||||
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||||
statusIcon.classNames.icon
|
|
||||||
)}
|
)}
|
||||||
/>,
|
/>
|
||||||
verificationStatus === 'VERIFICATION_FAILED' && (
|
</Tooltip>
|
||||||
<span class="text-sm font-bold text-red-500">SCAM</span>
|
)
|
||||||
),
|
}
|
||||||
]}
|
</h1>
|
||||||
</Tooltip>
|
<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)]">
|
||||||
}{
|
{
|
||||||
serviceVisibility === 'ARCHIVED' && (
|
categories.map((category) => (
|
||||||
<Tooltip
|
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||||
position="right"
|
<span>{category.name}</span>
|
||||||
class="-my-2 shrink-0 whitespace-nowrap"
|
</span>
|
||||||
>
|
))
|
||||||
<Icon
|
}
|
||||||
is:inline={inlineIcons}
|
</div>
|
||||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
</div>
|
||||||
class={cn(
|
</div>
|
||||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
|
||||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
<div class="flex-1">
|
||||||
)}
|
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||||
/>
|
{description}
|
||||||
</Tooltip>
|
</p>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
</h3>
|
<div class="flex items-center justify-start">
|
||||||
<div class="max-h-2 flex-1"></div>
|
<Tooltip
|
||||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
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) => (
|
currencies.map((currency) => {
|
||||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
|
||||||
<span>{category.name}</span>
|
return (
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</Element>
|
||||||
|
</article>
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -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',
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-label={`Remove filter: ${text}`}
|
||||||
>
|
>
|
||||||
{icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
|
{icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
}
|
}
|
||||||
}>[]
|
}>[]
|
||||||
attributeOptions: AttributeOption[]
|
attributeOptions: AttributeOption[]
|
||||||
|
inlineIcons?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -41,6 +42,7 @@ const {
|
|||||||
categories,
|
categories,
|
||||||
attributes,
|
attributes,
|
||||||
attributeOptions,
|
attributeOptions,
|
||||||
|
inlineIcons = true,
|
||||||
...divProps
|
...divProps
|
||||||
} = Astro.props
|
} = 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',
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-label="Applied filters"
|
||||||
{...divProps}
|
{...divProps}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
filters.q && (
|
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}
|
icon={category.icon}
|
||||||
searchParamName="categories"
|
searchParamName="categories"
|
||||||
searchParamValue={categorySlug}
|
searchParamValue={categorySlug}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -83,6 +92,7 @@ const {
|
|||||||
searchParamName="currencies"
|
searchParamName="currencies"
|
||||||
searchParamValue={currency.slug}
|
searchParamValue={currency.slug}
|
||||||
icon={currency.icon}
|
icon={currency.icon}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -97,6 +107,7 @@ const {
|
|||||||
icon={networkOption.icon}
|
icon={networkOption.icon}
|
||||||
searchParamName="networks"
|
searchParamName="networks"
|
||||||
searchParamValue={network}
|
searchParamValue={network}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -107,6 +118,7 @@ const {
|
|||||||
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
||||||
icon="ri:shield-keyhole-line"
|
icon="ri:shield-keyhole-line"
|
||||||
searchParamName="max-kyc"
|
searchParamName="max-kyc"
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,6 +128,7 @@ const {
|
|||||||
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
||||||
icon="ri:star-fill"
|
icon="ri:star-fill"
|
||||||
searchParamName="user-rating"
|
searchParamName="user-rating"
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -125,6 +138,7 @@ const {
|
|||||||
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
||||||
icon="ri:medal-line"
|
icon="ri:medal-line"
|
||||||
searchParamName="min-score"
|
searchParamName="min-score"
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -135,6 +149,7 @@ const {
|
|||||||
icon="ri:filter-3-line"
|
icon="ri:filter-3-line"
|
||||||
searchParamName="attribute-mode"
|
searchParamName="attribute-mode"
|
||||||
searchParamValue="and"
|
searchParamValue="and"
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -152,6 +167,7 @@ const {
|
|||||||
text={`${prefix}: ${attribute.title}`}
|
text={`${prefix}: ${attribute.title}`}
|
||||||
searchParamName={`attr-${attributeId}`}
|
searchParamName={`attr-${attributeId}`}
|
||||||
searchParamValue={attributeValue}
|
searchParamValue={attributeValue}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -176,6 +192,7 @@ const {
|
|||||||
iconClass={verificationStatusInfo.classNames.icon}
|
iconClass={verificationStatusInfo.classNames.icon}
|
||||||
searchParamName="verification"
|
searchParamName="verification"
|
||||||
searchParamValue={verificationStatusInfo.slug}
|
searchParamValue={verificationStatusInfo.slug}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -192,6 +190,7 @@ const searchTitle = (() => {
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
attributeOptions={attributeOptions}
|
attributeOptions={attributeOptions}
|
||||||
|
inlineIcons={inlineIcons}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -203,6 +202,7 @@ const searchTitle = (() => {
|
|||||||
name="ri:loader-4-line"
|
name="ri:loader-4-line"
|
||||||
id="search-indicator"
|
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"
|
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}
|
is:inline={inlineIcons}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -348,24 +348,26 @@ const searchTitle = (() => {
|
|||||||
</div>
|
</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) => (
|
{services.map((service, i) => (
|
||||||
<ServiceCard
|
<li>
|
||||||
inlineIcons={inlineIcons}
|
<ServiceCard
|
||||||
service={service}
|
inlineIcons={inlineIcons}
|
||||||
data-hx-search-results-card
|
service={service}
|
||||||
{...(i === services.length - 1 && currentPage < totalPages
|
data-hx-search-results-card
|
||||||
? {
|
{...(i === services.length - 1 && currentPage < totalPages
|
||||||
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
|
? {
|
||||||
'hx-trigger': 'revealed',
|
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
|
||||||
'hx-swap': 'afterend',
|
'hx-trigger': 'revealed',
|
||||||
'hx-select': '[data-hx-search-results-card]',
|
'hx-swap': 'afterend',
|
||||||
'hx-indicator': '#infinite-scroll-indicator',
|
'hx-select': '[data-hx-search-results-card]',
|
||||||
}
|
'hx-indicator': '#infinite-scroll-indicator',
|
||||||
: {})}
|
}
|
||||||
/>
|
: {})}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ol>
|
||||||
|
|
||||||
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
|
<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">
|
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const {
|
|||||||
enabled && (
|
enabled && (
|
||||||
<span
|
<span
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
class={cn(
|
class={cn(
|
||||||
'pointer-events-none hidden select-none group-hover/tooltip:flex',
|
'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',
|
'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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ const showFiltersId = 'show-filters'
|
|||||||
categories={categories}
|
categories={categories}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
attributeOptions={attributeOptions}
|
attributeOptions={attributeOptions}
|
||||||
|
inlineIcons={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -492,6 +493,7 @@ const showFiltersId = 'show-filters'
|
|||||||
/>
|
/>
|
||||||
<div
|
<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"
|
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
|
<ServicesFilters
|
||||||
searchResultsId={searchResultsId}
|
searchResultsId={searchResultsId}
|
||||||
@@ -519,6 +521,7 @@ const showFiltersId = 'show-filters'
|
|||||||
filtersOptions={filtersOptions}
|
filtersOptions={filtersOptions}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
attributeOptions={attributeOptions}
|
attributeOptions={attributeOptions}
|
||||||
|
aria-label="Search results"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user