Compare commits

...

39 Commits

Author SHA1 Message Date
pluja
cf5f3b3228 Release 202506131423 2025-06-13 14:23:14 +00:00
pluja
5a41816ac8 Release 202506131339 2025-06-13 13:39:12 +00:00
pluja
bf30a6cb2b Release 202506130639 2025-06-13 06:39:29 +00:00
pluja
4ca9b9a5c2 Release 202506112249 2025-06-11 22:49:54 +00:00
pluja
03abdef4f1 Release 202506112228 2025-06-11 22:28:53 +00:00
pluja
d9880fd83d Release 202506112108 2025-06-11 21:08:32 +00:00
pluja
39afcad089 Release 202506111705 2025-06-11 17:05:58 +00:00
pluja
99cb730bc0 Release 202506111039 2025-06-11 10:39:20 +00:00
pluja
d43402e162 Release 202506111007 2025-06-11 10:07:51 +00:00
pluja
9bb316b85f Release 202506102027 2025-06-10 20:27:48 +00:00
pluja
4aea68ee58 Release 202506101914 2025-06-10 19:14:10 +00:00
pluja
2f88c43236 Release 202506101800 2025-06-10 18:00:24 +00:00
pluja
ad3c561419 Release 202506101748 2025-06-10 17:48:58 +00:00
pluja
812937d2c7 Release 202506101742 2025-06-10 17:42:42 +00:00
pluja
459d7c91f7 Release 202506091901 2025-06-09 19:01:08 +00:00
pluja
b8b2dee4a4 Release 202506091433 2025-06-09 14:33:57 +00:00
pluja
eb0af871e1 Release 202506091118 2025-06-09 11:18:06 +00:00
pluja
3ccd7fd395 Release 202506091053 2025-06-09 10:53:52 +00:00
pluja
87f0f36aa1 Release 202506091000 2025-06-09 10:00:55 +00:00
pluja
8b90b3eef6 Release 202506061009 2025-06-06 10:09:59 +00:00
pluja
2489e94b0e Release 202506042153 2025-06-04 21:53:07 +00:00
pluja
144af17a70 Release 202506042038 2025-06-04 20:38:49 +00:00
pluja
02e52d7351 Release 202506041937 2025-06-04 19:37:33 +00:00
pluja
dacf73a804 Release 202506041641 2025-06-04 16:41:32 +00:00
pluja
5812399e29 Release 202506031821 2025-06-03 18:21:55 +00:00
pluja
6a6908518d Release 202506020353 2025-06-02 03:53:03 +00:00
pluja
d065910ff3 Release 202506011533 2025-06-01 15:33:44 +00:00
pluja
490433b002 Release 202506011511 2025-06-01 15:11:37 +00:00
pluja
e17bc8a521 Release 202505312236 2025-05-31 22:36:39 +00:00
pluja
ec1215f2ae Release 202505311848 2025-05-31 18:48:33 +00:00
pluja
3afa824c18 Release 202505311149 2025-05-31 11:49:38 +00:00
pluja
9a68112e24 Release 202505311113 2025-05-31 11:13:24 +00:00
pluja
0c40d8eec5 Release 202505311002 2025-05-31 10:02:50 +00:00
pluja
e16c9b64ed Release 202505311001 2025-05-31 10:01:35 +00:00
pluja
22944fcdb3 Release 202505310921 2025-05-31 09:21:32 +00:00
pluja
f7f380c591 Release 202505302056 2025-05-30 20:56:04 +00:00
pluja
577c524ca2 Release 202505302029 2025-05-30 20:29:01 +00:00
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
180 changed files with 11345 additions and 2416 deletions

View File

