Files
kycnotme/web/src/pages/service-suggestion/new.astro
2025-06-24 14:30:07 +00:00

393 lines
12 KiB
Plaintext

---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { orderBy } from 'lodash-es'
import {
SUGGESTION_DESCRIPTION_MAX_LENGTH,
SUGGESTION_NAME_MAX_LENGTH,
SUGGESTION_NOTES_MAX_LENGTH,
SUGGESTION_SLUG_MAX_LENGTH,
} from '../../actions/serviceSuggestion'
import Captcha from '../../components/Captcha.astro'
import InputCardGroup from '../../components/InputCardGroup.astro'
import InputCheckbox from '../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputImageFile from '../../components/InputImageFile.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { contactMethodUrlTypes } from '../../constants/contactMethods'
import { currencies } from '../../constants/currencies'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
import { kycLevels } from '../../constants/kycLevels'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
const user = Astro.locals.user
if (!user) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
}
const result = Astro.getActionResult(actions.serviceSuggestion.createService)
if (result && !result.error && !result.data.hasDuplicates) {
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
}
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
const [categories, attributes] = await Astro.locals.banners.tryMany([
[
'Failed to fetch categories',
() =>
prisma.category.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
icon: true,
},
}),
[],
],
[
'Failed to fetch attributes',
() =>
prisma.attribute.findMany({
orderBy: { category: 'asc' },
select: {
id: true,
title: true,
category: true,
type: true,
},
}),
[],
],
])
---
<BaseLayout
pageTitle="New service"
description="Suggest a new service to be added to KYCnot.me"
ogImage={{
template: 'generic',
title: 'New service',
description: 'Suggest a new service to be listed',
icon: 'ri:add-circle-line',
}}
widthClassName="max-w-screen-md"
breadcrumbs={[
{
name: 'Service suggestions',
url: '/service-suggestion',
},
{
name: 'New service',
},
]}
>
<h1 class="font-title mt-12 text-center text-3xl font-bold">Service suggestion</h1>
<p class="text-day-400 mb-6 text-center text-sm">
Suggestions are reviewed by moderators before being public.
</p>
<form
method="POST"
action={actions.serviceSuggestion.createService}
enctype="multipart/form-data"
class="space-y-6"
>
{
result?.data?.hasDuplicates && (
<>
<div class="sm:border-night-300 sm:bg-night-500 mb-16 sm:rounded-lg sm:border sm:p-2 md:p-4">
<input type="hidden" name="skipDuplicateCheck" value="true" />
<h2 class="font-title flex items-center justify-center gap-2 text-2xl font-semibold text-red-500">
Possible duplicates found
</h2>
<p class="text-day-400 text-center">Is your service already listed below?</p>
<div class="mt-4 space-y-2">
{result.data.possibleDuplicates.map((duplicate) => {
const editServiceUrl = new URL('/service-suggestion/edit', Astro.url)
editServiceUrl.searchParams.set('serviceId', duplicate.id.toString())
if (result.data.extraNotes) {
editServiceUrl.searchParams.set('notes', result.data.extraNotes)
}
return (
<div class="border-night-400 bg-night-600 flex gap-4 rounded-lg border p-4 shadow-sm">
<div class="grow">
<p class="text-day-100 mb-1 text-lg font-medium">{duplicate.name}</p>
<p class="text-day-300 mb-3 line-clamp-2 text-sm">{duplicate.description}</p>
</div>
<div class="flex shrink-0 flex-col justify-center gap-2">
<a
href={`/service/${duplicate.slug}`}
target="_blank"
class="text-day-300 bg-night-300 hover:bg-night-400 flex items-center gap-1 rounded px-2.5 py-1.5 text-sm font-medium transition-colors"
>
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
View
</a>
<a
href={editServiceUrl.toString()}
target="_blank"
class="flex items-center gap-1 rounded bg-green-600 px-2.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
>
<Icon name="ri:edit-line" class="size-4 shrink-0" />
Submit as edit
</a>
</div>
</div>
)
})}
</div>
<div class="bg-night-400 mt-4 hidden h-px w-full sm:block" />
<div class="mt-4 flex items-center gap-2 px-4">
<p class="text-day-200 flex-1">None of these match?</p>
<button
type="submit"
class="bg-day-700 text-day-100 hover:bg-day-600 flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium"
>
<Icon name="ri:send-plane-2-line" class="size-4" />
Submit anyway
</button>
</div>
</div>
<h1 class="font-title mt-12 mb-6 block text-center text-3xl font-bold sm:hidden">
Review your suggestion
</h1>
</>
)
}
<InputText
label="Name"
name="name"
inputProps={{
required: true,
maxlength: SUGGESTION_NAME_MAX_LENGTH,
'data-generate-slug': true,
}}
error={inputErrors.name}
/>
<InputText
label="Slug"
name="slug"
description="Auto-generated from name. Only lowercase letters, numbers, and hyphens."
error={inputErrors.slug}
inputProps={{
required: true,
pattern: '^[a-z0-9\\-]+$',
placeholder: 'my-service-name',
minlength: 1,
maxlength: SUGGESTION_SLUG_MAX_LENGTH,
'data-generate-slug': true,
}}
/>
<InputTextArea
label="Description"
name="description"
id="description"
inputProps={{
required: true,
maxlength: SUGGESTION_DESCRIPTION_MAX_LENGTH,
}}
error={inputErrors.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: 'example1.com\nexample2.onion\nexample3.b32.i2p',
class: 'md:min-h-20 min-h-24 h-full',
required: true,
}}
class="flex flex-col self-stretch"
error={inputErrors.allServiceUrls}
/>
<InputTextArea
label="Contact Methods"
description={[
'One per line.',
`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`,
].join('\n')}
name="contactMethods"
inputProps={{
placeholder: 'contact@example.com\nt.me/example\n+123 456 7890',
class: 'h-full',
}}
class="flex flex-col self-stretch"
error={inputErrors.contactMethods}
/>
</div>
<InputTextArea
label="ToS URLs"
description="One per line. AI review uses the first working URL only."
name="tosUrls"
inputProps={{
placeholder: 'example.com/tos',
required: true,
class: 'min-h-10',
}}
error={inputErrors.tosUrls}
/>
<InputText
label="Operating since"
name="operatingSince"
description="Date the service started operating"
inputProps={{
type: 'date',
}}
error={(inputErrors as Record<string, unknown>).operatingSince}
/>
<InputCardGroup
name="kycLevel"
label="KYC Level"
options={kycLevels.map((kycLevel) => ({
label: kycLevel.name,
value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/4_`,
}))}
iconSize="md"
cardSize="md"
required
error={inputErrors.kycLevel}
/>
<InputCardGroup
name="kycLevelClarification"
label="KYC Level Clarification"
options={kycLevelClarifications.map((clarification) => ({
label: clarification.label,
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
}))}
selectedValue="NONE"
iconSize="sm"
cardSize="sm"
error={inputErrors.kycLevelClarification}
/>
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
<InputCheckboxGroup
name="categories"
label="Categories"
required
options={categories.map((category) => ({
label: category.name,
value: category.id.toString(),
icon: category.icon,
}))}
size="lg"
error={inputErrors.categories}
class="min-w-auto"
/>
<InputCheckboxGroup
name="attributes"
label="Attributes"
options={orderBy(
attributes.map((attribute) => ({
...attribute,
categoryInfo: getAttributeCategoryInfo(attribute.category),
typeInfo: getAttributeTypeInfo(attribute.type),
})),
['categoryInfo.order', 'typeInfo.order']
).map((attribute) => ({
label: attribute.title,
value: attribute.id.toString(),
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}))}
error={inputErrors.attributes}
size="lg"
/>
</div>
<InputCardGroup
name="acceptedCurrencies"
label="Accepted Currencies"
options={currencies.map((currency) => ({
label: currency.name,
value: currency.id,
icon: currency.icon,
}))}
error={inputErrors.acceptedCurrencies}
required
multiple
/>
<InputImageFile
label="Service Image"
name="imageFile"
description="Square image. At least 192x192px. Transparency supported."
error={inputErrors.imageFile}
square
required
/>
<InputTextArea
label="Note for Moderators"
name="notes"
id="notes"
error={inputErrors.notes}
inputProps={{
maxlength: SUGGESTION_NOTES_MAX_LENGTH,
}}
/>
<Captcha action={actions.serviceSuggestion.createService} />
<InputCheckbox name="rulesConfirm" required error={inputErrors.rulesConfirm}>
I understand the
<a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
</InputCheckbox>
<InputHoneypotTrap name="message" />
<InputSubmitButton label={result?.data?.hasDuplicates ? 'Submit anyway' : 'Submit'} />
</form>
</BaseLayout>
<script>
document.addEventListener('astro:page-load', () => {
const triggerInputs = document.querySelectorAll<HTMLInputElement>('input[data-generate-slug]')
const slugInputs = document.querySelectorAll<HTMLInputElement>('input[name="slug"]')
triggerInputs.forEach((triggerInput) => {
triggerInput.addEventListener('input', (event) => {
const target = event.target as HTMLInputElement
slugInputs.forEach((slugInput) => {
slugInput.value = target.value
.toLowerCase()
.replace(/[^a-z0-9\-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
})
})
})
})
</script>