Compare commits

...

2 Commits

Author SHA1 Message Date
pluja
2489e94b0e Release 202506042153 2025-06-04 21:53:07 +00:00
pluja
144af17a70 Release 202506042038 2025-06-04 20:38:49 +00:00
12 changed files with 348 additions and 282 deletions

View File

@@ -6,7 +6,11 @@
// @ts-expect-error
const typedSelf = self
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
const CACHE_NAME = 'kycnot-sw-push-notifications-v2'
/** @typedef {import('../src/lib/webPush').NotificationPayload} NotificationPayload */
/** @typedef {{defaultActionUrl: string, payload: NotificationPayload | null}} NotificationData */
/** @typedef {NotificationOptions & { actions: { action: string; title: string; icon?: string }[], timestamp: number, data: NotificationData } } CustomNotificationOptions */
typedSelf.addEventListener('install', (event) => {
console.log('Service Worker installing')
@@ -22,36 +26,59 @@ typedSelf.addEventListener('push', (event) => {
console.log('Push event received:', event)
if (!event.data) {
console.log('Push event but no data')
console.error('Push event but no data')
return
}
let notificationData
try {
notificationData = event.data.json()
} catch (error) {
console.error('Error parsing push data:', error)
notificationData = {
title: 'New Notification',
options: {
body: event.data.text() || 'You have a new notification',
},
}
}
const { title, options } = notificationData
const notificationOptions = {
body: options.body || '',
icon: options.icon || '/favicon.svg',
badge: options.badge || '/favicon.svg',
data: options.data || {},
let title = 'New Notification'
/** @type {CustomNotificationOptions} */
let options = {
body: 'You have a new notification',
lang: 'en-US',
icon: '/favicon.svg',
badge: '/favicon.svg',
requireInteraction: false,
silent: false,
...options,
actions: [
{
action: 'view',
title: 'View',
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
},
],
timestamp: Date.now(),
data: {
defaultActionUrl: '/notifications',
payload: null,
},
}
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
try {
/** @type {NotificationPayload} */
const rawData = event.data.json()
if (typeof rawData !== 'object' || rawData === null) throw new Error('Invalid push data, not an object')
if (!('title' in rawData) || typeof rawData.title !== 'string')
throw new Error('Invalid push data, no title')
title = rawData.title
options = {
...options,
body: rawData.body || undefined,
actions: rawData.actions.map((action) => ({
action: action.action,
title: action.title,
icon: action.icon,
})),
data: {
...options.data,
payload: rawData,
},
}
} catch (error) {
console.error('Error parsing push data:', error)
}
event.waitUntil(typedSelf.registration.showNotification(title, options))
})
typedSelf.addEventListener('notificationclick', (event) => {
@@ -59,7 +86,11 @@ typedSelf.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = event.notification.data?.url || '/'
/** @type {NotificationData} */
const data = event.notification.data
// @ts-expect-error I already use optional chaining
const url = data.payload?.[event.action]?.url || data.defaultActionUrl
event.waitUntil(
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {

View File

@@ -2,7 +2,6 @@ import { z } from 'astro/zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { sendNotifications } from '../../lib/sendNotifications'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = {
@@ -26,10 +25,8 @@ export const adminNotificationActions = {
select: { id: true },
})
const results = await sendNotifications(notifications.map((notification) => notification.id))
return {
message: `Sent to ${results.subscriptions.success.toLocaleString()} devices, ${results.subscriptions.failure.toLocaleString()} failed.`,
message: `Created ${notifications.length.toString()} notifications.`,
}
},
}),

View File

@@ -48,7 +48,9 @@ const hasError = !!error && error.length > 0
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
<label for={inputId}>{label}</label>
<label for={inputId} transition:persist>
{label}
</label>
{required && '*'}
</legend>
{!!descriptionLabel && (

View File

@@ -8,7 +8,7 @@ type ServiceSuggestionStatusInfo<T extends string | null | undefined = string> =
slug: string
label: string
icon: string
iconClass: string
color: string
default: boolean
}
@@ -28,7 +28,7 @@ export const {
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line',
iconClass: 'text-current/60',
color: 'gray',
default: false,
}),
[
@@ -37,7 +37,7 @@ export const {
slug: 'pending',
label: 'Pending',
icon: 'ri:time-line',
iconClass: 'text-yellow-400',
color: 'yellow',
default: true,
},
{
@@ -45,7 +45,7 @@ export const {
slug: 'approved',
label: 'Approved',
icon: 'ri:check-line',
iconClass: 'text-green-400',
color: 'green',
default: false,
},
{
@@ -53,7 +53,7 @@ export const {
slug: 'rejected',
label: 'Rejected',
icon: 'ri:close-line',
iconClass: 'text-red-400',
color: 'red',
default: false,
},
{
@@ -61,7 +61,7 @@ export const {
slug: 'withdrawn',
label: 'Withdrawn',
icon: 'ri:arrow-left-line',
iconClass: 'text-gray-400',
color: 'gray',
default: false,
},
] as const satisfies ServiceSuggestionStatusInfo<ServiceSuggestionStatus>[]

View File

@@ -7,6 +7,7 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
import { makeCommentUrl } from './commentsWithReplies'
import type { NotificationAction } from './webPush'
import type { Prisma } from '@prisma/client'
export function makeNotificationTitle(
@@ -244,7 +245,7 @@ export function makeNotificationContent(
}
}
export function makeNotificationLink(
export function makeNotificationActions(
notification: Prisma.NotificationGetPayload<{
select: {
type: true
@@ -286,47 +287,120 @@ export function makeNotificationLink(
}
}>,
origin: string
): string | null {
): NotificationAction[] {
switch (notification.type) {
case 'TEST': {
return `${origin}/notifications`
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/notifications`,
},
{
action: 'profile',
title: 'Profile',
...iconNameAndUrl('ri:user-line'),
url: `${origin}/account`,
},
]
}
case 'COMMENT_STATUS_CHANGE':
case 'REPLY_COMMENT_CREATED':
case 'COMMUNITY_NOTE_ADDED':
case 'ROOT_COMMENT_CREATED': {
if (!notification.aboutComment) return null
return makeCommentUrl({
serviceSlug: notification.aboutComment.service.slug,
commentId: notification.aboutComment.id,
origin,
})
if (!notification.aboutComment) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: makeCommentUrl({
serviceSlug: notification.aboutComment.service.slug,
commentId: notification.aboutComment.id,
origin,
}),
},
]
}
case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return null
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`
if (!notification.aboutServiceSuggestionMessage) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`,
},
]
}
case 'SUGGESTION_STATUS_CHANGE': {
if (!notification.aboutServiceSuggestionId) return null
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`
if (!notification.aboutServiceSuggestionId) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
},
]
}
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK': {
// return `${origin}/account#karma-unlocks`
// return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
// }
case 'KARMA_CHANGE': {
return `${origin}/account#karma-transactions`
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/account#karma-transactions`,
},
]
}
case 'ACCOUNT_STATUS_CHANGE': {
return `${origin}/account#account-status`
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/account#account-status`,
},
]
}
case 'EVENT_CREATED': {
if (!notification.aboutEvent) return null
return `${origin}/service/${notification.aboutEvent.service.slug}#events`
if (!notification.aboutEvent) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
},
]
}
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
if (!notification.aboutService) return null
return `${origin}/service/${notification.aboutService.slug}#verification`
if (!notification.aboutService) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service/${notification.aboutService.slug}#verification`,
},
]
}
}
}
function iconUrl<T extends `${string}:${string}`>(iconName: T) {
return `https://api.iconify.design/${iconName.replace(':', '/') as T extends `${infer Prefix}:${infer Suffix}` ? `${Prefix}/${Suffix}` : never}.svg` as const
}
function iconNameAndUrl<T extends `${string}:${string}`>(iconName: T) {
return {
iconName,
icon: iconUrl(iconName),
} as const
}