@@ -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;
} = {}
);
```

View File

@@ -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

View File

@@ -7,12 +7,14 @@ services:
volumes:
- database:/var/lib/postgresql/data:z
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
env_file:
- .env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}',
]
interval: 10s
timeout: 5s
retries: 5
@@ -20,19 +22,17 @@ services:
pyworker:
build:
context: ./pyworker
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
restart: always
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
env_file:
- .env
crawl4ai:
image: unclecode/crawl4ai:basic-amd64
expose:
- "11235"
environment:
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
MAX_CONCURRENT_TASKS: 10
- '11235'
env_file:
- .env
volumes:
- /dev/shm:/dev/shm
deploy:
@@ -46,22 +46,16 @@ services:
image: redis:latest
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
astro:
build:
context: ./web
dockerfile: web/Dockerfile
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
REDIS_URL: "redis://redis:6379"
env_file:
- .env
depends_on:
@@ -72,7 +66,15 @@ services:
expose:
- 4321
healthcheck:
test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321/health"]
test:
[
'CMD',
'curl',
'-k',
'--silent',
'--fail',
'http://localhost:4321/internal-api/healthcheck',
]
interval: 10s
timeout: 5s
retries: 5

View File

@@ -51,42 +51,6 @@ import-db file="":
fi
fi
echo "Restoring database from $BACKUP_FILE..."
# First drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
# Drop and recreate database
echo "Dropping and recreating the database..."
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
# Restore the database
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
echo "Database restored successfully!"
# Import triggers
echo "Importing triggers..."
just import-triggers
echo "Database import completed!"
# Check if migrations need to be run
cd web && npx prisma migrate status
#!/bin/bash
if [ -z "{{file}}" ]; then
BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1)
if [ -z "$BACKUP_FILE" ]; then
echo "Error: No backup files found in the backups directory"
exit 1
fi
else
BACKUP_FILE="{{file}}"
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file '$BACKUP_FILE' not found"
exit 1
fi
fi
echo "=== STEP 1: PREPARING DATABASE ==="
# Drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres

View File

@@ -366,7 +366,7 @@ def run_worker_mode() -> int:
# Register service score recalculation task (every 5 minutes)
scheduler.register_task(
"service-score-recalc",
"service_score_recalc",
"*/5 * * * *",
run_service_score_recalc_task,
)

View File

@@ -332,29 +332,33 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
return remove_service_attribute(service_id, attribute_id)
def save_tos_review(service_id: int, review: TosReviewType):
"""
Save a TOS review for a specific service.
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
"""Persist a TOS review and/or update the timestamp for a service.
Args:
service_id: The ID of the service.
review: A TypedDict containing the review data.
If *review* is ``None`` the existing review (if any) is preserved while
only the ``tosReviewAt`` column is updated. This ensures we still track
when the review task last ran even if the review generation failed or
produced no changes.
"""
try:
# Serialize the dictionary to a JSON string for the database
review_json = json.dumps(review)
with get_db_connection() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(
"""
UPDATE "Service"
SET "tosReview" = %s, "tosReviewAt" = NOW()
WHERE id = %s
""",
(review_json, service_id),
)
if review is None:
cursor.execute(
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
(service_id,),
)
else:
review_json = json.dumps(review)
cursor.execute(
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
(review_json, service_id),
)
conn.commit()
logger.info(f"Successfully saved TOS review for service {service_id}")
logger.info(
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
)
except Exception as e:
logger.error(f"Error saving TOS review for service {service_id}: {e}")

View File

@@ -5,6 +5,8 @@ Task for retrieving Terms of Service (TOS) text.
import hashlib
from typing import Any, Dict, Optional
import requests
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
from pyworker.tasks.base import Task
from pyworker.utils.ai import prompt_check_tos_review, prompt_tos_review
@@ -52,65 +54,97 @@ class TosReviewTask(Task):
)
self.logger.info(f"TOS URLs: {tos_urls}")
review = self.get_tos_review(tos_urls, service.get("tosReview"))
# Always update the processed timestamp, even if review is None
save_tos_review(service_id, review)
if review is None:
self.logger.warning(
f"TOS review could not be generated for service {service_name} (ID: {service_id})"
)
return None
# Update the KYC level based on the review, when present
if "kycLevel" in review:
new_level = review["kycLevel"]
old_level = service.get("kycLevel")
# Update DB
if update_kyc_level(service_id, new_level):
msg = f"{service.get('slug', service_name)}: kycLevel {old_level} -> {new_level}"
# Log to console
self.logger.info(msg)
# Send notification via ntfy
try:
requests.post(
"https://ntfy.sh/knm-kyc-lvl-changes-knm", data=msg.encode()
)
except requests.RequestException as e:
self.logger.error(
f"Failed to send ntfy notification for KYC level change: {e}"
)
return review
def get_tos_review(
self, tos_urls: list[str], current_review: Optional[TosReviewType]
) -> Optional[TosReviewType]:
"""
Get TOS review from a list of URLs.
Args:
tos_urls: List of TOS URLs to check
current_review: Current review data from the database
Returns:
Dict containing:
- status: Literal["skipped", "failed", "success"]
- review: Optional[TosReviewType] - The review data if successful
"""
all_skipped = True
for tos_url in tos_urls:
api_url = f"{tos_url}"
self.logger.info(f"Fetching TOS from URL: {api_url}")
# Sleep for 1 second to avoid rate limiting
content = fetch_markdown(api_url)
if content:
# Hash the content to avoid repeating the same content
content_hash = hashlib.sha256(content.encode()).hexdigest()
self.logger.info(f"Content hash: {content_hash}")
# service.get("tosReview") can be None if the DB field is NULL.
# Default to an empty dict to prevent AttributeError on .get()
tos_review_data_from_service: Optional[Dict[str, Any]] = service.get(
"tosReview"
)
tos_review: Dict[str, Any] = (
tos_review_data_from_service
if tos_review_data_from_service is not None
else {}
)
stored_hash = tos_review.get("contentHash")
# Skip processing if we've seen this content before
if stored_hash == content_hash:
self.logger.info(
f"Skipping already processed TOS content with hash: {content_hash}"
)
continue
# Skip incomplete TOS content
check = prompt_check_tos_review(content)
if not check:
continue
elif not check["isComplete"]:
continue
# Query OpenAI to summarize the content
review = prompt_tos_review(content)
if review:
review["contentHash"] = content_hash
# Save the review to the database
save_tos_review(service_id, review)
# Update the KYC level based on the review
if "kycLevel" in review:
kyc_level = review["kycLevel"]
self.logger.info(
f"Updating KYC level to {kyc_level} for service {service_name}"
)
update_kyc_level(service_id, kyc_level)
# no need to check other TOS URLs
break
return review
else:
if not content:
self.logger.warning(
f"Failed to retrieve TOS content for URL: {tos_url}"
)
all_skipped = False
continue
# Hash the content to avoid repeating the same content
content_hash = hashlib.sha256(content.encode()).hexdigest()
self.logger.info(f"Content hash: {content_hash}")
# Skip processing if we've seen this content before
if current_review and current_review.get("contentHash") == content_hash:
self.logger.info(
f"Skipping already processed TOS content with hash: {content_hash}"
)
continue
all_skipped = False
# Skip incomplete TOS content
check = prompt_check_tos_review(content)
if not check or not check["isComplete"]:
continue
# Query OpenAI to summarize the content
review = prompt_tos_review(content)
if review:
review["contentHash"] = content_hash
return review
if all_skipped:
return current_review
return None

View File

@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
{"role": "user", "content": content},
]
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
result_dict = query_openai_json(
messages, model="openai/gemini-2.5-flash-preview-05-20"
)
return cast(TosReviewCheck, result_dict)
@@ -173,27 +175,27 @@ type TosReview = {
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
rating: 'negative' | 'neutral' | 'positive'
}[]
}[] // max 8 highlights, try to provide at least 3.
}
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
Be concise but thorough, and make sure your output is properly formatted JSON.
Focus on the most important information for the user. Be concise and thorough, and make sure your output is properly formatted JSON.
"""
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
You will be given a list of user comments to a service.
Your task is to summarize the comments in a way that is easy to understand and to the point.
The summary should be concise and to the point, no more than 150 words.
The summary should be concise and to the point, no more than 100 words. Keep it short and concise.
Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
You must format your response as a valid JSON object with the following structure:
interface CommentSummary {
summary: string;
summary: string; // Concise, 100 words max
sentiment: 'positive'|'negative'|'neutral';
whatUsersLike: string[]; // Concise, 2-3 words, max 4
whatUsersDislike: string[]; // Concise, 2-3 words, max 4
whatUsersLike: string[]; // Concise, 2-3 words max
whatUsersDislike: string[]; // Concise, 2-3 words max
}
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.

View File

@@ -2,8 +2,12 @@ DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com"
DATABASE_UI_URL="http://localhost:5555"
SITE_URL="https://localhost:4321"
SITE_URL="http://localhost:4321"
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
RELEASE_NUMBER=123
RELEASE_DATE="2025-05-23T19:00:00.000Z"
# Generated with `npx web-push generate-vapid-keys`
VAPID_PUBLIC_KEY="BPmJbRXzG9zT181vyg1GlpyV8qu7rjVjfg6vkkOgtqeTZECyt6lR4MuzmlarEHSBF6gPpc77ZA0_tTVtmYh65iM"
VAPID_PRIVATE_KEY="eN_S2SMXDB2hpwVXbgDkDrPIPMqirllZaJcUgYTt9w0"
VAPID_SUBJECT="mailto:no-reply@kycnot.me"

View File

@@ -1,26 +1,29 @@
FROM node:lts AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
COPY .env .env
COPY web/package.json web/package-lock.json ./
COPY .npmrc .npmrc
COPY web/.npmrc .npmrc
RUN npm ci
COPY . .
COPY web/ .
ARG ASTRO_BUILD_MODE=production
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
RUN npm run build:astro -- --mode ${ASTRO_BUILD_MODE} && npm run build:server-init
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
# Add knm-migrate command script and make it executable
COPY migrate.sh /usr/local/bin/knm-migrate
COPY web/migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/knm-migrate
CMD ["node", "./dist/server/entry.mjs"]
CMD ["sh", "-c", "node ./dist/server/server-init.js & node ./dist/server/entry.mjs"]

View File

@@ -4,26 +4,80 @@ import mdx from '@astrojs/mdx'
import node from '@astrojs/node'
import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite'
import { minimal2023Preset } from '@vite-pwa/assets-generator/config'
import AstroPWA from '@vite-pwa/astro'
import { defineConfig, envField } from 'astro/config'
import icon from 'astro-icon'
import { loadEnv } from 'vite'
import devtoolsJson from 'vite-plugin-devtools-json'
// @ts-expect-error process.env actually exists
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
const SITE_URL = getServerEnvVariable('SITE_URL')
export default defineConfig({
site: SITE_URL,
vite: {
build: {
sourcemap: true,
sourcemap: true, // Enable sourcemaps on production, so users can inspect the code
},
plugins: [tailwindcss()],
plugins: [devtoolsJson(), tailwindcss()],
},
integrations: [
postgresListener(),
icon(),
mdx(),
AstroPWA({
mode: 'development',
base: '/',
scope: '/',
registerType: 'autoUpdate',
manifest: {
name: 'KYCnot.me',
short_name: 'KYCnot.me',
description: 'Find services that respect your privacy',
theme_color: '#040505',
background_color: '#171c1b',
display: 'minimal-ui',
},
pwaAssets: {
image: './public/favicon.svg',
preset: {
...minimal2023Preset,
maskable: {
...minimal2023Preset.maskable,
padding: 0.1,
resizeOptions: {
...minimal2023Preset.maskable.resizeOptions,
background: '#3bdb78',
},
},
apple: {
...minimal2023Preset.apple,
padding: 0.1,
resizeOptions: {
...minimal2023Preset.apple.resizeOptions,
background: '#3bdb78',
},
},
},
},
workbox: {
navigateFallback: '/404',
globPatterns: ['**/*.{js,css,html,ico,jpg,jpeg,png,svg,webp,avif}'],
},
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
devOptions: {
enabled: true,
type: 'module',
},
experimental: {
directoryAndTrailingSlashHandler: true,
},
}),
sitemap({
filter: (page) => {
const url = new URL(page)
@@ -56,6 +110,8 @@ export default defineConfig({
'/attribute/[...slug]': '/attributes',
'/attr/[...slug]': '/attributes',
// #endregion
'/service/[...slug]/review': '/service/[...slug]#comments',
},
env: {
schema: {
@@ -69,7 +125,7 @@ export default defineConfig({
}),
// Public URLs (can be accessed from both server and client)
SOURCE_CODE_URL: envField.string({
context: 'server',
context: 'client',
access: 'public',
url: true,
optional: false,
@@ -94,35 +150,6 @@ export default defineConfig({
startsWith: 'redis://',
default: 'redis://redis:6379',
}),
REDIS_USER_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 60 * 24, // 24 hours in seconds
}),
REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 60 * 24, // 24 hours in seconds
}),
REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 5, // 5 minutes in seconds
}),
REDIS_ACTIONS_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 5, // 5 minutes in seconds
}),
// Development tokens
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({
@@ -195,6 +222,27 @@ export default defineConfig({
access: 'public',
optional: true,
}),
// Generated with `npx web-push generate-vapid-keys`
VAPID_PUBLIC_KEY: envField.string({
context: 'server',
access: 'public',
min: 1,
optional: false,
}),
// Generated with `npx web-push generate-vapid-keys`
VAPID_PRIVATE_KEY: envField.string({
context: 'server',
access: 'secret',
min: 1,
optional: false,
}),
VAPID_SUBJECT: envField.string({
context: 'server',
access: 'secret',
min: 1,
optional: false,
}),
},
},
})

View File

@@ -121,7 +121,7 @@ export default tseslint.config(
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-duplicates': 'error',
'import/no-unresolved': ['error', { ignore: ['^astro:'] }],
'import/no-unresolved': ['error', { ignore: ['^astro:', '^virtual:'] }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
'import/namespace': 'off',

5420
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,10 @@
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build --remote",
"preview": "astro preview",
"build": "npm run build:astro && npm run build:server-init",
"build:astro": "astro build --remote",
"build:server-init": "esbuild src/server-init.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server/server-init.js",
"preview": "node dist/server/server-init.js & astro preview",
"astro": "astro",
"db-admin": "prisma studio --browser=none",
"db-gen": "prisma generate",
@@ -23,18 +25,20 @@
},
"dependencies": {
"@astrojs/check": "0.9.4",
"@astrojs/db": "0.14.14",
"@astrojs/mdx": "4.2.6",
"@astrojs/node": "9.2.1",
"@astrojs/sitemap": "3.4.0",
"@fontsource-variable/space-grotesk": "5.2.7",
"@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2",
"@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5",
"@fontsource/space-grotesk": "5.2.7",
"@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4",
"@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.9.0",
"@tailwindcss/vite": "4.1.8",
"@types/mime-types": "3.0.0",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.7.13",
"astro": "5.9.0",
"astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0",
@@ -42,56 +46,65 @@
"clsx": "2.1.1",
"htmx.org": "1.9.12",
"javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.8",
"libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.0.1",
"redis": "5.5.6",
"schema-dts": "1.1.5",
"seedrandom": "3.0.5",
"sharp": "0.34.1",
"sharp": "0.34.2",
"slugify": "1.6.6",
"tailwind-merge": "3.3.0",
"tailwind-variants": "1.0.0",
"tailwindcss": "4.1.7",
"tailwindcss": "4.1.8",
"typescript": "5.8.3",
"unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7"
},
"devDependencies": {
"@eslint/js": "9.27.0",
"@eslint/js": "9.28.0",
"@faker-js/faker": "9.8.0",
"@iconify-json/material-symbols": "1.2.21",
"@iconify-json/material-symbols": "1.2.24",
"@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.2.0",
"@stylistic/eslint-plugin": "4.4.1",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5",
"@types/react": "19.1.4",
"@types/react": "19.1.6",
"@types/seedrandom": "3.0.8",
"@typescript-eslint/parser": "8.32.1",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.33.1",
"@vite-pwa/assets-generator": "1.0.0",
"@vite-pwa/astro": "1.1.0",
"astro-icon": "1.1.5",
"date-fns": "4.1.0",
"eslint": "9.27.0",
"eslint-import-resolver-typescript": "4.3.5",
"esbuild": "0.25.5",
"eslint": "9.28.0",
"eslint-import-resolver-typescript": "4.4.3",
"eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.1.0",
"globals": "16.2.0",
"prettier": "3.5.3",
"prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.11",
"prisma": "6.8.2",
"prisma-json-types-generator": "3.4.1",
"prettier-plugin-tailwindcss": "0.6.12",
"prisma": "6.9.0",
"prisma-json-types-generator": "3.4.2",
"tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4",
"ts-toolbelt": "9.6.0",
"tsx": "4.19.4",
"typescript-eslint": "8.32.1"
"typescript-eslint": "8.33.1",
"vite-plugin-devtools-json": "0.1.1",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
}
}

View File

@@ -0,0 +1,2 @@
-- Enable pg_trgm extension for similarity functions
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateIndex
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");

View File

@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "KycLevelClarification" AS ENUM ('NONE', 'DEPENDS_ON_PARTNERS');
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "kycLevelClarification" "KycLevelClarification",
ADD COLUMN "kycLevelDetailsId" INTEGER;

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
-- CreateIndex
CREATE INDEX "PushSubscription_endpoint_idx" ON "PushSubscription"("endpoint");
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -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';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'TEST';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[feedId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "feedId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_feedId_key" ON "User"("feedId");

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Made the column `feedId` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "feedId" SET NOT NULL;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';

View File

@@ -25,6 +25,7 @@ enum OrderIdStatus {
PENDING
APPROVED
REJECTED
WITHDRAWN
}
model Comment {
@@ -128,11 +129,13 @@ enum AccountStatusChange {
}
enum NotificationType {
TEST
COMMENT_STATUS_CHANGE
REPLY_COMMENT_CREATED
COMMUNITY_NOTE_ADDED
/// Comment that is not a reply. May include a rating.
ROOT_COMMENT_CREATED
SUGGESTION_CREATED
SUGGESTION_MESSAGE
SUGGESTION_STATUS_CHANGE
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
@@ -297,6 +300,11 @@ enum ServiceSuggestionType {
EDIT_SERVICE
}
enum KycLevelClarification {
NONE
DEPENDS_ON_PARTNERS
}
model ServiceSuggestion {
id Int @id @default(autoincrement())
type ServiceSuggestionType
@@ -336,9 +344,11 @@ model Service {
id Int @id @default(autoincrement())
name String
slug String @unique
previousSlugs String[] @default([])
description String
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE)
overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
@@ -396,6 +406,7 @@ model Service {
@@index([createdAt])
@@index([updatedAt])
@@index([slug])
@@index([previousSlugs])
}
model ServiceContactMethod {
@@ -487,6 +498,7 @@ model User {
moderator Boolean @default(false)
verifiedLink String?
secretTokenHash String @unique
feedId String @unique @default(cuid(2))
/// Computed via trigger. Do not update through prisma.
totalKarma Int @default(0)
@@ -506,6 +518,7 @@ model User {
notifications Notification[] @relation("NotificationOwner")
notificationPreferences NotificationPreferences?
serviceAffiliations ServiceUser[] @relation("UserServices")
pushSubscriptions PushSubscription[]
@@index([createdAt])
@@index([totalKarma])
@@ -565,6 +578,7 @@ enum VerificationStepStatus {
IN_PROGRESS
PASSED
FAILED
WARNING
}
model VerificationStep {
@@ -654,3 +668,21 @@ model Announcement {
@@index([isActive, startDate, endDate])
}
model PushSubscription {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @unique
/// Public key for encryption
p256dh String
/// Authentication secret
auth String
/// To identify different devices
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([endpoint])
}

View File

@@ -18,8 +18,10 @@ import {
type User,
type ServiceVisibility,
ServiceSuggestionType,
KycLevelClarification,
VerificationStepStatus,
} from '@prisma/client'
import { uniqBy } from 'lodash-es'
import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator'
import { kycLevels } from '../src/constants/kycLevels'
@@ -609,11 +611,21 @@ const generateFakeService = (users: User[]) => {
const name = faker.helpers.arrayElement(serviceNames)
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
probability: 0.8,
})
return {
name,
slug,
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
description: faker.helpers.arrayElement(serviceDescriptions),
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
kycLevelClarification: faker.helpers.maybe(
() =>
faker.helpers.arrayElement(omit(Object.values(KycLevelClarification), [KycLevelClarification.NONE])),
{ probability: 0.25 }
),
overallScore: 0,
privacyScore: 0,
trustScore: 0,
@@ -636,6 +648,19 @@ const generateFakeService = (users: User[]) => {
},
verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
verificationSteps:
(status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED') && faker.datatype.boolean(0.75)
? {
create: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
title: faker.lorem.sentence(),
description: faker.lorem.paragraph(),
status: faker.helpers.arrayElement(Object.values(VerificationStepStatus)),
evidenceMd: faker.lorem.paragraph(),
createdAt: faker.date.recent(),
updatedAt: faker.date.recent(),
})),
}
: undefined,
referral: faker.helpers.arrayElement([
`?ref=${faker.string.alphanumeric(6)}`,
`/ref/${faker.string.alphanumeric(6)}`,
@@ -654,8 +679,10 @@ const generateFakeService = (users: User[]) => {
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples),
tosReviewAt: faker.date.past(),
tosReview,
tosReviewAt: tosReview
? faker.date.recent()
: faker.helpers.maybe(() => faker.date.recent(), { probability: 0.5 }),
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(),
internalNotes: faker.helpers.maybe(
@@ -916,7 +943,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
},
moderator: {
name: 'moderator_dev',
@@ -928,7 +955,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
},
verified: {
name: 'verified_dev',
@@ -1116,7 +1143,7 @@ async function main() {
}
let users = await Promise.all(
Array.from({ length: 10 }, async () => {
Array.from({ length: 570 }, async () => {
const { user } = await createAccount()
return user
})
@@ -1134,7 +1161,7 @@ async function main() {
// ---- Create services ----
const services = await Promise.all(
Array.from({ length: numServices }, async () => {
const serviceData = generateFakeService(users)
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await prisma.service.create({
@@ -1274,7 +1301,7 @@ async function main() {
// ---- Create service suggestions for normal_dev user ----
// First create 3 CREATE_SERVICE suggestions with their services
for (let i = 0; i < 3; i++) {
const serviceData = generateFakeService(users)
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await prisma.service.create({

View File

@@ -22,8 +22,9 @@ DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
RETURNS INT AS $$
DECLARE
privacy_score INT := 50; -- Start from middle value (50)
privacy_score INT := 0;
kyc_factor INT;
clarification_factor INT := 0;
onion_factor INT := 0;
i2p_factor INT := 0;
monero_factor INT := 0;
@@ -46,6 +47,16 @@ BEGIN
FROM "Service"
WHERE "id" = service_id;
-- Adjust score based on KYC level clarification modifiers
SELECT
CASE
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
ELSE 0 -- Default modifier when no clarification or unrecognized value
END
INTO clarification_factor
FROM "Service"
WHERE "id" = service_id;
-- Check for onion URLs
IF EXISTS (
SELECT 1 FROM "Service"
@@ -78,7 +89,7 @@ BEGIN
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
-- Calculate final privacy score (base 100)
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
privacy_score := 50 + kyc_factor + clarification_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
-- Ensure the score is in reasonable bounds (0-100)
privacy_score := GREATEST(0, LEAST(100, privacy_score));
@@ -91,9 +102,11 @@ $$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
RETURNS INT AS $$
DECLARE
trust_score INT := 50; -- Start from middle value (50)
trust_score INT := 0;
verification_factor INT;
attributes_score INT := 0;
recently_listed_factor INT := 0;
tos_penalty_factor INT := 0;
BEGIN
-- Get verification status factor
SELECT
@@ -124,7 +137,7 @@ BEGIN
AND "verificationStatus" = 'APPROVED'
AND (NOW() - "listedAt") <= INTERVAL '15 days'
) THEN
trust_score := trust_score - 10;
recently_listed_factor := -10;
-- Update the isRecentlyListed flag to true
UPDATE "Service"
SET "isRecentlyListed" = TRUE
@@ -135,10 +148,21 @@ BEGIN
SET "isRecentlyListed" = FALSE
WHERE id = service_id;
END IF;
-- Apply penalty if ToS cannot be analyzed
IF EXISTS (
SELECT 1
FROM "Service"
WHERE id = service_id
AND "tosReviewAt" IS NOT NULL
AND "tosReview" IS NULL
) THEN
tos_penalty_factor := -3;
END IF;
-- Calculate final trust score (base 100)
trust_score := trust_score + verification_factor + attributes_score;
trust_score := 50 + verification_factor + attributes_score + recently_listed_factor + tos_penalty_factor;
-- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score));

View File

@@ -3,7 +3,20 @@ RETURNS TRIGGER AS $$
DECLARE
suggestion_status_change "ServiceSuggestionStatusChange";
BEGIN
IF TG_OP = 'INSERT' THEN -- Corresponds to ServiceSuggestionMessage insert
IF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestion' THEN -- Corresponds to ServiceSuggestion insert
-- Notify all admins when a new suggestion is created
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId")
SELECT u."id", 'SUGGESTION_CREATED', NEW."id"
FROM "User" u
WHERE u."admin" = true
AND NOT EXISTS (
SELECT 1 FROM "Notification" n
WHERE n."userId" = u."id"
AND n."type" = 'SUGGESTION_CREATED'
AND n."aboutServiceSuggestionId" = NEW."id"
);
ELSIF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestionMessage' THEN -- Corresponds to ServiceSuggestionMessage insert
-- Notify suggestion author (if not the sender)
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
@@ -55,6 +68,13 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
-- Trigger for new suggestions
DROP TRIGGER IF EXISTS service_suggestion_created_notifications_trigger ON "ServiceSuggestion";
CREATE TRIGGER service_suggestion_created_notifications_trigger
AFTER INSERT ON "ServiceSuggestion"
FOR EACH ROW
EXECUTE FUNCTION trigger_service_suggestion_notifications();
-- Trigger for new messages
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
CREATE TRIGGER service_suggestion_message_notifications_trigger

View 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();

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#00bfff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#3BDB78" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 619 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#FF0040" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#FF0040" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 613 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#fff" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#FF0000" viewBox="0 0 32 32" height="32" width="32">
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#040505" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#0080FF" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#33BE00" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#fff" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#00ffff" class="a" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#0080ff" class="a" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#fff" class="b" d="M4 4h24v24H4z" />
<path fill="#0080ff" class="a" fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -1,13 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<style>
@media (prefers-color-scheme: light) {
path {
fill: #0080ff;
}
}
</style>
<path fill="#00ffff" fill-rule="evenodd"
<path fill="#040505" class="b" d="M4 4h24v24H4z" />
<path fill="#00ffff" class="a" fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 642 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#040505" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill-rule="evenodd"
d="M30 26a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h20a4 4 0 0 1 4 4v20ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 566 B

View File

@@ -1,11 +1,9 @@
import { type Prisma, type PrismaClient } from '@prisma/client'
import { type Prisma } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
import { prisma } from '../../lib/prisma'
const selectAnnouncementReturnFields = {
id: true,

View File

@@ -1,15 +1,17 @@
import { adminAnnouncementActions } from './announcement'
import { adminAttributeActions } from './attribute'
import { adminEventActions } from './event'
import { adminNotificationActions } from './notification'
import { adminServiceActions } from './service'
import { adminServiceSuggestionActions } from './serviceSuggestion'
import { adminUserActions } from './user'
import { verificationStep } from './verificationStep'
export const adminActions = {
attribute: adminAttributeActions,
announcement: adminAnnouncementActions,
attribute: adminAttributeActions,
event: adminEventActions,
notification: adminNotificationActions,
service: adminServiceActions,
serviceSuggestions: adminServiceSuggestionActions,
user: adminUserActions,

View File

@@ -0,0 +1,33 @@
import { z } from 'astro/zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = {
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
}),
handler: async (input) => {
const users = await prisma.user.findMany({
where: { name: { in: input.userNames } },
select: { id: true },
})
const notifications = await prisma.notification.createManyAndReturn({
data: users.map((user) => ({
type: 'TEST',
userId: user.id,
})),
select: { id: true },
})
return {
message: `Created ${notifications.length.toString()} notifications.`,
}
},
}),
}

View File

@@ -1,48 +1,20 @@
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es'
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma'
import { separateServiceUrlsByType } from '../../lib/urls'
import {
imageFileSchema,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
zodContactMethod,
} from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(20),
description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z
.string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
.optional()
.nullable()
.default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
})
const addSlugIfMissing = <
T extends {
slug?: string | null | undefined
@@ -62,12 +34,61 @@ const addSlugIfMissing = <
}),
})
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(40),
description: z.string().min(1),
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
kycLevel: z.coerce.number().int().min(0).max(4),
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z
.string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
.optional()
.nullable()
.default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
})
// Define schema for the create action input
const createServiceInputSchema = serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing)
// Define schema for the update action input
const updateServiceInputSchema = serviceSchemaBase
.extend({
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing)
const evidenceImageAddSchema = z.object({
serviceId: z.number().int().positive(),
imageFile: imageFileSchema,
})
const evidenceImageDeleteSchema = z.object({
fileUrl: z.string().startsWith('/files/evidence/', 'Must be a valid evidence file URL'),
})
export const adminServiceActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
handler: async (input, context) => {
input: createServiceInputSchema,
handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
const existing = await prisma.service.findUnique({
where: {
slug: input.slug,
@@ -85,19 +106,28 @@ export const adminServiceActions = {
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({
data: {
name: input.name,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
@@ -134,12 +164,8 @@ export const adminServiceActions = {
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase
.extend({
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing),
handler: async (input) => {
input: updateServiceInputSchema,
handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
const anotherServiceWithNewSlug = await prisma.service.findUnique({
where: {
slug: input.slug,
@@ -154,19 +180,24 @@ export const adminServiceActions = {
})
}
const imageUrl = input.removeImage
? null
: input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const existingService = await prisma.service.findUnique({
where: { id: input.id },
include: {
categories: true,
select: {
slug: true,
previousSlugs: true,
categories: {
select: {
id: true,
},
},
attributes: {
include: {
attribute: true,
select: {
attributeId: true,
attribute: {
select: {
id: true,
},
},
},
},
},
@@ -187,23 +218,46 @@ export const adminServiceActions = {
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
const imageUrl = input.removeImage
? null
: input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
previousSlugs:
existingService.slug !== input.slug
? {
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
(slug) => slug !== input.slug
),
}
: undefined,
imageUrl,
categories: {
@@ -234,7 +288,7 @@ export const adminServiceActions = {
permissions: 'admin',
input: z.object({
label: z.string().min(1).max(50).nullable(),
value: z.string().url(),
value: zodContactMethod,
serviceId: z.number().int().positive(),
}),
handler: async (input) => {
@@ -255,7 +309,7 @@ export const adminServiceActions = {
input: z.object({
id: z.number().int().positive(),
label: z.string().min(1).max(50).nullable(),
value: z.string().url(),
value: zodContactMethod,
serviceId: z.number().int().positive(),
}),
handler: async (input) => {
@@ -366,4 +420,49 @@ 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) => {
await deleteFileLocally(input.fileUrl)
return { success: true }
},
}),
},
}

View File

@@ -1,12 +1,10 @@
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
import { type Prisma, type ServiceUserRole } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
import { prisma } from '../../lib/prisma'
const selectUserReturnFields = {
id: true,

View File

@@ -0,0 +1,5 @@
import { apiServiceActions } from './service'
export const apiActions = {
service: apiServiceActions,
}

View File

@@ -0,0 +1,151 @@
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { pick } from 'lodash-es'
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
import { getKycLevelInfo } from '../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
import type { Prisma } from '@prisma/client'
export const apiServiceActions = {
get: defineProtectedAction({
accept: 'json',
permissions: 'guest',
input: z.object({
id: z.coerce.number().int().positive().optional(),
slug: z
.string()
.min(1)
.max(2048)
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
url: zodUrlOptionalProtocol.optional(),
}),
handler: async (input, context) => {
if (!input.id && !input.slug && !input.url) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'At least one of the following parameters is required: id, slug, url',
})
}
const urlVariants = input.url
? [input.url]
.flatMap((url) =>
[
url,
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
].filter((url) => url !== undefined)
)
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined
const select = {
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
kycLevelClarification: true,
verificationStatus: true,
categories: {
select: {
name: true,
slug: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
referral: true,
listedAt: true,
verifiedAt: true,
serviceVisibility: true,
} as const satisfies Prisma.ServiceSelect
let service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
OR: [
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
...(urlVariants
? ([
{ serviceUrls: { hasSome: urlVariants } },
{ onionUrls: { hasSome: urlVariants } },
{ i2pUrls: { hasSome: urlVariants } },
] satisfies Prisma.ServiceWhereInput[])
: []),
],
},
select,
})
if (!service && input.slug) {
service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug },
},
select,
})
}
if (
!service ||
(service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') ||
!service.listedAt ||
service.listedAt > new Date()
) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
return {
id: service.id,
slug: service.slug,
name: service.name,
description: service.description,
serviceVisibility: service.serviceVisibility,
verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value',
'slug',
'label',
'labelShort',
'description',
]),
verifiedAt: service.verifiedAt,
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
kycLevelClarification: service.kycLevelClarification,
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
'value',
'name',
'description',
]),
categories: service.categories,
listedAt: service.listedAt,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
}
},
}),
}

View File

@@ -345,10 +345,11 @@ export const commentActions = {
'order-id-status',
'kyc-requested',
'funds-blocked',
'toggle-rating-active',
]),
value: z.union([
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
z.boolean(),
z.string(),
]),
@@ -411,7 +412,7 @@ export const commentActions = {
updateData.privateContext = input.value as string
break
case 'order-id-status':
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
break
case 'kyc-requested':
updateData.kycRequested = !!input.value
@@ -419,6 +420,9 @@ export const commentActions = {
case 'funds-blocked':
updateData.fundsBlocked = !!input.value
break
case 'toggle-rating-active':
updateData.ratingActive = !!input.value
break
}
// Update the comment

View File

@@ -1,5 +1,6 @@
import { accountActions } from './account'
import { adminActions } from './admin'
import { apiActions } from './api'
import { commentActions } from './comment'
import { notificationActions } from './notifications'
import { serviceActions } from './service'
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
export const server = {
account: accountActions,
admin: adminActions,
api: apiActions,
comment: commentActions,
notification: notificationActions,
service: serviceActions,

View File

@@ -23,6 +23,63 @@ export const notificationActions = {
})
},
}),
webPush: {
subscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
input: z.object({
endpoint: z.string(),
p256dhKey: z.string(),
authKey: z.string(),
userAgent: z.string().optional(),
}),
handler: async (input, context) => {
await prisma.pushSubscription.upsert({
where: {
userId: context.locals.user.id,
endpoint: input.endpoint,
},
update: {
p256dh: input.p256dhKey,
auth: input.authKey,
userAgent: input.userAgent,
},
create: {
userId: context.locals.user.id,
endpoint: input.endpoint,
p256dh: input.p256dhKey,
auth: input.authKey,
userAgent: input.userAgent,
},
})
},
}),
unsubscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
input: z.object({
endpoint: z.string().optional(),
}),
handler: async (input, context) => {
if (input.endpoint) {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
endpoint: input.endpoint,
},
})
} else {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
},
})
}
},
}),
},
preferences: {
update: defineProtectedAction({
accept: 'form',
@@ -31,7 +88,7 @@ export const notificationActions = {
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
enableAutowatchMyComments: z.coerce.boolean().optional(),
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).max(1_000_000).optional(),
}),
handler: async (input, context) => {
await prisma.notificationPreferences.upsert({

View File

@@ -1,10 +1,4 @@
import {
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { Currency, KycLevelClarification } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns'
@@ -12,11 +6,13 @@ import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma'
import { separateServiceUrlsByType } from '../lib/urls'
import {
imageFileSchemaRequired,
stringListOfUrlsSchema,
stringListOfContactMethodsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
@@ -33,11 +29,16 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
const findPossibleDuplicates = async (input: { name: string }) => {
const possibleDuplicates = await prisma.service.findMany({
const matches = await findServicesBySimilarity(input.name, 0.3)
return await prisma.service.findMany({
where: {
name: {
contains: input.name,
mode: 'insensitive',
id: {
in: matches.map(({ id }) => id),
},
listedAt: { lte: new Date() },
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
},
},
select: {
@@ -47,8 +48,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
description: true,
},
})
return possibleDuplicates
}
const serializeExtraNotes = <T extends Record<string, unknown>>(
@@ -63,6 +62,8 @@ const serializeExtraNotes = <T extends Record<string, unknown>>(
serializedValue = value
} else if (value === undefined || value === null) {
serializedValue = ''
} else if (Array.isArray(value)) {
serializedValue = value.map((item) => String(item)).join(', ')
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
serializedValue = value.toString()
@@ -122,9 +123,9 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await prisma.serviceSuggestion.create({
data: {
type: ServiceSuggestionType.EDIT_SERVICE,
type: 'EDIT_SERVICE',
notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING,
status: 'PENDING',
userId: context.locals.user.id,
serviceId: service.id,
},
@@ -149,26 +150,22 @@ export const serviceSuggestionActions = {
.max(SUGGESTION_SLUG_MAX_LENGTH)
.regex(/^[a-z0-9-]+$/, {
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
})
.refine(
async (slug) => {
const exists = await prisma.service.findUnique({
select: { id: true },
where: { slug },
})
return !exists
},
{ message: 'Slug must be unique, try a different one' }
),
}),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired,
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
contactMethods: stringListOfContactMethodsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
kycLevelClarification: z.nativeEnum(KycLevelClarification),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
imageFile: imageFileSchemaRequired,
rulesConfirm: z.literal('on', {
errorMap: () => ({
message: 'You must accept the suggestion rules and process to continue',
}),
}),
/** @deprecated Honey pot field, do not use */
message: z.unknown().optional(),
skipDuplicateCheck: z
@@ -188,8 +185,16 @@ export const serviceSuggestionActions = {
location: 'serviceSuggestion.createService',
})
const serviceWithSameSlug = await prisma.service.findUnique({
select: { id: true, name: true, slug: true, description: true },
where: { slug: input.slug },
})
if (!input.skipDuplicateCheck) {
const possibleDuplicates = await findPossibleDuplicates(input)
const possibleDuplicates = [
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
...(await findPossibleDuplicates(input)),
]
if (possibleDuplicates.length > 0) {
return {
@@ -201,15 +206,29 @@ export const serviceSuggestionActions = {
'imageFile',
'captcha-value',
'captcha-solution-hash',
'rulesConfirm',
]),
serviceSuggestion: undefined,
service: undefined,
} as const
}
} else {
if (serviceWithSameSlug) {
throw new ActionError({
message: 'Slug already in use, try a different one',
code: 'BAD_REQUEST',
})
}
}
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = {
id: true,
@@ -221,18 +240,20 @@ export const serviceSuggestionActions = {
name: input.name,
slug: input.slug,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
acceptedCurrencies: input.acceptedCurrencies,
imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
verificationStatus: 'COMMUNITY_CONTRIBUTED',
overallScore: 0,
privacyScore: 0,
trustScore: 0,
listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED,
serviceVisibility: 'UNLISTED',
categories: {
connect: input.categories.map((id) => ({ id })),
},
@@ -241,6 +262,11 @@ export const serviceSuggestionActions = {
attributeId: id,
})),
},
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
},
select: serviceSelect,
})
@@ -248,8 +274,8 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await tx.serviceSuggestion.create({
data: {
notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE,
status: ServiceSuggestionStatus.PENDING,
type: 'CREATE_SERVICE',
status: 'PENDING',
userId: context.locals.user.id,
serviceId: service.id,
},

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#BEBEBE"
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#3BDB78"
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
<rect width="431" height="63" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
<path fill="#28AE5B" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#3F3F3F"
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#28AE5B"
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
<rect width="255" height="95" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
<path fill="#3BDB78" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#BEBEBE"
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#3BDB78"
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
<rect width="255" height="95" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
<path fill="#28AE5B" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#3F3F3F"
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#28AE5B"
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -2,13 +2,14 @@
import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants'
import type { AstroChildren } from '../lib/astro'
import type { Polymorphic } from 'astro/types'
const badge = tv({
slots: {
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
icon: 'size-3 shrink-0',
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap [&>a]:hover:underline [&>a]:focus-visible:underline',
},
variants: {
color: {
@@ -122,18 +123,22 @@ const badge = tv({
},
})
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
type Props<
Tag extends 'a' | 'div' | 'li' = 'div',
Text extends string | undefined = string | undefined,
> = Polymorphic<
VariantProps<typeof badge> & {
as: Tag
icon?: string
endIcon?: string
text: string
text?: Text
inlineIcon?: boolean
classNames?: {
icon?: string
text?: string
endIcon?: string
}
children?: Text extends string ? never : AstroChildren
}
>
@@ -161,7 +166,7 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
)
}
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
<span class={textSlot({ class: classNames?.text })}>{textContent ?? <slot />}</span>
{
!!endIconName && (
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />

View File

@@ -2,12 +2,19 @@
import LoadingIndicator from 'astro-loading-indicator/component'
import { Schema } from 'astro-seo-schema'
import { ClientRouter } from 'astro:transitions'
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import DevToolsMessageScript from './DevToolsMessageScript.astro'
import DynamicFavicon from './DynamicFavicon.astro'
import HtmxScript from './HtmxScript.astro'
import NotificationEventsScript from './NotificationEventsScript.astro'
import { makeOgImageUrl } from './OgImage'
import ServerEventsScript from './ServerEventsScript.astro'
import ServiceWorkerScript from './ServiceWorkerScript.astro'
import TailwindJsPluggin from './TailwindJsPluggin.astro'
import type { ComponentProps } from 'astro/types'
@@ -70,12 +77,6 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
---
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
<!-- Primary Meta Tags -->
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
@@ -98,10 +99,20 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<!-- Other -->
<link rel="sitemap" href="/sitemap-index.xml" />
<meta name="theme-color" content="#040505" />
<!-- PWA -->
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
<DynamicFavicon />
<!-- Components -->
<ClientRouter />
{
!Astro.url.pathname.startsWith('/admin') && (
<ClientRouter />
) /* Disable to prevent bugs in important admin forms */
}
<LoadingIndicator color="green" />
<TailwindJsPluggin />
{htmx && <HtmxScript />}
@@ -131,3 +142,15 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
/>
))
}
{
Astro.locals.user && (
<>
<ServerEventsScript />
<ServiceWorkerScript />
<NotificationEventsScript />
</>
)
}
<DevToolsMessageScript />

View File

@@ -6,7 +6,7 @@ import { cn } from '../lib/cn'
import type { HTMLAttributes, Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
VariantProps<typeof button> & {
as: Tag
@@ -249,7 +249,7 @@ const button = tv({
})
const {
as: Tag = 'button' as 'a' | 'button' | 'label',
as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
label,
icon,
endIcon,
@@ -286,9 +286,9 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
<ActualTag
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ??
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
aria-disabled={disabled}
aria-label={label}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps}
>

View File

@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{...htmlProps}
id={`comment-${comment.id.toString()}`}
class={cn([
'group',
'group bg-night-700',
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]',
@@ -150,13 +150,13 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
checked={comment.suspicious}
/>
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
<div class="comment-header flex items-center gap-2 text-sm">
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
<span class="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span>
</label>
<span class="flex items-center gap-1">
<span class="flex min-w-16 items-center gap-1">
<UserBadge
user={comment.author}
size="md"
@@ -179,7 +179,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
</span>
{/* User badges - more compact but still with text */}
<div class="flex flex-wrap items-center gap-1">
<div class="flex w-min grow flex-wrap items-center gap-1">
{
comment.author.admin && (
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
@@ -240,18 +240,17 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
}
{
comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall
icon={roleInfo.icon}
color={roleInfo.color}
text={`${roleInfo.label} at ${affiliation.service.name}`}
variant="faded"
inlineIcon
/>
)
})
comment.author.serviceAffiliations
.filter((affiliation) => affiliation.service.slug === serviceSlug)
.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
{roleInfo.label} at
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
</BadgeSmall>
)
})
}
</div>
</div>
@@ -270,12 +269,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
)
}
{
comment.rating !== null && !comment.parentId && (
<Tooltip
@@ -320,6 +313,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
color={commentStatusById.REJECTED.color}
text={commentStatusById.REJECTED.label}
inlineIcon
endIcon="ri:lock-line"
/>
)
}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall
icon="ri:alert-fill"
color="yellow"
text="Needs admin review"
inlineIcon
endIcon="ri:lock-line"
/>
)
}

View File

@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
privateContext: true
orderId: true
orderIdStatus: true
rating: true
ratingActive: true
}
}>
}
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.moderator) return null
<div
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
>
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
<div class="border-night-500 flex flex-wrap items-center gap-1 border-b pb-2">
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -59,42 +61,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
<Icon name="ri:close-circle-line" class="h-3.5 w-3.5" />
<span>{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.suspicious ? 'Not Spam' : 'Spam'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'VERIFIED'
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
@@ -104,12 +77,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
<Icon name="ri:verified-badge-line" class="h-3.5 w-3.5" />
<span>{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
@@ -121,12 +95,49 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
<Icon name="ri:checkbox-circle-line" class="h-3.5 w-3.5" />
<span>
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
</span>
</button>
<div class="bg-night-500 h-5 w-px"></div>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:spam-2-line" class="h-3.5 w-3.5" />
<span>{comment.suspicious ? 'Not Spam' : 'Spam'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:shield-user-line" class="h-3.5 w-3.5" />
<span>{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}</span>
</button>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.kycRequested
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -136,12 +147,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
<Icon name="ri:bank-card-line" class="h-3.5 w-3.5" />
<span>{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.fundsBlocked
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -151,8 +163,31 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
<Icon name="ri:lock-line" class="h-3.5 w-3.5" />
<span>{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}</span>
</button>
<div class="bg-night-500 h-5 w-px"></div>
{
comment.rating && (
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.ratingActive
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)}
data-action="toggle-rating-active"
data-value={!comment.ratingActive}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:star-line" class="h-3.5 w-3.5" />
<span>{comment.ratingActive ? 'Disable Rating' : 'Enable Rating'}</span>
</button>
)
}
</div>
<div class="mt-2 space-y-1.5">
@@ -208,7 +243,7 @@ if (!user || !user.admin || !user.moderator) return null
<div class="flex gap-1">
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'APPROVED'
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
@@ -218,11 +253,12 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Approve
<Icon name="ri:check-line" class="h-3.5 w-3.5" />
<span>Approve</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -232,11 +268,12 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Reject
<Icon name="ri:close-line" class="h-3.5 w-3.5" />
<span>Reject</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
@@ -246,7 +283,23 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Pending
<Icon name="ri:time-line" class="h-3.5 w-3.5" />
<span>Pending</span>
</button>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'WITHDRAWN'
? 'border-night-400 bg-night-500/50 text-night-300 border'
: 'bg-night-700 hover:bg-night-500/50 hover:text-night-300'
)}
data-action="order-id-status"
data-value="WITHDRAWN"
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:arrow-go-back-line" class="h-3.5 w-3.5" />
<span>Withdrawn</span>
</button>
</div>
</div>
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.moderator) return null
action === 'suspicious' ||
action === 'requires-admin-review' ||
action === 'kyc-requested' ||
action === 'funds-blocked'
action === 'funds-blocked' ||
action === 'toggle-rating-active'
? value === 'true'
: value,
})
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.moderator) return null
if (action === 'status') {
window.location.reload()
} else if (action === 'suspicious') {
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'Not Spam' : 'Spam'
btn.classList.toggle('bg-yellow-500/20')
btn.classList.toggle('text-yellow-400')
btn.classList.toggle('border-yellow-500/30')
@@ -298,7 +353,8 @@ if (!user || !user.admin || !user.moderator) return null
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'requires-admin-review') {
btn.textContent = value === 'true' ? 'No Review' : 'Review'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No Admin Review' : 'Needs Admin Review'
btn.classList.toggle('bg-purple-500/20')
btn.classList.toggle('text-purple-400')
btn.classList.toggle('border-purple-500/30')
@@ -309,7 +365,8 @@ if (!user || !user.admin || !user.moderator) return null
// Refresh to show updated order ID status
window.location.reload()
} else if (action === 'kyc-requested') {
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30')
@@ -317,13 +374,23 @@ if (!user || !user.admin || !user.moderator) return null
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'funds-blocked') {
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'toggle-rating-active') {
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'Disable Rating' : 'Enable Rating'
btn.classList.toggle('bg-blue-500/20')
btn.classList.toggle('text-blue-400')
btn.classList.toggle('border-blue-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
}
} else {
console.error('Error moderating comment:', error)

View File

@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" />
<InputWrapper label="Tags" name="tags">
<InputWrapper label="I experienced..." name="tags">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400">

View File

@@ -0,0 +1,51 @@
---
---
<script>
import { SOURCE_CODE_URL } from 'astro:env/client'
const logoStyle = `
padding: 0 119.5px;
display: block;
line-height: 64px;
background-size: auto 64px;
background-repeat: no-repeat;
background-position: 50% 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 240 64'%3E%3Crect width='239' height='63' x='.5' y='.5' fill='%23101413' stroke='%23292B2A' rx='7.5'/%3E%3Cpath fill='%233BDB78' d='M19 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1H19Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.9 10.8a1 1 0 0 0-.1.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L47 23.5a1 1 0 0 1 .8-1.5ZM75 22h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H74v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H75a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L186 37.2l-6.7-14.6a1 1 0 0 0-1-.6H175Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z'/%3E%3C/svg%3E");
`
setTimeout(
console.log.bind(
console,
`\n%c \n%c\n 👋%c Hi there! %c\n\n‣ We included source maps, so you can easily inspect the code. 🕵🏻‍♂️\n‣ Everything works with JavaScript disabled.\n‣ Source code: ${SOURCE_CODE_URL}`,
logoStyle,
`
font-family: cursive;
font-size: 2rem;
font-weight: bold;
`,
`
font-family: cursive;
font-size: 2rem;
font-weight: bold;
background: repeating-linear-gradient(
90deg,
#d97706 0%,
#f59e0b 20%,
#f97316 40%,
#ea580c 60%,
#f97316 80%,
#f59e0b 100%
) -100%/ 200%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
`,
`
font-size: 1rem;
font-family: system-ui, -apple-system, sans-serif;
`
)
)
</script>

View File

@@ -0,0 +1,79 @@
---
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { prisma } from '../lib/prisma'
const user = Astro.locals.user
const hasUnreadNotifications = await Astro.locals.banners.try(
'Error getting unread notification count',
async () =>
user
? !!(await prisma.notification.findFirst({
where: { userId: user.id, read: false },
select: { id: true },
}))
: false,
false
)
function addBadgeIfUnread(href: string) {
if (hasUnreadNotifications) return href.replace('.svg', '-badge.svg')
return href
}
---
{
DEPLOYMENT_MODE === 'production' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
{
DEPLOYMENT_MODE === 'development' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-dev.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-dev-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
{
DEPLOYMENT_MODE === 'staging' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-stage.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-stage-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
<script>
document.addEventListener('sse:new-notification', () => {
const links = document.querySelectorAll('link[rel="icon"]')
links.forEach((link) => {
const href = link.getAttribute('href')
if (href && href.includes('favicon') && !href.endsWith('-badge.svg')) {
const newHref = href.replace('.svg', '-badge.svg')
link.setAttribute('href', newHref)
}
})
})
</script>

View File

@@ -1,6 +1,7 @@
---
import { Icon } from 'astro-icon/components'
import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { SOURCE_CODE_URL } from 'astro:env/client'
import { I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { cn } from '../lib/cn'
@@ -27,6 +28,18 @@ const links = [
icon: 'i2p',
external: true,
},
{
href: '/docs/api',
label: 'API',
icon: 'ri:plug-line',
external: false,
},
{
href: '/feeds',
label: 'RSS',
icon: 'ri:rss-line',
external: false,
},
{
href: '/about',
label: 'About',
@@ -43,7 +56,10 @@ const links = [
const { class: className, ...htmlProps } = Astro.props
---
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}>
<footer
class={cn('xs:gap-x-6 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 p-4', className)}
{...htmlProps}
>
{
links.map(
({ href, label, icon, external }) =>
@@ -52,9 +68,9 @@ const { class: className, ...htmlProps } = Astro.props
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
class="text-day-500 xs:gap-1 flex items-center gap-0.5 text-sm transition-colors hover:text-gray-200 hover:underline"
>
<Icon name={icon} class="h-4 w-4" />
<Icon name={icon} class="xs:opacity-100 h-4 w-4 opacity-40" />
{label}
</a>
)

View File

@@ -123,6 +123,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
transition:name="header-admin-link"
text="Admin Dashboard"
position="left"
aria-label="Admin Dashboard"
>
<Icon name="ri:home-gear-line" class="size-10" />
</Tooltip>

View File

@@ -27,6 +27,8 @@ const count =
user && (
<a
href="/notifications"
data-notification-count-link
data-current-count={count}
class={cn(
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
className
@@ -35,11 +37,32 @@ const count =
{...htmlProps}
>
<Icon name="material-symbols:notifications-outline" class="size-5" />
{count > 0 && (
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500">
{count > 99 ? '★' : count.toLocaleString()}
</span>
)}
<span
data-notification-count-badge
class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500 empty:hidden"
>
{count > 0 ? (count > 99 ? '★' : count.toLocaleString()) : ''}
</span>
</a>
)
}
<script>
document.addEventListener('sse:new-notification', () => {
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
const currentCount = Number(link.getAttribute('data-current-count') || 0)
const newCount = currentCount + 1
link.querySelectorAll<HTMLElement>('[data-notification-count-badge]').forEach((badge) => {
badge.textContent = newCount > 0 ? (newCount > 99 ? '★' : newCount.toLocaleString()) : ''
})
link.setAttribute(
'aria-label',
`Go to notifications${newCount > 0 ? ` (${newCount.toLocaleString()} unread)` : ''}`
)
link.setAttribute('data-current-count', String(newCount))
})
})
</script>

View File

@@ -0,0 +1,61 @@
---
import { cn } from '../lib/cn'
import type InputWrapper from './InputWrapper.astro'
import type { AstroChildren } from '../lib/astro'
import type { ComponentProps } from 'astro/types'
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
disabled?: boolean
id?: string
} & (
| {
label: string
children?: undefined
}
| {
label?: undefined
children: AstroChildren
}
)
const { disabled, name, required, error, id, label } = Astro.props
const hasError = !!error && error.length > 0
---
{}
<div>
<label
class={cn(
'inline-flex cursor-pointer items-center gap-2',
hasError && 'text-red-300',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<input
transition:persist
type="checkbox"
id={id}
name={name}
required={required}
disabled={disabled}
class={cn(disabled && 'opacity-50')}
/>
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
</label>
{
hasError &&
(typeof error === 'string' ? (
<p class="text-sm text-red-500">{error}</p>
) : (
<ul class="text-sm text-red-500">
{error.map((e) => (
<li>{e}</li>
))}
</ul>
))
}
</div>

View File

@@ -14,10 +14,11 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' |
value: string
disabled?: boolean
}[]
selectProps?: Omit<HTMLAttributes<'select'>, 'name'>
selectProps?: Omit<HTMLAttributes<'select'>, 'name' | 'value'>
selectedValue?: string[] | string
}
const { options, selectProps, ...wrapperProps } = Astro.props
const { options, selectProps, selectedValue, ...wrapperProps } = Astro.props
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
@@ -39,7 +40,15 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
>
{
options.map((option) => (
<option value={option.value} disabled={option.disabled}>
<option
value={option.value}
disabled={option.disabled}
selected={
Array.isArray(selectedValue)
? selectedValue.includes(option.value)
: selectedValue === option.value
}
>
{option.label}
</option>
))

View File

@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
import Button from './Button.astro'
import type { HTMLAttributes } from 'astro/types'
import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
hideCancel?: boolean
icon?: string
label?: string
disabled?: boolean
color?: ComponentProps<typeof Button>['color']
}
const {
hideCancel = false,
icon = 'ri:send-plane-2-line',
label = 'Submit',
disabled = false,
class: className,
color = 'success',
...htmlProps
} = Astro.props
---
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
<Button type="submit" label={label} icon={icon} class="ml-auto" color={color} disabled={disabled} />
</div>

View File

@@ -27,6 +27,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
class={cn(
baseInputClassNames.input,
baseInputClassNames.textarea,
!!inputProps?.rows && 'h-auto',
inputProps?.class,
hasError && baseInputClassNames.error,
!!inputProps?.disabled && baseInputClassNames.disabled

View File

@@ -48,7 +48,9 @@ const hasError = !!error && error.length > 0
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
<label for={inputId}>{label}</label>
<label for={inputId} transition:persist>
{label}
</label>
{required && '*'}
</legend>
{!!descriptionLabel && (

View File

@@ -0,0 +1,29 @@
---
---
<script>
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
import { makeNotificationOptions } from '../lib/notificationOptions'
document.addEventListener('sse:new-notification', (event) => {
if (isBrowserNotificationsEnabled()) {
const payload = event.detail
const notification = showBrowserNotification(
payload.title,
makeNotificationOptions(payload, { removeActions: true })
)
// Handle notification click
if (notification) {
notification.onclick = () => {
const defaultAction = payload.actions.find((a) => a.url) ?? payload.actions[0]
if (defaultAction?.url) {
window.open(defaultAction.url, '_blank')
}
notification.close()
}
}
}
})
</script>

View File

@@ -0,0 +1,151 @@
---
import favicon from '../../public/favicon.svg'
import logoMiniFull from '../assets/logo/logo-mini-full.svg'
import logoNormal from '../assets/logo/logo-normal.svg'
import logoSmall from '../assets/logo/logo-small.svg'
import reviewBadgeLongBlack from '../assets/review-badge/long-black.svg'
import reviewBadgeLongWhite from '../assets/review-badge/long-white.svg'
import reviewBadgeShortBlack from '../assets/review-badge/short-black.svg'
import reviewBadgeShortWhite from '../assets/review-badge/short-white.svg'
import Button from './Button.astro'
import MyPicture from './MyPicture.astro'
const categories: {
title: string
assets: {
name: string
path: typeof logoNormal
alt: string
}[]
}[] = [
{
title: 'Logos',
assets: [
{
name: 'Logo',
path: logoNormal,
alt: 'KYCnot.me logo normal version',
},
{
name: 'Logo small',
path: logoSmall,
alt: 'KYCnot.me logo small version',
},
{
name: 'Logo mini',
path: logoMiniFull,
alt: 'KYCnot.me logo mini version',
},
{
name: 'Logo icon',
path: favicon,
alt: 'KYCnot.me logo icon version',
},
],
},
{
title: 'Review badges',
assets: [
{
name: 'Review badge (long black)',
path: reviewBadgeLongBlack,
alt: 'KYCnot.me review badge long black version',
},
{
name: 'Review badge (long white)',
path: reviewBadgeLongWhite,
alt: 'KYCnot.me review badge long white version',
},
{
name: 'Review badge (short black)',
path: reviewBadgeShortBlack,
alt: 'KYCnot.me review badge short black version',
},
{
name: 'Review badge (short white)',
path: reviewBadgeShortWhite,
alt: 'KYCnot.me review badge short white version',
},
],
},
]
---
<div class="not-prose mb-16 space-y-8">
{
categories.map((category) => {
return (
<>
<h3 class="font-title mb-2 text-center text-xl font-semibold text-white">{category.title}</h3>
<ul class="xs:grid-cols-2 grid grid-cols-1 gap-6">
{category.assets.map((asset) => (
<li>
<div
class="bg-transparency-grid mx-auto flex aspect-[3/1] max-w-sm items-center justify-center rounded-lg p-4"
style={{
'--transparency-grid-color-1': 'var(--color-night-600)',
'--transparency-grid-color-2': 'var(--color-night-500)',
'--transparency-grid-size': 'calc(var(--spacing) * 4)',
}}
>
<MyPicture
src={asset.path}
alt={asset.alt}
pictureAttributes={{
class: 'contents',
}}
class="max-h-full min-h-8 max-w-full min-w-8 object-contain"
/>
</div>
<div class="mt-2 text-center">
<Button
as="a"
href={asset.path.src}
download={asset.name}
label={asset.name}
size="sm"
color="white"
variant="faded"
icon="ri:download-line"
/>
</div>
</li>
))}
</ul>
</>
)
})
}
</div>
<style>
.bg-transparency-grid {
--transparency-grid-color-1: #fff;
--transparency-grid-color-2: #ccc;
--transparency-grid-size: calc(var(--spacing) * 8);
background-color: var(--transparency-grid-color-1);
background-image:
linear-gradient(
45deg,
var(--transparency-grid-color-2) 25%,
transparent 25%,
transparent 75%,
var(--transparency-grid-color-2) 75%,
var(--transparency-grid-color-2)
),
linear-gradient(
45deg,
var(--transparency-grid-color-2) 25%,
transparent 25%,
transparent 75%,
var(--transparency-grid-color-2) 75%,
var(--transparency-grid-color-2)
);
background-size: var(--transparency-grid-size) var(--transparency-grid-size);
background-position:
0 0,
calc(var(--transparency-grid-size) / 2) calc(var(--transparency-grid-size) / 2);
}
</style>

View File

@@ -0,0 +1,267 @@
---
import { Icon } from 'astro-icon/components'
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
import { SUPPORT_EMAIL } from '../constants/project'
import { cn } from '../lib/cn'
import Button from './Button.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
dismissable?: boolean
hideIfEnabled?: boolean
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
select: {
endpoint: true
userAgent: true
}
}>[]
}
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
---
<div
data-push-notification-banner
data-dismissed={undefined as true | undefined /* Updated by client script */}
data-notification-supported={undefined as true | undefined /* Updated by client script */}
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
data-is-enabled={undefined as true | undefined /* Updated by client script */}
data-loaded={undefined as true | undefined /* Updated by client script */}
data-notification-permissions={undefined as
| NotificationPermission
| undefined /* Updated by client script */}
data-vapid-public-key={VAPID_PUBLIC_KEY}
class={cn(
'no-js:hidden xs:grid-cols-[auto_auto] xs:justify-between relative isolate grid items-center justify-stretch gap-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4 not-data-loaded:hidden',
'data-dismissed:hidden',
hideIfEnabled && 'data-is-enabled:hidden',
'not-data-notification-supported:hidden',
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
className
)}
{...props}
>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-blue-500/20 to-transparent opacity-50 blur-xl"
>
</div>
<div
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-blue-500/20 to-transparent opacity-50 blur-xl"
>
</div>
</div>
<div class="flex items-center gap-x-3">
<div class="rounded-md bg-blue-800/30 p-2">
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
</div>
<div>
<h3 class="font-medium text-blue-100" data-banner-title>Turn on push notifications?</h3>
<p class="text-sm text-blue-200/80" data-banner-description>Get notifications on this device.</p>
</div>
</div>
<div class="xs:justify-end flex shrink flex-wrap items-center justify-center gap-2">
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
<Button
as="span"
label="Yes, notify me"
color="white"
data-push-action="subscribe"
data-show-if-disabled
/>
<Button
as="span"
label="Stop notifications"
color="white"
variant="faded"
data-push-action="unsubscribe"
data-show-if-enabled
/>
</div>
<div
class="col-span-full flex items-center justify-center gap-2 leading-tight text-red-500 has-[[data-error-message]:empty]:hidden"
>
<Icon name="ri:error-warning-line" class="size-5 shrink-0" />
<span>
<span data-error-message></span>
<a href={`mailto:${SUPPORT_EMAIL}`} class="text-red-300 underline hover:text-red-200">
Contact support
</a>
</span>
</div>
</div>
<script>
/////////////////////////////////////////////////////////////
// Script to handle push notification banner dismissal. //
/////////////////////////////////////////////////////////////
import { typedLocalStorage } from '../lib/localstorage'
document.addEventListener('astro:page-load', () => {
let pushNotificationsBannerDismissedAt = typedLocalStorage.pushNotificationsBannerDismissedAt.get()
if (
pushNotificationsBannerDismissedAt &&
pushNotificationsBannerDismissedAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 365) // 1 year
) {
typedLocalStorage.pushNotificationsBannerDismissedAt.remove()
pushNotificationsBannerDismissedAt = undefined
}
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((banner) => {
const skipButton = banner.querySelector<HTMLElement>('[data-dismiss-button]')
if (!skipButton) return
if (pushNotificationsBannerDismissedAt) {
banner.dataset.dismissed = ''
}
skipButton.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
banner.dataset.dismissed = ''
const now = new Date()
typedLocalStorage.pushNotificationsBannerDismissedAt.set(now)
pushNotificationsBannerDismissedAt = now
})
})
})
</script>
<script>
///////////////////////////////////////////
// Script to handle push notifications. //
///////////////////////////////////////////
import {
subscribeToPushNotifications,
unsubscribeFromPushNotifications,
parsePushSubscriptions,
isCurrentDeviceSubscribed,
supportsPushNotifications,
type SafeResult,
} from '../lib/client/clientPushNotifications'
import {
enableBrowserNotifications,
disableBrowserNotifications,
isBrowserNotificationsEnabled,
supportsBrowserNotifications,
} from '../lib/client/browserNotifications'
async function setDataAttributes(banner: HTMLElement) {
if (!supportsPushNotifications() && !supportsBrowserNotifications()) {
return
}
banner.dataset.notificationSupported = ''
banner.dataset.notificationPermissions = Notification.permission
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
const titleElement = banner.querySelector<HTMLElement>('[data-banner-title]')
const descriptionElement = banner.querySelector<HTMLElement>('[data-banner-description]')
if (await isCurrentDeviceSubscribed(serverSubscriptions)) {
if (titleElement) titleElement.textContent = 'Push notifications enabled'
if (descriptionElement) descriptionElement.textContent = 'Turn push notifications off for this device?'
banner.dataset.isEnabled = ''
banner.dataset.loaded = ''
return
}
if (isBrowserNotificationsEnabled()) {
if (titleElement) titleElement.textContent = 'Browser notifications enabled'
if (descriptionElement)
descriptionElement.textContent = 'Turn off notifications? (They only work while the tab is open.)'
banner.dataset.isEnabled = ''
banner.dataset.loaded = ''
return
}
// Default state (disabled)
if (titleElement) titleElement.textContent = 'Turn on push notifications?'
if (descriptionElement) descriptionElement.textContent = 'Get notifications on this device.'
banner.dataset.loaded = ''
}
document.addEventListener('astro:page-load', async () => {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
await setDataAttributes(banner)
const vapidPublicKey = banner.dataset.vapidPublicKey
banner.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
button.addEventListener('click', async (event) => {
event.preventDefault()
event.stopPropagation()
const action = button.dataset.pushAction
if (action !== 'subscribe' && action !== 'unsubscribe') {
console.error('Invalid push action:', action)
return
}
let result: SafeResult
if (action === 'subscribe') {
const pushResult = vapidPublicKey
? await subscribeToPushNotifications(vapidPublicKey)
: { success: false as const, error: 'VAPID public key not found' }
if (pushResult.success) {
result = pushResult
} else {
console.error(
"Can't enable push notifications, trying browser notifications.",
pushResult.error
)
const browserResult = await enableBrowserNotifications()
if (browserResult.success) {
result = browserResult
} else {
console.error("Can't enable browser notifications:", browserResult.error)
result = {
success: false,
error: `Can't enable either push or browser notifications. Push: ${pushResult.error}. Browser: ${browserResult.error}`,
}
}
}
} else {
const pushResult = await unsubscribeFromPushNotifications()
const browserResult = disableBrowserNotifications()
const success = pushResult.success || browserResult.success
result = success
? { success: true }
: { success: false, error: `${pushResult.error} | ${browserResult.error}` }
}
if (!result.success) {
console.error(result.error)
const errorMessageElement = banner.querySelector<HTMLElement>('[data-error-message]')
if (errorMessageElement) {
errorMessageElement.textContent = `Failed to ${action === 'subscribe' ? 'enable' : 'disable'} notifications.`
}
return
}
window.location.reload()
})
})
})
})
</script>

