Compare commits
6 Commits
release-52
...
release-59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b90b3eef6 | ||
|
|
2489e94b0e | ||
|
|
144af17a70 | ||
|
|
02e52d7351 | ||
|
|
dacf73a804 | ||
|
|
5812399e29 |
53
.clinerules
53
.clinerules
@@ -1,53 +0,0 @@
|
||||
# Cursor Rules
|
||||
|
||||
- When merging tailwind classes, use the `cn` function.
|
||||
- When using Tailwind and you need to merge classes use the `cn` function if avilable.
|
||||
- We use Tailwind 4 (the latest version), make sure to not use outdated classes.
|
||||
- Instead of using the syntax`Array<T>`, use `T[]`.
|
||||
- Use TypeScript `type` over `interface`.
|
||||
- You are forbiddent o add comments unless explicitly stated by the user.
|
||||
- Avoid sending JavaScript to the client. The JS send should be optional.
|
||||
- In prisma preffer `select` over `include` when making queries.
|
||||
- Import the types from prisma instead of hardcoding duplicates.
|
||||
- Avoid duplicating similar html code, and parametrize it when possible or create separate components.
|
||||
- Remember to check the prisma schema when doing things related to the database.
|
||||
- Avoid hardcoding enums from the database, import them from prisma.
|
||||
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
|
||||
- The admin pages can use client-side JavaScript.
|
||||
- Keep README.md in sync with new capabilities.
|
||||
- The package manager is npm.
|
||||
- For icons use the `Icon` component from `astro-icon/components`.
|
||||
- For icons use the Remix Icon library preferably.
|
||||
- Use the `Image` component from `astro:assets` for images.
|
||||
- Use the `zod` library for schema validation.
|
||||
- In the astro actions return, don't return success: true, or similar, just return an object with the newly created/edited objects or nothing.
|
||||
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
|
||||
- Don't forget that the astro files have thre dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
|
||||
- The admin actions go into a separate folder.
|
||||
- In Actro actions when throwing errors use ActionError.
|
||||
- @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`. Example:
|
||||
|
||||
```ts
|
||||
import { actions } from "astro:actions"; /* CORRECT */
|
||||
import { server } from "~/actions"; /* WRONG!!!! DON'T DO THIS */
|
||||
import { adminAttributeActions } from "~/actions/admin/attribute.ts"; /* WRONG!!!! DON'T DO THIS */
|
||||
|
||||
const result = Astro.getActionResult(actions.admin.attribute.create);
|
||||
```
|
||||
|
||||
- Always use Astro actions instead of with API routes or `if (Astro.request.method === "POST")`.
|
||||
- When adding clientside js do it with HTMX.
|
||||
- When adding HTMX, the layout component BaseLayout accepts a prop htmx to load it in that page. No need to use a cdn.
|
||||
- When redirecting to login use the `makeLoginUrl` function from web/src/lib/redirectUrls.ts
|
||||
|
||||
```ts
|
||||
function makeLoginUrl(
|
||||
currentUrl: URL,
|
||||
options: {
|
||||
redirect?: URL | string | null;
|
||||
error?: string | null;
|
||||
logout?: boolean;
|
||||
message?: string | null;
|
||||
} = {}
|
||||
);
|
||||
```
|
||||
46
.env.example
46
.env.example
@@ -0,0 +1,46 @@
|
||||
# Database
|
||||
POSTGRES_USER=your_db_user
|
||||
POSTGRES_PASSWORD=your_db_password
|
||||
POSTGRES_DATABASE=your_db_name
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
|
||||
DATABASE_UI_URL="https://db.example.com"
|
||||
|
||||
# Generic Config
|
||||
UPLOAD_DIR=/app/uploads
|
||||
SITE_URL="https://your-site.example.com"
|
||||
SOURCE_CODE_URL="https://your-source-code.example.com"
|
||||
TIME_TRAP_SECRET=your_time_trap_secret
|
||||
LOGS_UI_URL="https://logs.example.com"
|
||||
|
||||
# Release Info
|
||||
RELEASE_NUMBER=
|
||||
RELEASE_DATE=
|
||||
|
||||
# Redis
|
||||
REDIS_URL="redis://redis:6379"
|
||||
|
||||
# Crawl4AI
|
||||
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
|
||||
CRAWL4AI_API_TOKEN=your_crawl4ai_token
|
||||
|
||||
# Tor and I2P
|
||||
ONION_ADDRESS="http://youronionaddress.onion"
|
||||
I2P_ADDRESS="http://youri2paddress.b32.i2p"
|
||||
I2P_PASS=your_i2p_password
|
||||
|
||||
# Push Notifications
|
||||
VAPID_PUBLIC_KEY=your_vapid_public_key
|
||||
VAPID_PRIVATE_KEY=your_vapid_private_key
|
||||
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
OPENAI_BASE_URL="https://your-openai-base-url.example.com"
|
||||
OPENAI_MODEL=your_openai_model
|
||||
OPENAI_RETRY=3
|
||||
|
||||
# Pyworker Crons
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day
|
||||
@@ -7,10 +7,8 @@ services:
|
||||
volumes:
|
||||
- database:/var/lib/postgresql/data:z
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
|
||||
interval: 10s
|
||||
@@ -20,19 +18,17 @@ services:
|
||||
pyworker:
|
||||
build:
|
||||
context: ./pyworker
|
||||
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
||||
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
|
||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
crawl4ai:
|
||||
image: unclecode/crawl4ai:basic-amd64
|
||||
expose:
|
||||
- "11235"
|
||||
environment:
|
||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
|
||||
MAX_CONCURRENT_TASKS: 10
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /dev/shm:/dev/shm
|
||||
deploy:
|
||||
@@ -53,15 +49,9 @@ services:
|
||||
|
||||
astro:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: web/Dockerfile
|
||||
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
|
||||
36
justfile
36
justfile
@@ -51,42 +51,6 @@ import-db file="":
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Restoring database from $BACKUP_FILE..."
|
||||
# First drop all connections to the database
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
|
||||
|
||||
# Drop and recreate database
|
||||
echo "Dropping and recreating the database..."
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
|
||||
|
||||
# Restore the database
|
||||
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
|
||||
echo "Database restored successfully!"
|
||||
|
||||
# Import triggers
|
||||
echo "Importing triggers..."
|
||||
just import-triggers
|
||||
|
||||
echo "Database import completed!"
|
||||
# Check if migrations need to be run
|
||||
cd web && npx prisma migrate status
|
||||
|
||||
#!/bin/bash
|
||||
if [ -z "{{file}}" ]; then
|
||||
BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1)
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "Error: No backup files found in the backups directory"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
BACKUP_FILE="{{file}}"
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "Error: Backup file '$BACKUP_FILE' not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== STEP 1: PREPARING DATABASE ==="
|
||||
# Drop all connections to the database
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
|
||||
|
||||
@@ -184,16 +184,16 @@ Be concise but thorough, and make sure your output is properly formatted JSON.
|
||||
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
||||
You will be given a list of user comments to a service.
|
||||
Your task is to summarize the comments in a way that is easy to understand and to the point.
|
||||
The summary should be concise and to the point, no more than 150 words.
|
||||
The summary should be concise and to the point, no more than 100 words. Keep it short and concise.
|
||||
Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
|
||||
|
||||
You must format your response as a valid JSON object with the following structure:
|
||||
|
||||
interface CommentSummary {
|
||||
summary: string;
|
||||
summary: string; // Concise, 100 words max
|
||||
sentiment: 'positive'|'negative'|'neutral';
|
||||
whatUsersLike: string[]; // Concise, 2-3 words, max 4
|
||||
whatUsersDislike: string[]; // Concise, 2-3 words, max 4
|
||||
whatUsersLike: string[]; // Concise, 2-3 words max
|
||||
whatUsersDislike: string[]; // Concise, 2-3 words max
|
||||
}
|
||||
|
||||
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.
|
||||
|
||||
@@ -2,12 +2,12 @@ DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
SOURCE_CODE_URL="https://github.com"
|
||||
DATABASE_UI_URL="http://localhost:5555"
|
||||
SITE_URL="https://localhost:4321"
|
||||
SITE_URL="http://localhost:4321"
|
||||
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
|
||||
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
||||
RELEASE_NUMBER=123
|
||||
RELEASE_DATE="2025-05-23T19:00:00.000Z"
|
||||
# Generated with `npx web-push generate-vapid-keys`
|
||||
VAPID_PUBLIC_KEY="<vapid-public-key-placeholder>"
|
||||
VAPID_PRIVATE_KEY="<vapid-private-key-placeholder>"
|
||||
VAPID_PUBLIC_KEY="BPmJbRXzG9zT181vyg1GlpyV8qu7rjVjfg6vkkOgtqeTZECyt6lR4MuzmlarEHSBF6gPpc77ZA0_tTVtmYh65iM"
|
||||
VAPID_PRIVATE_KEY="eN_S2SMXDB2hpwVXbgDkDrPIPMqirllZaJcUgYTt9w0"
|
||||
VAPID_SUBJECT="mailto:no-reply@kycnot.me"
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
FROM node:lts AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY .env .env
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
|
||||
COPY .npmrc .npmrc
|
||||
COPY web/.npmrc .npmrc
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
COPY web/ .
|
||||
|
||||
ARG ASTRO_BUILD_MODE=production
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
|
||||
|
||||
@@ -20,7 +23,7 @@ ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
|
||||
# Add knm-migrate command script and make it executable
|
||||
COPY migrate.sh /usr/local/bin/knm-migrate
|
||||
COPY web/migrate.sh /usr/local/bin/knm-migrate
|
||||
RUN chmod +x /usr/local/bin/knm-migrate
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
|
||||
@@ -6,11 +6,11 @@ import sitemap from '@astrojs/sitemap'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig, envField } from 'astro/config'
|
||||
import icon from 'astro-icon'
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
// @ts-expect-error process.env actually exists
|
||||
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
|
||||
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
|
||||
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
integrations: [
|
||||
postgresListener(),
|
||||
icon(),
|
||||
mdx(),
|
||||
sitemap({
|
||||
|
||||
1366
web/package-lock.json
generated
1366
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,18 +23,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@astrojs/db": "0.14.14",
|
||||
"@astrojs/mdx": "4.2.6",
|
||||
"@astrojs/node": "9.2.1",
|
||||
"@astrojs/sitemap": "3.4.0",
|
||||
"@fontsource-variable/space-grotesk": "5.2.7",
|
||||
"@astrojs/db": "0.15.0",
|
||||
"@astrojs/mdx": "4.3.0",
|
||||
"@astrojs/node": "9.2.2",
|
||||
"@astrojs/sitemap": "3.4.1",
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
"@fontsource/inter": "5.2.5",
|
||||
"@fontsource/space-grotesk": "5.2.7",
|
||||
"@prisma/client": "6.8.2",
|
||||
"@tailwindcss/vite": "4.1.7",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@fontsource/space-grotesk": "5.2.8",
|
||||
"@prisma/client": "6.9.0",
|
||||
"@tailwindcss/vite": "4.1.8",
|
||||
"@types/mime-types": "3.0.0",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.7.13",
|
||||
"astro": "5.9.0",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"astro-seo-schema": "5.0.0",
|
||||
@@ -42,58 +43,59 @@
|
||||
"clsx": "2.1.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.8",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
"lodash-es": "4.17.21",
|
||||
"mime-types": "3.0.1",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"pg": "8.16.0",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.0.1",
|
||||
"redis": "5.5.6",
|
||||
"schema-dts": "1.1.5",
|
||||
"seedrandom": "3.0.5",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.34.2",
|
||||
"slugify": "1.6.6",
|
||||
"tailwind-merge": "3.3.0",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.7",
|
||||
"tailwindcss": "4.1.8",
|
||||
"typescript": "5.8.3",
|
||||
"unique-username-generator": "1.4.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod-form-data": "2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.27.0",
|
||||
"@eslint/js": "9.28.0",
|
||||
"@faker-js/faker": "9.8.0",
|
||||
"@iconify-json/material-symbols": "1.2.21",
|
||||
"@iconify-json/material-symbols": "1.2.24",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
"@stylistic/eslint-plugin": "4.2.0",
|
||||
"@stylistic/eslint-plugin": "4.4.1",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"@typescript-eslint/parser": "8.33.1",
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-import-resolver-typescript": "4.3.5",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-import-resolver-typescript": "4.4.3",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"globals": "16.1.0",
|
||||
"globals": "16.2.0",
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.11",
|
||||
"prisma": "6.8.2",
|
||||
"prisma-json-types-generator": "3.4.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.12",
|
||||
"prisma": "6.9.0",
|
||||
"prisma-json-types-generator": "3.4.2",
|
||||
"tailwind-htmx": "0.1.2",
|
||||
"ts-essentials": "10.0.4",
|
||||
"ts-toolbelt": "9.6.0",
|
||||
"tsx": "4.19.4",
|
||||
"typescript-eslint": "8.32.1"
|
||||
"typescript-eslint": "8.33.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `kycLevelDetailsId` on the `Service` table. All the data in the column will be lost.
|
||||
- Made the column `kycLevelClarification` on table `Service` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" DROP COLUMN "kycLevelDetailsId",
|
||||
ALTER COLUMN "kycLevelClarification" SET NOT NULL,
|
||||
ALTER COLUMN "kycLevelClarification" SET DEFAULT 'NONE';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'TEST';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';
|
||||
@@ -25,6 +25,7 @@ enum OrderIdStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
model Comment {
|
||||
@@ -128,6 +129,7 @@ enum AccountStatusChange {
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
TEST
|
||||
COMMENT_STATUS_CHANGE
|
||||
REPLY_COMMENT_CREATED
|
||||
COMMUNITY_NOTE_ADDED
|
||||
@@ -345,7 +347,7 @@ model Service {
|
||||
description String
|
||||
categories Category[] @relation("ServiceToCategory")
|
||||
kycLevel Int @default(4)
|
||||
kycLevelClarification KycLevelClarification?
|
||||
kycLevelClarification KycLevelClarification @default(NONE)
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
@@ -391,7 +393,6 @@ model Service {
|
||||
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
|
||||
Notification Notification[]
|
||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||
kycLevelDetailsId Int?
|
||||
|
||||
@@index([listedAt])
|
||||
@@index([overallScore])
|
||||
|
||||
16
web/prisma/triggers/12_notification_push_trigger.sql
Normal file
16
web/prisma/triggers/12_notification_push_trigger.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE OR REPLACE FUNCTION trigger_notification_push()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notification_created', json_build_object('id', NEW.id)::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the trigger if it exists to ensure a clean setup
|
||||
DROP TRIGGER IF EXISTS notification_push_trigger ON "Notification";
|
||||
|
||||
-- Create the trigger to fire after inserts
|
||||
CREATE TRIGGER notification_push_trigger
|
||||
AFTER INSERT ON "Notification"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_notification_push();
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,80 +1,33 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { sumBy } from 'lodash-es'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { sendPushNotification } from '../../lib/webPush'
|
||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||
|
||||
export const adminNotificationActions = {
|
||||
webPush: {
|
||||
test: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userNames: stringListOfSlugsSchemaRequired,
|
||||
title: z.string().min(1).nullable(),
|
||||
body: z.string().nullable(),
|
||||
url: z.string().url().optional(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { user: { name: { in: input.userNames } } },
|
||||
select: {
|
||||
id: true,
|
||||
endpoint: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
userAgent: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: input.title ?? 'Test Notification',
|
||||
body: input.body ?? 'This is a test push notification from KYCNot.me',
|
||||
url: input.url ?? '/',
|
||||
}
|
||||
)
|
||||
|
||||
// If subscription is invalid, remove it from database
|
||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||
await prisma.pushSubscription.delete({
|
||||
where: { id: subscription.id },
|
||||
})
|
||||
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
|
||||
}
|
||||
|
||||
return result.success
|
||||
})
|
||||
)
|
||||
|
||||
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
|
||||
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
|
||||
const now = new Date()
|
||||
return {
|
||||
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
|
||||
totalSubscriptions: subscriptions.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
sentAt: now,
|
||||
}
|
||||
},
|
||||
test: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userNames: stringListOfSlugsSchemaRequired,
|
||||
}),
|
||||
},
|
||||
handler: async (input) => {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { name: { in: input.userNames } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const notifications = await prisma.notification.createManyAndReturn({
|
||||
data: users.map((user) => ({
|
||||
type: 'TEST',
|
||||
userId: user.id,
|
||||
})),
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return {
|
||||
message: `Created ${notifications.length.toString()} notifications.`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ import { uniq } from 'lodash-es'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
|
||||
import {
|
||||
imageFileSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
zodContactMethod,
|
||||
} from '../../lib/zodUtils'
|
||||
|
||||
const addSlugIfMissing = <
|
||||
T extends {
|
||||
@@ -69,6 +74,15 @@ const updateServiceInputSchema = serviceSchemaBase
|
||||
})
|
||||
.transform(addSlugIfMissing)
|
||||
|
||||
const evidenceImageAddSchema = z.object({
|
||||
serviceId: z.number().int().positive(),
|
||||
imageFile: imageFileSchema,
|
||||
})
|
||||
|
||||
const evidenceImageDeleteSchema = z.object({
|
||||
fileUrl: z.string().startsWith('/files/evidence/', 'Must be a valid evidence file URL'),
|
||||
})
|
||||
|
||||
export const adminServiceActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
@@ -107,7 +121,7 @@ export const adminServiceActions = {
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
kycLevelClarification: input.kycLevelClarification,
|
||||
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
||||
verificationStatus: input.verificationStatus,
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
@@ -225,7 +239,7 @@ export const adminServiceActions = {
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
kycLevelClarification: input.kycLevelClarification,
|
||||
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
||||
verificationStatus: input.verificationStatus,
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
@@ -272,7 +286,7 @@ export const adminServiceActions = {
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: z.string().url(),
|
||||
value: zodContactMethod,
|
||||
serviceId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
@@ -404,4 +418,50 @@ export const adminServiceActions = {
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
evidenceImage: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: evidenceImageAddSchema,
|
||||
handler: async (input) => {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: input.serviceId },
|
||||
select: { slug: true },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found to associate image with.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!input.imageFile) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Image file is required.',
|
||||
})
|
||||
}
|
||||
|
||||
const imageUrl = await saveFileLocally(
|
||||
input.imageFile,
|
||||
input.imageFile.name,
|
||||
`evidence/${service.slug}`
|
||||
)
|
||||
|
||||
return { imageUrl }
|
||||
},
|
||||
}),
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: evidenceImageDeleteSchema,
|
||||
handler: async (input) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await deleteFileLocally(input.fileUrl)
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
|
||||
import { type Prisma, type ServiceUserRole } from '@prisma/client'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
||||
|
||||
const prisma = prismaInstance as PrismaClient
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const selectUserReturnFields = {
|
||||
id: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
|
||||
import { getKycLevelInfo } from '../../constants/kycLevels'
|
||||
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
@@ -50,6 +51,7 @@ export const apiServiceActions = {
|
||||
slug: true,
|
||||
description: true,
|
||||
kycLevel: true,
|
||||
kycLevelClarification: true,
|
||||
verificationStatus: true,
|
||||
categories: {
|
||||
select: {
|
||||
@@ -130,6 +132,12 @@ export const apiServiceActions = {
|
||||
verifiedAt: service.verifiedAt,
|
||||
kycLevel: service.kycLevel,
|
||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||
kycLevelClarification: service.kycLevelClarification,
|
||||
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||
'value',
|
||||
'name',
|
||||
'description',
|
||||
]),
|
||||
categories: service.categories,
|
||||
listedAt: service.listedAt,
|
||||
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
|
||||
|
||||
@@ -345,10 +345,11 @@ export const commentActions = {
|
||||
'order-id-status',
|
||||
'kyc-requested',
|
||||
'funds-blocked',
|
||||
'toggle-rating-active',
|
||||
]),
|
||||
value: z.union([
|
||||
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
||||
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
|
||||
z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
]),
|
||||
@@ -411,7 +412,7 @@ export const commentActions = {
|
||||
updateData.privateContext = input.value as string
|
||||
break
|
||||
case 'order-id-status':
|
||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
|
||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
|
||||
break
|
||||
case 'kyc-requested':
|
||||
updateData.kycRequested = !!input.value
|
||||
@@ -419,6 +420,9 @@ export const commentActions = {
|
||||
case 'funds-blocked':
|
||||
updateData.fundsBlocked = !!input.value
|
||||
break
|
||||
case 'toggle-rating-active':
|
||||
updateData.ratingActive = !!input.value
|
||||
break
|
||||
}
|
||||
|
||||
// Update the comment
|
||||
|
||||
@@ -10,7 +10,12 @@ import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { separateServiceUrlsByType } from '../lib/urls'
|
||||
import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
|
||||
import {
|
||||
imageFileSchemaRequired,
|
||||
stringListOfContactMethodsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
} from '../lib/zodUtils'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
@@ -153,6 +158,7 @@ export const serviceSuggestionActions = {
|
||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
contactMethods: stringListOfContactMethodsSchema,
|
||||
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
||||
kycLevelClarification: z.nativeEnum(KycLevelClarification),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
@@ -239,6 +245,11 @@ export const serviceSuggestionActions = {
|
||||
attributeId: id,
|
||||
})),
|
||||
},
|
||||
contactMethods: {
|
||||
create: input.contactMethods.map((value) => ({
|
||||
value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: serviceSelect,
|
||||
})
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
const badge = tv({
|
||||
slots: {
|
||||
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
|
||||
icon: 'size-3 shrink-0',
|
||||
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap [&>a]:hover:underline [&>a]:focus-visible:underline',
|
||||
},
|
||||
variants: {
|
||||
color: {
|
||||
@@ -122,18 +123,22 @@ const badge = tv({
|
||||
},
|
||||
})
|
||||
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
||||
type Props<
|
||||
Tag extends 'a' | 'div' | 'li' = 'div',
|
||||
Text extends string | undefined = string | undefined,
|
||||
> = Polymorphic<
|
||||
VariantProps<typeof badge> & {
|
||||
as: Tag
|
||||
icon?: string
|
||||
endIcon?: string
|
||||
text: string
|
||||
text?: Text
|
||||
inlineIcon?: boolean
|
||||
classNames?: {
|
||||
icon?: string
|
||||
text?: string
|
||||
endIcon?: string
|
||||
}
|
||||
children?: Text extends string ? never : AstroChildren
|
||||
}
|
||||
>
|
||||
|
||||
@@ -161,7 +166,7 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
|
||||
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
|
||||
)
|
||||
}
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent ?? <slot />}</span>
|
||||
{
|
||||
!!endIconName && (
|
||||
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
|
||||
|
||||
@@ -243,13 +243,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
comment.author.serviceAffiliations.map((affiliation) => {
|
||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||
return (
|
||||
<BadgeSmall
|
||||
icon={roleInfo.icon}
|
||||
color={roleInfo.color}
|
||||
text={`${roleInfo.label} at ${affiliation.service.name}`}
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
|
||||
{roleInfo.label} at
|
||||
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
||||
</BadgeSmall>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
|
||||
privateContext: true
|
||||
orderId: true
|
||||
orderIdStatus: true
|
||||
rating: true
|
||||
ratingActive: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
<div
|
||||
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
|
||||
>
|
||||
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
|
||||
<div class="border-night-500 flex flex-wrap items-center gap-1 border-b pb-2">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -59,42 +61,13 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
|
||||
<Icon name="ri:close-circle-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.suspicious
|
||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||
)}
|
||||
data-action="suspicious"
|
||||
data-value={!comment.suspicious}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.suspicious ? 'Not Spam' : 'Spam'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.requiresAdminReview
|
||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||
)}
|
||||
data-action="requires-admin-review"
|
||||
data-value={!comment.requiresAdminReview}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'VERIFIED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
@@ -104,12 +77,13 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
|
||||
<Icon name="ri:verified-badge-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
@@ -121,12 +95,49 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
|
||||
<Icon name="ri:checkbox-circle-line" class="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="bg-night-500 h-5 w-px"></div>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.suspicious
|
||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||
)}
|
||||
data-action="suspicious"
|
||||
data-value={!comment.suspicious}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:spam-2-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.suspicious ? 'Not Spam' : 'Spam'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.requiresAdminReview
|
||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||
)}
|
||||
data-action="requires-admin-review"
|
||||
data-value={!comment.requiresAdminReview}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:shield-user-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.kycRequested
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -136,12 +147,13 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
|
||||
<Icon name="ri:bank-card-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.fundsBlocked
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -151,8 +163,31 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
|
||||
<Icon name="ri:lock-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}</span>
|
||||
</button>
|
||||
|
||||
<div class="bg-night-500 h-5 w-px"></div>
|
||||
|
||||
{
|
||||
comment.rating && (
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.ratingActive
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
)}
|
||||
data-action="toggle-rating-active"
|
||||
data-value={!comment.ratingActive}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:star-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.ratingActive ? 'Disable Rating' : 'Enable Rating'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1.5">
|
||||
@@ -208,7 +243,7 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'APPROVED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
@@ -218,11 +253,12 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Approve
|
||||
<Icon name="ri:check-line" class="h-3.5 w-3.5" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -232,11 +268,12 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Reject
|
||||
<Icon name="ri:close-line" class="h-3.5 w-3.5" />
|
||||
<span>Reject</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
@@ -246,7 +283,23 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Pending
|
||||
<Icon name="ri:time-line" class="h-3.5 w-3.5" />
|
||||
<span>Pending</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'WITHDRAWN'
|
||||
? 'border-night-400 bg-night-500/50 text-night-300 border'
|
||||
: 'bg-night-700 hover:bg-night-500/50 hover:text-night-300'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="WITHDRAWN"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:arrow-go-back-line" class="h-3.5 w-3.5" />
|
||||
<span>Withdrawn</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
action === 'suspicious' ||
|
||||
action === 'requires-admin-review' ||
|
||||
action === 'kyc-requested' ||
|
||||
action === 'funds-blocked'
|
||||
action === 'funds-blocked' ||
|
||||
action === 'toggle-rating-active'
|
||||
? value === 'true'
|
||||
: value,
|
||||
})
|
||||
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
if (action === 'status') {
|
||||
window.location.reload()
|
||||
} else if (action === 'suspicious') {
|
||||
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'Not Spam' : 'Spam'
|
||||
btn.classList.toggle('bg-yellow-500/20')
|
||||
btn.classList.toggle('text-yellow-400')
|
||||
btn.classList.toggle('border-yellow-500/30')
|
||||
@@ -298,7 +353,8 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'requires-admin-review') {
|
||||
btn.textContent = value === 'true' ? 'No Review' : 'Review'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No Admin Review' : 'Needs Admin Review'
|
||||
btn.classList.toggle('bg-purple-500/20')
|
||||
btn.classList.toggle('text-purple-400')
|
||||
btn.classList.toggle('border-purple-500/30')
|
||||
@@ -309,7 +365,8 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
// Refresh to show updated order ID status
|
||||
window.location.reload()
|
||||
} else if (action === 'kyc-requested') {
|
||||
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
@@ -317,13 +374,23 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'funds-blocked') {
|
||||
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'toggle-rating-active') {
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'Disable Rating' : 'Enable Rating'
|
||||
btn.classList.toggle('bg-blue-500/20')
|
||||
btn.classList.toggle('text-blue-400')
|
||||
btn.classList.toggle('border-blue-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
}
|
||||
} else {
|
||||
console.error('Error moderating comment:', error)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -56,7 +56,15 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
|
||||
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
|
||||
<span>Community-contributed. Information not reviewed.</span>
|
||||
<span>
|
||||
Community-contributed. Information not reviewed.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : wasRecentlyAdded ? (
|
||||
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
@@ -64,11 +72,25 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||
caution.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
|
||||
<Icon name="ri:information-line" class="size-5 text-blue-400" />
|
||||
<span>Basic checks passed, but not fully verified.</span>
|
||||
<span>
|
||||
Basic checks passed, but not fully verified.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { Assert } from '../lib/assert'
|
||||
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
|
||||
|
||||
type ContactMethodInfo<T extends string | null | undefined = string> = {
|
||||
type: T
|
||||
label: string
|
||||
@@ -10,6 +13,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
|
||||
matcher: RegExp
|
||||
formatter: (match: RegExpMatchArray) => string | null
|
||||
icon: string
|
||||
urlType: string
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -22,9 +26,10 @@ export const {
|
||||
(type): ContactMethodInfo<typeof type> => ({
|
||||
type,
|
||||
label: type ? transformCase(type, 'title') : String(type),
|
||||
icon: 'ri:shield-fill',
|
||||
icon: 'ri:link',
|
||||
matcher: /(.*)/,
|
||||
formatter: ([, value]) => value ?? String(value),
|
||||
urlType: type ?? 'unknown',
|
||||
}),
|
||||
[
|
||||
{
|
||||
@@ -33,24 +38,37 @@ export const {
|
||||
matcher: /mailto:(.+)/,
|
||||
formatter: ([, value]) => value ?? 'Email',
|
||||
icon: 'ri:mail-line',
|
||||
urlType: 'email',
|
||||
},
|
||||
{
|
||||
type: 'telephone',
|
||||
label: 'Telephone',
|
||||
matcher: /tel:(.+)/,
|
||||
formatter: ([, value]) => {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
|
||||
try {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
|
||||
} catch (_error) {
|
||||
console.error(`Invalid telephone number: ${value ?? 'undefined'}`, _error)
|
||||
return value ?? 'Telephone'
|
||||
}
|
||||
},
|
||||
icon: 'ri:phone-line',
|
||||
urlType: 'telephone',
|
||||
},
|
||||
{
|
||||
type: 'whatsapp',
|
||||
label: 'WhatsApp',
|
||||
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
|
||||
formatter: ([, value]) => {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
|
||||
try {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
|
||||
} catch (_error) {
|
||||
console.error(`Invalid WhatsApp number: ${value ?? 'undefined'}`, _error)
|
||||
return value ?? 'WhatsApp'
|
||||
}
|
||||
},
|
||||
icon: 'ri:whatsapp-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'telegram',
|
||||
@@ -58,6 +76,7 @@ export const {
|
||||
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
|
||||
icon: 'ri:telegram-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'linkedin',
|
||||
@@ -65,6 +84,7 @@ export const {
|
||||
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
|
||||
icon: 'ri:linkedin-box-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'x',
|
||||
@@ -72,6 +92,7 @@ export const {
|
||||
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `@${value}` : 'X'),
|
||||
icon: 'ri:twitter-x-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
@@ -79,6 +100,7 @@ export const {
|
||||
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
|
||||
icon: 'ri:instagram-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'matrix',
|
||||
@@ -86,6 +108,7 @@ export const {
|
||||
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
||||
icon: 'ri:hashtag',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'bitcointalk',
|
||||
@@ -93,6 +116,7 @@ export const {
|
||||
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||
formatter: () => 'BitcoinTalk',
|
||||
icon: 'ri:btc-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'simplex',
|
||||
@@ -100,6 +124,7 @@ export const {
|
||||
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
|
||||
formatter: () => 'SimpleX Chat',
|
||||
icon: 'simplex',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'nostr',
|
||||
@@ -107,6 +132,7 @@ export const {
|
||||
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
|
||||
formatter: () => 'Nostr',
|
||||
icon: 'nostr',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
// Website must go last because it's a catch-all
|
||||
@@ -115,6 +141,7 @@ export const {
|
||||
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||
formatter: ([, value]) => value ?? 'Website',
|
||||
icon: 'ri:global-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
] as const satisfies ContactMethodInfo[]
|
||||
)
|
||||
@@ -135,3 +162,38 @@ export function formatContactMethod(url: string) {
|
||||
|
||||
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
|
||||
}
|
||||
|
||||
type ContactMethodUrlTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
labelPlural: string
|
||||
}
|
||||
|
||||
export const {
|
||||
dataArray: contactMethodUrlTypes,
|
||||
dataObject: contactMethodUrlTypesById,
|
||||
getFn: getContactMethodUrlTypeInfo,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): ContactMethodUrlTypeInfo<typeof value> => ({
|
||||
value,
|
||||
labelPlural: value ? transformCase(value, 'title') : String(value),
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'email',
|
||||
labelPlural: 'emails',
|
||||
},
|
||||
{
|
||||
value: 'telephone',
|
||||
labelPlural: 'phone numbers',
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
labelPlural: 'URLs',
|
||||
},
|
||||
] as const satisfies ContactMethodUrlTypeInfo<(typeof contactMethods)[number]['urlType']>[]
|
||||
)
|
||||
|
||||
type _ExpectUrlTypesToHaveAllValues = Assert<
|
||||
Equals<(typeof contactMethods)[number]['urlType'], keyof typeof contactMethodUrlTypesById>
|
||||
>
|
||||
|
||||
@@ -20,6 +20,11 @@ export const {
|
||||
icon: 'ri:notification-line',
|
||||
}),
|
||||
[
|
||||
{
|
||||
id: 'TEST',
|
||||
label: 'Test notification',
|
||||
icon: 'ri:flask-line',
|
||||
},
|
||||
{
|
||||
id: 'COMMENT_STATUS_CHANGE',
|
||||
label: 'Comment status changed',
|
||||
|
||||
@@ -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>[]
|
||||
|
||||
10
web/src/lib/assert.ts
Normal file
10
web/src/lib/assert.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Gives an error if the type is not equal to 1.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'a'>> // Gives an error
|
||||
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'b' | 'a'>> // No error
|
||||
* ```
|
||||
*/
|
||||
export type Assert<T extends 1> = T
|
||||
@@ -69,6 +69,53 @@ export async function saveFileLocally(
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files in a specific subdirectory of the upload directory.
|
||||
* Returns an array of web-accessible URLs.
|
||||
*/
|
||||
export async function listFiles(subDir: string): Promise<string[]> {
|
||||
const { fsPath: uploadDir, webPath: webUploadPath } = getUploadDir(subDir)
|
||||
try {
|
||||
const files = await fs.readdir(uploadDir)
|
||||
return files.map((file) => sanitizePath(`${webUploadPath}/${file}`))
|
||||
} catch (error: unknown) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
console.error(`Error listing files in ${uploadDir}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file locally given its web-accessible URL path
|
||||
*/
|
||||
export async function deleteFileLocally(fileUrl: string): Promise<void> {
|
||||
// Extract the subpath and filename from the webPath
|
||||
// Example: /files/evidence/service-slug/image.jpg -> evidence/service-slug/image.jpg
|
||||
const basePath = '/files'
|
||||
if (!fileUrl.startsWith(basePath)) {
|
||||
throw new Error('Invalid file URL for deletion. Must start with /files')
|
||||
}
|
||||
|
||||
const subPathAndFile = fileUrl.substring(basePath.length).replace(/^\/+/, '') // Remove leading /files/ and any extra leading slashes
|
||||
const { fsPath: uploadDirWithoutSubDir } = getUploadDir() // Get base upload directory
|
||||
const filePath = path.join(uploadDirWithoutSubDir, subPathAndFile)
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch (error: unknown) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code === 'ENOENT') {
|
||||
console.warn(`File not found for deletion, but treating as success: ${filePath}`)
|
||||
return
|
||||
}
|
||||
console.error(`Error deleting file ${filePath}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePath(inputPath: string): string {
|
||||
let sanitized = inputPath.replace(/\\+/g, '/')
|
||||
// Collapse multiple slashes, but preserve protocol (e.g., http://)
|
||||
|
||||
35
web/src/lib/json.ts
Normal file
35
web/src/lib/json.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { z } from 'astro:content'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface JSONObject {
|
||||
[k: string]: JSONValue
|
||||
}
|
||||
type JSONList = JSONValue[]
|
||||
type JSONPrimitive = boolean | number | string | null
|
||||
type JSONValue = Date | JSONList | JSONObject | JSONPrimitive
|
||||
|
||||
export type ZodJSON = z.ZodType<JSONValue>
|
||||
|
||||
export function zodParseJSON<T extends z.ZodType<JSONValue>, D extends z.output<T> | undefined = undefined>(
|
||||
schema: T,
|
||||
stringValue: string | null | undefined,
|
||||
defaultValue?: D
|
||||
): D | z.output<T> {
|
||||
if (!stringValue) return defaultValue as D
|
||||
|
||||
let jsonValue: D | z.output<typeof schema> = defaultValue as D
|
||||
try {
|
||||
jsonValue = JSON.parse(stringValue)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return defaultValue as D
|
||||
}
|
||||
|
||||
const parsedValue = schema.safeParse(jsonValue)
|
||||
if (!parsedValue.success) {
|
||||
console.error(parsedValue.error)
|
||||
return defaultValue as D
|
||||
}
|
||||
|
||||
return parsedValue.data
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
import { z } from 'astro:schema'
|
||||
|
||||
import { zodParseJSON, type ZodJSON } from './json'
|
||||
import { typedObjectEntries } from './objects'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface JSONObject {
|
||||
[k: string]: JSONValue
|
||||
}
|
||||
type JSONList = JSONValue[]
|
||||
type JSONPrimitive = boolean | number | string | null
|
||||
type JSONValue = Date | JSONList | JSONObject | JSONPrimitive
|
||||
|
||||
function makeTypedLocalStorage<
|
||||
Schemas extends Record<string, z.ZodType<JSONValue>>,
|
||||
Schemas extends Record<string, ZodJSON>,
|
||||
T extends {
|
||||
[K in keyof Schemas]: {
|
||||
schema: Schemas[K]
|
||||
@@ -28,24 +21,7 @@ function makeTypedLocalStorage<
|
||||
key,
|
||||
{
|
||||
get: () => {
|
||||
const stringValue = localStorage.getItem(key)
|
||||
if (!stringValue) return option.default
|
||||
|
||||
let jsonValue: z.output<typeof option.schema> | undefined = option.default
|
||||
try {
|
||||
jsonValue = JSON.parse(stringValue)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return option.default
|
||||
}
|
||||
|
||||
const parsedValue = option.schema.safeParse(jsonValue)
|
||||
if (!parsedValue.success) {
|
||||
console.error(parsedValue.error)
|
||||
return option.default
|
||||
}
|
||||
|
||||
return parsedValue.data
|
||||
return zodParseJSON(option.schema, localStorage.getItem(key), option.default)
|
||||
},
|
||||
|
||||
set: (value: z.input<typeof option.schema>) => {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { prisma } from './prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>(
|
||||
userId: number,
|
||||
select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never },
|
||||
tx: Prisma.TransactionClient = prisma
|
||||
tx:
|
||||
| Parameters<Parameters<(typeof prisma)['$transaction']>[0]>[0]
|
||||
| Prisma.TransactionClient
|
||||
| PrismaClient = prisma
|
||||
) {
|
||||
return (
|
||||
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??
|
||||
|
||||
@@ -7,11 +7,13 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
|
||||
|
||||
import { makeCommentUrl } from './commentsWithReplies'
|
||||
|
||||
import type { NotificationAction } from './webPush'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export function makeNotificationTitle(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
type: true
|
||||
aboutAccountStatusChange: true
|
||||
aboutCommentStatusChange: true
|
||||
@@ -87,6 +89,9 @@ export function makeNotificationTitle(
|
||||
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
|
||||
): string {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return `Test notification #${notification.id.toString()}`
|
||||
}
|
||||
case 'COMMENT_STATUS_CHANGE': {
|
||||
if (!notification.aboutComment) return 'A comment you are watching had a status change'
|
||||
|
||||
@@ -178,6 +183,7 @@ export function makeNotificationTitle(
|
||||
export function makeNotificationContent(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
createdAt: true
|
||||
type: true
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
@@ -204,6 +210,9 @@ export function makeNotificationContent(
|
||||
}>
|
||||
): string | null {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return `Created on ${notification.createdAt.toLocaleString()}`
|
||||
}
|
||||
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
// case 'KARMA_UNLOCK':
|
||||
case 'KARMA_CHANGE': {
|
||||
@@ -236,7 +245,7 @@ export function makeNotificationContent(
|
||||
}
|
||||
}
|
||||
|
||||
export function makeNotificationLink(
|
||||
export function makeNotificationActions(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
type: true
|
||||
@@ -278,44 +287,120 @@ export function makeNotificationLink(
|
||||
}
|
||||
}>,
|
||||
origin: string
|
||||
): string | null {
|
||||
): NotificationAction[] {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
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
|
||||
}
|
||||
|
||||
100
web/src/lib/postgresListenerIntegration.ts
Normal file
100
web/src/lib/postgresListenerIntegration.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { Client } from 'pg'
|
||||
|
||||
import { zodParseJSON } from './json'
|
||||
import { sendNotification } from './sendNotifications'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
import type { AstroIntegration, HookParameters } from 'astro'
|
||||
|
||||
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
|
||||
|
||||
let pgClient: Client | null = null
|
||||
|
||||
const INTEGRATION_NAME = 'postgres-listener'
|
||||
|
||||
async function handleNotificationCreated(
|
||||
notificationId: number,
|
||||
options: HookParameters<'astro:server:start'>
|
||||
) {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
try {
|
||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
||||
|
||||
const results = await sendNotification(notificationId, logger)
|
||||
|
||||
logger.info(
|
||||
`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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function postgresListener(): AstroIntegration {
|
||||
return {
|
||||
name: 'postgres-listener',
|
||||
hooks: {
|
||||
'astro:server:start': async (options) => {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
|
||||
try {
|
||||
logger.info('Starting PostgreSQL notification listener...')
|
||||
|
||||
pgClient = new Client({ connectionString: DATABASE_URL })
|
||||
|
||||
await pgClient.connect()
|
||||
logger.info('Connected to PostgreSQL for notifications')
|
||||
|
||||
await pgClient.query('LISTEN notification_created')
|
||||
logger.info('Listening for notification_created events')
|
||||
|
||||
pgClient.on('notification', (msg) => {
|
||||
if (msg.channel === 'notification_created') {
|
||||
const payload = zodParseJSON(z.object({ id: z.number().int().positive() }), msg.payload)
|
||||
if (!payload) {
|
||||
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: Don't await to avoid blocking
|
||||
void handleNotificationCreated(payload.id, options)
|
||||
}
|
||||
})
|
||||
|
||||
pgClient.on('error', (error) => {
|
||||
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
|
||||
})
|
||||
|
||||
pgClient.on('end', () => {
|
||||
logger.info('PostgreSQL client connection ended')
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
},
|
||||
|
||||
'astro:server:done': async ({ logger: originalLogger }) => {
|
||||
const logger = originalLogger.fork(INTEGRATION_NAME)
|
||||
|
||||
if (pgClient) {
|
||||
try {
|
||||
logger.info('Stopping PostgreSQL notification listener...')
|
||||
await pgClient.end()
|
||||
pgClient = null
|
||||
logger.info('PostgreSQL listener stopped')
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
165
web/src/lib/sendNotifications.ts
Normal file
165
web/src/lib/sendNotifications.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications'
|
||||
import { prisma } from './prisma'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
import { type NotificationPayload, sendPushNotification } from './webPush'
|
||||
|
||||
import type { AstroIntegrationLogger } from 'astro'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
export async function sendNotification(
|
||||
notificationId: number,
|
||||
logger: AstroIntegrationLogger | Console = console
|
||||
) {
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
aboutAccountStatusChange: true,
|
||||
aboutCommentStatusChange: true,
|
||||
aboutServiceVerificationStatusChange: true,
|
||||
aboutSuggestionStatusChange: true,
|
||||
aboutComment: {
|
||||
select: {
|
||||
id: true,
|
||||
author: { select: { id: true } },
|
||||
status: true,
|
||||
content: true,
|
||||
communityNote: true,
|
||||
parent: {
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionId: true,
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
status: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionMessage: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
suggestion: {
|
||||
select: {
|
||||
id: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutEvent: {
|
||||
select: {
|
||||
title: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutService: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
verificationStatus: true,
|
||||
},
|
||||
},
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
points: true,
|
||||
action: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!notification) {
|
||||
logger.error(`Notification with ID ${notificationId.toString()} not found`)
|
||||
return { success: 0, failure: 0, total: 0 }
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
notificationPayload
|
||||
)
|
||||
|
||||
// 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 {
|
||||
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
|
||||
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
|
||||
total: subscriptionResults.length,
|
||||
}
|
||||
}
|
||||
14
web/src/lib/serverEnvVariables.ts
Normal file
14
web/src/lib/serverEnvVariables.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
/** Only use when you can't import the variables from `astro:env/server` */
|
||||
// @ts-expect-error process.env actually exists
|
||||
const untypedServerEnvVariables = loadEnv(process.env.NODE_ENV, process.cwd(), '')
|
||||
|
||||
/** Only use when you can't import the variables from `astro:env/server` */
|
||||
export function getServerEnvVariable<T extends keyof typeof untypedServerEnvVariables>(
|
||||
name: T
|
||||
): NonNullable<(typeof untypedServerEnvVariables)[T]> {
|
||||
const value = untypedServerEnvVariables[name]
|
||||
if (!value) throw new Error(`${name} environment variable is not set`)
|
||||
return value
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
/* eslint-disable import/no-named-as-default-member */
|
||||
import { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } from 'astro:env/server'
|
||||
import webpush, { WebPushError } from 'web-push'
|
||||
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
const VAPID_PUBLIC_KEY = getServerEnvVariable('VAPID_PUBLIC_KEY')
|
||||
const VAPID_PRIVATE_KEY = getServerEnvVariable('VAPID_PRIVATE_KEY')
|
||||
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
|
||||
|
||||
// Configure VAPID keys
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
||||
|
||||
export { webpush }
|
||||
|
||||
export type NotificationAction = {
|
||||
action: string
|
||||
title: string
|
||||
icon?: string
|
||||
|
||||
url: string | null
|
||||
iconName?: string
|
||||
}
|
||||
|
||||
export type NotificationPayload = {
|
||||
title: string
|
||||
body: string | null
|
||||
actions: NotificationAction[]
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
subscription: {
|
||||
endpoint: string
|
||||
@@ -15,32 +35,13 @@ export async function sendPushNotification(
|
||||
auth: string
|
||||
}
|
||||
},
|
||||
data: {
|
||||
title: string
|
||||
body?: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
url?: string
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -13,17 +13,44 @@ const addZodPipe = (schema: ZodTypeAny, zodPipe?: ZodTypeAny) => {
|
||||
export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
|
||||
addZodPipe(z.number().or(z.string().nonempty()), zodPipe)
|
||||
|
||||
const cleanUrl = (input: unknown) => {
|
||||
if (typeof input !== 'string') return input
|
||||
const cleanInput = input.trim().replace(/\/$/, '')
|
||||
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
|
||||
}
|
||||
|
||||
export const zodUrlOptionalProtocol = z.preprocess(
|
||||
(input) => {
|
||||
if (typeof input !== 'string') return input
|
||||
const cleanInput = input.trim().replace(/\/$/, '')
|
||||
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
|
||||
},
|
||||
cleanUrl,
|
||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||
message: 'Invalid URL',
|
||||
})
|
||||
)
|
||||
|
||||
export const zodContactMethod = z.preprocess(
|
||||
(input) => {
|
||||
if (typeof input !== 'string') return input
|
||||
const cleanInput = input.trim()
|
||||
|
||||
if (/^([\d\s+\-_/()[\]*#.,]|ext|x){7,}$/i.test(cleanInput)) return `tel:${cleanInput}`
|
||||
|
||||
if (/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(cleanInput)) return `mailto:${cleanInput}`
|
||||
|
||||
return cleanUrl(cleanInput)
|
||||
},
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
/^((https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
|
||||
value
|
||||
),
|
||||
{
|
||||
message: 'Invalid contact method',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
|
||||
return <T>(input: T) =>
|
||||
typeof input !== 'string'
|
||||
@@ -49,6 +76,11 @@ export const stringListOfUrlsSchemaRequired = z.preprocess(
|
||||
z.array(zodUrlOptionalProtocol).min(1)
|
||||
)
|
||||
|
||||
export const stringListOfContactMethodsSchema = z.preprocess(
|
||||
stringToArrayFactory(/[\s,\n]+/),
|
||||
z.array(zodContactMethod).default([])
|
||||
)
|
||||
|
||||
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = [
|
||||
|
||||
@@ -76,6 +76,7 @@ To list a new service, it must fulfill these requirements:
|
||||
- Terms of service or FAQ document
|
||||
|
||||
For examples:
|
||||
|
||||
- Just a Telegram link or a criptocurrency itself is not a valid service.
|
||||
|
||||
### Suggestion Review Process
|
||||
@@ -147,7 +148,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 +161,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.
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { Icon } from 'astro-icon/components'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { groupBy, round, uniq } from 'lodash-es'
|
||||
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputText from '../../components/InputText.astro'
|
||||
import Button from '../../components/Button.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||
import { cn } from '../../lib/cn'
|
||||
@@ -15,7 +14,7 @@ if (!Astro.locals.user?.admin) {
|
||||
return Astro.redirect('/access-denied')
|
||||
}
|
||||
|
||||
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
|
||||
const testResult = Astro.getActionResult(actions.admin.notification.test)
|
||||
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
|
||||
|
||||
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
|
||||
@@ -104,7 +103,7 @@ const stats = [
|
||||
|
||||
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
|
||||
|
||||
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
|
||||
<form method="POST" action={actions.admin.notification.test} class="space-y-4">
|
||||
<InputTextArea
|
||||
label="Users"
|
||||
name="userNames"
|
||||
@@ -121,35 +120,6 @@ const stats = [
|
||||
error={testInputErrors.userNames}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Title"
|
||||
name="title"
|
||||
inputProps={{
|
||||
value: 'Test Notification',
|
||||
required: true,
|
||||
}}
|
||||
error={testInputErrors.title}
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Body"
|
||||
name="body"
|
||||
inputProps={{
|
||||
value: 'This is a test push notification from KYCNot.me',
|
||||
}}
|
||||
error={testInputErrors.body}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Action URL"
|
||||
name="url"
|
||||
inputProps={{
|
||||
placeholder: 'https://example.com/path',
|
||||
}}
|
||||
description="URL to open when the notification is clicked"
|
||||
error={testInputErrors.url}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
|
||||
<Button type="submit" label="Send" icon="ri:send-plane-line" color="danger" class="w-full" />
|
||||
</form>
|
||||
</MiniLayout>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,7 +23,7 @@ import Tooltip from '../../../../components/Tooltip.astro'
|
||||
import UserBadge from '../../../../components/UserBadge.astro'
|
||||
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
|
||||
import { formatContactMethod } from '../../../../constants/contactMethods'
|
||||
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
|
||||
import { currencies } from '../../../../constants/currencies'
|
||||
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
||||
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '../../../../constants/verificationStepStatus'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
|
||||
import { listFiles } from '../../../../lib/fileStorage'
|
||||
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
||||
import { pluralize } from '../../../../lib/pluralize'
|
||||
import { prisma } from '../../../../lib/prisma'
|
||||
@@ -87,9 +88,36 @@ 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',
|
||||
@@ -200,6 +228,12 @@ if (!service) {
|
||||
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',
|
||||
() =>
|
||||
@@ -426,7 +460,7 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
description: clarification.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.kycLevelClarification ?? 'NONE'}
|
||||
selectedValue={service.kycLevelClarification}
|
||||
iconSize="sm"
|
||||
cardSize="sm"
|
||||
error={serviceInputErrors.kycLevelClarification}
|
||||
@@ -1113,6 +1147,7 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
value: method.label,
|
||||
placeholder: contactMethodInfo.formattedValue,
|
||||
}}
|
||||
error={contactMethodUpdateInputErrors.label}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
@@ -1122,6 +1157,7 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
value: method.value,
|
||||
placeholder: 'e.g., mailto:contact@example.com or https://t.me/example',
|
||||
}}
|
||||
error={contactMethodUpdateInputErrors.value}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
|
||||
@@ -1142,12 +1178,13 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
|
||||
<InputText
|
||||
label="Value"
|
||||
description="With protocol (e.g., `mailto:contact@example.com` or `https://t.me/example`)"
|
||||
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
|
||||
name="value"
|
||||
inputProps={{
|
||||
required: true,
|
||||
placeholder: 'mailto:contact@example.com',
|
||||
placeholder: 'contact@example.com',
|
||||
}}
|
||||
error={contactMethodAddInputErrors.value}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
|
||||
@@ -1164,6 +1201,16 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
</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}`}>
|
||||
@@ -1176,5 +1223,73 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
))
|
||||
}
|
||||
</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={``}
|
||||
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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SOURCE_CODE_URL } from 'astro:env/server'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import { verificationStatuses } from '../../constants/verificationStatus'
|
||||
import { serviceVisibilities } from '../../constants/serviceVisibility'
|
||||
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
|
||||
|
||||
Access basic service data via our public API.
|
||||
|
||||
@@ -26,11 +27,11 @@ Fetches details for a single service by various lookup criteria.
|
||||
|
||||
### Request Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ----------- |
|
||||
| `id` | number | No* | Service ID |
|
||||
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
|
||||
| `serviceUrl` | string | No* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
|
||||
| Parameter | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | --------------------------------------------------------------------------- |
|
||||
| `id` | number | No\* | Service ID |
|
||||
| `slug` | string | No\* | Service URL slug (lowercase letters, numbers, and hyphens only) |
|
||||
| `serviceUrl` | string | No\* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
|
||||
|
||||
\* At least one of the marked parameters is required.
|
||||
|
||||
@@ -58,6 +59,12 @@ type ServiceResponse = {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
||||
kycLevelClarificationInfo: {
|
||||
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
categories: {
|
||||
name: string
|
||||
slug: string
|
||||
@@ -72,31 +79,46 @@ type ServiceResponse = {
|
||||
#### KYC Levels
|
||||
|
||||
<ul>
|
||||
{kycLevels.map((level) => (
|
||||
<li key={level.id}>
|
||||
<strong>{level.id}</strong>: {level.name} - {level.description}
|
||||
</li>
|
||||
))}
|
||||
{kycLevels.map((level) => (
|
||||
<li key={level.id}>
|
||||
<strong>{level.id}</strong>: {level.name} - {level.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
#### Verification Status
|
||||
|
||||
<ul>
|
||||
{verificationStatuses.map((status) => (
|
||||
<li key={status.value}>
|
||||
<strong>{status.value}</strong>: {status.description}
|
||||
</li>
|
||||
))}
|
||||
{verificationStatuses.map((status) => (
|
||||
<li key={status.value}>
|
||||
<strong>{status.value}</strong>: {status.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
#### Service Visibility
|
||||
|
||||
<ul>
|
||||
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
|
||||
<li key={visibility.value}>
|
||||
<strong>{visibility.value}</strong>: {visibility.longDescription}
|
||||
</li>
|
||||
))}
|
||||
{serviceVisibilities
|
||||
.filter(
|
||||
(visibility) =>
|
||||
visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED'
|
||||
)
|
||||
.map((visibility) => (
|
||||
<li key={visibility.value}>
|
||||
<strong>{visibility.value}</strong>: {visibility.longDescription}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
#### KYC Level Clarifications
|
||||
|
||||
<ul>
|
||||
{kycLevelClarifications.map((clarification) => (
|
||||
<li key={clarification.value}>
|
||||
<strong>{clarification.value}</strong>: {clarification.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
### Examples
|
||||
@@ -131,6 +153,11 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||
"name": "Guaranteed no KYC",
|
||||
"description": "Terms explicitly state KYC will never be requested."
|
||||
},
|
||||
"kycLevelClarification": "NONE",
|
||||
"kycLevelClarificationInfo": {
|
||||
"value": "NONE",
|
||||
"description": "No clarification needed."
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"name": "Exchange",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,6 +19,7 @@ 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'
|
||||
@@ -208,26 +209,42 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
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: 'min-h-24',
|
||||
placeholder: 'example1.com\nexample2.onion\nexample3.b32.i2p',
|
||||
class: 'md:min-h-20 min-h-24 h-full',
|
||||
required: true,
|
||||
}}
|
||||
class="row-span-2 flex flex-col self-stretch"
|
||||
class="flex flex-col self-stretch"
|
||||
error={inputErrors.allServiceUrls}
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line"
|
||||
name="tosUrls"
|
||||
label="Contact Methods"
|
||||
description={[
|
||||
'One per line.',
|
||||
`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`,
|
||||
].join('\n')}
|
||||
name="contactMethods"
|
||||
inputProps={{
|
||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||
class: 'md:min-h-24',
|
||||
required: true,
|
||||
placeholder: 'contact@example.com\nt.me/example\n+123 456 7890',
|
||||
class: 'h-full',
|
||||
}}
|
||||
error={inputErrors.tosUrls}
|
||||
class="flex flex-col self-stretch"
|
||||
error={inputErrors.contactMethods}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line"
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
placeholder: 'example.com/tos',
|
||||
required: true,
|
||||
class: 'min-h-10',
|
||||
}}
|
||||
error={inputErrors.tosUrls}
|
||||
/>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevel"
|
||||
label="KYC Level"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { VerificationStepStatus, EventType, KycLevelClarification } from '@prisma/client'
|
||||
import { VerificationStepStatus, EventType } from '@prisma/client'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
@@ -1295,9 +1295,17 @@ const activeAlertOrWarningEvents = service.events.filter(
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="verification">
|
||||
Verification
|
||||
</h2>
|
||||
<div class="border-day-500 mt-6 mb-3 flex items-center justify-between border-b" id="verification">
|
||||
<h2 class="font-title text-day-100 text-lg font-bold">Verification</h2>
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-day-500 hover:text-day-200 inline-flex items-center gap-1 text-xs leading-none transition-colors hover:underline"
|
||||
>
|
||||
Learn more
|
||||
<Icon name="ri:arrow-right-s-line" class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class={cn('mb-6 rounded-lg p-4', statusInfo.classNames.containerBg)}>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex-1">
|
||||
|
||||
Reference in New Issue
Block a user