View File

@@ -2,7 +2,7 @@ import { z } from 'astro/zod'
import { Client } from 'pg'
import { zodParseJSON } from './json'
import { sendNotifications } from './sendNotifications'
import { sendNotification } from './sendNotifications'
import { getServerEnvVariable } from './serverEnvVariables'
import type { AstroIntegration, HookParameters } from 'astro'
@@ -21,10 +21,10 @@ async function handleNotificationCreated(
try {
logger.info(`Processing notification with ID: ${String(notificationId)}`)
const results = await sendNotifications([notificationId], logger)
const results = await sendNotification(notificationId, logger)
logger.info(
`Sent push notifications for notification ${String(notificationId)} to ${String(results.subscriptions.success)} devices, ${String(results.subscriptions.failure)} failed`
`Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed`
)
} catch (error) {
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)

View File

@@ -1,20 +1,18 @@
import { sum } from 'lodash-es'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications'
import { prisma } from './prisma'
import { getServerEnvVariable } from './serverEnvVariables'
import { type NotificationData, sendPushNotification } from './webPush'
import { type NotificationPayload, sendPushNotification } from './webPush'
import type { AstroIntegrationLogger } from 'astro'
const SITE_URL = getServerEnvVariable('SITE_URL')
export async function sendNotifications(
notificationIds: number[],
export async function sendNotification(
notificationId: number,
logger: AstroIntegrationLogger | Console = console
) {
const notifications = await prisma.notification.findMany({
where: { id: { in: notificationIds } },
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
select: {
id: true,
type: true,
@@ -110,76 +108,58 @@ export async function sendNotifications(
},
})
if (notifications.length < notificationIds.length) {
const missingNotificationIds = notificationIds.filter(
(id) => !notifications.some((notification) => notification.id === id)
)
logger.error(`Notifications with IDs ${missingNotificationIds.join(', ')} not found`)
if (!notification) {
logger.error(`Notification with ID ${notificationId.toString()} not found`)
return { success: 0, failure: 0, total: 0 }
}
const results = await Promise.allSettled(
notifications.map(async (notification) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: notification.userId },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: notification.userId },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
},
})
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return { success: 0, failure: 0, total: 0 }
}
const notificationPayload = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification),
actions: makeNotificationActions(notification, SITE_URL),
} satisfies NotificationPayload
const subscriptionResults = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
})
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return
}
const notificationData = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification) ?? undefined,
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
} satisfies NotificationData
const subscriptionResults = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
notificationData
)
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
notificationPayload
)
return {
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
total: subscriptionResults.length,
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
)
return {
subscriptions: {
success: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.success) ?? 0)),
failure: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.failure) ?? 0)),
total: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.total) ?? 0)),
},
notifications: {
success: results.filter((r) => r.status === 'fulfilled').length,
failure: results.filter((r) => r.status !== 'fulfilled').length,
total: results.length,
},
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
total: subscriptionResults.length,
}
}