View File

@@ -0,0 +1,108 @@
---
if (!Astro.locals.user) return
---
<script>
import type { ServerEventsEvent, SSEEventMap } from '../lib/serverEventsTypes'
const MAX_RECONNECT_ATTEMPTS = 5
const RECONNECT_DELAY = 2_000
let eventSource: EventSource | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
startServerEventsListener()
// ------------------------------------------------------------
export function startServerEventsListener() {
stopServerEventsListener()
try {
eventSource = new EventSource('/internal-api/server-events')
eventSource.onopen = () => {
reconnectAttempts = 0
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string)
if (isServerEventsEvent(data)) {
const eventType = `sse:${data.type}` as const
document.dispatchEvent(
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
)
} else {
console.error('Invalid server events event:', data)
}
} catch (error) {
console.error('Error parsing server events data:', error)
}
}
eventSource.onerror = (error) => {
console.error('Server events error:', error)
if (eventSource?.readyState === EventSource.CLOSED) {
scheduleReconnect()
}
}
} catch (error) {
console.error('Failed to start server events listener:', error)
scheduleReconnect()
}
}
export function stopServerEventsListener() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (eventSource) {
eventSource.close()
eventSource = null
console.info('Disconnected from server events listener')
}
reconnectAttempts = 0
}
function scheduleReconnect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error('Max reconnection attempts reached, giving up')
return
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
}
const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttempts)
reconnectAttempts++
const delayStr = String(delay)
const attemptsStr = String(reconnectAttempts)
const maxAttemptsStr = String(MAX_RECONNECT_ATTEMPTS)
console.info(`Attempting to reconnect in ${delayStr}ms (attempt ${attemptsStr}/${maxAttemptsStr})`)
reconnectTimeout = window.setTimeout(() => {
startServerEventsListener()
}, delay)
}
function isServerEventsEvent(event: unknown): event is ServerEventsEvent {
if (typeof event !== 'object' || event === null) return false
const e = event as Record<string, unknown>
return (
'type' in e &&
typeof e.type === 'string' &&
'data' in e &&
typeof e.data === 'object' &&
e.data !== null
)
}
</script>

