Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
8bdbe8ea36 small updates 2025-05-20 10:29:03 +00:00
pluja
af7ebe813b announcements style 2025-05-20 10:20:09 +00:00
pluja
dabc4e5c47 donation component 2025-05-20 08:02:55 +00:00
pluja
af3df8f79a Release 2025-05-20-0D8p 2025-05-20 01:47:50 +00:00
49 changed files with 1997 additions and 696 deletions

View File

@@ -143,7 +143,12 @@
<BaseLayout <BaseLayout
pageTitle="Edit service" pageTitle="Edit service"
description="Suggest an edit to service" description="Suggest an edit to service"
ogImage={{ template: 'generic', title: 'Edit service' }} ogImage={{
template: 'generic',
title: 'Edit service',
description: 'Suggest an edit to service',
icon: 'ri:edit-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
> >
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1> <h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>

View File

@@ -42,6 +42,10 @@ export default defineConfig({
open: false, open: false,
allowedHosts: [new URL(SITE_URL).hostname], allowedHosts: [new URL(SITE_URL).hostname],
}, },
image: {
domains: [new URL(SITE_URL).hostname],
remotePatterns: [{ protocol: 'https' }],
},
redirects: { redirects: {
// #region Redirects from old website // #region Redirects from old website
'/pending': '/?verification=verified&verification=approved&verification=community', '/pending': '/?verification=verified&verification=approved&verification=community',

878
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
"@astrojs/sitemap": "3.4.0", "@astrojs/sitemap": "3.4.0",
"@fontsource-variable/space-grotesk": "5.2.7", "@fontsource-variable/space-grotesk": "5.2.7",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.5",
"@fontsource/space-grotesk": "5.2.7",
"@prisma/client": "6.8.2", "@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7", "@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
@@ -43,10 +44,12 @@
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.0.1", "redis": "5.0.1",
"schema-dts": "1.1.5", "schema-dts": "1.1.5",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"sharp": "0.34.1",
"slugify": "1.6.6", "slugify": "1.6.6",
"tailwind-merge": "3.3.0", "tailwind-merge": "3.3.0",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
@@ -66,6 +69,7 @@
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5",
"@types/react": "19.1.4", "@types/react": "19.1.4",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@typescript-eslint/parser": "8.32.1", "@typescript-eslint/parser": "8.32.1",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Announcement" ADD COLUMN "link" TEXT;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `title` on the `Announcement` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Announcement" DROP COLUMN "title";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Announcement" ADD COLUMN "linkText" TEXT;

View File

@@ -613,9 +613,10 @@ model ServiceUser {
model Announcement { model Announcement {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String
content String content String
type AnnouncementType type AnnouncementType
link String?
linkText String?
startDate DateTime startDate DateTime
endDate DateTime? endDate DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)

View File

@@ -14,6 +14,7 @@ import {
EventType, EventType,
type User, type User,
ServiceUserRole, ServiceUserRole,
AnnouncementType,
} from '@prisma/client' } from '@prisma/client'
import { uniqBy } from 'lodash-es' import { uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
@@ -981,6 +982,22 @@ const generateFakeInternalNote = (userId: number, addedByUserId?: number) =>
addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined, addedByUser: addedByUserId ? { connect: { id: addedByUserId } } : undefined,
}) satisfies Prisma.InternalUserNoteCreateInput }) satisfies Prisma.InternalUserNoteCreateInput
const generateFakeAnnouncement = () => {
const type = faker.helpers.arrayElement(Object.values(AnnouncementType))
const startDate = faker.date.past()
const endDate = faker.helpers.maybe(() => faker.date.future(), { probability: 0.3 })
return {
content: faker.lorem.sentence(),
type,
link: faker.internet.url(),
linkText: faker.lorem.word({ length: 2 }),
startDate,
endDate,
isActive: true,
} as const satisfies Prisma.AnnouncementCreateInput
}
async function runFaker() { async function runFaker() {
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
@@ -1004,6 +1021,7 @@ async function runFaker() {
await tx.category.deleteMany() await tx.category.deleteMany()
await tx.internalUserNote.deleteMany() await tx.internalUserNote.deleteMany()
await tx.user.deleteMany() await tx.user.deleteMany()
await tx.announcement.deleteMany()
console.info('✅ Existing data cleaned up') console.info('✅ Existing data cleaned up')
} catch (error) { } catch (error) {
console.error('❌ Error cleaning up data:', error) console.error('❌ Error cleaning up data:', error)
@@ -1307,6 +1325,11 @@ async function runFaker() {
) )
}) })
) )
// ---- Create announcement ----
await tx.announcement.create({
data: generateFakeAnnouncement(),
})
}, },
{ {
timeout: 1000 * 60 * 10, // 10 minutes timeout: 1000 * 60 * 10, // 10 minutes

View File

@@ -1,4 +1,4 @@
import { type Prisma, type PrismaClient, type AnnouncementType } from '@prisma/client' import { type Prisma, type PrismaClient } from '@prisma/client'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { z } from 'zod' import { z } from 'zod'
@@ -9,9 +9,10 @@ const prisma = prismaInstance as PrismaClient
const selectAnnouncementReturnFields = { const selectAnnouncementReturnFields = {
id: true, id: true,
title: true,
content: true, content: true,
type: true, type: true,
link: true,
linkText: true,
startDate: true, startDate: true,
endDate: true, endDate: true,
isActive: true, isActive: true,
@@ -24,12 +25,18 @@ export const adminAnnouncementActions = {
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'),
content: z content: z
.string() .string()
.min(1, 'Content is required') .min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'), .max(1000, 'Content must be less than 1000 characters'),
type: z.enum(['INFO', 'WARNING', 'ALERT']), type: z.enum(['INFO', 'WARNING', 'ALERT']),
link: z.string().url().nullable().optional(),
linkText: z
.string()
.min(1, 'Link text is required')
.max(255, 'Link text must be less than 255 characters')
.nullable()
.optional(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date().nullable().optional(), endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true), isActive: z.coerce.boolean().default(true),
@@ -37,8 +44,13 @@ export const adminAnnouncementActions = {
handler: async (input) => { handler: async (input) => {
const announcement = await prisma.announcement.create({ const announcement = await prisma.announcement.create({
data: { data: {
...input, content: input.content,
endDate: input.endDate || null, type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
}, },
select: selectAnnouncementReturnFields, select: selectAnnouncementReturnFields,
}) })
@@ -52,12 +64,18 @@ export const adminAnnouncementActions = {
permissions: 'admin', permissions: 'admin',
input: z.object({ input: z.object({
id: z.coerce.number().int().positive(), id: z.coerce.number().int().positive(),
title: z.string().min(1, 'Title is required').max(255, 'Title must be less than 255 characters'),
content: z content: z
.string() .string()
.min(1, 'Content is required') .min(1, 'Content is required')
.max(1000, 'Content must be less than 1000 characters'), .max(1000, 'Content must be less than 1000 characters'),
type: z.enum(['INFO', 'WARNING', 'ALERT']), type: z.enum(['INFO', 'WARNING', 'ALERT']),
link: z.string().url().nullable().optional(),
linkText: z
.string()
.min(1, 'Link text is required')
.max(255, 'Link text must be less than 255 characters')
.nullable()
.optional(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date().nullable().optional(), endDate: z.coerce.date().nullable().optional(),
isActive: z.coerce.boolean().default(true), isActive: z.coerce.boolean().default(true),
@@ -82,8 +100,13 @@ export const adminAnnouncementActions = {
const updatedAnnouncement = await prisma.announcement.update({ const updatedAnnouncement = await prisma.announcement.update({
where: { id: announcement.id }, where: { id: announcement.id },
data: { data: {
...input, content: input.content,
endDate: input.endDate || null, type: input.type,
startDate: input.startDate,
isActive: input.isActive,
link: input.link ?? null,
linkText: input.linkText ?? null,
endDate: input.endDate ?? null,
}, },
select: selectAnnouncementReturnFields, select: selectAnnouncementReturnFields,
}) })

View File

@@ -1,82 +1,92 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import type { AnnouncementType } from '@prisma/client' import { getAnnouncementTypeInfo } from '../constants/announcementTypes'
import { cn } from '../lib/cn'
export type Announcement = { import type { Prisma } from '@prisma/client'
id: number import type { HTMLAttributes } from 'astro/types'
title: string
content: string type Props = HTMLAttributes<'div'> & {
type: AnnouncementType announcement: Prisma.AnnouncementGetPayload<{
startDate: Date select: {
endDate: Date | null id: true
isActive: boolean content: true
type: true
link: true
linkText: true
startDate: true
endDate: true
isActive: true
}
}>
} }
export type Props = { const { announcement, class: className, ...props } = Astro.props
announcements: Announcement[]
}
const { announcements } = Astro.props const typeInfo = getAnnouncementTypeInfo(announcement.type)
// Get icon and class based on announcement type const Tag = announcement.link ? 'a' : 'div'
const getTypeInfo = (type: AnnouncementType) => {
switch (type) {
case 'INFO':
return {
icon: 'ri:information-line',
containerClass: 'bg-blue-900/40 border-blue-500/30',
titleClass: 'text-blue-400',
contentClass: 'text-blue-300',
}
case 'WARNING':
return {
icon: 'ri:alert-line',
containerClass: 'bg-yellow-900/40 border-yellow-500/30',
titleClass: 'text-yellow-400',
contentClass: 'text-yellow-300',
}
case 'ALERT':
return {
icon: 'ri:error-warning-line',
containerClass: 'bg-red-900/40 border-red-500/30',
titleClass: 'text-red-400',
contentClass: 'text-red-300',
}
default:
return {
icon: 'ri:information-line',
containerClass: 'bg-blue-900/40 border-blue-500/30',
titleClass: 'text-blue-400',
contentClass: 'text-blue-300',
}
}
}
--- ---
{ <Tag
announcements.length > 0 && ( href={announcement.link}
<div class="mb-4 flex flex-col items-center space-y-1"> target={announcement.link ? '_blank' : undefined}
{announcements.map((announcement) => { rel="noopener noreferrer"
const typeInfo = getTypeInfo(announcement.type) class={cn(
'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',
return ( className
<div )}
class={`flex flex-row items-center rounded border ${typeInfo.containerClass} mx-auto w-auto max-w-full px-3 py-2`} {...props}
> >
<Icon name={typeInfo.icon} class={`size-4 flex-shrink-0 ${typeInfo.titleClass} mr-2`} /> <div
<div class="flex min-w-0 flex-col"> aria-hidden="true"
<span class={`text-sm leading-tight font-bold ${typeInfo.titleClass} truncate`}> class="pointer-events-none absolute top-1/2 left-[max(-7rem,calc(50%-52rem))] -z-10 -translate-y-1/2 transform-gpu blur-2xl"
{announcement.title} >
</span> <div
<span class={`text-xs ${typeInfo.contentClass} truncate leading-snug [&_a]:underline`}> class={cn(
<Markdown content={announcement.content} /> 'aspect-[577/310] w-[36.0625rem] bg-gradient-to-r from-green-500 to-green-700 opacity-20',
</span> typeInfo.classNames.bg
</div> )}
</div> style="clip-path:polygon(74.8% 41.9%, 97.2% 73.2%, 100% 34.9%, 92.5% 0.4%, 87.5% 0%, 75% 28.6%, 58.5% 54.6%, 50.1% 56.8%, 46.9% 44%, 48.3% 17.4%, 24.7% 53.9%, 0% 27.9%, 11.9% 74.2%, 24.9% 54.1%, 68.6% 100%, 74.8% 41.9%)"
) >
})}
</div> </div>
) </div>
} <div
aria-hidden="true"
class="pointer-events-none absolute top-1/2 left-[max(45rem,calc(50%+8rem))] -z-10 -translate-y-1/2 transform-gpu blur-2xl"
>
<div
class={cn(
'aspect-[577/310] w-[36.0625rem] bg-gradient-to-r from-green-500 to-green-700 opacity-30',
typeInfo.classNames.bg
)}
style="clip-path:polygon(74.8% 41.9%, 97.2% 73.2%, 100% 34.9%, 92.5% 0.4%, 87.5% 0%, 75% 28.6%, 58.5% 54.6%, 50.1% 56.8%, 46.9% 44%, 48.3% 17.4%, 24.7% 53.9%, 0% 27.9%, 11.9% 74.2%, 24.9% 54.1%, 68.6% 100%, 74.8% 41.9%)"
>
</div>
</div>
<div class={cn('flex items-center justify-between gap-x-3 md:justify-center', typeInfo.classNames.icon)}>
<Icon name={typeInfo.icon} class={cn('size-5 flex-shrink-0')} />
<span
class={cn(
'font-title line-clamp-3 bg-[linear-gradient(90deg,var(--gradient-edge,#FFEBF9)_0%,var(--gradient-center,#8a56cc)_50%,var(--gradient-edge,#FFEBF9)_100%)] bg-clip-text text-sm leading-tight text-pretty text-transparent [&_a]:underline',
typeInfo.classNames.content
)}
>
{announcement.content}
</span>
</div>
<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
name="ri:arrow-right-line"
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
/>
</div>
</Tag>

View File

@@ -1,7 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions' import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions'
import { Image } from 'astro:assets'
import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha' import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'

View File

@@ -1,9 +1,9 @@
--- ---
import { Picture } from 'astro:assets'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { formatDateShort } from '../lib/timeAgo' import { formatDateShort } from '../lib/timeAgo'
import MyPicture from './MyPicture.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'
@@ -73,13 +73,12 @@ const { messages, userId, class: className, ...htmlProps } = Astro.props
{!isCurrentUser && !isNextFromSameUser && ( {!isCurrentUser && !isNextFromSameUser && (
<p class="text-day-500 mb-0.5 text-xs"> <p class="text-day-500 mb-0.5 text-xs">
{!!message.user.picture && ( {!!message.user.picture && (
<Picture <MyPicture
src={message.user.picture} src={message.user.picture}
height={16} height={16}
width={16} width={16}
class="inline-block rounded-full align-[-0.33em]" class="inline-block rounded-full align-[-0.33em]"
alt="" alt=""
formats={['jxl', 'avif', 'webp']}
/> />
)} )}
{message.user.name} {message.user.name}

View File

@@ -1,5 +1,4 @@
--- ---
import Image from 'astro/components/Image.astro'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
@@ -19,6 +18,7 @@ import { formatDateShort } from '../lib/timeAgo'
import BadgeSmall from './BadgeSmall.astro' import BadgeSmall from './BadgeSmall.astro'
import CommentModeration from './CommentModeration.astro' import CommentModeration from './CommentModeration.astro'
import CommentReply from './CommentReply.astro' import CommentReply from './CommentReply.astro'
import MyPicture from './MyPicture.astro'
import TimeFormatted from './TimeFormatted.astro' import TimeFormatted from './TimeFormatted.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
@@ -158,11 +158,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
{ {
comment.author.picture && ( comment.author.picture && (
<Image <MyPicture
src={comment.author.picture} src={comment.author.picture}
alt={`Profile for ${comment.author.displayName ?? comment.author.name}`} alt={`Profile for ${comment.author.displayName ?? comment.author.name}`}
class="size-6 rounded-full bg-zinc-700 object-cover" class="size-6 rounded-full bg-zinc-700 object-cover"
loading="lazy"
height={24} height={24}
width={24} width={24}
/> />

View File

@@ -0,0 +1,60 @@
---
import { Icon } from 'astro-icon/components'
import QRCode from 'qrcode'
import { cn } from '../lib/cn'
type Props = {
cryptoName: string
cryptoIcon: string
address: string
className?: string
}
const { cryptoName, cryptoIcon, address, className } = Astro.props
function getAddressURI(address: string, cryptoName: string) {
if (cryptoName.toLowerCase() === 'monero') {
return `monero:${address}?tx_description=KYCnot.me%20Donation`
}
if (cryptoName.toLowerCase() === 'bitcoin') {
return `bitcoin:${address}?label=KYCnot.me%20Donation`
}
return address
}
const qrCodeDataURL = await QRCode.toDataURL(getAddressURI(address, cryptoName), {
width: 128,
margin: 1,
color: {
dark: '#ffffff',
light: '#171721',
},
})
---
<div class={cn('bg-night-800 border-night-600 flex items-center gap-2 rounded-lg border px-3', className)}>
<div class="flex flex-1 flex-col gap-1 py-3">
<div class="flex items-center gap-2 px-4 pt-3">
<Icon name={cryptoIcon} class="size-6 text-white" />
<span class="font-title text-base font-semibold text-white">{cryptoName}</span>
</div>
<p class="px-7 font-mono text-base leading-snug tracking-wide break-all text-white">
<span
class="cursor-pointer select-all"
set:html={address.length > 12
? `<span class="font-bold mr-0.5 text-green-500">${address.substring(0, 6)}</span>${address.substring(6, address.length - 6)}<span class="font-bold ml-0.5 text-green-500">${address.substring(address.length - 6)}</span>`
: `<span class="font-bold">${address}</span>`}
/>
</p>
</div>
<img
src={qrCodeDataURL}
alt={`${cryptoName} QR code`}
width="128"
height="128"
class="mr-4 hidden size-36 rounded sm:block"
/>
</div>

View File

@@ -35,6 +35,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
'border-red-900 bg-red-500/60': !!actualUser, 'border-red-900 bg-red-500/60': !!actualUser,
} }
)} )}
transition:name="header-container"
> >
<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">

View File

@@ -0,0 +1,42 @@
---
import type { ComponentProps } from 'react'
import { Picture } from 'astro:assets'
import defaultServiceImage from '../assets/fallback-service-image.jpg'
const fallbackImages = {
service: defaultServiceImage,
} as const satisfies Record<string, typeof defaultServiceImage>
type Props = Omit<ComponentProps<typeof Picture>, 'src'> & {
src: ComponentProps<typeof Picture>['src'] | null | undefined
fallback?: keyof typeof fallbackImages
}
const {
src,
formats = ['avif', 'webp'],
fallback = undefined as keyof typeof fallbackImages | undefined,
height,
width,
...props
} = Astro.props
const fallbackImage = fallback ? fallbackImages[fallback] : undefined
---
{/* eslint-disable @typescript-eslint/no-explicit-any */}
{
!!(src ?? fallbackImage) && (
<Picture
src={
typeof src === 'string' ? new URL(src, Astro.url).href : ((src ?? fallbackImage) as unknown as string)
}
formats={formats}
height={height ? Number(height) * 2 : undefined}
width={width ? Number(width) * 2 : undefined}
{...(props as any)}
/>
)
}

View File

@@ -2,16 +2,16 @@ import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { ImageResponse } from '@vercel/og' import { ImageResponse } from '@vercel/og'
import sharp from 'sharp'
import defaultOGImageBg from '../assets/ogimage-bg.png' import defaultOGImageBg from '../assets/ogimage-bg.png'
import defaultOGImage from '../assets/ogimage.png' import defaultOGImage from '../assets/ogimage.png'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { urlWithParams } from '../lib/urls' import { urlWithParams } from '../lib/urls'
import type { APIContext } from 'astro' import type { APIContext } from 'astro'
import type { Prettify } from 'ts-essentials' import type { Prettify } from 'ts-essentials'
export type GenericOgImageProps = Partial<Record<string, string>>
////////////////////////////////////////////////////// //////////////////////////////////////////////////////
// NOTE // // NOTE //
// Use this website to create and preview templates // // Use this website to create and preview templates //
@@ -52,15 +52,41 @@ const defaultOptions = {
) )
), ),
}, },
{
name: 'Space Grotesk',
weight: 400,
style: 'normal',
data: fs.readFileSync(
path.resolve(
process.cwd(),
'node_modules',
'@fontsource',
'space-grotesk',
'files',
'space-grotesk-latin-400-normal.woff'
)
),
},
{
name: 'Space Grotesk',
weight: 700,
style: 'normal',
data: fs.readFileSync(
path.resolve(
process.cwd(),
'node_modules',
'@fontsource',
'space-grotesk',
'files',
'space-grotesk-latin-700-normal.woff'
)
),
},
], ],
} as const satisfies ConstructorParameters<typeof ImageResponse>[1] } as const satisfies ConstructorParameters<typeof ImageResponse>[1]
function absoluteUrl(url: string, context: Pick<APIContext, 'url'>) {
return new URL(url, context.url.origin).href
}
export const ogImageTemplates = { export const ogImageTemplates = {
default: (_props: Record<never, never> = {}, context: APIContext) => { default: (_props: Record<never, never> = {}, context) => {
return new ImageResponse( return new ImageResponse(
( (
<img <img
@@ -74,37 +100,278 @@ export const ogImageTemplates = {
defaultOptions defaultOptions
) )
}, },
generic: ({ title }: { title?: string }, context) => { service: async (
{
title,
description,
categories,
score,
imageUrl,
}: {
title: string
description: string
categories: {
name: string
icon: string
}[]
score: number
imageUrl: string | null
},
context
) => {
const scoreInfo = makeOverallScoreInfo(score, 10)
const scoreColors = {
'bg-score-1': '#e26136',
'bg-score-2': '#eba370',
'bg-score-3': '#eddb82',
'bg-score-4': '#8de2d7',
'bg-score-5': '#3cdd71',
} as const satisfies Record<string, string>
const scoreColor =
Object.entries(scoreColors).find(([className]) => scoreInfo.classNameBg?.includes(className))?.[1] ??
'white'
const PADING = 80
return new ImageResponse( return new ImageResponse(
( (
<div <div
style={{ style={{
fontSize: 100,
fontWeight: 'bold',
color: 'white', color: 'white',
backgroundImage: `url(${absoluteUrl(defaultOGImageBg.src, context)})`, backgroundImage: `url(${absoluteUrl(defaultOGImageBg.src, context)})`,
width: '100%', width: '100%',
height: '100%', height: '100%',
padding: '50px 200px', padding: PADING,
textAlign: 'center',
justifyContent: 'space-around',
alignItems: 'center',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'relative',
gap: 20,
}} }}
> >
<span>{title}</span> <div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
gap: 10,
flex: 1,
}}
>
{!!imageUrl && (
<img
src={absoluteUrl(imageUrl, context)}
style={{
width: 140,
height: 140,
borderRadius: 20,
objectFit: 'contain',
}}
/>
)}
<div style={{ display: 'flex', paddingTop: 20 }}>
<span
style={{
fontSize: 100,
fontWeight: 'bold',
color: '#3bdb78',
fontFamily: 'Space Grotesk',
lineHeight: 1.2,
height: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: -20,
}}
>
{title}
</span>
</div>
</div>
<div
style={{
alignItems: 'flex-end',
display: 'flex',
gap: 50,
position: 'relative',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
flex: 1,
justifyContent: 'space-between',
alignSelf: 'stretch',
}}
>
<span
style={{
fontSize: 30,
color: 'white',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
maxHeight: 115,
}}
>
{description}
</span>
<div
style={{
display: 'flex',
gap: 40,
flexWrap: 'wrap',
fontWeight: 'bold',
fontSize: 50,
marginTop: 10,
color: 'white',
}}
>
{await Promise.all(
categories.map(async (category) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 10, whiteSpace: 'nowrap' }}>
<img
src={await iconUrl(category.icon, 50)}
width={50}
height={50}
style={{ width: 50, height: 50 }}
/>
{category.name}
</span>
))
)}
</div>
</div>
<div style={{ display: 'flex' }}>
<div
style={{
fontSize: 150,
color: 'black',
height: 200,
width: 200,
borderRadius: 30,
backgroundColor: scoreColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
}}
>
{score}
</div>
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="white"
width={400}
viewBox="0 0 204 28"
style={{ position: 'absolute', top: PADING, right: PADING }}
>
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg>
</div> </div>
), ),
defaultOptions defaultOptions
) )
}, },
} as const satisfies Record<string, (props: GenericOgImageProps, context: APIContext) => ImageResponse | null> generic: async (
{
title,
description,
icon,
}: {
title: string
description?: string | null
icon?: string | null
},
context
) => {
const PADING = 80
return new ImageResponse(
(
<div
style={{
color: 'white',
backgroundImage: `url(${absoluteUrl(defaultOGImageBg.src, context)})`,
width: '100%',
height: '100%',
padding: PADING,
display: 'flex',
flexDirection: 'column',
position: 'relative',
gap: 20,
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="white"
width={400}
viewBox="0 0 204 28"
style={{ marginBottom: 'auto' }}
>
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg>
<div style={{ display: 'flex', paddingTop: 20 }}>
<span
style={{
fontSize: 100,
fontWeight: 'bold',
color: '#3bdb78',
fontFamily: 'Space Grotesk',
lineHeight: 1.2,
height: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginTop: -20,
}}
>
{title}
</span>
</div>
<span
style={{
fontSize: 40,
color: 'white',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxHeight: 200,
}}
>
{description}
</span>
{!!icon && (
<img
src={await iconUrl(icon, 200)}
width={200}
height={200}
style={{
position: 'absolute',
top: PADING,
right: PADING,
}}
/>
)}
</div>
),
defaultOptions
)
},
} as const satisfies Record<
string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(props: any, context: APIContext) => ImageResponse | Promise<ImageResponse | null> | null
>
type OgImageTemplate = keyof typeof ogImageTemplates type OgImageTemplate = keyof typeof ogImageTemplates
type OgImageProps<T extends OgImageTemplate> = Parameters<(typeof ogImageTemplates)[T]>[0] type OgImageProps<T extends OgImageTemplate> = Parameters<(typeof ogImageTemplates)[T]>[0]
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
export type OgImageAllTemplatesWithGenericProps = { template: OgImageTemplate } & GenericOgImageProps
export type OgImageAllTemplatesWithProps = Prettify< export type OgImageAllTemplatesWithProps = Prettify<
{ {
@@ -119,5 +386,44 @@ export function makeOgImageUrl(
) { ) {
return typeof ogImage === 'string' return typeof ogImage === 'string'
? new URL(ogImage, baseUrl).href ? new URL(ogImage, baseUrl).href
: urlWithParams(new URL('/ogimage.png', baseUrl), ogImage ?? {}) : urlWithParams(new URL('/ogimage.png', baseUrl), { data: JSON.stringify(ogImage ?? {}) })
}
// Utilities ------------------------------------------------------------
function absoluteUrl(url: string, context: Pick<APIContext, 'url'>) {
return new URL(url, context.url.origin).href
}
async function svgUrlToBase64Png(svgUrl: string, width?: number, height?: number): Promise<string> {
// 1. Fetch the SVG file
const response = await fetch(svgUrl)
if (!response.ok) {
throw new Error(`Failed to fetch SVG: ${response.statusText}`)
}
const svgBuffer = await response.arrayBuffer()
// 2. Convert SVG to PNG using sharp
let image = sharp(svgBuffer).png().negate({ alpha: false })
if (width || height) {
image = image.resize(width, height, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
}
const pngBuffer = await image.toBuffer()
// 3. Convert to base64 string
const base64 = pngBuffer.toString('base64')
return `data:image/png;base64,${base64}`
}
async function iconUrl(icon: string, size = 30) {
const [, prefix, name] = /^([^:]+):(.*)$/.exec(icon) ?? []
if (!prefix || !name) return undefined
const url = `https://api.iconify.design/${prefix}/${name}.svg`
const result = await svgUrlToBase64Png(url, size, size)
return result
} }

View File

@@ -2,6 +2,7 @@
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
@@ -16,33 +17,6 @@ export type Props = HTMLAttributes<'div'> & {
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
export function makeOverallScoreInfo(score: number, total = 10) {
const classNamesByColor = {
red: 'bg-score-1 text-black',
orange: 'bg-score-2 text-black',
yellow: 'bg-score-3 text-black',
blue: 'bg-score-4 text-black',
green: 'bg-score-5 text-black',
} as const satisfies Record<string, string>
const formattedScore = Math.round(score).toLocaleString()
const n = score / total
if (n > 1) return { text: '', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.9 && n <= 1) return { text: 'Excellent', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.7 && n < 0.8) return { text: 'Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.6 && n < 0.7) return { text: 'Okay', classNameBg: classNamesByColor.yellow, formattedScore }
if (n >= 0.5 && n < 0.6) {
return { text: 'Acceptable', classNameBg: classNamesByColor.yellow, formattedScore }
}
if (n >= 0.4 && n < 0.5) return { text: 'Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.3 && n < 0.4) return { text: 'Very Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.2 && n < 0.3) return { text: 'Really Bad', classNameBg: classNamesByColor.red, formattedScore }
if (n >= 0 && n < 0.2) return { text: 'Terrible', classNameBg: classNamesByColor.red, formattedScore }
return { text: '', classNameBg: undefined, formattedScore }
}
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total) const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
--- ---

View File

@@ -1,14 +1,13 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import defaultImage from '../assets/fallback-service-image.jpg'
import { currencies } from '../constants/currencies' import { currencies } from '../constants/currencies'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import { makeOverallScoreInfo } from './ScoreSquare.astro' import MyPicture from './MyPicture.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -76,9 +75,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
> >
<!-- Header with Icon and Title --> <!-- Header with Icon and Title -->
<div class="flex items-center gap-(--gap)"> <div class="flex items-center gap-(--gap)">
<Image <MyPicture
src={// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing src={imageUrl}
imageUrl || (defaultImage as unknown as string)} fallback="service"
alt={name || 'Service logo'} alt={name || 'Service logo'}
class="size-12 shrink-0 rounded-sm object-contain text-white" class="size-12 shrink-0 rounded-sm object-contain text-white"
width={48} width={48}

View File

@@ -3,11 +3,11 @@ import { Icon } from 'astro-icon/components'
import { kycLevels } from '../constants/kycLevels' import { kycLevels } from '../constants/kycLevels'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro' import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro'
import Button from './Button.astro' import Button from './Button.astro'
import PillsRadioGroup from './PillsRadioGroup.astro' import PillsRadioGroup from './PillsRadioGroup.astro'
import { makeOverallScoreInfo } from './ScoreSquare.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'

View File

@@ -0,0 +1,75 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { AnnouncementType } from '@prisma/client'
type AnnouncementTypeInfo<T extends string | null | undefined = string> = {
value: T
label: string
icon: string
classNames: {
container: string
bg: string
content: string
icon: string
badge: string
}
}
export const {
dataArray: announcementTypes,
dataObject: announcementTypesById,
getFn: getAnnouncementTypeInfo,
zodEnumById: zodAnnouncementTypesById,
} = makeHelpersForOptions(
'value',
(value): AnnouncementTypeInfo<typeof value> => ({
value,
label: value ? transformCase(value.replaceAll('_', ' '), 'title') : String(value),
icon: 'ri:information-fill',
classNames: {
container: 'bg-blue-950',
bg: 'from-blue-400 to-blue-700',
content: '[--gradient-edge:var(--color-blue-100)] [--gradient-center:var(--color-blue-200)]',
icon: 'text-blue-400/80',
badge: 'bg-blue-900/30 text-blue-400',
},
}),
[
{
value: 'INFO',
label: 'Info',
icon: 'ri:information-fill',
classNames: {
container: 'bg-green-950',
bg: 'from-green-400 to-green-700',
content: '[--gradient-edge:var(--color-green-100)] [--gradient-center:var(--color-lime-200)]',
icon: 'text-green-400/80',
badge: 'bg-blue-900/30 text-blue-400',
},
},
{
value: 'WARNING',
label: 'Warning',
icon: 'ri:alert-fill',
classNames: {
container: 'bg-yellow-950',
bg: 'from-yellow-400 to-yellow-700',
content: '[--gradient-edge:var(--color-yellow-100)] [--gradient-center:var(--color-amber-200)]',
icon: 'text-yellow-400/80',
badge: 'bg-yellow-900/30 text-yellow-400',
},
},
{
value: 'ALERT',
label: 'Alert',
icon: 'ri:spam-fill',
classNames: {
container: 'bg-red-950',
bg: 'from-red-400 to-red-700',
content: '[--gradient-edge:var(--color-red-100)] [--gradient-center:var(--color-rose-200)]',
icon: 'text-red-400/80',
badge: 'bg-red-900/30 text-red-400',
},
},
] as const satisfies AnnouncementTypeInfo<AnnouncementType>[]
)

View File

@@ -14,4 +14,7 @@ export const splashTexts: string[] = [
'Ditch the gatekeepers.', 'Ditch the gatekeepers.',
'Own your identity.', 'Own your identity.',
'Financial privacy matters.', 'Financial privacy matters.',
'Surveillance is the enemy of the soul.',
'Privacy is freedom.',
'Privacy is the freedom to try things out.',
] ]

View File

@@ -1,8 +1,10 @@
--- ---
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
import BaseHead from '../components/BaseHead.astro' import BaseHead from '../components/BaseHead.astro'
import Footer from '../components/Footer.astro' import Footer from '../components/Footer.astro'
import Header from '../components/Header.astro' import Header from '../components/Header.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { prisma } from '../lib/prisma'
import type { AstroChildren } from '../lib/astro' import type { AstroChildren } from '../lib/astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
@@ -42,6 +44,31 @@ const {
const actualErrors = [...errors, ...Astro.locals.banners.errors] const actualErrors = [...errors, ...Astro.locals.banners.errors]
const actualSuccess = [...success, ...Astro.locals.banners.successes] const actualSuccess = [...success, ...Astro.locals.banners.successes]
const currentDate = new Date()
const announcement = await Astro.locals.banners.try(
'Unable to load announcements.',
() =>
prisma.announcement.findFirst({
where: {
isActive: true,
startDate: { lte: currentDate },
OR: [{ endDate: null }, { endDate: { gt: currentDate } }],
},
select: {
id: true,
content: true,
type: true,
link: true,
linkText: true,
startDate: true,
endDate: true,
isActive: true,
},
orderBy: [{ type: 'desc' }, { createdAt: 'desc' }],
}),
null
)
--- ---
<html lang="en" transition:name="root" transition:animate="none"> <html lang="en" transition:name="root" transition:animate="none">
@@ -51,6 +78,7 @@ const actualSuccess = [...success, ...Astro.locals.banners.successes]
<BaseHead {...baseHeadProps} /> <BaseHead {...baseHeadProps} />
</head> </head>
<body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}> <body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}>
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
<Header <Header
classNames={{ classNames={{
nav: cn( nav: cn(

View File

@@ -16,6 +16,7 @@ type Props = ComponentProps<typeof BaseLayout> &
author: string author: string
pubDate: string pubDate: string
description: string description: string
icon?: string
}> }>
const { frontmatter, schemas, ...baseLayoutProps } = Astro.props const { frontmatter, schemas, ...baseLayoutProps } = Astro.props
@@ -23,6 +24,8 @@ const publishDate = frontmatter.pubDate ? new Date(frontmatter.pubDate) : null
const ogImageTemplateData = { const ogImageTemplateData = {
template: 'generic', template: 'generic',
title: frontmatter.title, title: frontmatter.title,
description: frontmatter.description,
icon: frontmatter.icon,
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me' const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
--- ---

View File

@@ -0,0 +1,26 @@
export function makeOverallScoreInfo(score: number, total = 10) {
const classNamesByColor = {
red: 'bg-score-1 text-black',
orange: 'bg-score-2 text-black',
yellow: 'bg-score-3 text-black',
blue: 'bg-score-4 text-black',
green: 'bg-score-5 text-black',
} as const satisfies Record<string, string>
const formattedScore = Math.round(score).toLocaleString()
const n = score / total
if (n > 1) return { text: '', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.9 && n <= 1) return { text: 'Excellent', classNameBg: classNamesByColor.green, formattedScore }
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.7 && n < 0.8) return { text: 'Good', classNameBg: classNamesByColor.blue, formattedScore }
if (n >= 0.6 && n < 0.7) return { text: 'Okay', classNameBg: classNamesByColor.yellow, formattedScore }
if (n >= 0.5 && n < 0.6) {
return { text: 'Acceptable', classNameBg: classNamesByColor.yellow, formattedScore }
}
if (n >= 0.4 && n < 0.5) return { text: 'Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.3 && n < 0.4) return { text: 'Very Bad', classNameBg: classNamesByColor.orange, formattedScore }
if (n >= 0.2 && n < 0.3) return { text: 'Really Bad', classNameBg: classNamesByColor.red, formattedScore }
if (n >= 0 && n < 0.2) return { text: 'Terrible', classNameBg: classNamesByColor.red, formattedScore }
return { text: '', classNameBg: undefined, formattedScore }
}

View File

@@ -50,7 +50,6 @@ export const ACCEPTED_IMAGE_TYPES = [
'image/svg+xml', 'image/svg+xml',
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
'image/jxl',
'image/avif', 'image/avif',
'image/webp', 'image/webp',
] as const satisfies string[] ] as const satisfies string[]
@@ -66,7 +65,7 @@ export const imageFileSchema = z
) )
.refine( .refine(
(file) => !file || ACCEPTED_IMAGE_TYPES.some((type) => file.type === type), (file) => !file || ACCEPTED_IMAGE_TYPES.some((type) => file.type === type),
'Only SVG, PNG, JPG, JPEG XL, AVIF, WebP formats are supported.' 'Only SVG, PNG, JPG, AVIF, WebP formats are supported.'
) )
export const imageFileSchemaRequired = imageFileSchema.refine((file) => !!file, 'Required') export const imageFileSchemaRequired = imageFileSchema.refine((file) => !!file, 'Required')

View File

@@ -4,8 +4,11 @@ title: About
author: KYCnot.me author: KYCnot.me
pubDate: 2025-05-15 pubDate: 2025-05-15
description: 'Learn how KYCnot.me website works and about our mission to protect privacy in cryptocurrency.' description: 'Learn how KYCnot.me website works and about our mission to protect privacy in cryptocurrency.'
icon: 'ri:information-line'
--- ---
import DonationAddress from '../components/DonationAddress.astro'
## What is this page? ## What is this page?
KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy. KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy.
@@ -187,12 +190,15 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## Support the project ## Support
If you like this project, you can support it through these methods: If you like this project, you can **support** it through these methods:
- Monero: <DonationAddress
- `88V2Xi2mvcu3NdnHkVeZGyPtACg2w3iXZdUMJugUiPvFQHv5mVkih3o43ceVGz6cVs9uTBMt4MRMVW2xFgfGdh8DTCQ7vtp` cryptoName="Monero"
cryptoIcon="monero"
address="86nkJeHWarEYZJh3gcPGKcQeueKbq2uRRC2NX6kopBpdHFfY1j4vmrVAwRG1T4pNBwBwfJ4U4USLUZ6CjDtacp8x4y8v3rq"
/>
## Contact ## Contact
@@ -201,8 +207,7 @@ You can contact via direct chat or via email.
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion) - [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
- If you use ProtonMail or Tutanota, you can have E2E encrypted communications with us directly. We also offer a [PGP Key](/pgp). Otherwise, we recommend reaching out via SimpleX chat for encrypted communications. - If you use ProtonMail or Tutanota, you can have E2E encrypted communications with us directly. We also offer a [PGP Key](/pgp). Otherwise, we recommend reaching out via SimpleX chat for encrypted communications.
- [tuta.io](https://tuta.io) - <kycnotme@tuta.io>
- [proton.me](https://proton.me) - <contact@kycnot.me>
## Disclaimer ## Disclaimer

View File

@@ -24,7 +24,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
<MiniLayout <MiniLayout
pageTitle={`Edit Profile - ${user.name}`} pageTitle={`Edit Profile - ${user.name}`}
description="Edit your user profile" description="Edit your user profile"
ogImage={{ template: 'generic', title: 'Edit Profile' }} ogImage={{ template: 'generic', title: 'Edit Profile', icon: 'ri:user-settings-line' }}
layoutHeader={{ layoutHeader={{
icon: 'ri:edit-line', icon: 'ri:edit-line',
title: 'Edit profile', title: 'Edit profile',

View File

@@ -25,7 +25,12 @@ const prettyToken = preGeneratedToken ? prettifyUserSecretToken(preGeneratedToke
<MiniLayout <MiniLayout
pageTitle="Create Account" pageTitle="Create Account"
description="Create a new account" description="Create a new account"
ogImage={{ template: 'generic', title: 'Create Account' }} ogImage={{
template: 'generic',
title: 'Create Account',
description: 'Zero data, 100% anonymous',
icon: 'ri:user-add-line',
}}
layoutHeader={{ layoutHeader={{
icon: 'ri:user-add-line', icon: 'ri:user-add-line',
title: 'New account', title: 'New account',

View File

@@ -1,12 +1,11 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { Picture } from 'astro:assets'
import { sortBy } from 'lodash-es' import { sortBy } from 'lodash-es'
import defaultServiceImage from '../../assets/fallback-service-image.jpg'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
import Button from '../../components/Button.astro' import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro' import Tooltip from '../../components/Tooltip.astro'
import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions' import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
@@ -161,7 +160,12 @@ if (!user) return Astro.rewrite('/404')
<BaseLayout <BaseLayout
pageTitle={`${user.name} - Account`} pageTitle={`${user.name} - Account`}
description="Manage your user profile" description="Manage your user profile"
ogImage={{ template: 'generic', title: `${user.name} | Account` }} ogImage={{
template: 'generic',
title: `${user.name} | Account`,
description: 'Manage your user profile',
icon: 'ri:user-3-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
className={{ className={{
main: 'space-y-6', main: 'space-y-6',
@@ -180,7 +184,13 @@ if (!user) return Astro.rewrite('/404')
<header class="flex items-center gap-4"> <header class="flex items-center gap-4">
{ {
user.picture ? ( user.picture ? (
<img src={user.picture} alt="" class="ring-day-500/30 size-16 rounded-full ring-2" /> <MyPicture
src={user.picture}
alt=""
class="ring-day-500/30 size-16 rounded-full ring-2"
width={64}
height={64}
/>
) : ( ) : (
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2"> <div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
<Icon name="ri:user-3-line" class="size-8" /> <Icon name="ri:user-3-line" class="size-8" />
@@ -460,8 +470,9 @@ if (!user) return Astro.rewrite('/404')
href={`/service/${affiliation.service.slug}`} href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm" class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
> >
<Picture <MyPicture
src={affiliation.service.imageUrl ?? (defaultServiceImage as unknown as string)} src={affiliation.service.imageUrl}
fallback="service"
alt={affiliation.service.name} alt={affiliation.service.name}
width={40} width={40}
height={40} height={40}

View File

@@ -30,7 +30,12 @@ const message = Astro.url.searchParams.get('message')
<MiniLayout <MiniLayout
pageTitle="Login" pageTitle="Login"
description="Login to your account" description="Login to your account"
ogImage={{ template: 'generic', title: 'Login' }} ogImage={{
template: 'generic',
title: 'Login',
description: message ?? 'Enter your login key',
icon: 'ri:login-box-line',
}}
layoutHeader={{ layoutHeader={{
icon: 'ri:user-line', icon: 'ri:user-line',
title: 'Welcome back', title: 'Welcome back',

View File

@@ -17,7 +17,12 @@ const prettyToken = result ? prettifyUserSecretToken(result.data.token) : null
<MiniLayout <MiniLayout
pageTitle="Welcome" pageTitle="Welcome"
description="New account welcome page" description="New account welcome page"
ogImage={{ template: 'generic', title: 'Welcome' }} ogImage={{
template: 'generic',
title: 'Welcome',
description: 'New account welcome page',
icon: 'ri:key-2-line',
}}
layoutHeader={{ layoutHeader={{
icon: 'ri:key-2-line', icon: 'ri:key-2-line',
title: 'Save your Login Key', title: 'Save your Login Key',

View File

@@ -4,9 +4,19 @@ import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:schema' import { z } from 'astro:schema'
import { adminAnnouncementActions } from '../../../actions/admin/announcement' import { adminAnnouncementActions } from '../../../actions/admin/announcement'
import Button from '../../../components/Button.astro'
import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro' import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro' import Tooltip from '../../../components/Tooltip.astro'
import {
announcementTypes,
getAnnouncementTypeInfo,
zodAnnouncementTypesById,
} from '../../../constants/announcementTypes'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
@@ -20,7 +30,7 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
.default('createdAt'), .default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'), 'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(), search: z.string().optional(),
type: z.enum(['INFO', 'WARNING', 'ALERT']).optional(), type: zodAnnouncementTypesById.optional(),
status: z.enum(['active', 'inactive']).optional(), status: z.enum(['active', 'inactive']).optional(),
}, },
Astro Astro
@@ -35,10 +45,7 @@ const prismaOrderBy = {
const whereClause: Prisma.AnnouncementWhereInput = {} const whereClause: Prisma.AnnouncementWhereInput = {}
if (filters.search) { if (filters.search) {
whereClause.OR = [ whereClause.OR = [{ content: { contains: filters.search, mode: 'insensitive' } }]
{ title: { contains: filters.search, mode: 'insensitive' } },
{ content: { contains: filters.search, mode: 'insensitive' } },
]
} }
if (filters.type) { if (filters.type) {
@@ -66,32 +73,19 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
return `/admin/announcements?${searchParams.toString()}` return `/admin/announcements?${searchParams.toString()}`
} }
// Get type badge class based on announcement type
const getTypeBadgeClass = (type: AnnouncementType) => {
switch (type) {
case 'INFO':
return 'bg-blue-900/30 text-blue-400'
case 'WARNING':
return 'bg-yellow-900/30 text-yellow-400'
case 'ALERT':
return 'bg-red-900/30 text-red-400'
default:
return 'bg-zinc-900/30 text-zinc-400'
}
}
// Current date for form min values // Current date for form min values
const currentDate = new Date().toISOString().slice(0, 16) // Format: YYYY-MM-DDThh:mm const currentDate = new Date().toISOString().slice(0, 16) // Format: YYYY-MM-DDThh:mm
// Default new announcement // Default new announcement
const newAnnouncement = { const newAnnouncement = {
title: '',
content: '', content: '',
type: 'INFO' as const, type: 'INFO' as const,
link: null,
linkText: null,
startDate: currentDate, startDate: currentDate,
endDate: '', endDate: '',
isActive: true, isActive: true as boolean,
} } satisfies Prisma.AnnouncementCreateInput
// Get action results // Get action results
const createResult = Astro.getActionResult(adminAnnouncementActions.create) const createResult = Astro.getActionResult(adminAnnouncementActions.create)
@@ -99,6 +93,8 @@ const updateResult = Astro.getActionResult(adminAnnouncementActions.update)
const deleteResult = Astro.getActionResult(adminAnnouncementActions.delete) const deleteResult = Astro.getActionResult(adminAnnouncementActions.delete)
const toggleResult = Astro.getActionResult(adminAnnouncementActions.toggleActive) const toggleResult = Astro.getActionResult(adminAnnouncementActions.toggleActive)
const createInputErrors = isInputError(createResult?.error) ? createResult.error.fields : {}
// Add success messages to banners // Add success messages to banners
Astro.locals.banners.addIfSuccess(createResult, 'Announcement created successfully!') Astro.locals.banners.addIfSuccess(createResult, 'Announcement created successfully!')
Astro.locals.banners.addIfSuccess(updateResult, 'Announcement updated successfully!') Astro.locals.banners.addIfSuccess(updateResult, 'Announcement updated successfully!')
@@ -176,9 +172,13 @@ if (toggleResult?.error) {
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none" class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
> >
<option value="" selected={!filters.type}>All Types</option> <option value="" selected={!filters.type}>All Types</option>
<option value="INFO" selected={filters.type === 'INFO'}>Info</option> {
<option value="WARNING" selected={filters.type === 'WARNING'}>Warning</option> announcementTypes.map((type) => (
<option value="ALERT" selected={filters.type === 'ALERT'}>Alert</option> <option value={type.value} selected={filters.type === type.value}>
{type.label}
</option>
))
}
</select> </select>
</div> </div>
<div> <div>
@@ -221,100 +221,91 @@ if (toggleResult?.error) {
<h2 class="font-title mb-4 text-lg font-semibold text-blue-400">Create New Announcement</h2> <h2 class="font-title mb-4 text-lg font-semibold text-blue-400">Create New Announcement</h2>
<form method="POST" action={actions.admin.announcement.create} class="grid gap-4 md:grid-cols-2"> <form method="POST" action={actions.admin.announcement.create} class="grid gap-4 md:grid-cols-2">
<div class="space-y-3 md:col-span-2"> <div class="space-y-3 md:col-span-2">
<div> <InputTextArea
<label for="title" class="block text-xs font-medium text-zinc-400">Title*</label> label="Content"
<input name="content"
type="text" error={createInputErrors.content}
name="title" value={newAnnouncement.content}
id="title" inputProps={{
required required: true,
maxlength="255" maxlength: 1000,
placeholder="Announcement Title" rows: 3,
value={newAnnouncement.title} placeholder: 'Announcement Content',
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none" }}
/> />
</div>
<div>
<label for="content" class="block text-xs font-medium text-zinc-400">Content*</label>
<textarea
name="content"
id="content"
required
maxlength="1000"
rows="3"
placeholder="Announcement Content"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>{newAnnouncement.content}</textarea
>
</div>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <InputText
<label for="type" class="block text-xs font-medium text-zinc-400">Type*</label> label="Link"
<select name="link"
name="type" error={createInputErrors.link}
id="type" inputProps={{
required type: 'url',
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none" placeholder: 'https://example.com',
> }}
<option value="INFO" selected={true}>Info</option> />
<option value="WARNING" selected={false}>Warning</option> <InputText
<option value="ALERT" selected={false}>Alert</option> label="Link Text "
</select> name="linkText"
</div> error={createInputErrors.linkText}
inputProps={{
<div> placeholder: 'Link Text',
<label for="startDate" class="block text-xs font-medium text-zinc-400">Start Date & Time*</label> }}
<input />
type="datetime-local"
name="startDate"
id="startDate"
required
min={currentDate}
value={newAnnouncement.startDate}
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="endDate" class="block text-xs font-medium text-zinc-400"
>End Date & Time (Optional)</label
>
<input
type="datetime-local"
name="endDate"
id="endDate"
min={currentDate}
value={newAnnouncement.endDate}
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center"> <InputCardGroup
<input label="Type"
type="checkbox" name="type"
name="isActive" options={announcementTypes.map((type) => ({
id="isActive" label: type.label,
value="true" value: type.value,
checked={newAnnouncement.isActive} icon: type.icon,
class="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-blue-600 focus:ring-blue-500" }))}
/> cardSize="sm"
<label for="isActive" class="ml-2 block text-sm text-zinc-400">Active</label> required
</div> selectedValue={newAnnouncement.type}
/>
<InputText
label="Start Date & Time"
name="startDate"
error={createInputErrors.startDate}
inputProps={{
type: 'datetime-local',
required: true,
value: newAnnouncement.startDate,
}}
/>
<InputText
label="End Date & Time"
name="endDate"
error={createInputErrors.endDate}
inputProps={{
type: 'datetime-local',
value: newAnnouncement.endDate,
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
name="isActive"
label="Status"
error={createInputErrors.isActive}
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
]}
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
cardSize="sm"
/>
<div class="pt-4"> <div class="pt-4">
<button <InputSubmitButton label="Create Announcement" icon="ri:save-line" hideCancel />
type="submit"
class="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:save-line" class="mr-1 h-4 w-4" />
Create Announcement
</button>
<button <button
type="button" type="button"
id="cancel-create" id="cancel-create"
@@ -328,127 +319,6 @@ if (toggleResult?.error) {
</div> </div>
</div> </div>
<!-- Edit Announcement Modal -->
<dialog
id="edit-announcement-modal"
class="m-auto w-full max-w-2xl rounded-lg border border-zinc-700 bg-zinc-800 p-0 backdrop:bg-black/70"
>
<div class="p-4">
<div class="mb-4 flex items-center justify-between border-b border-zinc-700 pb-3">
<h3 class="font-title text-lg font-semibold text-blue-400">Edit Announcement</h3>
<button type="button" class="close-modal text-zinc-400 hover:text-zinc-200">
<Icon name="ri:close-line" class="h-6 w-6" />
</button>
</div>
<form
method="POST"
action={actions.admin.announcement.update}
id="edit-form"
class="grid gap-4 md:grid-cols-2"
>
<input type="hidden" name="id" id="edit-id" />
<div class="space-y-3 md:col-span-2">
<div>
<label for="edit-title" class="block text-xs font-medium text-zinc-400">Title*</label>
<input
type="text"
name="title"
id="edit-title"
required
maxlength="255"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-content" class="block text-xs font-medium text-zinc-400">Content*</label>
<textarea
name="content"
id="edit-content"
required
maxlength="1000"
rows="3"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
></textarea>
</div>
</div>
<div class="space-y-3">
<div>
<label for="edit-type" class="block text-xs font-medium text-zinc-400">Type*</label>
<select
name="type"
id="edit-type"
required
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="INFO" selected={true}>Info</option>
<option value="WARNING" selected={false}>Warning</option>
<option value="ALERT" selected={false}>Alert</option>
</select>
</div>
<div>
<label for="edit-startDate" class="block text-xs font-medium text-zinc-400"
>Start Date & Time*</label
>
<input
type="datetime-local"
name="startDate"
id="edit-startDate"
required
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-endDate" class="block text-xs font-medium text-zinc-400"
>End Date & Time (Optional)</label
>
<input
type="datetime-local"
name="endDate"
id="edit-endDate"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center">
<input
type="checkbox"
name="isActive"
id="edit-isActive"
value="true"
class="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-blue-600 focus:ring-blue-500"
/>
<label for="edit-isActive" class="ml-2 block text-sm text-zinc-400">Active</label>
</div>
<div class="pt-4">
<button
type="submit"
class="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:save-line" class="mr-1 h-4 w-4" />
Update Announcement
</button>
<button
type="button"
class="close-modal ml-2 inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Cancel
</button>
</div>
</div>
</form>
</div>
</dialog>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<dialog <dialog
id="delete-confirmation-modal" id="delete-confirmation-modal"
@@ -583,97 +453,215 @@ if (toggleResult?.error) {
) )
} }
{ {
announcements.map((announcement) => ( announcements.map((announcement) => {
<tr class="group hover:bg-zinc-700/30"> const announcementTypeInfo = getAnnouncementTypeInfo(announcement.type)
<td class="px-4 py-3 text-sm">
<div class="font-medium text-zinc-200">{announcement.title}</div>
<div class="mt-1 line-clamp-1 text-xs text-zinc-400">{announcement.content}</div>
</td>
<td class="px-4 py-3 text-left text-sm">
<span
class={`inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-medium ${getTypeBadgeClass(announcement.type)}`}
>
{announcement.type}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
<TimeFormatted date={announcement.startDate} hourPrecision={false} prefix={false} />
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
{announcement.endDate ? (
<TimeFormatted date={announcement.endDate} hourPrecision={false} prefix={false} />
) : (
<span class="text-zinc-500">—</span>
)}
</td>
<td class="px-4 py-3 text-center text-sm">
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${announcement.isActive ? 'bg-green-900/30 text-green-400' : 'bg-zinc-700/50 text-zinc-400'}`}
>
{announcement.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-400">
<TimeFormatted date={announcement.createdAt} hourPrecision hoursShort prefix={false} />
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="button"
type="button"
data-id={announcement.id}
class="edit-button inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-1 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
text="Edit"
data-announcement={JSON.stringify(announcement)}
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
<form return (
method="POST" <>
action={actions.admin.announcement.toggleActive} <tr class="group hover:bg-zinc-700/30">
class="inline-block" <td class="px-4 py-3 text-sm">
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`} <div class="line-clamp-2 text-zinc-400">{announcement.content}</div>
> </td>
<input type="hidden" name="id" value={announcement.id} /> <td class="px-4 py-3 text-left text-sm">
<input type="hidden" name="isActive" value={String(!announcement.isActive)} /> <span
<button class={`inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-medium ${announcementTypeInfo.classNames.badge}`}
type="submit"
class={`rounded-md border px-1 py-1 text-xs transition-colors ${
announcement.isActive
? 'border-yellow-500/50 bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
: 'border-green-500/50 bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
> >
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}> <Icon name={announcementTypeInfo.icon} class="me-1 size-3" />
<Icon {announcementTypeInfo.label}
name={announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'} </span>
class="size-4" </td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
<TimeFormatted date={announcement.startDate} hourPrecision={false} prefix={false} />
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-300">
{announcement.endDate ? (
<TimeFormatted date={announcement.endDate} hourPrecision={false} prefix={false} />
) : (
<span class="text-zinc-500">—</span>
)}
</td>
<td class="px-4 py-3 text-center text-sm">
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${announcement.isActive ? 'bg-green-900/30 text-green-400' : 'bg-zinc-700/50 text-zinc-400'}`}
>
{announcement.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td class="px-4 py-3 text-left text-sm text-zinc-400">
<TimeFormatted
date={announcement.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="button"
type="button"
data-id={announcement.id}
class="edit-button inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-1 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
text="Edit"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
<form
method="POST"
action={actions.admin.announcement.toggleActive}
class="inline-block"
data-confirm={`Are you sure you want to ${announcement.isActive ? 'deactivate' : 'activate'} this announcement?`}
>
<input type="hidden" name="id" value={announcement.id} />
<input type="hidden" name="isActive" value={String(!announcement.isActive)} />
<button
type="submit"
class={`rounded-md border px-1 py-1 text-xs transition-colors ${
announcement.isActive
? 'border-yellow-500/50 bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
: 'border-green-500/50 bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
>
<Tooltip text={announcement.isActive ? 'Deactivate' : 'Activate'}>
<Icon
name={
announcement.isActive ? 'ri:pause-circle-line' : 'ri:play-circle-line'
}
class="size-4"
/>
</Tooltip>
</button>
</form>
<form
method="POST"
action={actions.admin.announcement.delete}
class="inline-block"
data-confirm="Are you sure you want to delete this announcement?"
>
<input type="hidden" name="id" value={announcement.id} />
<button
type="submit"
class="rounded-md border border-red-500/50 bg-red-500/20 px-1 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/30"
>
<Tooltip text="Delete">
<Icon name="ri:delete-bin-line" class="size-4" />
</Tooltip>
</button>
</form>
</div>
</td>
</tr>
<tr id={`edit-announcement-form-${announcement.id}`} class="hidden bg-zinc-700/20">
<td colspan="7" class="p-4">
<h3 class="font-title text-md mb-3 font-semibold text-blue-300">
Edit: {announcement.content}
</h3>
<form
method="POST"
action={actions.admin.announcement.update}
class="grid gap-4 md:grid-cols-2"
>
<input type="hidden" name="id" value={announcement.id} />
<div class="space-y-3 md:col-span-2">
<InputTextArea
label="Content"
name="content"
value={announcement.content}
inputProps={{
required: true,
maxlength: 1000,
rows: 3,
}}
/> />
</Tooltip> </div>
</button>
</form>
<form <div class="space-y-3">
method="POST" <InputText
action={actions.admin.announcement.delete} label="Link"
class="inline-block" name="link"
data-confirm="Are you sure you want to delete this announcement?" inputProps={{
> type: 'url',
<input type="hidden" name="id" value={announcement.id} /> placeholder: 'https://example.com',
<button value: announcement.link,
type="submit" }}
class="rounded-md border border-red-500/50 bg-red-500/20 px-1 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/30" />
> <InputText
<Tooltip text="Delete"> label="Link Text"
<Icon name="ri:delete-bin-line" class="size-4" /> name="linkText"
</Tooltip> inputProps={{
</button> placeholder: 'Link Text',
</form> value: announcement.linkText,
</div> }}
</td> />
</tr> </div>
))
<div class="space-y-3">
<InputCardGroup
label="Type"
name="type"
options={announcementTypes.map((type) => ({
label: type.label,
value: type.value,
icon: type.icon,
}))}
cardSize="sm"
required
selectedValue={announcement.type}
/>
<InputText
label="Start Date & Time"
name="startDate"
inputProps={{
type: 'datetime-local',
required: true,
value: new Date(announcement.startDate).toISOString().slice(0, 16),
}}
/>
<InputText
label="End Date & Time"
name="endDate"
inputProps={{
type: 'datetime-local',
value: announcement.endDate
? new Date(announcement.endDate).toISOString().slice(0, 16)
: '',
}}
/>
</div>
<div class="space-y-3">
<InputCardGroup
name="isActive"
label="Status"
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
]}
selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm"
/>
<div class="pt-4">
<InputSubmitButton label="Save Changes" icon="ri:save-line" hideCancel />
<Button
type="button"
label="Cancel"
color="gray"
onclick={`document.getElementById('edit-announcement-form-${announcement.id}').classList.toggle('hidden')`}
class="ml-2"
/>
</div>
</div>
</form>
</td>
</tr>
</>
)
})
} }
</tbody> </tbody>
</table> </table>
@@ -714,6 +702,9 @@ if (toggleResult?.error) {
input[type='date'] { input[type='date'] {
color-scheme: dark; color-scheme: dark;
} }
input[type='datetime-local'] {
color-scheme: dark;
}
</style> </style>
<script> <script>
@@ -730,53 +721,18 @@ if (toggleResult?.error) {
newAnnouncementForm?.classList.add('hidden') newAnnouncementForm?.classList.add('hidden')
}) })
// Edit Modal functionality
const editModal = document.getElementById('edit-announcement-modal') as HTMLDialogElement
const editButtons = document.querySelectorAll('.edit-button')
const editForm = document.getElementById('edit-form') as HTMLFormElement
editButtons.forEach((button) => {
button.addEventListener('click', () => {
const announcementData = JSON.parse(button.getAttribute('data-announcement') || '{}')
const idInput = document.getElementById('edit-id') as HTMLInputElement
const titleInput = document.getElementById('edit-title') as HTMLInputElement
const contentInput = document.getElementById('edit-content') as HTMLTextAreaElement
const typeSelect = document.getElementById('edit-type') as HTMLSelectElement
const startDateInput = document.getElementById('edit-startDate') as HTMLInputElement
const endDateInput = document.getElementById('edit-endDate') as HTMLInputElement
const isActiveCheckbox = document.getElementById('edit-isActive') as HTMLInputElement
idInput.value = announcementData.id.toString()
titleInput.value = announcementData.title
contentInput.value = announcementData.content
typeSelect.value = announcementData.type
// Format dates for the date inputs (YYYY-MM-DDThh:mm)
const formatDateForInput = (dateString: string | null) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toISOString().slice(0, 16)
}
startDateInput.value = formatDateForInput(announcementData.startDate) ?? ''
endDateInput.value = formatDateForInput(announcementData.endDate) ?? ''
isActiveCheckbox.checked = announcementData.isActive
editModal?.showModal()
})
})
// Delete Modal functionality // Delete Modal functionality
const deleteModal = document.getElementById('delete-confirmation-modal') as HTMLDialogElement const deleteModal = document.getElementById('delete-confirmation-modal') as HTMLDialogElement
const deleteButtons = document.querySelectorAll('.delete-button') const deleteButtons = document.querySelectorAll('.delete-button')
const deleteForm = document.getElementById('delete-form') as HTMLFormElement // const deleteForm = document.getElementById('delete-form') as HTMLFormElement // Not strictly needed if not manipulating it
deleteButtons.forEach((button) => { deleteButtons.forEach((button) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const id = button.getAttribute('data-id') const id = button.getAttribute('data-id')
const deleteIdInput = document.getElementById('delete-id') as HTMLInputElement const deleteIdInput = document.getElementById('delete-id') as HTMLInputElement
deleteIdInput.value = id || '' if (deleteIdInput) {
deleteIdInput.value = id || ''
}
deleteModal?.showModal() deleteModal?.showModal()
}) })
}) })

View File

@@ -8,8 +8,8 @@ import {
} from '@prisma/client' } 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 { Image } from 'astro:assets'
import MyPicture from '../../../../components/MyPicture.astro'
import { serviceVisibilities } from '../../../../constants/serviceVisibility' import { serviceVisibilities } from '../../../../constants/serviceVisibility'
import BaseLayout from '../../../../layouts/BaseLayout.astro' import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { cn } from '../../../../lib/cn' import { cn } from '../../../../lib/cn'
@@ -334,7 +334,7 @@ const buttonSmallWarningClasses = cn(
{ {
service.imageUrl ? ( service.imageUrl ? (
<div class="mt-2 shrink-0"> <div class="mt-2 shrink-0">
<Image <MyPicture
src={service.imageUrl} src={service.imageUrl}
alt="Current service image" alt="Current service image"
width={100} width={100}

View File

@@ -2,9 +2,8 @@
import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/client' import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/client'
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import defaultImage from '../../../assets/fallback-service-image.jpg' import MyPicture from '../../../components/MyPicture.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro' import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import { getKycLevelInfo } from '../../../constants/kycLevels' import { getKycLevelInfo } from '../../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../../constants/verificationStatus' import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
@@ -343,23 +342,14 @@ const truncate = (text: string, length: number) => {
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="h-10 w-10 flex-shrink-0"> <div class="h-10 w-10 flex-shrink-0">
{service.imageUrl ? ( <MyPicture
<Image src={service.imageUrl}
src={service.imageUrl} fallback="service"
alt={service.name} alt={service.name}
width={40} width={40}
height={40} height={40}
class="h-10 w-10 rounded-md object-cover" class="h-10 w-10 rounded-md object-cover"
/> />
) : (
<Image
src={defaultImage}
alt={service.name}
width={40}
height={40}
class="h-10 w-10 rounded-md object-cover"
/>
)}
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-sm font-medium text-zinc-200">{service.name}</div> <div class="text-sm font-medium text-zinc-200">{service.name}</div>

View File

@@ -1,7 +1,6 @@
--- ---
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 { Image } from 'astro:assets'
import BadgeSmall from '../../../components/BadgeSmall.astro' import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro' import Button from '../../../components/Button.astro'
@@ -11,6 +10,7 @@ import InputSelect from '../../../components/InputSelect.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro' import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro' import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro' import InputTextArea from '../../../components/InputTextArea.astro'
import MyPicture from '../../../components/MyPicture.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro' import TimeFormatted from '../../../components/TimeFormatted.astro'
import { getServiceUserRoleInfo, serviceUserRoles } from '../../../constants/serviceUserRoles' import { getServiceUserRoleInfo, serviceUserRoles } from '../../../constants/serviceUserRoles'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
@@ -123,7 +123,7 @@ if (!user) return Astro.rewrite('/404')
<div class="mt-12"> <div class="mt-12">
{ {
!!user.picture && ( !!user.picture && (
<Image <MyPicture
src={user.picture} src={user.picture}
alt="" alt=""
width={80} width={80}
@@ -218,7 +218,7 @@ if (!user) return Astro.rewrite('/404')
value={user.picture} value={user.picture}
error={updateInputErrors.pictureFile} error={updateInputErrors.pictureFile}
square square
description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF, JXL. Max size: 5MB." description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF. Max size: 5MB."
/> />
<InputCardGroup <InputCardGroup
@@ -265,7 +265,7 @@ if (!user) return Astro.rewrite('/404')
<div class="mb-1 flex items-center justify-between gap-4"> <div class="mb-1 flex items-center justify-between gap-4">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{!!note.addedByUser?.picture && ( {!!note.addedByUser?.picture && (
<Image <MyPicture
src={note.addedByUser.picture} src={note.addedByUser.picture}
alt="" alt=""
width={12} width={12}

View File

@@ -1,12 +1,11 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { Picture } from 'astro:assets'
import { z } from 'astro:content' import { z } from 'astro:content'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import BadgeStandard from '../components/BadgeStandard.astro' import BadgeStandard from '../components/BadgeStandard.astro'
import { makeOverallScoreInfo } from '../components/ScoreSquare.astro' import MyPicture from '../components/MyPicture.astro'
import SortArrowIcon from '../components/SortArrowIcon.astro' import SortArrowIcon from '../components/SortArrowIcon.astro'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { getAttributeTypeInfo } from '../constants/attributeTypes'
@@ -15,6 +14,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import { sortAttributes } from '../lib/attributes' import { sortAttributes } from '../lib/attributes'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { formatNumber } from '../lib/numbers' import { formatNumber } from '../lib/numbers'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
@@ -102,8 +102,13 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<BaseLayout <BaseLayout
pageTitle="Attributes" pageTitle="Attributes"
description="Browse all available service attributes used to evaluate privacy and trust scores on KYCnot.me." description="Browse all available service attributes used to evaluate privacy and trust scores."
ogImage={{ template: 'generic', title: 'All attributes' }} ogImage={{
template: 'generic',
title: 'All attributes',
description: 'Browse all available service attributes',
icon: 'ri:list-radio',
}}
> >
<h1 class="font-title mb-2 text-center text-3xl font-bold text-white">Service attributes</h1> <h1 class="font-title mb-2 text-center text-3xl font-bold text-white">Service attributes</h1>
@@ -202,12 +207,11 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-zinc-800" class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-zinc-800"
> >
{service.imageUrl ? ( {service.imageUrl ? (
<Picture <MyPicture
src={service.imageUrl} src={service.imageUrl}
alt={service.name} alt={service.name}
width={24} width={24}
height={24} height={24}
formats={['jxl', 'avif', 'webp']}
class="size-6 shrink-0 rounded-xs object-contain" class="size-6 shrink-0 rounded-xs object-contain"
/> />
) : ( ) : (
@@ -349,12 +353,11 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-zinc-800" class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-zinc-800"
> >
{service.imageUrl ? ( {service.imageUrl ? (
<Picture <MyPicture
src={service.imageUrl} src={service.imageUrl}
alt={service.name} alt={service.name}
width={24} width={24}
height={24} height={24}
formats={['jxl', 'avif', 'webp']}
class="size-6 shrink-0 rounded-xs object-contain" class="size-6 shrink-0 rounded-xs object-contain"
/> />
) : ( ) : (

View File

@@ -1,11 +1,11 @@
--- ---
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Picture } from 'astro:assets'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import FormatTimeInterval from '../components/FormatTimeInterval.astro' import FormatTimeInterval from '../components/FormatTimeInterval.astro'
import MyPicture from '../components/MyPicture.astro'
import TimeFormatted from '../components/TimeFormatted.astro' import TimeFormatted from '../components/TimeFormatted.astro'
import { import {
eventTypes, eventTypes,
@@ -151,7 +151,12 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
description="Discover important events, updates, and news about KYC-free services in chronological order." description="Discover important events, updates, and news about KYC-free services in chronological order."
widthClassName="max-w-screen-lg" widthClassName="max-w-screen-lg"
className={{ main: 'sm:flex sm:items-start sm:gap-6' }} className={{ main: 'sm:flex sm:items-start sm:gap-6' }}
ogImage={{ template: 'generic', title: 'Events' }} ogImage={{
template: 'generic',
title: 'Events',
description: 'Discover important events, updates, and news about KYC-free services',
icon: 'ri:calendar-event-line',
}}
htmx htmx
> >
<h1 class="font-title mb-6 block text-center text-2xl font-bold text-white sm:hidden"> <h1 class="font-title mb-6 block text-center text-2xl font-bold text-white sm:hidden">
@@ -287,12 +292,11 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
class="group flex h-8 items-center gap-2 rounded-full border border-green-500/30 bg-black/40 px-3 text-sm text-white" class="group flex h-8 items-center gap-2 rounded-full border border-green-500/30 bg-black/40 px-3 text-sm text-white"
> >
{service?.imageUrl && ( {service?.imageUrl && (
<Picture <MyPicture
src={service.imageUrl} src={service.imageUrl}
alt={service.name} alt={service.name}
width={16} width={16}
height={16} height={16}
formats={['jxl', 'avif', 'webp']}
class="size-4 shrink-0 rounded-xs object-contain" class="size-4 shrink-0 rounded-xs object-contain"
/> />
)} )}
@@ -385,12 +389,11 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
class="-m-1.5 flex w-fit items-center rounded-md p-1.5 leading-none transition-colors hover:bg-zinc-800" class="-m-1.5 flex w-fit items-center rounded-md p-1.5 leading-none transition-colors hover:bg-zinc-800"
> >
{event.service.imageUrl && ( {event.service.imageUrl && (
<Picture <MyPicture
src={event.service.imageUrl} src={event.service.imageUrl}
alt={event.service.name} alt={event.service.name}
width={16} width={16}
height={16} height={16}
formats={['jxl', 'avif', 'webp']}
class="size-4 shrink-0 rounded-xs object-contain" class="size-4 shrink-0 rounded-xs object-contain"
/> />
)} )}

View File

@@ -4,7 +4,6 @@ import { z } from 'astro:schema'
import { groupBy, orderBy } from 'lodash-es' import { groupBy, orderBy } from 'lodash-es'
import seedrandom from 'seedrandom' import seedrandom from 'seedrandom'
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import Pagination from '../components/Pagination.astro' import Pagination from '../components/Pagination.astro'
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro' import ServiceFiltersPill from '../components/ServiceFiltersPill.astro'
@@ -489,18 +488,7 @@ const filtersOptions = {
export type ServicesFiltersOptions = typeof filtersOptions export type ServicesFiltersOptions = typeof filtersOptions
const currentDate = new Date() //
const activeAnnouncements = await prisma.announcement.findMany({
where: {
isActive: true,
startDate: { lte: currentDate },
OR: [{ endDate: null }, { endDate: { gt: currentDate } }],
},
orderBy: [
{ type: 'desc' }, // ALERT first, then WARNING, then INFO
{ createdAt: 'desc' },
],
})
--- ---
<BaseLayout <BaseLayout
@@ -516,9 +504,6 @@ const activeAnnouncements = await prisma.announcement.findMany({
}, },
]} ]}
> >
<!-- Display announcements at the top of the page -->
<AnnouncementBanner announcements={activeAnnouncements} />
<div class="flex flex-col gap-4 sm:flex-row sm:gap-8"> <div class="flex flex-col gap-4 sm:flex-row sm:gap-8">
<div <div
class='[&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-night-700 flex items-stretch sm:hidden [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-2 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-green-500 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-2' class='[&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-night-700 flex items-stretch sm:hidden [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-2 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-green-500 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-2'

View File

@@ -2,6 +2,7 @@
layout: ../layouts/MarkdownLayout.astro layout: ../layouts/MarkdownLayout.astro
title: How does karma work? title: How does karma work?
description: "KYCnot.me has a user karma system, here's how it works" description: "KYCnot.me has a user karma system, here's how it works"
icon: 'ri:hearts-line'
author: KYCnot.me author: KYCnot.me
pubDate: 2025-05-15 pubDate: 2025-05-15
--- ---

View File

@@ -183,7 +183,12 @@ const notifications = dbNotifications.map((notification) => ({
pageTitle="Notifications" pageTitle="Notifications"
description="View your notifications and manage your notification preferences." description="View your notifications and manage your notification preferences."
widthClassName="max-w-screen-lg" widthClassName="max-w-screen-lg"
ogImage={{ template: 'generic', title: 'Notifications' }} ogImage={{
template: 'generic',
title: 'Notifications',
description: 'View and manage your notifications',
icon: 'ri:notification-line',
}}
> >
<section class="mx-auto w-full"> <section class="mx-auto w-full">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">

View File

@@ -1,19 +1,31 @@
import { ogImageTemplates } from '../components/OgImage' import { ogImageTemplates, type OgImageAllTemplatesWithProps } from '../components/OgImage'
import { urlParamsToObject } from '../lib/urls'
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
import type { Misc } from 'ts-toolbelt'
export const GET: APIRoute = (context) => { function toJSON<T extends Misc.JSON.Value>(data: string | null | undefined): T | undefined {
const { template, ...props } = urlParamsToObject(context.url.searchParams) if (!data) return undefined
try {
return JSON.parse(data) as T
} catch (_error) {
return undefined
}
}
if (!template) return ogImageTemplates.default({}, context) export const GET: APIRoute = async (context) => {
const { template, ...props } = toJSON<OgImageAllTemplatesWithProps>(
context.url.searchParams.get('data')
) ?? { template: 'default' }
if (!template as unknown) return ogImageTemplates.default({}, context)
if (!(template in ogImageTemplates)) { if (!(template in ogImageTemplates)) {
console.error(`Invalid template: "${template}"`) console.error(`Invalid template: "${template}"`)
return ogImageTemplates.default({}, context) return ogImageTemplates.default({}, context)
} }
const response = ogImageTemplates[template as keyof typeof ogImageTemplates](props, context) // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
const response = await ogImageTemplates[template](props as any, context)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!response) { if (!response) {
console.error(`Cannot generate image for template: ${template} and props: ${JSON.stringify(props)}`) console.error(`Cannot generate image for template: ${template} and props: ${JSON.stringify(props)}`)

View File

@@ -86,7 +86,12 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
<BaseLayout <BaseLayout
pageTitle={`${serviceSuggestion.service.name} | Service suggestion`} pageTitle={`${serviceSuggestion.service.name} | Service suggestion`}
description="View your service suggestion" description="View your service suggestion"
ogImage={{ template: 'generic', title: serviceSuggestion.service.name }} ogImage={{
template: 'generic',
title: 'My service suggestions',
description: 'View and manage your service suggestion',
icon: 'ri:service-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
htmx htmx
breadcrumbs={[ breadcrumbs={[

View File

@@ -65,7 +65,12 @@ if (!service) return Astro.rewrite('/404')
<BaseLayout <BaseLayout
pageTitle="Edit service" pageTitle="Edit service"
description="Suggest an edit to service" description="Suggest an edit to service"
ogImage={{ template: 'generic', title: 'Edit service' }} ogImage={{
template: 'generic',
title: 'Edit service',
description: 'Suggest an edit to service',
icon: 'ri:edit-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
breadcrumbs={[ breadcrumbs={[
{ {

View File

@@ -1,11 +1,10 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { Picture } from 'astro:assets'
import { z } from 'astro:content' import { z } from 'astro:content'
import defaultServiceImage from '../../assets/fallback-service-image.jpg'
import Button from '../../components/Button.astro' import Button from '../../components/Button.astro'
import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro' import Tooltip from '../../components/Tooltip.astro'
import { import {
@@ -72,7 +71,12 @@ const success = !!createResult && !createResult.error
<BaseLayout <BaseLayout
pageTitle="My service suggestions" pageTitle="My service suggestions"
description="Manage your service suggestions" description="Manage your service suggestions"
ogImage={{ template: 'generic', title: 'Service suggestions' }} ogImage={{
template: 'generic',
title: 'Service suggestions',
description: 'Manage your service suggestions',
icon: 'ri:service-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
breadcrumbs={[ breadcrumbs={[
{ {
@@ -122,13 +126,13 @@ const success = !!createResult && !createResult.error
href={`/service/${suggestion.service.slug}`} href={`/service/${suggestion.service.slug}`}
class="inline-flex w-full min-w-32 items-center gap-2 hover:underline" class="inline-flex w-full min-w-32 items-center gap-2 hover:underline"
> >
<Picture <MyPicture
src={suggestion.service.imageUrl ?? (defaultServiceImage as unknown as string)} src={suggestion.service.imageUrl}
fallback="service"
alt={suggestion.service.name} alt={suggestion.service.name}
width={32} width={32}
height={32} height={32}
class="inline-block size-8 min-w-8 shrink-0 rounded-md" class="inline-block size-8 min-w-8 shrink-0 rounded-md"
formats={['jxl', 'avif', 'webp']}
/> />
<span class="shrink truncate">{suggestion.service.name}</span> <span class="shrink truncate">{suggestion.service.name}</span>
</a> </a>

View File

@@ -65,7 +65,12 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
<BaseLayout <BaseLayout
pageTitle="New service" pageTitle="New service"
description="Suggest a new service to be added to KYCnot.me" description="Suggest a new service to be added to KYCnot.me"
ogImage={{ template: 'generic', title: 'New service' }} ogImage={{
template: 'generic',
title: 'New service',
description: 'Suggest a new service to be listed',
icon: 'ri:add-circle-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
breadcrumbs={[ breadcrumbs={[
{ {

View File

@@ -4,8 +4,7 @@ import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { Picture } from 'astro:assets' import { head, orderBy, pick, shuffle, sortBy, tail } from 'lodash-es'
import { head, orderBy, shuffle, sortBy, tail } from 'lodash-es'
import AdminOnly from '../../components/AdminOnly.astro' import AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
@@ -17,6 +16,7 @@ import DropdownButton from '../../components/DropdownButton.astro'
import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro' import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro'
import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro' import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro'
import FormatTimeInterval from '../../components/FormatTimeInterval.astro' import FormatTimeInterval from '../../components/FormatTimeInterval.astro'
import MyPicture from '../../components/MyPicture.astro'
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage' import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage'
import ScoreGauge from '../../components/ScoreGauge.astro' import ScoreGauge from '../../components/ScoreGauge.astro'
import ScoreSquare from '../../components/ScoreSquare.astro' import ScoreSquare from '../../components/ScoreSquare.astro'
@@ -349,8 +349,12 @@ const getVerificationStepStatusInfo = (status: VerificationStepStatus) => {
const itemReviewedId = new URL(`/service/${service.slug}`, Astro.url).href const itemReviewedId = new URL(`/service/${service.slug}`, Astro.url).href
const ogImageTemplateData = { const ogImageTemplateData = {
template: 'generic', template: 'service',
title: service.name, title: service.name,
description: service.description,
categories: service.categories.map((category) => pick(category, ['name', 'icon'])),
score: service.overallScore,
imageUrl: service.imageUrl,
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
--- ---
@@ -477,9 +481,8 @@ const ogImageTemplateData = {
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{ {
!!service.imageUrl && ( !!service.imageUrl && (
<Picture <MyPicture
src={service.imageUrl} src={service.imageUrl}
formats={['jxl', 'avif', 'webp']}
alt={service.name || "Service's logo"} alt={service.name || "Service's logo"}
class="size-12 shrink-0 rounded-sm object-contain" class="size-12 shrink-0 rounded-sm object-contain"
width={48} width={48}

View File

@@ -1,14 +1,13 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { Picture } from 'astro:assets'
import { sortBy } from 'lodash-es' import { sortBy } from 'lodash-es'
import defaultServiceImage from '../../assets/fallback-service-image.jpg'
import AdminOnly from '../../components/AdminOnly.astro' import AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro' import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputTextArea from '../../components/InputTextArea.astro' import InputTextArea from '../../components/InputTextArea.astro'
import MyPicture from '../../components/MyPicture.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro' import Tooltip from '../../components/Tooltip.astro'
import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions' import { getKarmaTransactionActionInfo } from '../../constants/karmaTransactionActions'
@@ -178,7 +177,13 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<BaseLayout <BaseLayout
pageTitle={`${user.name} - Account`} pageTitle={`${user.name} - Account`}
description="Manage your user profile" description="Manage your user profile"
ogImage={{ template: 'generic', title: `${user.name} | Account` }} ogImage={{
template: 'generic',
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
title: user.displayName || user.name,
description: 'User profile page',
icon: 'ri:user-3-line',
}}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
className={{ className={{
main: 'space-y-6', main: 'space-y-6',
@@ -238,7 +243,13 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<header class="flex items-center gap-4"> <header class="flex items-center gap-4">
{ {
user.picture ? ( user.picture ? (
<img src={user.picture} alt="" class="ring-day-500/30 size-16 rounded-full ring-2" /> <MyPicture
src={user.picture}
alt=""
class="ring-day-500/30 size-16 rounded-full ring-2"
width={64}
height={64}
/>
) : ( ) : (
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2"> <div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
<Icon name="ri:user-3-line" class="size-8" /> <Icon name="ri:user-3-line" class="size-8" />
@@ -555,8 +566,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
href={`/service/${affiliation.service.slug}`} href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm" class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
> >
<Picture <MyPicture
src={affiliation.service.imageUrl ?? (defaultServiceImage as unknown as string)} src={affiliation.service.imageUrl}
fallback="service"
alt={affiliation.service.name} alt={affiliation.service.name}
width={40} width={40}
height={40} height={40}