Compare commits

...

23 Commits

Author SHA1 Message Date
pluja
9285d952a5 Release 202507281850 2025-07-28 18:50:07 +00:00
pluja
fd5c7ab475 Release 202507240432 2025-07-24 04:32:40 +00:00
pluja
9a78a9b377 Release 202507231133 2025-07-23 11:33:11 +00:00
pluja
9e0193fc3c Release 202507092007 2025-07-09 20:07:42 +00:00
pluja
a68523fc73 Release 202507090857 2025-07-09 08:57:55 +00:00
pluja
a465849a76 Release 202507080951 2025-07-08 09:51:46 +00:00
pluja
25f6dba3eb Release 202507080939 2025-07-08 09:39:11 +00:00
pluja
7e7046e7d2 Release 202507080931 2025-07-08 09:31:10 +00:00
pluja
a5d1fb9a5d Release 202507061906 2025-07-06 19:06:17 +00:00
pluja
28b84a7d9b Release 202507061859 2025-07-06 18:59:23 +00:00
pluja
7a294cb0a1 Release 202507061803 2025-07-06 18:03:45 +00:00
pluja
349c26a4df Release 202507031546 2025-07-03 15:46:21 +00:00
pluja
86b1afb2c7 Release 202507031255 2025-07-03 12:55:03 +00:00
pluja
99bc1f4e0f Release 202507031129 2025-07-03 11:29:46 +00:00
pluja
3166349dfb Release 202507031117 2025-07-03 11:17:39 +00:00
pluja
5a54352d95 Release 202507031107 2025-07-03 11:07:41 +00:00
pluja
a545726abf Release 202507030838 2025-07-03 08:38:11 +00:00
pluja
01488b8b3b Release 202507010806 2025-07-01 08:06:36 +00:00
pluja
b456af9448 Release 202507010759 2025-07-01 07:59:22 +00:00
pluja
b7ae6dc22c Release 202507010740 2025-07-01 07:40:26 +00:00
pluja
e4a5fa8fa7 Release 202506241430 2025-06-24 14:30:07 +00:00
pluja
6ed07c8386 Release 202506171537 2025-06-17 15:37:10 +00:00
pluja
6a9f5f5e99 Release 202506151438 2025-06-15 14:38:24 +00:00
82 changed files with 4217 additions and 2168 deletions

View File

@@ -39,8 +39,11 @@ OPENAI_BASE_URL="https://your-openai-base-url.example.com"
OPENAI_MODEL=your_openai_model OPENAI_MODEL=your_openai_model
OPENAI_RETRY=3 OPENAI_RETRY=3
# Pyworker Crons # Task schedules ---------------------------------------------------
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily

View File

@@ -7,7 +7,8 @@
"golang.go", "golang.go",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"craigrbroughton.htmx-attributes", "craigrbroughton.htmx-attributes",
"nefrob.vscode-just-syntax" "nefrob.vscode-just-syntax",
"prisma.prisma"
], ],
"unwantedRecommendations": [] "unwantedRecommendations": []
} }

View File

@@ -14,8 +14,11 @@ OPENAI_BASE_URL="https://xxxxxx/api/v1"
OPENAI_MODEL="xxxxxxxxx" OPENAI_MODEL="xxxxxxxxx"
OPENAI_RETRY=3 OPENAI_RETRY=3
CRON_TOSREVIEW_TASK=0 0 1 * * # Every month # Task schedules ---------------------------------------------------
CRON_USER_SENTIMENT_TASK=0 0 * * * # Every day CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
CRON_COMMENT_MODERATION_TASK=0 0 * * * # Every hour CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
CRON_FORCE_TRIGGERS_TASK=0 2 * * * # Every day CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
CRON_SERVICE_SCORE_RECALC_TASK=*/5 * * * * # Every 10 minutes CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily

View File

@@ -38,6 +38,7 @@ Required environment variables:
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task - `CRON_MODERATION_TASK`: Cron expression for comment moderation task
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task - `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task - `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
- `CRON_INACTIVE_USERS_TASK`: Cron expression for inactive users cleanup task
## Usage ## Usage
@@ -60,6 +61,9 @@ uv run -m pyworker force-triggers
# Run service score recalculation task # Run service score recalculation task
uv run -m pyworker service-score-recalc [--service-id ID] uv run -m pyworker service-score-recalc [--service-id ID]
# Run inactive users cleanup task
uv run -m pyworker inactive-users
``` ```
### Worker Mode ### Worker Mode
@@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules.
- Calculates privacy, trust, and overall scores - Calculates privacy, trust, and overall scores
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK` - Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
### Inactive Users Task
- Handles cleanup of inactive user accounts
- Identifies users who have been inactive for 1 year (no comments, votes, suggestions, and 0 karma)
- Sends deletion warning notifications at 30, 15, 5, and 1 day intervals
- Deletes accounts that remain inactive after the warning period
- Cancels deletion for users who become active again
- Scheduled via `CRON_INACTIVE_USERS_TASK`
## Development ## Development
### Project Structure ### Project Structure
@@ -124,6 +137,7 @@ pyworker/
│ │ ├── base.py │ │ ├── base.py
│ │ ├── comment_moderation.py │ │ ├── comment_moderation.py
│ │ ├── force_triggers.py │ │ ├── force_triggers.py
│ │ ├── inactive_users.py
│ │ ├── service_score_recalc.py │ │ ├── service_score_recalc.py
│ │ ├── tos_review.py │ │ ├── tos_review.py
│ │ └── user_sentiment.py │ │ └── user_sentiment.py

View File

@@ -17,6 +17,7 @@ from pyworker.scheduler import TaskScheduler
from .tasks import ( from .tasks import (
CommentModerationTask, CommentModerationTask,
ForceTriggersTask, ForceTriggersTask,
InactiveUsersTask,
ServiceScoreRecalculationTask, ServiceScoreRecalculationTask,
TosReviewTask, TosReviewTask,
UserSentimentTask, UserSentimentTask,
@@ -95,6 +96,18 @@ def parse_args(args: List[str]) -> argparse.Namespace:
help="Recalculate scores for all services (ignores --service-id)", help="Recalculate scores for all services (ignores --service-id)",
) )
# Service Score Recalculation task for all services
subparsers.add_parser(
"service-score-recalc-all",
help="Recalculate service scores for all services",
)
# Inactive users task
subparsers.add_parser(
"inactive-users",
help="Handle inactive users - send deletion warnings and clean up accounts",
)
return parser.parse_args(args) return parser.parse_args(args)
@@ -358,6 +371,37 @@ def run_service_score_recalc_task(
close_db_pool() close_db_pool()
def run_service_score_recalc_all_task() -> int:
"""
Run the service score recalculation task for all services.
"""
return run_service_score_recalc_task(all_services=True)
def run_inactive_users_task() -> int:
"""
Run the inactive users task.
Returns:
Exit code.
"""
logger.info("Starting inactive users task")
try:
# Initialize task and use as context manager
with InactiveUsersTask() as task: # type: ignore
result = task.run() # type: ignore
logger.info(f"Inactive users task completed. Results: {result}")
return 0
except Exception as e:
logger.exception(f"Error running inactive users task: {e}")
return 1
finally:
# Ensure connection pool is closed even if an error occurs
close_db_pool()
def run_worker_mode() -> int: def run_worker_mode() -> int:
""" """
Run in worker mode, scheduling tasks to run periodically. Run in worker mode, scheduling tasks to run periodically.
@@ -369,42 +413,37 @@ def run_worker_mode() -> int:
# Get task schedules from config # Get task schedules from config
task_schedules = config.task_schedules task_schedules = config.task_schedules
if not task_schedules: logger.info(
"Found %s cron schedule%s from environment variables: %s",
len(task_schedules),
"s" if len(task_schedules) != 1 else "",
", ".join(task_schedules.keys()) if task_schedules else "<none>",
)
required_tasks: dict[str, Any] = {
"tosreview": run_tos_task,
"user_sentiment": run_sentiment_task,
"comment_moderation": run_moderation_task,
"force_triggers": run_force_triggers_task,
"inactive_users": run_inactive_users_task,
"service_score_recalc": run_service_score_recalc_task,
"service_score_recalc_all": run_service_score_recalc_all_task,
}
missing_tasks = [t for t in required_tasks if t not in task_schedules]
if missing_tasks:
logger.error( logger.error(
"No task schedules defined. Set CRON_TASKNAME_TASK environment variables." "Missing cron schedule for task%s: %s. Set the corresponding CRON_<TASKNAME>_TASK environment variable%s.",
"s" if len(missing_tasks) != 1 else "",
", ".join(missing_tasks),
"s" if len(missing_tasks) != 1 else "",
) )
return 1 return 1
logger.info(
f"Found {len(task_schedules)} scheduled tasks: {', '.join(task_schedules.keys())}"
)
# Initialize the scheduler
scheduler = TaskScheduler() scheduler = TaskScheduler()
# Register tasks with their schedules for task_name, task_callable in required_tasks.items():
for task_name, cron_expression in task_schedules.items(): scheduler.register_task(task_name, task_schedules[task_name], task_callable)
if task_name.lower() == "tosreview":
scheduler.register_task(task_name, cron_expression, run_tos_task)
elif task_name.lower() == "user_sentiment":
scheduler.register_task(task_name, cron_expression, run_sentiment_task)
elif task_name.lower() == "comment_moderation":
scheduler.register_task(task_name, cron_expression, run_moderation_task)
elif task_name.lower() == "force_triggers":
scheduler.register_task(task_name, cron_expression, run_force_triggers_task)
elif task_name.lower() == "service_score_recalc":
scheduler.register_task(
task_name, cron_expression, run_service_score_recalc_task
)
else:
logger.warning(f"Unknown task '{task_name}', skipping")
# Register service score recalculation task (every 5 minutes)
scheduler.register_task(
"service_score_recalc",
"*/5 * * * *",
run_service_score_recalc_task,
)
# Start the scheduler if tasks were registered # Start the scheduler if tasks were registered
if scheduler.tasks: if scheduler.tasks:
@@ -457,6 +496,10 @@ def main() -> int:
return run_service_score_recalc_task( return run_service_score_recalc_task(
args.service_id, getattr(args, "all", False) args.service_id, getattr(args, "all", False)
) )
elif args.task == "service-score-recalc-all":
return run_service_score_recalc_all_task()
elif args.task == "inactive-users":
return run_inactive_users_task()
elif args.task: elif args.task:
logger.error(f"Unknown task: {args.task}") logger.error(f"Unknown task: {args.task}")
return 1 return 1

View File

@@ -14,6 +14,7 @@ from pyworker.database import close_db_pool
from .tasks import ( from .tasks import (
CommentModerationTask, CommentModerationTask,
ForceTriggersTask, ForceTriggersTask,
InactiveUsersTask,
ServiceScoreRecalculationTask, ServiceScoreRecalculationTask,
TosReviewTask, TosReviewTask,
UserSentimentTask, UserSentimentTask,
@@ -62,25 +63,29 @@ class TaskScheduler:
cron_expression: Cron expression defining the schedule. cron_expression: Cron expression defining the schedule.
task_func: Function to execute. task_func: Function to execute.
*args: Arguments to pass to the task function. *args: Arguments to pass to the task function.
**kwargs: Keyword arguments to pass to the task function. **kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg.
""" """
instantiate = kwargs.pop("instantiate", True)
# Declare task_instance variable with type annotation upfront # Declare task_instance variable with type annotation upfront
task_instance: Any = None task_instance: Any = None
# Initialize the appropriate task class based on the task name if instantiate:
if task_name.lower() == "tosreview": # Initialize the appropriate task class based on the task name
task_instance = TosReviewTask() if task_name.lower() == "tosreview":
elif task_name.lower() == "user_sentiment": task_instance = TosReviewTask()
task_instance = UserSentimentTask() elif task_name.lower() == "user_sentiment":
elif task_name.lower() == "comment_moderation": task_instance = UserSentimentTask()
task_instance = CommentModerationTask() elif task_name.lower() == "comment_moderation":
elif task_name.lower() == "force_triggers": task_instance = CommentModerationTask()
task_instance = ForceTriggersTask() elif task_name.lower() == "force_triggers":
elif task_name.lower() == "service_score_recalc": task_instance = ForceTriggersTask()
task_instance = ServiceScoreRecalculationTask() elif task_name.lower() == "service_score_recalc":
else: task_instance = ServiceScoreRecalculationTask()
self.logger.warning(f"Unknown task '{task_name}', skipping") elif task_name.lower() == "inactive_users":
return task_instance = InactiveUsersTask()
else:
self.logger.warning(f"Unknown task '{task_name}', skipping")
return
self.tasks[task_name] = { self.tasks[task_name] = {
"cron": cron_expression, "cron": cron_expression,
@@ -126,8 +131,12 @@ class TaskScheduler:
self.logger.info(f"Running task '{task_name}'") self.logger.info(f"Running task '{task_name}'")
# Use task instance as a context manager to ensure # Use task instance as a context manager to ensure
# a single database connection is used for the entire task # a single database connection is used for the entire task
with task_info["instance"]: if task_info["instance"]:
# Execute the registered task function with its arguments with task_info["instance"]:
# Execute the registered task function with its arguments
task_info["func"](*task_info["args"], **task_info["kwargs"])
else:
# Execute the registered task function without a context manager
task_info["func"](*task_info["args"], **task_info["kwargs"]) task_info["func"](*task_info["args"], **task_info["kwargs"])
self.logger.info(f"Task '{task_name}' completed") self.logger.info(f"Task '{task_name}' completed")
except Exception as e: except Exception as e:

View File

@@ -3,6 +3,7 @@
from .base import Task from .base import Task
from .comment_moderation import CommentModerationTask from .comment_moderation import CommentModerationTask
from .force_triggers import ForceTriggersTask from .force_triggers import ForceTriggersTask
from .inactive_users import InactiveUsersTask
from .service_score_recalc import ServiceScoreRecalculationTask from .service_score_recalc import ServiceScoreRecalculationTask
from .tos_review import TosReviewTask from .tos_review import TosReviewTask
from .user_sentiment import UserSentimentTask from .user_sentiment import UserSentimentTask
@@ -11,6 +12,7 @@ __all__ = [
"Task", "Task",
"CommentModerationTask", "CommentModerationTask",
"ForceTriggersTask", "ForceTriggersTask",
"InactiveUsersTask",
"ServiceScoreRecalculationTask", "ServiceScoreRecalculationTask",
"TosReviewTask", "TosReviewTask",
"UserSentimentTask", "UserSentimentTask",

View File

@@ -0,0 +1,258 @@
"""
Task for handling inactive users - sending deletion warnings and cleaning up accounts.
"""
from datetime import datetime, timedelta, date
from typing import Any, Dict, List, Optional
from pyworker.database import execute_db_command, run_db_query
from pyworker.tasks.base import Task
class InactiveUsersTask(Task):
"""Task for handling inactive users"""
def __init__(self):
"""Initialize the inactive users task."""
super().__init__("inactive_users")
def run(self) -> Dict[str, Any]:
"""
Run the inactive users task.
This task:
1. Identifies users who have been inactive for 1 year
2. Schedules them for deletion
3. Sends warning notifications at 30, 15, 5, and 1 day intervals
4. Deletes accounts that have reached their deletion date
"""
results = {
"users_scheduled_for_deletion": 0,
"notifications_sent": 0,
"accounts_deleted": 0,
"deletions_cancelled": 0,
}
# Step 1: Cancel deletion for users who became active again
results["deletions_cancelled"] = self._cancel_deletion_for_active_users()
# Step 2: Schedule new inactive users for deletion
results["users_scheduled_for_deletion"] = self._schedule_inactive_users_for_deletion()
# Step 3: Send warning notifications
results["notifications_sent"] = self._send_deletion_warnings()
# Step 4: Delete accounts that have reached their deletion date
results["accounts_deleted"] = self._delete_scheduled_accounts()
self.logger.info(
f"Inactive users task completed. "
f"Deletions cancelled: {results['deletions_cancelled']}, "
f"Scheduled: {results['users_scheduled_for_deletion']}, "
f"Notifications sent: {results['notifications_sent']}, "
f"Accounts deleted: {results['accounts_deleted']}"
)
return results
def _schedule_inactive_users_for_deletion(self) -> int:
"""
Schedule inactive users for deletion.
A user is considered inactive if:
- Account was created more than 1 year ago
- Has 0 karma
- Has no comments, comment votes, or service suggestions
- Is not scheduled for deletion already
- Is not an admin or moderator
"""
one_year_ago = datetime.now() - timedelta(days=365)
deletion_date = date.today() + timedelta(days=30) # 30 days from today
# Find inactive users
query = """
UPDATE "User"
SET "scheduledDeletionAt" = %s, "updatedAt" = NOW()
WHERE "id" IN (
SELECT u."id"
FROM "User" u
WHERE u."createdAt" < %s
AND u."scheduledDeletionAt" IS NULL
AND u."admin" = false
AND u."moderator" = false
AND u."totalKarma" = 0
AND NOT EXISTS (
SELECT 1 FROM "Comment" c WHERE c."authorId" = u."id"
)
AND NOT EXISTS (
SELECT 1 FROM "CommentVote" cv WHERE cv."userId" = u."id"
)
AND NOT EXISTS (
SELECT 1 FROM "ServiceSuggestion" ss WHERE ss."userId" = u."id"
)
)
"""
count = execute_db_command(query, (deletion_date, one_year_ago))
self.logger.info(f"Scheduled {count} inactive users for deletion on {deletion_date}")
return count
def _send_deletion_warnings(self) -> int:
"""
Send deletion warning notifications to users at appropriate intervals.
"""
today = date.today()
notifications_sent = 0
# Define warning intervals and their corresponding notification types
warning_intervals = [
(30, 'ACCOUNT_DELETION_WARNING_30_DAYS'),
(15, 'ACCOUNT_DELETION_WARNING_15_DAYS'),
(5, 'ACCOUNT_DELETION_WARNING_5_DAYS'),
(1, 'ACCOUNT_DELETION_WARNING_1_DAY'),
]
for days_before, notification_type in warning_intervals:
# Find users who should receive this warning (exact date match)
target_date = today + timedelta(days=days_before)
# Check if user is still inactive (no recent activity)
users_query = """
SELECT u."id", u."name", u."scheduledDeletionAt"
FROM "User" u
WHERE u."scheduledDeletionAt" = %s
AND u."admin" = false
AND u."moderator" = false
AND u."totalKarma" = 0
AND NOT EXISTS (
SELECT 1 FROM "Notification" n
WHERE n."userId" = u."id"
AND n."type" = %s
AND n."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
-- Still check if user is inactive (no activity since being scheduled)
AND NOT EXISTS (
SELECT 1 FROM "Comment" c
WHERE c."authorId" = u."id"
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
AND NOT EXISTS (
SELECT 1 FROM "CommentVote" cv
WHERE cv."userId" = u."id"
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
AND NOT EXISTS (
SELECT 1 FROM "ServiceSuggestion" ss
WHERE ss."userId" = u."id"
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
"""
users = run_db_query(users_query, (target_date, notification_type))
# Create notifications for these users
for user in users:
insert_notification_query = """
INSERT INTO "Notification" ("userId", "type", "createdAt", "updatedAt")
VALUES (%s, %s, NOW(), NOW())
ON CONFLICT DO NOTHING
"""
execute_db_command(insert_notification_query, (user['id'], notification_type))
notifications_sent += 1
self.logger.info(
f"Sent {notification_type} notification to user {user['name']} "
f"(ID: {user['id']}) scheduled for deletion on {user['scheduledDeletionAt']}"
)
return notifications_sent
def _delete_scheduled_accounts(self) -> int:
"""
Delete accounts that have reached their scheduled deletion date and are still inactive.
"""
today = date.today()
# Find users scheduled for deletion who are still inactive
users_to_delete_query = """
SELECT u."id", u."name", u."scheduledDeletionAt"
FROM "User" u
WHERE u."scheduledDeletionAt" <= %s
AND u."admin" = false
AND u."moderator" = false
AND u."totalKarma" = 0
-- Double-check they're still inactive
AND NOT EXISTS (
SELECT 1 FROM "Comment" c
WHERE c."authorId" = u."id"
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
AND NOT EXISTS (
SELECT 1 FROM "CommentVote" cv
WHERE cv."userId" = u."id"
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
AND NOT EXISTS (
SELECT 1 FROM "ServiceSuggestion" ss
WHERE ss."userId" = u."id"
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
"""
users_to_delete = run_db_query(users_to_delete_query, (today,))
deleted_count = 0
for user in users_to_delete:
try:
# Delete the user (this will cascade and delete related records)
delete_query = 'DELETE FROM "User" WHERE "id" = %s'
execute_db_command(delete_query, (user['id'],))
deleted_count += 1
self.logger.info(
f"Deleted inactive user {user['name']} (ID: {user['id']}) "
f"scheduled for deletion on {user['scheduledDeletionAt']}"
)
except Exception as e:
self.logger.error(
f"Failed to delete user {user['name']} (ID: {user['id']}): {e}"
)
return deleted_count
def _cancel_deletion_for_active_users(self) -> int:
"""
Cancel scheduled deletion for users who have become active again.
"""
# Find users scheduled for deletion who have recent activity or gained karma
query = """
UPDATE "User"
SET "scheduledDeletionAt" = NULL, "updatedAt" = NOW()
WHERE "scheduledDeletionAt" IS NOT NULL
AND (
"totalKarma" > 0
OR EXISTS (
SELECT 1 FROM "Comment" c
WHERE c."authorId" = "User"."id"
AND c."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
)
OR EXISTS (
SELECT 1 FROM "CommentVote" cv
WHERE cv."userId" = "User"."id"
AND cv."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
)
OR EXISTS (
SELECT 1 FROM "ServiceSuggestion" ss
WHERE ss."userId" = "User"."id"
AND ss."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
)
)
"""
count = execute_db_command(query)
if count > 0:
self.logger.info(f"Cancelled deletion for {count} users who became active again or gained karma")
return count

View File

@@ -1 +1 @@
23 24

View File

@@ -14,6 +14,8 @@ import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables' import { getServerEnvVariable } from './src/lib/serverEnvVariables'
const SITE_URL = getServerEnvVariable('SITE_URL') const SITE_URL = getServerEnvVariable('SITE_URL')
const ONION_ADDRESS = getServerEnvVariable('ONION_ADDRESS')
const I2P_ADDRESS = getServerEnvVariable('I2P_ADDRESS')
export default defineConfig({ export default defineConfig({
site: SITE_URL, site: SITE_URL,
@@ -95,6 +97,19 @@ export default defineConfig({
server: { server: {
open: false, open: false,
allowedHosts: [new URL(SITE_URL).hostname], allowedHosts: [new URL(SITE_URL).hostname],
headers: {
'Onion-Location': ONION_ADDRESS,
'X-I2P-Location': I2P_ADDRESS,
'X-Frame-Options': 'DENY',
// Astro is working on this feature, when it's stable use it instead of this.
// https://astro.build/blog/astro-590/#experimental-content-security-policy-support
'Content-Security-Policy':
SITE_URL === 'http://localhost:4321'
? "frame-ancestors 'none'; upgrade-insecure-requests"
: "default-src 'self'; img-src 'self' *; frame-ancestors 'none'; upgrade-insecure-requests",
'Strict-Transport-Security':
SITE_URL === 'http://localhost:4321' ? undefined : 'max-age=31536000; includeSubdomains; preload;',
},
}, },
image: { image: {
domains: [new URL(SITE_URL).hostname], domains: [new URL(SITE_URL).hostname],

1218
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,83 +27,86 @@
"@astrojs/check": "0.9.4", "@astrojs/check": "0.9.4",
"@astrojs/db": "0.15.0", "@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0", "@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2", "@astrojs/node": "9.3.0",
"@astrojs/rss": "4.0.12", "@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8", "@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.6",
"@fontsource/space-grotesk": "5.2.8", "@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.9.0", "@prisma/client": "6.11.1",
"@tailwindcss/vite": "4.1.8", "@tailwindcss/vite": "4.1.11",
"@types/mime-types": "3.0.0", "@types/mime-types": "3.0.1",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.7.2",
"astro": "5.9.0", "astro": "5.9.0",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4", "astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0", "astro-seo-schema": "5.0.0",
"canvas": "3.1.0", "canvas": "3.1.2",
"clsx": "2.1.1", "clsx": "2.1.1",
"htmx.org": "1.9.12", "countries-list": "3.1.1",
"country-flag-icons": "1.5.19",
"he": "1.2.0",
"htmx.org": "2.0.6",
"javascript-time-ago": "2.5.11", "javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.9", "libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"pg": "8.16.0", "pg": "8.16.3",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.5.6", "redis": "5.6.0",
"schema-dts": "1.1.5", "schema-dts": "1.1.5",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"sharp": "0.34.2", "sharp": "0.34.2",
"slugify": "1.6.6", "slugify": "1.6.6",
"tailwind-merge": "3.3.0", "tailwind-merge": "3.3.1",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
"tailwindcss": "4.1.8", "tailwindcss": "4.1.11",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7", "web-push": "3.6.7"
"zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.28.0", "@eslint/js": "9.30.1",
"@faker-js/faker": "9.8.0", "@faker-js/faker": "9.9.0",
"@iconify-json/material-symbols": "1.2.24", "@iconify-json/material-symbols": "1.2.29",
"@iconify-json/mdi": "1.2.3", "@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5", "@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.4.1", "@stylistic/eslint-plugin": "5.1.0",
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/he": "1.2.3",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4", "@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.33.1", "@typescript-eslint/parser": "8.36.0",
"@vite-pwa/assets-generator": "1.0.0", "@vite-pwa/assets-generator": "1.0.0",
"@vite-pwa/astro": "1.1.0", "@vite-pwa/astro": "1.1.0",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"esbuild": "0.25.5", "esbuild": "0.25.6",
"eslint": "9.28.0", "eslint": "9.30.1",
"eslint-import-resolver-typescript": "4.4.3", "eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-astro": "1.3.1", "eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.2.0", "globals": "16.3.0",
"prettier": "3.5.3", "prettier": "3.6.2",
"prettier-plugin-astro": "0.14.1", "prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.12", "prettier-plugin-tailwindcss": "0.6.13",
"prisma": "6.9.0", "prisma": "6.11.1",
"prisma-json-types-generator": "3.4.2", "prisma-json-types-generator": "3.5.1",
"tailwind-htmx": "0.1.2", "tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4", "ts-essentials": "10.1.1",
"ts-toolbelt": "9.6.0", "ts-toolbelt": "9.6.0",
"tsx": "4.19.4", "tsx": "4.20.3",
"typescript-eslint": "8.33.1", "typescript-eslint": "8.36.0",
"vite-plugin-devtools-json": "0.1.1", "vite-plugin-devtools-json": "0.2.1",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
} }

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ALTER COLUMN "operatingSince" SET DATA TYPE DATE;

View File

@@ -0,0 +1,15 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_30_DAYS';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_15_DAYS';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_5_DAYS';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_1_DAY';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "scheduledDeletionAt" DATE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "namePluralLong" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "strictCommentingEnabled" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "commentSectionMessage" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "registrationCountryCode" VARCHAR(2);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "registeredCompanyName" TEXT;

View File

@@ -144,6 +144,10 @@ enum NotificationType {
ACCOUNT_STATUS_CHANGE ACCOUNT_STATUS_CHANGE
EVENT_CREATED EVENT_CREATED
SERVICE_VERIFICATION_STATUS_CHANGE SERVICE_VERIFICATION_STATUS_CHANGE
ACCOUNT_DELETION_WARNING_30_DAYS
ACCOUNT_DELETION_WARNING_15_DAYS
ACCOUNT_DELETION_WARNING_5_DAYS
ACCOUNT_DELETION_WARNING_1_DAY
} }
enum CommentStatusChange { enum CommentStatusChange {
@@ -341,58 +345,64 @@ model ServiceSuggestionMessage {
} }
model Service { model Service {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
slug String @unique slug String @unique
previousSlugs String[] @default([]) previousSlugs String[] @default([])
description String description String
categories Category[] @relation("ServiceToCategory") categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4) kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE) kycLevelClarification KycLevelClarification @default(NONE)
overallScore Int @default(0) /// Date only, no time.
privacyScore Int @default(0) operatingSince DateTime? @db.Date
trustScore Int @default(0) overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
/// Computed via trigger. Do not update through prisma. /// Computed via trigger. Do not update through prisma.
averageUserRating Float? averageUserRating Float?
serviceVisibility ServiceVisibility @default(PUBLIC) serviceVisibility ServiceVisibility @default(PUBLIC)
serviceInfoBanner ServiceInfoBanner @default(NONE) serviceInfoBanner ServiceInfoBanner @default(NONE)
serviceInfoBannerNotes String? serviceInfoBannerNotes String?
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED) verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
verificationSummary String? verificationSummary String?
verificationRequests ServiceVerificationRequest[] verificationRequests ServiceVerificationRequest[]
verificationProofMd String? verificationProofMd String?
/// [UserSentiment] /// [UserSentiment]
userSentiment Json? userSentiment Json?
userSentimentAt DateTime? userSentimentAt DateTime?
referral String? referral String?
acceptedCurrencies Currency[] @default([]) acceptedCurrencies Currency[] @default([])
serviceUrls String[] serviceUrls String[]
tosUrls String[] @default([]) tosUrls String[] @default([])
onionUrls String[] @default([]) onionUrls String[] @default([])
i2pUrls String[] @default([]) i2pUrls String[] @default([])
imageUrl String? imageUrl String?
/// ISO 3166-1 alpha-2 country code where the service company is registered
registrationCountryCode String? @db.VarChar(2)
/// Official name of the registered company
registeredCompanyName String?
/// [TosReview] /// [TosReview]
tosReview Json? tosReview Json?
tosReviewAt DateTime? tosReviewAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma. /// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
listedAt DateTime? listedAt DateTime?
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma. /// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
approvedAt DateTime? approvedAt DateTime?
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma. /// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
verifiedAt DateTime? verifiedAt DateTime?
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma. /// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
spamAt DateTime? spamAt DateTime?
/// Computed via trigger. Do not update through prisma. /// Computed via trigger. Do not update through prisma.
isRecentlyApproved Boolean @default(false) isRecentlyApproved Boolean @default(false)
comments Comment[] comments Comment[]
events Event[] events Event[]
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod") contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
attributes ServiceAttribute[] attributes ServiceAttribute[]
verificationSteps VerificationStep[] verificationSteps VerificationStep[]
suggestions ServiceSuggestion[] suggestions ServiceSuggestion[]
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes") internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices") onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices") onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
@@ -400,6 +410,9 @@ model Service {
Notification Notification[] Notification Notification[]
affiliatedUsers ServiceUser[] @relation("ServiceUsers") affiliatedUsers ServiceUser[] @relation("ServiceUsers")
strictCommentingEnabled Boolean @default(false)
commentSectionMessage String?
@@index([listedAt]) @@index([listedAt])
@@index([approvedAt]) @@index([approvedAt])
@@index([verifiedAt]) @@index([verifiedAt])
@@ -496,20 +509,22 @@ model InternalServiceNote {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
displayName String? displayName String?
link String? link String?
picture String? picture String?
spammer Boolean @default(false) spammer Boolean @default(false)
verified Boolean @default(false) verified Boolean @default(false)
admin Boolean @default(false) admin Boolean @default(false)
moderator Boolean @default(false) moderator Boolean @default(false)
verifiedLink String? verifiedLink String?
secretTokenHash String @unique secretTokenHash String @unique
feedId String @unique @default(cuid(2)) feedId String @unique @default(cuid(2))
/// Computed via trigger. Do not update through prisma. /// Computed via trigger. Do not update through prisma.
totalKarma Int @default(0) totalKarma Int @default(0)
/// Date when user is scheduled for deletion due to inactivity
scheduledDeletionAt DateTime? @db.Date
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@ -607,10 +622,11 @@ model VerificationStep {
} }
model Category { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
icon String namePluralLong String?
slug String @unique icon String
slug String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt

View File

@@ -28,6 +28,7 @@ import { generateUsername } from 'unique-username-generator'
import { kycLevels } from '../src/constants/kycLevels' import { kycLevels } from '../src/constants/kycLevels'
import { undefinedIfEmpty } from '../src/lib/arrays' import { undefinedIfEmpty } from '../src/lib/arrays'
import { transformCase } from '../src/lib/strings' import { transformCase } from '../src/lib/strings'
import { countries } from '../src/constants/countries'
// Exit if not in development mode // Exit if not in development mode
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
@@ -141,81 +142,97 @@ const generateFakeAttribute = () => {
const categoriesToCreate = [ const categoriesToCreate = [
{ {
name: 'Exchange', name: 'Exchange',
namePluralLong: 'Exchanges',
slug: 'exchange', slug: 'exchange',
icon: 'ri:arrow-left-right-fill', icon: 'ri:arrow-left-right-fill',
}, },
{ {
name: 'VPN', name: 'VPN',
namePluralLong: 'VPNs',
slug: 'vpn', slug: 'vpn',
icon: 'ri:door-lock-fill', icon: 'ri:door-lock-fill',
}, },
{ {
name: 'Email', name: 'Email',
namePluralLong: 'Email providers',
slug: 'email', slug: 'email',
icon: 'ri:mail-fill', icon: 'ri:mail-fill',
}, },
{ {
name: 'Hosting', name: 'Hosting',
namePluralLong: 'Hostings',
slug: 'hosting', slug: 'hosting',
icon: 'ri:server-fill', icon: 'ri:server-fill',
}, },
{ {
name: 'VPS', name: 'VPS',
namePluralLong: 'VPS providers',
slug: 'vps', slug: 'vps',
icon: 'ri:function-add-fill', icon: 'ri:function-add-fill',
}, },
{ {
name: 'Gift Cards', name: 'Gift Cards',
namePluralLong: 'Gift cards',
slug: 'gift-cards', slug: 'gift-cards',
icon: 'ri:gift-line', icon: 'ri:gift-line',
}, },
{ {
name: 'Goods', name: 'Goods',
namePluralLong: 'Goods providers',
slug: 'goods', slug: 'goods',
icon: 'ri:shopping-basket-fill', icon: 'ri:shopping-basket-fill',
}, },
{ {
name: 'Travel', name: 'Travel',
namePluralLong: 'Travel services',
slug: 'travel', slug: 'travel',
icon: 'ri:plane-fill', icon: 'ri:plane-fill',
}, },
{ {
name: 'SMS', name: 'SMS',
namePluralLong: 'SMS providers',
slug: 'sms', slug: 'sms',
icon: 'ri:message-2-fill', icon: 'ri:message-2-fill',
}, },
{ {
name: 'Store', name: 'Store',
namePluralLong: 'Stores',
slug: 'store', slug: 'store',
icon: 'ri:store-2-line', icon: 'ri:store-2-line',
}, },
{ {
name: 'Tool', name: 'Tool',
namePluralLong: 'Tools',
slug: 'tool', slug: 'tool',
icon: 'ri:tools-fill', icon: 'ri:tools-fill',
}, },
{ {
name: 'Market', name: 'Market',
namePluralLong: 'Markets',
slug: 'market', slug: 'market',
icon: 'ri:price-tag-3-line', icon: 'ri:price-tag-3-line',
}, },
{ {
name: 'Aggregator', name: 'Aggregator',
namePluralLong: 'Aggregators',
slug: 'aggregator', slug: 'aggregator',
icon: 'ri:list-ordered', icon: 'ri:list-ordered',
}, },
{ {
name: 'AI', name: 'AI',
namePluralLong: 'AI services',
slug: 'ai', slug: 'ai',
icon: 'ri:ai-generate-2', icon: 'ri:ai-generate-2',
}, },
{ {
name: 'CEX', name: 'CEX',
namePluralLong: 'CEXs',
slug: 'cex', slug: 'cex',
icon: 'ri:rotate-lock-fill', icon: 'ri:rotate-lock-fill',
}, },
{ {
name: 'DEX', name: 'DEX',
namePluralLong: 'DEXs',
slug: 'dex', slug: 'dex',
icon: 'ri:fediverse-line', icon: 'ri:fediverse-line',
}, },
@@ -681,6 +698,12 @@ const generateFakeService = (users: User[]) => {
{ count: { min: 0, max: 2 } } { count: { min: 0, max: 2 } }
), ),
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`, imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
registrationCountryCode: faker.helpers.maybe(() => faker.helpers.arrayElement(countries).code, {
probability: 0.7,
}),
registeredCompanyName: faker.helpers.maybe(() => faker.company.name(), {
probability: 0.6,
}),
listedAt: listedAt:
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED' serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
? faker.date.recent({ days: 30 }) ? faker.date.recent({ days: 30 })
@@ -704,6 +727,8 @@ const generateFakeService = (users: User[]) => {
}), }),
{ probability: 0.33 } { probability: 0.33 }
), ),
strictCommentingEnabled: faker.datatype.boolean(0.33333),
commentSectionMessage: faker.helpers.maybe(() => faker.lorem.paragraph(), { probability: 0.3 }),
} as const satisfies Prisma.ServiceCreateInput } as const satisfies Prisma.ServiceCreateInput
} }