View File

@@ -87,6 +87,25 @@ function makeLink(url: string, referral: string | null) {
}
}
const bitcointalkMatch = /^(?:https?:\/\/)?(?:www\.)?bitcointalk\.org$/.exec(hostname)
if (bitcointalkMatch) {
return {
type: 'clearnet' as const,
url: urlWithReferral,
textBits: [
{
style: 'normal',
text: 'BitcoinTalk ',
},
{
style: 'irrelevant',
text: 'thread',
},
],
icon: networksBySlug.clearnet.icon,
}
}
return {
type: 'clearnet' as const,
url: urlWithReferral,

View File

@@ -0,0 +1,51 @@
---
---
<script>
import { registerSW } from 'virtual:pwa-register'
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
let hasPendingUpdate = false
const updateSW = registerSW({
immediate: true,
onRegisteredSW: (_swScriptUrl, registration) => {
if (registration) window.__SW_REGISTRATION__ = registration
document.addEventListener('astro:after-swap', checkAndApplyPendingUpdate, { passive: true })
window.addEventListener('popstate', checkAndApplyPendingUpdate, { passive: true })
},
onNeedRefresh: () => {
if (shouldSkipAutoReload()) {
void updateSW(false)
hasPendingUpdate = true
return
}
void updateSW(true)
},
onRegisterError: (error) => {
console.error('Service Worker registration error', error)
},
})
function shouldSkipAutoReload() {
const currentPath = window.location.pathname
const isErrorPage = document.querySelector('[data-is-error-page]') !== null
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
}
function checkAndApplyPendingUpdate() {
if (hasPendingUpdate && !shouldSkipAutoReload()) {
hasPendingUpdate = false
void updateSW(true)
}
}
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault()
})
</script>

