Files
kycnotme/web/src/pages/admin/services/[slug]/edit.astro
2025-06-14 18:56:58 +00:00

1300 lines
49 KiB
Plaintext

---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions'
import { Code } from 'astro:components'
import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../../components/BadgeSmall.astro'
import Button from '../../../../components/Button.astro'
import FormSection from '../../../../components/FormSection.astro'
import FormSubSection from '../../../../components/FormSubSection.astro'
import InputCardGroup from '../../../../components/InputCardGroup.astro'
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
import InputImageFile from '../../../../components/InputImageFile.astro'
import InputSelect from '../../../../components/InputSelect.astro'
import InputSubmitButton from '../../../../components/InputSubmitButton.astro'
import InputText from '../../../../components/InputText.astro'
import InputTextArea from '../../../../components/InputTextArea.astro'
import MyPicture from '../../../../components/MyPicture.astro'
import ServiceCard from '../../../../components/ServiceCard.astro'
import TimeFormatted from '../../../../components/TimeFormatted.astro'
import Tooltip from '../../../../components/Tooltip.astro'
import UserBadge from '../../../../components/UserBadge.astro'
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
import { currencies } from '../../../../constants/currencies'
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
import { kycLevels } from '../../../../constants/kycLevels'
import { serviceVisibilities } from '../../../../constants/serviceVisibility'
import { verificationStatuses } from '../../../../constants/verificationStatus'
import {
getVerificationStepStatusInfo,
verificationStepStatuses,
} from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/client/envVariables'
import { listFiles } from '../../../../lib/fileStorage'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma'
const { slug } = Astro.params
if (!slug) return Astro.rewrite('/404')
const serviceResult = Astro.getActionResult(actions.admin.service.update)
Astro.locals.banners.addIfSuccess(serviceResult, 'Service updated successfully')
const serviceInputErrors = isInputError(serviceResult?.error) ? serviceResult.error.fields : {}
if (serviceResult && !serviceResult.error && slug !== serviceResult.data.service.slug) {
return Astro.redirect(`/admin/services/${serviceResult.data.service.slug}/edit`)
}
const eventCreateResult = Astro.getActionResult(actions.admin.event.create)
Astro.locals.banners.addIfSuccess(eventCreateResult, 'Event created successfully')
const eventInputErrors = isInputError(eventCreateResult?.error) ? eventCreateResult.error.fields : {}
const eventUpdateResult = Astro.getActionResult(actions.admin.event.update)
Astro.locals.banners.addIfSuccess(eventUpdateResult, 'Event updated successfully')
const eventUpdateInputErrors = isInputError(eventUpdateResult?.error) ? eventUpdateResult.error.fields : {}
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')
const verificationStepInputErrors = isInputError(verificationStepCreateResult?.error)
? verificationStepCreateResult.error.fields
: {}
const verificationStepUpdateResult = Astro.getActionResult(actions.admin.verificationStep.update)
Astro.locals.banners.addIfSuccess(verificationStepUpdateResult, 'Verification step updated successfully')
const verificationStepUpdateInputErrors = isInputError(verificationStepUpdateResult?.error)
? verificationStepUpdateResult.error.fields
: {}
const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete)
Astro.locals.banners.addIfSuccess(verificationStepDeleteResult, 'Verification step deleted successfully')
const internalNoteCreateResult = Astro.getActionResult(actions.admin.service.internalNote.add)
Astro.locals.banners.addIfSuccess(internalNoteCreateResult, 'Internal note added successfully')
const internalNoteInputErrors = isInputError(internalNoteCreateResult?.error)
? internalNoteCreateResult.error.fields
: {}
const contactMethodUpdateResult = Astro.getActionResult(actions.admin.service.contactMethod.update)
Astro.locals.banners.addIfSuccess(contactMethodUpdateResult, 'Contact method updated successfully')
const contactMethodUpdateInputErrors = isInputError(contactMethodUpdateResult?.error)
? contactMethodUpdateResult.error.fields
: {}
const contactMethodAddResult = Astro.getActionResult(actions.admin.service.contactMethod.add)
Astro.locals.banners.addIfSuccess(contactMethodAddResult, 'Contact method added successfully')
const contactMethodAddInputErrors = isInputError(contactMethodAddResult?.error)
? contactMethodAddResult.error.fields
: {}
const internalNoteDeleteResult = Astro.getActionResult(actions.admin.service.internalNote.delete)
Astro.locals.banners.addIfSuccess(internalNoteDeleteResult, 'Internal note deleted successfully')
const evidenceImageAddResult = Astro.getActionResult(actions.admin.service.evidenceImage.add)
if (evidenceImageAddResult?.data?.imageUrl) {
Astro.locals.banners.add({
uiMessage: 'Evidence image added successfully',
type: 'success',
origin: 'action',
})
}
const evidenceImageAddInputErrors = isInputError(evidenceImageAddResult?.error)
? evidenceImageAddResult.error.fields
: {}
const evidenceImageDeleteResult = Astro.getActionResult(actions.admin.service.evidenceImage.delete)
Astro.locals.banners.addIfSuccess(evidenceImageDeleteResult, 'Evidence image deleted successfully')
const [service, categories, attributes] = await Astro.locals.banners.tryMany([
[
'Error fetching service',
() =>
prisma.service.findUnique({
where: { slug },
include: {
attributes: {
select: {
attribute: {
select: {
id: true,
},
},
},
},
categories: {
select: {
id: true,
name: true,
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',
},
},
internalNotes: {
include: {
addedByUser: {
select: {
name: true,
displayName: true,
picture: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
_count: {
select: {
verificationRequests: true,
},
},
},
}),
],
[
'Error fetching categories',
() =>
prisma.category.findMany({
orderBy: { name: 'asc' },
}),
[] as [],
],
[
'Error fetching attributes',
() =>
prisma.attribute.findMany({
orderBy: { category: 'asc' },
}),
[] as [],
],
])
if (!service) {
try {
const serviceWithOldSlug = await prisma.service.findFirst({
where: { previousSlugs: { has: slug } },
select: { slug: true },
})
if (serviceWithOldSlug) {
return Astro.redirect(`/admin/services/${serviceWithOldSlug.slug}/edit`, 301)
}
} catch (error) {
console.error(error)
}
return Astro.rewrite('/404')
}
const evidenceImageUrls = await Astro.locals.banners.try(
'Error listing evidence files',
() => listFiles(`evidence/${service.slug}`),
[] as string[]
)
const apiCalls = await Astro.locals.banners.try(
'Error fetching api calls',
() =>
Promise.all([
makeAdminApiCallInfo({
method: 'QUERY',
path: '/service/get',
input: { slug: service.slug },
baseUrl: Astro.url,
}),
]),
[]
)
---
<BaseLayout pageTitle={`Edit Service: ${service.name}`}>
<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 />
</div>
<FormSection title="Service Details">
<form
method="POST"
action={actions.admin.service.update}
class="space-y-6"
enctype="multipart/form-data"
>
<input type="hidden" name="id" value={service.id} />
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<InputText
label="Slug"
description={`Auto-generated if empty. ${
service.previousSlugs.length > 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : ''
}`}
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
</div>
<InputTextArea
label="Description"
name="description"
inputProps={{
required: true,
rows: 4,
}}
value={service.description}
error={serviceInputErrors.description}
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="Service URLs"
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'grow min-h-24',
required: true,
}}
class="row-span-2 flex flex-col self-stretch"
value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/>
<InputTextArea
label="ToS URLs"
description="One per line. AI review uses the first working URL only."
name="tosUrls"
inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true,
}}
value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls}
/>
<InputText
label="Referral link path"
name="referral"
inputProps={{
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
error={serviceInputErrors.referral}
class="self-end"
description="Will be appended to the service URL"
/>
</div>
<div class="flex items-center justify-between gap-2">
<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}
downloadButton
removeCheckbox={service.imageUrl
? {
name: 'removeImage',
label: 'Remove image',
}
: undefined}
class="grow"
/>
</div>
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
<InputCheckboxGroup
name="categories"
label="Categories"
size="lg"
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}
class="min-w-auto"
/>
<InputCheckboxGroup
name="attributes"
label="Attributes"
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],
}
})}
selectedValues={service.attributes.map((a) => a.attribute.id.toString())}
error={serviceInputErrors.attributes}
/>
</div>
<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]"
/>
<InputCardGroup
name="kycLevelClarification"
label="KYC Level Clarification"
options={kycLevelClarifications.map((clarification) => ({
label: clarification.label,
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
}))}
selectedValue={service.kycLevelClarification}
iconSize="sm"
cardSize="sm"
error={serviceInputErrors.kycLevelClarification}
/>
<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
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-[2fr_3fr]">
<InputTextArea
label="Verification Summary"
description="Markdown supported"
name="verificationSummary"
inputProps={{
rows: 4,
}}
value={service.verificationSummary ?? undefined}
error={serviceInputErrors.verificationSummary}
/>
<InputTextArea
label="Verification Proof"
description="Markdown supported"
name="verificationProofMd"
inputProps={{
rows: 8,
}}
value={service.verificationProofMd ?? undefined}
error={serviceInputErrors.verificationProofMd}
/>
</div>
<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"
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form>
</FormSection>
<FormSection title="Internal Notes" id="internal-notes">
<FormSubSection title="Existing Notes">
{
service.internalNotes.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No internal notes yet.
</p>
) : (
<div class="space-y-4">
{service.internalNotes.map((note) => (
<div class="border-night-600 bg-night-800 rounded-md border p-4">
<input
type="checkbox"
class="peer/edit-note sr-only"
data-edit-note-checkbox
id={`edit-note-${note.id}`}
/>
<div class="flex items-start justify-between">
<div class="flex-grow space-y-1">
<div
data-note-content
class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty"
>
<Markdown content={note.content} />
</div>
<div class="text-day-500 flex items-center gap-2 text-xs">
<TimeFormatted date={note.createdAt} hourPrecision />
{note.addedByUser && (
<span class="flex items-center gap-1">
by <UserBadge user={note.addedByUser} size="sm" />
</span>
)}
</div>
</div>
<div class="flex items-center gap-1">
<Button
as="label"
for={`edit-note-${note.id}`}
variant="faded"
size="sm"
icon="ri:edit-line"
iconOnly
label="Edit"
/>
<form method="POST" action={actions.admin.service.internalNote.delete} class="contents">
<input type="hidden" name="noteId" value={note.id} />
<Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form>
</div>
</div>
<form
method="POST"
action={actions.admin.service.internalNote.update}
data-note-edit-form
data-astro-reload
class="mt-4 hidden space-y-4 peer-checked/edit-note:block"
>
<input type="hidden" name="noteId" value={note.id} />
<InputTextArea
label="Note Content"
name="content"
value={note.content}
inputProps={{ class: 'bg-night-700' }}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</div>
))}
</div>
)
}
</FormSubSection>
<FormSubSection title="Add New Note">
<form method="POST" action={actions.admin.service.internalNote.add} class="space-y-4">
<input type="hidden" name="serviceId" value={service.id} />
<InputTextArea
label="Note Content"
name="content"
inputProps={{
required: true,
rows: 4,
placeholder: 'Add internal note about this service...',
}}
error={internalNoteInputErrors.content}
/>
<InputSubmitButton label="Add Note" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
<FormSection title="Events">
<FormSubSection title="Existing Events">
{
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" />
)}
</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'}
</span>
{event.source && <span>Source: {event.source}</span>}
</div>
</div>
<div class="flex shrink-0 gap-1.5">
<Tooltip text={event.visible ? 'Hide' : 'Show'}>
<form method="POST" action={actions.admin.event.toggle} class="contents">
<input type="hidden" name="eventId" value={event.id} />
<Button
type="submit"
variant="faded"
size="sm"
icon={event.visible ? 'ri:eye-off-line' : 'ri:eye-line'}
iconOnly
label={event.visible ? 'Hide' : 'Show'}
/>
</form>
</Tooltip>
<Tooltip text="Edit">
<Button
type="button"
variant="faded"
size="sm"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/>
</Tooltip>
<Tooltip text="Delete">
<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"
iconOnly
label="Delete"
/>
</form>
</Tooltip>
</div>
</div>
{/* 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"
name="title"
inputProps={{ required: true, value: event.title }}
error={eventUpdateInputErrors.title}
/>
<InputTextArea
label="Content"
name="content"
inputProps={{ required: true, rows: 3 }}
value={event.content}
error={eventUpdateInputErrors.content}
/>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<InputText
label="Started At"
name="startedAt"
inputProps={{
type: 'datetime-local',
required: true,
value: new Date(
new Date(event.startedAt).getTime() -
new Date(event.startedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16),
}}
error={eventUpdateInputErrors.startedAt}
/>
<InputText
label="Ended At"
name="endedAt"
inputProps={{
type: 'datetime-local',
value: event.endedAt
? new Date(
new Date(event.endedAt).getTime() -
new Date(event.endedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16)
: '',
}}
error={eventUpdateInputErrors.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-3 md:grid-cols-2">
<InputText
label="Source URL"
name="source"
inputProps={{ value: event.source }}
error={eventUpdateInputErrors.source}
/>
<InputSelect
label="Type"
name="type"
options={eventTypes.map((type) => ({
label: type.label,
value: type.id,
}))}
selectedValue={event.type}
selectProps={{ required: true }}
error={eventUpdateInputErrors.type}
/>
</div>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form>
</div>
)
})}
</div>
)
}
</FormSubSection>
<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: 'datetime-local',
required: true,
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}}
error={eventInputErrors.startedAt}
/>
<InputText
label="Ended At"
name="endedAt"
inputProps={{
type: 'datetime-local',
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}}
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">
{
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">
<Tooltip text="Edit">
<Button
type="button"
size="sm"
variant="faded"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/>
</Tooltip>
<Tooltip text="Delete">
<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"
iconOnly
label="Delete"
/>
</form>
</Tooltip>
</div>
</div>
{/* 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"
name="title"
inputProps={{ value: step.title }}
error={verificationStepUpdateInputErrors.title}
/>
<InputTextArea
label="Description (Max 200 chars)"
name="description"
inputProps={{ rows: 2 }}
value={step.description}
error={verificationStepUpdateInputErrors.description}
/>
<InputTextArea
label="Evidence"
description="Markdown supported"
name="evidenceMd"
inputProps={{ rows: 4 }}
value={step.evidenceMd}
error={verificationStepUpdateInputErrors.evidenceMd}
/>
<InputSelect
label="Status"
name="status"
options={verificationStepStatuses.map((status) => ({
label: status.label,
value: status.value,
}))}
selectedValue={step.status}
error={verificationStepUpdateInputErrors.status}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form>
</div>
)
})}
</div>
)
}
</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"
options={verificationStepStatuses.map((status) => ({
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">
{
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>
) : (
<div class="space-y-3">
{service.contactMethods.map((method) => {
const contactMethodInfo = formatContactMethod(method.value)
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">
<Icon name={contactMethodInfo.icon} class="text-day-300 size-4" />
<span class="text-md text-day-100 font-semibold">
{method.label ?? contactMethodInfo.formattedValue}
</span>
</div>
<p class="text-day-400 text-sm text-pretty">{method.value}</p>
</div>
<div class="flex shrink-0 gap-1.5">
<Tooltip text="Edit">
<Button
type="button"
variant="faded"
size="sm"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/>
</Tooltip>
<Tooltip text="Delete">
<form
method="POST"
action={actions.admin.service.contactMethod.delete}
class="contents"
>
<input type="hidden" name="id" value={method.id} />
<Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form>
</Tooltip>
</div>
</div>
{/* Edit Contact Method Form - Hidden by default */}
<form
id={`edit-contact-method-${method.id}`}
method="POST"
action={actions.admin.service.contactMethod.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={method.id} />
<input type="hidden" name="serviceId" value={service.id} />
<InputText
label="Value"
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
name="value"
inputProps={{
required: true,
value: method.value,
placeholder: 'contact@example.com',
}}
error={contactMethodUpdateInputErrors.value}
/>
<InputText
label="Label"
name="label"
description="Leave empty to auto-generate"
inputProps={{
value: method.label,
placeholder: contactMethodInfo.formattedValue,
}}
error={contactMethodUpdateInputErrors.label}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form>
</div>
)
})}
</div>
)
}
</FormSubSection>
<FormSubSection title="Add New Contact Method">
<form method="POST" action={actions.admin.service.contactMethod.add} class="space-y-2">
<input type="hidden" name="serviceId" value={service.id} />
<InputText
label="Value"
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
name="value"
inputProps={{
required: true,
placeholder: 'contact@example.com',
}}
error={contactMethodAddInputErrors.value}
/>
<InputText
label="Label"
description="Leave empty to auto-generate"
name="label"
inputProps={{ placeholder: 'Auto-generated' }}
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
<FormSection title="API">
{
DEPLOYMENT_MODE === 'staging' && (
<p class="rounded-lg bg-red-900/30 p-4 text-sm text-red-200">
<Icon name="ri:alert-line" class="inline-block size-4 align-[-0.2em] text-red-400" />
This endpoints section doesn't work in PRE. Use curl commands instead.
</p>
)
}
<div class="mb-6 flex justify-center">
<Button
as="a"
href="/docs/api"
icon="ri:book-open-line"
color="gray"
size="sm"
label=" Documentation"
/>
</div>
{
apiCalls.map((call) => (
<FormSubSection title={`${call.method} ${call.path}`}>
<p class="text-day-400 text-sm">Input:</p>
<Code code={JSON.stringify(call.input, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
<p class="text-day-400 text-sm">Output:</p>
<Code code={JSON.stringify(call.output, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
</FormSubSection>
))
}
</FormSection>
<FormSection title="Evidence Images" id="evidence-images">
<FormSubSection title="Existing Evidence Images">
{
evidenceImageUrls.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No evidence images yet.
</p>
) : (
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{evidenceImageUrls.map((imageUrl: string) => (
<div class="border-night-600 bg-night-800 group relative rounded-md border p-2">
<MyPicture
src={imageUrl}
alt="Evidence image"
class="aspect-square w-full rounded object-cover"
width={200}
height={200}
/>
<form
method="POST"
action={actions.admin.service.evidenceImage.delete}
class="absolute top-1 right-1"
>
<input type="hidden" name="fileUrl" value={imageUrl} />
<Button
type="submit"
variant="faded"
color="danger"
size="sm"
icon="ri:delete-bin-line"
iconOnly
label="Delete Image"
class="opacity-0 transition-opacity group-hover:opacity-100"
/>
</form>
<input
type="text"
readonly
value={`![Evidence](${imageUrl})`}
class="bg-night-700 text-day-200 mt-2 w-full cursor-text rounded border p-2 font-mono text-xs select-all"
/>
</div>
))}
</div>
)
}
</FormSubSection>
<FormSubSection title="Add New Evidence Image">
<form
method="POST"
action={actions.admin.service.evidenceImage.add}
class="space-y-4"
enctype="multipart/form-data"
>
<input type="hidden" name="serviceId" value={service.id} />
<InputImageFile
label="Upload Image"
name="imageFile"
description="Upload an evidence image."
error={evidenceImageAddInputErrors.imageFile}
required
/>
<InputSubmitButton label="Add Image" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
</div>
</BaseLayout>