View File

@@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
NEW RECORD, NEW RECORD,
OLD RECORD OLD RECORD
) RETURNS VOID AS $$ ) RETURNS VOID AS $$
DECLARE
is_user_related_to_service BOOLEAN;
is_user_admin_or_moderator BOOLEAN;
BEGIN BEGIN
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
PERFORM insert_karma_transaction( -- Check if the user is related to the service (e.g., owns/manages it)
NEW."authorId", SELECT EXISTS(
1, SELECT 1 FROM "ServiceUser"
'COMMENT_APPROVED', WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId"
NEW.id, ) INTO is_user_related_to_service;
format('Your comment #comment-%s in %s has been approved!',
NEW.id, -- Check if the user is an admin or moderator
(SELECT name FROM "Service" WHERE id = NEW."serviceId")) SELECT (admin = true OR moderator = true)
); FROM "User"
PERFORM update_user_karma(NEW."authorId", 1); WHERE id = NEW."authorId"
INTO is_user_admin_or_moderator;
-- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator
IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN
PERFORM insert_karma_transaction(
NEW."authorId",
1,
'COMMENT_APPROVED',
NEW.id,
format('Your comment #comment-%s in %s has been approved!',
NEW.id,
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
);
PERFORM update_user_karma(NEW."authorId", 1);
END IF;
END IF; END IF;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
NEW RECORD, NEW RECORD,
OLD RECORD OLD RECORD
) RETURNS VOID AS $$ ) RETURNS VOID AS $$
DECLARE
is_user_admin_or_moderator BOOLEAN;
BEGIN BEGIN
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
PERFORM insert_karma_transaction( -- Check if the comment author is an admin or moderator
NEW."authorId", SELECT (admin = true OR moderator = true)
5, FROM "User"
'COMMENT_VERIFIED', WHERE id = NEW."authorId"
NEW.id, INTO is_user_admin_or_moderator;
format('Your comment #comment-%s in %s has been verified!',
NEW.id, -- Only award karma if the user is NOT an admin/moderator
(SELECT name FROM "Service" WHERE id = NEW."serviceId")) IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
); PERFORM insert_karma_transaction(
PERFORM update_user_karma(NEW."authorId", 5); NEW."authorId",
5,
'COMMENT_VERIFIED',
NEW.id,
format('Your comment #comment-%s in %s has been verified!',
NEW.id,
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
);
PERFORM update_user_karma(NEW."authorId", 5);
END IF;
END IF; END IF;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -146,12 +175,19 @@ DECLARE
comment_author_id INT; comment_author_id INT;
service_name TEXT; service_name TEXT;
upvote_change INT := 0; -- Variable to track change in upvotes upvote_change INT := 0; -- Variable to track change in upvotes
is_author_admin_or_moderator BOOLEAN;
BEGIN BEGIN
-- Get comment author and service info -- Get comment author and service info
SELECT c."authorId", s.name INTO comment_author_id, service_name SELECT c."authorId", s.name INTO comment_author_id, service_name
FROM "Comment" c FROM "Comment" c
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id; JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
-- Check if the comment author is an admin or moderator
SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = comment_author_id
INTO is_author_admin_or_moderator;
-- Calculate karma impact based on vote type -- Calculate karma impact based on vote type
IF TG_OP = 'INSERT' THEN IF TG_OP = 'INSERT' THEN
-- New vote -- New vote
@@ -181,16 +217,19 @@ BEGIN
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
END IF; END IF;
-- Record karma transaction and update user karma -- Only award karma if the author is NOT an admin/moderator
PERFORM insert_karma_transaction( IF NOT COALESCE(is_author_admin_or_moderator, false) THEN
comment_author_id, -- Record karma transaction and update user karma
karma_points, PERFORM insert_karma_transaction(
vote_action, comment_author_id,
COALESCE(NEW."commentId", OLD."commentId"), karma_points,
vote_description vote_action,
); COALESCE(NEW."commentId", OLD."commentId"),
vote_description
PERFORM update_user_karma(comment_author_id, karma_points); );
PERFORM update_user_karma(comment_author_id, karma_points);
END IF;
-- Update comment's upvotes count incrementally -- Update comment's upvotes count incrementally
UPDATE "Comment" UPDATE "Comment"
@@ -236,26 +275,40 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
DECLARE DECLARE
service_name TEXT; service_name TEXT;
service_visibility "ServiceVisibility";
is_user_admin_or_moderator BOOLEAN;
BEGIN BEGIN
-- Award karma for first approval -- Award karma for first approval
-- Check that OLD.status is not NULL to handle the initial creation case if needed, -- Check that OLD.status is not NULL to handle the initial creation case if needed,
-- and ensure it wasn't already APPROVED. -- and ensure it wasn't already APPROVED.
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
-- Fetch service name for the description -- Fetch service details for the description
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId"; SELECT name, "serviceVisibility" INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId";
-- Only award karma if the service is public
IF service_visibility = 'PUBLIC' THEN
-- Check if the user is an admin or moderator
SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = NEW."userId"
INTO is_user_admin_or_moderator;
-- Only award karma if the user is NOT an admin/moderator
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
-- Insert karma transaction, linking it to the suggestion
PERFORM insert_karma_transaction(
NEW."userId",
10,
'SUGGESTION_APPROVED',
NULL, -- p_comment_id (not applicable)
format('Your suggestion for service ''%s'' has been approved!', service_name),
NEW.id -- p_suggestion_id
);
-- Insert karma transaction, linking it to the suggestion -- Update user's total karma
PERFORM insert_karma_transaction( PERFORM update_user_karma(NEW."userId", 10);
NEW."userId", END IF;
10, END IF;
'SUGGESTION_APPROVED',
NULL, -- p_comment_id (not applicable)
format('Your suggestion for service ''%s'' has been approved!', service_name),
NEW.id -- p_suggestion_id
);
-- Update user's total karma
PERFORM update_user_karma(NEW."userId", 10);
END IF; END IF;
RETURN NEW; -- Result is ignored since this is an AFTER trigger RETURN NEW; -- Result is ignored since this is an AFTER trigger

View File

@@ -95,6 +95,8 @@ DECLARE
attributes_score INT := 0; attributes_score INT := 0;
recently_approved_factor INT := 0; recently_approved_factor INT := 0;
tos_penalty_factor INT := 0; tos_penalty_factor INT := 0;
operating_since_factor INT := 0;
legally_registered_factor INT := 0;
BEGIN BEGIN
-- Get verification status factor -- Get verification status factor
SELECT SELECT
@@ -148,8 +150,28 @@ BEGIN
tos_penalty_factor := -3; tos_penalty_factor := -3;
END IF; END IF;
-- Determine trust adjustment based on operatingSince
SELECT
CASE
WHEN "operatingSince" IS NULL THEN 0
WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty
WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus
ELSE 0
END
INTO operating_since_factor
FROM "Service"
WHERE id = service_id;
-- Check for legal registration (country code or company name)
IF EXISTS (
SELECT 1 FROM "Service"
WHERE id = service_id AND ("registrationCountryCode" IS NOT NULL OR "registeredCompanyName" IS NOT NULL)
) THEN
legally_registered_factor := 2;
END IF;
-- Calculate final trust score (base 100) -- Calculate final trust score (base 100)
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor; trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor + legally_registered_factor;
-- Ensure the score is in reasonable bounds (0-100) -- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score)); trust_score := GREATEST(0, LEAST(100, trust_score));

View File

@@ -1,5 +1,6 @@
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { pick } from 'lodash-es'
import { karmaUnlocksById } from '../constants/karmaUnlocks' import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { createAccount } from '../lib/accountCreate' import { createAccount } from '../lib/accountCreate'
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
import { handleHoneypotTrap } from '../lib/honeypot' import { handleHoneypotTrap } from '../lib/honeypot'
import { startImpersonating } from '../lib/impersonation' import { startImpersonating, stopImpersonating } from '../lib/impersonation'
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks' import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens' import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
@@ -225,4 +226,36 @@ export const accountActions = {
return { user } return { user }
}, },
}), }),
delete: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z
.object({
...captchaFormSchemaProperties,
})
.superRefine(captchaFormSchemaSuperRefine),
handler: async (_input, context) => {
if (context.locals.user.admin || context.locals.user.moderator) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Admins and moderators cannot delete their own accounts.',
})
}
await prisma.user.delete({
where: { id: context.locals.user.id },
})
const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture'])
if (context.locals.actualUser) {
await stopImpersonating(context)
} else {
await logout(context)
}
return { deletedUser }
},
}),
} }

View File

@@ -4,12 +4,14 @@ import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es' import { uniq } from 'lodash-es'
import slugify from 'slugify' import slugify from 'slugify'
import { countriesZodEnumById } from '../../constants/countries'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage' import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { separateServiceUrlsByType } from '../../lib/urls' import { separateServiceUrlsByType } from '../../lib/urls'
import { import {
imageFileSchema, imageFileSchema,
stringListOfContactMethodsSchema,
stringListOfUrlsSchemaRequired, stringListOfUrlsSchemaRequired,
zodCohercedNumber, zodCohercedNumber,
zodContactMethod, zodContactMethod,
@@ -44,6 +46,7 @@ const serviceSchemaBase = z.object({
description: z.string().min(1), description: z.string().min(1),
allServiceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
contactMethods: stringListOfContactMethodsSchema,
kycLevel: z.coerce.number().int().min(0).max(4), kycLevel: z.coerce.number().int().min(0).max(4),
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null), kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
@@ -51,17 +54,27 @@ const serviceSchemaBase = z.object({
verificationStatus: z.nativeEnum(VerificationStatus), verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null), verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null), verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)), acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
referral: z referral: z
.string() .string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL') .regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
.optional() .optional()
.nullable() .nullable()
.default(null), .default(null),
operatingSince: z.coerce.date().optional().nullable(),
registrationCountryCode: z
.union([countriesZodEnumById, z.literal('')])
.optional()
.nullable()
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
message: 'Country code must be a valid 2-character code or empty',
}),
registeredCompanyName: z.string().trim().max(100).optional().nullable(),
imageFile: imageFileSchema, imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility), serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(), internalNote: z.string().optional(),
strictCommentingEnabled: z.boolean().optional().default(false),
commentSectionMessage: z.string().trim().min(3).max(1000).optional().nullable().default(null),
}) })
// Define schema for the create action input // Define schema for the create action input
@@ -126,11 +139,12 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
strictCommentingEnabled: input.strictCommentingEnabled,
commentSectionMessage: input.commentSectionMessage,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null, referral: input.referral || null,
serviceVisibility: input.serviceVisibility, serviceVisibility: input.serviceVisibility,
slug: input.slug, slug: input.slug,
overallScore: input.overallScore,
categories: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),
}, },
@@ -141,6 +155,11 @@ export const adminServiceActions = {
}, },
})), })),
}, },
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
imageUrl, imageUrl,
internalNotes: input.internalNote internalNotes: input.internalNote
? { ? {
@@ -150,6 +169,9 @@ export const adminServiceActions = {
}, },
} }
: undefined, : undefined,
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
}, },
select: { select: {
id: true, id: true,
@@ -245,11 +267,12 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
strictCommentingEnabled: input.strictCommentingEnabled,
commentSectionMessage: input.commentSectionMessage,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null, referral: input.referral || null,
serviceVisibility: input.serviceVisibility, serviceVisibility: input.serviceVisibility,
slug: input.slug, slug: input.slug,
overallScore: input.overallScore,
previousSlugs: previousSlugs:
existingService.slug !== input.slug existingService.slug !== input.slug
? { ? {
@@ -258,7 +281,6 @@ export const adminServiceActions = {
), ),
} }
: undefined, : undefined,
imageUrl, imageUrl,
categories: { categories: {
connect: categoriesToAdd.map((id) => ({ id })), connect: categoriesToAdd.map((id) => ({ id })),
@@ -275,6 +297,9 @@ export const adminServiceActions = {
attributeId, attributeId,
})), })),
}, },
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
}, },
}) })
@@ -335,7 +360,6 @@ export const adminServiceActions = {
await prisma.serviceContactMethod.delete({ await prisma.serviceContactMethod.delete({
where: { id: input.id }, where: { id: input.id },
}) })
return { success: true }
}, },
}), }),
}, },
@@ -461,7 +485,6 @@ export const adminServiceActions = {
input: evidenceImageDeleteSchema, input: evidenceImageDeleteSchema,
handler: async (input) => { handler: async (input) => {
await deleteFileLocally(input.fileUrl) await deleteFileLocally(input.fileUrl)
return { success: true }
}, },
}), }),
}, },

