Compare commits
15 Commits
release-55
...
release-70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43402e162 | ||
|
|
9bb316b85f | ||
|
|
4aea68ee58 | ||
|
|
2f88c43236 | ||
|
|
ad3c561419 | ||
|
|
812937d2c7 | ||
|
|
459d7c91f7 | ||
|
|
b8b2dee4a4 | ||
|
|
eb0af871e1 | ||
|
|
3ccd7fd395 | ||
|
|
87f0f36aa1 | ||
|
|
8b90b3eef6 | ||
|
|
2489e94b0e | ||
|
|
144af17a70 | ||
|
|
02e52d7351 |
46
.env.example
@@ -0,0 +1,46 @@
|
||||
# Database
|
||||
POSTGRES_USER=your_db_user
|
||||
POSTGRES_PASSWORD=your_db_password
|
||||
POSTGRES_DATABASE=your_db_name
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
|
||||
DATABASE_UI_URL="https://db.example.com"
|
||||
|
||||
# Generic Config
|
||||
UPLOAD_DIR=/app/uploads
|
||||
SITE_URL="https://your-site.example.com"
|
||||
SOURCE_CODE_URL="https://your-source-code.example.com"
|
||||
TIME_TRAP_SECRET=your_time_trap_secret
|
||||
LOGS_UI_URL="https://logs.example.com"
|
||||
|
||||
# Release Info
|
||||
RELEASE_NUMBER=
|
||||
RELEASE_DATE=
|
||||
|
||||
# Redis
|
||||
REDIS_URL="redis://redis:6379"
|
||||
|
||||
# Crawl4AI
|
||||
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
|
||||
CRAWL4AI_API_TOKEN=your_crawl4ai_token
|
||||
|
||||
# Tor and I2P
|
||||
ONION_ADDRESS="http://youronionaddress.onion"
|
||||
I2P_ADDRESS="http://youri2paddress.b32.i2p"
|
||||
I2P_PASS=your_i2p_password
|
||||
|
||||
# Push Notifications
|
||||
VAPID_PUBLIC_KEY=your_vapid_public_key
|
||||
VAPID_PRIVATE_KEY=your_vapid_private_key
|
||||
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
OPENAI_BASE_URL="https://your-openai-base-url.example.com"
|
||||
OPENAI_MODEL=your_openai_model
|
||||
OPENAI_RETRY=3
|
||||
|
||||
# Pyworker Crons
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day
|
||||
@@ -7,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
|
||||
|
||||
@@ -332,7 +332,7 @@ 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):
|
||||
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
|
||||
"""
|
||||
Save a TOS review for a specific service.
|
||||
|
||||
@@ -341,8 +341,8 @@ def save_tos_review(service_id: int, review: TosReviewType):
|
||||
review: A TypedDict containing the review data.
|
||||
"""
|
||||
try:
|
||||
# Serialize the dictionary to a JSON string for the database
|
||||
review_json = json.dumps(review)
|
||||
# Only serialize to JSON if review is not None
|
||||
review_json = json.dumps(review) if review is not None else None
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cursor:
|
||||
cursor.execute(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -173,27 +173,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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -4,8 +4,11 @@ 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 devtoolsJson from 'vite-plugin-devtools-json'
|
||||
|
||||
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||
@@ -16,15 +19,63 @@ 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',
|
||||
description: 'Find services that respect your privacy',
|
||||
theme_color: '#040505',
|
||||
background_color: '#171c1b',
|
||||
},
|
||||
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 +107,7 @@ export default defineConfig({
|
||||
'/service/[...slug]/proof': '/service/[...slug]/#verification',
|
||||
'/attribute/[...slug]': '/attributes',
|
||||
'/attr/[...slug]': '/attributes',
|
||||
'/service/[...slug]/review': '/service/[...slug]#comments',
|
||||
// #endregion
|
||||
},
|
||||
env: {
|
||||
@@ -95,35 +147,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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
5196
web/package-lock.json
generated
@@ -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,19 +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",
|
||||
@@ -43,59 +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",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'TEST';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -495,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)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type ServiceVisibility,
|
||||
ServiceSuggestionType,
|
||||
KycLevelClarification,
|
||||
VerificationStepStatus,
|
||||
} from '@prisma/client'
|
||||
import { omit, uniqBy } from 'lodash-es'
|
||||
import { generateUsername } from 'unique-username-generator'
|
||||
@@ -610,6 +611,10 @@ 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,
|
||||
@@ -643,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)}`,
|
||||
@@ -661,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(
|
||||
@@ -1123,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
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ 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;
|
||||
onion_factor INT := 0;
|
||||
i2p_factor INT := 0;
|
||||
@@ -78,7 +78,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 + 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 +91,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 +126,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 +137,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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
8
web/public/favicon-badge.svg
Normal 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 |
8
web/public/favicon-dev-badge.svg
Normal 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 |
8
web/public/favicon-dev-lightmode-badge.svg
Normal 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 |
7
web/public/favicon-dev-lightmode.svg
Normal 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 |
@@ -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 |
8
web/public/favicon-lightmode-badge.svg
Normal 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 |
@@ -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 |
8
web/public/favicon-stage-badge.svg
Normal 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 |
8
web/public/favicon-stage-lightmode-badge.svg
Normal 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 |
7
web/public/favicon-stage-lightmode.svg
Normal 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 |
@@ -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 |
@@ -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 |
6
web/public/notification-icon.svg
Normal 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 |
@@ -1,83 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/** @type {ServiceWorkerGlobalScope} */
|
||||
// @ts-expect-error
|
||||
const typedSelf = self
|
||||
|
||||
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
|
||||
|
||||
typedSelf.addEventListener('install', (event) => {
|
||||
console.log('Service Worker installing')
|
||||
typedSelf.skipWaiting()
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker activating')
|
||||
event.waitUntil(typedSelf.clients.claim())
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('push', (event) => {
|
||||
console.log('Push event received:', event)
|
||||
|
||||
if (!event.data) {
|
||||
console.log('Push event but no data')
|
||||
return
|
||||
}
|
||||
|
||||
let notificationData
|
||||
try {
|
||||
notificationData = event.data.json()
|
||||
} catch (error) {
|
||||
console.error('Error parsing push data:', error)
|
||||
notificationData = {
|
||||
title: 'New Notification',
|
||||
options: {
|
||||
body: event.data.text() || 'You have a new notification',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { title, options } = notificationData
|
||||
|
||||
const notificationOptions = {
|
||||
body: options.body || '',
|
||||
icon: options.icon || '/favicon.svg',
|
||||
badge: options.badge || '/favicon.svg',
|
||||
data: options.data || {},
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
...options,
|
||||
}
|
||||
|
||||
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('notificationclick', (event) => {
|
||||
console.log('Notification clicked:', event)
|
||||
|
||||
event.notification.close()
|
||||
|
||||
const url = event.notification.data?.url || '/'
|
||||
|
||||
event.waitUntil(
|
||||
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
// If a window is already open, focus it
|
||||
for (const client of clientList) {
|
||||
if (client.url === url && 'focus' in client) {
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, open a new window
|
||||
if (typedSelf.clients.openWindow) {
|
||||
return typedSelf.clients.openWindow(url)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('notificationclose', (event) => {
|
||||
console.log('Notification closed:', event)
|
||||
})
|
||||
@@ -1,80 +1,33 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { sumBy } from 'lodash-es'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { sendPushNotification } from '../../lib/webPush'
|
||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||
|
||||
export const adminNotificationActions = {
|
||||
webPush: {
|
||||
test: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userNames: stringListOfSlugsSchemaRequired,
|
||||
title: z.string().min(1).nullable(),
|
||||
body: z.string().nullable(),
|
||||
url: z.string().url().optional(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { user: { name: { in: input.userNames } } },
|
||||
select: {
|
||||
id: true,
|
||||
endpoint: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
userAgent: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: input.title ?? 'Test Notification',
|
||||
body: input.body ?? 'This is a test push notification from KYCNot.me',
|
||||
url: input.url ?? '/',
|
||||
}
|
||||
)
|
||||
|
||||
// If subscription is invalid, remove it from database
|
||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||
await prisma.pushSubscription.delete({
|
||||
where: { id: subscription.id },
|
||||
})
|
||||
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
|
||||
}
|
||||
|
||||
return result.success
|
||||
})
|
||||
)
|
||||
|
||||
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
|
||||
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
|
||||
const now = new Date()
|
||||
return {
|
||||
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
|
||||
totalSubscriptions: subscriptions.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
sentAt: now,
|
||||
}
|
||||
},
|
||||
test: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userNames: stringListOfSlugsSchemaRequired,
|
||||
}),
|
||||
},
|
||||
handler: async (input) => {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { name: { in: input.userNames } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const notifications = await prisma.notification.createManyAndReturn({
|
||||
data: users.map((user) => ({
|
||||
type: 'TEST',
|
||||
userId: user.id,
|
||||
})),
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return {
|
||||
message: `Created ${notifications.length.toString()} notifications.`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -307,7 +307,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) => {
|
||||
@@ -458,7 +458,6 @@ export const adminServiceActions = {
|
||||
permissions: 'admin',
|
||||
input: evidenceImageDeleteSchema,
|
||||
handler: async (input) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await deleteFileLocally(input.fileUrl)
|
||||
return { success: true }
|
||||
},
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
|
||||
import { type Prisma, type ServiceUserRole } from '@prisma/client'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
||||
|
||||
const prisma = prismaInstance as PrismaClient
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const selectUserReturnFields = {
|
||||
id: true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,10 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -58,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()
|
||||
@@ -144,17 +150,7 @@ 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),
|
||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
@@ -165,6 +161,11 @@ export const serviceSuggestionActions = {
|
||||
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
|
||||
@@ -184,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 {
|
||||
@@ -197,11 +206,19 @@ 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)
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
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 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 +76,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,7 +98,13 @@ 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 />
|
||||
@@ -131,3 +137,13 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
Astro.locals.user && (
|
||||
<>
|
||||
<ServerEventsScript />
|
||||
<ServiceWorkerScript />
|
||||
<NotificationEventsScript />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ 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 scrollbar-w-none -mt-10 flex items-center gap-2 overflow-auto pt-10 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>
|
||||
@@ -240,15 +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} variant="faded" inlineIcon>
|
||||
{roleInfo.label} at
|
||||
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
||||
</BadgeSmall>
|
||||
)
|
||||
})
|
||||
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>
|
||||
|
||||
@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
|
||||
privateContext: true
|
||||
orderId: true
|
||||
orderIdStatus: true
|
||||
rating: true
|
||||
ratingActive: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
<div
|
||||
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
|
||||
>
|
||||
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
|
||||
<div class="border-night-500 flex flex-wrap items-center gap-1 border-b pb-2">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -59,42 +61,13 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
|
||||
<Icon name="ri:close-circle-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.suspicious
|
||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||
)}
|
||||
data-action="suspicious"
|
||||
data-value={!comment.suspicious}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.suspicious ? 'Not Spam' : 'Spam'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.requiresAdminReview
|
||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||
)}
|
||||
data-action="requires-admin-review"
|
||||
data-value={!comment.requiresAdminReview}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'VERIFIED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
@@ -104,12 +77,13 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
|
||||
<Icon name="ri:verified-badge-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
@@ -121,12 +95,49 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
|
||||
<Icon name="ri:checkbox-circle-line" class="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="bg-night-500 h-5 w-px"></div>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.suspicious
|
||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||
)}
|
||||
data-action="suspicious"
|
||||
data-value={!comment.suspicious}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:spam-2-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.suspicious ? 'Not Spam' : 'Spam'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.requiresAdminReview
|
||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||
)}
|
||||
data-action="requires-admin-review"
|
||||
data-value={!comment.requiresAdminReview}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:shield-user-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.kycRequested
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -136,12 +147,13 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
|
||||
<Icon name="ri:bank-card-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.fundsBlocked
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -151,8 +163,31 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
|
||||
<Icon name="ri:lock-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}</span>
|
||||
</button>
|
||||
|
||||
<div class="bg-night-500 h-5 w-px"></div>
|
||||
|
||||
{
|
||||
comment.rating && (
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.ratingActive
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
)}
|
||||
data-action="toggle-rating-active"
|
||||
data-value={!comment.ratingActive}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:star-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.ratingActive ? 'Disable Rating' : 'Enable Rating'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1.5">
|
||||
@@ -208,7 +243,7 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'APPROVED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
@@ -218,11 +253,12 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Approve
|
||||
<Icon name="ri:check-line" class="h-3.5 w-3.5" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -232,11 +268,12 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Reject
|
||||
<Icon name="ri:close-line" class="h-3.5 w-3.5" />
|
||||
<span>Reject</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
@@ -246,7 +283,23 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Pending
|
||||
<Icon name="ri:time-line" class="h-3.5 w-3.5" />
|
||||
<span>Pending</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'WITHDRAWN'
|
||||
? 'border-night-400 bg-night-500/50 text-night-300 border'
|
||||
: 'bg-night-700 hover:bg-night-500/50 hover:text-night-300'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="WITHDRAWN"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:arrow-go-back-line" class="h-3.5 w-3.5" />
|
||||
<span>Withdrawn</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
action === 'suspicious' ||
|
||||
action === 'requires-admin-review' ||
|
||||
action === 'kyc-requested' ||
|
||||
action === 'funds-blocked'
|
||||
action === 'funds-blocked' ||
|
||||
action === 'toggle-rating-active'
|
||||
? value === 'true'
|
||||
: value,
|
||||
})
|
||||
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
if (action === 'status') {
|
||||
window.location.reload()
|
||||
} else if (action === 'suspicious') {
|
||||
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'Not Spam' : 'Spam'
|
||||
btn.classList.toggle('bg-yellow-500/20')
|
||||
btn.classList.toggle('text-yellow-400')
|
||||
btn.classList.toggle('border-yellow-500/30')
|
||||
@@ -298,7 +353,8 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'requires-admin-review') {
|
||||
btn.textContent = value === 'true' ? 'No Review' : 'Review'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No Admin Review' : 'Needs Admin Review'
|
||||
btn.classList.toggle('bg-purple-500/20')
|
||||
btn.classList.toggle('text-purple-400')
|
||||
btn.classList.toggle('border-purple-500/30')
|
||||
@@ -309,7 +365,8 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
// Refresh to show updated order ID status
|
||||
window.location.reload()
|
||||
} else if (action === 'kyc-requested') {
|
||||
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
@@ -317,13 +374,23 @@ if (!user || !user.admin || !user.moderator) return null
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'funds-blocked') {
|
||||
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'toggle-rating-active') {
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'Disable Rating' : 'Enable Rating'
|
||||
btn.classList.toggle('bg-blue-500/20')
|
||||
btn.classList.toggle('text-blue-400')
|
||||
btn.classList.toggle('border-blue-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
}
|
||||
} else {
|
||||
console.error('Error moderating comment:', error)
|
||||
|
||||
79
web/src/components/DynamicFavicon.astro
Normal 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>
|
||||
@@ -33,6 +33,12 @@ const links = [
|
||||
icon: 'ri:plug-line',
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: '/feeds',
|
||||
label: 'RSS',
|
||||
icon: 'ri:rss-line',
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: '/about',
|
||||
label: 'About',
|
||||
@@ -49,7 +55,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 }) =>
|
||||
@@ -58,9 +67,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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
61
web/src/components/InputCheckbox.astro
Normal 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>
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
29
web/src/components/NotificationEventsScript.astro
Normal 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>
|
||||
@@ -2,6 +2,7 @@
|
||||
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'
|
||||
@@ -21,24 +22,24 @@ type Props = HTMLAttributes<'div'> & {
|
||||
}
|
||||
|
||||
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
|
||||
|
||||
// TODO: Feature flag, enabled only for admins
|
||||
if (!Astro.locals.user?.admin) {
|
||||
return null
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
data-push-notification-banner
|
||||
data-dismissed={undefined /* Updated by client script */}
|
||||
data-supports-push-notifications={undefined /* Updated by client script */}
|
||||
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 /* Updated by client script */}
|
||||
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 relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
|
||||
'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-supports-push-notifications:hidden',
|
||||
'not-data-notification-supported:hidden',
|
||||
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
||||
className
|
||||
)}
|
||||
@@ -60,25 +61,18 @@ if (!Astro.locals.user?.admin) {
|
||||
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-blue-100">
|
||||
<span data-show-if-enabled>Push notifications enabled</span>
|
||||
<span data-show-if-disabled>Turn on push notifications?</span>
|
||||
</h3>
|
||||
<p class="text-sm text-blue-200/80">
|
||||
<span data-show-if-enabled>Turn notifications off for this device?</span>
|
||||
<span data-show-if-disabled>Get notifications on this device.</span>
|
||||
</p>
|
||||
<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="flex items-center gap-2">
|
||||
<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-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||
data-show-if-disabled
|
||||
/>
|
||||
<Button
|
||||
@@ -87,10 +81,21 @@ if (!Astro.locals.user?.admin) {
|
||||
color="white"
|
||||
variant="faded"
|
||||
data-push-action="unsubscribe"
|
||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||
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>
|
||||
@@ -134,240 +139,128 @@ if (!Astro.locals.user?.admin) {
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////
|
||||
// Script to style when notifications enabled. //
|
||||
////////////////////////////////////////////////////////
|
||||
///////////////////////////////////////////
|
||||
// Script to handle push notifications. //
|
||||
///////////////////////////////////////////
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
import {
|
||||
subscribeToPushNotifications,
|
||||
unsubscribeFromPushNotifications,
|
||||
parsePushSubscriptions,
|
||||
isCurrentDeviceSubscribed,
|
||||
supportsPushNotifications,
|
||||
type SafeResult,
|
||||
} from '../lib/client/clientPushNotifications'
|
||||
|
||||
/** Parse push subscriptions from string */
|
||||
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
||||
try {
|
||||
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
|
||||
import {
|
||||
enableBrowserNotifications,
|
||||
disableBrowserNotifications,
|
||||
isBrowserNotificationsEnabled,
|
||||
supportsBrowserNotifications,
|
||||
} from '../lib/client/browserNotifications'
|
||||
|
||||
const subscriptions = JSON.parse(subscriptionsAsString)
|
||||
|
||||
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
|
||||
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
|
||||
throw new Error('Push subscriptions must be an array of objects')
|
||||
}
|
||||
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
|
||||
throw new Error('Push subscriptions must be an array of objects with endpoint property')
|
||||
}
|
||||
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
|
||||
throw new Error('Push subscriptions must be an array of objects with userAgent property')
|
||||
}
|
||||
|
||||
return subscriptions as ServerSubscription[]
|
||||
} catch (error) {
|
||||
console.error('Failed to parse push subscriptions:', error)
|
||||
return []
|
||||
async function setDataAttributes(banner: HTMLElement) {
|
||||
if (!supportsPushNotifications() && !supportsBrowserNotifications()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current device has an active push subscription */
|
||||
async function getCurrentPushSubscription() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) return null
|
||||
banner.dataset.notificationSupported = ''
|
||||
banner.dataset.notificationPermissions = Notification.permission
|
||||
|
||||
return await registration.pushManager.getSubscription()
|
||||
} catch (error) {
|
||||
console.error('Error getting current push subscription:', error)
|
||||
return null
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current subscription matches any server subscription */
|
||||
function isCurrentDeviceSubscribed(
|
||||
currentSubscription: PushSubscription | null,
|
||||
serverSubscriptions: ServerSubscription[]
|
||||
) {
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
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
|
||||
}
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
// 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) => {
|
||||
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
||||
const currentSubscription = await getCurrentPushSubscription()
|
||||
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
|
||||
await setDataAttributes(banner)
|
||||
|
||||
if (isSubscribed) banner.dataset.isEnabled = ''
|
||||
})
|
||||
})
|
||||
</script>
|
||||
const vapidPublicKey = banner.dataset.vapidPublicKey
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////
|
||||
// Script to handle push notification subscription. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
banner.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
import type { actions } from 'astro:actions'
|
||||
import type { ActionInput } from '../lib/astroActions'
|
||||
const action = button.dataset.pushAction
|
||||
if (action !== 'subscribe' && action !== 'unsubscribe') {
|
||||
console.error('Invalid push action:', action)
|
||||
return
|
||||
}
|
||||
|
||||
/** Utility function to convert VAPID key */
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
||||
const base64 = cleaned + padding
|
||||
let result: SafeResult
|
||||
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
if (action === 'subscribe') {
|
||||
const pushResult = vapidPublicKey
|
||||
? await subscribeToPushNotifications(vapidPublicKey)
|
||||
: { success: false as const, error: 'VAPID public key not found' }
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
if (pushResult.success) {
|
||||
result = pushResult
|
||||
} else {
|
||||
console.error(
|
||||
"Can't enable push notifications, trying browser notifications.",
|
||||
pushResult.error
|
||||
)
|
||||
const browserResult = await enableBrowserNotifications()
|
||||
|
||||
/** Check for browser support */
|
||||
function checkSupport() {
|
||||
const isSecure =
|
||||
window.isSecureContext ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1'
|
||||
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
}
|
||||
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()
|
||||
|
||||
async function registerServiceWorker() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js')
|
||||
console.log('Service Worker registered:', registration)
|
||||
const success = pushResult.success || browserResult.success
|
||||
|
||||
const readyRegistration = await navigator.serviceWorker.ready
|
||||
console.log('Service Worker is active and ready:', readyRegistration)
|
||||
result = success
|
||||
? { success: true }
|
||||
: { success: false, error: `${pushResult.error} | ${browserResult.error}` }
|
||||
}
|
||||
|
||||
return readyRegistration
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error(result.error)
|
||||
|
||||
async function subscribeToPush(vapidPublicKey: string) {
|
||||
try {
|
||||
if (!checkSupport()) return
|
||||
const errorMessageElement = banner.querySelector<HTMLElement>('[data-error-message]')
|
||||
if (errorMessageElement) {
|
||||
errorMessageElement.textContent = `Failed to ${action === 'subscribe' ? 'enable' : 'disable'} notifications.`
|
||||
}
|
||||
|
||||
// Request notification permission
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
alert('Push notifications permission denied')
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const registration = await registerServiceWorker()
|
||||
|
||||
// Subscribe to push manager
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const p256dh = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/internal-api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('Push subscription successful')
|
||||
|
||||
// Reload page to update UI
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Push subscription failed:', error)
|
||||
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribeFromPush() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) {
|
||||
console.log('No service worker registration found')
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
console.log('No push subscription found')
|
||||
return
|
||||
}
|
||||
|
||||
// Unsubscribe from browser
|
||||
await subscription.unsubscribe()
|
||||
|
||||
// Remove from server
|
||||
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('Push unsubscription successful')
|
||||
|
||||
// Reload page to update UI
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Push unsubscription failed:', error)
|
||||
alert('Failed to unsubscribe from push notifications')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const supportsPushNotifications = checkSupport()
|
||||
if (supportsPushNotifications) {
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
|
||||
element.dataset.supportsPushNotifications = ''
|
||||
})
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
||||
const vapidPublicKey = button.dataset.vapidPublicKey
|
||||
if (!vapidPublicKey) {
|
||||
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
|
||||
return
|
||||
}
|
||||
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const action = button.dataset.pushAction
|
||||
if (action === 'subscribe') {
|
||||
await subscribeToPush(vapidPublicKey)
|
||||
} else if (action === 'unsubscribe') {
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
window.location.reload()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
108
web/src/components/ServerEventsScript.astro
Normal 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>
|
||||
54
web/src/components/ServiceWorkerScript.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Window {
|
||||
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -106,7 +106,7 @@ export const {
|
||||
type: 'matrix',
|
||||
label: 'Matrix',
|
||||
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
||||
formatter: ([, value]) => value ?? 'Matrix',
|
||||
icon: 'ri:hashtag',
|
||||
urlType: 'url',
|
||||
},
|
||||
@@ -121,7 +121,7 @@ export const {
|
||||
{
|
||||
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',
|
||||
|
||||
@@ -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
|
||||
@@ -14,7 +13,7 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||
dot: string
|
||||
}
|
||||
icon: string
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
color: TailwindColor
|
||||
isSolved: boolean
|
||||
showBanner: boolean
|
||||
}
|
||||
|
||||
@@ -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'>[]
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
web/src/env.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
||||
|
||||
import type { ErrorBanners } from './lib/errorBanners'
|
||||
import type { KarmaUnlocks } from './lib/karmaUnlocks'
|
||||
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type * as htmx from 'htmx.org'
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = ComponentProps<typeof BaseHead> & {
|
||||
children: AstroChildren
|
||||
errors?: string[]
|
||||
success?: string[]
|
||||
className?: {
|
||||
classNames?: {
|
||||
body?: string
|
||||
main?: string
|
||||
footer?: string
|
||||
@@ -31,14 +31,16 @@ type Props = ComponentProps<typeof BaseHead> & {
|
||||
| 'max-w-screen-sm'
|
||||
| 'max-w-screen-xl'
|
||||
| 'max-w-screen-xs'
|
||||
isErrorPage?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
errors = [],
|
||||
success = [],
|
||||
className,
|
||||
classNames,
|
||||
widthClassName = 'max-w-screen-2xl',
|
||||
showSplashText,
|
||||
isErrorPage,
|
||||
...baseHeadProps
|
||||
} = Astro.props
|
||||
|
||||
@@ -77,7 +79,10 @@ const announcement = await Astro.locals.banners.try(
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<BaseHead {...baseHeadProps} />
|
||||
</head>
|
||||
<body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}>
|
||||
<body
|
||||
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
|
||||
data-is-error-page={isErrorPage}
|
||||
>
|
||||
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||
<Header
|
||||
classNames={{
|
||||
@@ -116,7 +121,7 @@ const announcement = await Astro.locals.banners.try(
|
||||
<main
|
||||
class={cn(
|
||||
'container mx-auto mt-4 mb-12 grow px-4',
|
||||
className?.main,
|
||||
classNames?.main,
|
||||
(widthClassName === 'max-w-none' || widthClassName === 'max-w-screen-2xl') && 'lg:px-8 2xl:px-12',
|
||||
widthClassName
|
||||
)}
|
||||
@@ -124,6 +129,6 @@ const announcement = await Astro.locals.banners.try(
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<Footer class={className?.footer} />
|
||||
<Footer class={classNames?.footer} />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,20 +9,30 @@ import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
||||
layoutHeader: { icon: string; title: string; subtitle?: string }
|
||||
size?: 'md' | 'xs'
|
||||
}
|
||||
|
||||
const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||
const { layoutHeader, size = 'xs', ...baseLayoutProps } = Astro.props
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
className={{
|
||||
...baseLayoutProps.className,
|
||||
main: cn('xs:items-center-safe flex grow flex-col justify-center-safe', baseLayoutProps.className?.main),
|
||||
classNames={{
|
||||
main: cn(
|
||||
'flex grow flex-col justify-center-safe',
|
||||
{
|
||||
'xs:items-center-safe': size === 'xs',
|
||||
'md:items-center-safe': size === 'md',
|
||||
},
|
||||
baseLayoutProps.classNames?.main
|
||||
),
|
||||
}}
|
||||
{...baseLayoutProps}
|
||||
>
|
||||
<div
|
||||
class="bg-night-800 border-night-500 xs:block xs:max-w-screen-xs contents w-full rounded-xl border p-8"
|
||||
class={cn('bg-night-800 border-night-500 contents w-full rounded-xl border p-8', {
|
||||
'xs:block xs:max-w-screen-xs': size === 'xs',
|
||||
'md:block md:max-w-screen-md': size === 'md',
|
||||
})}
|
||||
>
|
||||
<div class="bg-day-200 mx-auto flex size-12 items-center justify-center rounded-lg">
|
||||
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
||||
@@ -31,14 +41,24 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||
<h1
|
||||
class={cn(
|
||||
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
|
||||
!layoutHeader.subtitle && 'xs:mb-8 mb-6'
|
||||
!layoutHeader.subtitle && {
|
||||
'xs:mb-8 mb-6': size === 'xs',
|
||||
'mb-6 md:mb-8': size === 'md',
|
||||
}
|
||||
)}
|
||||
>
|
||||
{layoutHeader.title}
|
||||
</h1>
|
||||
{
|
||||
!!layoutHeader.subtitle && (
|
||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
||||
<p
|
||||
class={cn('text-day-500 mt-1 mb-6 text-center', {
|
||||
'xs:mb-8': size === 'xs',
|
||||
'md:mb-8': size === 'md',
|
||||
})}
|
||||
>
|
||||
{layoutHeader.subtitle}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { orderBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { getKycLevelClarificationInfo } from '../constants/kycLevelClarifications'
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
|
||||
@@ -9,6 +11,262 @@ import { formatDateShort } from './timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
type NonDbAttribute = Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
title: true
|
||||
type: true
|
||||
category: true
|
||||
description: true
|
||||
privacyPoints: true
|
||||
trustPoints: true
|
||||
}
|
||||
}> & {
|
||||
slug: string
|
||||
links: {
|
||||
url: string
|
||||
label: string
|
||||
icon: string
|
||||
}[]
|
||||
}
|
||||
|
||||
type NonDbAttributeFull = NonDbAttribute & {
|
||||
customize: (
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
createdAt: true
|
||||
tosReviewAt: true
|
||||
tosReview: true
|
||||
onionUrls: true
|
||||
i2pUrls: true
|
||||
acceptedCurrencies: true
|
||||
kycLevel: true
|
||||
kycLevelClarification: true
|
||||
}
|
||||
}>
|
||||
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||
show: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
{
|
||||
slug: 'verification-verified',
|
||||
title: 'Verified',
|
||||
type: 'GOOD',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=verified',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'verification-approved',
|
||||
title: 'Approved',
|
||||
type: 'INFO',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.verificationStatus === 'APPROVED',
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'verification-community-contributed',
|
||||
title: 'Community contributed',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=community',
|
||||
label: 'With this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Without this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'verification-scam',
|
||||
title: 'Is SCAM',
|
||||
type: 'BAD',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=scam',
|
||||
label: 'With this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Without this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.verificationStatus === 'VERIFICATION_FAILED',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||
}),
|
||||
},
|
||||
...kycLevels.map<NonDbAttributeFull>((kycLevel) => ({
|
||||
slug: `kyc-level-${kycLevel.id}`,
|
||||
title: kycLevel.name,
|
||||
type: kycLevel.attributeType,
|
||||
category: 'PRIVACY',
|
||||
description: kycLevel.description,
|
||||
privacyPoints: kycLevel.privacyPoints,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: `/?max-kyc=${kycLevel.id}`,
|
||||
label: 'With this or better',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => {
|
||||
const clarification = getKycLevelClarificationInfo(service.kycLevelClarification)
|
||||
return {
|
||||
show: service.kycLevel === kycLevel.value,
|
||||
title: kycLevel.name + (clarification.value !== 'NONE' ? ` (${clarification.label})` : ''),
|
||||
description:
|
||||
kycLevel.description + (clarification.value !== 'NONE' ? ` ${clarification.description}` : ''),
|
||||
}
|
||||
},
|
||||
})),
|
||||
{
|
||||
slug: 'archived',
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||
privacyPoints: 0,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.serviceVisibility === 'ARCHIVED',
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'recently-listed',
|
||||
title: 'Recently listed',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: 'Listed on KYCnot.me less than 15 days ago. Proceed with caution.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -5,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.isRecentlyListed,
|
||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'cannot-analyse-tos',
|
||||
title: "Can't analyse ToS",
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description:
|
||||
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -3,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.tosReviewAt !== null && service.tosReview === null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'has-onion-urls',
|
||||
title: 'Has Onion URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?onion=true',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.onionUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'has-i2p-urls',
|
||||
title: 'Has I2P URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'I2P URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?i2p=true',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.i2pUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'monero-accepted',
|
||||
title: 'Accepts Monero',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description:
|
||||
'This service accepts Monero, a privacy-focused cryptocurrency that provides enhanced anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?currency=monero',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.acceptedCurrencies.includes('MONERO'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
export function sortAttributes<
|
||||
T extends Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
@@ -34,131 +292,15 @@ export function sortAttributes<
|
||||
}
|
||||
|
||||
export function makeNonDbAttributes(
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
createdAt: true
|
||||
}
|
||||
}>,
|
||||
service: Parameters<NonDbAttributeFull['customize']>[0],
|
||||
{ filter = false }: { filter?: boolean } = {}
|
||||
) {
|
||||
const nonDbAttributes: (Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
title: true
|
||||
type: true
|
||||
category: true
|
||||
description: true
|
||||
privacyPoints: true
|
||||
trustPoints: true
|
||||
}
|
||||
}> & {
|
||||
show: boolean
|
||||
links: {
|
||||
url: string
|
||||
label: string
|
||||
icon: string
|
||||
}[]
|
||||
})[] = [
|
||||
{
|
||||
title: 'Verified',
|
||||
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
|
||||
type: 'GOOD',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=verified',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Approved',
|
||||
show: service.verificationStatus === 'APPROVED',
|
||||
type: 'INFO',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community contributed',
|
||||
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=community',
|
||||
label: 'With this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Without this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Is SCAM',
|
||||
show: service.verificationStatus === 'VERIFICATION_FAILED',
|
||||
type: 'BAD',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=scam',
|
||||
label: 'With this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Without this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
show: service.serviceVisibility === 'ARCHIVED',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||
privacyPoints: 0,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: 'Recently listed',
|
||||
show: service.isRecentlyListed,
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
||||
privacyPoints: 0,
|
||||
trustPoints: -5,
|
||||
links: [],
|
||||
},
|
||||
]
|
||||
const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({
|
||||
...attribute,
|
||||
...customize(service),
|
||||
}))
|
||||
|
||||
if (filter) return nonDbAttributes.filter(({ show }) => show)
|
||||
if (filter) return attributes.filter(({ show }) => show)
|
||||
|
||||
return nonDbAttributes
|
||||
return attributes
|
||||
}
|
||||
|
||||
60
web/src/lib/client/browserNotifications.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { typedLocalStorage } from '../localstorage'
|
||||
|
||||
type SafeResult = { success: false; error: string } | { success: true; error?: undefined }
|
||||
|
||||
export function supportsBrowserNotifications() {
|
||||
return 'Notification' in window
|
||||
}
|
||||
|
||||
export function isBrowserNotificationsEnabled() {
|
||||
return (
|
||||
supportsBrowserNotifications() &&
|
||||
Notification.permission === 'granted' &&
|
||||
typedLocalStorage.browserNotificationsEnabled.get()
|
||||
)
|
||||
}
|
||||
|
||||
export async function enableBrowserNotifications(): Promise<SafeResult> {
|
||||
try {
|
||||
if (!supportsBrowserNotifications()) {
|
||||
return { success: false, error: 'Browser notifications are not supported' }
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
return { success: false, error: 'Notification permission denied' }
|
||||
}
|
||||
|
||||
typedLocalStorage.browserNotificationsEnabled.set(true)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Browser notification setup failed:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `Browser notification setup failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
export function disableBrowserNotifications(): SafeResult {
|
||||
try {
|
||||
typedLocalStorage.browserNotificationsEnabled.set(false)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Browser notification disable failed:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `Browser notification disable failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
export function showBrowserNotification(title: string, options?: NotificationOptions) {
|
||||
if (!isBrowserNotificationsEnabled()) {
|
||||
console.warn('Browser notifications are not enabled')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new Notification(title, options)
|
||||
} catch (error) {
|
||||
console.error('Failed to show browser notification:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
187
web/src/lib/client/clientPushNotifications.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { ActionInput } from '../astroActions'
|
||||
import type { actions } from 'astro:actions'
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
export type SafeResult =
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
| {
|
||||
success: true
|
||||
error?: undefined
|
||||
}
|
||||
|
||||
export async function subscribeToPushNotifications(vapidPublicKey: string): Promise<SafeResult> {
|
||||
try {
|
||||
if (!supportsPushNotifications()) {
|
||||
return { success: false, error: 'Push notifications are not supported in this browser' }
|
||||
}
|
||||
|
||||
const registration = await getServiceWorkerRegistration()
|
||||
if (!registration) return { success: false, error: 'Service worker not available' }
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
return { success: false, error: 'Notification permission denied' }
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const p256dh = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
|
||||
const response = await fetch('/internal-api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return { success: false, error: `Server error: ${response.statusText} ${errorText}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Push subscription failed:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `Subscription failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPushNotifications(): Promise<SafeResult> {
|
||||
try {
|
||||
const registration = await getServiceWorkerRegistration()
|
||||
if (!registration) return { success: false, error: 'Service worker not available' }
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return { success: false, error: 'No push subscription found' }
|
||||
|
||||
const unsubscribed = await subscription.unsubscribe()
|
||||
if (!unsubscribed) return { success: false, error: 'Failed to unsubscribe from browser' }
|
||||
|
||||
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return { success: false, error: `Server error: ${response.statusText} ${errorText}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Push unsubscription failed:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: `Unsubscription failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsPushNotifications() {
|
||||
const isSecure =
|
||||
window.isSecureContext ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1'
|
||||
|
||||
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
}
|
||||
|
||||
async function getServiceWorkerRegistration() {
|
||||
try {
|
||||
if (window.__SW_REGISTRATION__) return window.__SW_REGISTRATION__
|
||||
return (await navigator.serviceWorker.getRegistration()) ?? null
|
||||
} catch (error) {
|
||||
console.error('Error getting service worker registration:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentSubscription() {
|
||||
try {
|
||||
const registration = await getServiceWorkerRegistration()
|
||||
if (!registration) return null
|
||||
|
||||
return await registration.pushManager.getSubscription()
|
||||
} catch (error) {
|
||||
console.error('Error getting current push subscription:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubscription[]) {
|
||||
const currentSubscription = await getCurrentSubscription()
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
}
|
||||
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
||||
const base64 = cleaned + padding
|
||||
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
export function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
||||
try {
|
||||
if (typeof subscriptionsAsString !== 'string') {
|
||||
console.error('Push subscriptions are not a string')
|
||||
return []
|
||||
}
|
||||
|
||||
const subscriptions = JSON.parse(subscriptionsAsString)
|
||||
|
||||
if (!Array.isArray(subscriptions)) {
|
||||
console.error('Push subscriptions are not an array')
|
||||
return []
|
||||
}
|
||||
|
||||
if (!subscriptions.every(isServerSubscription)) {
|
||||
console.error('Push subscriptions are not valid')
|
||||
return subscriptions.filter(isServerSubscription)
|
||||
}
|
||||
|
||||
return subscriptions
|
||||
} catch (error) {
|
||||
console.error('Failed to parse push subscriptions:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
|
||||
if (typeof subscription !== 'object' || subscription === null) return false
|
||||
const s = subscription as Record<string, unknown>
|
||||
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null)
|
||||
}
|
||||
25
web/src/lib/colors.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type TailwindColor =
|
||||
| 'amber'
|
||||
| 'black'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'emerald'
|
||||
| 'fuchsia'
|
||||
| 'gray'
|
||||
| 'green'
|
||||
| 'indigo'
|
||||
| 'lime'
|
||||
| 'neutral'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'rose'
|
||||
| 'sky'
|
||||
| 'slate'
|
||||
| 'stone'
|
||||
| 'teal'
|
||||
| 'violet'
|
||||
| 'white'
|
||||
| 'yellow'
|
||||
| 'zinc'
|
||||
323
web/src/lib/feeds.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { prisma } from './prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
type SafeResult<T> =
|
||||
| {
|
||||
success: false
|
||||
error: { message: string; responseInit: ResponseInit }
|
||||
data?: undefined
|
||||
}
|
||||
| {
|
||||
success: true
|
||||
error?: undefined
|
||||
data: T
|
||||
}
|
||||
|
||||
export const takeCounts = {
|
||||
serviceComments: 100,
|
||||
serviceEvents: 100,
|
||||
allEvents: 100,
|
||||
userNotifications: 50,
|
||||
} as const satisfies Record<string, number>
|
||||
|
||||
const serviceSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
} as const satisfies Prisma.ServiceSelect
|
||||
|
||||
export async function getService(slug: string | undefined): Promise<
|
||||
SafeResult<{
|
||||
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||
}>
|
||||
> {
|
||||
if (!slug || typeof slug !== 'string') {
|
||||
return { success: false, error: { message: 'Invalid slug', responseInit: { status: 400 } } }
|
||||
}
|
||||
|
||||
const service =
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
slug,
|
||||
},
|
||||
select: serviceSelect,
|
||||
})) ??
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
previousSlugs: { has: slug },
|
||||
},
|
||||
select: serviceSelect,
|
||||
}))
|
||||
|
||||
if (!service) {
|
||||
return { success: false, error: { message: 'Service not found', responseInit: { status: 404 } } }
|
||||
}
|
||||
|
||||
return { success: true, data: { service } }
|
||||
}
|
||||
|
||||
const serviceCommentSelect = {
|
||||
id: true,
|
||||
content: true,
|
||||
rating: true,
|
||||
ratingActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
author: {
|
||||
select: {
|
||||
name: true,
|
||||
displayName: true,
|
||||
verified: true,
|
||||
admin: true,
|
||||
moderator: true,
|
||||
spammer: true,
|
||||
serviceAffiliations: {
|
||||
select: {
|
||||
role: true,
|
||||
service: { select: { name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.CommentSelect
|
||||
|
||||
export async function getCommentsForService(slug: string | undefined): Promise<
|
||||
SafeResult<{
|
||||
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||
comments: Prisma.CommentGetPayload<{ select: typeof serviceCommentSelect }>[]
|
||||
}>
|
||||
> {
|
||||
const result = await getService(slug)
|
||||
if (!result.success) return result
|
||||
const { service } = result.data
|
||||
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||
suspicious: false,
|
||||
parentId: null, // Only root comments for the main feed
|
||||
},
|
||||
select: serviceCommentSelect,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: takeCounts.serviceComments,
|
||||
})
|
||||
|
||||
return { success: true, data: { service, comments } }
|
||||
}
|
||||
|
||||
const eventSelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
type: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.EventSelect
|
||||
|
||||
const serviceEventSelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
type: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
} as const satisfies Prisma.EventSelect
|
||||
|
||||
export async function getEventsForService(slug: string | undefined): Promise<
|
||||
SafeResult<{
|
||||
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||
events: Prisma.EventGetPayload<{ select: typeof serviceEventSelect }>[]
|
||||
}>
|
||||
> {
|
||||
const result = await getService(slug)
|
||||
if (!result.success) return result
|
||||
const { service } = result.data
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
visible: true,
|
||||
},
|
||||
select: serviceEventSelect,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: takeCounts.serviceEvents,
|
||||
})
|
||||
|
||||
return { success: true, data: { service, events } }
|
||||
}
|
||||
|
||||
export async function getEvents(): Promise<
|
||||
SafeResult<{
|
||||
events: Prisma.EventGetPayload<{ select: typeof eventSelect }>[]
|
||||
}>
|
||||
> {
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
visible: true,
|
||||
service: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
select: eventSelect,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: takeCounts.allEvents,
|
||||
})
|
||||
|
||||
return { success: true, data: { events } }
|
||||
}
|
||||
|
||||
const userSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
} as const satisfies Prisma.UserSelect
|
||||
|
||||
const notificationSelect = {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
aboutAccountStatusChange: true,
|
||||
aboutCommentStatusChange: true,
|
||||
aboutServiceVerificationStatusChange: true,
|
||||
aboutSuggestionStatusChange: true,
|
||||
aboutServiceSuggestionId: true,
|
||||
aboutComment: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
communityNote: true,
|
||||
status: true,
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutEvent: {
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutService: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
verificationStatus: true,
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionMessage: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
suggestion: {
|
||||
select: {
|
||||
id: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
points: true,
|
||||
action: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.NotificationSelect
|
||||
|
||||
export async function getUserNotifications(feedId: string | undefined): Promise<
|
||||
SafeResult<{
|
||||
user: Prisma.UserGetPayload<{ select: typeof userSelect }>
|
||||
notifications: Prisma.NotificationGetPayload<{ select: typeof notificationSelect }>[]
|
||||
}>
|
||||
> {
|
||||
if (!feedId || typeof feedId !== 'string') {
|
||||
return { success: false, error: { message: 'Invalid feed ID', responseInit: { status: 400 } } }
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { feedId, spammer: false },
|
||||
select: userSelect,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: { message: 'User not found', responseInit: { status: 404 } } }
|
||||
}
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: notificationSelect,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: takeCounts.userNotifications,
|
||||
})
|
||||
|
||||
return { success: true, data: { user, notifications } }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { z } from 'astro:content'
|
||||
import type { z } from 'astro/zod'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface JSONObject {
|
||||
|
||||
@@ -50,4 +50,8 @@ export const typedLocalStorage = makeTypedLocalStorage({
|
||||
pushNotificationsBannerDismissedAt: {
|
||||
schema: z.coerce.date(),
|
||||
},
|
||||
browserNotificationsEnabled: {
|
||||
schema: z.boolean(),
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
56
web/src/lib/notificationOptions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { NotificationData, NotificationPayload } from './serverEventsTypes'
|
||||
|
||||
export type CustomNotificationOptions = NotificationOptions & {
|
||||
actions?: { action: string; title: string; icon?: string }[]
|
||||
timestamp: number
|
||||
data: NotificationData
|
||||
}
|
||||
|
||||
export function makeNotificationOptions(
|
||||
payload: NotificationPayload | null,
|
||||
options: { removeActions?: boolean } = {}
|
||||
) {
|
||||
const defaultOptions: CustomNotificationOptions = {
|
||||
body: 'You have a new notification',
|
||||
lang: 'en-US',
|
||||
icon: '/favicon.svg',
|
||||
badge: '/notification-icon.svg',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
actions: options.removeActions
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
defaultActionUrl: '/notifications',
|
||||
payload: null,
|
||||
},
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return defaultOptions
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultOptions,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
body: payload.body || undefined,
|
||||
actions: options.removeActions
|
||||
? undefined
|
||||
: payload.actions.map((action) => ({
|
||||
action: action.action,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
})),
|
||||
data: {
|
||||
...defaultOptions.data,
|
||||
payload,
|
||||
},
|
||||
} as CustomNotificationOptions
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { prisma } from './prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>(
|
||||
userId: number,
|
||||
select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never },
|
||||
tx: Prisma.TransactionClient = prisma
|
||||
tx:
|
||||
| Parameters<Parameters<(typeof prisma)['$transaction']>[0]>[0]
|
||||
| Prisma.TransactionClient
|
||||
| PrismaClient = prisma
|
||||
) {
|
||||
return (
|
||||
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??
|
||||
|
||||
@@ -3,15 +3,18 @@ import { commentStatusChangesById } from '../constants/commentStatusChange'
|
||||
import { eventTypesById } from '../constants/eventTypes'
|
||||
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
|
||||
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
|
||||
import { getServiceSuggestionTypeInfo } from '../constants/serviceSuggestionType'
|
||||
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
|
||||
|
||||
import { makeCommentUrl } from './commentsWithReplies'
|
||||
|
||||
import type { NotificationAction } from './serverEventsTypes'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export function makeNotificationTitle(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
type: true
|
||||
aboutAccountStatusChange: true
|
||||
aboutCommentStatusChange: true
|
||||
@@ -46,6 +49,7 @@ export function makeNotificationTitle(
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
status: true
|
||||
type: true
|
||||
service: {
|
||||
select: {
|
||||
name: true
|
||||
@@ -87,6 +91,9 @@ export function makeNotificationTitle(
|
||||
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
|
||||
): string {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return `Test notification #${notification.id.toString()}`
|
||||
}
|
||||
case 'COMMENT_STATUS_CHANGE': {
|
||||
if (!notification.aboutComment) return 'A comment you are watching had a status change'
|
||||
|
||||
@@ -126,6 +133,12 @@ export function makeNotificationTitle(
|
||||
? `New unmoderated comment on ${service}`
|
||||
: `New comment on ${service}`
|
||||
}
|
||||
case 'SUGGESTION_CREATED': {
|
||||
if (!notification.aboutServiceSuggestion) return 'New service suggestion'
|
||||
const typeInfo = getServiceSuggestionTypeInfo(notification.aboutServiceSuggestion.type)
|
||||
const service = notification.aboutServiceSuggestion.service.name
|
||||
return `New ${typeInfo.labelAlt} suggestion for ${service}`
|
||||
}
|
||||
case 'SUGGESTION_MESSAGE': {
|
||||
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
|
||||
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
|
||||
@@ -178,6 +191,7 @@ export function makeNotificationTitle(
|
||||
export function makeNotificationContent(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
createdAt: true
|
||||
type: true
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
@@ -204,12 +218,16 @@ export function makeNotificationContent(
|
||||
}>
|
||||
): string | null {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return `Created on ${notification.createdAt.toLocaleString()}`
|
||||
}
|
||||
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
// case 'KARMA_UNLOCK':
|
||||
case 'KARMA_CHANGE': {
|
||||
if (!notification.aboutKarmaTransaction) return null
|
||||
return notification.aboutKarmaTransaction.description
|
||||
}
|
||||
case 'SUGGESTION_CREATED':
|
||||
case 'SUGGESTION_STATUS_CHANGE':
|
||||
case 'ACCOUNT_STATUS_CHANGE':
|
||||
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
||||
@@ -236,7 +254,7 @@ export function makeNotificationContent(
|
||||
}
|
||||
}
|
||||
|
||||
export function makeNotificationLink(
|
||||
export function makeNotificationActions(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
type: true
|
||||
@@ -278,44 +296,131 @@ export function makeNotificationLink(
|
||||
}
|
||||
}>,
|
||||
origin: string
|
||||
): string | null {
|
||||
): NotificationAction[] {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/notifications`,
|
||||
},
|
||||
{
|
||||
action: 'profile',
|
||||
title: 'Profile',
|
||||
...iconNameAndUrl('ri:user-line'),
|
||||
url: `${origin}/account`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'COMMENT_STATUS_CHANGE':
|
||||
case 'REPLY_COMMENT_CREATED':
|
||||
case 'COMMUNITY_NOTE_ADDED':
|
||||
case 'ROOT_COMMENT_CREATED': {
|
||||
if (!notification.aboutComment) return null
|
||||
return makeCommentUrl({
|
||||
serviceSlug: notification.aboutComment.service.slug,
|
||||
commentId: notification.aboutComment.id,
|
||||
origin,
|
||||
})
|
||||
if (!notification.aboutComment) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: makeCommentUrl({
|
||||
serviceSlug: notification.aboutComment.service.slug,
|
||||
commentId: notification.aboutComment.id,
|
||||
origin,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_CREATED': {
|
||||
if (!notification.aboutServiceSuggestionId) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_MESSAGE': {
|
||||
if (!notification.aboutServiceSuggestionMessage) return null
|
||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`
|
||||
if (!notification.aboutServiceSuggestionMessage) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_STATUS_CHANGE': {
|
||||
if (!notification.aboutServiceSuggestionId) return null
|
||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`
|
||||
if (!notification.aboutServiceSuggestionId) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
// case 'KARMA_UNLOCK': {
|
||||
// return `${origin}/account#karma-unlocks`
|
||||
// return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
|
||||
// }
|
||||
case 'KARMA_CHANGE': {
|
||||
return `${origin}/account#karma-transactions`
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/account#karma-transactions`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'ACCOUNT_STATUS_CHANGE': {
|
||||
return `${origin}/account#account-status`
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/account#account-status`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'EVENT_CREATED': {
|
||||
if (!notification.aboutEvent) return null
|
||||
return `${origin}/service/${notification.aboutEvent.service.slug}#events`
|
||||
if (!notification.aboutEvent) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
||||
if (!notification.aboutService) return null
|
||||
return `${origin}/service/${notification.aboutService.slug}#verification`
|
||||
if (!notification.aboutService) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service/${notification.aboutService.slug}#verification`,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function iconUrl<T extends `${string}:${string}`>(iconName: T) {
|
||||
return `https://api.iconify.design/${iconName.replace(':', '/') as T extends `${infer Prefix}:${infer Suffix}` ? `${Prefix}/${Suffix}` : never}.svg` as const
|
||||
}
|
||||
|
||||
function iconNameAndUrl<T extends `${string}:${string}`>(iconName: T) {
|
||||
return {
|
||||
iconName,
|
||||
icon: iconUrl(iconName),
|
||||
} as const
|
||||
}
|
||||
|
||||
@@ -1,248 +1,21 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { Client } from 'pg'
|
||||
import { startListener, stopListener } from './postgresListeners'
|
||||
|
||||
import { zodParseJSON } from './json'
|
||||
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
|
||||
import { prisma } from './prisma'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
import { sendPushNotification, type NotificationData } from './webPush'
|
||||
|
||||
import type { AstroIntegration, HookParameters } from 'astro'
|
||||
|
||||
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
let pgClient: Client | null = null
|
||||
import type { AstroIntegration } from 'astro'
|
||||
|
||||
const INTEGRATION_NAME = 'postgres-listener'
|
||||
|
||||
async function handleNotificationCreated(
|
||||
notificationId: number,
|
||||
options: HookParameters<'astro:server:start'>
|
||||
) {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
try {
|
||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
||||
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
aboutAccountStatusChange: true,
|
||||
aboutCommentStatusChange: true,
|
||||
aboutServiceVerificationStatusChange: true,
|
||||
aboutSuggestionStatusChange: true,
|
||||
aboutComment: {
|
||||
select: {
|
||||
id: true,
|
||||
author: { select: { id: true } },
|
||||
status: true,
|
||||
content: true,
|
||||
communityNote: true,
|
||||
parent: {
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionId: true,
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
status: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionMessage: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
suggestion: {
|
||||
select: {
|
||||
id: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutEvent: {
|
||||
select: {
|
||||
title: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutService: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
verificationStatus: true,
|
||||
},
|
||||
},
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
points: true,
|
||||
action: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!notification) {
|
||||
logger.warn(`Notification with ID ${String(notificationId)} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: notification.userId },
|
||||
select: {
|
||||
id: true,
|
||||
endpoint: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
logger.info(`No push subscriptions found for user ${notification.user.name}`)
|
||||
return
|
||||
}
|
||||
const notificationData = {
|
||||
title: makeNotificationTitle(notification, notification.user),
|
||||
body: makeNotificationContent(notification) ?? undefined,
|
||||
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
|
||||
} satisfies NotificationData
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
notificationData
|
||||
)
|
||||
|
||||
// Remove invalid subscriptions
|
||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
|
||||
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
|
||||
}
|
||||
|
||||
return result.success
|
||||
})
|
||||
)
|
||||
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length
|
||||
const failureCount = results.filter((r) => !(r.status === 'fulfilled' && r.value)).length
|
||||
|
||||
logger.info(
|
||||
`Push notification sent for notification ${String(notificationId)} to user ${notification.user.name}: ${String(successCount)} successful, ${String(failureCount)} failed`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function postgresListener(): AstroIntegration {
|
||||
return {
|
||||
name: 'postgres-listener',
|
||||
hooks: {
|
||||
'astro:server:start': async (options) => {
|
||||
'astro:server:start': (options) => {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
|
||||
try {
|
||||
logger.info('Starting PostgreSQL notification listener...')
|
||||
|
||||
pgClient = new Client({ connectionString: DATABASE_URL })
|
||||
|
||||
await pgClient.connect()
|
||||
logger.info('Connected to PostgreSQL for notifications')
|
||||
|
||||
await pgClient.query('LISTEN notification_created')
|
||||
logger.info('Listening for notification_created events')
|
||||
|
||||
pgClient.on('notification', (msg) => {
|
||||
if (msg.channel === 'notification_created') {
|
||||
const payload = zodParseJSON(z.object({ id: z.number().int().positive() }), msg.payload)
|
||||
if (!payload) {
|
||||
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: Don't await to avoid blocking
|
||||
void handleNotificationCreated(payload.id, options)
|
||||
}
|
||||
})
|
||||
|
||||
pgClient.on('error', (error) => {
|
||||
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
|
||||
})
|
||||
|
||||
pgClient.on('end', () => {
|
||||
logger.info('PostgreSQL client connection ended')
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
void startListener(logger)
|
||||
},
|
||||
|
||||
'astro:server:done': async ({ logger: originalLogger }) => {
|
||||
const logger = originalLogger.fork(INTEGRATION_NAME)
|
||||
|
||||
if (pgClient) {
|
||||
try {
|
||||
logger.info('Stopping PostgreSQL notification listener...')
|
||||
await pgClient.end()
|
||||
pgClient = null
|
||||
logger.info('PostgreSQL listener stopped')
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
'astro:server:done': (options) => {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
void stopListener(logger)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
112
web/src/lib/postgresListeners.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Client } from 'pg'
|
||||
|
||||
import { getRedisServerEvents, type RedisServerEvents } from './redis/redisServerEvents'
|
||||
import { sendNotification } from './sendNotifications'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
import type { AstroIntegrationLogger } from 'astro'
|
||||
|
||||
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
|
||||
|
||||
let pgClient: Client | null = null
|
||||
|
||||
export async function startListener(
|
||||
logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>
|
||||
) {
|
||||
try {
|
||||
logger.info('Starting PostgreSQL notification listener...')
|
||||
|
||||
pgClient = new Client({ connectionString: DATABASE_URL })
|
||||
|
||||
await pgClient.connect()
|
||||
logger.info('Connected to PostgreSQL for notifications')
|
||||
|
||||
await pgClient.query('LISTEN notification_created')
|
||||
logger.info('Listening for notification_created events')
|
||||
|
||||
const redisServerEvents = await getRedisServerEvents()
|
||||
|
||||
pgClient.on('notification', (msg) => {
|
||||
if (msg.channel === 'notification_created') {
|
||||
const payload = parseJSON(msg.payload)
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
!('id' in payload) ||
|
||||
typeof payload.id !== 'number' ||
|
||||
payload.id <= 0 ||
|
||||
!Number.isFinite(payload.id) ||
|
||||
!Number.isInteger(payload.id)
|
||||
) {
|
||||
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: Don't await to avoid blocking
|
||||
void handleNotificationCreated(payload.id, logger, redisServerEvents)
|
||||
}
|
||||
})
|
||||
|
||||
pgClient.on('error', (error) => {
|
||||
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
|
||||
})
|
||||
|
||||
pgClient.on('end', () => {
|
||||
logger.info('PostgreSQL client connection ended')
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationCreated(
|
||||
notificationId: number,
|
||||
logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>,
|
||||
redisServerEvents: RedisServerEvents
|
||||
) {
|
||||
try {
|
||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
||||
|
||||
const results = await sendNotification(notificationId, logger, redisServerEvents)
|
||||
|
||||
logger.info(
|
||||
`Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopListener(logger: AstroIntegrationLogger) {
|
||||
if (pgClient) {
|
||||
try {
|
||||
logger.info('Stopping PostgreSQL notification listener...')
|
||||
await pgClient.end()
|
||||
pgClient = null
|
||||
logger.info('PostgreSQL listener stopped')
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJSON<T = unknown, D extends T | undefined = undefined>(
|
||||
stringValue: string | null | undefined,
|
||||
defaultValue?: D
|
||||
): D | T {
|
||||
if (!stringValue) return defaultValue as D
|
||||
|
||||
try {
|
||||
return JSON.parse(stringValue) as T
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return defaultValue as D
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { deserializeActionResult } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
import { REDIS_ACTIONS_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
@@ -29,11 +28,13 @@ const dataSchema = z.object({
|
||||
})
|
||||
|
||||
class RedisActionsSessions extends RedisGenericManager {
|
||||
private readonly prefix = 'actions_session:'
|
||||
|
||||
async store(data: z.input<typeof dataSchema>) {
|
||||
const sessionId = randomUUID()
|
||||
|
||||
const parsedData = dataSchema.parse(data)
|
||||
await this.redisClient.set(`actions-session:${sessionId}`, JSON.stringify(parsedData), {
|
||||
await this.redisClient.set(`${this.prefix}${sessionId}`, JSON.stringify(parsedData), {
|
||||
EX: this.expirationTime,
|
||||
})
|
||||
|
||||
@@ -43,7 +44,7 @@ class RedisActionsSessions extends RedisGenericManager {
|
||||
async get(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return null
|
||||
|
||||
const key = `actions-session:${sessionId}`
|
||||
const key = `${this.prefix}${sessionId}`
|
||||
|
||||
const rawData = await this.redisClient.get(key)
|
||||
if (!rawData) return null
|
||||
@@ -60,10 +61,10 @@ class RedisActionsSessions extends RedisGenericManager {
|
||||
async delete(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return
|
||||
|
||||
await this.redisClient.del(`actions-session:${sessionId}`)
|
||||
await this.redisClient.del(`${this.prefix}${sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const redisActionsSessions = await RedisActionsSessions.createAndConnect({
|
||||
expirationTime: REDIS_ACTIONS_SESSION_EXPIRY_SECONDS,
|
||||
expirationTime: 60 * 5, // 5 minutes in seconds
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { REDIS_URL } from 'astro:env/server'
|
||||
import { createClient } from 'redis'
|
||||
|
||||
import { getServerEnvVariable } from '../serverEnvVariables'
|
||||
|
||||
const REDIS_URL = getServerEnvVariable('REDIS_URL')
|
||||
|
||||
type RedisGenericManagerOptions = {
|
||||
expirationTime: number
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { z } from 'astro:content'
|
||||
import { REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
@@ -11,11 +10,13 @@ const dataSchema = z.object({
|
||||
})
|
||||
|
||||
class RedisImpersonationSessions extends RedisGenericManager {
|
||||
private readonly prefix = 'impersonation_session:'
|
||||
|
||||
async store(data: z.input<typeof dataSchema>) {
|
||||
const sessionId = randomUUID()
|
||||
|
||||
const parsedData = dataSchema.parse(data)
|
||||
await this.redisClient.set(`impersonation-session:${sessionId}`, JSON.stringify(parsedData), {
|
||||
await this.redisClient.set(`${this.prefix}${sessionId}`, JSON.stringify(parsedData), {
|
||||
EX: this.expirationTime,
|
||||
})
|
||||
|
||||
@@ -25,7 +26,7 @@ class RedisImpersonationSessions extends RedisGenericManager {
|
||||
async get(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return null
|
||||
|
||||
const key = `impersonation-session:${sessionId}`
|
||||
const key = `${this.prefix}${sessionId}`
|
||||
|
||||
const rawData = await this.redisClient.get(key)
|
||||
if (!rawData) return null
|
||||
@@ -36,10 +37,10 @@ class RedisImpersonationSessions extends RedisGenericManager {
|
||||
async delete(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return
|
||||
|
||||
await this.redisClient.del(`impersonation-session:${sessionId}`)
|
||||
await this.redisClient.del(`${this.prefix}${sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const redisImpersonationSessions = await RedisImpersonationSessions.createAndConnect({
|
||||
expirationTime: REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS,
|
||||
expirationTime: 60 * 60 * 24, // 24 hours in seconds
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS } from 'astro:env/server'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
class RedisPreGeneratedSecretTokens extends RedisGenericManager {
|
||||
private readonly prefix = 'pregenerated_user_secret_token:'
|
||||
|
||||
/**
|
||||
* Stores a pre-generated token with expiration
|
||||
* @param token The pre-generated token
|
||||
*/
|
||||
async storePreGeneratedToken(token: string): Promise<void> {
|
||||
await this.redisClient.set(`pregenerated-user-secret-token:${token}`, '1', {
|
||||
await this.redisClient.set(`${this.prefix}${token}`, '1', {
|
||||
EX: this.expirationTime,
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class RedisPreGeneratedSecretTokens extends RedisGenericManager {
|
||||
* @returns true if token was valid and consumed, false otherwise
|
||||
*/
|
||||
async validateAndConsumePreGeneratedToken(token: string): Promise<boolean> {
|
||||
const key = `pregenerated-user-secret-token:${token}`
|
||||
const key = `${this.prefix}${token}`
|
||||
const exists = await this.redisClient.exists(key)
|
||||
if (exists) {
|
||||
await this.redisClient.del(key)
|
||||
@@ -30,5 +30,5 @@ class RedisPreGeneratedSecretTokens extends RedisGenericManager {
|
||||
}
|
||||
|
||||
export const redisPreGeneratedSecretTokens = await RedisPreGeneratedSecretTokens.createAndConnect({
|
||||
expirationTime: REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS,
|
||||
expirationTime: 60 * 5, // 5 minutes in seconds
|
||||
})
|
||||
|
||||
72
web/src/lib/redis/redisServerEvents.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { allServerEventsData, type ServerEventsData, type ServerEventsEvent } from '../serverEventsTypes'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
export class RedisServerEvents extends RedisGenericManager {
|
||||
private readonly prefix = 'server_events:'
|
||||
|
||||
/**
|
||||
* Broadcast an event to a user's server events listener.
|
||||
*
|
||||
* @param eventName - The event name to broadcast.
|
||||
* @param userId - The user ID to broadcast to.
|
||||
* @param data - The event to broadcast.
|
||||
*/
|
||||
async send<T extends keyof ServerEventsData>(
|
||||
userId: number,
|
||||
eventName: T,
|
||||
data: ServerEventsData[T]
|
||||
): Promise<void> {
|
||||
const channel = `${this.prefix}${String(userId)}:${eventName}` as const
|
||||
await this.redisClient.publish(channel, JSON.stringify(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to server events for a user.
|
||||
*
|
||||
* @param eventName - The event name to subscribe to.
|
||||
* @param userId - The user ID to subscribe to.
|
||||
* @param callback - The callback to call when the event is received.
|
||||
* @returns A cleanup function to unsubscribe.
|
||||
*/
|
||||
async addListener<T extends keyof ServerEventsData | 'all'>(
|
||||
eventName: T,
|
||||
userId: number,
|
||||
callback: (event: T extends 'all' ? ServerEventsEvent : Extract<ServerEventsEvent, { type: T }>) => void
|
||||
): Promise<() => Promise<void>> {
|
||||
const subscriber = this.redisClient.duplicate()
|
||||
await subscriber.connect()
|
||||
|
||||
const channel =
|
||||
eventName === 'all'
|
||||
? allServerEventsData.map((eventName) => `${this.prefix}${String(userId)}:${eventName}` as const)
|
||||
: (`${this.prefix}${String(userId)}:${eventName}` as const)
|
||||
|
||||
await subscriber.subscribe(channel, (message, channelKey) => {
|
||||
try {
|
||||
const data = JSON.parse(message) as ServerEventsData[T extends 'all' ? keyof ServerEventsData : T]
|
||||
const type = channelKey.split(':')[2] as T extends 'all' ? keyof ServerEventsData : T
|
||||
const event = { type, data } as T extends 'all'
|
||||
? ServerEventsEvent
|
||||
: Extract<ServerEventsEvent, { type: T }>
|
||||
callback(event)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse notification stream event:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return async () => {
|
||||
await subscriber.unsubscribe(channel)
|
||||
subscriber.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let redisServerEvents: RedisServerEvents | null = null
|
||||
|
||||
export async function getRedisServerEvents() {
|
||||
redisServerEvents ??= await RedisServerEvents.createAndConnect({
|
||||
expirationTime: 60 * 60 * 24, // 24 hours in seconds
|
||||
})
|
||||
return redisServerEvents
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
import { REDIS_USER_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
|
||||
|
||||
import { RedisGenericManager } from './redisGenericManager'
|
||||
|
||||
class RedisSessions extends RedisGenericManager {
|
||||
@@ -74,5 +72,5 @@ class RedisSessions extends RedisGenericManager {
|
||||
}
|
||||
|
||||
export const redisSessions = await RedisSessions.createAndConnect({
|
||||
expirationTime: REDIS_USER_SESSION_EXPIRY_SECONDS,
|
||||
expirationTime: 60 * 60 * 24, // 24 hours in seconds
|
||||
})
|
||||
|
||||
166
web/src/lib/sendNotifications.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications'
|
||||
import { prisma } from './prisma'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
import { sendPushNotification } from './webPush'
|
||||
|
||||
import type { RedisServerEvents } from './redis/redisServerEvents'
|
||||
import type { NotificationPayload } from './serverEventsTypes'
|
||||
import type { AstroIntegrationLogger } from 'astro'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
export async function sendNotification(
|
||||
notificationId: number,
|
||||
logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>,
|
||||
redisServerEvents: RedisServerEvents
|
||||
) {
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
aboutAccountStatusChange: true,
|
||||
aboutCommentStatusChange: true,
|
||||
aboutServiceVerificationStatusChange: true,
|
||||
aboutSuggestionStatusChange: true,
|
||||
aboutComment: {
|
||||
select: {
|
||||
id: true,
|
||||
author: { select: { id: true } },
|
||||
status: true,
|
||||
content: true,
|
||||
communityNote: true,
|
||||
parent: {
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionId: true,
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
status: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionMessage: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
suggestion: {
|
||||
select: {
|
||||
id: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutEvent: {
|
||||
select: {
|
||||
title: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutService: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
verificationStatus: true,
|
||||
},
|
||||
},
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
points: true,
|
||||
action: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!notification) {
|
||||
logger.error(`Notification with ID ${notificationId.toString()} not found`)
|
||||
return { success: 0, failure: 0, total: 0 }
|
||||
}
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: notification.userId },
|
||||
select: {
|
||||
id: true,
|
||||
endpoint: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
},
|
||||
})
|
||||
|
||||
const notificationPayload = {
|
||||
title: makeNotificationTitle(notification, notification.user),
|
||||
body: makeNotificationContent(notification),
|
||||
actions: makeNotificationActions(notification, SITE_URL),
|
||||
} satisfies NotificationPayload
|
||||
|
||||
await redisServerEvents.send(notification.userId, 'new-notification', notificationPayload)
|
||||
|
||||
const subscriptionResults = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
notificationPayload
|
||||
)
|
||||
|
||||
// Remove invalid subscriptions
|
||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
|
||||
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
|
||||
}
|
||||
|
||||
return result.success
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
|
||||
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
|
||||
total: subscriptionResults.length,
|
||||
}
|
||||
}
|
||||
60
web/src/lib/serverEventsTypes.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Assert } from './assert'
|
||||
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
|
||||
|
||||
export type NotificationAction = {
|
||||
action: string
|
||||
title: string
|
||||
icon?: string
|
||||
|
||||
url: string | null
|
||||
iconName?: string
|
||||
}
|
||||
|
||||
export type NotificationPayload = {
|
||||
title: string
|
||||
body: string | null
|
||||
actions: NotificationAction[]
|
||||
}
|
||||
|
||||
export type NotificationData = {
|
||||
defaultActionUrl: string
|
||||
payload: NotificationPayload | null
|
||||
}
|
||||
|
||||
export type ServerEventsData = {
|
||||
'new-notification': NotificationPayload
|
||||
'new-connection': {
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
export const allServerEventsData = [
|
||||
'new-notification',
|
||||
'new-connection',
|
||||
] as const satisfies (keyof ServerEventsData)[]
|
||||
|
||||
type _ExpectServerEventsDataToHaveAllValues = Assert<
|
||||
Equals<(typeof allServerEventsData)[number], keyof ServerEventsData>
|
||||
>
|
||||
|
||||
export type ServerEventsEvent = {
|
||||
[K in keyof ServerEventsData]: {
|
||||
type: K
|
||||
data: ServerEventsData[K]
|
||||
}
|
||||
}[keyof ServerEventsData]
|
||||
|
||||
export type SSEEventMap = {
|
||||
[K in keyof ServerEventsData as `sse:${K}`]: CustomEvent<ServerEventsData[K]>
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Document {
|
||||
addEventListener<K extends keyof SSEEventMap>(
|
||||
type: K,
|
||||
listener: (this: Document, ev: SSEEventMap[K]) => void
|
||||
): void
|
||||
dispatchEvent<K extends keyof SSEEventMap>(ev: SSEEventMap[K]): void
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,17 @@ import webpush, { WebPushError } from 'web-push'
|
||||
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
import type { NotificationPayload } from './serverEventsTypes'
|
||||
|
||||
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
|
||||
const VAPID_PUBLIC_KEY = getServerEnvVariable('VAPID_PUBLIC_KEY')
|
||||
const VAPID_PRIVATE_KEY = getServerEnvVariable('VAPID_PRIVATE_KEY')
|
||||
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
|
||||
|
||||
// Configure VAPID keys
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
||||
|
||||
export { webpush }
|
||||
|
||||
export type NotificationData = {
|
||||
title: string
|
||||
body?: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
subscription: {
|
||||
endpoint: string
|
||||
@@ -28,26 +22,13 @@ export async function sendPushNotification(
|
||||
auth: string
|
||||
}
|
||||
},
|
||||
data: NotificationData
|
||||
payload: NotificationPayload
|
||||
) {
|
||||
try {
|
||||
const result = await webpush.sendNotification(
|
||||
subscription,
|
||||
JSON.stringify({
|
||||
title: data.title,
|
||||
options: {
|
||||
body: data.body,
|
||||
icon: data.icon ?? '/favicon.svg',
|
||||
badge: data.badge ?? '/favicon.svg',
|
||||
data: {
|
||||
url: data.url,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
TTL: 24 * 60 * 60, // 24 hours
|
||||
}
|
||||
)
|
||||
// NOTE: View sw.js to see how the notification is handled
|
||||
const result = await webpush.sendNotification(subscription, JSON.stringify(payload), {
|
||||
TTL: 24 * 60 * 60, // 24 hours
|
||||
})
|
||||
return { success: true, result } as const
|
||||
} catch (error) {
|
||||
console.error('Error sending push notification:', error)
|
||||
|
||||
@@ -21,7 +21,7 @@ const cleanUrl = (input: unknown) => {
|
||||
|
||||
export const zodUrlOptionalProtocol = z.preprocess(
|
||||
cleanUrl,
|
||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||
z.string().refine((value) => /^(https?:\/\/)?[^\s$.?#]+(\.[^\s$.?#])*(\.[a-z0-9]{2,}).*$/i.test(value), {
|
||||
message: 'Invalid URL',
|
||||
})
|
||||
)
|
||||
@@ -42,7 +42,7 @@ export const zodContactMethod = z.preprocess(
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
/^((https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
|
||||
/^((https?:\/\/)?[^\s$.?#]+(\.[^\s$.?#])*(\.[a-z0-9]{2,}).*|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
|
||||
value
|
||||
),
|
||||
{
|
||||
|
||||
@@ -7,11 +7,12 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
<BaseLayout
|
||||
pageTitle="404: Page Not Found"
|
||||
description="The page doesn't exist, double check the URL."
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'm-0 -mt-16 flex max-w-none flex-col items-center justify-center bg-[#737373] pt-16 text-[#fafafa] [--speed:2s] perspective-distant [&_*]:[transform-style:preserve-3d]',
|
||||
footer: 'bg-black',
|
||||
}}
|
||||
widthClassName="max-w-none"
|
||||
isErrorPage
|
||||
>
|
||||
<h1
|
||||
data-text="404"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { z } from 'astro/zod'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { LOGS_UI_URL } from 'astro:env/server'
|
||||
|
||||
import { SUPPORT_EMAIL } from '../constants/project'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
@@ -21,9 +22,10 @@ const {
|
||||
<BaseLayout
|
||||
pageTitle="500: Server Error"
|
||||
description="Sorry, something crashed on the server."
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'relative my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
|
||||
}}
|
||||
isErrorPage
|
||||
>
|
||||
<Icon
|
||||
name="ri:bug-line"
|
||||
@@ -93,6 +95,20 @@ const {
|
||||
/>
|
||||
Contact support
|
||||
</a>
|
||||
{
|
||||
Astro.locals.user?.admin && !!LOGS_UI_URL && (
|
||||
<a
|
||||
href={LOGS_UI_URL}
|
||||
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
|
||||
>
|
||||
<Icon
|
||||
name="ri:menu-search-line"
|
||||
class="size-5 transition-transform group-hover:-translate-y-1 group-active:translate-y-0"
|
||||
/>
|
||||
View logs <span class="text-xs text-gray-400">(admin only)</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
|
||||
@@ -61,57 +61,72 @@ Users can also be **affiliated** with a service if they're related to it, such a
|
||||
|
||||
### Suggesting a new listing
|
||||
|
||||
To suggest a new listing, visit the [service suggestion form](/service-suggestion/new) and provide the most accurate information possible for higher chances to get approved.
|
||||
Suggest a new listing by visiting the [service suggestion form](/service-suggestion/new). Provide the most accurate information for higher chances of getting approved.
|
||||
|
||||
Once submitted, you get a unique tracking page where you can monitor its status and communicate directly with moderators.
|
||||
Once submitted, you will get a **unique tracking page** where you can monitor the suggestion status and communicate directly with moderators.
|
||||
|
||||
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
|
||||
|
||||
#### Requirements
|
||||
#### Listing Requirements
|
||||
|
||||
To list a new service, it must fulfill these requirements:
|
||||
|
||||
- Offer a service.
|
||||
- Offer a service
|
||||
- Publicly available website explaining what the service is about
|
||||
- Terms of service or FAQ document
|
||||
|
||||
For examples:
|
||||
- Just a Telegram link or a criptocurrency itself is not a valid service.
|
||||
Examples of non-valid services:
|
||||
|
||||
### Suggestion Review Process
|
||||
- Just a Telegram link
|
||||
- A cryptocurrency project
|
||||
- A cryptocurrency wallet
|
||||
|
||||
#### First Review
|
||||
#### Suggestion Review Process
|
||||
|
||||
When you submit a new service, it gets the `COMMUNITY_CONTRIBUTED` status and the `unlisted` visibility level. The service **will not appear in search results**, but it can be viewed with a direct link.
|
||||
|
||||
##### First Review (Unlisted -> Public)
|
||||
|
||||
- A member of the **KYCnot.me team** reviews the submission to ensure it isn't spam or inappropriate.
|
||||
- If the listing passes this initial check, it becomes **publicly visible** to all users.
|
||||
- At this stage, the listing is **Community Contributed** and will show a disclaimer.
|
||||
|
||||
#### Second Review (APPROVED)
|
||||
> Allow up to a week for the initial review. As a small team of two volunteers, we have to handle a large number of suggestions alongside our other life commitments.
|
||||
|
||||
- The service is tested and investigated again.
|
||||
- If it proves to be reliable, it is granted the `APPROVED` status, which means:
|
||||
- The information is accurate.
|
||||
- The service works as described (at the time of the testing).
|
||||
- Basic functionality has been tested.
|
||||
- Communication with the service's support was successful.
|
||||
- A brief investigation found no obvious red flags.
|
||||
##### Second Review (Community Contributed -> Approved)
|
||||
|
||||
#### Final Review (VERIFIED)
|
||||
The service will be tested and investigated more in depth. If:
|
||||
|
||||
- After a period of no reported issues, the service will undergo a **third, comprehensive review**.
|
||||
- The service is tested across different dates and under various conditions.
|
||||
- The service administrators and support teams will be contacted for additional verification.
|
||||
- If the service meets all requirements, it is granted the **`VERIFIED`** status.
|
||||
- Service information is accurate.
|
||||
- Works as described (at the time of the testing).
|
||||
- Basic functionality is tested successfully.
|
||||
- Communication with service's support was successful.
|
||||
- No obvious red flags.
|
||||
- Platform user reviews will be taken into account.
|
||||
- Terms of Service and other documents are briefly reviewed.
|
||||
- Other platforms are checked for the service reputation (Trustpilot, Reddit, Bitcointalk, etc.)
|
||||
|
||||
#### Failed Verifications
|
||||
The service will be granted the `APPROVED` status.
|
||||
|
||||
##### Final Review (Approved -> Verified)
|
||||
|
||||
After a period of no reported issues, we will do a **third review**.
|
||||
|
||||
- The service is tested across different dates and under various conditions.
|
||||
- The service administrators and support teams will be contacted for additional verification.
|
||||
- User reviews will be checked for any issues and taken into account.
|
||||
- Terms of Service and other documents will be reviewed again.
|
||||
- Other platforms are checked for the service reputation (Trustpilot, Reddit, Bitcointalk, etc.)
|
||||
|
||||
If the service meets all requirements, it is granted the **`VERIFIED`** status.
|
||||
|
||||
##### Failed Verifications (Scams)
|
||||
|
||||
If the data is not accurate, the service is a scam, or any other checks fail, the service will be rejected and will appear with a disclaimer.
|
||||
|
||||
### Verification Steps
|
||||
#### Verification Steps
|
||||
|
||||
Services will usually show the verification steps that the admins took to reach the verified (or not) status. Each step will have a description and some evidence attached.
|
||||
|
||||
### Service Attributes
|
||||
#### Service Attributes
|
||||
|
||||
An attribute is a feature of a service, categorized as:
|
||||
|
||||
@@ -125,50 +140,35 @@ You can view all available attributes on the [Attributes page](/attributes).
|
||||
Attributes are classified into two main types:
|
||||
|
||||
1. **Privacy Attributes** – Related to data protection and anonymity.
|
||||
2. **Trust Attributes** – Related to reliability and security.
|
||||
1. **Trust Attributes** – Related to reliability and security.
|
||||
|
||||
These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**.
|
||||
|
||||
### Service Scores
|
||||
#### Service Scores
|
||||
|
||||
Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
|
||||
|
||||
#### Privacy Score
|
||||
##### Privacy Score
|
||||
|
||||
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
|
||||
|
||||
1. **Base Score:** Every service starts with a neutral score of 50 points.
|
||||
2. **KYC Level:** Adjusts the score based on the level of identity verification required:
|
||||
- KYC Level 0 (No KYC): **+25 points**
|
||||
- KYC Level 1 (Minimal KYC): **+10 points**
|
||||
- KYC Level 2 (Moderate KYC): **-5 points**
|
||||
- KYC Level 3 (More KYC): **-15 points**
|
||||
- KYC Level 4 (Full mandatory KYC): **-25 points**
|
||||
3. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
|
||||
4. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
|
||||
5. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
|
||||
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score.
|
||||
7. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
1. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
|
||||
1. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
|
||||
#### Trust Score
|
||||
##### Trust Score
|
||||
|
||||
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
|
||||
|
||||
1. **Base Score:** Every service begins with a neutral score of 50 points.
|
||||
2. **Verification Status Adjustment:**
|
||||
- **Verification Success:** +10 points
|
||||
- **Approved:** +5 points
|
||||
- **Community Contributed:** 0 points
|
||||
- **Verification Failed (SCAM):** -50 points
|
||||
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score.
|
||||
4. **Recently Listed Penalty & Flag:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
|
||||
5. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
1. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
|
||||
1. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
|
||||
#### Overall Score
|
||||
##### Overall Score
|
||||
|
||||
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust.
|
||||
|
||||
### Terms of Service Reviews
|
||||
#### Terms of Service Reviews
|
||||
|
||||
KYCnot.me automatically reviews and summarizes the Terms of Service (ToS) for every service monthly using AI. You get simple, clear summaries that highlight the most important points, so you can quickly see what matters.
|
||||
|
||||
@@ -176,7 +176,7 @@ We hash each ToS document and only review it again if it changes. Some services
|
||||
|
||||
We aim for accuracy, but the AI may sometimes miss details or highlight less relevant information. If you see any error, contact us.
|
||||
|
||||
### Events
|
||||
#### Events
|
||||
|
||||
There are two types of events:
|
||||
|
||||
@@ -185,7 +185,7 @@ There are two types of events:
|
||||
|
||||
You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
|
||||
|
||||
### Reviews and Comments
|
||||
## Reviews and Comments
|
||||
|
||||
Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one.
|
||||
|
||||
@@ -195,7 +195,7 @@ If you've used the service, you can add an **order ID** or proof—this is only
|
||||
|
||||
Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
|
||||
|
||||
#### Note on moderation
|
||||
### Note on moderation
|
||||
|
||||
**All comments are moderated.** First, an AI checks each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a human to review. We only remove comments that are spam, nonsense, unsupported accusations, doxxing, or clear rule violations.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
|
||||
<BaseLayout
|
||||
pageTitle="403: Access Denied"
|
||||
description="You don't have permission to access this page."
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
|
||||
body: 'cursor-not-allowed bg-[oklch(0.16_0.07_31.84)] text-red-50',
|
||||
}}
|
||||
|
||||
@@ -169,7 +169,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
icon: 'ri:user-3-line',
|
||||
}}
|
||||
widthClassName="max-w-screen-md"
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'space-y-6',
|
||||
}}
|
||||
breadcrumbs={[
|
||||
|
||||
@@ -46,8 +46,6 @@ const createInputErrors = isInputError(createResult?.error) ? createResult.error
|
||||
const updateResult = Astro.getActionResult(actions.admin.attribute.update)
|
||||
Astro.locals.banners.addIfSuccess(updateResult, 'Attribute updated successfully')
|
||||
|
||||
const updatedAttributeId = updateResult?.data?.attribute.id ?? null
|
||||
|
||||
const sortBy = filters['sort-by']
|
||||
const sortOrder = filters['sort-order']
|
||||
|
||||
@@ -374,7 +372,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-thin max-w-full overflow-x-auto">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="min-w-[750px]">
|
||||
<table class="w-full divide-y divide-zinc-700">
|
||||
<thead class="bg-zinc-900/30">
|
||||
@@ -723,53 +721,3 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script define:vars={{ updatedAttributeId }}></script>
|
||||
|
||||
<style>
|
||||
@keyframes highlight {
|
||||
0% {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base CSS text size utility */
|
||||
.text-2xs {
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
line-height: 1rem; /* 16px */
|
||||
}
|
||||
|
||||
/* Scrollbar styling for better mobile experience */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: rgba(30, 41, 59, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2); /* thumb track for firefox*/
|
||||
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { Icon } from 'astro-icon/components'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { groupBy, round, uniq } from 'lodash-es'
|
||||
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputText from '../../components/InputText.astro'
|
||||
import Button from '../../components/Button.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||
import { cn } from '../../lib/cn'
|
||||
@@ -15,7 +14,7 @@ if (!Astro.locals.user?.admin) {
|
||||
return Astro.redirect('/access-denied')
|
||||
}
|
||||
|
||||
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
|
||||
const testResult = Astro.getActionResult(actions.admin.notification.test)
|
||||
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
|
||||
|
||||
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
|
||||
@@ -104,7 +103,7 @@ const stats = [
|
||||
|
||||
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
|
||||
|
||||
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
|
||||
<form method="POST" action={actions.admin.notification.test} class="space-y-4">
|
||||
<InputTextArea
|
||||
label="Users"
|
||||
name="userNames"
|
||||
@@ -121,35 +120,6 @@ const stats = [
|
||||
error={testInputErrors.userNames}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Title"
|
||||
name="title"
|
||||
inputProps={{
|
||||
value: 'Test Notification',
|
||||
required: true,
|
||||
}}
|
||||
error={testInputErrors.title}
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Body"
|
||||
name="body"
|
||||
inputProps={{
|
||||
value: 'This is a test push notification from KYCNot.me',
|
||||
}}
|
||||
error={testInputErrors.body}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Action URL"
|
||||
name="url"
|
||||
inputProps={{
|
||||
placeholder: 'https://example.com/path',
|
||||
}}
|
||||
description="URL to open when the notification is clicked"
|
||||
error={testInputErrors.url}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
|
||||
<Button type="submit" label="Send" icon="ri:send-plane-line" color="danger" class="w-full" />
|
||||
</form>
|
||||
</MiniLayout>
|
||||
|
||||
@@ -16,9 +16,6 @@ const releaseDate =
|
||||
title: 'Releases',
|
||||
subtitle: 'Current release',
|
||||
}}
|
||||
className={{
|
||||
main: 'flex flex-col items-center justify-center text-center',
|
||||
}}
|
||||
>
|
||||
<p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider">
|
||||
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
|
||||
@@ -36,7 +33,7 @@ const releaseDate =
|
||||
</time>
|
||||
{
|
||||
!!releaseDate && (
|
||||
<p class="text-day-500 mt-2">
|
||||
<p class="text-day-500 mt-2 text-center">
|
||||
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
|
||||
import BadgeSmall from '../../../components/BadgeSmall.astro'
|
||||
import Button from '../../../components/Button.astro'
|
||||
import Chat from '../../../components/Chat.astro'
|
||||
import InputSelect from '../../../components/InputSelect.astro'
|
||||
import ServiceCard from '../../../components/ServiceCard.astro'
|
||||
import UserBadge from '../../../components/UserBadge.astro'
|
||||
import {
|
||||
@@ -13,16 +14,22 @@ import {
|
||||
} from '../../../constants/serviceSuggestionStatus'
|
||||
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../../lib/cn'
|
||||
import { parseIntWithFallback } from '../../../lib/numbers'
|
||||
import { prisma } from '../../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../../lib/redirectUrls'
|
||||
import { formatDateShort } from '../../../lib/timeAgo'
|
||||
|
||||
const user = Astro.locals.user
|
||||
if (!user?.admin) {
|
||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
|
||||
}
|
||||
|
||||
const serviceSuggestionUpdateResult = Astro.getActionResult(actions.admin.serviceSuggestions.update)
|
||||
Astro.locals.banners.addIfSuccess(serviceSuggestionUpdateResult, 'Service suggestion updated successfully')
|
||||
const serviceSuggestionUpdateInputErrors = isInputError(serviceSuggestionUpdateResult?.error)
|
||||
? serviceSuggestionUpdateResult.error.fields
|
||||
: {}
|
||||
|
||||
const { id: serviceSuggestionIdRaw } = Astro.params
|
||||
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
|
||||
if (!serviceSuggestionId) {
|
||||
@@ -100,114 +107,88 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
|
||||
|
||||
<BaseLayout
|
||||
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
|
||||
htmx
|
||||
description="View and manage service suggestion"
|
||||
widthClassName="max-w-screen-md"
|
||||
htmx
|
||||
>
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<Button
|
||||
as="a"
|
||||
href="/admin/service-suggestions"
|
||||
color="success"
|
||||
variant="faded"
|
||||
size="md"
|
||||
icon="ri:arrow-left-s-line"
|
||||
label="Back"
|
||||
/>
|
||||
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1>
|
||||
|
||||
<h1 class="font-title text-day-200 text-xl">Service suggestion</h1>
|
||||
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
|
||||
|
||||
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
|
||||
<h2 class="font-title text-day-200 mb-3 text-lg">Suggestion Details</h2>
|
||||
|
||||
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<span class="font-title text-gray-400">Type:</span>
|
||||
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||
|
||||
<span class="font-title text-gray-400">Status:</span>
|
||||
<span
|
||||
class={cn(
|
||||
'inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
statusInfo.iconClass
|
||||
)}
|
||||
>
|
||||
<Icon name={statusInfo.icon} class="mr-1 size-3" />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
|
||||
<span class="font-title text-gray-400">Submitted by:</span>
|
||||
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
|
||||
|
||||
<span class="font-title text-gray-400">Submitted at:</span>
|
||||
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
|
||||
|
||||
<span class="font-title text-gray-400">Service page:</span>
|
||||
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
|
||||
View Service <Icon
|
||||
name="ri:external-link-line"
|
||||
class="ml-0.5 inline-block size-3 align-[-0.05em]"
|
||||
/>
|
||||
</a>
|
||||
<section class="border-night-400 bg-night-600 rounded-lg border p-6">
|
||||
<div class="text-day-200 xs:grid-cols-2 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-title font-bold">Status:</span>
|
||||
<BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-title font-bold">Type:</span>
|
||||
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-title font-bold">Author:</span>
|
||||
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-title font-bold">Submitted:</span>
|
||||
<span>
|
||||
{
|
||||
formatDateShort(serviceSuggestion.createdAt, {
|
||||
prefix: false,
|
||||
hourPrecision: true,
|
||||
caseType: 'sentence',
|
||||
})
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-title font-bold">Service:</span>
|
||||
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
|
||||
Open <Icon name="ri:external-link-line" class="ml-0.5 inline-block size-3 align-[-0.05em]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
|
||||
<span class="font-title block font-bold">Notes for moderators:</span>
|
||||
{
|
||||
serviceSuggestion.notes && (
|
||||
<div class="mb-4">
|
||||
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3>
|
||||
<div
|
||||
class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm wrap-anywhere whitespace-pre-wrap text-gray-300"
|
||||
set:text={serviceSuggestion.notes}
|
||||
/>
|
||||
</div>
|
||||
serviceSuggestion.notes ? (
|
||||
<div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
|
||||
) : (
|
||||
<div class="text-day-400 my-4 text-center text-sm italic">Empty</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-title text-day-200 text-lg">Messages</h2>
|
||||
<form method="POST" action={actions.admin.serviceSuggestions.update} class="mt-6 flex items-end gap-2">
|
||||
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
|
||||
<InputSelect
|
||||
name="status"
|
||||
label="Update status"
|
||||
options={serviceSuggestionStatuses.map((status) => ({
|
||||
label: status.label,
|
||||
value: status.value,
|
||||
}))}
|
||||
selectedValue={serviceSuggestion.status}
|
||||
class="flex-1"
|
||||
error={serviceSuggestionUpdateInputErrors.status}
|
||||
/>
|
||||
<Button as="button" type="submit" color="success" size="md" icon="ri:save-line" label="Update" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
|
||||
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
|
||||
<select
|
||||
name="status"
|
||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50"
|
||||
>
|
||||
{
|
||||
serviceSuggestionStatuses.map((status) => (
|
||||
<option value={status.value} selected={serviceSuggestion.status === status.value}>
|
||||
{status.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<Button
|
||||
as="button"
|
||||
type="submit"
|
||||
color="success"
|
||||
variant="faded"
|
||||
size="md"
|
||||
icon="ri:save-line"
|
||||
label="Update"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Chat
|
||||
messages={serviceSuggestion.messages}
|
||||
userId={user.id}
|
||||
action={actions.admin.serviceSuggestions.message}
|
||||
formData={{
|
||||
suggestionId: serviceSuggestion.id,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Chat
|
||||
messages={serviceSuggestion.messages}
|
||||
title="Chat with moderators"
|
||||
userId={user.id}
|
||||
action={actions.admin.serviceSuggestions.message}
|
||||
formData={{
|
||||
suggestionId: serviceSuggestion.id,
|
||||
}}
|
||||
class="mt-12"
|
||||
/>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -37,12 +37,8 @@ if (!user?.admin) {
|
||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
search: z.string().optional(),
|
||||
status: serviceSuggestionStatusesZodEnumBySlug
|
||||
.transform((slug) => serviceSuggestionStatusSlugToId(slug))
|
||||
.optional(),
|
||||
type: serviceSuggestionTypesZodEnumBySlug
|
||||
.transform((slug) => serviceSuggestionTypeSlugToId(slug))
|
||||
.optional(),
|
||||
status: serviceSuggestionStatusesZodEnumBySlug.or(z.literal('all')).default('pending'),
|
||||
type: serviceSuggestionTypesZodEnumBySlug.or(z.literal('all')).optional(),
|
||||
'sort-by': z
|
||||
.enum(['service', 'status', 'type', 'user', 'createdAt', 'messageCount'])
|
||||
.default('createdAt'),
|
||||
@@ -51,6 +47,11 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
Astro
|
||||
)
|
||||
|
||||
const statusFilter = filters.status === 'all' ? undefined : serviceSuggestionStatusSlugToId(filters.status)
|
||||
|
||||
const typeFilter =
|
||||
!filters.type || filters.type === 'all' ? undefined : serviceSuggestionTypeSlugToId(filters.type)
|
||||
|
||||
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
|
||||
if (filters['sort-by'] === 'createdAt') {
|
||||
prismaOrderBy = { createdAt: filters['sort-order'] }
|
||||
@@ -67,8 +68,8 @@ let suggestions = await prisma.serviceSuggestion.findMany({
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
status: filters.status,
|
||||
type: filters.type,
|
||||
status: statusFilter,
|
||||
type: typeFilter,
|
||||
},
|
||||
orderBy: prismaOrderBy,
|
||||
select: {
|
||||
@@ -198,10 +199,10 @@ const makeSortUrl = (slug: string) => {
|
||||
id="status-filter"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="all" selected={filters.status === 'all'}>All Statuses</option>
|
||||
{
|
||||
serviceSuggestionStatuses.map((status) => (
|
||||
<option value={status.slug} selected={filters.status === status.value}>
|
||||
<option value={status.slug} selected={filters.status === status.slug}>
|
||||
{status.label}
|
||||
</option>
|
||||
))
|
||||
@@ -215,10 +216,10 @@ const makeSortUrl = (slug: string) => {
|
||||
id="type-filter"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="all" selected={!filters.type || filters.type === 'all'}>All Types</option>
|
||||
{
|
||||
serviceSuggestionTypes.map((type) => (
|
||||
<option value={type.slug} selected={filters.type === type.value}>
|
||||
<option value={type.slug} selected={filters.type === type.slug}>
|
||||
{type.label}
|
||||
</option>
|
||||
))
|
||||
|
||||
@@ -352,7 +352,7 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
/>
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line"
|
||||
description="One per line. AI review uses the first working URL only."
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||
@@ -801,7 +801,8 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
label: type.label,
|
||||
value: type.id,
|
||||
}))}
|
||||
selectProps={{ required: true, value: event.type }}
|
||||
selectedValue={event.type}
|
||||
selectProps={{ required: true }}
|
||||
error={eventUpdateInputErrors.type}
|
||||
/>
|
||||
</div>
|
||||
@@ -982,7 +983,7 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
label: status.label,
|
||||
value: status.value,
|
||||
}))}
|
||||
selectProps={{ value: step.status }}
|
||||
selectedValue={step.status}
|
||||
error={verificationStepUpdateInputErrors.status}
|
||||
/>
|
||||
|
||||
@@ -1141,8 +1142,21 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
<input type="hidden" name="serviceId" value={service.id} />
|
||||
|
||||
<InputText
|
||||
label="Override Label (Optional)"
|
||||
label="Value"
|
||||
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
|
||||
name="value"
|
||||
inputProps={{
|
||||
required: true,
|
||||
value: method.value,
|
||||
placeholder: 'contact@example.com',
|
||||
}}
|
||||
error={contactMethodUpdateInputErrors.value}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Label"
|
||||
name="label"
|
||||
description="Leave empty to auto-generate"
|
||||
inputProps={{
|
||||
value: method.label,
|
||||
placeholder: contactMethodInfo.formattedValue,
|
||||
@@ -1150,16 +1164,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
error={contactMethodUpdateInputErrors.label}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Value (with protocol)"
|
||||
name="value"
|
||||
inputProps={{
|
||||
value: method.value,
|
||||
placeholder: 'e.g., mailto:contact@example.com or https://t.me/example',
|
||||
}}
|
||||
error={contactMethodUpdateInputErrors.value}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
|
||||
</form>
|
||||
</div>
|
||||
@@ -1174,8 +1178,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
<form method="POST" action={actions.admin.service.contactMethod.add} class="space-y-2">
|
||||
<input type="hidden" name="serviceId" value={service.id} />
|
||||
|
||||
<InputText label="Override Label" name="label" />
|
||||
|
||||
<InputText
|
||||
label="Value"
|
||||
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
|
||||
@@ -1187,6 +1189,13 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
error={contactMethodAddInputErrors.value}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Label"
|
||||
description="Leave empty to auto-generate"
|
||||
name="label"
|
||||
inputProps={{ placeholder: 'Auto-generated' }}
|
||||
/>
|
||||
|
||||
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
|
||||
</form>
|
||||
</FormSubSection>
|
||||
|
||||
@@ -120,7 +120,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
<BaseLayout
|
||||
pageTitle={`${user.displayName ?? user.name} - User`}
|
||||
widthClassName="max-w-screen-lg"
|
||||
className={{ main: 'space-y-24' }}
|
||||
classNames={{ main: 'space-y-24' }}
|
||||
>
|
||||
<div class="mt-12">
|
||||
{
|
||||
|
||||