View File

@@ -12,12 +12,19 @@ webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
export { webpush }
export type NotificationData = {
export type NotificationAction = {
action: string
title: string
body?: string
icon?: string
badge?: string
url?: string
url: string | null
iconName?: string
}
export type NotificationPayload = {
title: string
body: string | null
actions: NotificationAction[]
}
export async function sendPushNotification(
@@ -28,26 +35,13 @@ export async function sendPushNotification(
auth: string
}
},
data: NotificationData
payload: NotificationPayload
) {
try {
const result = await webpush.sendNotification(
subscription,
JSON.stringify({
title: data.title,
options: {
body: data.body,
icon: data.icon ?? '/favicon.svg',
badge: data.badge ?? '/favicon.svg',
data: {
url: data.url,
},
},
}),
{
TTL: 24 * 60 * 60, // 24 hours
}
)
// NOTE: View sw.js to see how the notification is handled
const result = await webpush.sendNotification(subscription, JSON.stringify(payload), {
TTL: 24 * 60 * 60, // 24 hours
})
return { success: true, result } as const
} catch (error) {
console.error('Error sending push notification:', error)

View File

@@ -147,7 +147,7 @@ The privacy score measures how well a service protects user privacy, using a tra
3. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
4. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
5. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score.
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
7. **Final Score Range:** The final score is always kept between 0 and 100.
#### Trust Score
@@ -160,7 +160,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
- **Approved:** +5 points
- **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 points
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score.
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
4. **Recently Listed Penalty & Flag:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
5. **Final Score Range:** The final score is always kept between 0 and 100.

View File

@@ -1,10 +1,11 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro'
import InputSelect from '../../../components/InputSelect.astro'
import ServiceCard from '../../../components/ServiceCard.astro'
import UserBadge from '../../../components/UserBadge.astro'
import {
@@ -17,12 +18,20 @@ import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers'
import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls'
import { formatDateShort } from '../../../lib/timeAgo'
import BadgeStandard from '../../../components/BadgeStandard.astro'
const user = Astro.locals.user
if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
}
const serviceSuggestionUpdateResult = Astro.getActionResult(actions.admin.serviceSuggestions.update)
Astro.locals.banners.addIfSuccess(serviceSuggestionUpdateResult, 'Service suggestion updated successfully')
const serviceSuggestionUpdateInputErrors = isInputError(serviceSuggestionUpdateResult?.error)
? serviceSuggestionUpdateResult.error.fields
: {}
const { id: serviceSuggestionIdRaw } = Astro.params
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
if (!serviceSuggestionId) {
@@ -100,114 +109,88 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
<BaseLayout
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
htmx
description="View and manage service suggestion"
widthClassName="max-w-screen-md"
htmx
>
<div class="mb-4 flex items-center gap-4">
<Button
as="a"
href="/admin/service-suggestions"
color="success"
variant="faded"
size="md"
icon="ri:arrow-left-s-line"
label="Back"
/>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1>
<h1 class="font-title text-day-200 text-xl">Service suggestion</h1>
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
</div>
<div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
<h2 class="font-title text-day-200 mb-3 text-lg">Suggestion Details</h2>
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<span class="font-title text-gray-400">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
<span class="font-title text-gray-400">Status:</span>
<span
class={cn(
'inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
<span class="font-title text-gray-400">Submitted by:</span>
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
<span class="font-title text-gray-400">Submitted at:</span>
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
<span class="font-title text-gray-400">Service page:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
View Service <Icon
name="ri:external-link-line"
class="ml-0.5 inline-block size-3 align-[-0.05em]"
/>
</a>
<section class="border-night-400 bg-night-600 rounded-lg border p-6">
<div class="text-day-200 xs:grid-cols-2 grid gap-2 text-sm sm:grid-cols-3">
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Status:</span>
<BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Author:</span>
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Submitted:</span>
<span>
{
formatDateShort(serviceSuggestion.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Service:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
Open <Icon name="ri:external-link-line" class="ml-0.5 inline-block size-3 align-[-0.05em]" />
</a>
</div>
</div>
<div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
<span class="font-title block font-bold">Notes for moderators:</span>
{
serviceSuggestion.notes && (
<div class="mb-4">
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3>
<div
class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm wrap-anywhere whitespace-pre-wrap text-gray-300"
set:text={serviceSuggestion.notes}
/>
</div>
serviceSuggestion.notes ? (
<div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
) : (
<div class="text-day-400 my-4 text-center text-sm italic">Empty</div>
)
}
</div>
</div>
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
<div class="flex items-center justify-between">
<h2 class="font-title text-day-200 text-lg">Messages</h2>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="mt-6 flex items-end gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
<InputSelect
name="status"
label="Update status"
options={serviceSuggestionStatuses.map((status) => ({
label: status.label,
value: status.value,
}))}
selectProps={{ value: serviceSuggestion.status }}
class="flex-1"
error={serviceSuggestionUpdateInputErrors.status}
/>
<Button as="button" type="submit" color="success" size="md" icon="ri:save-line" label="Update" />
</form>
</section>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
<select
name="status"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50"
>
{
serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={serviceSuggestion.status === status.value}>
{status.label}
</option>
))
}
</select>
<Button
as="button"
type="submit"
color="success"
variant="faded"
size="md"
icon="ri:save-line"
label="Update"
/>
</form>
</div>
<Chat
messages={serviceSuggestion.messages}
userId={user.id}
action={actions.admin.serviceSuggestions.message}
formData={{
suggestionId: serviceSuggestion.id,
}}
/>
</div>
<Chat
messages={serviceSuggestion.messages}
title="Chat with moderators"
userId={user.id}
action={actions.admin.serviceSuggestions.message}
formData={{
suggestionId: serviceSuggestion.id,
}}
class="mt-12"
/>
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { getNotificationTypeInfo } from '../constants/notificationTypes'
import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from '../lib/notifications'
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from '../lib/notifications'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
import { makeLoginUrl } from '../lib/redirectUrls'
@@ -199,7 +199,7 @@ const notifications = dbNotifications.map((notification) => ({
typeInfo: getNotificationTypeInfo(notification.type),
title: makeNotificationTitle(notification, user),
content: makeNotificationContent(notification),
link: makeNotificationLink(notification, Astro.url.origin),
actions: makeNotificationActions(notification, Astro.url.origin),
}))
---
@@ -285,18 +285,21 @@ const notifications = dbNotifications.map((notification) => ({
type="submit"
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
>
<Icon name={notification.read ? 'ri:eye-close-line' : 'ri:eye-line'} class="size-4" />
<Icon name={notification.read ? 'ri:close-line' : 'ri:check-line'} class="size-4" />
</Tooltip>
</form>
{notification.link && (
<a
href={notification.link}
{notification.actions.map((action) => (
<Tooltip
as="a"
href={action.url}
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
text={action.title}
position="left"
>
<Icon name="ri:arrow-right-line" class="size-4" />
<span class="sr-only">View details</span>
</a>
)}
<Icon name={action.iconName ?? 'ri:arrow-right-line'} class="size-4" />
<span class="sr-only">{action.title}</span>
</Tooltip>
))}
</div>
</div>
</div>

View File

@@ -111,9 +111,9 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
},
]}
>
<div class="mt-12 mb-6 flex flex-col items-center justify-center gap-3">
<div class="flex items-center gap-2">
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
<div class="mt-12 mb-6 text-center">
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
<div class="flex items-center justify-center gap-2">
<AdminOnly>
<Button
as="a"
@@ -124,29 +124,24 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
/>
</AdminOnly>
</div>
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
</div>
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
<section class="border-night-400 bg-night-600 rounded-lg border p-6">
<div class="text-day-200 grid grid-cols-2 gap-6 text-sm">
<div class="text-day-200 xs:grid-cols-2 grid gap-2 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span>Status:</span>
<span
class={cn(
'border-night-500 bg-night-800 box-content inline-flex h-8 items-center justify-center gap-1 rounded-full border px-2',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="size-4" />
{statusInfo.label}
</span>
<span class="font-title font-bold">Status:</span>
<BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span>Submitted:</span>
<span class="font-title font-bold">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Submitted:</span>
<span>
{
formatDateShort(serviceSuggestion.createdAt, {
@@ -157,15 +152,22 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Service:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
Open <Icon name="ri:external-link-line" class="ml-0.5 inline-block size-3 align-[-0.05em]" />
</a>
</div>
</div>
<div class="mt-6">
<div class="text-day-200 mb-2 text-sm">Notes for moderators:</div>
<div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
<span class="font-title block font-bold">Notes for moderators:</span>
{
serviceSuggestion.notes ? (
<div class="text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
<div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
) : (
<span class="text-sm italic">Empty</span>
<div class="text-day-400 my-4 text-center text-sm italic">Empty</div>
)
}
</div>