View File

@@ -133,7 +133,7 @@ export const apiServiceActions = {
kycLevelClarification: service.kycLevelClarification, kycLevelClarification: service.kycLevelClarification,
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [ kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
'value', 'value',
'name', 'label',
'description', 'description',
]), ]),
categories: service.categories, categories: service.categories,

View File

@@ -17,6 +17,7 @@ import type { CommentStatus, Prisma } from '@prisma/client'
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2 const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
const MAX_COMMENTS_PER_WINDOW = 1 const MAX_COMMENTS_PER_WINDOW = 1
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10 const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
export const COMMENT_ORDER_ID_MAX_LENGTH = 600
export const commentActions = { export const commentActions = {
vote: defineProtectedAction({ vote: defineProtectedAction({
@@ -103,7 +104,7 @@ export const commentActions = {
issueFundsBlocked: z.coerce.boolean().optional(), issueFundsBlocked: z.coerce.boolean().optional(),
issueScam: z.coerce.boolean().optional(), issueScam: z.coerce.boolean().optional(),
issueDetails: z.string().max(120).optional(), issueDetails: z.string().max(120).optional(),
orderId: z.string().max(100).optional(), orderId: z.string().max(COMMENT_ORDER_ID_MAX_LENGTH).optional(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.rating && data.parentId) { if (data.rating && data.parentId) {

View File

@@ -1,4 +1,3 @@
import { ActionError } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
@@ -58,28 +57,15 @@ export const notificationActions = {
accept: 'json', accept: 'json',
permissions: 'guest', permissions: 'guest',
input: z.object({ input: z.object({
endpoint: z.string().optional(), endpoint: z.string(),
}), }),
handler: async (input, context) => { handler: async (input, context) => {
if (input.endpoint) { await prisma.pushSubscription.delete({
await prisma.pushSubscription.deleteMany({ where: {
where: { userId: context.locals.user?.id ?? undefined,
userId: context.locals.user?.id ?? undefined, endpoint: input.endpoint,
endpoint: input.endpoint, },
}, })
})
} else if (context.locals.user) {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
},
})
} else {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Endpoint is required when user is not logged in.',
})
}
}, },
}), }),
}, },

View File

