From cdfdcfc122c43de403ee844879f307311e3bdc88 Mon Sep 17 00:00:00 2001 From: pluja Date: Fri, 23 May 2025 11:52:16 +0000 Subject: [PATCH] Release 2025-05-23-R3WZ --- .../migration.sql | 13 + web/prisma/schema.prisma | 13 +- web/scripts/faker.ts | 25 +- web/src/actions/admin/service.ts | 26 +- web/src/actions/comment.ts | 4 +- web/src/constants/contactMethods.ts | 122 +++ web/src/lib/contactMethods.ts | 60 -- .../admin/service-suggestions/[id].astro | 9 +- .../admin/service-suggestions/index.astro | 18 +- .../pages/admin/services/[slug]/edit.astro | 838 +++++++----------- web/src/pages/admin/users/index.astro | 14 +- web/src/pages/service/[slug].astro | 20 +- 12 files changed, 490 insertions(+), 672 deletions(-) create mode 100644 web/prisma/migrations/20250523111356_remove_icon_id_from_contact_methods/migration.sql create mode 100644 web/src/constants/contactMethods.ts delete mode 100644 web/src/lib/contactMethods.ts diff --git a/web/prisma/migrations/20250523111356_remove_icon_id_from_contact_methods/migration.sql b/web/prisma/migrations/20250523111356_remove_icon_id_from_contact_methods/migration.sql new file mode 100644 index 0000000..0d24298 --- /dev/null +++ b/web/prisma/migrations/20250523111356_remove_icon_id_from_contact_methods/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `iconId` on the `ServiceContactMethod` table. All the data in the column will be lost. + - You are about to drop the column `info` on the `ServiceContactMethod` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "ServiceContactMethod" DROP COLUMN "iconId", +DROP COLUMN "info", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ALTER COLUMN "label" DROP NOT NULL; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 786989c..94dc7c6 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -397,12 +397,15 @@ model Service { } model ServiceContactMethod { - id Int @id @default(autoincrement()) - label String + id Int @id @default(autoincrement()) + /// Only include it if you want to override the formatted value. + label String? /// Including the protocol (e.g. "mailto:", "tel:", "https://") - value String - iconId String - info String + value String + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade) serviceId Int } diff --git a/web/scripts/faker.ts b/web/scripts/faker.ts index cb082a0..026784a 100755 --- a/web/scripts/faker.ts +++ b/web/scripts/faker.ts @@ -845,40 +845,29 @@ const generateFakeComment = (userId: number, serviceId: number, parentId?: numbe const generateFakeServiceContactMethod = (serviceId: number) => { const types = [ { - label: 'Email', value: `mailto:${faker.internet.email()}`, - iconId: 'ri:mail-line', - info: faker.lorem.sentence(), }, { - label: 'Phone', value: `tel:${faker.phone.number({ style: 'international' })}`, - iconId: 'ri:phone-line', - info: faker.lorem.sentence(), }, { - label: 'WhatsApp', value: `https://wa.me/${faker.phone.number({ style: 'international' })}`, - iconId: 'ri:whatsapp-line', - info: faker.lorem.sentence(), }, { - label: 'Telegram', value: `https://t.me/${faker.internet.username()}`, - iconId: 'ri:telegram-line', - info: faker.lorem.sentence(), }, { - label: 'Website', + value: `https://x.com/${faker.internet.username()}`, + }, + { + value: faker.internet.url(), + }, + { + label: faker.lorem.word({ length: 2 }), value: faker.internet.url(), - iconId: 'ri:global-line', - info: faker.lorem.sentence(), }, { - label: 'LinkedIn', value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`, - iconId: 'ri:linkedin-box-line', - info: faker.lorem.sentence(), }, ] as const satisfies Partial[] diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts index 1492054..e64e0db 100644 --- a/web/src/actions/admin/service.ts +++ b/web/src/actions/admin/service.ts @@ -14,7 +14,7 @@ import { } from '../../lib/zodUtils' const serviceSchemaBase = z.object({ - id: z.number(), + id: z.number().int().positive(), slug: z .string() .regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens') @@ -56,15 +56,6 @@ const addSlugIfMissing = < }), }) -const contactMethodSchema = z.object({ - id: z.number().optional(), - label: z.string().min(1).max(50), - value: z.string().min(1).max(200), - iconId: z.string().min(1).max(50), - info: z.string().max(200).optional().default(''), - serviceId: z.number(), -}) - export const adminServiceActions = { create: defineProtectedAction({ accept: 'form', @@ -195,7 +186,11 @@ export const adminServiceActions = { createContactMethod: defineProtectedAction({ accept: 'form', permissions: 'admin', - input: contactMethodSchema.omit({ id: true }), + input: z.object({ + label: z.string().min(1).max(50).optional(), + value: z.string().url(), + serviceId: z.number().int().positive(), + }), handler: async (input) => { const contactMethod = await prisma.serviceContactMethod.create({ data: input, @@ -207,7 +202,12 @@ export const adminServiceActions = { updateContactMethod: defineProtectedAction({ accept: 'form', permissions: 'admin', - input: contactMethodSchema, + input: z.object({ + id: z.number().int().positive().optional(), + label: z.string().min(1).max(50).optional(), + value: z.string().url(), + serviceId: z.number().int().positive(), + }), handler: async (input) => { const { id, ...data } = input const contactMethod = await prisma.serviceContactMethod.update({ @@ -222,7 +222,7 @@ export const adminServiceActions = { accept: 'form', permissions: 'admin', input: z.object({ - id: z.number(), + id: z.number().int().positive(), }), handler: async (input) => { await prisma.serviceContactMethod.delete({ diff --git a/web/src/actions/comment.ts b/web/src/actions/comment.ts index 78e1747..cedabc7 100644 --- a/web/src/actions/comment.ts +++ b/web/src/actions/comment.ts @@ -14,9 +14,9 @@ import { timeTrapSecretKey } from '../lib/timeTrapSecret' import type { CommentStatus, Prisma } from '@prisma/client' -const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 5 +const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2 const MAX_COMMENTS_PER_WINDOW = 1 -const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 5 +const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10 export const commentActions = { vote: defineProtectedAction({ diff --git a/web/src/constants/contactMethods.ts b/web/src/constants/contactMethods.ts new file mode 100644 index 0000000..f309e3b --- /dev/null +++ b/web/src/constants/contactMethods.ts @@ -0,0 +1,122 @@ +import { parsePhoneNumberWithError } from 'libphonenumber-js' + +import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' +import { transformCase } from '../lib/strings' + +type ContactMethodInfo = { + type: T + label: string + /** Notice that the first capture group is then used to format the value */ + matcher: RegExp + formatter: (value: string) => string | null + icon: string +} + +export const { + dataArray: contactMethods, + dataObject: contactMethodsById, + /** Use {@link formatContactMethod} instead */ + getFn: getContactMethodInfo, +} = makeHelpersForOptions( + 'type', + (type): ContactMethodInfo => ({ + type, + label: type ? transformCase(type, 'title') : String(type), + icon: 'ri:shield-fill', + matcher: /(.*)/, + formatter: (value) => value, + }), + [ + { + type: 'email', + label: 'Email', + matcher: /mailto:(.*)/, + formatter: (value) => value, + icon: 'ri:mail-line', + }, + { + type: 'telephone', + label: 'Telephone', + matcher: /tel:(.*)/, + formatter: (value) => { + return parsePhoneNumberWithError(value).formatInternational() + }, + icon: 'ri:phone-line', + }, + { + type: 'whatsapp', + label: 'WhatsApp', + matcher: /https?:\/\/(?:www\.)?wa\.me\/(.*)\/?/, + formatter: (value) => { + return parsePhoneNumberWithError(value).formatInternational() + }, + icon: 'ri:whatsapp-line', + }, + { + type: 'telegram', + label: 'Telegram', + matcher: /https?:\/\/(?:www\.)?t\.me\/(.*)\/?/, + formatter: (value) => `t.me/${value}`, + icon: 'ri:telegram-line', + }, + { + type: 'linkedin', + label: 'LinkedIn', + matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/, + formatter: (value) => `in/${value}`, + icon: 'ri:linkedin-box-line', + }, + { + type: 'website', + label: 'Website', + matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/, + formatter: (value) => value, + icon: 'ri:global-line', + }, + { + type: 'x', + label: 'X', + matcher: /https?:\/\/(?:www\.)?x\.com\/(.*)\/?/, + formatter: (value) => `@${value}`, + icon: 'ri:twitter-x-line', + }, + { + type: 'instagram', + label: 'Instagram', + matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.*)\/?/, + formatter: (value) => `@${value}`, + icon: 'ri:instagram-line', + }, + { + type: 'matrix', + label: 'Matrix', + matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.*)\/?/, + formatter: (value) => value, + icon: 'ri:hashtag', + }, + { + type: 'bitcointalk', + label: 'BitcoinTalk', + matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/, + formatter: () => 'BitcoinTalk', + icon: 'ri:btc-line', + }, + ] as const satisfies ContactMethodInfo[] +) + +export function formatContactMethod(url: string) { + for (const contactMethod of contactMethods) { + const captureGroup = url.match(contactMethod.matcher)?.[1] + if (!captureGroup) continue + + const formattedValue = contactMethod.formatter(captureGroup) + if (!formattedValue) continue + + return { + ...contactMethod, + formattedValue, + } as const + } + + return { ...getContactMethodInfo('unknown'), formattedValue: url } as const +} diff --git a/web/src/lib/contactMethods.ts b/web/src/lib/contactMethods.ts deleted file mode 100644 index d8239d8..0000000 --- a/web/src/lib/contactMethods.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { parsePhoneNumberWithError } from 'libphonenumber-js' - -type Formatter = { - id: string - matcher: RegExp - formatter: (value: string) => string | null -} -const formatters = [ - { - id: 'email', - matcher: /mailto:(.*)/, - formatter: (value) => value, - }, - { - id: 'telephone', - matcher: /tel:(.*)/, - formatter: (value) => { - return parsePhoneNumberWithError(value).formatInternational() - }, - }, - { - id: 'whatsapp', - matcher: /https?:\/\/wa\.me\/(.*)\/?/, - formatter: (value) => { - return parsePhoneNumberWithError(value).formatInternational() - }, - }, - { - id: 'telegram', - matcher: /https?:\/\/t\.me\/(.*)\/?/, - formatter: (value) => `t.me/${value}`, - }, - { - id: 'linkedin', - matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/, - formatter: (value) => `in/${value}`, - }, - { - id: 'website', - matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/, - formatter: (value) => value, - }, -] as const satisfies Formatter[] - -export function formatContactMethod(url: string) { - for (const formatter of formatters) { - const captureGroup = url.match(formatter.matcher)?.[1] - if (!captureGroup) continue - - const formattedValue = formatter.formatter(captureGroup) - if (!formattedValue) continue - - return { - type: formatter.id, - formattedValue, - } as const - } - - return null -} diff --git a/web/src/pages/admin/service-suggestions/[id].astro b/web/src/pages/admin/service-suggestions/[id].astro index 38be454..fad2ed6 100644 --- a/web/src/pages/admin/service-suggestions/[id].astro +++ b/web/src/pages/admin/service-suggestions/[id].astro @@ -4,6 +4,7 @@ import { actions } from 'astro:actions' import Chat from '../../../components/Chat.astro' import ServiceCard from '../../../components/ServiceCard.astro' +import UserBadge from '../../../components/UserBadge.astro' import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus' import BaseLayout from '../../../layouts/BaseLayout.astro' import { cn } from '../../../lib/cn' @@ -37,6 +38,8 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service select: { id: true, name: true, + displayName: true, + picture: true, }, }, service: { @@ -127,11 +130,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status) Submitted by: - - - {serviceSuggestion.user.name} - - + Submitted at: {serviceSuggestion.createdAt.toLocaleString()} diff --git a/web/src/pages/admin/service-suggestions/index.astro b/web/src/pages/admin/service-suggestions/index.astro index ad2fb4e..a09563d 100644 --- a/web/src/pages/admin/service-suggestions/index.astro +++ b/web/src/pages/admin/service-suggestions/index.astro @@ -2,7 +2,7 @@ import { Icon } from 'astro-icon/components' import { actions } from 'astro:actions' import { z } from 'astro:content' -import { orderBy as lodashOrderBy } from 'lodash-es' +import { orderBy } from 'lodash-es' import SortArrowIcon from '../../../components/SortArrowIcon.astro' import TimeFormatted from '../../../components/TimeFormatted.astro' @@ -122,21 +122,13 @@ let suggestionsWithDetails = suggestions.map((s) => ({ })) if (sortBy === 'service') { - suggestionsWithDetails = lodashOrderBy( - suggestionsWithDetails, - [(s) => s.service.name.toLowerCase()], - [sortOrder] - ) + suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder]) } else if (sortBy === 'status') { - suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder]) + suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder]) } else if (sortBy === 'user') { - suggestionsWithDetails = lodashOrderBy( - suggestionsWithDetails, - [(s) => s.user.name.toLowerCase()], - [sortOrder] - ) + suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder]) } else if (sortBy === 'messageCount') { - suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, ['messageCount'], [sortOrder]) + suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder]) } const suggestionCount = suggestionsWithDetails.length diff --git a/web/src/pages/admin/services/[slug]/edit.astro b/web/src/pages/admin/services/[slug]/edit.astro index 7f8ef0e..5797b8a 100644 --- a/web/src/pages/admin/services/[slug]/edit.astro +++ b/web/src/pages/admin/services/[slug]/edit.astro @@ -1,21 +1,23 @@ --- -import { - AttributeCategory, - Currency, - EventType, - VerificationStatus, - VerificationStepStatus, -} from '@prisma/client' +import { EventType, VerificationStepStatus } from '@prisma/client' import { Icon } from 'astro-icon/components' import { actions, isInputError } from 'astro:actions' -import MyPicture from '../../../../components/MyPicture.astro' +import InputCardGroup from '../../../../components/InputCardGroup.astro' +import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.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 UserBadge from '../../../../components/UserBadge.astro' +import { formatContactMethod } from '../../../../constants/contactMethods' +import { currencies } from '../../../../constants/currencies' +import { kycLevels } from '../../../../constants/kycLevels' import { serviceVisibilities } from '../../../../constants/serviceVisibility' +import { verificationStatuses } from '../../../../constants/verificationStatus' import BaseLayout from '../../../../layouts/BaseLayout.astro' import { cn } from '../../../../lib/cn' import { prisma } from '../../../../lib/prisma' -import { ACCEPTED_IMAGE_TYPES } from '../../../../lib/zodUtils' const { slug } = Astro.params @@ -131,15 +133,7 @@ const attributes = await Astro.locals.banners.try( [] ) -const inputBaseClasses = - 'w-full rounded-md border-zinc-600 bg-zinc-700/80 p-2 text-zinc-200 placeholder-zinc-400 focus:border-sky-500 focus:ring-1 focus:ring-sky-500 text-sm' -const labelBaseClasses = 'block text-sm font-medium text-zinc-300 mb-1' -const errorTextClasses = 'mt-1 text-xs text-red-400' -const checkboxLabelClasses = 'inline-flex items-center text-sm text-zinc-300' -const checkboxInputClasses = - 'rounded-sm border-zinc-500 bg-zinc-700 text-sky-500 focus:ring-sky-500 focus:ring-offset-zinc-800' - -// Button style constants +// Button style constants for admin sections (Events, etc.) const buttonPrimaryClasses = 'inline-flex items-center justify-center rounded-md border border-transparent bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-zinc-900' @@ -160,6 +154,12 @@ const buttonSmallWarningClasses = cn( buttonSmallBaseClasses, 'text-yellow-400 hover:bg-yellow-700/30 hover:text-yellow-300' ) + +// Legacy classes for existing admin forms (Events, Verification Steps, Contact Methods) +const inputBaseClasses = + 'w-full rounded-md border-zinc-600 bg-zinc-700/80 p-2 text-zinc-200 placeholder-zinc-400 focus:border-sky-500 focus:ring-1 focus:ring-sky-500 text-sm' +const labelBaseClasses = 'block text-sm font-medium text-zinc-300 mb-1' +const errorTextClasses = 'mt-1 text-xs text-red-400' --- @@ -190,430 +190,227 @@ const buttonSmallWarningClasses = cn( enctype="multipart/form-data" > + + + + + + +
+ + + + +
+
- - +
+ +
+ ({ + label: category.name, + value: category.id.toString(), + icon: category.icon, + }))} + selectedValues={service.categories.map((c) => c.id.toString())} + error={serviceInputErrors.categories} /> - {serviceInputErrors.name &&

{serviceInputErrors.name.join(', ')}

}
- -