View File

@@ -49,6 +49,7 @@ const {
class={cn(
// Check the scam filter when there is a text quey and the user has checked verified and approved
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
className
)}
>
@@ -80,16 +81,20 @@ const {
))
}
</select>
<p class="text-day-500 mt-1.5 text-center text-sm">
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
Ties randomly sorted
</p>
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
Sorted by match first
</p>
</fieldset>
<!-- Text Search -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">
<label for="q">Text</label>
<label for="q">Name</label>
</legend>
<input
type="text"

View File

@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number
sortSeed?: string
filters: ServicesFiltersObject
includeScams: boolean
countCommunityOnly: number | null
inlineIcons?: boolean
}
@@ -35,15 +34,12 @@ const {
sortSeed,
class: className,
filters,
includeScams,
countCommunityOnly,
inlineIcons,
...divProps
} = Astro.props
const hasScams =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('VERIFICATION_FAILED') || includeScams
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
@@ -75,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
/>
{
countCommunityOnly && (
!!countCommunityOnly && (
<>
<Button
as="a"
@@ -196,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
inlineIcon={inlineIcons}
/>
)}
{countCommunityOnly && (
{!!countCommunityOnly && (
<Button
as="a"
href={urlIfIncludingCommunity}

View File

@@ -3,6 +3,7 @@ import { Icon } from 'astro-icon/components'
import { differenceInDays, isPast } from 'date-fns'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
import { cn } from '../lib/cn'
import TimeFormatted from './TimeFormatted.astro'
@@ -56,19 +57,41 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
<span>Community-contributed. Information not reviewed.</span>
<span>
Community-contributed. Information not reviewed.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</span>
</div>
) : wasRecentlyAdded ? (
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</div>
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
<Icon name="ri:information-line" class="size-5 text-blue-400" />
<span>Basic checks passed, but not fully verified.</span>
<span>
Basic checks passed, but not fully verified.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</span>
</div>
) : null
}
@@ -76,14 +99,29 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.verificationSteps.some((step) => step.status === 'FAILED') && (
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
<a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400 transition-colors hover:bg-red-900/60"
>
<Icon
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
/>
<span>
This service has failed one or more verification steps. Review the verification details carefully.
</span>
</div>
<span>Some verification steps failed. Please review the details below.</span>
</a>
)
}
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
!service.verificationSteps.some((step) => step.status === 'FAILED') &&
service.verificationSteps.some((step) => step.status === 'WARNING') && (
<a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200 transition-colors hover:bg-yellow-600/40"
>
<Icon name={verificationStepStatusesByValue.WARNING.icon} class={cn('size-5 text-yellow-400')} />
<span>Some verification steps are marked as warnings.</span>
</a>
)
}

View File

@@ -108,3 +108,19 @@ export const {
},
] as const satisfies AttributeTypeInfo<AttributeType>[]
)
export const baseScoreType = {
value: 'BASE_SCORE',
slug: 'base-score',
label: 'Base score',
icon: 'ri:information-line',
order: 5,
classNames: {
container: 'bg-night-500',
subcontainer: '',
text: 'text-day-200',
textLight: '',
icon: '',
button: '',
},
} as const satisfies AttributeTypeInfo