@@ -3,6 +3,7 @@ import { z } from 'astro/zod'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns' import { formatDistanceStrict } from 'date-fns'
import { countriesZodEnumById } from '../constants/countries'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation' import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
@@ -49,33 +50,51 @@ const findPossibleDuplicates = async (input: { name: string }) => {
}) })
} }
const serializeExtraNotes = <T extends Record<string, unknown>>( const serializeExtraNotes = async <T extends Record<string, unknown>, NK extends keyof T>(
input: T, input: T,
skipKeys: (keyof T)[] = [] skipKeys: NK[] = [],
): string => { mapKeys: { [P in Exclude<Extract<keyof T, string>, NK>]?: (value: T[P]) => unknown } = {}
return Object.entries(input) ): Promise<string> => {
.filter(([key]) => !skipKeys.includes(key as keyof T)) return (
.map(([key, value]) => { await Promise.all(
let serializedValue = '' Object.entries(input)
if (typeof value === 'string') { .filter(
serializedValue = value (
} else if (value === undefined || value === null) { entry
serializedValue = '' ): entry is [Exclude<Extract<keyof T, string>, NK>, T[Exclude<Extract<keyof T, string>, NK>]] =>
} else if (Array.isArray(value)) { !skipKeys.some((k) => k === entry[0])
serializedValue = value.map((item) => String(item)).join(', ') )
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') { .map(async ([key, originalValue]) => {
// eslint-disable-next-line @typescript-eslint/no-base-to-string const value = mapKeys[key] ? await mapKeys[key](originalValue) : originalValue
serializedValue = value.toString() let serializedValue = ''
} else { if (typeof value === 'string') {
try { serializedValue = value
serializedValue = JSON.stringify(value) } else if (value === undefined || value === null) {
} catch (error) { serializedValue = ''
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}` } else if (Array.isArray(value)) {
} serializedValue = value.map((item) => String(item)).join(', ')
} } else if (typeof value === 'object' && value instanceof Date) {
return `- ${key}: ${serializedValue}` serializedValue = value.toISOString()
}) } else if (
.join('\n') typeof value === 'object' &&
'toString' in value &&
typeof value.toString === 'function' &&
// eslint-disable-next-line @typescript-eslint/no-base-to-string
value.toString() !== '[object Object]'
) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
serializedValue = value.toString()
} else {
try {
serializedValue = JSON.stringify(value)
} catch (error) {
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
}
}
return `- ${key}: ${serializedValue}`
})
)
).join('\n')
} }
export const serviceSuggestionActions = { export const serviceSuggestionActions = {
@@ -165,6 +184,15 @@ export const serviceSuggestionActions = {
message: 'You must accept the suggestion rules and process to continue', message: 'You must accept the suggestion rules and process to continue',
}), }),
}), }),
operatingSince: z.coerce.date().optional(),
registrationCountryCode: z
.union([countriesZodEnumById, z.literal('')])
.optional()
.nullable()
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
message: 'Country code must be a valid 2-character code or empty',
}),
registeredCompanyName: z.string().trim().max(100).optional(),
/** @deprecated Honey pot field, do not use */ /** @deprecated Honey pot field, do not use */
message: z.unknown().optional(), message: z.unknown().optional(),
skipDuplicateCheck: z skipDuplicateCheck: z
@@ -199,14 +227,37 @@ export const serviceSuggestionActions = {
return { return {
hasDuplicates: true, hasDuplicates: true,
possibleDuplicates, possibleDuplicates,
extraNotes: serializeExtraNotes(input, [ extraNotes: await serializeExtraNotes(
'skipDuplicateCheck', input,
'message', [
'imageFile', 'skipDuplicateCheck',
'captcha-value', 'message',
'captcha-solution-hash', 'imageFile',
'rulesConfirm', 'captcha-value',
]), 'captcha-solution-hash',
'rulesConfirm',
],
{
attributes: async (value) => {
const dbAttributes = await prisma.attribute.findMany({
select: {
title: true,
},
where: { id: { in: value } },
})
return dbAttributes.map((attribute) => `\n - ${attribute.title}`).join('')
},
categories: async (value) => {
const dbCategories = await prisma.category.findMany({
select: {
name: true,
},
where: { id: { in: value } },
})
return dbCategories.map((category) => category.name)
},
}
),
serviceSuggestion: undefined, serviceSuggestion: undefined,
service: undefined, service: undefined,
} as const } as const
@@ -239,6 +290,9 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls, onionUrls,

View File

@@ -0,0 +1,24 @@
---
if (!Astro.locals.user?.admin) return
---
<script>
/////////////////////////////////////////////////////////////////////////////////////////////
// Script that adds data-astro-reload to all admin links or all links if on an admin page. //
// This is a workaround to prevent the client router messing up inputs. //
/////////////////////////////////////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLAnchorElement | HTMLFormElement>('a,form').forEach((element) => {
const isAdminPage = window.location.pathname.startsWith('/admin')
if (isAdminPage) {
element.setAttribute('data-astro-reload', '')
}
const url = element.href ? new URL(element.href) : null
if (url?.pathname.startsWith('/admin')) {
element.setAttribute('data-astro-reload', '')
}
})
})
</script>

View File

@@ -6,20 +6,24 @@ import { cn } from '../lib/cn'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
import type { O } from 'ts-toolbelt'
type Props = HTMLAttributes<'div'> & { type Props = HTMLAttributes<'div'> & {
announcement: Prisma.AnnouncementGetPayload<{ announcement: O.Optional<
select: { Prisma.AnnouncementGetPayload<{
id: true select: {
content: true id: true
type: true content: true
link: true type: true
linkText: true link: true
startDate: true linkText: true
endDate: true startDate: true
isActive: true endDate: true
} isActive: true
}> }
}>,
'link' | 'linkText'
>
} }
const { announcement, class: className, ...props } = Astro.props const { announcement, class: className, ...props } = Astro.props
@@ -31,12 +35,15 @@ const Tag = announcement.link ? 'a' : 'div'
<Tag <Tag
href={announcement.link} href={announcement.link}
target={announcement.link ? '_blank' : undefined} target={announcement.link && new URL(announcement.link, Astro.url.origin).origin !== Astro.url.origin
? '_blank'
: undefined}
rel="noopener noreferrer" rel="noopener noreferrer"
class={cn( class={cn(
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5', 'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
className className
)} )}
aria-label="Announcement banner"
{...props} {...props}
> >
<div <div
@@ -78,15 +85,15 @@ const Tag = announcement.link ? 'a' : 'div'
</span> </span>
</div> </div>
<div {
class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]" !!announcement.link && !!announcement.linkText && (
> <div class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]">
<span class="2xs:inline-block hidden"> <span class="2xs:inline-block hidden">{announcement.linkText}</span>
{announcement.linkText} <Icon
</span> name="ri:arrow-right-line"
<Icon class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
name="ri:arrow-right-line" />
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5" </div>
/> )
</div> }
</Tag> </Tag>

View File

@@ -1,6 +1,7 @@
--- ---
import LoadingIndicator from 'astro-loading-indicator/component' import LoadingIndicator from 'astro-loading-indicator/component'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
import { ONION_ADDRESS } from 'astro:env/server'
import { ClientRouter } from 'astro:transitions' import { ClientRouter } from 'astro:transitions'
import { pwaAssetsHead } from 'virtual:pwa-assets/head' import { pwaAssetsHead } from 'virtual:pwa-assets/head'
import { pwaInfo } from 'virtual:pwa-info' import { pwaInfo } from 'virtual:pwa-info'
@@ -8,6 +9,7 @@ import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays' import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/client/envVariables' import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import AdminNavigationFixScript from './AdminNavigationFixScript.astro'
import DevToolsMessageScript from './DevToolsMessageScript.astro' import DevToolsMessageScript from './DevToolsMessageScript.astro'
import DynamicFavicon from './DynamicFavicon.astro' import DynamicFavicon from './DynamicFavicon.astro'
import HtmxScript from './HtmxScript.astro' import HtmxScript from './HtmxScript.astro'
@@ -77,30 +79,32 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url) const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
--- ---
<!-- Primary Meta Tags --> {/* Primary Meta Tags */}
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<meta name="description" content={description} /> <meta name="description" content={description} />
<title>{fullTitle}</title> <title>{fullTitle}</title>
<!-- {canonicalUrl && <link rel="canonical" href={canonicalUrl} />} --> {/* canonicalUrl && <link rel="canonical" href={canonicalUrl} /> */}
<meta http-equiv="onion-location" content={ONION_ADDRESS} />
<!-- Open Graph / Facebook --> {/* Open Graph / Facebook */}
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} /> <meta property="og:url" content={Astro.url} />
<meta property="og:title" content={fullTitle} /> <meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />} {!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
<!-- Twitter --> {/* Twitter */}
<meta property="twitter:card" content="summary_large_image" /> <meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} /> <meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={fullTitle} /> <meta property="twitter:title" content={fullTitle} />
<meta property="twitter:description" content={description} /> <meta property="twitter:description" content={description} />
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />} {!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
<!-- Other --> {/* Other */}
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<link rel="sitemap" href="/sitemaps/search.xml" />
<!-- PWA --> {/* PWA */}
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />} {pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)} {pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />} {pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
@@ -108,15 +112,16 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<DynamicFavicon /> <DynamicFavicon />
<ClientRouter /> <ClientRouter />
<AdminNavigationFixScript />
<LoadingIndicator color="green" /> <LoadingIndicator color="green" />
<TailwindJsPluggin /> <TailwindJsPluggin />
{htmx && <HtmxScript />} {htmx && <HtmxScript />}
<!-- JSON-LD Schemas --> {/* JSON-LD Schemas */}
{schemas?.map((item) => <Schema item={item} />)} {schemas?.map((item) => <Schema item={item} />)}
<!-- Breadcrumbs --> {/* Breadcrumbs */}
{ {
breadcrumbLists.map((breadcrumbList) => ( breadcrumbLists.map((breadcrumbList) => (
<Schema <Schema

View File

@@ -33,6 +33,7 @@ type Props = HTMLAttributes<'div'> & {
highlightedCommentId: number | null highlightedCommentId: number | null
serviceSlug: string serviceSlug: string
itemReviewedId: string itemReviewedId: string
strictCommentingEnabled?: boolean
} }
const { const {
@@ -42,6 +43,7 @@ const {
highlightedCommentId = null, highlightedCommentId = null,
serviceSlug, serviceSlug,
itemReviewedId, itemReviewedId,
strictCommentingEnabled,
class: className, class: className,
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
@@ -492,6 +494,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
serviceId={comment.serviceId} serviceId={comment.serviceId}
parentId={comment.id} parentId={comment.id}
commentId={comment.id} commentId={comment.id}
strictCommentingEnabled={strictCommentingEnabled}
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block" class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
/> />
</> </>

View File

@@ -1,7 +1,9 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { COMMENT_ORDER_ID_MAX_LENGTH } from '../actions/comment'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeLoginUrl } from '../lib/redirectUrls' import { makeLoginUrl } from '../lib/redirectUrls'
@@ -20,6 +22,8 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
serviceId: number serviceId: number
parentId?: number parentId?: number
commentId?: number commentId?: number
strictCommentingEnabled?: boolean
commentSectionMessage?: string | null
activeRatingComment?: Prisma.CommentGetPayload<{ activeRatingComment?: Prisma.CommentGetPayload<{
select: { select: {
id: true id: true
@@ -28,7 +32,16 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
}> | null }> | null
} }
const { serviceId, parentId, commentId, activeRatingComment, class: className, ...htmlProps } = Astro.props const {
serviceId,
parentId,
commentId,
activeRatingComment,
strictCommentingEnabled,
commentSectionMessage,
class: className,
...htmlProps
} = Astro.props
const MIN_COMMENT_LENGTH = parentId ? 10 : 30 const MIN_COMMENT_LENGTH = parentId ? 10 : 30
@@ -88,69 +101,83 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
</div> </div>
{!parentId ? ( {!parentId ? (
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden"> <>
<div class="flex flex-wrap gap-4"> <div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
<InputRating name="rating" label="Rating" /> <div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" />
<InputWrapper label="I experienced..." name="tags"> <InputWrapper label="I experienced..." name="tags">
<label class="flex cursor-pointer items-center gap-2"> <label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" /> <input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400"> <span class="flex items-center gap-1 text-xs text-red-400">
<Icon name="ri:user-forbid-fill" class="size-3" /> <Icon name="ri:user-forbid-fill" class="size-3" />
KYC Issue KYC Issue
</span> </span>
</label> </label>
<label class="flex cursor-pointer items-center gap-2"> <label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" /> <input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
<span class="flex items-center gap-1 text-xs text-orange-400"> <span class="flex items-center gap-1 text-xs text-orange-400">
<Icon name="ri:wallet-3-fill" class="size-3" /> <Icon name="ri:wallet-3-fill" class="size-3" />
Funds Blocked Funds Blocked
</span> </span>
</label> </label>
</InputWrapper> </InputWrapper>
<InputText <InputText
label="Order ID" label="Order ID"
name="orderId" name="orderId"
inputProps={{ inputProps={{
maxlength: 100, maxlength: COMMENT_ORDER_ID_MAX_LENGTH,
placeholder: 'Order ID / URL / Proof', placeholder: 'Order ID / URL / Proof',
class: 'bg-night-800', class: 'bg-night-800',
}} required: strictCommentingEnabled,
descriptionLabel="Only visible to admins, to verify your comment" }}
class="grow" descriptionLabel="Only visible to admins, to verify your comment"
/> class="grow"
</div> />
</div>
<div class="mt-4 flex items-start justify-end gap-2"> <div class="mt-4 flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
{!!activeRatingComment?.rating && ( {!!activeRatingComment?.rating && (
<div <div
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400" class="mt-1 rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
data-show-if-rating data-show-if-rating
>
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
<a
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
class="inline-flex items-center gap-1 underline"
target="_blank"
rel="noopener noreferrer"
> >
Your previous rating <Icon name="ri:information-line" class="mr-1 inline size-3.5" />
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" /> <a
</a> href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
of class="inline-flex items-center gap-1 underline"
{[ target="_blank"
activeRatingComment.rating.toLocaleString(), rel="noopener noreferrer"
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />, >
]} Your previous rating
won't count for the total. <Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
</div> </a>
)} of
{[
activeRatingComment.rating.toLocaleString(),
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
]}
won't count for the total.
</div>
)}
<Button type="submit" label="Send" icon="ri:send-plane-2-line" /> <div class="flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
{!!commentSectionMessage && (
<div class="flex items-start gap-1 pt-1.5">
<Icon name="ri:information-line" class="mt-1.25 inline size-3.5" />
<div class="prose prose-invert prose-sm text-day-200 max-w-none grow">
<Markdown content={commentSectionMessage} />
</div>
</div>
)}
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
</div>
</div>
</div> </div>
</div> </>
) : ( ) : (
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<Button type="submit" label="Reply" icon="ri:reply-line" /> <Button type="submit" label="Reply" icon="ri:reply-line" />

View File

@@ -35,6 +35,8 @@ type Props = {
name: true name: true
description: true description: true
createdAt: true createdAt: true
strictCommentingEnabled: true
commentSectionMessage: true
} }
}> }>
} }
@@ -173,7 +175,13 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
comment: comments.map(makeReplySchema), comment: comments.map(makeReplySchema),
} as WithContext<DiscussionForumPosting>} } as WithContext<DiscussionForumPosting>}
/> />
<CommentReply serviceId={service.id} activeRatingComment={activeRatingComment} class="xs:mb-4 mb-2" /> <CommentReply
serviceId={service.id}
activeRatingComment={activeRatingComment}
strictCommentingEnabled={service.strictCommentingEnabled}
commentSectionMessage={service.commentSectionMessage}
class="xs:mb-4 mb-2"
/>
<div class="mb-6 flex flex-wrap items-center justify-between gap-2"> <div class="mb-6 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center"> <div class="flex items-center">
@@ -258,6 +266,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
showPending={params.showPending} showPending={params.showPending}
serviceSlug={service.slug} serviceSlug={service.slug}
itemReviewedId={itemReviewedId} itemReviewedId={itemReviewedId}
strictCommentingEnabled={service.strictCommentingEnabled}
/> />
)) ))
) : ( ) : (

View File

@@ -37,6 +37,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
} }
)} )}
transition:name="header-container" transition:name="header-container"
aria-label="Header"
> >
<nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}> <nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}>
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center"> <div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">

View File

@@ -3,7 +3,7 @@
--- ---
<script> <script>
import * as htmx from 'htmx.org' import htmx from 'htmx.org'
htmx.config.globalViewTransitions = false htmx.config.globalViewTransitions = false

View File

@@ -7,6 +7,8 @@ import type { ComponentProps } from 'astro/types'
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & { type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
disabled?: boolean disabled?: boolean
checked?: boolean
descriptionInline?: string
id?: string id?: string
} & ( } & (
| { | {
@@ -19,13 +21,11 @@ type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'requi
} }
) )
const { disabled, name, required, error, id, label } = Astro.props const { disabled, name, required, error, id, label, checked, descriptionInline } = Astro.props
const hasError = !!error && error.length > 0 const hasError = !!error && error.length > 0
--- ---
{}
<div> <div>
<label <label
class={cn( class={cn(
@@ -41,9 +41,11 @@ const hasError = !!error && error.length > 0
name={name} name={name}
required={required} required={required}
disabled={disabled} disabled={disabled}
checked={checked}
class={cn(disabled && 'opacity-50')} class={cn(disabled && 'opacity-50')}
/> />
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span> <span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
{descriptionInline && <p class="text-day-400 text-xs">{descriptionInline}</p>}
</label> </label>
{ {

View File

@@ -9,6 +9,7 @@ import defaultOGImage from '../assets/ogimage.png'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
import { urlWithParams } from '../lib/urls' import { urlWithParams } from '../lib/urls'
import type { VerificationStatus } from '@prisma/client'
import type { APIContext } from 'astro' import type { APIContext } from 'astro'
import type { Prettify } from 'ts-essentials' import type { Prettify } from 'ts-essentials'
@@ -107,6 +108,7 @@ export const ogImageTemplates = {
categories, categories,
score, score,
imageUrl, imageUrl,
verificationStatus,
}: { }: {
title: string title: string
description: string description: string
@@ -116,6 +118,7 @@ export const ogImageTemplates = {
}[] }[]
score: number score: number
imageUrl: string | null imageUrl: string | null
verificationStatus: VerificationStatus | null
}, },
context context
) => { ) => {
@@ -272,6 +275,37 @@ export const ogImageTemplates = {
> >
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" /> <path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg> </svg>
{verificationStatus === 'VERIFICATION_FAILED' && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
transform: 'rotate(-20deg)',
fontSize: 200,
fontWeight: 'bold',
color: 'red',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
boxShadow: '0 0 15px 30px rgba(0, 0, 0, 0.5)',
border: '15px solid red',
borderRadius: 15,
padding: '10px 50px',
textAlign: 'center',
}}
>
SCAM
</div>
</div>
)}
</div> </div>
), ),
defaultOptions defaultOptions

View File

@@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type { HTMLAttributes } from 'astro/types' import type { HTMLTag, Polymorphic } from 'astro/types'
import type { Review, WithContext } from 'schema-dts' import type { Review, WithContext } from 'schema-dts'
export type Props = HTMLAttributes<'div'> & { type Props = Polymorphic<{
as: HTMLTag
score: number score: number
label: string label: string
total?: number total?: number
itemReviewedId?: string itemReviewedId?: string
} showInfo?: boolean
children?: never
}>
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props const {
as: Tag = 'div',
score,
label,
total = 100,
class: className,
itemReviewedId,
showInfo = false,
...htmlProps
} = Astro.props
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1) const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
@@ -65,13 +77,13 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
) )
} }
<div <Tag
{...htmlProps} {...htmlProps}
class={cn( class={cn(
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white', '2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
className className
)} )}
role="group" role={htmlProps.role ?? 'group'}
> >
<div <div
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', { class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
@@ -166,10 +178,14 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined} transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
class="stroke-night-700"></path> class="stroke-night-700"></path>
<!-- Info icon --> {
<!-- <path showInfo && (
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z" <path
fill="white" d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
fill-opacity="0.67"></path> --> class="text-current/60"
fill="currentColor"
/>
)
}
</svg> </svg>
</div> </Tag>

View File

@@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type { HTMLAttributes } from 'astro/types' import type { HTMLTag, Polymorphic } from 'astro/types'
export type Props = HTMLAttributes<'div'> & { type Props = Polymorphic<{
as: HTMLTag
score: number score: number
label: string label: string
total?: number total?: number
itemReviewedId?: string itemReviewedId?: string
} showInfo?: boolean
children?: never
}>
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props const {
as: Tag = 'div',
score,
label,
total = 10,
class: className,
itemReviewedId,
showInfo = false,
...htmlProps
} = Astro.props
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total) const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
--- ---
<div <Tag
{...htmlProps} {...htmlProps}
class={cn( class={cn(
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white', '2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
className className
)} )}
role="group" role={htmlProps.role ?? 'group'}
> >
{ {
!!itemReviewedId && ( !!itemReviewedId && (
@@ -48,15 +60,17 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
/> />
) )
} }
<!-- <svg {
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60" showInfo && (
viewBox="0 0 12 12" <svg
fill="currentColor" class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
> viewBox="0 0 12 12"
<path fill="currentColor"
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" >
></path> <path d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" />
</svg> --> </svg>
)
}
<div <div
class={cn( class={cn(
@@ -77,4 +91,4 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
</div> </div>
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span> <span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
</div> </Tag>

View File

@@ -6,7 +6,6 @@ import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
import { transformCase } from '../lib/strings'
import MyPicture from './MyPicture.astro' import MyPicture from './MyPicture.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
@@ -14,7 +13,7 @@ import Tooltip from './Tooltip.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'a'> & { type Props = HTMLAttributes<'article'> & {
inlineIcons?: boolean inlineIcons?: boolean
withoutLink?: boolean withoutLink?: boolean
service: Prisma.ServiceGetPayload<{ service: Prisma.ServiceGetPayload<{
@@ -23,6 +22,8 @@ type Props = HTMLAttributes<'a'> & {
slug: true slug: true
description: true description: true
overallScore: true overallScore: true
privacyScore: true
trustScore: true
kycLevel: true kycLevel: true
imageUrl: true imageUrl: true
verificationStatus: true verificationStatus: true
@@ -45,6 +46,8 @@ const {
slug, slug,
description, description,
overallScore, overallScore,
privacyScore,
trustScore,
kycLevel, kycLevel,
imageUrl, imageUrl,
categories, categories,
@@ -54,7 +57,7 @@ const {
}, },
class: className, class: className,
withoutLink = false, withoutLink = false,
...aProps ...htmlProps
} = Astro.props } = Astro.props
const statusIcon = { const statusIcon = {
@@ -67,127 +70,129 @@ const Element = withoutLink ? 'div' : 'a'
const overallScoreInfo = makeOverallScoreInfo(overallScore) const overallScoreInfo = makeOverallScoreInfo(overallScore)
--- ---
<Element <article {...htmlProps}>
href={Element === 'a' ? `/service/${slug}` : undefined} <Element
{...aProps} href={Element === 'a' ? `/service/${slug}` : undefined}
class={cn( aria-label={Element === 'a' ? name : undefined}
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]', class={cn(
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') && 'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100', (serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
className 'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
)} className
> )}
<!-- Header with Icon and Title --> >
<div class="flex items-center gap-(--gap)"> <!-- Header with Icon and Title -->
<MyPicture <div class="flex items-center gap-(--gap)">
src={imageUrl} <MyPicture
fallback="service" src={imageUrl}
alt={name || 'Service logo'} fallback="service"
class={cn( alt="Logo"
'size-12 shrink-0 rounded-sm object-contain text-white', class={cn(
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') && 'size-12 shrink-0 rounded-sm object-contain text-white',
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0' (serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
)} 'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
width={48} )}
height={48} width={48}
/> height={48}
/>
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch"> <div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white"> <h1 class="font-title text-lg leading-none font-medium tracking-wide text-white">
{name}{ {name}{
statusIcon && ( statusIcon && (
<Tooltip <Tooltip
text={statusIcon.label} text={statusIcon.label}
position="right" position="right"
class="-my-2 shrink-0 whitespace-nowrap" class="-my-2 shrink-0 whitespace-nowrap"
enabled={verificationStatus !== 'VERIFICATION_FAILED'} enabled={verificationStatus !== 'VERIFICATION_FAILED'}
> >
{[ {[
<Icon
is:inline={inlineIcons}
name={statusIcon.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
statusIcon.classNames.icon
)}
/>,
verificationStatus === 'VERIFICATION_FAILED' && (
<span class="text-sm font-bold text-red-500">SCAM</span>
),
]}
</Tooltip>
)
}{
serviceVisibility === 'ARCHIVED' && (
<Tooltip
text={serviceVisibilitiesById.ARCHIVED.label}
position="right"
class="-my-2 shrink-0 whitespace-nowrap"
>
<Icon <Icon
is:inline={inlineIcons} is:inline={inlineIcons}
name={statusIcon.icon} name={serviceVisibilitiesById.ARCHIVED.icon}
class={cn( class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]', 'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0', serviceVisibilitiesById.ARCHIVED.iconClass
statusIcon.classNames.icon
)} )}
/>, />
verificationStatus === 'VERIFICATION_FAILED' && ( </Tooltip>
<span class="text-sm font-bold text-red-500">SCAM</span> )
), }
]} </h1>
</Tooltip> <div class="max-h-2 flex-1" aria-hidden="true"></div>
) <div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
}{ {
serviceVisibility === 'ARCHIVED' && ( categories.map((category) => (
<Tooltip <span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
text={serviceVisibilitiesById.ARCHIVED.label} <Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
position="right" <span>{category.name}</span>
class="-my-2 shrink-0 whitespace-nowrap" </span>
> ))
<Icon }
is:inline={inlineIcons} </div>
name={serviceVisibilitiesById.ARCHIVED.icon} </div>
class={cn( </div>
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
serviceVisibilitiesById.ARCHIVED.iconClass <div class="flex-1">
)} <p class="text-day-400 line-clamp-3 text-sm leading-tight">
/> {description}
</Tooltip> </p>
) </div>
}
</h3> <div class="flex items-center justify-start">
<div class="max-h-2 flex-1"></div> <Tooltip
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]"> class={cn(
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
overallScoreInfo.classNameBg
)}
text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
>
{overallScoreInfo.formattedScore}
</Tooltip>
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
KYC &nbsp;{kycLevel.toLocaleString()}
</span>
<div class="-m-1 ml-auto flex">
{ {
categories.map((category) => ( currencies.map((currency) => {
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none"> const isAccepted = acceptedCurrencies.includes(currency.id)
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
<span>{category.name}</span> return (
</span> <Tooltip text={currency.name}>
)) <Icon
is:inline={inlineIcons}
name={currency.icon}
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
/>
</Tooltip>
)
})
} }
</div> </div>
</div> </div>
</div> </Element>
</article>
<div class="flex-1">
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
{description}
</p>
</div>
<div class="flex items-center justify-start">
<Tooltip
class={cn(
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
overallScoreInfo.classNameBg
)}
text={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`}
>
{overallScoreInfo.formattedScore}
</Tooltip>
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
KYC &nbsp;{kycLevel.toLocaleString()}
</span>
<div class="-m-1 ml-auto flex">
{
currencies.map((currency) => {
const isAccepted = acceptedCurrencies.includes(currency.id)
return (
<Tooltip text={currency.name}>
<Icon
is:inline={inlineIcons}
name={currency.icon}
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
/>
</Tooltip>
)
})
}
</div>
</div>
</Element>

View File

@@ -11,9 +11,19 @@ type Props = HTMLAttributes<'a'> & {
searchParamValue?: string searchParamValue?: string
icon?: string icon?: string
iconClass?: string iconClass?: string
inlineIcons?: boolean
} }
const { text, searchParamName, searchParamValue, icon, iconClass, class: className, ...aProps } = Astro.props const {
text,
searchParamName,
searchParamValue,
icon,
iconClass,
inlineIcons = true,
class: className,
...aProps
} = Astro.props
const makeUrlWithoutFilter = (filter: string, value?: string) => { const makeUrlWithoutFilter = (filter: string, value?: string) => {
const url = new URL(Astro.url) const url = new URL(Astro.url)
@@ -29,8 +39,9 @@ const makeUrlWithoutFilter = (filter: string, value?: string) => {
'bg-night-800 hover:bg-night-900 border-night-400 flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm text-white', 'bg-night-800 hover:bg-night-900 border-night-400 flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm text-white',
className className
)} )}
aria-label={`Remove filter: ${text}`}
> >
{icon && <Icon name={icon} class={cn('size-4', iconClass)} />} {icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
{text} {text}
<Icon name="ri:close-large-line" class="text-day-400 size-4" /> <Icon name="ri:close-large-line" class="text-day-400 size-4" is:inline={inlineIcons} />
</a> </a>

View File

@@ -0,0 +1,200 @@
---
import { orderBy } from 'lodash-es'
import { getCurrencyInfo } from '../constants/currencies'
import { getNetworkInfo } from '../constants/networks'
import { getVerificationStatusInfo } from '../constants/verificationStatus'
import { areEqualArraysWithoutOrder } from '../lib/arrays'
import { cn } from '../lib/cn'
import { transformCase } from '../lib/strings'
import ServiceFiltersPill from './ServiceFiltersPill.astro'
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
import type { AttributeOption, ServicesFiltersObject } from '../pages/index.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
filters: ServicesFiltersObject
filtersOptions: ServicesFiltersOptions
categories: Prisma.CategoryGetPayload<{
select: {
name: true
slug: true
icon: true
}
}>[]
attributes: Prisma.AttributeGetPayload<{
select: {
title: true
id: true
}
}>[]
attributeOptions: AttributeOption[]
inlineIcons?: boolean
}
const {
class: className,
filters,
filtersOptions,
categories,
attributes,
attributeOptions,
inlineIcons = true,
...divProps
} = Astro.props
---
<div
class={cn(
'not-pointer-coarse:no-scrollbar -ml-4 flex shrink grow items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4',
className
)}
aria-label="Applied filters"
{...divProps}
>
{
filters.q && (
<ServiceFiltersPill
text={`"${filters.q}"`}
searchParamName="q"
searchParamValue={filters.q}
inlineIcons={inlineIcons}
/>
)
}
{
filters.categories.map((categorySlug) => {
const category = categories.find((c) => c.slug === categorySlug)
if (!category) return null
return (
<ServiceFiltersPill
text={category.name}
icon={category.icon}
searchParamName="categories"
searchParamValue={categorySlug}
inlineIcons={inlineIcons}
/>
)
})
}
{
filters.currencies.map((currencyId) => {
const currency = getCurrencyInfo(currencyId)
return (
<ServiceFiltersPill
text={currency.name}
searchParamName="currencies"
searchParamValue={currency.slug}
icon={currency.icon}
inlineIcons={inlineIcons}
/>
)
})
}
{
filters.networks.map((network) => {
const networkOption = getNetworkInfo(network)
return (
<ServiceFiltersPill
text={networkOption.name}
icon={networkOption.icon}
searchParamName="networks"
searchParamValue={network}
inlineIcons={inlineIcons}
/>
)
})
}
{
filters['max-kyc'] < 4 && (
<ServiceFiltersPill
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
icon="ri:shield-keyhole-line"
searchParamName="max-kyc"
inlineIcons={inlineIcons}
/>
)
}
{
filters['user-rating'] > 0 && (
<ServiceFiltersPill
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
icon="ri:star-fill"
searchParamName="user-rating"
inlineIcons={inlineIcons}
/>
)
}
{
filters['min-score'] > 0 && (
<ServiceFiltersPill
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
icon="ri:medal-line"
searchParamName="min-score"
inlineIcons={inlineIcons}
/>
)
}
{
filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && (
<ServiceFiltersPill
text="Attributes: AND"
icon="ri:filter-3-line"
searchParamName="attribute-mode"
searchParamValue="and"
inlineIcons={inlineIcons}
/>
)
}
{
filters.attr &&
Object.entries(filters.attr)
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
.map(([attributeId, attributeValue]) => {
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
if (!attribute) return null
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
return (
<ServiceFiltersPill
text={`${prefix}: ${attribute.title}`}
searchParamName={`attr-${attributeId}`}
searchParamValue={attributeValue}
inlineIcons={inlineIcons}
/>
)
})
}
{
!areEqualArraysWithoutOrder(
filters.verification,
filtersOptions.verification
.filter((verification) => verification.default)
.map((verification) => verification.value)
) &&
orderBy(
filters.verification.map((verificationStatus) => getVerificationStatusInfo(verificationStatus)),
'order',
'desc'
).map((verificationStatusInfo) => {
return (
<ServiceFiltersPill
text={verificationStatusInfo.label}
icon={verificationStatusInfo.icon}
iconClass={verificationStatusInfo.classNames.icon}
searchParamName="verification"
searchParamValue={verificationStatusInfo.slug}
inlineIcons={inlineIcons}
/>
)
})
}
</div>

View File

@@ -7,13 +7,21 @@ import { cn } from '../lib/cn'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
type Props = Omit<HTMLAttributes<'a'>, 'href' | 'rel' | 'target'> & { type Props = Omit<HTMLAttributes<'a' | 'span'>, 'href' | 'rel' | 'target'> & {
url: string url: string
referral: string | null referral: string | null
enableMinWidth?: boolean enableMinWidth?: boolean
isScam?: boolean
} }
const { url: baseUrl, referral, class: className, enableMinWidth = false, ...htmlProps } = Astro.props const {
url: baseUrl,
referral,
class: className,
enableMinWidth = false,
isScam = false,
...htmlProps
} = Astro.props
function makeLink(url: string, referral: string | null) { function makeLink(url: string, referral: string | null) {
const hostname = new URL(url).hostname const hostname = new URL(url).hostname
@@ -124,28 +132,39 @@ const link = makeLink(baseUrl, referral)
if (!z.string().url().safeParse(link.url).success) { if (!z.string().url().safeParse(link.url).success) {
console.error(`Invalid service URL with referral: ${link.url}`) console.error(`Invalid service URL with referral: ${link.url}`)
} }
const Tag = isScam ? 'span' : 'a'
--- ---
<a <Tag
href={link.url} href={isScam ? undefined : link.url}
target="_blank" target={isScam ? undefined : '_blank'}
rel="noopener noreferrer" rel={isScam ? undefined : 'noopener noreferrer'}
class={cn( class={cn(
'2xs:text-sm 2xs:h-8 2xs:gap-2 focus-visible:ring-offset-night-700 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black focus-visible:ring-4 focus-visible:ring-orange-500 focus-visible:ring-offset-2 focus-visible:outline-none', '2xs:text-sm 2xs:h-8 2xs:gap-2 focus-visible:ring-offset-night-700 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black focus-visible:ring-4 focus-visible:ring-orange-500 focus-visible:ring-offset-2 focus-visible:outline-none',
isScam && 'bg-day-800 cursor-not-allowed text-red-300',
className className
)} )}
title={link.url}
{...htmlProps} {...htmlProps}
> >
<Icon name={link.icon} class="2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4" /> <Icon
name={isScam ? 'ri:alert-line' : link.icon}
class={cn('2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4', isScam && 'text-red-400')}
/>
<span class={cn('font-title font-bold', { 'min-w-[29ch]': enableMinWidth })}> <span class={cn('font-title font-bold', { 'min-w-[29ch]': enableMinWidth })}>
{ {
link.textBits.map((textBit) => ( link.textBits.map((textBit) => (
<span class={cn(textBit.style === 'irrelevant' && 'text-zinc-500')}>{textBit.text}</span> <span class={cn(textBit.style === 'irrelevant' && 'opacity-60')}>{textBit.text}</span>
)) ))
} }
</span> </span>
<Icon {
name="ri:arrow-right-line" !isScam && (
class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white" <Icon
/> name="ri:arrow-right-line"
</a> class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white"
/>
)
}
</Tag>

View File

@@ -1,15 +1,15 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { kycLevels } from '../constants/kycLevels'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro' import { type ServicesFiltersObject } from '../pages/index.astro'
import Button from './Button.astro' import Button from './Button.astro'
import PillsRadioGroup from './PillsRadioGroup.astro' import PillsRadioGroup from './PillsRadioGroup.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
export type Props = HTMLAttributes<'form'> & { export type Props = HTMLAttributes<'form'> & {
@@ -111,7 +111,7 @@ const {
<legend class="font-title mb-3 leading-none text-green-500">Type</legend> <legend class="font-title mb-3 leading-none text-green-500">Type</legend>
<ul class="[&:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden"> <ul class="[&:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden">
{ {
options.categories?.map((category) => ( options.categories.map((category) => (
<li data-show-always={category.showAlways ? '' : undefined}> <li data-show-always={category.showAlways ? '' : undefined}>
<label class="flex cursor-pointer items-center gap-2 text-sm text-white"> <label class="flex cursor-pointer items-center gap-2 text-sm text-white">
<input <input
@@ -252,7 +252,7 @@ const {
</div> </div>
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs"> <div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
{ {
kycLevels.map((level) => ( options.kycLevels.map((level) => (
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap"> <span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
{level.value} {level.value}
<Icon name={level.icon} class="ms-1 size-3 shrink-0" /> <Icon name={level.icon} class="ms-1 size-3 shrink-0" />
@@ -334,7 +334,7 @@ const {
<li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer"> <li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer">
<fieldset class="relative flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white"> <fieldset class="relative flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
<legend class="sr-only"> <legend class="sr-only">
{attribute.title} ({attribute._count?.services}) {attribute.title} ({attribute._count.services})
</legend> </legend>
<input <input
type="radio" type="radio"
@@ -414,10 +414,13 @@ const {
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)} class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span
class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
title={attribute.title}
>
{attribute.title} {attribute.title}
</span> </span>
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span> <span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
</label> </label>
<label <label
for={emptyId} for={emptyId}
@@ -429,10 +432,13 @@ const {
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)} class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
aria-hidden="true" aria-hidden="true"
/> />
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"> <span
class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
title={attribute.title}
>
{attribute.title} {attribute.title}
</span> </span>
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span> <span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
</label> </label>
</fieldset> </fieldset>
</li> </li>

View File

@@ -1,16 +1,22 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { uniq } from 'lodash-es' import { uniq, orderBy } from 'lodash-es'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { getCurrencyInfo } from '../constants/currencies'
import { verificationStatusesByValue, getVerificationStatusInfo } from '../constants/verificationStatus'
import { areEqualArraysWithoutOrder } from '../lib/arrays'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { pluralize } from '../lib/pluralize' import { pluralize } from '../lib/pluralize'
import { transformCase } from '../lib/strings'
import { createPageUrl, urlWithParams } from '../lib/urls' import { createPageUrl, urlWithParams } from '../lib/urls'
import Button from './Button.astro' import Button from './Button.astro'
import ServiceCard from './ServiceCard.astro' import ServiceCard from './ServiceCard.astro'
import ServiceFiltersPillsRow from './ServiceFiltersPillsRow.astro'
import type { ServicesFiltersObject } from '../pages/index.astro' import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
import type { AttributeOption, ServicesFiltersObject } from '../pages/index.astro'
import type { Prisma } from '@prisma/client'
import type { ComponentProps, HTMLAttributes } from 'astro/types' import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & { type Props = HTMLAttributes<'div'> & {
@@ -23,6 +29,22 @@ type Props = HTMLAttributes<'div'> & {
filters: ServicesFiltersObject filters: ServicesFiltersObject
countCommunityOnly: number | null countCommunityOnly: number | null
inlineIcons?: boolean inlineIcons?: boolean
filtersOptions: ServicesFiltersOptions
categories: Prisma.CategoryGetPayload<{
select: {
name: true
namePluralLong: true
slug: true
icon: true
}
}>[]
attributes: Prisma.AttributeGetPayload<{
select: {
title: true
id: true
}
}>[]
attributeOptions: AttributeOption[]
} }
const { const {
@@ -36,6 +58,10 @@ const {
filters, filters,
countCommunityOnly, countCommunityOnly,
inlineIcons, inlineIcons,
categories,
filtersOptions,
attributes,
attributeOptions,
...divProps ...divProps
} = Astro.props } = Astro.props
@@ -55,9 +81,118 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug, verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug,
]), ]),
}) })
// NOTE: If you make changes to this function, remember to update the sitemap: src/pages/sitemaps/search.xml.ts
const searchTitle = (() => {
if (filters.q) {
return `Search results for “${filters.q}”`
}
const listOrformatter = new Intl.ListFormat('en', { style: 'long', type: 'disjunction' })
const listAndformatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' })
let [prefix, base, attributesPart, currencies, kycLevel, score] = ['', 'services', '', '', '', '']
if (!hasDefaultFilters) {
prefix = 'filtered'
}
const attributesFilters = Object.entries(filters.attr ?? {})
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
.map(([attributeId, attributeValue]) => {
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
if (!attribute) return null
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
const prefixWith = valueInfo?.prefixWith ?? transformCase(attributeValue, 'title')
return {
prefix,
prefixWith,
attribute,
valueInfo,
value: attributeValue,
}
})
.filter((attr) => !!attr)
if (attributesFilters.length === 1 || attributesFilters.length === 2) {
const formatter = filters['attribute-mode'] === 'and' ? listAndformatter : listOrformatter
attributesPart = formatter.format(
attributesFilters.map((attr) => `${attr.prefixWith} ${attr.attribute.title}`)
)
prefix = ''
}
if (
filters.verification.length === 1 ||
(!attributesFilters.length &&
!filters.currencies.length &&
!(filters['max-kyc'] <= 3) &&
!(filters['min-score'] >= 1) &&
areEqualArraysWithoutOrder(filters.verification, ['APPROVED', 'VERIFICATION_SUCCESS']))
) {
base = `${listAndformatter.format(
orderBy(
filters.verification.map((v) => getVerificationStatusInfo(v)),
'order',
'desc'
).map((v) => v.label)
)} services`
prefix = ''
}
if (filters.categories.length >= 1) {
base = listAndformatter.format(
filters.categories.map((c) => {
const cat = categories.find((cat) => cat.slug === c)
if (!cat) return c
return cat.namePluralLong ?? cat.name
})
)
prefix = ''
}
if (filters.currencies.length >= 1) {
const currenciesList = filters.currencies.map((c) => getCurrencyInfo(c).name)
const formatter = filters['currency-mode'] === 'and' ? listAndformatter : listOrformatter
currencies = `that accept ${formatter.format(currenciesList)}`
prefix = ''
}
if (filters['max-kyc'] === 0) {
kycLevel = 'without KYC'
prefix = ''
} else if (filters['max-kyc'] <= 3) {
kycLevel = `with KYC level ${filters['max-kyc']} or better`
prefix = ''
}
if (filters['min-score'] >= 1) {
score = `with score ${filters['min-score'].toLocaleString()} or more`
prefix = ''
}
const finalArray = [attributesPart, currencies, kycLevel, score].filter((str) => !!str)
const title = transformCase(`${prefix} ${base} ${finalArray.join('; ')}`.trim(), 'first-upper')
return title.length > 60 ? 'Filtered Services' : title
})()
--- ---
<div {...divProps} class={cn('flex-1', className)}> <div {...divProps} class={cn('min-w-0 flex-1', className)}>
<div class="hidden flex-wrap items-center justify-between gap-x-4 sm:flex">
<h1 class="font-title text-day-100 mb-2 text-xl leading-tight lg:text-2xl">
{searchTitle}
</h1>
<ServiceFiltersPillsRow
class="mask-l-from-[calc(100%-var(--spacing)*4)] pb-2"
filters={filters}
filtersOptions={filtersOptions}
categories={categories}
attributes={attributes}
attributeOptions={attributeOptions}
inlineIcons={inlineIcons}
/>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-day-500 xs:gap-x-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm sm:gap-x-6"> <span class="text-day-500 xs:gap-x-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm sm:gap-x-6">
{total.toLocaleString()} {total.toLocaleString()}
@@ -67,6 +202,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
name="ri:loader-4-line" name="ri:loader-4-line"
id="search-indicator" id="search-indicator"
class="htmx-request:opacity-100 xs:-mx-1.5 -mx-1 inline-block size-4 animate-spin text-white opacity-0 transition-opacity duration-500 sm:-mx-3" class="htmx-request:opacity-100 xs:-mx-1.5 -mx-1 inline-block size-4 animate-spin text-white opacity-0 transition-opacity duration-500 sm:-mx-3"
aria-hidden="true"
is:inline={inlineIcons} is:inline={inlineIcons}
/> />
@@ -212,11 +348,9 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
</div> </div>
) : ( ) : (
<> <>
<div class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]"> <ol class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
{services.map((service, i) => ( {services.map((service, i) => (
<ServiceCard <li
inlineIcons={inlineIcons}
service={service}
data-hx-search-results-card data-hx-search-results-card
{...(i === services.length - 1 && currentPage < totalPages {...(i === services.length - 1 && currentPage < totalPages
? { ? {
@@ -227,9 +361,11 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
'hx-indicator': '#infinite-scroll-indicator', 'hx-indicator': '#infinite-scroll-indicator',
} }
: {})} : {})}
/> >
<ServiceCard inlineIcons={inlineIcons} service={service} />
</li>
))} ))}
</div> </ol>
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator"> <div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500"> <div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">

View File

@@ -33,6 +33,7 @@ const {
enabled && ( enabled && (
<span <span
tabindex="-1" tabindex="-1"
aria-hidden="true"
class={cn( class={cn(
'pointer-events-none hidden select-none group-hover/tooltip:flex', 'pointer-events-none hidden select-none group-hover/tooltip:flex',
'ease-out-cubic scale-75 opacity-0 transition-all transition-discrete duration-100 group-hover/tooltip:scale-100 group-hover/tooltip:opacity-100 starting:group-hover/tooltip:scale-75 starting:group-hover/tooltip:opacity-0', 'ease-out-cubic scale-75 opacity-0 transition-all transition-discrete duration-100 group-hover/tooltip:scale-100 group-hover/tooltip:opacity-100 starting:group-hover/tooltip:scale-75 starting:group-hover/tooltip:opacity-0',

View File

@@ -52,9 +52,9 @@ const { service } = Astro.props
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200"> <div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
<Icon name="ri:alert-line" class="size-5 text-yellow-400" /> <Icon name="ri:alert-line" class="size-5 text-yellow-400" />
<span> <span>
Community-contributed. Information not reviewed. Community contributed. Information not reviewed.
<a <a
href="/about#suggestion-review-process" href="/about#community-contributed"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100" class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
> >
Learn more Learn more
@@ -68,7 +68,7 @@ const { service } = Astro.props
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with {service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution. caution.
<a <a
href="/about#suggestion-review-process" href="/about#approved"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100" class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
> >
Learn more Learn more
@@ -78,9 +78,9 @@ const { service } = Astro.props
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200"> <div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
<Icon name="ri:information-line" class="size-5 text-blue-400" /> <Icon name="ri:information-line" class="size-5 text-blue-400" />
<span> <span>
Basic checks passed, but not fully verified. Basic checks passed, but service is not verified.
<a <a
href="/about#suggestion-review-process" href="/about#approved"
class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100" class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
> >
Learn more Learn more

View File

@@ -0,0 +1,76 @@
import { countries as countriesData, type TCountryCode } from 'countries-list'
import { countries as flagCountries } from 'country-flag-icons'
import getUnicodeFlagIcon from 'country-flag-icons/unicode'
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
type CountryInfo<T extends string | null | undefined = string> = {
code: T
name: string
flag: string
slug: string
order: number
}
// Convert countries-list data to our format, ensuring we only use countries that have flags
const countriesArray = Object.entries(countriesData)
.filter(([code]) => flagCountries.includes(code as TCountryCode))
.map(([code, data]) => ({
code: code as TCountryCode,
name: data.name,
flag: getUnicodeFlagIcon(code) || '🏳️',
slug: code.toLowerCase(),
order: data.name.charCodeAt(0), // Sort alphabetically by first letter
}))
// Pre-sort the array alphabetically by name for performance
.sort((a, b) => a.name.localeCompare(b.name))
// Create a map for efficient lookups
const countriesMap = new Map(countriesArray.map((country) => [country.code, country]))
export const {
dataArray: countries,
dataObject: countriesById,
getFn: getCountryInfo,
getFnSlug: getCountryInfoBySlug,
zodEnumBySlug: countriesZodEnumBySlug,
zodEnumById: countriesZodEnumById,
keyToSlug: countryCodeToSlug,
slugToKey: countrySlugToCode,
} = makeHelpersForOptions(
'code',
(code): CountryInfo<typeof code> => {
// For null, undefined, or empty string, return a default "No Country" object
if (!code) {
return {
code,
name: 'No Country',
flag: '🏳️',
slug: '',
order: 999,
} as CountryInfo<typeof code>
}
// Try to find the country in our pre-built map
const country = countriesMap.get(code as TCountryCode)
// If found, return it; otherwise, return a default "Unknown Country" object
if (country) {
return country as CountryInfo
} else {
return {
code,
name: 'Unknown Country',
flag: '🏳️',
slug: code.toLowerCase(),
order: 999,
} as CountryInfo
}
},
countriesArray
)
// Helper function to validate country code
export const isValidCountryCode = (code: string): code is TCountryCode => {
return code in countriesData && flagCountries.includes(code as TCountryCode)
}

View File

@@ -76,5 +76,25 @@ export const {
label: 'Service verification changed', label: 'Service verification changed',
icon: 'ri:verified-badge-line', icon: 'ri:verified-badge-line',
}, },
{
id: 'ACCOUNT_DELETION_WARNING_30_DAYS',
label: 'Account deletion warning - 30 days',
icon: 'ri:alarm-warning-line',
},
{
id: 'ACCOUNT_DELETION_WARNING_15_DAYS',
label: 'Account deletion warning - 15 days',
icon: 'ri:alarm-warning-line',
},
{
id: 'ACCOUNT_DELETION_WARNING_5_DAYS',
label: 'Account deletion warning - 5 days',
icon: 'ri:alarm-warning-line',
},
{
id: 'ACCOUNT_DELETION_WARNING_1_DAY',
label: 'Account deletion warning - 1 day',
icon: 'ri:alarm-warning-line',
},
] as const satisfies NotificationTypeInfo<NotificationType>[] ] as const satisfies NotificationTypeInfo<NotificationType>[]
) )

View File

@@ -0,0 +1,33 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
type ReadStatusInfo<T extends string | null | undefined = string> = {
id: T
label: string
readValue: boolean
}
export const {
dataArray: readStatuses,
getFn: getReadStatus,
zodEnumById: readStatusZodEnum,
} = makeHelpersForOptions(
'id',
(id): ReadStatusInfo<typeof id> => ({
id,
label: id ? transformCase(id, 'title') : String(id),
readValue: false,
}),
[
{
id: 'unread',
label: 'Unread',
readValue: false,
},
{
id: 'read',
label: 'Read',
readValue: true,
},
] as const satisfies ReadStatusInfo[]
)

View File

@@ -26,7 +26,7 @@ type VerificationStatusInfo<T extends string | null | undefined = string> = {
} }
export const READ_MORE_SENTENCE_LINK = export const READ_MORE_SENTENCE_LINK =
'Read more about the [suggestion review process](/about#suggestion-review-process).' satisfies MarkdownString 'Read more about the [listing statuses](/about#listing-statuses).' satisfies MarkdownString
export const { export const {
dataArray: verificationStatuses, dataArray: verificationStatuses,

2
web/src/env.d.ts vendored
View File

@@ -3,7 +3,7 @@
import type { ErrorBanners } from './lib/errorBanners' import type { ErrorBanners } from './lib/errorBanners'
import type { KarmaUnlocks } from './lib/karmaUnlocks' import type { KarmaUnlocks } from './lib/karmaUnlocks'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type * as htmx from 'htmx.org' import type htmx from 'htmx.org'
declare global { declare global {
namespace App { namespace App {

View File

@@ -1,12 +1,16 @@
--- ---
import { differenceInCalendarDays } from 'date-fns'
import AnnouncementBanner from '../components/AnnouncementBanner.astro' import AnnouncementBanner from '../components/AnnouncementBanner.astro'
import BaseHead from '../components/BaseHead.astro' import BaseHead from '../components/BaseHead.astro'
import Footer from '../components/Footer.astro' import Footer from '../components/Footer.astro'
import Header from '../components/Header.astro' import Header from '../components/Header.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { pluralize } from '../lib/pluralize'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import type { AstroChildren } from '../lib/astro' import type { AstroChildren } from '../lib/astro'
import type { Prisma } from '@prisma/client'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
import '@fontsource-variable/space-grotesk' import '@fontsource-variable/space-grotesk'
@@ -71,6 +75,26 @@ const announcement = await Astro.locals.banners.try(
}), }),
null null
) )
function getDeletionAnnouncement(
user: Prisma.UserGetPayload<{ select: { scheduledDeletionAt: true } }> | null,
currentDate: Date = new Date()
) {
if (!user?.scheduledDeletionAt) return null
const daysUntilDeletion = differenceInCalendarDays(user.scheduledDeletionAt, currentDate)
return {
id: 0,
content: `Your account will be deleted ${daysUntilDeletion <= 0 ? 'today' : `in ${daysUntilDeletion.toLocaleString()} ${pluralize('day', daysUntilDeletion)}`} due to inactivity.`,
type: 'ALERT' as const,
link: '/account',
linkText: 'Prevent deletion',
startDate: currentDate,
endDate: null,
isActive: true,
}
}
const deletionAnnouncement = getDeletionAnnouncement(Astro.locals.user, currentDate)
--- ---
<html lang="en" transition:name="root" transition:animate="none"> <html lang="en" transition:name="root" transition:animate="none">
@@ -85,6 +109,14 @@ const announcement = await Astro.locals.banners.try(
data-is-logged-in={Astro.locals.user !== null ? '' : undefined} data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
> >
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />} {announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
{
deletionAnnouncement && (
<AnnouncementBanner
announcement={deletionAnnouncement}
transition:name="deletion-warning-announcement"
/>
)
}
<Header <Header
classNames={{ classNames={{
nav: cn( nav: cn(

View File

@@ -58,9 +58,13 @@ const ogImageTemplateData = {
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto" class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
> >
<h1 class="mb-0!">{frontmatter.title}</h1> <h1 class="mb-0!">{frontmatter.title}</h1>
<p class="mt-2! opacity-70"> {
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />} !!frontmatter.updatedAt && (
</p> <p class="mt-2! opacity-70">
Updated <TimeFormatted date={new Date(frontmatter.updatedAt)} />
</p>
)
}
<slot /> <slot />
</div> </div>

View File

@@ -1,7 +1,10 @@
import { differenceInMonths, differenceInYears } from 'date-fns'
import he from 'he'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { getCountryInfo } from '../constants/countries'
import { kycLevelClarifications } from '../constants/kycLevelClarifications' import { kycLevelClarifications } from '../constants/kycLevelClarifications'
import { kycLevels } from '../constants/kycLevels' import { kycLevels } from '../constants/kycLevels'
import { serviceVisibilitiesById } from '../constants/serviceVisibility' import { serviceVisibilitiesById } from '../constants/serviceVisibility'
@@ -45,6 +48,9 @@ type NonDbAttributeFull = NonDbAttribute & {
acceptedCurrencies: true acceptedCurrencies: true
kycLevel: true kycLevel: true
kycLevelClarification: true kycLevelClarification: true
operatingSince: true
registrationCountryCode: true
registeredCompanyName: true
} }
}> }>
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & { ) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
@@ -255,6 +261,91 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
show: service.acceptedCurrencies.includes('MONERO'), show: service.acceptedCurrencies.includes('MONERO'),
}), }),
}, },
{
slug: 'new-service',
title: 'New service',
type: 'WARNING',
category: 'TRUST',
description:
'The service started operations less than a year ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).',
privacyPoints: 0,
trustPoints: -4,
links: [],
customize: (service) => {
if (!service.operatingSince) return { show: false }
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
if (yearsOperated >= 1) return { show: false }
const monthsOperated = differenceInMonths(new Date(), service.operatingSince)
return {
show: true,
description: `The service started operations ${
monthsOperated === 0
? 'less than a month'
: `${String(monthsOperated)} month${monthsOperated > 1 ? 's' : ''}`
} ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).`,
}
},
},
{
slug: 'mature-service',
title: 'Mature service',
type: 'GOOD',
category: 'TRUST',
description:
'This service has been operational for at least 2 years. While this indicates stability, it is not a future-proof guarantee.',
privacyPoints: 0,
trustPoints: 5,
links: [],
customize: (service) => {
if (!service.operatingSince) return { show: false }
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
return {
show: yearsOperated >= 2,
description: `This service has been operational for **${String(
yearsOperated
)} years**. While this indicates stability, it is not a future-proof guarantee.`,
}
},
},
{
slug: 'legally-registered',
title: 'Legally registered',
type: 'INFO',
category: 'TRUST',
description: 'This service is legally registered as a company.',
privacyPoints: 0,
trustPoints: 2,
links: [],
customize: (service) => {
const countryCode = service.registrationCountryCode
const companyName = service.registeredCompanyName
if (!countryCode && !companyName) {
return { show: false }
}
const countryInfo = countryCode ? getCountryInfo(countryCode) : null
const flagTitle = countryInfo ? `${countryInfo.flag} Legally registered` : 'Legally registered'
let description = 'Legally registered.'
if (companyName && countryCode && countryInfo) {
description = `Legally registered as **${he.escape(companyName)}** in **${countryInfo.name}**.`
} else if (companyName) {
description = `Legally registered as **${he.escape(companyName)}**.`
} else if (countryCode && countryInfo) {
description = `Legally registered in **${countryInfo.name}**.`
}
return {
show: true,
title: flagTitle,
description,
}
},
},
] ]
export function sortAttributes< export function sortAttributes<

View File

@@ -185,6 +185,18 @@ export function makeNotificationTitle(
serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange] serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange]
return `${serviceName} ${statusChange.notificationTitle}` return `${serviceName} ${statusChange.notificationTitle}`
} }
case 'ACCOUNT_DELETION_WARNING_30_DAYS': {
return 'Account deletion warning - 30 days remaining'
}
case 'ACCOUNT_DELETION_WARNING_15_DAYS': {
return 'Account deletion warning - 15 days remaining'
}
case 'ACCOUNT_DELETION_WARNING_5_DAYS': {
return 'Account deletion warning - 5 days remaining'
}
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
return 'Account deletion warning - 1 day remaining'
}
} }
} }
@@ -251,6 +263,12 @@ export function makeNotificationContent(
if (!notification.aboutEvent) return null if (!notification.aboutEvent) return null
return notification.aboutEvent.title return notification.aboutEvent.title
} }
case 'ACCOUNT_DELETION_WARNING_30_DAYS':
case 'ACCOUNT_DELETION_WARNING_15_DAYS':
case 'ACCOUNT_DELETION_WARNING_5_DAYS':
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
return 'Your account will be deleted due to inactivity. Log in and perform any activity (comment, vote, or create a suggestion) to prevent deletion.'
}
} }
} }
@@ -411,6 +429,19 @@ export function makeNotificationActions(
}, },
] ]
} }
case 'ACCOUNT_DELETION_WARNING_30_DAYS':
case 'ACCOUNT_DELETION_WARNING_15_DAYS':
case 'ACCOUNT_DELETION_WARNING_5_DAYS':
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
return [
{
action: 'login',
title: 'Login & Stay Active',
...iconNameAndUrl('ri:login-box-line'),
url: `${origin}/login`,
},
]
}
} }
} }

