Files
kycnotme/web/src/pages/events.astro
2025-06-14 18:56:58 +00:00

493 lines
17 KiB
Plaintext

---
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import { orderBy } from 'lodash-es'
import Button from '../components/Button.astro'
import FormatTimeInterval from '../components/FormatTimeInterval.astro'
import MyPicture from '../components/MyPicture.astro'
import TimeFormatted from '../components/TimeFormatted.astro'
import {
eventTypes,
eventTypesZodEnumBySlug,
getEventTypeInfo,
getEventTypeInfoBySlug,
} from '../constants/eventTypes'
import { getServiceVisibilityInfo } from '../constants/serviceVisibility'
import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
import { formatDateShort } from '../lib/timeAgo'
import { createPageUrl } from '../lib/urls'
import type { Prisma } from '@prisma/client'
const PAGE_SIZE = 100
const { data: params, hasDefaultData: hasDefaultFilters } = zodParseQueryParamsStoringErrors(
{
page: z.coerce.number().int().min(1).default(1),
now: z.coerce.date().default(new Date()),
from: z.preprocess((val) => (val === '' ? undefined : val), z.coerce.date().optional()),
to: z.preprocess((val) => (val === '' ? undefined : val), z.coerce.date().optional()),
/** Service's slug */
service: z.string().optional(),
type: eventTypesZodEnumBySlug.optional(),
},
Astro
)
const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
[
'Error fetching services',
async () =>
prisma.service.findMany({
where: {
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
},
select: {
id: true,
slug: true,
name: true,
imageUrl: true,
verificationStatus: true,
},
orderBy: {
name: 'asc',
},
}),
[],
],
[
'Error fetching events',
async () =>
prisma.event.findManyAndCount({
where: {
visible: true,
createdAt: {
lte: params.now,
},
service: {
slug: params.service ?? undefined,
serviceVisibility: {
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
},
},
type: params.type ? getEventTypeInfoBySlug(params.type).id : undefined,
...(params.from || params.to
? {
OR: [
...(params.from
? ([
{ endedAt: null },
{ endedAt: { gte: params.from } },
] satisfies Prisma.EventWhereInput[])
: []),
...(params.to
? ([{ startedAt: { lte: params.to } }] satisfies Prisma.EventWhereInput[])
: []),
],
}
: {}),
},
select: {
id: true,
title: true,
content: true,
source: true,
type: true,
startedAt: true,
endedAt: true,
service: {
select: {
id: true,
slug: true,
name: true,
imageUrl: true,
verificationStatus: true,
serviceVisibility: true,
},
},
},
orderBy: {
startedAt: 'desc',
},
skip: (params.page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
[[], 0] as const,
],
])
const events = orderBy(
dbEvents.map((event) => ({
...event,
actualEndedAt: event.endedAt ?? params.now,
typeInfo: getEventTypeInfo(event.type),
service: {
...event.service,
verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus),
serviceVisibilityInfo: getServiceVisibilityInfo(event.service.serviceVisibility),
},
})),
['actualEndedAt', 'startedAt'],
'desc'
)
const totalPages = Math.ceil(totalEvents / PAGE_SIZE) || 1
const hasMorePages = params.page < totalPages
const createUrlWithoutFilter = (paramName: keyof typeof params) => {
const url = new URL(Astro.url)
url.searchParams.delete(paramName)
url.searchParams.forEach((value, key) => {
if (value === '') {
url.searchParams.delete(key)
}
})
return url.toString()
}
---
<BaseLayout
pageTitle="Events"
description="Discover important events, updates, and news about KYC-free services in chronological order."
widthClassName="max-w-screen-lg"
classNames={{ main: 'sm:flex sm:items-start sm:gap-6' }}
ogImage={{
template: 'generic',
title: 'Events',
description: 'Discover important events, updates, and news about KYC-free services',
icon: 'ri:calendar-event-line',
}}
htmx
>
<h1 class="font-title mb-6 block text-center text-2xl font-bold text-white sm:hidden">
Service Events Timeline
</h1>
<form
method="GET"
class={cn(
'mx-auto max-w-xs rounded-lg border border-zinc-700 bg-zinc-800/30 p-4 sm:sticky sm:top-20',
'[&:has(~[data-has-default-filters="true"])_[data-clear-filters-button]]:hidden'
)}
hx-get={Astro.url.pathname}
hx-trigger="input from:find input, keyup[key=='Enter'], change from:find select"
hx-target="#events-list-container"
hx-select="#events-list-container"
hx-swap="outerHTML"
hx-push-url="true"
hx-indicator="#search-indicator"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="font-title text-xl text-green-500">
FILTERS
<Icon
id="search-indicator"
name="ri:loader-2-line"
class="htmx-request:inline-block hidden size-5 animate-spin align-[-0.1em] text-green-500"
/>
</h2>
<a href="/events" class="text-sm text-green-500 hover:text-green-400" data-clear-filters-button>
Clear all
</a>
</div>
<label for="from" class="mt-2 mb-0.5 block text-sm text-zinc-300">From</label>
<input
type="date"
id="from"
name="from"
value={params.from?.toISOString().split('T')[0]}
class="w-full rounded-sm border border-green-500/30 bg-black/40 p-2 text-white focus:border-green-500 focus:outline-hidden"
/>
<label for="to" class="mt-2 mb-0.5 block text-sm text-zinc-300">To</label>
<input
type="date"
id="to"
name="to"
value={params.to?.toISOString().split('T')[0]}
class="w-full rounded-sm border border-green-500/30 bg-black/40 p-2 text-white focus:border-green-500 focus:outline-hidden"
/>
<label for="type" class="mt-2 mb-0.5 block text-sm text-zinc-300">Type</label>
<select
id="type"
name="type"
class="w-full rounded-sm border border-green-500/30 bg-black/40 p-2 text-white focus:border-green-500 focus:outline-hidden"
>
<option value="">All Types</option>
{
eventTypes.map((type) => (
<option value={type.slug} selected={params.type === type.slug}>
{type.label}
</option>
))
}
</select>
<label for="service" class="mt-2 mb-0.5 block text-sm text-zinc-300">Service</label>
<select
id="service"
name="service"
class="w-full rounded-sm border border-green-500/30 bg-black/40 p-2 text-white focus:border-green-500 focus:outline-hidden"
>
<option value="">All Services</option>
{
services.map((service) => (
<option value={service.slug} selected={params.service === service.slug}>
{service.name}
</option>
))
}
</select>
<Button type="submit" label="Apply" size="lg" class="sm:js:hidden mt-6 w-full" color="success" shadow />
</form>
<div class="flex-1" id="events-list-container" data-has-default-filters={hasDefaultFilters}>
<h1 class="font-title mb-3 hidden text-2xl font-bold text-white sm:block">Service Events Timeline</h1>
<!-- Active filters -->
{
!hasDefaultFilters && (
<div class="mt-8 mb-6 flex flex-wrap justify-center gap-2 sm:mt-0 sm:justify-start">
{params.from && (
<a
href={createUrlWithoutFilter('from')}
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"
>
<span>
From <TimeFormatted date={params.from} prefix={false} />
</span>
<div class="text-gray-400 group-hover:text-white">
<Icon name="ri:close-large-line" class="size-4" />
</div>
</a>
)}
{params.to && (
<a
href={createUrlWithoutFilter('to')}
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"
>
<span>
To <TimeFormatted date={params.to} prefix={false} />
</span>
<div class="text-gray-400 group-hover:text-white">
<Icon name="ri:close-large-line" class="size-4" />
</div>
</a>
)}
{params.service &&
(() => {
const service = services.find((s) => s.slug === params.service)
const verificationStatusInfo = service
? getVerificationStatusInfo(service.verificationStatus)
: null
return (
<a
href={createUrlWithoutFilter('service')}
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 && (
<MyPicture
src={service.imageUrl}
alt={service.name}
width={16}
height={16}
class="size-4 shrink-0 rounded-xs object-contain"
/>
)}
<span>
{service?.name ?? params.service}
{service && service.verificationStatus !== 'APPROVED' && verificationStatusInfo && (
<Icon
name={verificationStatusInfo.icon}
class={cn(
'inline-block size-3 shrink-0 align-[-0.1em]',
verificationStatusInfo.classNames.icon
)}
/>
)}
</span>
<div class="text-gray-400 group-hover:text-white">
<Icon name="ri:close-large-line" class="size-4" />
</div>
</a>
)
})()}
{params.type && (
<a
href={createUrlWithoutFilter('type')}
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"
>
<Icon
name={getEventTypeInfo(params.type).icon}
class={cn('size-4', getEventTypeInfo(params.type).classNames.dot, 'bg-transparent')}
/>
<span>{getEventTypeInfo(params.type).label}</span>
<div class="text-gray-400 group-hover:text-white">
<Icon name="ri:close-large-line" class="size-4" />
</div>
</a>
)}
</div>
)
}
{
events.length > 0 ? (
<ol id="events-list" class="mx-auto mt-12 sm:mt-0">
{events.map((event, i) => (
<li
class="flex items-stretch gap-2"
data-hx-event-item
{...(i === events.length - 1 && hasMorePages
? {
'hx-get': createPageUrl(params.page + 1, Astro.url, {
...Object.fromEntries(Astro.url.searchParams.entries()),
now: params.now.toISOString(),
}),
'hx-trigger': 'revealed',
'hx-swap': 'afterend',
'hx-select': '[data-hx-event-item]',
'hx-indicator': '#infinite-scroll-indicator',
}
: {})}
>
<div class="flex flex-col items-center">
<div
class={cn(
'z-10 flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-700 ring-3 ring-zinc-700/50',
event.typeInfo.classNames.dot
)}
>
<Icon name={event.typeInfo.icon} class="size-3" />
</div>
<div class="w-0.5 flex-1 bg-zinc-600" />
</div>
<div class="xs:pb-12 -mt-0.5 flex-1 pb-8">
<h3 class="font-title min-h-5 flex-1 text-lg leading-tight font-semibold text-pretty text-white">
{event.title}
</h3>
<FormatTimeInterval
as="p"
start={event.startedAt}
end={event.endedAt}
now={params.now}
class="mt-2 block text-sm leading-none font-normal text-balance text-zinc-300"
/>
<div class="mt-3 flex items-center gap-4">
<a
href={`/service/${event.service.slug}`}
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 && (
<MyPicture
src={event.service.imageUrl}
alt={event.service.name}
width={16}
height={16}
class="size-4 shrink-0 rounded-xs object-contain"
/>
)}
<span
class={cn(
'ms-2 text-sm leading-none text-zinc-300',
event.service.verificationStatus === 'VERIFICATION_FAILED' && 'text-red-200'
)}
>
{event.service.name}
</span>
{event.service.verificationStatus !== 'APPROVED' && (
<Icon
name={event.service.verificationStatusInfo.icon}
class={cn(
'ms-1 inline-block size-3 shrink-0',
event.service.verificationStatusInfo.classNames.icon
)}
/>
)}
{event.service.serviceVisibility === 'ARCHIVED' && (
<Icon
name={event.service.serviceVisibilityInfo.icon}
class={cn(
'ms-1 inline-block size-3 shrink-0',
event.service.serviceVisibilityInfo.iconClass
)}
/>
)}
</a>
{event.source && (
<a
href={event.source}
target="_blank"
rel="noopener noreferrer"
class="inline-block text-xs text-blue-400 hover:underline"
>
Source
<Icon name="ri:external-link-line" class="inline-block size-3 align-[-0.1em]" />
</a>
)}
</div>
<p class="mt-4 text-base font-normal text-pretty text-zinc-400">{event.content}</p>
</div>
</li>
))}
{!hasMorePages && (
<div class="flex min-h-8 items-center gap-2" data-hx-event-item>
<div class="flex w-5 flex-shrink-0 flex-col items-center self-stretch">
<div class="w-0.5 flex-1 bg-zinc-600" />
<div class="size-2 flex-shrink-0 rounded-full bg-zinc-600" />
<div class="flex-1" />
</div>
<p class="flex-1 text-xs text-zinc-400 italic">
{params.from
? formatDateShort(params.from, { prefix: false, caseType: 'sentence' })
: events.length > 10
? 'Big Bang'
: null}
</p>
</div>
)}
<div
class="no-js:hidden htmx-request:flex hidden min-h-8 items-center gap-2"
id="infinite-scroll-indicator"
>
<div class="flex w-5 flex-shrink-0 flex-col items-center self-stretch">
<div class="w-0.5 flex-1 bg-gradient-to-b from-zinc-600 to-transparent" />
</div>
<p class="flex-1 animate-pulse text-xs text-zinc-400 italic">Loading more events...</p>
</div>
<div class="no-js:flex hidden min-h-8 items-center gap-2" id="infinite-scroll-indicator">
<div class="flex w-5 flex-shrink-0 flex-col items-center self-stretch">
<div class="w-0.5 flex-1 bg-gradient-to-b from-zinc-600 to-transparent" />
</div>
<p class="flex-1 text-xs text-zinc-400 italic">Enable JavaScript to load more events</p>
</div>
</ol>
) : (
<p class="my-12 text-center text-zinc-400">No events reported</p>
)
}
</div>
</BaseLayout>