View File

@@ -1,15 +1,14 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { TailwindColor } from '../lib/colors'
import type { CommentStatus } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type CommentStatusInfo<T extends string | null | undefined = string> = {
id: T
icon: string
label: string
color: ComponentProps<typeof BadgeSmall>['color']
color: TailwindColor
creativeWorkStatus: string | undefined
}

View File

@@ -3,14 +3,13 @@ import { transformCase } from '../lib/strings'
import { commentStatusById } from './commentStatus'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { TailwindColor } from '../lib/colors'
import type { Prisma } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type CommentStatusFilterInfo<T extends string | null | undefined = string> = {
value: T
label: string
color: ComponentProps<typeof BadgeSmall>['color']
color: TailwindColor
icon: string
whereClause: Prisma.CommentWhereInput
classNames: {

View File

@@ -3,6 +3,9 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { Assert } from '../lib/assert'
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
type ContactMethodInfo<T extends string | null | undefined = string> = {
type: T
label: string
@@ -10,6 +13,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
matcher: RegExp
formatter: (match: RegExpMatchArray) => string | null
icon: string
urlType: string
}
export const {
@@ -22,9 +26,10 @@ export const {
(type): ContactMethodInfo<typeof type> => ({
type,
label: type ? transformCase(type, 'title') : String(type),
icon: 'ri:shield-fill',
icon: 'ri:link',
matcher: /(.*)/,
formatter: ([, value]) => value ?? String(value),
urlType: type ?? 'unknown',
}),
[
{
@@ -33,24 +38,37 @@ export const {
matcher: /mailto:(.+)/,
formatter: ([, value]) => value ?? 'Email',
icon: 'ri:mail-line',
urlType: 'email',
},
{
type: 'telephone',
label: 'Telephone',
matcher: /tel:(.+)/,
formatter: ([, value]) => {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
try {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
} catch (_error) {
console.error(`Invalid telephone number: ${value ?? 'undefined'}`, _error)
return value ?? 'Telephone'
}
},
icon: 'ri:phone-line',
urlType: 'telephone',
},
{
type: 'whatsapp',
label: 'WhatsApp',
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
formatter: ([, value]) => {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
try {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
} catch (_error) {
console.error(`Invalid WhatsApp number: ${value ?? 'undefined'}`, _error)
return value ?? 'WhatsApp'
}
},
icon: 'ri:whatsapp-line',
urlType: 'url',
},
{
type: 'telegram',
@@ -58,6 +76,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
icon: 'ri:telegram-line',
urlType: 'url',
},
{
type: 'linkedin',
@@ -65,6 +84,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
icon: 'ri:linkedin-box-line',
urlType: 'url',
},
{
type: 'x',
@@ -72,6 +92,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'X'),
icon: 'ri:twitter-x-line',
urlType: 'url',
},
{
type: 'instagram',
@@ -79,13 +100,15 @@ export const {
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
icon: 'ri:instagram-line',
urlType: 'url',
},
{
type: 'matrix',
label: 'Matrix',
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
formatter: ([, value]) => value ?? 'Matrix',
icon: 'ri:hashtag',
urlType: 'url',
},
{
type: 'bitcointalk',
@@ -93,13 +116,15 @@ export const {
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line',
urlType: 'url',
},
{
type: 'simplex',
label: 'SimpleX Chat',
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
matcher: /^https?:\/\/(?:www\.)?((?:simplex\.chat|smp\d+\.simplex\.im))\//,
formatter: () => 'SimpleX Chat',
icon: 'simplex',
urlType: 'url',
},
{
type: 'nostr',
@@ -107,6 +132,7 @@ export const {
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
formatter: () => 'Nostr',
icon: 'nostr',
urlType: 'url',
},
{
// Website must go last because it's a catch-all
@@ -115,6 +141,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: ([, value]) => value ?? 'Website',
icon: 'ri:global-line',
urlType: 'url',
},
] as const satisfies ContactMethodInfo[]
)
@@ -135,3 +162,38 @@ export function formatContactMethod(url: string) {
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
}
type ContactMethodUrlTypeInfo<T extends string | null | undefined = string> = {
value: T
labelPlural: string
}
export const {
dataArray: contactMethodUrlTypes,
dataObject: contactMethodUrlTypesById,
getFn: getContactMethodUrlTypeInfo,
} = makeHelpersForOptions(
'value',
(value): ContactMethodUrlTypeInfo<typeof value> => ({
value,
labelPlural: value ? transformCase(value, 'title') : String(value),
}),
[
{
value: 'email',
labelPlural: 'emails',
},
{
value: 'telephone',
labelPlural: 'phone numbers',
},
{
value: 'url',
labelPlural: 'URLs',
},
] as const satisfies ContactMethodUrlTypeInfo<(typeof contactMethods)[number]['urlType']>[]
)
type _ExpectUrlTypesToHaveAllValues = Assert<
Equals<(typeof contactMethods)[number]['urlType'], keyof typeof contactMethodUrlTypesById>
>

View File

@@ -1,9 +1,8 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { TailwindColor } from '../lib/colors'
import type { EventType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type EventTypeInfo<T extends string | null | undefined = string> = {
id: T
@@ -12,9 +11,10 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
description: string
classNames: {
dot: string
banner?: string
}
icon: string
color: ComponentProps<typeof BadgeSmall>['color']
color: TailwindColor
isSolved: boolean
showBanner: boolean
}
@@ -35,6 +35,7 @@ export const {
description: '',
classNames: {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
},
icon: 'ri:question-fill',
color: 'gray',
@@ -49,6 +50,7 @@ export const {
description: 'Potential issues that users should be aware of',
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
},
icon: 'ri:alert-fill',
color: 'yellow',
@@ -62,6 +64,7 @@ export const {
description: 'A previously reported warning has been solved',
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
},
icon: 'ri:alert-fill',
color: 'green',
@@ -75,6 +78,7 @@ export const {
description: 'Critical issues affecting service functionality',
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
},
icon: 'ri:spam-fill',
color: 'red',
@@ -88,6 +92,7 @@ export const {
description: 'A previously reported alert has been solved',
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
},
icon: 'ri:spam-fill',
color: 'green',
@@ -101,6 +106,7 @@ export const {
description: 'General information about the service',
classNames: {
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
banner: 'bg-blue-900/50 text-blue-300 hover:bg-blue-800/60 focus-visible:bg-blue-800/60',
},
icon: 'ri:information-fill',
color: 'sky',
@@ -114,6 +120,7 @@ export const {
description: 'Regular service update or announcement',
classNames: {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
},
icon: 'ri:notification-fill',
color: 'green',
@@ -127,6 +134,7 @@ export const {
description: 'Service details were updated on kycnot.me',
classNames: {
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
banner: 'bg-sky-900/50 text-sky-300 hover:bg-sky-800/60 focus-visible:bg-sky-800/60',
},
icon: 'ri:pencil-fill',
color: 'sky',

View File

@@ -0,0 +1,51 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { AttributeType, KycLevelClarification } from '@prisma/client'
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
description: string
icon: string
privacyPoints: number
attributeType: AttributeType
}
export const {
dataArray: kycLevelClarifications,
dataObject: kycLevelClarificationsById,
getFn: getKycLevelClarificationInfo,
} = makeHelpersForOptions(
'value',
(value): KycLevelClarificationInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase().replace('_', '-') : '',
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
description: '',
icon: 'ri:question-line',
privacyPoints: 0,
attributeType: 'INFO',
}),
[
{
value: 'NONE',
slug: 'none',
label: 'None',
description: 'No clarification needed.',
icon: 'ri:file-copy-line',
privacyPoints: 0,
attributeType: 'INFO',
},
{
value: 'DEPENDS_ON_PARTNERS',
slug: 'depends-on-partners',
label: 'Depends on partners',
description: 'May vary across partners.',
icon: 'ri:share-forward-line',
privacyPoints: -5,
attributeType: 'WARNING',
},
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
)

View File

@@ -2,12 +2,16 @@ import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { parseIntWithFallback } from '../lib/numbers'
import { transformCase } from '../lib/strings'
import type { AttributeType } from '@prisma/client'
type KycLevelInfo<T extends string | null | undefined = string> = {
id: T
value: number
icon: string
name: string
description: string
privacyPoints: number
attributeType: AttributeType
}
export const {
@@ -22,6 +26,8 @@ export const {
icon: 'diamond-question',
name: `KYC ${id ? transformCase(id, 'title') : String(id)}`,
description: '',
privacyPoints: 0,
attributeType: 'INFO',
}),
[
{
@@ -30,6 +36,8 @@ export const {
icon: 'anonymous-mask',
name: 'Guaranteed no KYC',
description: 'Terms explicitly state KYC will never be requested.',
privacyPoints: 25,
attributeType: 'GOOD',
},
{
id: '1',
@@ -37,6 +45,8 @@ export const {
icon: 'diamond-question',
name: 'No KYC mention',
description: 'No mention of current or future KYC requirements.',
privacyPoints: 15,
attributeType: 'GOOD',
},
{
id: '2',
@@ -45,6 +55,8 @@ export const {
name: 'KYC on authorities request',
description:
'No routine KYC, but may cooperate with authorities, block funds or implement future KYC requirements.',
privacyPoints: -5,
attributeType: 'WARNING',
},
{
id: '3',
@@ -52,6 +64,8 @@ export const {
icon: 'gun',
name: 'Shotgun KYC',
description: 'May request KYC and block funds based on automated triggers.',
privacyPoints: -15,
attributeType: 'WARNING',
},
{
id: '4',
@@ -59,6 +73,8 @@ export const {
icon: 'fingerprint-detailed',
name: 'Mandatory KYC',
description: 'Required for key features and can be required arbitrarily at any time.',
privacyPoints: -25,
attributeType: 'BAD',
},
] as const satisfies KycLevelInfo<'0' | '1' | '2' | '3' | '4'>[]
)

View File

@@ -20,6 +20,11 @@ export const {
icon: 'ri:notification-line',
}),
[
{
id: 'TEST',
label: 'Test notification',
icon: 'ri:flask-line',
},
{
id: 'COMMENT_STATUS_CHANGE',
label: 'Comment status changed',
@@ -35,6 +40,11 @@ export const {
label: 'New comment/rating',
icon: 'ri:chat-4-line',
},
{
id: 'SUGGESTION_CREATED',
label: 'New suggestion',
icon: 'ri:lightbulb-line',
},
{
id: 'SUGGESTION_MESSAGE',
label: 'New message in suggestion',

View File

@@ -8,7 +8,7 @@ type ServiceSuggestionStatusInfo<T extends string | null | undefined = string> =
slug: string
label: string
icon: string
iconClass: string
color: string
default: boolean
}
@@ -28,7 +28,7 @@ export const {
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line',
iconClass: 'text-current/60',
color: 'gray',
default: false,
}),
[
@@ -37,7 +37,7 @@ export const {
slug: 'pending',
label: 'Pending',
icon: 'ri:time-line',
iconClass: 'text-yellow-400',
color: 'yellow',
default: true,
},
{
@@ -45,7 +45,7 @@ export const {
slug: 'approved',
label: 'Approved',
icon: 'ri:check-line',
iconClass: 'text-green-400',
color: 'green',
default: false,
},
{
@@ -53,7 +53,7 @@ export const {
slug: 'rejected',
label: 'Rejected',
icon: 'ri:close-line',
iconClass: 'text-red-400',
color: 'red',
default: false,
},
{
@@ -61,7 +61,7 @@ export const {
slug: 'withdrawn',
label: 'Withdrawn',
icon: 'ri:arrow-left-line',
iconClass: 'text-gray-400',
color: 'gray',
default: false,
},
] as const satisfies ServiceSuggestionStatusInfo<ServiceSuggestionStatus>[]

View File

@@ -1,18 +1,18 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { TailwindColor } from '../lib/colors'
import type { ServiceSuggestionType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
labelAlt: string
icon: string
order: number
default: boolean
color: ComponentProps<typeof BadgeSmall>['color']
color: TailwindColor
}
export const {
@@ -34,12 +34,14 @@ export const {
order: Infinity,
default: false,
color: 'zinc',
labelAlt: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
}),
[
{
value: 'CREATE_SERVICE',
slug: 'create',
label: 'Create',
labelAlt: 'service',
icon: 'ri:add-line',
order: 1,
default: true,
@@ -49,6 +51,7 @@ export const {
value: 'EDIT_SERVICE',
slug: 'edit',
label: 'Edit',
labelAlt: 'edit',
icon: 'ri:pencil-line',
order: 2,
default: false,

View File

@@ -1,9 +1,8 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { TailwindColor } from '../lib/colors'
import type { ServiceUserRole } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type ServiceUserRoleInfo<T extends string | null | undefined = string> = {
value: T
@@ -11,7 +10,7 @@ type ServiceUserRoleInfo<T extends string | null | undefined = string> = {
label: string
icon: string
order: number
color: NonNullable<ComponentProps<typeof BadgeSmall>['color']>
color: NonNullable<TailwindColor>
}
export const {

View File

@@ -65,7 +65,7 @@ export const {
value: 'ARCHIVED',
slug: 'archived',
label: 'Archived',
description: 'No longer operational',
description: 'No longer operational.',
longDescription:
'Archived service, no longer exists or ceased operations. Information may be outdated.',
icon: 'ri:archive-line',

View File

@@ -1,15 +1,14 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { TailwindColor } from '../lib/colors'
import type { VerificationStepStatus } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
value: T
label: string
icon: string
color: ComponentProps<typeof BadgeSmall>['color']
color: TailwindColor
}
export const {
@@ -43,6 +42,12 @@ export const {
icon: 'ri:alert-line',
color: 'red',
},
{
value: 'WARNING',
label: 'Warning',
icon: 'ri:alert-line',
color: 'yellow',
},
{
value: 'PENDING',
label: 'Pending',

Some files were not shown because too many files have changed in this diff Show More