View File

@@ -25,6 +25,10 @@ const knownPlurals = {
singular: 'Request', singular: 'Request',
plural: 'Requests', plural: 'Requests',
}, },
day: {
singular: 'Day',
plural: 'Days',
},
something: { something: {
singular: 'Something', singular: 'Something',
plural: 'Somethings', plural: 'Somethings',

View File

@@ -45,7 +45,6 @@ function prismaClientSingleton() {
} }
declare global { declare global {
// eslint-disable-next-line no-var
var prisma: ReturnType<typeof prismaClientSingleton> | undefined var prisma: ReturnType<typeof prismaClientSingleton> | undefined
} }

View File

@@ -0,0 +1,187 @@
import { orderBy, groupBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { currencies } from '../constants/currencies'
import { kycLevels } from '../constants/kycLevels'
import { networks } from '../constants/networks'
import { verificationStatuses } from '../constants/verificationStatus'
import type { Prisma } from '@prisma/client'
const MIN_CATEGORIES_TO_SHOW = 8
const MIN_ATTRIBUTES_TO_SHOW = 8
export const sortOptions = [
{
value: 'score-desc',
label: 'Score (High → Low)',
orderBy: {
key: 'overallScore',
direction: 'desc',
},
},
{
value: 'score-asc',
label: 'Score (Low → High)',
orderBy: {
key: 'overallScore',
direction: 'asc',
},
},
{
value: 'name-asc',
label: 'Name (A → Z)',
orderBy: {
key: 'name',
direction: 'asc',
},
},
{
value: 'name-desc',
label: 'Name (Z → A)',
orderBy: {
key: 'name',
direction: 'desc',
},
},
{
value: 'recent',
label: 'Date listed (New → Old)',
orderBy: {
key: 'listedAt',
direction: 'desc',
},
},
{
value: 'oldest',
label: 'Date listed (Old → New)',
orderBy: {
key: 'listedAt',
direction: 'asc',
},
},
] as const satisfies {
value: string
label: string
orderBy: {
key: keyof Prisma.ServiceSelect
direction: 'asc' | 'desc'
}
}[]
export const defaultSortOption = sortOptions[0]
export const modeOptions = [
{
value: 'or',
label: 'OR',
},
{
value: 'and',
label: 'AND',
},
] as const satisfies {
value: string
label: string
}[]
export function makeSearchFiltersOptions({
filters,
categories,
attributes,
}: {
filters: {
categories: string[]
attr: Record<number, '' | 'no' | 'yes'> | undefined
} | null
categories: Prisma.CategoryGetPayload<{
select: {
name: true
namePluralLong: true
slug: true
icon: true
_count: {
select: {
services: {
where: {
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }
}
}
}
}
}
}>[]
attributes: Prisma.AttributeGetPayload<{
select: {
id: true
slug: true
title: true
category: true
type: true
_count: {
select: {
services: true
}
}
}
}>[]
}) {
const attributesByCategory = orderBy(
Object.entries(
groupBy(
attributes.map((attr) => {
return {
typeInfo: getAttributeTypeInfo(attr.type),
...attr,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
value: filters?.attr?.[attr.id] || undefined,
}
}),
'category'
)
).map(([category, attributes]) => ({
category,
categoryInfo: getAttributeCategoryInfo(category),
attributes: orderBy(
attributes,
['value', 'type', '_count.services', 'title'],
['asc', 'asc', 'desc', 'asc']
).map((attr, i) => ({
...attr,
showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined,
})),
})),
['category'],
['asc']
)
const categoriesSorted = orderBy(
categories.map((category) => {
const checked = filters?.categories.includes(category.slug) ?? false
return {
...category,
checked,
}
}),
['checked', '_count.services', 'name'],
['desc', 'desc', 'asc']
).map((category, i) => ({
...category,
showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked,
}))
return {
currencies,
categories: categoriesSorted,
sort: sortOptions,
modeOptions,
network: networks,
verification: verificationStatuses,
attributesByCategory,
kycLevels,
} as const
}
export type ServicesFiltersOptions = ReturnType<typeof makeSearchFiltersOptions>

View File

@@ -20,7 +20,7 @@ export const areSameNormalized = (str1: string, str2: string): boolean => {
return normalize(str1) === normalize(str2) return normalize(str1) === normalize(str2)
} }
export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'upper' export type TransformCaseType = 'first-upper' | 'lower' | 'original' | 'sentence' | 'title' | 'upper'
/** /**
* Transform a string to a different case. * Transform a string to a different case.
@@ -31,6 +31,7 @@ export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'u
* transformCase('hello WORLD', 'sentence') // 'Hello world' * transformCase('hello WORLD', 'sentence') // 'Hello world'
* transformCase('hello WORLD', 'title') // 'Hello World' * transformCase('hello WORLD', 'title') // 'Hello World'
* transformCase('hello WORLD', 'original') // 'hello WORLD' * transformCase('hello WORLD', 'original') // 'hello WORLD'
* transformCase('Hello WORLD', 'first-upper') // 'Hello WORLD'
*/ */
export const transformCase = <T extends string, C extends TransformCaseType>( export const transformCase = <T extends string, C extends TransformCaseType>(
str: T, str: T,
@@ -43,7 +44,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
? Capitalize<Lowercase<T>> ? Capitalize<Lowercase<T>>
: C extends 'title' : C extends 'title'
? Capitalize<Lowercase<T>> ? Capitalize<Lowercase<T>>
: T => { : C extends 'first-upper'
? Capitalize<T>
: T => {
switch (caseType) { switch (caseType) {
case 'lower': case 'lower':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
@@ -54,6 +57,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
case 'sentence': case 'sentence':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return (str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) as any return (str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) as any
case 'first-upper':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return (str.charAt(0).toUpperCase() + str.slice(1)) as any
case 'title': case 'title':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return str return str

View File

@@ -8,6 +8,21 @@ icon: 'ri:information-line'
import DonationAddress from '../components/DonationAddress.astro' import DonationAddress from '../components/DonationAddress.astro'
- [What is KYC?](#what-is-kyc)
- [Why does this site exist?](#why-does-this-site-exist)
- [Why only Bitcoin and Monero?](#why-only-bitcoin-and-monero)
- [User Accounts](#user-accounts)
- [Verified and Affiliated Users](#verified-and-affiliated-users)
- [Listings](#listings)
- [Suggesting a new listing](#suggesting-a-new-listing)
- [Listing statuses](#listing-statuses)
- [Reviews and Comments](#reviews-and-comments)
- [Moderation](#moderation)
- [API](#api)
- [Donate](#support)
- [Contact](#contact)
- [Downloads and Assets](#downloads-and-assets)
## What is this page? ## What is this page?
KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy. KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy.
@@ -52,9 +67,9 @@ Users earn karma by participating in the community. When your comments get appro
### Verified and Affiliated Users ### Verified and Affiliated Users
Some users are **verified**, this means that the moderators have confirmed that the user is related to a specific link or service. The verification is directly linked to the URL, ensuring that the person behind the username has a legitimate connection to the service they claim to represent. This verification process is reserved for individuals with established reputation. **Verified users** have proven their identity by linking their account to a specific website. This verification confirms they are legitimate representatives of that site, whether it's a personal website, blog, social media profile, or service page. You can request verification [in your profile](/account).
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us. **Affiliated users** are users who represent a service listed in the directory, such as owners, support staff, or team members. If you represent a service and want to become affiliated, you can request it [in your profile](/account).
## Listings ## Listings
@@ -71,12 +86,13 @@ 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 - Publicly available website explaining what the service is about
- Terms of service or FAQ document - Terms of service or FAQ document
- The service must have been operating for at least 6 months.
Examples of non-valid services: Examples of non-valid services:
- Just a Telegram link
- A cryptocurrency project - A cryptocurrency project
- A cryptocurrency wallet - A cryptocurrency wallet
- A chat link without any information about the service, or review page.
#### Suggestion Review Process #### Suggestion Review Process
@@ -145,7 +161,7 @@ These categories **directly influence** a service's Privacy and Trust scores, wh
#### 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. Scores are calculated **automatically** using clear, fixed rules based on the attributes of the service ([See all attributes](/attributes)). 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
@@ -184,6 +200,32 @@ 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. You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
### Listing Statuses
#### **Unlisted**
Initial state after the service is submitted. The service will **not appear in the list or search results**. Only accessible with a **direct link**. An **initial review** is done by the team to ensure the service is **not spam or inappropriate**.
#### **Community Contributed**
The service is **listed** in the directory, but it has **not been reviewed** by our team. The information **may be inaccurate, incomplete, or fraudulent**. Users should use these services **with caution**.
#### **Approved**
The service is listed in the directory and has been **reviewed by our team**. The information is **accurate and complete**. Initial tests were **successful**, but there is **not enough trust** to be verified.
#### **Verified**
The service has been listed for a while and has **not been reported** as a scam, user reviews are **mostly positive**, and the service is **not under any investigation**. Further tests and checks have been **successfully completed**. Contact with support or admins was also successful.
#### **Scam**
The service is a **scam**. User reports, negative reviews, or **failed internal testing** and other red flags were found. Evidence is provided in the **verification section** of the service page.
#### **Archived**
The service is **no longer available**. It may have been **shut down, acquired, or otherwise discontinued**. Still **visible** in the directory **for reference**.
## 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. 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.
@@ -194,21 +236,38 @@ 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**. Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
### Note on moderation ### 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. **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 review**. We only remove comments that do not follow the guidelines.
Comments from [**users affiliated with a service**](/about#verified-and-affiliated-users) are automatically approved on their own service page.
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
#### Comment Guidelines
We welcome honest, constructive discussion. However, any comment or review that contains the following will be **rejected**:
- **Spam** promotional content, fake reviews, or coordinated campaigns.
- **Doxxing** harassment, threats, or disclosure of private information.
- **Illegal content** anything that encourages illegal activity.
- **AI-generated text** AI-written content is not allowed.
- **Unrelated content** content that is not related to the service.
- **Personal discussion** discussions not related to the service.
A review score may be **disabled** if it:
- Is not based on your own first-hand experience.
- Contains demonstrably false or unverified claims.
- Is not relevant to the service being reviewed.
## API ## API
You can access basic service data via our public API. You can access basic service data through our [public API](/docs/api).
See the [API page](/docs/api) for more details.
## Support ## Support
If you like this project, you can **support** it through these methods: You can **support** our work through these methods:
<DonationAddress <DonationAddress
cryptoName="Monero" cryptoName="Monero"
@@ -218,11 +277,11 @@ If you like this project, you can **support** it through these methods:
## Contact ## Contact
You can contact via direct chat: You can contact us through SimpleX Chat:
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion) - [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
## Downloads and assets ## Downloads and Assets
For logos and brand assets, visit our [downloads page](/downloads). For logos and brand assets, visit our [downloads page](/downloads).

View File

@@ -0,0 +1,141 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Captcha from '../../components/Captcha.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import UserBadge from '../../components/UserBadge.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
const deleteResult = Astro.getActionResult(actions.account.delete)
if (deleteResult && !deleteResult.error) {
Astro.locals.banners.addIfSuccess(deleteResult, 'Account deleted successfully')
return Astro.redirect('/')
}
const userId = Astro.locals.user?.id
if (!userId) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to delete your account' }))
}
const user = await Astro.locals.banners.try('Failed to load user profile', () =>
prisma.user.findUnique({
where: { id: userId },
select: {
admin: true,
moderator: true,
name: true,
displayName: true,
picture: true,
_count: {
select: {
comments: true,
commentVotes: true,
suggestions: true,
suggestionMessages: true,
verificationRequests: true,
},
},
},
})
)
if (!user) return Astro.rewrite('/404')
---
<MiniLayout
pageTitle="Delete account"
description="Delete your account"
ogImage={{
template: 'generic',
title: 'Delete account',
description: 'Delete your account',
icon: 'ri:delete-bin-line',
}}
layoutHeader={{
icon: 'ri:delete-bin-line',
title: 'Delete account',
subtitle: 'Are you sure? This is irreversible.',
}}
breadcrumbs={[
{
name: 'Accounts',
url: '/account',
},
{
name: 'Delete account',
},
]}
>
<h2 class="font-title text-day-200 mb-2 text-lg font-semibold">What will be deleted:</h2>
<a
href="/account"
class="group relative mb-2 flex flex-col items-center rounded-xl border border-red-500/10 bg-red-500/10 p-4"
>
<UserBadge user={user} size="lg" noLink classNames={{ text: 'group-hover:underline' }} />
<Icon
name="ri:forbid-line"
class="2xs:block absolute top-1/2 left-3 hidden size-8 -translate-y-1/2 text-red-300/10"
/>
<Icon
name="ri:forbid-2-line"
class="2xs:block absolute top-1/2 right-3 hidden size-8 -translate-y-1/2 text-red-300/10"
/>
</a>
<ul class="2xs:grid-cols-2 mb-8 grid grid-cols-1 gap-x-1 gap-y-0">
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold">1</span>
<span class="text-day-400 text-sm">User</span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.comments.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Comments <span class="text-xs">(+ their replies)</span></span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.commentVotes.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Comment Votes</span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.suggestions.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Service Suggestions</span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.suggestionMessages.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Suggestion Messages</span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.verificationRequests.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Verification Requests</span>
</li>
</ul>
{
user.admin || user.moderator ? (
<p class="text-center text-balance text-red-300">Admins and moderators cannot be deleted.</p>
) : (
<form method="POST" action={actions.account.delete}>
<Captcha action={actions.account.delete} class="mb-6" />
<InputSubmitButton label="Delete account" color="danger" icon="ri:delete-bin-line" />
</form>
)
}
</MiniLayout>

View File

@@ -1,6 +1,7 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { differenceInCalendarDays } from 'date-fns'
import { sortBy } from 'lodash-es' import { sortBy } from 'lodash-es'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
@@ -19,6 +20,7 @@ import { verificationStatusesByValue } from '../../constants/verificationStatus'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks' import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
import { pluralize } from '../../lib/pluralize'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls' import { makeLoginUrl } from '../../lib/redirectUrls'
import { formatDateShort } from '../../lib/timeAgo' import { formatDateShort } from '../../lib/timeAgo'
@@ -49,6 +51,7 @@ const user = await Astro.locals.banners.try('user', async () => {
verifiedLink: true, verifiedLink: true,
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
scheduledDeletionAt: true,
_count: { _count: {
select: { select: {
comments: true, comments: true,
@@ -158,6 +161,10 @@ const user = await Astro.locals.banners.try('user', async () => {
}) })
if (!user) return Astro.rewrite('/404') if (!user) return Astro.rewrite('/404')
const daysUntilDeletion = user.scheduledDeletionAt
? differenceInCalendarDays(user.scheduledDeletionAt, new Date())
: null
--- ---
<BaseLayout <BaseLayout
@@ -394,6 +401,33 @@ if (!user) return Astro.rewrite('/404')
</div> </div>
</li> </li>
{
daysUntilDeletion && (
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:delete-bin-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Deletion Status</p>
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
Scheduled for deletion
{daysUntilDeletion <= 0 ? (
'today'
) : (
<>
in {daysUntilDeletion.toLocaleString()}&nbsp;{pluralize('day', daysUntilDeletion)}
</>
)}
</span>
<p class="text-day-400 mt-2 text-xs">
To prevent deletion, take any action such as voting, commenting, or suggesting a
service.
</p>
</div>
</li>
)
}
<li class="flex items-start"> <li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span> <span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div> <div>
@@ -962,7 +996,7 @@ if (!user) return Astro.rewrite('/404')
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout <Icon name="ri:logout-box-r-line" class="size-4" /> Logout
</a> </a>
<a <a
href={`mailto:${SUPPORT_EMAIL}`} href="/account/delete"
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
> >
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account <Icon name="ri:delete-bin-line" class="size-4" /> Delete account

View File

@@ -5,6 +5,7 @@ import { Icon } from 'astro-icon/components'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
import CommentModeration from '../../components/CommentModeration.astro' import CommentModeration from '../../components/CommentModeration.astro'
import MyPicture from '../../components/MyPicture.astro' import MyPicture from '../../components/MyPicture.astro'
import Pagination from '../../components/Pagination.astro'
import TimeFormatted from '../../components/TimeFormatted.astro' import TimeFormatted from '../../components/TimeFormatted.astro'
import UserBadge from '../../components/UserBadge.astro' import UserBadge from '../../components/UserBadge.astro'
import { import {
@@ -27,7 +28,7 @@ if (!user || (!user.admin && !user.moderator)) {
const { data: params } = zodParseQueryParamsStoringErrors( const { data: params } = zodParseQueryParamsStoringErrors(
{ {
status: commentStatusFiltersZodEnum.default('all'), status: commentStatusFiltersZodEnum.default('all'),
page: z.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),
}, },
Astro Astro
) )
@@ -241,29 +242,5 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
</div> </div>
<!-- Pagination --> <!-- Pagination -->
{ {totalPages > 1 && <Pagination currentPage={params.page} totalPages={totalPages} class="mt-8" />}
totalPages > 1 && (
<div class="mt-8 flex justify-center gap-2">
{params.page > 1 && (
<a
href={urlWithParams(Astro.url, { page: params.page - 1 })}
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
>
Previous
</a>
)}
<span class="font-title px-3 py-1 text-sm">
Page {params.page} of {totalPages}
</span>
{params.page < totalPages && (
<a
href={urlWithParams(Astro.url, { page: params.page + 1 })}
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
>
Next
</a>
)}
</div>
)
}
</BaseLayout> </BaseLayout>

View File

@@ -10,6 +10,7 @@ import Button from '../../../../components/Button.astro'
import FormSection from '../../../../components/FormSection.astro' import FormSection from '../../../../components/FormSection.astro'
import FormSubSection from '../../../../components/FormSubSection.astro' import FormSubSection from '../../../../components/FormSubSection.astro'
import InputCardGroup from '../../../../components/InputCardGroup.astro' import InputCardGroup from '../../../../components/InputCardGroup.astro'
import InputCheckbox from '../../../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro' import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
import InputImageFile from '../../../../components/InputImageFile.astro' import InputImageFile from '../../../../components/InputImageFile.astro'
import InputSelect from '../../../../components/InputSelect.astro' import InputSelect from '../../../../components/InputSelect.astro'
@@ -24,6 +25,7 @@ import UserBadge from '../../../../components/UserBadge.astro'
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories' import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes' import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods' import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
import { countries } from '../../../../constants/countries'
import { currencies } from '../../../../constants/currencies' import { currencies } from '../../../../constants/currencies'
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes' import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications' import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
@@ -361,6 +363,17 @@ const apiCalls = await Astro.locals.banners.try(
value={service.tosUrls.join('\n')} value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls} error={serviceInputErrors.tosUrls}
/> />
<InputText
label="Operating since"
name="operatingSince"
description="Date the service started operating"
inputProps={{
type: 'date',
value: service.operatingSince?.toISOString().slice(0, 10),
max: new Date().toISOString().slice(0, 10),
}}
error={serviceInputErrors.operatingSince}
/>
<InputText <InputText
label="Referral link path" label="Referral link path"
name="referral" name="referral"
@@ -374,6 +387,36 @@ const apiCalls = await Astro.locals.banners.try(
/> />
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputText
label="Registered Company Name"
name="registeredCompanyName"
description="Official name of the registered company"
inputProps={{
value: service.registeredCompanyName,
placeholder: 'e.g. Example Corp Ltd.',
}}
error={serviceInputErrors.registeredCompanyName}
/>
<InputSelect
name="registrationCountryCode"
label="Company Registration Country"
description="Country where the service company is legally registered"
options={[
{ label: 'Not registered', value: '' },
...countries
.sort((a, b) => a.name.localeCompare(b.name))
.map((country) => ({
label: `${country.flag} ${country.name}`,
value: country.code,
}))
]}
selectedValue={service.registrationCountryCode || ''}
error={serviceInputErrors.registrationCountryCode}
/>
</div>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<InputImageFile <InputImageFile
label="Image" label="Image"
@@ -534,6 +577,24 @@ const apiCalls = await Astro.locals.banners.try(
cardSize="sm" cardSize="sm"
/> />
<InputCheckbox
label="Strict Commenting"
name="strictCommentingEnabled"
checked={service.strictCommentingEnabled}
descriptionInline="Require proof of being a client for comments."
/>
<InputTextArea
label="Comment Section Message"
name="commentSectionMessage"
value={service.commentSectionMessage ?? ''}
description="Markdown supported"
inputProps={{
rows: 4,
}}
error={serviceInputErrors.commentSectionMessage}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel /> <InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form> </form>
</FormSection> </FormSection>

View File

@@ -1,23 +1,57 @@
--- ---
import { AttributeCategory, Currency, VerificationStatus } from '@prisma/client' import { ServiceVisibility, VerificationStatus } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import { orderBy } from 'lodash-es'
import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputCheckbox from '../../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../../components/InputCheckboxGroup.astro'
import InputImageFile from '../../../components/InputImageFile.astro'
import InputSelect from '../../../components/InputSelect.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../../constants/attributeTypes'
import { contactMethodUrlTypes } from '../../../constants/contactMethods'
import { countries } from '../../../constants/countries'
import { currencies } from '../../../constants/currencies'
import { kycLevelClarifications } from '../../../constants/kycLevelClarifications'
import { kycLevels } from '../../../constants/kycLevels'
import { serviceVisibilities } from '../../../constants/serviceVisibility'
import { verificationStatuses } from '../../../constants/verificationStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
const categories = await Astro.locals.banners.try('Failed to fetch categories', () => const [categories, attributes] = await Astro.locals.banners.tryMany([
prisma.category.findMany({ [
orderBy: { name: 'asc' }, 'Failed to fetch categories',
}) () =>
) prisma.category.findMany({
orderBy: { name: 'asc' },
const attributes = await Astro.locals.banners.try('Failed to fetch attributes', () => select: {
prisma.attribute.findMany({ id: true,
orderBy: { category: 'asc' }, name: true,
}) icon: true,
) },
}),
[],
],
[
'Failed to fetch attributes',
() =>
prisma.attribute.findMany({
orderBy: { category: 'asc' },
select: {
id: true,
title: true,
category: true,
type: true,
},
}),
[],
],
])
const result = Astro.getActionResult(actions.admin.service.create) const result = Astro.getActionResult(actions.admin.service.create)
Astro.locals.banners.addIfSuccess(result, 'Service created successfully') Astro.locals.banners.addIfSuccess(result, 'Service created successfully')
@@ -27,353 +61,287 @@ if (result && !result.error) {
const inputErrors = isInputError(result?.error) ? result.error.fields : {} const inputErrors = isInputError(result?.error) ? result.error.fields : {}
--- ---
<BaseLayout pageTitle="Create Service" widthClassName="max-w-screen-sm"> <BaseLayout
<section class="mb-8"> pageTitle="Create Service"
<div class="font-title mb-4"> description="Create a new service for KYCnot.me"
<span class="text-sm text-green-500">service.create</span> widthClassName="max-w-screen-md"
>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Create Service</h1>
<form
method="POST"
action={actions.admin.service.create}
enctype="multipart/form-data"
class="space-y-6"
>
<InputText
label="Name"
name="name"
inputProps={{
required: true,
maxlength: 40,
}}
error={inputErrors.name}
/>
<InputTextArea
label="Description"
name="description"
inputProps={{
required: true,
}}
error={inputErrors.description}
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="Service URLs"
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
placeholder: 'example1.com\nexample2.onion\nexample3.b32.i2p',
class: 'md:min-h-20 min-h-24 h-full',
required: true,
}}
class="flex flex-col self-stretch"
error={inputErrors.allServiceUrls}
/>
<InputTextArea
label="Contact Methods"
description={[
'One per line.',
`Accepts: ${contactMethodUrlTypes.map((type: any) => type.labelPlural).join(', ')}`,
].join('\n')}
name="contactMethods"
inputProps={{
placeholder: 'contact@example.com\nt.me/example\n+123 456 7890',
class: 'h-full',
}}
class="flex flex-col self-stretch"
error={inputErrors.contactMethods}
/>
</div> </div>
<form <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
method="POST" <InputTextArea
action={actions.admin.service.create} label="ToS URLs"
class="space-y-4 rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs" description="One per line. AI review uses the first working URL only."
enctype="multipart/form-data" name="tosUrls"
> inputProps={{
<div> placeholder: 'example.com/tos',
<label for="name" class="font-title mb-2 block text-sm text-green-500">name</label> required: true,
<input class: 'min-h-10',
transition:persist }}
type="text" error={inputErrors.tosUrls}
name="name" />
id="name"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
inputErrors.name && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.name.join(', ')}</p>
)
}
</div>
<div> <InputText
<label for="description" class="font-title mb-2 block text-sm text-green-500">description</label> label="Operating since"
<textarea name="operatingSince"
transition:persist inputProps={{
name="description" type: 'date',
id="description" max: new Date().toISOString().slice(0, 10),
required }}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" error={inputErrors.operatingSince}
set:text="" />
/> </div>
{
inputErrors.description && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.description.join(', ')}</p>
)
}
</div>
<div> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label> <InputText
<textarea label="Registered Company Name"
transition:persist name="registeredCompanyName"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" description="Official name of the registered company (optional)"
name="allServiceUrls" inputProps={{
id="allServiceUrls" placeholder: 'e.g. Example Corp Ltd.',
rows={3} }}
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p" />
set:text=""
/>
{
inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
)
}
</div>
<div> <InputSelect
<label for="tosUrls" class="font-title mb-2 block text-sm text-green-500">tosUrls</label> name="registrationCountryCode"
<textarea label="Company Registration Country"
transition:persist description="Country where the service company is legally registered (optional)"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" options={[
name="tosUrls" { label: 'Not registered', value: '' },
id="tosUrls" ...countries
rows={3} .sort((a: any, b: any) => a.name.localeCompare(b.name))
placeholder="https://example1.com/tos https://example2.com/tos" .map((country: any) => ({
set:text="" label: `${country.flag} ${country.name}`,
/> value: country.code,
{ }))
inputErrors.tosUrls && ( ]}
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.tosUrls.join(', ')}</p> selectedValue=""
) />
} </div>
</div>
<div> <InputCardGroup
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label> name="kycLevel"
<div class="space-y-2"> label="KYC Level"
<input options={kycLevels.map((kycLevel: any) => ({
transition:persist label: kycLevel.name,
type="file" value: kycLevel.id.toString(),
name="imageFile" icon: kycLevel.icon,
id="imageFile" description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/4_`,
accept="image/*" }))}
required iconSize="md"
class="font-title file:font-title block w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 file:mr-3 file:rounded-md file:border-0 file:bg-green-500/30 file:px-3 file:py-1 file:text-gray-300 focus:border-green-500 focus:ring-green-500" cardSize="md"
/> required
<p class="font-title text-xs text-gray-400"> error={inputErrors.kycLevel}
Upload a square image for best results. Supported formats: JPG, PNG, WebP, SVG. />
</p>
</div>
{
inputErrors.imageFile && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.imageFile.join(', ')}</p>
)
}
</div>
<div> <InputCardGroup
<label class="font-title mb-2 block text-sm text-green-500" for="categories">categories</label> name="kycLevelClarification"
<div class="mt-2 grid grid-cols-2 gap-2"> label="KYC Level Clarification"
{ options={kycLevelClarifications.map((clarification: any) => ({
categories?.map((category) => ( label: clarification.label,
<label class="inline-flex items-center"> value: clarification.value,
<input icon: clarification.icon,
transition:persist description: clarification.description,
type="checkbox" }))}
name="categories" selectedValue="NONE"
value={category.id} iconSize="sm"
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black" cardSize="sm"
/> error={inputErrors.kycLevelClarification}
<span class="font-title ml-2 flex items-center gap-2 text-gray-300"> />
<Icon class="h-3 w-3 text-green-500" name={category.icon} />
{category.name}
</span>
</label>
))
}
</div>
{
inputErrors.categories && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.categories.join(', ')}</p>
)
}
</div>
<div> <div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
<label for="kycLevel" class="font-title mb-2 block text-sm text-green-500">kycLevel</label> <InputCheckboxGroup
<input name="categories"
transition:persist label="Categories"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" required
type="number" options={categories.map((category: any) => ({
name="kycLevel" label: category.name,
id="kycLevel" value: category.id.toString(),
min={0} icon: category.icon,
max={4} }))}
value={4} size="lg"
required error={inputErrors.categories}
/> class="min-w-auto"
{ />
inputErrors.kycLevel && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.kycLevel.join(', ')}</p>
)
}
</div>
<div> <InputCheckboxGroup
<label class="font-title mb-2 block text-sm text-green-500" for="attributes">attributes</label> name="attributes"
<div class="space-y-4"> label="Attributes"
{ options={orderBy(
Object.values(AttributeCategory).map((category) => ( attributes.map((attribute: any) => ({
<div class="rounded-md border border-green-500/20 bg-black/30 p-4"> ...attribute,
<h4 class="font-title mb-3 text-green-400">{category}</h4> categoryInfo: getAttributeCategoryInfo(attribute.category),
<div class="grid grid-cols-1 gap-2"> typeInfo: getAttributeTypeInfo(attribute.type),
{attributes })),
?.filter((attr) => attr.category === category) ['categoryInfo.order', 'typeInfo.order']
.map((attr) => ( ).map((attribute: any) => ({
<label class="inline-flex items-center"> label: attribute.title,
<input value: attribute.id.toString(),
transition:persist icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
type="checkbox" iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
name="attributes" }))}
value={attr.id} description="See list of [all attributes](/attributes) and their scoring."
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black" error={inputErrors.attributes}
/> size="lg"
<span class="font-title ml-2 flex items-center gap-2 text-gray-300"> />
{attr.title} </div>
<span
class={cn('font-title rounded-sm px-1.5 py-0.5 text-xs', {
'border border-green-500/50 bg-green-500/20 text-green-400':
attr.type === 'GOOD',
'border border-red-500/50 bg-red-500/20 text-red-400': attr.type === 'BAD',
'border border-yellow-500/50 bg-yellow-500/20 text-yellow-400':
attr.type === 'WARNING',
'border border-blue-500/50 bg-blue-500/20 text-blue-400': attr.type === 'INFO',
})}
>
{attr.type}
</span>
</span>
</label>
))}
</div>
{inputErrors.attributes && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.attributes.join(', ')}</p>
)}
</div>
))
}
</div>
</div>
<div> <InputCardGroup
<label class="font-title mb-2 block text-sm text-green-500" for="verificationStatus" name="acceptedCurrencies"
>verificationStatus</label label="Accepted Currencies"
> options={currencies.map((currency: any) => ({
<select label: currency.name,
transition:persist value: currency.id,
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500" icon: currency.icon,
name="verificationStatus" }))}
id="verificationStatus" error={inputErrors.acceptedCurrencies}
required multiple
> />
{Object.values(VerificationStatus).map((status) => <option value={status}>{status}</option>)}
</select>
{
inputErrors.verificationStatus && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationStatus.join(', ')}</p>
)
}
</div>
<div> <InputImageFile
<label class="font-title mb-2 block text-sm text-green-500" for="verificationSummary" label="Service Image"
>verificationSummary</label name="imageFile"
> description="Square image. At least 192x192px. Transparency supported."
<textarea error={inputErrors.imageFile}
transition:persist square
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" required
name="verificationSummary" />
id="verificationSummary"
rows={3}
set:text=""
/>
{
inputErrors.verificationSummary && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationSummary.join(', ')}</p>
)
}
</div>
<div> <InputSelect
<label class="font-title mb-2 block text-sm text-green-500" for="verificationProofMd" name="verificationStatus"
>verificationProofMd</label label="Verification Status"
> options={Object.values(VerificationStatus).map((status) => ({
<textarea label: status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l: string) => l.toUpperCase()),
transition:persist value: status,
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" }))}
name="verificationProofMd" error={inputErrors.verificationStatus}
id="verificationProofMd" />
rows={10}
set:text=""
/>
{
inputErrors.verificationProofMd && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationProofMd.join(', ')}</p>
)
}
</div>
<div> <InputTextArea
<label class="font-title mb-2 block text-sm text-green-500" for="acceptedCurrencies" label="Verification Summary"
>acceptedCurrencies</label name="verificationSummary"
> error={inputErrors.verificationSummary}
<div class="mt-2 grid grid-cols-2 gap-2"> />
{
Object.values(Currency).map((currency) => (
<label class="inline-flex items-center">
<input
transition:persist
type="checkbox"
name="acceptedCurrencies"
value={currency}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title ml-2 text-gray-300">{currency}</span>
</label>
))
}
</div>
{
inputErrors.acceptedCurrencies && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.acceptedCurrencies.join(', ')}</p>
)
}
</div>
<div> <InputTextArea
<label class="font-title mb-2 block text-sm text-green-500" for="overallScore">overallScore</label> label="Verification Proof (Markdown)"
<input name="verificationProofMd"
transition:persist inputProps={{
type="number" rows: 10,
name="overallScore" }}
id="overallScore" error={inputErrors.verificationProofMd}
value={0} />
min={0}
max={10}
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
/>
{
inputErrors.overallScore && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.overallScore.join(', ')}</p>
)
}
</div>
<div> <InputText
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral link path</label> label="Referral Link Path"
<input name="referral"
transition:persist inputProps={{
type="text" placeholder: 'e.g. ?ref=123 or /ref/123',
name="referral" }}
id="referral" error={inputErrors.referral}
placeholder="e.g. ?ref=123 or /ref/123" description="Will be appended to the service URL"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" />
/>
{
inputErrors.referral && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.referral.join(', ')}</p>
)
}
</div>
<div> <InputCardGroup
<label for="internalNote" class="font-title mb-2 block text-sm text-green-500">internalNote</label> name="serviceVisibility"
<div class="space-y-2"> label="Service Visibility"
<textarea options={serviceVisibilities.map((visibility: any) => ({
transition:persist label: visibility.label,
name="internalNote" value: visibility.value,
id="internalNote" icon: visibility.icon,
rows={4} iconClass: visibility.iconClass,
placeholder="Markdown supported" description: visibility.description,
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" }))}
set:text="" selectedValue="PUBLIC"
/> error={inputErrors.serviceVisibility}
</div> cardSize="sm"
{ />
inputErrors.internalNote && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.internalNote.join(', ')}</p>
)
}
</div>
<button <InputTextArea
type="submit" label="Internal Note"
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" name="internalNote"
> description="Markdown supported. Internal notes for admins."
Create Service inputProps={{
</button> rows: 4,
</form> }}
</section> error={inputErrors.internalNote}
/>
<InputCheckbox
label="Strict Commenting"
name="strictCommentingEnabled"
checked={false}
descriptionInline="Require proof of being a client for comments."
error={inputErrors.strictCommentingEnabled}
/>
<InputTextArea
label="Comment Section Message"
name="commentSectionMessage"
description="Markdown supported. This message will be displayed in the comment section for root comments."
inputProps={{
rows: 4,
}}
error={inputErrors.commentSectionMessage}
/>
<InputSubmitButton label="Create Service" icon="ri:add-line" hideCancel />
</form>
</FormSection>
</div>
</BaseLayout> </BaseLayout>

View File

@@ -461,6 +461,25 @@ if (!user) return Astro.rewrite('/404')
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel /> <InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
</form> </form>
</FormSection> </FormSection>
<FormSection title="Delete User">
{
user.admin || user.moderator ? (
<p class="text-center text-balance">This user can't be deleted</p>
) : (
<p class="text-center text-balance">
To delete this user,
<a
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account/delete`}
data-astro-prefetch="tap"
class="underline"
>
impersonate it and go to /account/delete
</a>
</p>
)
}
</FormSection>
</BaseLayout> </BaseLayout>
<script> <script>

View File

@@ -319,7 +319,7 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<span <span
class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`} class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`}
> >
{user.totalKarma} {user.totalKarma.toLocaleString()}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center text-sm"> <td class="px-4 py-3 text-center text-sm">

View File

@@ -62,7 +62,7 @@ type ServiceResponse = {
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ... kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
kycLevelClarificationInfo: { kycLevelClarificationInfo: {
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ... value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
name: string label: string
description: string description: string
} }
categories: { categories: {
@@ -135,8 +135,10 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
```json ```json
{ {
"id": 123,
"name": "My Example Service", "name": "My Example Service",
"description": "This is a description of my example service", "description": "This is a description of my example service",
"slug": "my-example-service",
"serviceVisibility": "PUBLIC", "serviceVisibility": "PUBLIC",
"verificationStatus": "VERIFICATION_SUCCESS", "verificationStatus": "VERIFICATION_SUCCESS",
"verificationStatusInfo": { "verificationStatusInfo": {
@@ -157,6 +159,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"kycLevelClarification": "NONE", "kycLevelClarification": "NONE",
"kycLevelClarificationInfo": { "kycLevelClarificationInfo": {
"value": "NONE", "value": "NONE",
"label": "None",
"description": "No clarification needed." "description": "No clarification needed."
}, },
"categories": [ "categories": [

View File

@@ -15,55 +15,58 @@ import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
There are several ways to earn karma points: There are several ways to earn karma points:
1. **Comment Approval** (+1 point) 1. **Comment Approval** (+1 point)
- When your comment moves from 'unmoderated' to 'approved' status.
- When your comment moves from 'unmoderated' to 'approved' status - This is the basic reward for contributing a valid comment.
- This is the basic reward for contributing a valid comment - Users related to the service (e.g. owners, admins, etc.) do not get karma for their comments.
2. **Comment Verification** (+5 points) 2. **Comment Verification** (+5 points)
- When your comment is marked as 'verified'.
- When your comment is marked as 'verified' - This is a significant reward for providing particularly valuable or verified information.
- This is a significant reward for providing particularly valuable or verified information
3. **Upvotes** 3. **Upvotes**
- Each upvote on your comment adds +1 to your karma - Each upvote on your comment adds +1 to your karma.
- Similarly, each downvote reduces your karma by -1 - Similarly, each downvote reduces your karma by -1.
- This allows the community to reward helpful contributions - This allows the community to reward helpful contributions.
4. **Suggestion Approval** (+10 points)
- When your suggestion to add or edit a service is approved and the service is listed publicly.
- Suggestions on non-listed services do not earn karma.
- This rewards users for helping to expand and improve the service directory.
## Karma Penalties ## Karma Penalties
The system also includes penalties to discourage spam and low-quality content: The system also includes penalties to discourage spam and low-quality content:
1. **Spam Detection** (-10 points) 1. **Spam Detection** (-10 points)
- If your comment is marked as suspicious/spam - If your comment is marked as suspicious/spam.
- This is a significant penalty to discourage spam behavior - This is a significant penalty to discourage spam behavior.
- If the spam mark is removed, the 10 points are restored - If the spam mark is removed, the 10 points are restored.
## Karma Tracking ## Karma Tracking
The system maintains a detailed record of all karma changes through: The system maintains a detailed record of all karma changes through:
1. **Karma Transactions** 1. **Karma Transactions**
- Every karma change is recorded as a transaction.
- Every karma change is recorded as a transaction
- Each transaction includes: - Each transaction includes:
- The action that triggered it - The action that triggered it.
- The number of points awarded/deducted - The number of points awarded/deducted.
- A description of why the karma changed - A description of why the karma changed.
- The related comment (if applicable) - The related comment (if applicable).
2. **Total Karma** 2. **Total Karma**
- Your total karma is displayed on your profile - Your total karma is displayed on your profile.
- It's the sum of all your karma transactions - It's the sum of all your karma transactions.
- This score helps establish your reputation in the community - This score helps establish your reputation in the community.
## Impact of Karma ## Impact of Karma
Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate: Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate:
- Active participation in the community - Active participation in the community.
- History of providing valuable information - History of providing valuable information.
- Trustworthiness of your contributions - Trustworthiness of your contributions.
- Commitment to community standards - Commitment to community standards.
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform. The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.

View File

@@ -9,11 +9,15 @@ icon: 'ri:image-line'
import PressAssets from '../components/PressAssets.astro' import PressAssets from '../components/PressAssets.astro'
## How you can use our logo?
**You are not allowed to list us as partners**. We don't accept partnerships. You should make sure it's clear that you're adding our logo **voluntarily**. Examples of correct use: "Rate us on", "Listed on" sections.
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly. Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
<PressAssets /> <PressAssets />
Review service link format: `https://kycnot.me/service/[slug]/review` > You can link to a service's review section with: `https://kycnot.me/service/[slug]/review`
## Brand design ## Brand design

View File

@@ -6,20 +6,12 @@ import seedrandom from 'seedrandom'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import InputText from '../components/InputText.astro' import InputText from '../components/InputText.astro'
import Pagination from '../components/Pagination.astro' import Pagination from '../components/Pagination.astro'
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro' import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.astro'
import ServicesFilters from '../components/ServicesFilters.astro' import ServicesFilters from '../components/ServicesFilters.astro'
import ServicesSearchResults from '../components/ServicesSearchResults.astro' import ServicesSearchResults from '../components/ServicesSearchResults.astro'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { networks } from '../constants/networks'
import { import {
currencies,
currenciesZodEnumBySlug,
currencySlugToId,
getCurrencyInfo,
} from '../constants/currencies'
import { getNetworkInfo, networks } from '../constants/networks'
import {
getVerificationStatusInfo,
verificationStatuses, verificationStatuses,
verificationStatusesZodEnumBySlug, verificationStatusesZodEnumBySlug,
verificationStatusSlugToId, verificationStatusSlugToId,
@@ -31,107 +23,41 @@ import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects' import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import {
defaultSortOption,
makeSearchFiltersOptions,
modeOptions,
sortOptions,
} from '../lib/searchFiltersOptions'
import { makeSortSeed } from '../lib/sortSeed' import { makeSortSeed } from '../lib/sortSeed'
import { transformCase } from '../lib/strings'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
const MIN_CATEGORIES_TO_SHOW = 8
const MIN_ATTRIBUTES_TO_SHOW = 8
const PAGE_SIZE = 30 const PAGE_SIZE = 30
const sortOptions = [ export type AttributeOption = {
{
value: 'score-desc',
label: 'Score (High → Low)',
orderBy: {
key: 'overallScore',
direction: 'desc',
},
},
{
value: 'score-asc',
label: 'Score (Low → High)',
orderBy: {
key: 'overallScore',
direction: 'asc',
},
},
{
value: 'name-asc',
label: 'Name (A → Z)',
orderBy: {
key: 'name',
direction: 'asc',
},
},
{
value: 'name-desc',
label: 'Name (Z → A)',
orderBy: {
key: 'name',
direction: 'desc',
},
},
{
value: 'recent',
label: 'Date listed (New → Old)',
orderBy: {
key: 'listedAt',
direction: 'desc',
},
},
{
value: 'oldest',
label: 'Date listed (Old → New)',
orderBy: {
key: 'listedAt',
direction: 'asc',
},
},
] as const satisfies {
value: string value: string
label: string prefix: string
orderBy: { prefixWith: string
key: keyof Prisma.ServiceSelect }
direction: 'asc' | 'desc'
}
}[]
const defaultSortOption = sortOptions[0]
const modeOptions = [
{
value: 'or',
label: 'OR',
},
{
value: 'and',
label: 'AND',
},
] as const satisfies {
value: string
label: string
}[]
const attributeOptions = [ const attributeOptions = [
{ {
value: 'yes', value: 'yes',
prefix: 'Has', prefix: 'Has',
prefixWith: 'with',
}, },
{ {
value: 'no', value: 'no',
prefix: 'Not', prefix: 'Not',
prefixWith: 'without',
}, },
{ {
value: '', value: '',
prefix: '', prefix: '',
prefixWith: '',
}, },
] as const satisfies { ] as const satisfies AttributeOption[]
value: string
prefix: string
}[]
const ignoredKeysForDefaultData = ['sort-seed'] const ignoredKeysForDefaultData = ['sort-seed']
@@ -309,6 +235,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
prisma.category.findMany({ prisma.category.findMany({
select: { select: {
name: true, name: true,
namePluralLong: true,
slug: true, slug: true,
icon: true, icon: true,
_count: { _count: {
@@ -322,6 +249,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
}, },
}, },
}), }),
[],
], ],
[ [
'Unable to load services.', 'Unable to load services.',
@@ -477,62 +405,11 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
], ],
]) ])
const attributesByCategory = orderBy( const filtersOptions = makeSearchFiltersOptions({
Object.entries( filters,
groupBy( categories,
attributes.map((attr) => { attributes,
return { })
typeInfo: getAttributeTypeInfo(attr.type),
...attr,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
value: filters.attr?.[attr.id] || undefined,
}
}),
'category'
)
).map(([category, attributes]) => ({
category,
categoryInfo: getAttributeCategoryInfo(category),
attributes: orderBy(
attributes,
['value', 'type', '_count.services', 'title'],
['asc', 'asc', 'desc', 'asc']
).map((attr, i) => ({
...attr,
showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined,
})),
})),
['category'],
['asc']
)
const categoriesSorted = orderBy(
categories?.map((category) => {
const checked = filters.categories.includes(category.slug)
return {
...category,
checked,
}
}),
['checked', '_count.services', 'name'],
['desc', 'desc', 'asc']
).map((category, i) => ({
...category,
showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked,
}))
const filtersOptions = {
currencies,
categories: categoriesSorted,
sort: sortOptions,
modeOptions,
network: networks,
verification: verificationStatuses,
attributesByCategory,
} as const
export type ServicesFiltersOptions = typeof filtersOptions
const searchResultsId = 'search-results' const searchResultsId = 'search-results'
const showFiltersId = 'show-filters' const showFiltersId = 'show-filters'
@@ -584,114 +461,14 @@ const showFiltersId = 'show-filters'
/> />
</form> </form>
) : ( ) : (
<div class="-ml-4 flex flex-1 items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4"> <ServiceFiltersPillsRow
{filters.q && ( filters={filters}
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} /> filtersOptions={filtersOptions}
)} categories={categories}
attributes={attributes}
{!areEqualArraysWithoutOrder( attributeOptions={attributeOptions}
filters.verification, inlineIcons={false}
filtersOptions.verification />
.filter((verification) => verification.default)
.map((verification) => verification.value)
) &&
filters.verification.map((verificationStatus) => {
const verificationStatusInfo = getVerificationStatusInfo(verificationStatus)
return (
<ServiceFiltersPill
text={verificationStatusInfo.label}
icon={verificationStatusInfo.icon}
iconClass={verificationStatusInfo.classNames.icon}
searchParamName="verification"
searchParamValue={verificationStatusInfo.slug}
/>
)
})}
{filters.categories.map((categorySlug) => {
const category = categories?.find((c) => c.slug === categorySlug)
if (!category) return null
return (
<ServiceFiltersPill
text={category.name}
icon={category.icon}
searchParamName="categories"
searchParamValue={categorySlug}
/>
)
})}
{filters.currencies.map((currencyId) => {
const currency = getCurrencyInfo(currencyId)
return (
<ServiceFiltersPill
text={currency.name}
searchParamName="currencies"
searchParamValue={currency.slug}
icon={currency.icon}
/>
)
})}
{filters.networks.map((network) => {
const networkOption = getNetworkInfo(network)
return (
<ServiceFiltersPill
text={networkOption.name}
icon={networkOption.icon}
searchParamName="networks"
searchParamValue={network}
/>
)
})}
{filters['max-kyc'] < 4 && (
<ServiceFiltersPill
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
icon="ri:shield-keyhole-line"
searchParamName="max-kyc"
/>
)}
{filters['user-rating'] > 0 && (
<ServiceFiltersPill
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
icon="ri:star-fill"
searchParamName="user-rating"
/>
)}
{filters['min-score'] > 0 && (
<ServiceFiltersPill
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
icon="ri:medal-line"
searchParamName="min-score"
/>
)}
{filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && (
<ServiceFiltersPill
text="Attributes: AND"
icon="ri:filter-3-line"
searchParamName="attribute-mode"
searchParamValue="and"
/>
)}
{filters.attr &&
Object.entries(filters.attr)
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
.map(([attributeId, attributeValue]) => {
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
if (!attribute) return null
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
return (
<ServiceFiltersPill
text={`${prefix}: ${attribute.title}`}
searchParamName={`attr-${attributeId}`}
searchParamValue={attributeValue}
/>
)
})}
</div>
) )
} }
@@ -716,6 +493,7 @@ const showFiltersId = 'show-filters'
/> />
<div <div
class="bg-night-700 fixed top-0 left-0 z-50 hidden h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 max-sm:peer-checked:block sm:relative sm:z-auto sm:block sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0" class="bg-night-700 fixed top-0 left-0 z-50 hidden h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 max-sm:peer-checked:block sm:relative sm:z-auto sm:block sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0"
aria-label="Search filters"
> >
<ServicesFilters <ServicesFilters
searchResultsId={searchResultsId} searchResultsId={searchResultsId}
@@ -739,6 +517,11 @@ const showFiltersId = 'show-filters'
filters={filters} filters={filters}
countCommunityOnly={countCommunityOnly} countCommunityOnly={countCommunityOnly}
inlineIcons inlineIcons
categories={categories}
filtersOptions={filtersOptions}
attributes={attributes}
attributeOptions={attributeOptions}
aria-label="Search results"
/> />
</div> </div>
{ {

View File

@@ -5,10 +5,12 @@ import { actions } from 'astro:actions'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import CopyButton from '../components/CopyButton.astro' import CopyButton from '../components/CopyButton.astro'
import Pagination from '../components/Pagination.astro'
import PushNotificationBanner from '../components/PushNotificationBanner.astro' import PushNotificationBanner from '../components/PushNotificationBanner.astro'
import TimeFormatted from '../components/TimeFormatted.astro' import TimeFormatted from '../components/TimeFormatted.astro'
import Tooltip from '../components/Tooltip.astro' import Tooltip from '../components/Tooltip.astro'
import { getNotificationTypeInfo } from '../constants/notificationTypes' import { getNotificationTypeInfo } from '../constants/notificationTypes'
import { getReadStatus, readStatusZodEnum } from '../constants/readStatus'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences' import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
@@ -16,6 +18,10 @@ import { makeNotificationActions, makeNotificationContent, makeNotificationTitle
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { makeLoginUrl } from '../lib/redirectUrls' import { makeLoginUrl } from '../lib/redirectUrls'
import { transformCase } from '../lib/strings'
import { urlWithParams } from '../lib/urls'
import type { Prisma } from '@prisma/client'
const user = Astro.locals.user const user = Astro.locals.user
if (!user) return Astro.redirect(makeLoginUrl(Astro.url)) if (!user) return Astro.redirect(makeLoginUrl(Astro.url))
@@ -25,20 +31,26 @@ const PAGE_SIZE = 20
const { data: params } = zodParseQueryParamsStoringErrors( const { data: params } = zodParseQueryParamsStoringErrors(
{ {
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
readStatus: readStatusZodEnum.optional(),
}, },
Astro Astro
) )
const skip = (params.page - 1) * PAGE_SIZE const skip = (params.page - 1) * PAGE_SIZE
const readStatusInfo = params.readStatus ? getReadStatus(params.readStatus) : undefined
const notificationWhereClause: Prisma.NotificationWhereInput = {
userId: user.id,
read: readStatusInfo?.readValue,
}
const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] = const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] =
await Astro.locals.banners.tryMany([ await Astro.locals.banners.tryMany([
[ [
'Error while fetching notifications', 'Error while fetching notifications',
() => () =>
prisma.notification.findMany({ prisma.notification.findMany({
where: { where: notificationWhereClause,
userId: user.id,
},
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },
@@ -153,7 +165,7 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
], ],
[ [
'Error while fetching total notifications', 'Error while fetching total notifications',
() => prisma.notification.count({ where: { userId: user.id } }), () => prisma.notification.count({ where: notificationWhereClause }),
0, 0,
], ],
[ [
@@ -227,13 +239,18 @@ const notifications = dbNotifications.map((notification) => ({
) )
} }
<div class="mb-4 flex items-center justify-between"> <div class="xs:flex-row xs:flex-wrap mb-4 flex flex-col items-center justify-between gap-2">
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider"> <h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" /> <Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
Notifications
{transformCase(`${readStatusInfo?.label ?? ''} notifications`.trim(), 'sentence')}
</h1> </h1>
<form method="POST" action={actions.notification.updateReadStatus}> <form
method="POST"
action={actions.notification.updateReadStatus}
class="xs:justify-start flex flex-1 flex-row-reverse flex-wrap items-center justify-center gap-2"
>
<input type="hidden" name="notificationId" value="all" /> <input type="hidden" name="notificationId" value="all" />
<input type="hidden" name="read" value="true" /> <input type="hidden" name="read" value="true" />
<Button <Button
@@ -243,6 +260,25 @@ const notifications = dbNotifications.map((notification) => ({
disabled={notifications.length === 0} disabled={notifications.length === 0}
color="white" color="white"
/> />
{
params.readStatus === 'unread' ? (
<Button
as="a"
href={urlWithParams(Astro.url, { readStatus: null }, { clearExisting: true })}
label="See all"
icon="ri:eye-line"
color="black"
/>
) : (
<Button
as="a"
href={urlWithParams(Astro.url, { readStatus: 'unread' }, { clearExisting: true })}
label="See unread only"
icon="ri:eye-off-line"
color="black"
/>
)
}
</form> </form>
</div> </div>
@@ -323,31 +359,12 @@ const notifications = dbNotifications.map((notification) => ({
))} ))}
{totalPages > 1 && ( {totalPages > 1 && (
<div class="mt-8 flex justify-center gap-4"> <Pagination
<form method="GET" action="/notifications" class="inline"> currentPage={params.page}
<input type="hidden" name="page" value={params.page - 1} /> totalPages={totalPages}
<button currentUrl={Astro.url}
type="submit" class="mt-8"
class="rounded-md border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors duration-200 hover:bg-zinc-700" />
disabled={params.page <= 1}
>
Previous
</button>
</form>
<span class="inline-flex items-center px-2 text-sm text-zinc-400">
Page {params.page} of {totalPages}
</span>
<form method="GET" action="/notifications" class="inline">
<input type="hidden" name="page" value={params.page + 1} />
<button
type="submit"
class="rounded-md border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors duration-200 hover:bg-zinc-700"
disabled={params.page >= totalPages}
>
Next
</button>
</form>
</div>
)} )}
</div> </div>
) )

View File

@@ -15,12 +15,14 @@ import InputCheckbox from '../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro' import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro' import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputImageFile from '../../components/InputImageFile.astro' import InputImageFile from '../../components/InputImageFile.astro'
import InputSelect from '../../components/InputSelect.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro' import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro' import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro' import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories' import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes' import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { contactMethodUrlTypes } from '../../constants/contactMethods' import { contactMethodUrlTypes } from '../../constants/contactMethods'
import { countries } from '../../constants/countries'
import { currencies } from '../../constants/currencies' import { currencies } from '../../constants/currencies'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications' import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
import { kycLevels } from '../../constants/kycLevels' import { kycLevels } from '../../constants/kycLevels'
@@ -238,17 +240,56 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
/> />
</div> </div>
<InputTextArea <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="ToS URLs" <InputTextArea
description="One per line. AI review uses the first working URL only." label="ToS URLs"
name="tosUrls" description="One per line. AI review uses the first working URL only."
inputProps={{ name="tosUrls"
placeholder: 'example.com/tos', inputProps={{
required: true, placeholder: 'example.com/tos',
class: 'min-h-10', required: true,
}} class: 'min-h-10',
error={inputErrors.tosUrls} }}
/> error={inputErrors.tosUrls}
/>
<InputText
label="Operating since"
name="operatingSince"
inputProps={{
type: 'date',
max: new Date().toISOString().slice(0, 10),
}}
error={inputErrors.operatingSince}
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputText
label="Registered Company Name"
name="registeredCompanyName"
description="Official name of the registered company (optional)"
inputProps={{
placeholder: 'e.g. Example Corp Ltd.',
}}
/>
<InputSelect
name="registrationCountryCode"
label="Company Registration Country"
description="Country where the service company is legally registered (optional)"
options={[
{ label: 'Not registered', value: '' },
...countries
.sort((a, b) => a.name.localeCompare(b.name))
.map((country) => ({
label: `${country.flag} ${country.name}`,
value: country.code,
}))
]}
selectedValue=""
/>
</div>
<InputCardGroup <InputCardGroup
name="kycLevel" name="kycLevel"
@@ -311,6 +352,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon], icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon], iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}))} }))}
description="See list of [all attributes](/attributes) and their scoring."
error={inputErrors.attributes} error={inputErrors.attributes}
size="lg" size="lg"
/> />

View File

@@ -95,12 +95,17 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
approvedAt: true, approvedAt: true,
createdAt: true, createdAt: true,
acceptedCurrencies: true, acceptedCurrencies: true,
operatingSince: true,
registrationCountryCode: true,
registeredCompanyName: true,
tosReview: true, tosReview: true,
tosReviewAt: true, tosReviewAt: true,
userSentiment: true, userSentiment: true,
userSentimentAt: true, userSentimentAt: true,
averageUserRating: true, averageUserRating: true,
isRecentlyApproved: true, isRecentlyApproved: true,
strictCommentingEnabled: true,
commentSectionMessage: true,
contactMethods: { contactMethods: {
select: { select: {
value: true, value: true,
@@ -293,8 +298,6 @@ const statusIcon = {
APPROVED: undefined, APPROVED: undefined,
}[service.verificationStatus] }[service.verificationStatus]
const isScam = service.verificationStatus === 'VERIFICATION_FAILED'
const shuffledLinks = { const shuffledLinks = {
clearnet: shuffle(service.serviceUrls), clearnet: shuffle(service.serviceUrls),
onion: shuffle(service.onionUrls), onion: shuffle(service.onionUrls),
@@ -411,6 +414,7 @@ const ogImageTemplateData = {
categories: service.categories.map((category) => pick(category, ['name', 'icon'])), categories: service.categories.map((category) => pick(category, ['name', 'icon'])),
score: service.overallScore, score: service.overallScore,
imageUrl: service.imageUrl, imageUrl: service.imageUrl,
verificationStatus: service.verificationStatus,
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility) const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
@@ -423,6 +427,30 @@ const activeAlertOrWarningEvents = service.events
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now)) .filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
const activeEventToShow = const activeEventToShow =
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0] activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
// Sort verification steps: failed first, then warnings, then others, newest first within each group
const getVerificationStepPriority = (status: VerificationStepStatus) => {
switch (status) {
case VerificationStepStatus.FAILED:
return 0 // Highest priority
case VerificationStepStatus.WARNING:
return 1
case VerificationStepStatus.IN_PROGRESS:
return 2
case VerificationStepStatus.PENDING:
return 3
case VerificationStepStatus.PASSED:
return 4 // Lowest priority
default:
return 5
}
}
const sortedVerificationSteps = orderBy(
service.verificationSteps,
[(step) => getVerificationStepPriority(step.status), (step) => step.updatedAt],
['asc', 'desc'] // Priority ascending (failed first), date descending (newest first)
)
--- ---
<BaseLayout <BaseLayout
@@ -764,18 +792,12 @@ const activeEventToShow =
<ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2"> <ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2">
{shownLinks.map((url) => ( {shownLinks.map((url) => (
<li> <li>
{isScam ? ( <ServiceLinkButton
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400"> url={url}
<Icon name="ri:alert-line" class="size-4 text-red-400" /> referral={service.referral}
{urlDomain(url)} enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
</span> isScam={service.verificationStatus === 'VERIFICATION_FAILED'}
) : ( />
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
</li> </li>
))} ))}
@@ -799,18 +821,12 @@ const activeEventToShow =
{hiddenLinks.map((url) => ( {hiddenLinks.map((url) => (
<li class="hidden peer-checked:block"> <li class="hidden peer-checked:block">
{isScam ? ( <ServiceLinkButton
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400"> url={url}
<Icon name="ri:alert-line" class="size-4 text-red-400" /> referral={service.referral}
{urlDomain(url)} enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
</span> isScam={service.verificationStatus === 'VERIFICATION_FAILED'}
) : ( />
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
</li> </li>
))} ))}
</ul> </ul>
@@ -901,13 +917,34 @@ const activeEventToShow =
</h2> </h2>
<div class="xs:gap-8 flex flex-row flex-wrap items-stretch justify-center gap-4 sm:justify-between"> <div class="xs:gap-8 flex flex-row flex-wrap items-stretch justify-center gap-4 sm:justify-between">
<div class="flex max-w-84 flex-grow flex-row items-center justify-evenly gap-4"> <div class="flex max-w-84 flex-grow flex-row items-center justify-evenly gap-4">
<ScoreSquare score={service.overallScore} label="Overall" itemReviewedId={itemReviewedId} /> <ScoreSquare
as="a"
href="/about#service-scores"
score={service.overallScore}
label="Overall"
itemReviewedId={itemReviewedId}
showInfo
/>
<div class="bg-day-800 h-12 w-px flex-shrink-0 self-center"></div> <div class="bg-day-800 h-12 w-px flex-shrink-0 self-center"></div>
<ScoreGauge score={service.privacyScore} label="Privacy" itemReviewedId={itemReviewedId} /> <ScoreGauge
as="a"
href="/about#service-scores"
score={service.privacyScore}
label="Privacy"
itemReviewedId={itemReviewedId}
showInfo
/>
<ScoreGauge score={service.trustScore} label="Trust" itemReviewedId={itemReviewedId} /> <ScoreGauge
as="a"
href="/about#service-scores"
score={service.trustScore}
label="Trust"
itemReviewedId={itemReviewedId}
showInfo
/>
</div> </div>
<div <div
class="@container flex max-w-112 flex-shrink-0 flex-grow-100 basis-64 flex-row items-start rounded-lg bg-white px-3 py-2 text-black" class="@container flex max-w-112 flex-shrink-0 flex-grow-100 basis-64 flex-row items-start rounded-lg bg-white px-3 py-2 text-black"
@@ -1452,11 +1489,11 @@ const activeEventToShow =
) )
} }
{ {
service.verificationSteps.length > 0 && ( sortedVerificationSteps.length > 0 && (
<> <>
<h3 class="font-title text-md mt-6 mb-2 font-semibold">Verification Steps</h3> <h3 class="font-title text-md mt-6 mb-2 font-semibold">Verification Steps</h3>
<ul class="mb-8 space-y-2"> <ul class="mb-8 space-y-2">
{service.verificationSteps.map((step) => { {sortedVerificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status) const statusInfo = getVerificationStepStatusInfo(step.status)
return ( return (
@@ -1511,6 +1548,20 @@ const activeEventToShow =
<li>Moderation is light.</li> <li>Moderation is light.</li>
<li>Double-check before trusting.</li> <li>Double-check before trusting.</li>
</ul> </ul>
{
service.strictCommentingEnabled && (
<p class="mt-2">
<Icon
name="ri:verified-badge-fill"
class="me-0.5 inline-block size-4 align-[-0.3em] text-orange-100/95"
/>
<span class="font-medium text-orange-100/95">Proof of being a client required</span>, for this
service.
</p>
)
}
<div class="absolute inset-y-2 right-2 flex flex-col justify-center"> <div class="absolute inset-y-2 right-2 flex flex-col justify-center">
<Icon name="ri:alert-line" class="xs:opacity-20 h-full max-h-16 w-auto opacity-10" /> <Icon name="ri:alert-line" class="xs:opacity-20 h-full max-h-16 w-auto opacity-10" />
</div> </div>

View File

@@ -0,0 +1,190 @@
/* eslint-disable import/no-named-as-default-member */
import he from 'he'
import { prisma } from '../../lib/prisma'
import { makeSearchFiltersOptions } from '../../lib/searchFiltersOptions'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async ({ site }) => {
if (!site) return new Response('Site URL not configured', { status: 500 })
try {
const searchUrls = await generateSEOSitemapUrls(site.href)
const result = `
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${searchUrls.map((url) => `<url><loc>${he.encode(url)}</loc></url>`).join('\n')}
</urlset>
`.trim()
return new Response(result, {
headers: {
'Content-Type': 'application/xml',
},
})
} catch (error) {
console.error('Failed to generate SEO sitemap URLs:', error)
return new Response('Failed to generate SEO sitemap URLs', { status: 500 })
}
}
async function generateSEOSitemapUrls(siteUrl: string) {
const [categories, attributes] = await Promise.all([
prisma.category.findMany({
select: {
name: true,
namePluralLong: true,
slug: true,
icon: true,
_count: {
select: {
services: {
where: {
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
},
},
},
},
},
}),
prisma.attribute.findMany({
select: {
id: true,
slug: true,
title: true,
category: true,
type: true,
_count: {
select: {
services: {
where: {
service: {
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
},
},
},
},
},
},
orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
}),
])
const filtersOptions = makeSearchFiltersOptions({
filters: null,
categories,
attributes,
})
const byCategory = filtersOptions.categories.map(
(category) =>
new URLSearchParams({
categories: category.slug,
})
)
const byVerificationStatus = filtersOptions.verification.map(
(status) =>
new URLSearchParams({
verification: status.slug,
})
)
const byKycLevel = filtersOptions.kycLevels.map(
(level) =>
new URLSearchParams({
'max-kyc': level.id,
})
)
const byCurrency = filtersOptions.currencies.map(
(currency) =>
new URLSearchParams({
currencies: currency.slug,
})
)
const withOneAttribute = filtersOptions.attributesByCategory
.flatMap(({ attributes }) => attributes)
.map(
(attribute) =>
new URLSearchParams({
[`attr-${attribute.id.toString()}`]: 'yes',
})
)
const withoutOneAttribute = filtersOptions.attributesByCategory
.flatMap(({ attributes }) => attributes)
.map(
(attribute) =>
new URLSearchParams({
[`attr-${attribute.id.toString()}`]: 'no',
})
)
const byCategoryAndCurrency = filtersOptions.categories.flatMap((category) =>
filtersOptions.currencies.map(
(currency) =>
new URLSearchParams({
categories: category.slug,
currencies: currency.slug,
})
)
)
const byCategoryAndAttributes = filtersOptions.categories.flatMap((category) =>
filtersOptions.attributesByCategory
.flatMap(({ attributes }) => attributes)
.flatMap((attribute) => [
new URLSearchParams({
categories: category.slug,
[`attr-${attribute.id.toString()}`]: 'yes',
}),
new URLSearchParams({
categories: category.slug,
[`attr-${attribute.id.toString()}`]: 'no',
}),
])
)
const relevantCurrencies = [
'xmr',
'btc',
] as const satisfies (typeof filtersOptions.currencies)[number]['slug'][]
const byCategoryAndAttributesAndRelevantCurrency = filtersOptions.categories.flatMap((category) =>
filtersOptions.attributesByCategory
.flatMap(({ attributes }) => attributes)
.flatMap((attribute) =>
relevantCurrencies.map(
(currency) =>
new URLSearchParams({
categories: category.slug,
currencies: currency,
[`attr-${attribute.id.toString()}`]:
attribute.type === 'GOOD' || attribute.type === 'INFO' ? 'yes' : 'no',
})
)
)
)
const allQueryParams = [
...byCategory,
...byVerificationStatus,
...byKycLevel,
...byCurrency,
...withOneAttribute,
...withoutOneAttribute,
...byCategoryAndCurrency,
...byCategoryAndAttributes,
...byCategoryAndAttributesAndRelevantCurrency,
] satisfies URLSearchParams[]
return allQueryParams.map((queryParams) => {
const url = new URL(siteUrl)
url.search = queryParams.toString()
return url.href
})
}

View File

@@ -393,7 +393,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span> <span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span>
<div> <div>
<p class="text-day-500 text-xs">Karma</p> <p class="text-day-500 text-xs">Karma</p>
<p class="text-day-300">{user.totalKarma.toLocaleString()}</p> <p class="text-day-300">
{!user.admin && !user.moderator ? user.totalKarma.toLocaleString() : '∞'}
</p>
</div> </div>
</li> </li>
</ul> </ul>
@@ -575,187 +577,130 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</section> </section>
</AdminOnly> </AdminOnly>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> {
<header> !user.admin && !user.moderator && (
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<Icon name="ri:building-4-line" class="mr-2 inline-block size-5" /> <header>
Service Affiliations <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
</h2> <Icon name="ri:building-4-line" class="mr-2 inline-block size-5" />
</header> Service Affiliations
</h2>
</header>
{ {user.serviceAffiliations.length > 0 ? (
user.serviceAffiliations.length > 0 ? ( <ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4"> {user.serviceAffiliations.map((affiliation) => {
{user.serviceAffiliations.map((affiliation) => { const roleInfo = getServiceUserRoleInfo(affiliation.role)
const roleInfo = getServiceUserRoleInfo(affiliation.role) const statusIcon = {
const statusIcon = { ...verificationStatusesByValue,
...verificationStatusesByValue, APPROVED: undefined,
APPROVED: undefined, }[affiliation.service.verificationStatus]
}[affiliation.service.verificationStatus]
return ( return (
<li class="shrink-0"> <li class="shrink-0">
<a <a
href={`/service/${affiliation.service.slug}`} href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm" class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
> >
<MyPicture <MyPicture
src={affiliation.service.imageUrl} src={affiliation.service.imageUrl}
fallback="service" fallback="service"
alt={affiliation.service.name} alt={affiliation.service.name}
width={40} width={40}
height={40} height={40}
class="size-10 shrink-0 rounded-lg" class="size-10 shrink-0 rounded-lg"
/> />
<div class="flex min-w-0 flex-1 flex-col justify-center"> <div class="flex min-w-0 flex-1 flex-col justify-center">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} /> <BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
<span class="text-day-500">of</span> <span class="text-day-500">of</span>
</div>
<div class="text-day-300 flex items-center gap-1 font-semibold">
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap group-hover:underline">
{affiliation.service.name}
</span>
{statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0">
<Icon
name={statusIcon.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1',
statusIcon.classNames.icon
)}
/>
</Tooltip>
)}
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
</div>
</div> </div>
</a>
</li>
)
})}
</ul>
) : (
<p class="text-day-400 mb-6">No service affiliations yet.</p>
)}
</section>
)
}
<div class="text-day-300 flex items-center gap-1 font-semibold"> {
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap group-hover:underline"> !user.admin && !user.moderator && (
{affiliation.service.name} <section
</span> class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
{statusIcon && ( id="karma-unlocks"
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0"> >
<Icon <header>
name={statusIcon.icon} <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
class={cn( <Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" />
'inline-block size-6 shrink-0 rounded-lg p-1', Karma Unlocks
statusIcon.classNames.icon </h2>
)}
/>
</Tooltip>
)}
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
</div>
</div>
</a>
</li>
)
})}
</ul>
) : (
<p class="text-day-400 mb-6">No service affiliations yet.</p>
)
}
</section>
<section <div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs" <p class="text-day-300">
id="karma-unlocks" Earn karma to unlock features and privileges.{' '}
> <a href="/docs/karma" class="text-day-200 hover:underline">
<header> Learn about karma
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> </a>
<Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" /> </p>
Karma Unlocks </div>
</h2> </header>
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<p class="text-day-300"> <div class="space-y-3">
Earn karma to unlock features and privileges. <a <input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
href="/docs/karma" <label
class="text-day-200 hover:underline">Learn about karma</a for="positive-unlocks-toggle"
> class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
</p> >
</div> <h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
</header> <Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="mt-3 hidden space-y-3 peer-checked:block md:block">
<div class="space-y-3"> {sortBy(
<input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked /> karmaUnlocks.filter((unlock) => unlock.karma >= 0),
<label 'karma'
for="positive-unlocks-toggle" ).map((unlock) => (
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
'karma'
).map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
</div>
</div>
<div class="space-y-3">
<input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
<label
for="negative-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div <div
class={cn( class={cn(
'flex items-center justify-between rounded-md border p-3', 'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id] user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10' ? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800' : 'border-night-500 bg-night-800'
)} )}
> >
<div class="flex items-center"> <div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}> <span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" /> <Icon name={unlock.icon} class="size-5" />
</span> </span>
<div> <div>
<p <p
class={cn( class={cn(
'font-medium', 'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400' user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)} )}
> >
{unlock.name} {unlock.name}
@@ -765,28 +710,89 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div> </div>
<div> <div>
{user.karmaUnlocks[unlock.id] ? ( {user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400"> <span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active <Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span> </span>
) : ( ) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs"> <span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided <Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span> </span>
)} )}
</div> </div>
</div> </div>
)) ))}
} </div>
</div>
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs"> <div class="space-y-3">
<Icon name="ri:information-line" class="inline-block size-4" /> <input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties. <label
</p> for="negative-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span
class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}
>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
</span>
)}
</div>
</div>
))}
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />
Keep interactions positive to avoid penalties.
</p>
</div>
</div>
</div> </div>
</div> </section>
</div> )
</section> }
<div class="space-y-6"> <div class="space-y-6">
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
@@ -929,139 +935,143 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
) )
} }
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> {
<header class="flex items-center justify-between"> !user.admin && !user.moderator && (
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" /> <header class="flex items-center justify-between">
Recent Suggestions <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
</h2> <Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" />
</header> Recent Suggestions
</h2>
</header>
{ {user.suggestions.length === 0 ? (
user.suggestions.length === 0 ? ( <p class="text-day-400">No suggestions yet.</p>
<p class="text-day-400">No suggestions yet.</p> ) : (
) : ( <div class="overflow-x-auto">
<div class="overflow-x-auto"> <table class="divide-night-400/20 w-full min-w-full divide-y">
<table class="divide-night-400/20 w-full min-w-full divide-y"> <thead>
<thead> <tr>
<tr> <th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th> <th class="text-day-400 px-4 py-3 text-left text-xs">Type</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Type</th> <th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th> <th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th> </tr>
</tr> </thead>
</thead> <tbody class="divide-night-400/10 divide-y">
<tbody class="divide-night-400/10 divide-y"> {user.suggestions.map((suggestion) => {
{user.suggestions.map((suggestion) => { const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type) const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
return ( return (
<tr class="hover:bg-night-500/5"> <tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap"> <td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a <a
href={`/service-suggestion/${suggestion.id}`} href={`/service-suggestion/${suggestion.id}`}
class="text-blue-400 hover:underline" class="text-blue-400 hover:underline"
> >
{suggestion.service.name} {suggestion.service.name}
</a> </a>
</td> </td>
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap"> <td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center"> <span class="inline-flex items-center">
<Icon name={typeInfo.icon} class="mr-1 size-4" /> <Icon name={typeInfo.icon} class="mr-1 size-4" />
{typeInfo.label} {typeInfo.label}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs"> <span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
<Icon name={statusInfo.icon} class="mr-1 size-3" /> <Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label} {statusInfo.label}
</span> </span>
</td> </td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap"> <td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted <TimeFormatted
date={suggestion.createdAt} date={suggestion.createdAt}
prefix={false} prefix={false}
hourPrecision hourPrecision
caseType="sentence" caseType="sentence"
/> />
</td> </td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
) )}
} </section>
</section> )
}
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> {
<header class="flex items-center justify-between"> !user.admin && !user.moderator && (
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" /> <header class="flex items-center justify-between">
Recent Karma Transactions <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
</h2> <Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span> Recent Karma Transactions
</header> </h2>
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
</header>
{ {user.karmaTransactions.length === 0 ? (
user.karmaTransactions.length === 0 ? ( <p class="text-day-400">No karma transactions yet.</p>
<p class="text-day-400">No karma transactions yet.</p> ) : (
) : ( <div class="overflow-x-auto">
<div class="overflow-x-auto"> <table class="divide-night-400/20 w-full min-w-full divide-y">
<table class="divide-night-400/20 w-full min-w-full divide-y"> <thead>
<thead> <tr>
<tr> <th class="text-day-400 px-4 py-3 text-left text-xs">Action</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Action</th> <th class="text-day-400 px-4 py-3 text-left text-xs">Description</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Description</th> <th class="text-day-400 px-4 py-3 text-right text-xs">Points</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Points</th> <th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th> </tr>
</tr> </thead>
</thead> <tbody class="divide-night-400/10 divide-y">
<tbody class="divide-night-400/10 divide-y"> {user.karmaTransactions.map((transaction) => {
{user.karmaTransactions.map((transaction) => { const actionInfo = getKarmaTransactionActionInfo(transaction.action)
const actionInfo = getKarmaTransactionActionInfo(transaction.action) return (
return ( <tr class="hover:bg-night-500/5">
<tr class="hover:bg-night-500/5"> <td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap"> <span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1"> <Icon name={actionInfo.icon} class="size-4" />
<Icon name={actionInfo.icon} class="size-4" /> {actionInfo.label}
{actionInfo.label} {transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && ( <>
<> <span class="text-day-500">from</span>
<span class="text-day-500">from</span> <UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
<UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" /> </>
</> )}
</span>
</td>
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
<td
class={cn(
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)} )}
</span> >
</td> {transaction.points >= 0 && '+'}
<td class="text-day-300 px-4 py-3">{transaction.description}</td> {transaction.points}
<td </td>
class={cn( <td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
'px-4 py-3 text-right text-xs whitespace-nowrap', <TimeFormatted
transaction.points >= 0 ? 'text-green-400' : 'text-red-400' date={transaction.createdAt}
)} prefix={false}
> hourPrecision
{transaction.points >= 0 && '+'} caseType="sentence"
{transaction.points} />
</td> </td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap"> </tr>
<TimeFormatted )
date={transaction.createdAt} })}
prefix={false} </tbody>
hourPrecision </table>
caseType="sentence" </div>
/> )}
</td> </section>
</tr> )
) }
})}
</tbody>
</table>
</div>
)
}
</section>
</div> </div>
</BaseLayout> </BaseLayout>

View File

@@ -1,14 +1,15 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
const getRobotsTxt = (sitemapURL: URL) => ` const getRobotsTxt = (sitemaps: `/${string}`[], siteUrl: URL) => `
User-agent: * User-agent: *
Allow: / Allow: /
Disallow: /admin/ Disallow: /admin/
Sitemap: ${sitemapURL.href} ${sitemaps.map((sitemap) => `Sitemap: ${new URL(sitemap, siteUrl).href}`).join('\n')}
` `
export const GET: APIRoute = ({ site }) => { export const GET: APIRoute = ({ site }) => {
const sitemapURL = new URL('sitemap-index.xml', site) if (!site) return new Response('Site URL not configured', { status: 500 })
return new Response(getRobotsTxt(sitemapURL))
return new Response(getRobotsTxt(['/sitemap-index.xml', '/sitemaps/search.xml'], site))
} }

View File

@@ -110,6 +110,16 @@
} }
} }
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
& {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
@theme { @theme {
--animate-text-gradient: text-gradient 4s linear 0s infinite normal forwards running; --animate-text-gradient: text-gradient 4s linear 0s infinite normal forwards running;