Files
kycnotme/web/src/pages/admin/services/[slug]/edit.astro

925 lines
35 KiB
Plaintext
Raw Normal View History

2025-05-19 10:23:36 +00:00
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
2025-05-23 21:50:03 +00:00
import { orderBy } from 'lodash-es'
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
import BadgeSmall from '../../../../components/BadgeSmall.astro'
import Button from '../../../../components/Button.astro'
import FormSection from '../../../../components/FormSection.astro'
import FormSubSection from '../../../../components/FormSubSection.astro'
2025-05-23 11:52:16 +00:00
import InputCardGroup from '../../../../components/InputCardGroup.astro'
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
import InputImageFile from '../../../../components/InputImageFile.astro'
2025-05-23 21:50:03 +00:00
import InputSelect from '../../../../components/InputSelect.astro'
2025-05-23 11:52:16 +00:00
import InputSubmitButton from '../../../../components/InputSubmitButton.astro'
import InputText from '../../../../components/InputText.astro'
import InputTextArea from '../../../../components/InputTextArea.astro'
2025-05-23 21:50:03 +00:00
import MyPicture from '../../../../components/MyPicture.astro'
import ServiceCard from '../../../../components/ServiceCard.astro'
import TimeFormatted from '../../../../components/TimeFormatted.astro'
import Tooltip from '../../../../components/Tooltip.astro'
2025-05-22 11:10:18 +00:00
import UserBadge from '../../../../components/UserBadge.astro'
2025-05-23 21:50:03 +00:00
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
2025-05-23 11:52:16 +00:00
import { formatContactMethod } from '../../../../constants/contactMethods'
import { currencies } from '../../../../constants/currencies'
2025-05-23 21:50:03 +00:00
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
2025-05-23 11:52:16 +00:00
import { kycLevels } from '../../../../constants/kycLevels'
2025-05-19 10:23:36 +00:00
import { serviceVisibilities } from '../../../../constants/serviceVisibility'
2025-05-23 11:52:16 +00:00
import { verificationStatuses } from '../../../../constants/verificationStatus'
2025-05-25 10:07:02 +00:00
import {
getVerificationStepStatusInfo,
verificationStepStatuses,
} from '../../../../constants/verificationStepStatus'
2025-05-19 10:23:36 +00:00
import BaseLayout from '../../../../layouts/BaseLayout.astro'
2025-05-23 21:50:03 +00:00
import { pluralize } from '../../../../lib/pluralize'
2025-05-19 10:23:36 +00:00
import { prisma } from '../../../../lib/prisma'
const { slug } = Astro.params
2025-05-25 10:07:02 +00:00
if (!slug) return Astro.rewrite('/404')
2025-05-19 10:23:36 +00:00
2025-05-25 10:07:02 +00:00
const serviceResult = Astro.getActionResult(actions.admin.service.update)
2025-05-19 10:23:36 +00:00
Astro.locals.banners.addIfSuccess(serviceResult, 'Service updated successfully')
2025-05-25 10:07:02 +00:00
const serviceInputErrors = isInputError(serviceResult?.error) ? serviceResult.error.fields : {}
2025-05-19 10:23:36 +00:00
if (serviceResult && !serviceResult.error && slug !== serviceResult.data.service.slug) {
return Astro.redirect(`/admin/services/${serviceResult.data.service.slug}/edit`)
}
2025-05-25 10:07:02 +00:00
const eventCreateResult = Astro.getActionResult(actions.admin.event.create)
Astro.locals.banners.addIfSuccess(eventCreateResult, 'Event created successfully')
2025-05-19 10:23:36 +00:00
const eventInputErrors = isInputError(eventCreateResult?.error) ? eventCreateResult.error.fields : {}
2025-05-25 10:07:02 +00:00
const eventUpdateResult = Astro.getActionResult(actions.admin.event.update)
Astro.locals.banners.addIfSuccess(eventUpdateResult, 'Event updated successfully')
2025-05-19 10:23:36 +00:00
const eventUpdateInputErrors = isInputError(eventUpdateResult?.error) ? eventUpdateResult.error.fields : {}
2025-05-25 10:07:02 +00:00
const eventToggleResult = Astro.getActionResult(actions.admin.event.toggle)
Astro.locals.banners.addIfSuccess(eventToggleResult, 'Event visibility updated successfully')
const eventDeleteResult = Astro.getActionResult(actions.admin.event.delete)
Astro.locals.banners.addIfSuccess(eventDeleteResult, 'Event deleted successfully')
const verificationStepCreateResult = Astro.getActionResult(actions.admin.verificationStep.create)
Astro.locals.banners.addIfSuccess(verificationStepCreateResult, 'Verification step added successfully')
2025-05-19 10:23:36 +00:00
const verificationStepInputErrors = isInputError(verificationStepCreateResult?.error)
? verificationStepCreateResult.error.fields
: {}
2025-05-25 10:07:02 +00:00
const verificationStepUpdateResult = Astro.getActionResult(actions.admin.verificationStep.update)
Astro.locals.banners.addIfSuccess(verificationStepUpdateResult, 'Verification step updated successfully')
2025-05-19 10:23:36 +00:00
const verificationStepUpdateInputErrors = isInputError(verificationStepUpdateResult?.error)
? verificationStepUpdateResult.error.fields
: {}
2025-05-25 10:07:02 +00:00
const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete)
Astro.locals.banners.addIfSuccess(verificationStepDeleteResult, 'Verification step deleted successfully')
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
const [service, categories, attributes] = await Astro.locals.banners.tryMany([
[
'Error fetching service',
() =>
prisma.service.findUnique({
where: { slug },
include: {
attributes: {
2025-05-19 10:23:36 +00:00
select: {
2025-05-23 21:50:03 +00:00
attribute: {
select: {
id: true,
},
},
2025-05-19 10:23:36 +00:00
},
},
2025-05-23 21:50:03 +00:00
categories: {
2025-05-19 10:23:36 +00:00
select: {
2025-05-23 21:50:03 +00:00
id: true,
2025-05-19 10:23:36 +00:00
name: true,
2025-05-23 21:50:03 +00:00
icon: true,
},
},
events: {
orderBy: {
startedAt: 'desc',
},
},
verificationRequests: {
select: {
id: true,
user: {
select: {
name: true,
displayName: true,
picture: true,
},
},
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
},
verificationSteps: {
orderBy: {
createdAt: 'desc',
},
},
contactMethods: {
orderBy: {
label: 'asc',
},
},
_count: {
select: {
verificationRequests: true,
2025-05-19 10:23:36 +00:00
},
},
},
2025-05-23 21:50:03 +00:00
}),
],
[
'Error fetching categories',
() =>
prisma.category.findMany({
orderBy: { name: 'asc' },
}),
[] as [],
],
[
'Error fetching attributes',
() =>
prisma.attribute.findMany({
orderBy: { category: 'asc' },
}),
[] as [],
],
])
2025-05-19 10:23:36 +00:00
if (!service) return Astro.rewrite('/404')
---
<BaseLayout pageTitle={`Edit Service: ${service.name}`}>
2025-05-23 21:50:03 +00:00
<div class="mx-auto max-w-3xl space-y-24">
<div class="mt-12 flex flex-wrap items-center justify-center gap-6">
<div class="grow-1">
{
!!service.imageUrl && (
<MyPicture
src={service.imageUrl}
alt=""
width={80}
height={80}
class="mx-auto mb-2 block size-20 rounded-xl"
/>
)
}
<h1 class="font-title mb-2 flex items-center justify-center gap-2 text-center text-3xl font-bold">
{service.name}
</h1>
<div class="flex justify-center gap-2">
<Button
as="a"
href={`/service/${service.slug}`}
icon="ri:external-link-line"
color="success"
shadow
size="sm"
label="View"
/>
<Button
as="a"
href="/admin/services"
icon="ri:arrow-left-line"
color="gray"
size="sm"
label="Back"
/>
</div>
</div>
<ServiceCard service={service} class="flex-1 grow-2 basis-64" withoutLink />
2025-05-19 10:23:36 +00:00
</div>
2025-05-23 21:50:03 +00:00
<FormSection title="Service Details">
2025-05-19 10:23:36 +00:00
<form
method="POST"
action={actions.admin.service.update}
2025-05-23 21:50:03 +00:00
class="space-y-6"
2025-05-19 10:23:36 +00:00
enctype="multipart/form-data"
>
<input type="hidden" name="id" value={service.id} />
2025-05-23 11:52:16 +00:00
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<InputTextArea
label="Description"
name="description"
inputProps={{
required: true,
rows: 4,
}}
value={service.description}
error={serviceInputErrors.description}
/>
<InputText
2025-05-23 21:50:03 +00:00
label="Slug"
description="Auto-generated if empty"
2025-05-23 11:52:16 +00:00
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
2025-05-23 21:50:03 +00:00
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
2025-05-23 11:52:16 +00:00
<InputTextArea
2025-05-23 21:50:03 +00:00
label="Service URLs"
description="One per line"
2025-05-23 11:52:16 +00:00
name="serviceUrls"
inputProps={{
rows: 3,
placeholder: 'https://example1.com\nhttps://example2.com',
}}
value={service.serviceUrls.join('\n')}
error={serviceInputErrors.serviceUrls}
/>
<InputTextArea
2025-05-23 21:50:03 +00:00
label="ToS URLs"
description="One per line"
2025-05-23 11:52:16 +00:00
name="tosUrls"
inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
}}
value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls}
/>
<InputTextArea
2025-05-23 21:50:03 +00:00
label="Onion URLs"
description="One per line"
2025-05-23 11:52:16 +00:00
name="onionUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.onion\nhttp://example2.onion',
}}
value={service.onionUrls.join('\n')}
error={serviceInputErrors.onionUrls}
/>
<InputTextArea
2025-05-23 21:50:03 +00:00
label="I2P URLs"
description="One per line"
2025-05-23 11:52:16 +00:00
name="i2pUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
}}
value={service.i2pUrls.join('\n')}
2025-05-19 10:23:36 +00:00
/>
</div>
2025-05-23 21:50:03 +00:00
<InputText
label="Referral Code/Link"
name="referral"
inputProps={{
value: service.referral ?? undefined,
placeholder: 'e.g., REFCODE123 or https://example.com?ref=123',
}}
error={serviceInputErrors.referral}
/>
<InputImageFile
label="Image"
name="imageFile"
description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
error={serviceInputErrors.imageFile}
square
value={service.imageUrl}
/>
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
<div class="grid grid-cols-1 items-stretch gap-x-4 gap-y-6 sm:grid-cols-[1fr_2fr]">
2025-05-23 11:52:16 +00:00
<InputCheckboxGroup
name="categories"
label="Categories"
2025-05-23 21:50:03 +00:00
size="lg"
2025-05-23 11:52:16 +00:00
required
options={categories.map((category) => ({
label: category.name,
value: category.id.toString(),
icon: category.icon,
}))}
selectedValues={service.categories.map((c) => c.id.toString())}
error={serviceInputErrors.categories}
2025-05-19 10:23:36 +00:00
/>
2025-05-23 11:52:16 +00:00
<InputCheckboxGroup
name="attributes"
label="Attributes"
2025-05-23 21:50:03 +00:00
size="lg"
options={orderBy(
attributes.map((attribute) => ({
...attribute,
categoryInfo: getAttributeCategoryInfo(attribute.category),
typeInfo: getAttributeTypeInfo(attribute.type),
})),
['categoryInfo.order', 'typeInfo.order']
).map((attribute) => {
return {
label: attribute.title,
value: attribute.id.toString(),
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}
})}
2025-05-23 11:52:16 +00:00
selectedValues={service.attributes.map((a) => a.attribute.id.toString())}
error={serviceInputErrors.attributes}
/>
2025-05-19 10:23:36 +00:00
</div>
2025-05-23 21:50:03 +00:00
<InputCardGroup
name="kycLevel"
label="KYC Level"
options={kycLevels.map((kycLevel) => ({
label: `${kycLevel.name} (${kycLevel.value}/4)`,
value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: kycLevel.description,
}))}
selectedValue={service.kycLevel.toString()}
iconSize="md"
cardSize="md"
error={serviceInputErrors.kycLevel}
class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
/>
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
<InputCardGroup
name="verificationStatus"
label="Verification Status"
options={verificationStatuses.map((status) => ({
label: status.label,
value: status.value,
icon: status.icon,
iconClass: status.classNames.icon,
description: status.description,
}))}
selectedValue={service.verificationStatus}
error={serviceInputErrors.verificationStatus}
cardSize="sm"
iconSize="sm"
class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
/>
<InputCardGroup
name="acceptedCurrencies"
label="Accepted Currencies"
options={currencies.map((currency) => ({
label: currency.name,
value: currency.id,
icon: currency.icon,
}))}
selectedValue={service.acceptedCurrencies}
error={serviceInputErrors.acceptedCurrencies}
required
multiple
/>
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-[2fr_3fr]">
2025-05-23 11:52:16 +00:00
<InputTextArea
2025-05-23 21:50:03 +00:00
label="Verification Summary"
description="Markdown supported"
2025-05-19 10:23:36 +00:00
name="verificationSummary"
2025-05-23 11:52:16 +00:00
inputProps={{
rows: 4,
}}
value={service.verificationSummary ?? undefined}
error={serviceInputErrors.verificationSummary}
2025-05-21 07:03:39 +00:00
/>
2025-05-19 10:23:36 +00:00
2025-05-23 11:52:16 +00:00
<InputTextArea
2025-05-23 21:50:03 +00:00
label="Verification Proof"
description="Markdown supported"
2025-05-19 10:23:36 +00:00
name="verificationProofMd"
2025-05-23 11:52:16 +00:00
inputProps={{
rows: 8,
}}
value={service.verificationProofMd ?? undefined}
error={serviceInputErrors.verificationProofMd}
2025-05-21 07:03:39 +00:00
/>
2025-05-19 10:23:36 +00:00
</div>
2025-05-23 21:50:03 +00:00
<InputCardGroup
name="serviceVisibility"
label="Service Visibility"
options={serviceVisibilities.map((visibility) => ({
label: visibility.label,
value: visibility.value,
icon: visibility.icon,
iconClass: visibility.iconClass,
description: visibility.description,
}))}
selectedValue={service.serviceVisibility}
error={serviceInputErrors.serviceVisibility}
cardSize="sm"
/>
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
2025-05-19 10:23:36 +00:00
</form>
2025-05-23 21:50:03 +00:00
</FormSection>
<FormSection title="Events">
<FormSubSection title="Existing Events">
2025-05-19 10:23:36 +00:00
{
2025-05-23 21:50:03 +00:00
service.events.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No events yet.
</p>
) : (
<div class="space-y-2">
{service.events.map((event) => {
const eventTypeInfo = getEventTypeInfo(event.type)
return (
<div class="border-night-600 bg-night-800 rounded-md border px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="flex-grow space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-md text-day-100 font-semibold">{event.title}</span>
<BadgeSmall
text={eventTypeInfo.label}
variant="faded"
color={eventTypeInfo.color}
icon={eventTypeInfo.icon}
/>
{!event.visible && (
<BadgeSmall text="Hidden" variant="faded" color="gray" icon="ri:eye-off-line" />
2025-05-19 10:23:36 +00:00
)}
2025-05-23 21:50:03 +00:00
</div>
<p class="text-day-400 text-sm text-pretty">{event.content}</p>
<div class="text-day-500 flex flex-wrap items-center gap-3 text-xs">
<span>Started: {new Date(event.startedAt).toLocaleDateString()}</span>
<span>
Ended:
{event.endedAt
? event.endedAt === event.startedAt
? '1-time event'
: new Date(event.endedAt).toLocaleDateString()
: 'Ongoing'}
2025-05-19 10:23:36 +00:00
</span>
2025-05-23 21:50:03 +00:00
{event.source && <span>Source: {event.source}</span>}
</div>
2025-05-19 10:23:36 +00:00
</div>
2025-05-23 21:50:03 +00:00
<div class="flex shrink-0 gap-1.5">
<form method="POST" action={actions.admin.event.toggle} class="contents">
<input type="hidden" name="eventId" value={event.id} />
<Tooltip text={event.visible ? 'Hide Event' : 'Show Event'}>
<Button
type="submit"
variant="faded"
size="sm"
icon={event.visible ? 'ri:eye-off-line' : 'ri:eye-line'}
/>
</Tooltip>
</form>
<Button
type="button"
variant="faded"
size="sm"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`}
/>
<form method="POST" action={actions.admin.event.delete} class="contents">
<input type="hidden" name="eventId" value={event.id} />
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
</form>
2025-05-19 10:23:36 +00:00
</div>
</div>
2025-05-23 21:50:03 +00:00
{/* Edit Event Form - Hidden by default */}
<form
id={`edit-event-${event.id}`}
method="POST"
action={actions.admin.event.update}
class="border-night-500 bg-night-700 mt-3 hidden space-y-3 rounded-md border p-3"
>
<input type="hidden" name="eventId" value={event.id} />
<InputText
label="Title"
2025-05-19 10:23:36 +00:00
name="title"
2025-05-23 21:50:03 +00:00
inputProps={{ required: true, value: event.title }}
error={eventUpdateInputErrors.title}
2025-05-19 10:23:36 +00:00
/>
2025-05-23 21:50:03 +00:00
<InputTextArea
label="Content"
2025-05-19 10:23:36 +00:00
name="content"
2025-05-23 21:50:03 +00:00
inputProps={{ required: true, rows: 3 }}
value={event.content}
error={eventUpdateInputErrors.content}
2025-05-21 07:03:39 +00:00
/>
2025-05-23 21:50:03 +00:00
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<InputText
label="Started At"
2025-05-19 10:23:36 +00:00
name="startedAt"
2025-05-23 21:50:03 +00:00
inputProps={{
type: 'date',
required: true,
value: new Date(event.startedAt).toISOString().split('T')[0],
}}
error={eventUpdateInputErrors.startedAt}
2025-05-19 10:23:36 +00:00
/>
2025-05-23 21:50:03 +00:00
<InputText
label="Ended At"
2025-05-19 10:23:36 +00:00
name="endedAt"
2025-05-23 21:50:03 +00:00
inputProps={{
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '',
}}
error={eventUpdateInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
2025-05-19 10:23:36 +00:00
/>
</div>
2025-05-23 21:50:03 +00:00
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<InputText
label="Source URL"
2025-05-19 10:23:36 +00:00
name="source"
2025-05-23 21:50:03 +00:00
inputProps={{ value: event.source }}
error={eventUpdateInputErrors.source}
/>
<InputSelect
label="Type"
name="type"
options={eventTypes.map((type) => ({
label: type.label,
value: type.id,
}))}
selectProps={{ required: true, value: event.type }}
error={eventUpdateInputErrors.type}
2025-05-19 10:23:36 +00:00
/>
</div>
2025-05-23 21:50:03 +00:00
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form>
</div>
)
})}
2025-05-19 10:23:36 +00:00
</div>
)
}
2025-05-23 21:50:03 +00:00
</FormSubSection>
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
<FormSubSection title="Add New Event">
<form method="POST" action={actions.admin.event.create} class="space-y-2">
<input type="hidden" name="serviceId" value={service.id} />
<InputText
label="Title"
name="title"
inputProps={{ required: true }}
error={eventInputErrors.title}
/>
<InputTextArea
label="Content"
name="content"
inputProps={{ required: true, rows: 3 }}
error={eventInputErrors.content}
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputText
label="Started At"
name="startedAt"
inputProps={{
type: 'date',
required: true,
value: new Date().toISOString().split('T')[0],
}}
error={eventInputErrors.startedAt}
/>
<InputText
label="Ended At"
name="endedAt"
inputProps={{
value: new Date().toISOString().split('T')[0],
}}
error={eventInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputText label="Source URL" name="source" error={eventInputErrors.source} />
<InputSelect
label="Type"
name="type"
options={eventTypes.map((type) => ({
label: type.label,
value: type.id,
}))}
selectProps={{ required: true }}
error={eventInputErrors.type}
/>
</div>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
<FormSection title="Verification Steps">
<FormSubSection title="Existing Verification Steps">
2025-05-19 10:23:36 +00:00
{
2025-05-23 21:50:03 +00:00
service.verificationSteps.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No verification steps yet.
</p>
) : (
<div class="space-y-2">
{service.verificationSteps.map((step) => {
const verificationStepStatusInfo = getVerificationStepStatusInfo(step.status)
return (
<div class="border-night-600 bg-night-800 rounded-md border p-3">
<div class="flex items-start justify-between gap-3">
<div class="flex-grow space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-md text-day-100 font-semibold">{step.title}</span>
<BadgeSmall
text={verificationStepStatusInfo.label}
variant="faded"
color={verificationStepStatusInfo.color}
icon={verificationStepStatusInfo.icon}
/>
</div>
<p class="text-day-400 text-sm text-pretty">{step.description}</p>
{step.evidenceMd && (
<p class="text-day-500 mt-1 text-xs italic">Evidence provided (see edit form)</p>
)}
<p class="text-day-500 text-xs">
Created: {new Date(step.createdAt).toLocaleDateString()}
</p>
</div>
<div class="flex shrink-0 gap-1.5">
<Button
type="button"
size="sm"
variant="faded"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`}
/>
<form method="POST" action={actions.admin.verificationStep.delete} class="inline">
<input type="hidden" name="id" value={step.id} />
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
</form>
2025-05-19 10:23:36 +00:00
</div>
</div>
2025-05-23 21:50:03 +00:00
{/* Edit Verification Step Form - Hidden by default */}
<form
id={`edit-verification-step-${step.id}`}
method="POST"
action={actions.admin.verificationStep.update}
class="border-night-500 bg-night-700 mt-3 hidden space-y-3 rounded-md border p-3"
>
<input type="hidden" name="id" value={step.id} />
<InputText
label="Title"
2025-05-19 10:23:36 +00:00
name="title"
2025-05-23 21:50:03 +00:00
inputProps={{ value: step.title }}
error={verificationStepUpdateInputErrors.title}
2025-05-19 10:23:36 +00:00
/>
2025-05-23 21:50:03 +00:00
<InputTextArea
label="Description (Max 200 chars)"
2025-05-19 10:23:36 +00:00
name="description"
2025-05-23 21:50:03 +00:00
inputProps={{ rows: 2 }}
value={step.description}
error={verificationStepUpdateInputErrors.description}
2025-05-21 07:03:39 +00:00
/>
2025-05-23 21:50:03 +00:00
<InputTextArea
label="Evidence"
description="Markdown supported"
2025-05-19 10:23:36 +00:00
name="evidenceMd"
2025-05-23 21:50:03 +00:00
inputProps={{ rows: 4 }}
value={step.evidenceMd}
error={verificationStepUpdateInputErrors.evidenceMd}
2025-05-21 07:03:39 +00:00
/>
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
<InputSelect
label="Status"
name="status"
2025-05-25 10:07:02 +00:00
options={verificationStepStatuses.map((status) => ({
2025-05-23 21:50:03 +00:00
label: status.label,
value: status.value,
}))}
selectProps={{ value: step.status }}
error={verificationStepUpdateInputErrors.status}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form>
</div>
2025-05-19 10:23:36 +00:00
)
2025-05-23 21:50:03 +00:00
})}
2025-05-19 10:23:36 +00:00
</div>
)
}
2025-05-23 21:50:03 +00:00
</FormSubSection>
<FormSubSection title="Add New Verification Step">
<form method="POST" action={actions.admin.verificationStep.create} class="space-y-2">
<input type="hidden" name="serviceId" value={service.id} />
<InputText
label="Title"
name="title"
inputProps={{ required: true }}
error={verificationStepInputErrors.title}
/>
<InputTextArea
label="Description"
description="Max 200 chars"
name="description"
inputProps={{ required: true, rows: 3 }}
error={verificationStepInputErrors.description}
/>
<InputTextArea
label="Evidence"
description="Markdown supported"
name="evidenceMd"
inputProps={{ rows: 5 }}
error={verificationStepInputErrors.evidenceMd}
/>
<InputSelect
label="Status"
name="status"
2025-05-25 10:07:02 +00:00
options={verificationStepStatuses.map((status) => ({
2025-05-23 21:50:03 +00:00
label: status.label,
value: status.value,
}))}
selectProps={{ required: true }}
error={verificationStepInputErrors.status}
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
<FormSection
title="Verification Requests"
subtitle={`Total: ${service._count.verificationRequests} ${pluralize('request', service._count.verificationRequests)}`}
>
{
service.verificationRequests.length > 0 ? (
<div class="bg-night-800 border-night-500 overflow-x-auto rounded-md border">
<table class="w-full text-sm">
<thead class="bg-night-600">
<tr class="text-left">
<th class="text-day-300 p-3 font-semibold">User</th>
<th class="text-day-300 p-3 font-semibold">Requested</th>
</tr>
</thead>
<tbody class="divide-night-500 divide-y">
{service.verificationRequests.map((request) => (
<tr class="hover:bg-night-700 transition-colors">
<td class="text-day-300 p-3">
<UserBadge user={request.user} size="md" />
</td>
<td class="text-day-400 p-3">
<TimeFormatted date={request.createdAt} hourPrecision />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No verification requests yet.
</p>
)
}
</FormSection>
<FormSection title="Contact Methods">
<FormSubSection title="Existing Contact Methods">
2025-05-19 10:23:36 +00:00
{
2025-05-23 21:50:03 +00:00
service.contactMethods.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No contact methods yet.
</p>
) : (
2025-05-19 10:23:36 +00:00
<div class="space-y-3">
2025-05-23 11:52:16 +00:00
{service.contactMethods.map((method) => {
const contactMethodInfo = formatContactMethod(method.value)
return (
2025-05-23 21:50:03 +00:00
<div class="border-night-600 bg-night-800 rounded-md border p-3">
2025-05-23 11:52:16 +00:00
<div class="flex items-start justify-between gap-3">
<div class="flex-grow space-y-1">
<div class="flex flex-wrap items-center gap-2">
2025-05-23 21:50:03 +00:00
<Icon name={contactMethodInfo.icon} class="text-day-300 size-4" />
<span class="text-md text-day-100 font-semibold">
{method.label ?? contactMethodInfo.formattedValue}
</span>
2025-05-23 11:52:16 +00:00
</div>
2025-05-23 21:50:03 +00:00
<p class="text-day-400 text-sm text-pretty">{method.value}</p>
2025-05-19 10:23:36 +00:00
</div>
2025-05-23 11:52:16 +00:00
<div class="flex shrink-0 gap-1.5">
2025-05-23 21:50:03 +00:00
<Button
2025-05-23 11:52:16 +00:00
type="button"
2025-05-23 21:50:03 +00:00
variant="faded"
size="sm"
icon="ri:pencil-line"
2025-05-23 11:52:16 +00:00
onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`}
2025-05-23 21:50:03 +00:00
/>
<form
method="POST"
action={actions.admin.service.deleteContactMethod}
class="contents"
2025-05-19 10:23:36 +00:00
>
2025-05-23 11:52:16 +00:00
<input type="hidden" name="id" value={method.id} />
2025-05-23 21:50:03 +00:00
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
2025-05-23 11:52:16 +00:00
</form>
</div>
2025-05-19 10:23:36 +00:00
</div>
2025-05-23 11:52:16 +00:00
{/* Edit Contact Method Form - Hidden by default */}
<form
id={`edit-contact-method-${method.id}`}
method="POST"
action={actions.admin.service.updateContactMethod}
2025-05-23 21:50:03 +00:00
class="border-night-500 bg-night-700 mt-3 hidden space-y-3 rounded-md border p-3"
2025-05-23 11:52:16 +00:00
>
<input type="hidden" name="id" value={method.id} />
<input type="hidden" name="serviceId" value={service.id} />
2025-05-23 21:50:03 +00:00
<InputText
label="Override Label (Optional)"
name="label"
inputProps={{
value: method.label,
placeholder: contactMethodInfo.formattedValue,
}}
/>
<InputText
label="Value (with protocol)"
name="value"
inputProps={{
value: method.value,
placeholder: 'e.g., mailto:contact@example.com or https://t.me/example',
}}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
2025-05-23 11:52:16 +00:00
</form>
</div>
)
})}
2025-05-19 10:23:36 +00:00
</div>
)
}
2025-05-23 21:50:03 +00:00
</FormSubSection>
2025-05-19 10:23:36 +00:00
2025-05-23 21:50:03 +00:00
<FormSubSection title="Add New Contact Method">
<form method="POST" action={actions.admin.service.createContactMethod} class="space-y-2">
<input type="hidden" name="serviceId" value={service.id} />
2025-05-23 11:52:16 +00:00
2025-05-23 21:50:03 +00:00
<InputText label="Override Label" name="label" />
<InputText
label="Value"
description="With protocol (e.g., `mailto:contact@example.com` or `https://t.me/example`)"
name="value"
inputProps={{
required: true,
placeholder: 'mailto:contact@example.com',
}}
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
</div>
</BaseLayout>