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:
|
volumes:
|
||||||
- database:/var/lib/postgresql/data:z
|
- database:/var/lib/postgresql/data:z
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
env_file:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
- .env
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -20,19 +18,17 @@ services:
|
|||||||
pyworker:
|
pyworker:
|
||||||
build:
|
build:
|
||||||
context: ./pyworker
|
context: ./pyworker
|
||||||
|
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
env_file:
|
||||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
- .env
|
||||||
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
|
|
||||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
|
|
||||||
|
|
||||||
crawl4ai:
|
crawl4ai:
|
||||||
image: unclecode/crawl4ai:basic-amd64
|
image: unclecode/crawl4ai:basic-amd64
|
||||||
expose:
|
expose:
|
||||||
- "11235"
|
- "11235"
|
||||||
environment:
|
env_file:
|
||||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
|
- .env
|
||||||
MAX_CONCURRENT_TASKS: 10
|
|
||||||
volumes:
|
volumes:
|
||||||
- /dev/shm:/dev/shm
|
- /dev/shm:/dev/shm
|
||||||
deploy:
|
deploy:
|
||||||
@@ -53,15 +49,9 @@ services:
|
|||||||
|
|
||||||
astro:
|
astro:
|
||||||
build:
|
build:
|
||||||
context: ./web
|
dockerfile: web/Dockerfile
|
||||||
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
|
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
|
||||||
restart: unless-stopped
|
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_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
36
justfile
36
justfile
@@ -51,42 +51,6 @@ import-db file="":
|
|||||||
fi
|
fi
|
||||||
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 ==="
|
echo "=== STEP 1: PREPARING DATABASE ==="
|
||||||
# Drop all connections to the 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
|
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 = """
|
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
||||||
You will be given a list of user comments to a service.
|
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.
|
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.
|
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:
|
You must format your response as a valid JSON object with the following structure:
|
||||||
|
|
||||||
interface CommentSummary {
|
interface CommentSummary {
|
||||||
summary: string;
|
summary: string; // Concise, 100 words max
|
||||||
sentiment: 'positive'|'negative'|'neutral';
|
sentiment: 'positive'|'negative'|'neutral';
|
||||||
whatUsersLike: string[]; // Concise, 2-3 words, max 4
|
whatUsersLike: string[]; // Concise, 2-3 words max
|
||||||
whatUsersDislike: string[]; // Concise, 2-3 words, max 4
|
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.
|
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"
|
REDIS_URL="redis://localhost:6379"
|
||||||
SOURCE_CODE_URL="https://github.com"
|
SOURCE_CODE_URL="https://github.com"
|
||||||
DATABASE_UI_URL="http://localhost:5555"
|
DATABASE_UI_URL="http://localhost:5555"
|
||||||
SITE_URL="https://localhost:4321"
|
SITE_URL="http://localhost:4321"
|
||||||
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
|
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
|
||||||
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
||||||
RELEASE_NUMBER=123
|
RELEASE_NUMBER=123
|
||||||
RELEASE_DATE="2025-05-23T19:00:00.000Z"
|
RELEASE_DATE="2025-05-23T19:00:00.000Z"
|
||||||
# Generated with `npx web-push generate-vapid-keys`
|
# Generated with `npx web-push generate-vapid-keys`
|
||||||
VAPID_PUBLIC_KEY="<vapid-public-key-placeholder>"
|
VAPID_PUBLIC_KEY="BPmJbRXzG9zT181vyg1GlpyV8qu7rjVjfg6vkkOgtqeTZECyt6lR4MuzmlarEHSBF6gPpc77ZA0_tTVtmYh65iM"
|
||||||
VAPID_PRIVATE_KEY="<vapid-private-key-placeholder>"
|
VAPID_PRIVATE_KEY="eN_S2SMXDB2hpwVXbgDkDrPIPMqirllZaJcUgYTt9w0"
|
||||||
VAPID_SUBJECT="mailto:no-reply@kycnot.me"
|
VAPID_SUBJECT="mailto:no-reply@kycnot.me"
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
FROM node:lts AS runtime
|
FROM node:lts AS runtime
|
||||||
WORKDIR /app
|
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
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY web/ .
|
||||||
|
|
||||||
ARG ASTRO_BUILD_MODE=production
|
ARG ASTRO_BUILD_MODE=production
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
|
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
|
||||||
|
|
||||||
@@ -20,7 +23,7 @@ ENV PORT=4321
|
|||||||
EXPOSE 4321
|
EXPOSE 4321
|
||||||
|
|
||||||
# Add knm-migrate command script and make it executable
|
# 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
|
RUN chmod +x /usr/local/bin/knm-migrate
|
||||||
|
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import sitemap from '@astrojs/sitemap'
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { defineConfig, envField } from 'astro/config'
|
import { defineConfig, envField } from 'astro/config'
|
||||||
import icon from 'astro-icon'
|
import icon from 'astro-icon'
|
||||||
import { loadEnv } from 'vite'
|
|
||||||
|
|
||||||
// @ts-expect-error process.env actually exists
|
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||||
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
|
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||||
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
|
|
||||||
|
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: SITE_URL,
|
site: SITE_URL,
|
||||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
|||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
|
postgresListener(),
|
||||||
icon(),
|
icon(),
|
||||||
mdx(),
|
mdx(),
|
||||||
sitemap({
|
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": {
|
"dependencies": {
|
||||||
"@astrojs/check": "0.9.4",
|
"@astrojs/check": "0.9.4",
|
||||||
"@astrojs/db": "0.14.14",
|
"@astrojs/db": "0.15.0",
|
||||||
"@astrojs/mdx": "4.2.6",
|
"@astrojs/mdx": "4.3.0",
|
||||||
"@astrojs/node": "9.2.1",
|
"@astrojs/node": "9.2.2",
|
||||||
"@astrojs/sitemap": "3.4.0",
|
"@astrojs/sitemap": "3.4.1",
|
||||||
"@fontsource-variable/space-grotesk": "5.2.7",
|
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||||
"@fontsource/inter": "5.2.5",
|
"@fontsource/inter": "5.2.5",
|
||||||
"@fontsource/space-grotesk": "5.2.7",
|
"@fontsource/space-grotesk": "5.2.8",
|
||||||
"@prisma/client": "6.8.2",
|
"@prisma/client": "6.9.0",
|
||||||
"@tailwindcss/vite": "4.1.7",
|
"@tailwindcss/vite": "4.1.8",
|
||||||
"@types/mime-types": "2.1.4",
|
"@types/mime-types": "3.0.0",
|
||||||
|
"@types/pg": "8.15.4",
|
||||||
"@vercel/og": "0.6.8",
|
"@vercel/og": "0.6.8",
|
||||||
"astro": "5.7.13",
|
"astro": "5.9.0",
|
||||||
"astro-loading-indicator": "0.7.0",
|
"astro-loading-indicator": "0.7.0",
|
||||||
"astro-remote": "0.3.4",
|
"astro-remote": "0.3.4",
|
||||||
"astro-seo-schema": "5.0.0",
|
"astro-seo-schema": "5.0.0",
|
||||||
@@ -42,58 +43,59 @@
|
|||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"htmx.org": "1.9.12",
|
"htmx.org": "1.9.12",
|
||||||
"javascript-time-ago": "2.5.11",
|
"javascript-time-ago": "2.5.11",
|
||||||
"libphonenumber-js": "1.12.8",
|
"libphonenumber-js": "1.12.9",
|
||||||
"lodash-es": "4.17.21",
|
"lodash-es": "4.17.21",
|
||||||
"mime-types": "3.0.1",
|
"mime-types": "3.0.1",
|
||||||
"object-to-formdata": "4.5.1",
|
"object-to-formdata": "4.5.1",
|
||||||
|
"pg": "8.16.0",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"redis": "5.0.1",
|
"redis": "5.5.6",
|
||||||
"schema-dts": "1.1.5",
|
"schema-dts": "1.1.5",
|
||||||
"seedrandom": "3.0.5",
|
"seedrandom": "3.0.5",
|
||||||
"sharp": "0.34.1",
|
"sharp": "0.34.2",
|
||||||
"slugify": "1.6.6",
|
"slugify": "1.6.6",
|
||||||
"tailwind-merge": "3.3.0",
|
"tailwind-merge": "3.3.0",
|
||||||
"tailwind-variants": "1.0.0",
|
"tailwind-variants": "1.0.0",
|
||||||
"tailwindcss": "4.1.7",
|
"tailwindcss": "4.1.8",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"unique-username-generator": "1.4.0",
|
"unique-username-generator": "1.4.0",
|
||||||
"web-push": "3.6.7",
|
"web-push": "3.6.7",
|
||||||
"zod-form-data": "2.0.7"
|
"zod-form-data": "2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.27.0",
|
"@eslint/js": "9.28.0",
|
||||||
"@faker-js/faker": "9.8.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/mdi": "1.2.3",
|
||||||
"@iconify-json/ri": "1.2.5",
|
"@iconify-json/ri": "1.2.5",
|
||||||
"@stylistic/eslint-plugin": "4.2.0",
|
"@stylistic/eslint-plugin": "4.4.1",
|
||||||
"@tailwindcss/forms": "0.5.10",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.16",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@types/eslint__js": "9.14.0",
|
"@types/eslint__js": "9.14.0",
|
||||||
"@types/lodash-es": "4.17.12",
|
"@types/lodash-es": "4.17.12",
|
||||||
"@types/qrcode": "1.5.5",
|
"@types/qrcode": "1.5.5",
|
||||||
"@types/react": "19.1.4",
|
"@types/react": "19.1.6",
|
||||||
"@types/seedrandom": "3.0.8",
|
"@types/seedrandom": "3.0.8",
|
||||||
"@types/web-push": "3.6.4",
|
"@types/web-push": "3.6.4",
|
||||||
"@typescript-eslint/parser": "8.32.1",
|
"@typescript-eslint/parser": "8.33.1",
|
||||||
"astro-icon": "1.1.5",
|
"astro-icon": "1.1.5",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"eslint": "9.27.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-import-resolver-typescript": "4.3.5",
|
"eslint-import-resolver-typescript": "4.4.3",
|
||||||
"eslint-plugin-astro": "1.3.1",
|
"eslint-plugin-astro": "1.3.1",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"globals": "16.1.0",
|
"globals": "16.2.0",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"prettier-plugin-astro": "0.14.1",
|
"prettier-plugin-astro": "0.14.1",
|
||||||
"prettier-plugin-tailwindcss": "0.6.11",
|
"prettier-plugin-tailwindcss": "0.6.12",
|
||||||
"prisma": "6.8.2",
|
"prisma": "6.9.0",
|
||||||
"prisma-json-types-generator": "3.4.1",
|
"prisma-json-types-generator": "3.4.2",
|
||||||
"tailwind-htmx": "0.1.2",
|
"tailwind-htmx": "0.1.2",
|
||||||
"ts-essentials": "10.0.4",
|
"ts-essentials": "10.0.4",
|
||||||
"ts-toolbelt": "9.6.0",
|
"ts-toolbelt": "9.6.0",
|
||||||
"tsx": "4.19.4",
|
"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
|
PENDING
|
||||||
APPROVED
|
APPROVED
|
||||||
REJECTED
|
REJECTED
|
||||||
|
WITHDRAWN
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -128,6 +129,7 @@ enum AccountStatusChange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationType {
|
enum NotificationType {
|
||||||
|
TEST
|
||||||
COMMENT_STATUS_CHANGE
|
COMMENT_STATUS_CHANGE
|
||||||
REPLY_COMMENT_CREATED
|
REPLY_COMMENT_CREATED
|
||||||
COMMUNITY_NOTE_ADDED
|
COMMUNITY_NOTE_ADDED
|
||||||
@@ -345,7 +347,7 @@ model Service {
|
|||||||
description String
|
description String
|
||||||
categories Category[] @relation("ServiceToCategory")
|
categories Category[] @relation("ServiceToCategory")
|
||||||
kycLevel Int @default(4)
|
kycLevel Int @default(4)
|
||||||
kycLevelClarification KycLevelClarification?
|
kycLevelClarification KycLevelClarification @default(NONE)
|
||||||
overallScore Int @default(0)
|
overallScore Int @default(0)
|
||||||
privacyScore Int @default(0)
|
privacyScore Int @default(0)
|
||||||
trustScore Int @default(0)
|
trustScore Int @default(0)
|
||||||
@@ -391,7 +393,6 @@ model Service {
|
|||||||
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
|
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
|
||||||
Notification Notification[]
|
Notification Notification[]
|
||||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||||
kycLevelDetailsId Int?
|
|
||||||
|
|
||||||
@@index([listedAt])
|
@@index([listedAt])
|
||||||
@@index([overallScore])
|
@@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
|
// @ts-expect-error
|
||||||
const typedSelf = self
|
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) => {
|
typedSelf.addEventListener('install', (event) => {
|
||||||
console.log('Service Worker installing')
|
console.log('Service Worker installing')
|
||||||
@@ -22,36 +26,59 @@ typedSelf.addEventListener('push', (event) => {
|
|||||||
console.log('Push event received:', event)
|
console.log('Push event received:', event)
|
||||||
|
|
||||||
if (!event.data) {
|
if (!event.data) {
|
||||||
console.log('Push event but no data')
|
console.error('Push event but no data')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let notificationData
|
let title = 'New Notification'
|
||||||
try {
|
/** @type {CustomNotificationOptions} */
|
||||||
notificationData = event.data.json()
|
let options = {
|
||||||
} catch (error) {
|
body: 'You have a new notification',
|
||||||
console.error('Error parsing push data:', error)
|
lang: 'en-US',
|
||||||
notificationData = {
|
icon: '/favicon.svg',
|
||||||
title: 'New Notification',
|
badge: '/favicon.svg',
|
||||||
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 || {},
|
|
||||||
requireInteraction: false,
|
requireInteraction: false,
|
||||||
silent: 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) => {
|
typedSelf.addEventListener('notificationclick', (event) => {
|
||||||
@@ -59,7 +86,11 @@ typedSelf.addEventListener('notificationclick', (event) => {
|
|||||||
|
|
||||||
event.notification.close()
|
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(
|
event.waitUntil(
|
||||||
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||||
|
|||||||
@@ -1,80 +1,33 @@
|
|||||||
import { z } from 'astro/zod'
|
import { z } from 'astro/zod'
|
||||||
import { sumBy } from 'lodash-es'
|
|
||||||
|
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { sendPushNotification } from '../../lib/webPush'
|
|
||||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||||
|
|
||||||
export const adminNotificationActions = {
|
export const adminNotificationActions = {
|
||||||
webPush: {
|
test: defineProtectedAction({
|
||||||
test: defineProtectedAction({
|
accept: 'form',
|
||||||
accept: 'form',
|
permissions: 'admin',
|
||||||
permissions: 'admin',
|
input: z.object({
|
||||||
input: z.object({
|
userNames: stringListOfSlugsSchemaRequired,
|
||||||
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,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
},
|
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 slugify from 'slugify'
|
||||||
|
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../../lib/fileStorage'
|
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { separateServiceUrlsByType } from '../../lib/urls'
|
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||||
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
|
import {
|
||||||
|
imageFileSchema,
|
||||||
|
stringListOfUrlsSchemaRequired,
|
||||||
|
zodCohercedNumber,
|
||||||
|
zodContactMethod,
|
||||||
|
} from '../../lib/zodUtils'
|
||||||
|
|
||||||
const addSlugIfMissing = <
|
const addSlugIfMissing = <
|
||||||
T extends {
|
T extends {
|
||||||
@@ -69,6 +74,15 @@ const updateServiceInputSchema = serviceSchemaBase
|
|||||||
})
|
})
|
||||||
.transform(addSlugIfMissing)
|
.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 = {
|
export const adminServiceActions = {
|
||||||
create: defineProtectedAction({
|
create: defineProtectedAction({
|
||||||
accept: 'form',
|
accept: 'form',
|
||||||
@@ -107,7 +121,7 @@ export const adminServiceActions = {
|
|||||||
onionUrls,
|
onionUrls,
|
||||||
i2pUrls,
|
i2pUrls,
|
||||||
kycLevel: input.kycLevel,
|
kycLevel: input.kycLevel,
|
||||||
kycLevelClarification: input.kycLevelClarification,
|
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
||||||
verificationStatus: input.verificationStatus,
|
verificationStatus: input.verificationStatus,
|
||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
@@ -225,7 +239,7 @@ export const adminServiceActions = {
|
|||||||
onionUrls,
|
onionUrls,
|
||||||
i2pUrls,
|
i2pUrls,
|
||||||
kycLevel: input.kycLevel,
|
kycLevel: input.kycLevel,
|
||||||
kycLevelClarification: input.kycLevelClarification,
|
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
||||||
verificationStatus: input.verificationStatus,
|
verificationStatus: input.verificationStatus,
|
||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
@@ -272,7 +286,7 @@ export const adminServiceActions = {
|
|||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: z.object({
|
input: z.object({
|
||||||
label: z.string().min(1).max(50).nullable(),
|
label: z.string().min(1).max(50).nullable(),
|
||||||
value: z.string().url(),
|
value: zodContactMethod,
|
||||||
serviceId: z.number().int().positive(),
|
serviceId: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
handler: async (input) => {
|
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 { ActionError } from 'astro:actions'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../../lib/fileStorage'
|
import { saveFileLocally } from '../../lib/fileStorage'
|
||||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
|
|
||||||
const prisma = prismaInstance as PrismaClient
|
|
||||||
|
|
||||||
const selectUserReturnFields = {
|
const selectUserReturnFields = {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from 'astro/zod'
|
|||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { pick } from 'lodash-es'
|
import { pick } from 'lodash-es'
|
||||||
|
|
||||||
|
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
|
||||||
import { getKycLevelInfo } from '../../constants/kycLevels'
|
import { getKycLevelInfo } from '../../constants/kycLevels'
|
||||||
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
|
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
@@ -50,6 +51,7 @@ export const apiServiceActions = {
|
|||||||
slug: true,
|
slug: true,
|
||||||
description: true,
|
description: true,
|
||||||
kycLevel: true,
|
kycLevel: true,
|
||||||
|
kycLevelClarification: true,
|
||||||
verificationStatus: true,
|
verificationStatus: true,
|
||||||
categories: {
|
categories: {
|
||||||
select: {
|
select: {
|
||||||
@@ -130,6 +132,12 @@ export const apiServiceActions = {
|
|||||||
verifiedAt: service.verifiedAt,
|
verifiedAt: service.verifiedAt,
|
||||||
kycLevel: service.kycLevel,
|
kycLevel: service.kycLevel,
|
||||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||||
|
kycLevelClarification: service.kycLevelClarification,
|
||||||
|
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||||
|
'value',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
]),
|
||||||
categories: service.categories,
|
categories: service.categories,
|
||||||
listedAt: service.listedAt,
|
listedAt: service.listedAt,
|
||||||
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
|
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
|
||||||
|
|||||||
@@ -345,10 +345,11 @@ export const commentActions = {
|
|||||||
'order-id-status',
|
'order-id-status',
|
||||||
'kyc-requested',
|
'kyc-requested',
|
||||||
'funds-blocked',
|
'funds-blocked',
|
||||||
|
'toggle-rating-active',
|
||||||
]),
|
]),
|
||||||
value: z.union([
|
value: z.union([
|
||||||
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
||||||
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
|
z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
|
||||||
z.boolean(),
|
z.boolean(),
|
||||||
z.string(),
|
z.string(),
|
||||||
]),
|
]),
|
||||||
@@ -411,7 +412,7 @@ export const commentActions = {
|
|||||||
updateData.privateContext = input.value as string
|
updateData.privateContext = input.value as string
|
||||||
break
|
break
|
||||||
case 'order-id-status':
|
case 'order-id-status':
|
||||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
|
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
|
||||||
break
|
break
|
||||||
case 'kyc-requested':
|
case 'kyc-requested':
|
||||||
updateData.kycRequested = !!input.value
|
updateData.kycRequested = !!input.value
|
||||||
@@ -419,6 +420,9 @@ export const commentActions = {
|
|||||||
case 'funds-blocked':
|
case 'funds-blocked':
|
||||||
updateData.fundsBlocked = !!input.value
|
updateData.fundsBlocked = !!input.value
|
||||||
break
|
break
|
||||||
|
case 'toggle-rating-active':
|
||||||
|
updateData.ratingActive = !!input.value
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the comment
|
// Update the comment
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
|||||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { separateServiceUrlsByType } from '../lib/urls'
|
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'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ export const serviceSuggestionActions = {
|
|||||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
|
contactMethods: stringListOfContactMethodsSchema,
|
||||||
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
||||||
kycLevelClarification: z.nativeEnum(KycLevelClarification),
|
kycLevelClarification: z.nativeEnum(KycLevelClarification),
|
||||||
attributes: z.array(z.coerce.number().int().positive()),
|
attributes: z.array(z.coerce.number().int().positive()),
|
||||||
@@ -239,6 +245,11 @@ export const serviceSuggestionActions = {
|
|||||||
attributeId: id,
|
attributeId: id,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
contactMethods: {
|
||||||
|
create: input.contactMethods.map((value) => ({
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: serviceSelect,
|
select: serviceSelect,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { tv, type VariantProps } from 'tailwind-variants'
|
import { tv, type VariantProps } from 'tailwind-variants'
|
||||||
|
|
||||||
|
import type { AstroChildren } from '../lib/astro'
|
||||||
import type { Polymorphic } from 'astro/types'
|
import type { Polymorphic } from 'astro/types'
|
||||||
|
|
||||||
const badge = tv({
|
const badge = tv({
|
||||||
slots: {
|
slots: {
|
||||||
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
|
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',
|
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: {
|
variants: {
|
||||||
color: {
|
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> & {
|
VariantProps<typeof badge> & {
|
||||||
as: Tag
|
as: Tag
|
||||||
icon?: string
|
icon?: string
|
||||||
endIcon?: string
|
endIcon?: string
|
||||||
text: string
|
text?: Text
|
||||||
inlineIcon?: boolean
|
inlineIcon?: boolean
|
||||||
classNames?: {
|
classNames?: {
|
||||||
icon?: string
|
icon?: string
|
||||||
text?: string
|
text?: string
|
||||||
endIcon?: 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} />
|
<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 && (
|
!!endIconName && (
|
||||||
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
|
<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) => {
|
comment.author.serviceAffiliations.map((affiliation) => {
|
||||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||||
return (
|
return (
|
||||||
<BadgeSmall
|
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
|
||||||
icon={roleInfo.icon}
|
{roleInfo.label} at
|
||||||
color={roleInfo.color}
|
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
||||||
text={`${roleInfo.label} at ${affiliation.service.name}`}
|
</BadgeSmall>
|
||||||
variant="faded"
|
|
||||||
inlineIcon
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
privateContext: true
|
privateContext: true
|
||||||
orderId: true
|
orderId: true
|
||||||
orderIdStatus: true
|
orderIdStatus: true
|
||||||
|
rating: true
|
||||||
|
ratingActive: true
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
<div
|
<div
|
||||||
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
|
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
|
<button
|
||||||
class={cn(
|
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'
|
comment.status === 'REJECTED'
|
||||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||||
: 'bg-night-700 hover:bg-red-500/20 hover: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-comment-id={comment.id}
|
||||||
data-user-id={user.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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={cn(
|
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.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',
|
|
||||||
comment.status === 'VERIFIED'
|
comment.status === 'VERIFIED'
|
||||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||||
: 'bg-night-700 hover:bg-green-500/20 hover: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-comment-id={comment.id}
|
||||||
data-user-id={user.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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={cn(
|
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'
|
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
|
||||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||||
: 'bg-night-700 hover:bg-blue-500/20 hover: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-comment-id={comment.id}
|
||||||
data-user-id={user.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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={cn(
|
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
|
comment.kycRequested
|
||||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||||
: 'bg-night-700 hover:bg-red-500/20 hover: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-comment-id={comment.id}
|
||||||
data-user-id={user.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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={cn(
|
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
|
comment.fundsBlocked
|
||||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||||
: 'bg-night-700 hover:bg-red-500/20 hover: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-comment-id={comment.id}
|
||||||
data-user-id={user.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>
|
</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>
|
||||||
|
|
||||||
<div class="mt-2 space-y-1.5">
|
<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">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
class={cn(
|
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'
|
comment.orderIdStatus === 'APPROVED'
|
||||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||||
: 'bg-night-700 hover:bg-green-500/20 hover: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-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
Approve
|
<Icon name="ri:check-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>Approve</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class={cn(
|
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'
|
comment.orderIdStatus === 'REJECTED'
|
||||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||||
: 'bg-night-700 hover:bg-red-500/20 hover: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-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
Reject
|
<Icon name="ri:close-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>Reject</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class={cn(
|
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'
|
comment.orderIdStatus === 'PENDING'
|
||||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||||
: 'bg-night-700 hover:bg-blue-500/20 hover: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-comment-id={comment.id}
|
||||||
data-user-id={user.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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
action === 'suspicious' ||
|
action === 'suspicious' ||
|
||||||
action === 'requires-admin-review' ||
|
action === 'requires-admin-review' ||
|
||||||
action === 'kyc-requested' ||
|
action === 'kyc-requested' ||
|
||||||
action === 'funds-blocked'
|
action === 'funds-blocked' ||
|
||||||
|
action === 'toggle-rating-active'
|
||||||
? value === 'true'
|
? value === 'true'
|
||||||
: value,
|
: value,
|
||||||
})
|
})
|
||||||
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
if (action === 'status') {
|
if (action === 'status') {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else if (action === 'suspicious') {
|
} 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('bg-yellow-500/20')
|
||||||
btn.classList.toggle('text-yellow-400')
|
btn.classList.toggle('text-yellow-400')
|
||||||
btn.classList.toggle('border-yellow-500/30')
|
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.classList.toggle('bg-night-700')
|
||||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||||
} else if (action === 'requires-admin-review') {
|
} 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('bg-purple-500/20')
|
||||||
btn.classList.toggle('text-purple-400')
|
btn.classList.toggle('text-purple-400')
|
||||||
btn.classList.toggle('border-purple-500/30')
|
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
|
// Refresh to show updated order ID status
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else if (action === 'kyc-requested') {
|
} 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('bg-red-500/20')
|
||||||
btn.classList.toggle('text-red-400')
|
btn.classList.toggle('text-red-400')
|
||||||
btn.classList.toggle('border-red-500/30')
|
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.classList.toggle('bg-night-700')
|
||||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||||
} else if (action === 'funds-blocked') {
|
} 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('bg-red-500/20')
|
||||||
btn.classList.toggle('text-red-400')
|
btn.classList.toggle('text-red-400')
|
||||||
btn.classList.toggle('border-red-500/30')
|
btn.classList.toggle('border-red-500/30')
|
||||||
btn.classList.toggle('border')
|
btn.classList.toggle('border')
|
||||||
btn.classList.toggle('bg-night-700')
|
btn.classList.toggle('bg-night-700')
|
||||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
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 {
|
} else {
|
||||||
console.error('Error moderating comment:', error)
|
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')}>
|
<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')}>
|
<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]" />}
|
{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 && '*'}
|
{required && '*'}
|
||||||
</legend>
|
</legend>
|
||||||
{!!descriptionLabel && (
|
{!!descriptionLabel && (
|
||||||
|
|||||||
@@ -56,7 +56,15 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
|||||||
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
|
) : 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">
|
<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" />
|
<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>
|
</div>
|
||||||
) : wasRecentlyAdded ? (
|
) : wasRecentlyAdded ? (
|
||||||
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
<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} />
|
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
||||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||||
caution.
|
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>
|
</div>
|
||||||
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
|
) : 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">
|
<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" />
|
<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>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
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 ContactMethodInfo<T extends string | null | undefined = string> = {
|
||||||
type: T
|
type: T
|
||||||
label: string
|
label: string
|
||||||
@@ -10,6 +13,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
|
|||||||
matcher: RegExp
|
matcher: RegExp
|
||||||
formatter: (match: RegExpMatchArray) => string | null
|
formatter: (match: RegExpMatchArray) => string | null
|
||||||
icon: string
|
icon: string
|
||||||
|
urlType: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -22,9 +26,10 @@ export const {
|
|||||||
(type): ContactMethodInfo<typeof type> => ({
|
(type): ContactMethodInfo<typeof type> => ({
|
||||||
type,
|
type,
|
||||||
label: type ? transformCase(type, 'title') : String(type),
|
label: type ? transformCase(type, 'title') : String(type),
|
||||||
icon: 'ri:shield-fill',
|
icon: 'ri:link',
|
||||||
matcher: /(.*)/,
|
matcher: /(.*)/,
|
||||||
formatter: ([, value]) => value ?? String(value),
|
formatter: ([, value]) => value ?? String(value),
|
||||||
|
urlType: type ?? 'unknown',
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -33,24 +38,37 @@ export const {
|
|||||||
matcher: /mailto:(.+)/,
|
matcher: /mailto:(.+)/,
|
||||||
formatter: ([, value]) => value ?? 'Email',
|
formatter: ([, value]) => value ?? 'Email',
|
||||||
icon: 'ri:mail-line',
|
icon: 'ri:mail-line',
|
||||||
|
urlType: 'email',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'telephone',
|
type: 'telephone',
|
||||||
label: 'Telephone',
|
label: 'Telephone',
|
||||||
matcher: /tel:(.+)/,
|
matcher: /tel:(.+)/,
|
||||||
formatter: ([, value]) => {
|
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',
|
icon: 'ri:phone-line',
|
||||||
|
urlType: 'telephone',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'whatsapp',
|
type: 'whatsapp',
|
||||||
label: 'WhatsApp',
|
label: 'WhatsApp',
|
||||||
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
|
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
|
||||||
formatter: ([, value]) => {
|
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',
|
icon: 'ri:whatsapp-line',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'telegram',
|
type: 'telegram',
|
||||||
@@ -58,6 +76,7 @@ export const {
|
|||||||
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
|
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
|
||||||
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
|
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
|
||||||
icon: 'ri:telegram-line',
|
icon: 'ri:telegram-line',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'linkedin',
|
type: 'linkedin',
|
||||||
@@ -65,6 +84,7 @@ export const {
|
|||||||
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
|
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
|
||||||
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
|
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
|
||||||
icon: 'ri:linkedin-box-line',
|
icon: 'ri:linkedin-box-line',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'x',
|
type: 'x',
|
||||||
@@ -72,6 +92,7 @@ export const {
|
|||||||
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
|
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
|
||||||
formatter: ([, value]) => (value ? `@${value}` : 'X'),
|
formatter: ([, value]) => (value ? `@${value}` : 'X'),
|
||||||
icon: 'ri:twitter-x-line',
|
icon: 'ri:twitter-x-line',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'instagram',
|
type: 'instagram',
|
||||||
@@ -79,6 +100,7 @@ export const {
|
|||||||
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
|
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
|
||||||
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
|
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
|
||||||
icon: 'ri:instagram-line',
|
icon: 'ri:instagram-line',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'matrix',
|
type: 'matrix',
|
||||||
@@ -86,6 +108,7 @@ export const {
|
|||||||
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||||
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
||||||
icon: 'ri:hashtag',
|
icon: 'ri:hashtag',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'bitcointalk',
|
type: 'bitcointalk',
|
||||||
@@ -93,6 +116,7 @@ export const {
|
|||||||
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
|
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||||
formatter: () => 'BitcoinTalk',
|
formatter: () => 'BitcoinTalk',
|
||||||
icon: 'ri:btc-line',
|
icon: 'ri:btc-line',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'simplex',
|
type: 'simplex',
|
||||||
@@ -100,6 +124,7 @@ export const {
|
|||||||
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
|
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
|
||||||
formatter: () => 'SimpleX Chat',
|
formatter: () => 'SimpleX Chat',
|
||||||
icon: 'simplex',
|
icon: 'simplex',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'nostr',
|
type: 'nostr',
|
||||||
@@ -107,6 +132,7 @@ export const {
|
|||||||
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
|
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
|
||||||
formatter: () => 'Nostr',
|
formatter: () => 'Nostr',
|
||||||
icon: 'nostr',
|
icon: 'nostr',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Website must go last because it's a catch-all
|
// 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]+)/,
|
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||||
formatter: ([, value]) => value ?? 'Website',
|
formatter: ([, value]) => value ?? 'Website',
|
||||||
icon: 'ri:global-line',
|
icon: 'ri:global-line',
|
||||||
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
] as const satisfies ContactMethodInfo[]
|
] as const satisfies ContactMethodInfo[]
|
||||||
)
|
)
|
||||||
@@ -135,3 +162,38 @@ export function formatContactMethod(url: string) {
|
|||||||
|
|
||||||
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
|
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',
|
icon: 'ri:notification-line',
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
id: 'TEST',
|
||||||
|
label: 'Test notification',
|
||||||
|
icon: 'ri:flask-line',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'COMMENT_STATUS_CHANGE',
|
id: 'COMMENT_STATUS_CHANGE',
|
||||||
label: 'Comment status changed',
|
label: 'Comment status changed',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type ServiceSuggestionStatusInfo<T extends string | null | undefined = string> =
|
|||||||
slug: string
|
slug: string
|
||||||
label: string
|
label: string
|
||||||
icon: string
|
icon: string
|
||||||
iconClass: string
|
color: string
|
||||||
default: boolean
|
default: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export const {
|
|||||||
slug: value ? value.toLowerCase() : '',
|
slug: value ? value.toLowerCase() : '',
|
||||||
label: value ? transformCase(value, 'title') : String(value),
|
label: value ? transformCase(value, 'title') : String(value),
|
||||||
icon: 'ri:question-line',
|
icon: 'ri:question-line',
|
||||||
iconClass: 'text-current/60',
|
color: 'gray',
|
||||||
default: false,
|
default: false,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -37,7 +37,7 @@ export const {
|
|||||||
slug: 'pending',
|
slug: 'pending',
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
icon: 'ri:time-line',
|
icon: 'ri:time-line',
|
||||||
iconClass: 'text-yellow-400',
|
color: 'yellow',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -45,7 +45,7 @@ export const {
|
|||||||
slug: 'approved',
|
slug: 'approved',
|
||||||
label: 'Approved',
|
label: 'Approved',
|
||||||
icon: 'ri:check-line',
|
icon: 'ri:check-line',
|
||||||
iconClass: 'text-green-400',
|
color: 'green',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,7 +53,7 @@ export const {
|
|||||||
slug: 'rejected',
|
slug: 'rejected',
|
||||||
label: 'Rejected',
|
label: 'Rejected',
|
||||||
icon: 'ri:close-line',
|
icon: 'ri:close-line',
|
||||||
iconClass: 'text-red-400',
|
color: 'red',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,7 +61,7 @@ export const {
|
|||||||
slug: 'withdrawn',
|
slug: 'withdrawn',
|
||||||
label: 'Withdrawn',
|
label: 'Withdrawn',
|
||||||
icon: 'ri:arrow-left-line',
|
icon: 'ri:arrow-left-line',
|
||||||
iconClass: 'text-gray-400',
|
color: 'gray',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
] as const satisfies ServiceSuggestionStatusInfo<ServiceSuggestionStatus>[]
|
] 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
|
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 {
|
function sanitizePath(inputPath: string): string {
|
||||||
let sanitized = inputPath.replace(/\\+/g, '/')
|
let sanitized = inputPath.replace(/\\+/g, '/')
|
||||||
// Collapse multiple slashes, but preserve protocol (e.g., http://)
|
// 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 { z } from 'astro:schema'
|
||||||
|
|
||||||
|
import { zodParseJSON, type ZodJSON } from './json'
|
||||||
import { typedObjectEntries } from './objects'
|
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<
|
function makeTypedLocalStorage<
|
||||||
Schemas extends Record<string, z.ZodType<JSONValue>>,
|
Schemas extends Record<string, ZodJSON>,
|
||||||
T extends {
|
T extends {
|
||||||
[K in keyof Schemas]: {
|
[K in keyof Schemas]: {
|
||||||
schema: Schemas[K]
|
schema: Schemas[K]
|
||||||
@@ -28,24 +21,7 @@ function makeTypedLocalStorage<
|
|||||||
key,
|
key,
|
||||||
{
|
{
|
||||||
get: () => {
|
get: () => {
|
||||||
const stringValue = localStorage.getItem(key)
|
return zodParseJSON(option.schema, localStorage.getItem(key), option.default)
|
||||||
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
|
|
||||||
},
|
},
|
||||||
|
|
||||||
set: (value: z.input<typeof option.schema>) => {
|
set: (value: z.input<typeof option.schema>) => {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { prisma } from './prisma'
|
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>(
|
export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>(
|
||||||
userId: number,
|
userId: number,
|
||||||
select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never },
|
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 (
|
return (
|
||||||
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??
|
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
|
|||||||
|
|
||||||
import { makeCommentUrl } from './commentsWithReplies'
|
import { makeCommentUrl } from './commentsWithReplies'
|
||||||
|
|
||||||
|
import type { NotificationAction } from './webPush'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
export function makeNotificationTitle(
|
export function makeNotificationTitle(
|
||||||
notification: Prisma.NotificationGetPayload<{
|
notification: Prisma.NotificationGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
|
id: true
|
||||||
type: true
|
type: true
|
||||||
aboutAccountStatusChange: true
|
aboutAccountStatusChange: true
|
||||||
aboutCommentStatusChange: true
|
aboutCommentStatusChange: true
|
||||||
@@ -87,6 +89,9 @@ export function makeNotificationTitle(
|
|||||||
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
|
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
|
||||||
): string {
|
): string {
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
|
case 'TEST': {
|
||||||
|
return `Test notification #${notification.id.toString()}`
|
||||||
|
}
|
||||||
case 'COMMENT_STATUS_CHANGE': {
|
case 'COMMENT_STATUS_CHANGE': {
|
||||||
if (!notification.aboutComment) return 'A comment you are watching had a 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(
|
export function makeNotificationContent(
|
||||||
notification: Prisma.NotificationGetPayload<{
|
notification: Prisma.NotificationGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
|
createdAt: true
|
||||||
type: true
|
type: true
|
||||||
aboutKarmaTransaction: {
|
aboutKarmaTransaction: {
|
||||||
select: {
|
select: {
|
||||||
@@ -204,6 +210,9 @@ export function makeNotificationContent(
|
|||||||
}>
|
}>
|
||||||
): string | null {
|
): string | null {
|
||||||
switch (notification.type) {
|
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.
|
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||||
// case 'KARMA_UNLOCK':
|
// case 'KARMA_UNLOCK':
|
||||||
case 'KARMA_CHANGE': {
|
case 'KARMA_CHANGE': {
|
||||||
@@ -236,7 +245,7 @@ export function makeNotificationContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeNotificationLink(
|
export function makeNotificationActions(
|
||||||
notification: Prisma.NotificationGetPayload<{
|
notification: Prisma.NotificationGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
type: true
|
type: true
|
||||||
@@ -278,44 +287,120 @@ export function makeNotificationLink(
|
|||||||
}
|
}
|
||||||
}>,
|
}>,
|
||||||
origin: string
|
origin: string
|
||||||
): string | null {
|
): NotificationAction[] {
|
||||||
switch (notification.type) {
|
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 'COMMENT_STATUS_CHANGE':
|
||||||
case 'REPLY_COMMENT_CREATED':
|
case 'REPLY_COMMENT_CREATED':
|
||||||
case 'COMMUNITY_NOTE_ADDED':
|
case 'COMMUNITY_NOTE_ADDED':
|
||||||
case 'ROOT_COMMENT_CREATED': {
|
case 'ROOT_COMMENT_CREATED': {
|
||||||
if (!notification.aboutComment) return null
|
if (!notification.aboutComment) return []
|
||||||
return makeCommentUrl({
|
return [
|
||||||
serviceSlug: notification.aboutComment.service.slug,
|
{
|
||||||
commentId: notification.aboutComment.id,
|
action: 'view',
|
||||||
origin,
|
title: 'View',
|
||||||
})
|
...iconNameAndUrl('ri:arrow-right-line'),
|
||||||
|
url: makeCommentUrl({
|
||||||
|
serviceSlug: notification.aboutComment.service.slug,
|
||||||
|
commentId: notification.aboutComment.id,
|
||||||
|
origin,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
case 'SUGGESTION_MESSAGE': {
|
case 'SUGGESTION_MESSAGE': {
|
||||||
if (!notification.aboutServiceSuggestionMessage) return null
|
if (!notification.aboutServiceSuggestionMessage) return []
|
||||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`
|
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': {
|
case 'SUGGESTION_STATUS_CHANGE': {
|
||||||
if (!notification.aboutServiceSuggestionId) return null
|
if (!notification.aboutServiceSuggestionId) return []
|
||||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`
|
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.
|
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||||
// case 'KARMA_UNLOCK': {
|
// case 'KARMA_UNLOCK': {
|
||||||
// return `${origin}/account#karma-unlocks`
|
// return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
|
||||||
// }
|
// }
|
||||||
case 'KARMA_CHANGE': {
|
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': {
|
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': {
|
case 'EVENT_CREATED': {
|
||||||
if (!notification.aboutEvent) return null
|
if (!notification.aboutEvent) return []
|
||||||
return `${origin}/service/${notification.aboutEvent.service.slug}#events`
|
return [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
title: 'View',
|
||||||
|
...iconNameAndUrl('ri:arrow-right-line'),
|
||||||
|
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
||||||
if (!notification.aboutService) return null
|
if (!notification.aboutService) return []
|
||||||
return `${origin}/service/${notification.aboutService.slug}#verification`
|
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 */
|
/* 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 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
|
// Configure VAPID keys
|
||||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
||||||
|
|
||||||
export { webpush }
|
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(
|
export async function sendPushNotification(
|
||||||
subscription: {
|
subscription: {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
@@ -15,32 +35,13 @@ export async function sendPushNotification(
|
|||||||
auth: string
|
auth: string
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data: {
|
payload: NotificationPayload
|
||||||
title: string
|
|
||||||
body?: string
|
|
||||||
icon?: string
|
|
||||||
badge?: string
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await webpush.sendNotification(
|
// NOTE: View sw.js to see how the notification is handled
|
||||||
subscription,
|
const result = await webpush.sendNotification(subscription, JSON.stringify(payload), {
|
||||||
JSON.stringify({
|
TTL: 24 * 60 * 60, // 24 hours
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return { success: true, result } as const
|
return { success: true, result } as const
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending push notification:', error)
|
console.error('Error sending push notification:', error)
|
||||||
|
|||||||
@@ -13,17 +13,44 @@ const addZodPipe = (schema: ZodTypeAny, zodPipe?: ZodTypeAny) => {
|
|||||||
export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
|
export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
|
||||||
addZodPipe(z.number().or(z.string().nonempty()), zodPipe)
|
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(
|
export const zodUrlOptionalProtocol = z.preprocess(
|
||||||
(input) => {
|
cleanUrl,
|
||||||
if (typeof input !== 'string') return input
|
|
||||||
const cleanInput = input.trim().replace(/\/$/, '')
|
|
||||||
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
|
|
||||||
},
|
|
||||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||||
message: 'Invalid URL',
|
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 = ',') => {
|
const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
|
||||||
return <T>(input: T) =>
|
return <T>(input: T) =>
|
||||||
typeof input !== 'string'
|
typeof input !== 'string'
|
||||||
@@ -49,6 +76,11 @@ export const stringListOfUrlsSchemaRequired = z.preprocess(
|
|||||||
z.array(zodUrlOptionalProtocol).min(1)
|
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 MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
|
|
||||||
export const ACCEPTED_IMAGE_TYPES = [
|
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
|
- Terms of service or FAQ document
|
||||||
|
|
||||||
For examples:
|
For examples:
|
||||||
|
|
||||||
- Just a Telegram link or a criptocurrency itself is not a valid service.
|
- Just a Telegram link or a criptocurrency itself is not a valid service.
|
||||||
|
|
||||||
### Suggestion Review Process
|
### 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.
|
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.
|
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.
|
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.
|
7. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||||
|
|
||||||
#### Trust Score
|
#### Trust Score
|
||||||
@@ -160,7 +161,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
|
|||||||
- **Approved:** +5 points
|
- **Approved:** +5 points
|
||||||
- **Community Contributed:** 0 points
|
- **Community Contributed:** 0 points
|
||||||
- **Verification Failed (SCAM):** -50 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.
|
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.
|
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 { actions, isInputError } from 'astro:actions'
|
||||||
import { groupBy, round, uniq } from 'lodash-es'
|
import { groupBy, round, uniq } from 'lodash-es'
|
||||||
|
|
||||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
import Button from '../../components/Button.astro'
|
||||||
import InputText from '../../components/InputText.astro'
|
|
||||||
import InputTextArea from '../../components/InputTextArea.astro'
|
import InputTextArea from '../../components/InputTextArea.astro'
|
||||||
import MiniLayout from '../../layouts/MiniLayout.astro'
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
import { cn } from '../../lib/cn'
|
import { cn } from '../../lib/cn'
|
||||||
@@ -15,7 +14,7 @@ if (!Astro.locals.user?.admin) {
|
|||||||
return Astro.redirect('/access-denied')
|
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 : {}
|
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
|
||||||
|
|
||||||
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
|
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>
|
<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
|
<InputTextArea
|
||||||
label="Users"
|
label="Users"
|
||||||
name="userNames"
|
name="userNames"
|
||||||
@@ -121,35 +120,6 @@ const stats = [
|
|||||||
error={testInputErrors.userNames}
|
error={testInputErrors.userNames}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<Button type="submit" label="Send" icon="ri:send-plane-line" color="danger" class="w-full" />
|
||||||
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" />
|
|
||||||
</form>
|
</form>
|
||||||
</MiniLayout>
|
</MiniLayout>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { actions } from 'astro:actions'
|
import { actions, isInputError } from 'astro:actions'
|
||||||
|
|
||||||
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
||||||
import Button from '../../../components/Button.astro'
|
import Button from '../../../components/Button.astro'
|
||||||
import Chat from '../../../components/Chat.astro'
|
import Chat from '../../../components/Chat.astro'
|
||||||
|
import InputSelect from '../../../components/InputSelect.astro'
|
||||||
import ServiceCard from '../../../components/ServiceCard.astro'
|
import ServiceCard from '../../../components/ServiceCard.astro'
|
||||||
import UserBadge from '../../../components/UserBadge.astro'
|
import UserBadge from '../../../components/UserBadge.astro'
|
||||||
import {
|
import {
|
||||||
@@ -17,12 +18,20 @@ import { cn } from '../../../lib/cn'
|
|||||||
import { parseIntWithFallback } from '../../../lib/numbers'
|
import { parseIntWithFallback } from '../../../lib/numbers'
|
||||||
import { prisma } from '../../../lib/prisma'
|
import { prisma } from '../../../lib/prisma'
|
||||||
import { makeLoginUrl } from '../../../lib/redirectUrls'
|
import { makeLoginUrl } from '../../../lib/redirectUrls'
|
||||||
|
import { formatDateShort } from '../../../lib/timeAgo'
|
||||||
|
import BadgeStandard from '../../../components/BadgeStandard.astro'
|
||||||
|
|
||||||
const user = Astro.locals.user
|
const user = Astro.locals.user
|
||||||
if (!user?.admin) {
|
if (!user?.admin) {
|
||||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
|
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 { id: serviceSuggestionIdRaw } = Astro.params
|
||||||
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
|
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
|
||||||
if (!serviceSuggestionId) {
|
if (!serviceSuggestionId) {
|
||||||
@@ -100,114 +109,88 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
|
|||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
|
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
|
||||||
htmx
|
description="View and manage service suggestion"
|
||||||
widthClassName="max-w-screen-md"
|
widthClassName="max-w-screen-md"
|
||||||
|
htmx
|
||||||
>
|
>
|
||||||
<div class="mb-4 flex items-center gap-4">
|
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1>
|
||||||
<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 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} />
|
<section class="border-night-400 bg-night-600 rounded-lg border p-6">
|
||||||
</div>
|
<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">
|
||||||
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
<span class="font-title font-bold">Status:</span>
|
||||||
<div>
|
<BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
|
||||||
<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>
|
|
||||||
</div>
|
</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 && (
|
serviceSuggestion.notes ? (
|
||||||
<div class="mb-4">
|
<div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
|
||||||
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3>
|
) : (
|
||||||
<div
|
<div class="text-day-400 my-4 text-center text-sm italic">Empty</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
|
<form method="POST" action={actions.admin.serviceSuggestions.update} class="mt-6 flex items-end gap-2">
|
||||||
<div class="flex items-center justify-between">
|
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
|
||||||
<h2 class="font-title text-day-200 text-lg">Messages</h2>
|
<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">
|
<Chat
|
||||||
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
|
messages={serviceSuggestion.messages}
|
||||||
<select
|
title="Chat with moderators"
|
||||||
name="status"
|
userId={user.id}
|
||||||
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"
|
action={actions.admin.serviceSuggestions.message}
|
||||||
>
|
formData={{
|
||||||
{
|
suggestionId: serviceSuggestion.id,
|
||||||
serviceSuggestionStatuses.map((status) => (
|
}}
|
||||||
<option value={status.value} selected={serviceSuggestion.status === status.value}>
|
class="mt-12"
|
||||||
{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>
|
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import Tooltip from '../../../../components/Tooltip.astro'
|
|||||||
import UserBadge from '../../../../components/UserBadge.astro'
|
import UserBadge from '../../../../components/UserBadge.astro'
|
||||||
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
|
||||||
import { formatContactMethod } from '../../../../constants/contactMethods'
|
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
|
||||||
import { currencies } from '../../../../constants/currencies'
|
import { currencies } from '../../../../constants/currencies'
|
||||||
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
||||||
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
|
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from '../../../../constants/verificationStepStatus'
|
} from '../../../../constants/verificationStepStatus'
|
||||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||||
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
|
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
|
||||||
|
import { listFiles } from '../../../../lib/fileStorage'
|
||||||
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
||||||
import { pluralize } from '../../../../lib/pluralize'
|
import { pluralize } from '../../../../lib/pluralize'
|
||||||
import { prisma } from '../../../../lib/prisma'
|
import { prisma } from '../../../../lib/prisma'
|
||||||
@@ -87,9 +88,36 @@ const internalNoteInputErrors = isInputError(internalNoteCreateResult?.error)
|
|||||||
? internalNoteCreateResult.error.fields
|
? 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)
|
const internalNoteDeleteResult = Astro.getActionResult(actions.admin.service.internalNote.delete)
|
||||||
Astro.locals.banners.addIfSuccess(internalNoteDeleteResult, 'Internal note deleted successfully')
|
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([
|
const [service, categories, attributes] = await Astro.locals.banners.tryMany([
|
||||||
[
|
[
|
||||||
'Error fetching service',
|
'Error fetching service',
|
||||||
@@ -200,6 +228,12 @@ if (!service) {
|
|||||||
return Astro.rewrite('/404')
|
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(
|
const apiCalls = await Astro.locals.banners.try(
|
||||||
'Error fetching api calls',
|
'Error fetching api calls',
|
||||||
() =>
|
() =>
|
||||||
@@ -426,7 +460,7 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
description: clarification.description,
|
description: clarification.description,
|
||||||
noTransitionPersist: true,
|
noTransitionPersist: true,
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.kycLevelClarification ?? 'NONE'}
|
selectedValue={service.kycLevelClarification}
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
error={serviceInputErrors.kycLevelClarification}
|
error={serviceInputErrors.kycLevelClarification}
|
||||||
@@ -1113,6 +1147,7 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
value: method.label,
|
value: method.label,
|
||||||
placeholder: contactMethodInfo.formattedValue,
|
placeholder: contactMethodInfo.formattedValue,
|
||||||
}}
|
}}
|
||||||
|
error={contactMethodUpdateInputErrors.label}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<InputText
|
||||||
@@ -1122,6 +1157,7 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
value: method.value,
|
value: method.value,
|
||||||
placeholder: 'e.g., mailto:contact@example.com or https://t.me/example',
|
placeholder: 'e.g., mailto:contact@example.com or https://t.me/example',
|
||||||
}}
|
}}
|
||||||
|
error={contactMethodUpdateInputErrors.value}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
|
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
|
||||||
@@ -1142,12 +1178,13 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
|
|
||||||
<InputText
|
<InputText
|
||||||
label="Value"
|
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"
|
name="value"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'mailto:contact@example.com',
|
placeholder: 'contact@example.com',
|
||||||
}}
|
}}
|
||||||
|
error={contactMethodAddInputErrors.value}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
|
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
|
||||||
@@ -1164,6 +1201,16 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
</p>
|
</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) => (
|
apiCalls.map((call) => (
|
||||||
<FormSubSection title={`${call.method} ${call.path}`}>
|
<FormSubSection title={`${call.method} ${call.path}`}>
|
||||||
@@ -1176,5 +1223,73 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</FormSection>
|
</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>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { SOURCE_CODE_URL } from 'astro:env/server'
|
|||||||
import { kycLevels } from '../../constants/kycLevels'
|
import { kycLevels } from '../../constants/kycLevels'
|
||||||
import { verificationStatuses } from '../../constants/verificationStatus'
|
import { verificationStatuses } from '../../constants/verificationStatus'
|
||||||
import { serviceVisibilities } from '../../constants/serviceVisibility'
|
import { serviceVisibilities } from '../../constants/serviceVisibility'
|
||||||
|
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
|
||||||
|
|
||||||
Access basic service data via our public API.
|
Access basic service data via our public API.
|
||||||
|
|
||||||
@@ -26,11 +27,11 @@ Fetches details for a single service by various lookup criteria.
|
|||||||
|
|
||||||
### Request Parameters
|
### Request Parameters
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
| ------------ | ------ | -------- | ----------- |
|
| ------------ | ------ | -------- | --------------------------------------------------------------------------- |
|
||||||
| `id` | number | No* | Service ID |
|
| `id` | number | No\* | Service ID |
|
||||||
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
|
| `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. |
|
| `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.
|
\* At least one of the marked parameters is required.
|
||||||
|
|
||||||
@@ -58,6 +59,12 @@ type ServiceResponse = {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
||||||
|
kycLevelClarificationInfo: {
|
||||||
|
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
categories: {
|
categories: {
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
@@ -72,31 +79,46 @@ type ServiceResponse = {
|
|||||||
#### KYC Levels
|
#### KYC Levels
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{kycLevels.map((level) => (
|
{kycLevels.map((level) => (
|
||||||
<li key={level.id}>
|
<li key={level.id}>
|
||||||
<strong>{level.id}</strong>: {level.name} - {level.description}
|
<strong>{level.id}</strong>: {level.name} - {level.description}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
#### Verification Status
|
#### Verification Status
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{verificationStatuses.map((status) => (
|
{verificationStatuses.map((status) => (
|
||||||
<li key={status.value}>
|
<li key={status.value}>
|
||||||
<strong>{status.value}</strong>: {status.description}
|
<strong>{status.value}</strong>: {status.description}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
#### Service Visibility
|
#### Service Visibility
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
|
{serviceVisibilities
|
||||||
<li key={visibility.value}>
|
.filter(
|
||||||
<strong>{visibility.value}</strong>: {visibility.longDescription}
|
(visibility) =>
|
||||||
</li>
|
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>
|
</ul>
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
@@ -131,6 +153,11 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
"name": "Guaranteed no KYC",
|
"name": "Guaranteed no KYC",
|
||||||
"description": "Terms explicitly state KYC will never be requested."
|
"description": "Terms explicitly state KYC will never be requested."
|
||||||
},
|
},
|
||||||
|
"kycLevelClarification": "NONE",
|
||||||
|
"kycLevelClarificationInfo": {
|
||||||
|
"value": "NONE",
|
||||||
|
"description": "No clarification needed."
|
||||||
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
{
|
{
|
||||||
"name": "Exchange",
|
"name": "Exchange",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { getNotificationTypeInfo } from '../constants/notificationTypes'
|
|||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
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 { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||||
@@ -199,7 +199,7 @@ const notifications = dbNotifications.map((notification) => ({
|
|||||||
typeInfo: getNotificationTypeInfo(notification.type),
|
typeInfo: getNotificationTypeInfo(notification.type),
|
||||||
title: makeNotificationTitle(notification, user),
|
title: makeNotificationTitle(notification, user),
|
||||||
content: makeNotificationContent(notification),
|
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"
|
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"
|
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>
|
</Tooltip>
|
||||||
</form>
|
</form>
|
||||||
{notification.link && (
|
{notification.actions.map((action) => (
|
||||||
<a
|
<Tooltip
|
||||||
href={notification.link}
|
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"
|
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" />
|
<Icon name={action.iconName ?? 'ri:arrow-right-line'} class="size-4" />
|
||||||
<span class="sr-only">View details</span>
|
<span class="sr-only">{action.title}</span>
|
||||||
</a>
|
</Tooltip>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="mt-12 mb-6 text-center">
|
||||||
<div class="flex items-center gap-2">
|
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
|
||||||
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
<div class="flex items-center justify-center gap-2">
|
||||||
<AdminOnly>
|
<AdminOnly>
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
@@ -124,29 +124,24 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
|
|||||||
/>
|
/>
|
||||||
</AdminOnly>
|
</AdminOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
|
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
|
||||||
|
|
||||||
<section class="border-night-400 bg-night-600 rounded-lg border p-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">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span>Status:</span>
|
<span class="font-title font-bold">Status:</span>
|
||||||
<span
|
<BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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>
|
<span>
|
||||||
{
|
{
|
||||||
formatDateShort(serviceSuggestion.createdAt, {
|
formatDateShort(serviceSuggestion.createdAt, {
|
||||||
@@ -157,15 +152,22 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
|
||||||
<div class="text-day-200 mb-2 text-sm">Notes for moderators:</div>
|
<span class="font-title block font-bold">Notes for moderators:</span>
|
||||||
{
|
{
|
||||||
serviceSuggestion.notes ? (
|
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>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import InputText from '../../components/InputText.astro'
|
|||||||
import InputTextArea from '../../components/InputTextArea.astro'
|
import InputTextArea from '../../components/InputTextArea.astro'
|
||||||
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||||
|
import { contactMethodUrlTypes } from '../../constants/contactMethods'
|
||||||
import { currencies } from '../../constants/currencies'
|
import { currencies } from '../../constants/currencies'
|
||||||
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
|
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
|
||||||
import { kycLevels } from '../../constants/kycLevels'
|
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."
|
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
|
||||||
name="allServiceUrls"
|
name="allServiceUrls"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
|
placeholder: 'example1.com\nexample2.onion\nexample3.b32.i2p',
|
||||||
class: 'min-h-24',
|
class: 'md:min-h-20 min-h-24 h-full',
|
||||||
required: true,
|
required: true,
|
||||||
}}
|
}}
|
||||||
class="row-span-2 flex flex-col self-stretch"
|
class="flex flex-col self-stretch"
|
||||||
error={inputErrors.allServiceUrls}
|
error={inputErrors.allServiceUrls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputTextArea
|
<InputTextArea
|
||||||
label="ToS URLs"
|
label="Contact Methods"
|
||||||
description="One per line"
|
description={[
|
||||||
name="tosUrls"
|
'One per line.',
|
||||||
|
`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`,
|
||||||
|
].join('\n')}
|
||||||
|
name="contactMethods"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
placeholder: 'contact@example.com\nt.me/example\n+123 456 7890',
|
||||||
class: 'md:min-h-24',
|
class: 'h-full',
|
||||||
required: true,
|
|
||||||
}}
|
}}
|
||||||
error={inputErrors.tosUrls}
|
class="flex flex-col self-stretch"
|
||||||
|
error={inputErrors.contactMethods}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<InputCardGroup
|
||||||
name="kycLevel"
|
name="kycLevel"
|
||||||
label="KYC Level"
|
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 { Icon } from 'astro-icon/components'
|
||||||
import { Markdown } from 'astro-remote'
|
import { Markdown } from 'astro-remote'
|
||||||
import { Schema } from 'astro-seo-schema'
|
import { Schema } from 'astro-seo-schema'
|
||||||
@@ -1295,9 +1295,17 @@ const activeAlertOrWarningEvents = service.events.filter(
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="verification">
|
<div class="border-day-500 mt-6 mb-3 flex items-center justify-between border-b" id="verification">
|
||||||
Verification
|
<h2 class="font-title text-day-100 text-lg font-bold">Verification</h2>
|
||||||
</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={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 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user