Compare commits

...

29 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
pluja
e6edee2dbe Release 202506151429 2025-06-15 14:29:34 +00:00
pluja
c7ee1606e4 Release 202506151416 2025-06-15 14:16:59 +00:00
pluja
f3c9b92ddb Release 202506151318 2025-06-15 13:18:22 +00:00
pluja
effb6689d7 Release 202506141856 2025-06-14 18:56:58 +00:00
pluja
cf5f3b3228 Release 202506131423 2025-06-13 14:23:14 +00:00
pluja
5a41816ac8 Release 202506131339 2025-06-13 13:39:12 +00:00
113 changed files with 4688 additions and 2447 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
@@ -96,7 +100,7 @@ Tasks will run according to their configured cron schedules.
### Force Triggers Task ### Force Triggers Task
- Maintains database triggers by forcing them to run under certain conditions - Maintains database triggers by forcing them to run under certain conditions
- Currently handles updating the "isRecentlyListed" flag for services after 15 days - Currently handles updating the "isRecentlyApproved" flag for services after 15 days
- Scheduled via `CRON_FORCE-TRIGGERS_TASK` - Scheduled via `CRON_FORCE-TRIGGERS_TASK`
### Service Score Recalculation Task ### Service Score Recalculation Task
@@ -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,
@@ -89,6 +90,23 @@ def parse_args(args: List[str]) -> argparse.Namespace:
score_recalc_parser.add_argument( score_recalc_parser.add_argument(
"--service-id", type=int, help="Specific service ID to process (optional)" "--service-id", type=int, help="Specific service ID to process (optional)"
) )
score_recalc_parser.add_argument(
"--all",
action="store_true",
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)
@@ -295,12 +313,15 @@ def run_force_triggers_task() -> int:
close_db_pool() close_db_pool()
def run_service_score_recalc_task(service_id: Optional[int] = None) -> int: def run_service_score_recalc_task(
service_id: Optional[int] = None, all_services: bool = False
) -> int:
""" """
Run the service score recalculation task. Run the service score recalculation task.
Args: Args:
service_id: Optional specific service ID to process. service_id: Optional specific service ID to process.
all_services: Whether to recalculate scores for all services.
Returns: Returns:
Exit code. Exit code.
@@ -310,7 +331,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
try: try:
# Initialize task and use as context manager # Initialize task and use as context manager
with ServiceScoreRecalculationTask() as task: # type: ignore with ServiceScoreRecalculationTask() as task: # type: ignore
result = task.run(service_id) # type: ignore if all_services:
queued = task.recalculate_all_services() # type: ignore
if not queued:
logger.warning(
"Failed to queue recalculation jobs for all services"
)
# Continuously process queued jobs in batches until none remain
while True:
_ = task.run() # type: ignore
# Check if there are still unprocessed jobs
remaining = 0
if task.conn:
with task.conn.cursor() as cursor:
cursor.execute(
'SELECT COUNT(*) FROM "ServiceScoreRecalculationJob" WHERE "processedAt" IS NULL'
)
remaining = cursor.fetchone()[0]
if remaining == 0:
break
result = True # All jobs processed successfully
else:
result = task.run(service_id) # type: ignore
if result: if result:
logger.info("Successfully recalculated service scores") logger.info("Successfully recalculated service scores")
else: else:
@@ -323,6 +371,37 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
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.
@@ -334,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:
@@ -419,7 +493,13 @@ def main() -> int:
elif args.task == "force-triggers": elif args.task == "force-triggers":
return run_force_triggers_task() return run_force_triggers_task()
elif args.task == "service-score-recalc": elif args.task == "service-score-recalc":
return run_service_score_recalc_task(args.service_id) return run_service_score_recalc_task(
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

@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
Force triggers to run under certain conditions. Force triggers to run under certain conditions.
""" """
RECENT_LISTED_INTERVAL_DAYS = 15 RECENT_APPROVED_INTERVAL_DAYS = 15
def __init__(self): def __init__(self):
super().__init__("force_triggers") super().__init__("force_triggers")
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
update_query = f""" update_query = f"""
UPDATE "Service" UPDATE "Service"
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW() SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
WHERE "isRecentlyListed" = TRUE WHERE "isRecentlyApproved" = TRUE
AND "listedAt" IS NOT NULL AND "approvedAt" IS NOT NULL
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days' AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
""" """
try: try:
with self.conn.cursor() as cursor: with self.conn.cursor() as cursor:

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

@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
cursor.execute( cursor.execute(
""" """
SELECT id SELECT id
FROM "Service" FROM "Service"
WHERE "isActive" = TRUE
""" """
) )
services = cursor.fetchall() services = cursor.fetchall()

View File

@@ -34,8 +34,8 @@ class TosReviewTask(Task):
service_name = service["name"] service_name = service["name"]
verification_status = service.get("verificationStatus") verification_status = service.get("verificationStatus")
# Only process verified or approved services # Only process verified, approved, or community contributed services
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]: if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
self.logger.info( self.logger.info(
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}" f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
) )

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],

1266
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 @@
-- AlterEnum
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';

View File

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

View File

@@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the column `isRecentlyListed` on the `Service` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Service" DROP COLUMN "isRecentlyListed",
ADD COLUMN "isRecentlyApproved" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "Service_approvedAt_idx" ON "Service"("approvedAt");
-- CreateIndex
CREATE INDEX "Service_verifiedAt_idx" ON "Service"("verifiedAt");
-- CreateIndex
CREATE INDEX "Service_spamAt_idx" ON "Service"("spamAt");
-- CreateIndex
CREATE INDEX "Service_serviceVisibility_idx" ON "Service"("serviceVisibility");

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";

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,53 +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.
isRecentlyListed Boolean @default(false) averageUserRating Float?
/// Computed via trigger. Do not update through prisma. serviceVisibility ServiceVisibility @default(PUBLIC)
averageUserRating Float? serviceInfoBanner ServiceInfoBanner @default(NONE)
serviceVisibility ServiceVisibility @default(PUBLIC) serviceInfoBannerNotes String?
serviceInfoBanner ServiceInfoBanner @default(NONE) verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
serviceInfoBannerNotes String? verificationSummary String?
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED) verificationRequests ServiceVerificationRequest[]
verificationSummary String? verificationProofMd String?
verificationRequests ServiceVerificationRequest[]
verificationProofMd String?
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
verifiedAt DateTime?
/// [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
listedAt DateTime? /// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
comments Comment[] listedAt DateTime?
events Event[] /// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod") approvedAt DateTime?
attributes ServiceAttribute[] /// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
verificationSteps VerificationStep[] verifiedAt DateTime?
suggestions ServiceSuggestion[] /// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes") spamAt DateTime?
/// Computed via trigger. Do not update through prisma.
isRecentlyApproved Boolean @default(false)
comments Comment[]
events Event[]
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
attributes ServiceAttribute[]
verificationSteps VerificationStep[]
suggestions ServiceSuggestion[]
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices") onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices") onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
@@ -395,7 +410,13 @@ 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([verifiedAt])
@@index([spamAt])
@@index([overallScore]) @@index([overallScore])
@@index([privacyScore]) @@index([privacyScore])
@@index([trustScore]) @@index([trustScore])
@@ -407,6 +428,7 @@ model Service {
@@index([updatedAt]) @@index([updatedAt])
@@index([slug]) @@index([slug])
@@index([previousSlugs]) @@index([previousSlugs])
@@index([serviceVisibility])
} }
model ServiceContactMethod { model ServiceContactMethod {
@@ -487,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
@@ -578,6 +602,7 @@ enum VerificationStepStatus {
IN_PROGRESS IN_PROGRESS
PASSED PASSED
FAILED FAILED
WARNING
} }
model VerificationStep { model VerificationStep {
@@ -597,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
@@ -677,8 +703,6 @@ model PushSubscription {
p256dh String p256dh String
/// Authentication secret /// Authentication secret
auth String auth String
/// To identify different devices
userAgent String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -13,20 +13,22 @@ import {
PrismaClient, PrismaClient,
ServiceSuggestionStatus, ServiceSuggestionStatus,
ServiceUserRole, ServiceUserRole,
VerificationStatus,
type Prisma, type Prisma,
type User, type User,
type ServiceVisibility, type ServiceVisibility,
ServiceSuggestionType, ServiceSuggestionType,
KycLevelClarification, KycLevelClarification,
VerificationStepStatus, VerificationStepStatus,
type VerificationStatus,
} from '@prisma/client' } from '@prisma/client'
import { differenceInDays, isPast } from 'date-fns'
import { omit, uniqBy } from 'lodash-es' import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' 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') {
@@ -140,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',
}, },
@@ -614,6 +632,14 @@ const generateFakeService = (users: User[]) => {
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), { const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
probability: 0.8, probability: 0.8,
}) })
const serviceVisibility = faker.helpers.weightedArrayElement<ServiceVisibility>([
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
])
const approvedAt =
status === 'APPROVED' || status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null
return { return {
name, name,
@@ -629,12 +655,7 @@ const generateFakeService = (users: User[]) => {
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([ serviceVisibility,
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
]),
verificationStatus: status, verificationStatus: status,
verificationSummary: verificationSummary:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null, status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
@@ -677,8 +698,20 @@ 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`,
listedAt: faker.date.past(), registrationCountryCode: faker.helpers.maybe(() => faker.helpers.arrayElement(countries).code, {
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null, probability: 0.7,
}),
registeredCompanyName: faker.helpers.maybe(() => faker.company.name(), {
probability: 0.6,
}),
listedAt:
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
? faker.date.recent({ days: 30 })
: null,
verifiedAt: status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null,
spamAt: status === 'VERIFICATION_FAILED' ? faker.date.recent({ days: 30 }) : null,
approvedAt,
isRecentlyApproved: !!approvedAt && isPast(approvedAt) && differenceInDays(new Date(), approvedAt) < 15,
tosReview, tosReview,
tosReviewAt: tosReview tosReviewAt: tosReview
? faker.date.recent() ? faker.date.recent()
@@ -694,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
} }
@@ -908,7 +943,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`, value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
}, },
{ {
label: faker.lorem.word({ length: 2 }), label: 'Custom label',
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`, value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
}, },
{ {
@@ -918,7 +953,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
value: faker.internet.url(), value: faker.internet.url(),
}, },
{ {
label: faker.lorem.word({ length: 2 }), label: 'Custom label',
value: faker.internet.url(), value: faker.internet.url(),
}, },
{ {
@@ -1307,7 +1342,7 @@ async function main() {
const service = await prisma.service.create({ const service = await prisma.service.create({
data: { data: {
...serviceData, ...serviceData,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED, verificationStatus: 'COMMUNITY_CONTRIBUTED',
categories: { categories: {
connect: randomCategories.map((cat) => ({ id: cat.id })), connect: randomCategories.map((cat) => ({ id: cat.id })),
}, },

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

@@ -1,7 +1,7 @@
-- This script defines PostgreSQL functions and triggers for managing service scores: -- This script defines PostgreSQL functions and triggers for managing service scores:
-- 1. Automatically calculates and updates privacy, trust, and overall scores -- 1. Automatically calculates and updates privacy, trust, and overall scores
-- for services when services or their attributes change. -- for services when services or their attributes change.
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days. -- 2. Updates the isRecentlyApproved flag for services approved within the last 15 days.
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob" -- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
-- when an "Attribute" definition (e.g., points) is updated, ensuring -- when an "Attribute" definition (e.g., points) is updated, ensuring
-- efficient handling of widespread score updates. -- efficient handling of widespread score updates.
@@ -25,12 +25,8 @@ DECLARE
privacy_score INT := 0; privacy_score INT := 0;
kyc_factor INT; kyc_factor INT;
clarification_factor INT := 0; clarification_factor INT := 0;
onion_factor INT := 0; onion_or_i2p_factor INT := 0;
i2p_factor INT := 0;
monero_factor INT := 0; monero_factor INT := 0;
open_source_factor INT := 0;
p2p_factor INT := 0;
decentralized_factor INT := 0;
attributes_score INT := 0; attributes_score INT := 0;
BEGIN BEGIN
-- Get service data -- Get service data
@@ -57,20 +53,12 @@ BEGIN
FROM "Service" FROM "Service"
WHERE "id" = service_id; WHERE "id" = service_id;
-- Check for onion URLs -- Check for onion or i2p URLs
IF EXISTS ( IF EXISTS (
SELECT 1 FROM "Service" SELECT 1 FROM "Service"
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0 WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
) THEN ) THEN
onion_factor := 5; onion_or_i2p_factor := 5;
END IF;
-- Check for i2p URLs
IF EXISTS (
SELECT 1 FROM "Service"
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
) THEN
i2p_factor := 5;
END IF; END IF;
-- Check for Monero acceptance -- Check for Monero acceptance
@@ -86,10 +74,10 @@ BEGIN
INTO attributes_score INTO attributes_score
FROM "ServiceAttribute" sa FROM "ServiceAttribute" sa
JOIN "Attribute" a ON sa."attributeId" = a."id" JOIN "Attribute" a ON sa."attributeId" = a."id"
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY'; WHERE sa."serviceId" = service_id;
-- Calculate final privacy score (base 100) -- Calculate final privacy score (base 100)
privacy_score := 50 + kyc_factor + clarification_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score; privacy_score := 50 + kyc_factor + clarification_factor + onion_or_i2p_factor + monero_factor + attributes_score;
-- Ensure the score is in reasonable bounds (0-100) -- Ensure the score is in reasonable bounds (0-100)
privacy_score := GREATEST(0, LEAST(100, privacy_score)); privacy_score := GREATEST(0, LEAST(100, privacy_score));
@@ -105,8 +93,10 @@ DECLARE
trust_score INT := 0; trust_score INT := 0;
verification_factor INT; verification_factor INT;
attributes_score INT := 0; attributes_score INT := 0;
recently_listed_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
@@ -126,26 +116,26 @@ BEGIN
INTO attributes_score INTO attributes_score
FROM "ServiceAttribute" sa FROM "ServiceAttribute" sa
JOIN "Attribute" a ON sa."attributeId" = a.id JOIN "Attribute" a ON sa."attributeId" = a.id
WHERE sa."serviceId" = service_id AND a.category = 'TRUST'; WHERE sa."serviceId" = service_id;
-- Apply penalty if service was listed within the last 15 days -- Apply penalty if service was approved within the last 15 days
IF EXISTS ( IF EXISTS (
SELECT 1 SELECT 1
FROM "Service" FROM "Service"
WHERE id = service_id WHERE id = service_id
AND "listedAt" IS NOT NULL AND "approvedAt" IS NOT NULL
AND "verificationStatus" = 'APPROVED' AND "verificationStatus" = 'APPROVED'
AND (NOW() - "listedAt") <= INTERVAL '15 days' AND (NOW() - "approvedAt") <= INTERVAL '15 days'
) THEN ) THEN
recently_listed_factor := -10; recently_approved_factor := -10;
-- Update the isRecentlyListed flag to true -- Update the isRecentlyApproved flag to true
UPDATE "Service" UPDATE "Service"
SET "isRecentlyListed" = TRUE SET "isRecentlyApproved" = TRUE
WHERE id = service_id; WHERE id = service_id;
ELSE ELSE
-- Update the isRecentlyListed flag to false -- Update the isRecentlyApproved flag to false
UPDATE "Service" UPDATE "Service"
SET "isRecentlyListed" = FALSE SET "isRecentlyApproved" = FALSE
WHERE id = service_id; WHERE id = service_id;
END IF; END IF;
@@ -160,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_listed_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));
@@ -176,7 +186,7 @@ RETURNS INT AS $$
DECLARE DECLARE
overall_score INT; overall_score INT;
BEGIN BEGIN
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT); overall_score := CAST(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0 AS INT);
RETURN GREATEST(0, LEAST(10, overall_score)); RETURN GREATEST(0, LEAST(10, overall_score));
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;

View File

@@ -1,48 +1,60 @@
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps CREATE OR REPLACE FUNCTION manage_service_visibility_timestamps()
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
-- are set or cleared appropriately when a service's verification status is updated.
CREATE OR REPLACE FUNCTION manage_service_timestamps()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
-- Manage listedAt timestamp IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
IF OLD."listedAt" IS NULL THEN IF OLD."listedAt" IS NULL THEN
NEW."listedAt" := NOW(); NEW."listedAt" := NOW();
NEW."isRecentlyListed" := TRUE;
END IF; END IF;
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN ELSE
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
NEW."listedAt" := NULL; NEW."listedAt" := NULL;
NEW."isRecentlyListed" := FALSE;
END IF;
-- Manage verifiedAt timestamp
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
NEW."verifiedAt" := NOW();
NEW."isRecentlyListed" := FALSE;
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
NEW."verifiedAt" := NULL;
NEW."isRecentlyListed" := FALSE;
END IF; END IF;
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- Drop the old trigger first if it exists under the old name CREATE OR REPLACE FUNCTION manage_service_verification_timestamps()
RETURNS TRIGGER AS $$
BEGIN
IF (NEW."verificationStatus" = 'APPROVED' OR NEW."verificationStatus" = 'VERIFICATION_SUCCESS') THEN
IF OLD."approvedAt" IS NULL THEN
NEW."approvedAt" := NOW();
NEW."isRecentlyApproved" := TRUE;
END IF;
ELSE
NEW."approvedAt" := NULL;
NEW."isRecentlyApproved" := FALSE;
END IF;
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
NEW."verifiedAt" := NOW();
ELSE
NEW."verifiedAt" := NULL;
END IF;
IF NEW."verificationStatus" = 'VERIFICATION_FAILED' THEN
NEW."spamAt" := NOW();
ELSE
NEW."spamAt" := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the old triggers TODO: remove this some day
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service"; DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
-- Drop the trigger if it exists under the new name
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service"; DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
CREATE TRIGGER trigger_manage_service_timestamps DROP TRIGGER IF EXISTS trigger_manage_service_visibility_timestamps ON "Service";
DROP TRIGGER IF EXISTS trigger_manage_service_verification_timestamps ON "Service";
CREATE TRIGGER trigger_manage_service_visibility_timestamps
BEFORE UPDATE OF "serviceVisibility" ON "Service"
FOR EACH ROW
EXECUTE FUNCTION manage_service_visibility_timestamps();
CREATE TRIGGER trigger_manage_service_verification_timestamps
BEFORE UPDATE OF "verificationStatus" ON "Service" BEFORE UPDATE OF "verificationStatus" ON "Service"
FOR EACH ROW FOR EACH ROW
-- Only execute the function if the verificationStatus value has actually changed EXECUTE FUNCTION manage_service_verification_timestamps();
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
EXECUTE FUNCTION manage_service_timestamps();

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,10 +139,12 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral, strictCommentingEnabled: input.strictCommentingEnabled,
commentSectionMessage: input.commentSectionMessage,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
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 })),
}, },
@@ -140,6 +155,11 @@ export const adminServiceActions = {
}, },
})), })),
}, },
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
imageUrl, imageUrl,
internalNotes: input.internalNote internalNotes: input.internalNote
? { ? {
@@ -149,6 +169,9 @@ export const adminServiceActions = {
}, },
} }
: undefined, : undefined,
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
}, },
select: { select: {
id: true, id: true,
@@ -244,10 +267,12 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral, strictCommentingEnabled: input.strictCommentingEnabled,
commentSectionMessage: input.commentSectionMessage,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
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
? { ? {
@@ -256,7 +281,6 @@ export const adminServiceActions = {
), ),
} }
: undefined, : undefined,
imageUrl, imageUrl,
categories: { categories: {
connect: categoriesToAdd.map((id) => ({ id })), connect: categoriesToAdd.map((id) => ({ id })),
@@ -273,6 +297,9 @@ export const adminServiceActions = {
attributeId, attributeId,
})), })),
}, },
operatingSince: input.operatingSince,
registrationCountryCode: input.registrationCountryCode ?? null,
registeredCompanyName: input.registeredCompanyName,
}, },
}) })
@@ -333,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 }
}, },
}), }),
}, },
@@ -459,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

@@ -65,13 +65,13 @@ export const apiServiceActions = {
tosUrls: true, tosUrls: true,
referral: true, referral: true,
listedAt: true, listedAt: true,
approvedAt: true,
verifiedAt: true, verifiedAt: true,
serviceVisibility: true, serviceVisibility: true,
} as const satisfies Prisma.ServiceSelect } as const satisfies Prisma.ServiceSelect
let service = await prisma.service.findFirst({ let service = await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
OR: [ OR: [
@@ -92,7 +92,6 @@ export const apiServiceActions = {
if (!service && input.slug) { if (!service && input.slug) {
service = await prisma.service.findFirst({ service = await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug }, previousSlugs: { has: input.slug },
@@ -105,9 +104,7 @@ export const apiServiceActions = {
!service || !service ||
(service.serviceVisibility !== 'PUBLIC' && (service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' && service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') || service.serviceVisibility !== 'UNLISTED')
!service.listedAt ||
service.listedAt > new Date()
) { ) {
throw new ActionError({ throw new ActionError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
@@ -130,12 +127,13 @@ export const apiServiceActions = {
'description', 'description',
]), ]),
verifiedAt: service.verifiedAt, verifiedAt: service.verifiedAt,
approvedAt: service.approvedAt,
kycLevel: service.kycLevel, kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']), kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
kycLevelClarification: service.kycLevelClarification, 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) {
@@ -270,6 +271,18 @@ export const commentActions = {
} }
} }
const isRelatedToService = !!(await tx.serviceUser.findUnique({
where: {
userId_serviceId: {
userId: context.locals.user.id,
serviceId: input.serviceId,
},
},
select: {
id: true,
},
}))
// Prepare data object with proper type safety // Prepare data object with proper type safety
const commentData: Prisma.CommentCreateInput = { const commentData: Prisma.CommentCreateInput = {
content: input.content, content: input.content,
@@ -277,7 +290,12 @@ export const commentActions = {
author: { connect: { id: context.locals.user.id } }, author: { connect: { id: context.locals.user.id } },
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review // Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING', status:
context.locals.user.admin || context.locals.user.moderator || isRelatedToService
? 'APPROVED'
: isIssueReport
? 'HUMAN_PENDING'
: 'PENDING',
requiresAdminReview, requiresAdminReview,
orderId: input.orderId?.trim() ?? null, orderId: input.orderId?.trim() ?? null,
kycRequested: input.issueKycRequested === true, kycRequested: input.issueKycRequested === true,

View File

@@ -32,7 +32,6 @@ export const notificationActions = {
endpoint: z.string(), endpoint: z.string(),
p256dhKey: z.string(), p256dhKey: z.string(),
authKey: z.string(), authKey: z.string(),
userAgent: z.string().optional(),
}), }),
handler: async (input, context) => { handler: async (input, context) => {
await prisma.pushSubscription.upsert({ await prisma.pushSubscription.upsert({
@@ -43,14 +42,12 @@ export const notificationActions = {
update: { update: {
p256dh: input.p256dhKey, p256dh: input.p256dhKey,
auth: input.authKey, auth: input.authKey,
userAgent: input.userAgent,
}, },
create: { create: {
userId: context.locals.user.id, userId: context.locals.user.id,
endpoint: input.endpoint, endpoint: input.endpoint,
p256dh: input.p256dhKey, p256dh: input.p256dhKey,
auth: input.authKey, auth: input.authKey,
userAgent: input.userAgent,
}, },
}) })
}, },
@@ -58,25 +55,17 @@ export const notificationActions = {
unsubscribe: defineProtectedAction({ unsubscribe: defineProtectedAction({
accept: 'json', accept: 'json',
permissions: 'user', 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, endpoint: input.endpoint,
endpoint: input.endpoint, },
}, })
})
} else {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
},
})
}
}, },
}), }),
}, },

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'
@@ -36,7 +37,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
id: { id: {
in: matches.map(({ id }) => id), in: matches.map(({ id }) => id),
}, },
listedAt: { lte: new Date() },
serviceVisibility: { serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'], in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
}, },
@@ -50,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 = {
@@ -166,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
@@ -200,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
@@ -240,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,
@@ -252,7 +305,6 @@ export const serviceSuggestionActions = {
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
listedAt: new Date(),
serviceVisibility: 'UNLISTED', serviceVisibility: 'UNLISTED',
categories: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),

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,13 +1,15 @@
--- ---
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'
import { isNotArray } from '../lib/arrays' import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/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,46 +79,49 @@ 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} />}
<DynamicFavicon /> <DynamicFavicon />
<!-- Components -->
<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

@@ -15,6 +15,7 @@ import {
} from '../lib/commentsWithReplies' } from '../lib/commentsWithReplies'
import { computeKarmaUnlocks } from '../lib/karmaUnlocks' import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
import { formatDateShort } from '../lib/timeAgo' import { formatDateShort } from '../lib/timeAgo'
import { urlDomain } from '../lib/urls'
import BadgeSmall from './BadgeSmall.astro' import BadgeSmall from './BadgeSmall.astro'
import CommentModeration from './CommentModeration.astro' import CommentModeration from './CommentModeration.astro'
@@ -32,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 {
@@ -41,6 +43,7 @@ const {
highlightedCommentId = null, highlightedCommentId = null,
serviceSlug, serviceSlug,
itemReviewedId, itemReviewedId,
strictCommentingEnabled,
class: className, class: className,
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
@@ -170,7 +173,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.author.admin || comment.author.moderator comment.author.admin || comment.author.moderator
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}` ? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
: '' : ''
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`} }${comment.author.verifiedLink ? `Related to ${urlDomain(comment.author.verifiedLink)}` : ''}`}
> >
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" /> <Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
</Tooltip> </Tooltip>
@@ -491,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
} }
}> }>
} }
@@ -71,12 +73,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
'Failed to fetch comments', 'Failed to fetch comments',
async () => async () =>
await prisma.comment.findMany( await prisma.comment.findMany(
makeCommentsNestedQuery({ await makeCommentsNestedQuery({
depth: MAX_COMMENT_DEPTH, depth: MAX_COMMENT_DEPTH,
user, user,
showPending: params.showPending, showPending: params.showPending,
serviceId: service.id, serviceId: service.id,
sort: params.sort, sort: params.sort,
highlightedCommentId: params.comment,
}) })
), ),
[], [],
@@ -172,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">
@@ -257,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

@@ -1,5 +1,5 @@
--- ---
import { DEPLOYMENT_MODE } from '../lib/envVariables' import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
const user = Astro.locals.user const user = Astro.locals.user

View File

@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
import { sample } from 'lodash-es' import { sample } from 'lodash-es'
import { splashTexts } from '../constants/splashTexts' import { splashTexts } from '../constants/splashTexts'
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls' import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
import AdminOnly from './AdminOnly.astro' import AdminOnly from './AdminOnly.astro'
@@ -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

@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
iconClass?: string iconClass?: string
description?: MarkdownString description?: MarkdownString
disabled?: boolean disabled?: boolean
noTransitionPersist?: boolean
}[] }[]
disabled?: boolean disabled?: boolean
selectedValue?: Multiple extends true ? string[] : string selectedValue?: Multiple extends true ? string[] : string
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
)} )}
> >
<input <input
transition:persist={option.noTransitionPersist || !multiple ? undefined : true} transition:persist
type={multiple ? 'checkbox' : 'radio'} type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name} name={wrapperProps.name}
value={option.value} value={option.value}

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

@@ -4,14 +4,17 @@
<script> <script>
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications' import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
import { makeNotificationOptions } from '../lib/notificationOptions' import {
makeBrowserNotificationOptions,
makeBrowserNotificationTitle,
} from '../lib/client/notificationOptions'
document.addEventListener('sse:new-notification', (event) => { document.addEventListener('sse:new-notification', (event) => {
if (isBrowserNotificationsEnabled()) { if (isBrowserNotificationsEnabled()) {
const payload = event.detail const payload = event.detail
const notification = showBrowserNotification( const notification = showBrowserNotification(
payload.title, makeBrowserNotificationTitle(payload.title),
makeNotificationOptions(payload, { removeActions: true }) makeBrowserNotificationOptions(payload, { removeActions: true })
) )
// Handle notification click // Handle notification click

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

@@ -16,7 +16,6 @@ type Props = HTMLAttributes<'div'> & {
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{ pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
select: { select: {
endpoint: true endpoint: true
userAgent: true
} }
}>[] }>[]
} }

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

@@ -27,6 +27,12 @@ if (!Astro.locals.user) return
} }
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
// NOTE: Disable sse: events when user is not logged in
if (!document.body.hasAttribute('data-is-logged-in')) {
stopServerEventsListener()
return
}
try { try {
const data = JSON.parse(event.data as string) const data = JSON.parse(event.data as string)

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

@@ -4,6 +4,7 @@
<script> <script>
import { registerSW } from 'virtual:pwa-register' import { registerSW } from 'virtual:pwa-register'
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[] const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
@@ -33,7 +34,7 @@
function shouldSkipAutoReload() { function shouldSkipAutoReload() {
const currentPath = window.location.pathname const currentPath = window.location.pathname
const isErrorPage = document.querySelector('[data-is-error-page]') !== null const isErrorPage = document.body.hasAttribute('data-is-error-page')
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route) return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
} }
@@ -48,4 +49,11 @@
window.addEventListener('beforeinstallprompt', (event) => { window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault() event.preventDefault()
}) })
document.addEventListener('astro:page-load', async () => {
if (!document.body.hasAttribute('data-is-logged-in')) {
await unsubscribeFromPushNotifications()
window.__SW_REGISTRATION__?.unregister()
}
})
</script> </script>

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

@@ -1,23 +1,21 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { differenceInDays, isPast } from 'date-fns'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { formatDaysAgo } from '../lib/timeAgo'
import TimeFormatted from './TimeFormatted.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
const RECENTLY_ADDED_DAYS = 7
type Props = { type Props = {
service: Prisma.ServiceGetPayload<{ service: Prisma.ServiceGetPayload<{
select: { select: {
verificationStatus: true verificationStatus: true
verificationProofMd: true verificationProofMd: true
verificationSummary: true verificationSummary: true
listedAt: true approvedAt: true
isRecentlyApproved: true
createdAt: true createdAt: true
verificationSteps: { verificationSteps: {
select: { select: {
@@ -29,9 +27,6 @@ type Props = {
} }
const { service } = Astro.props const { service } = Astro.props
const listedDate = service.listedAt ?? service.createdAt
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
--- ---
{ {
@@ -57,23 +52,23 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
<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
</a> </a>
</span> </span>
</div> </div>
) : wasRecentlyAdded ? ( ) : service.isRecentlyApproved ? (
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400"> <div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '} This service was approved
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} /> {service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
{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
@@ -83,10 +78,10 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
<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-yellow-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
</a> </a>
@@ -98,14 +93,29 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
{ {
service.verificationStatus !== 'VERIFICATION_FAILED' && service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.verificationSteps.some((step) => step.status === 'FAILED') && ( service.verificationSteps.some((step) => step.status === 'FAILED') && (
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400"> <a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400 transition-colors hover:bg-red-900/60"
>
<Icon <Icon
name={verificationStatusesByValue.VERIFICATION_FAILED.icon} name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)} class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
/> />
<span> <span>Some verification steps failed. Please review the details below.</span>
This service has failed one or more verification steps. Review the verification details carefully. </a>
</span> )
</div> }
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
!service.verificationSteps.some((step) => step.status === 'FAILED') &&
service.verificationSteps.some((step) => step.status === 'WARNING') && (
<a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200 transition-colors hover:bg-yellow-600/40"
>
<Icon name={verificationStepStatusesByValue.WARNING.icon} class={cn('size-5 text-yellow-400')} />
<span>Some verification steps are marked as warnings.</span>
</a>
) )
} }

View File

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

View File

@@ -42,6 +42,12 @@ export const {
icon: 'ri:alert-line', icon: 'ri:alert-line',
color: 'red', color: 'red',
}, },
{
value: 'WARNING',
label: 'Warning',
icon: 'ri:alert-line',
color: 'yellow',
},
{ {
value: 'PENDING', value: 'PENDING',
label: 'Pending', label: 'Pending',

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">
@@ -81,9 +105,18 @@ const announcement = await Astro.locals.banners.try(
</head> </head>
<body <body
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)} class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
data-is-error-page={isErrorPage} data-is-error-page={isErrorPage ? '' : 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,13 +1,16 @@
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'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus' import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
import { formatDateShort } from './timeAgo' import { formatDaysAgo } from './timeAgo'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -35,8 +38,8 @@ type NonDbAttributeFull = NonDbAttribute & {
select: { select: {
verificationStatus: true verificationStatus: true
serviceVisibility: true serviceVisibility: true
isRecentlyListed: true isRecentlyApproved: true
listedAt: true approvedAt: true
createdAt: true createdAt: true
tosReviewAt: true tosReviewAt: true
tosReview: true tosReview: true
@@ -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'>> & {
@@ -189,17 +195,17 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
}), }),
}, },
{ {
slug: 'recently-listed', slug: 'recently-approved',
title: 'Recently listed', title: 'Recently approved',
type: 'WARNING', type: 'WARNING',
category: 'TRUST', category: 'TRUST',
description: 'Listed on KYCnot.me less than 15 days ago. Proceed with caution.', description: 'Approved on KYCnot.me less than 15 days ago. Proceed with caution.',
privacyPoints: 0, privacyPoints: 0,
trustPoints: -5, trustPoints: -5,
links: [], links: [],
customize: (service) => ({ customize: (service) => ({
show: service.isRecentlyListed, show: service.isRecentlyApproved,
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`, description: `Approved on KYCnot.me less than 15 days ago${service.approvedAt ? ` (${formatDaysAgo(service.approvedAt)})` : ''}. Proceed with caution.`,
}), }),
}, },
{ {
@@ -217,41 +223,22 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
}), }),
}, },
{ {
slug: 'has-onion-urls', slug: 'has-onion-or-i2p-urls',
title: 'Has Onion URLs', title: 'Has Onion or I2P URLs',
type: 'GOOD', type: 'GOOD',
category: 'PRIVACY', category: 'PRIVACY',
description: 'Onion (Tor) URLs enhance privacy and anonymity.', description: 'Onion (Tor) and I2P URLs enhance privacy and anonymity.',
privacyPoints: 5, privacyPoints: 5,
trustPoints: 0, trustPoints: 0,
links: [ links: [
{ {
url: '/?onion=true', url: '/?networks=onion&networks=i2p',
label: 'Search with this', label: 'Search with this',
icon: 'ri:search-line', icon: 'ri:search-line',
}, },
], ],
customize: (service) => ({ customize: (service) => ({
show: service.onionUrls.length > 0, show: service.onionUrls.length > 0 || service.i2pUrls.length > 0,
}),
},
{
slug: 'has-i2p-urls',
title: 'Has I2P URLs',
type: 'GOOD',
category: 'PRIVACY',
description: 'I2P URLs enhance privacy and anonymity.',
privacyPoints: 5,
trustPoints: 0,
links: [
{
url: '/?i2p=true',
label: 'Search with this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.i2pUrls.length > 0,
}), }),
}, },
{ {
@@ -274,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

@@ -7,11 +7,15 @@ export function supportsBrowserNotifications() {
} }
export function isBrowserNotificationsEnabled() { export function isBrowserNotificationsEnabled() {
return ( const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
supportsBrowserNotifications() && if (!browserNotificationsEnabled) return false
Notification.permission === 'granted' &&
typedLocalStorage.browserNotificationsEnabled.get() if (!document.body.hasAttribute('data-is-logged-in')) {
) typedLocalStorage.browserNotificationsEnabled.set(false)
return false
}
return supportsBrowserNotifications() && Notification.permission === 'granted'
} }
export async function enableBrowserNotifications(): Promise<SafeResult> { export async function enableBrowserNotifications(): Promise<SafeResult> {

View File

@@ -5,7 +5,6 @@ import type { actions } from 'astro:actions'
type ServerSubscription = { type ServerSubscription = {
endpoint: string endpoint: string
userAgent: string | null
} }
export type SafeResult = export type SafeResult =
@@ -45,7 +44,6 @@ export async function subscribeToPushNotifications(vapidPublicKey: string): Prom
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
userAgent: navigator.userAgent,
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '', p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '', authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>), } satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
@@ -131,13 +129,7 @@ export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubsc
const currentSubscription = await getCurrentSubscription() const currentSubscription = await getCurrentSubscription()
if (!currentSubscription || serverSubscriptions.length === 0) return false if (!currentSubscription || serverSubscriptions.length === 0) return false
const currentEndpoint = currentSubscription.endpoint return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
const currentUserAgent = navigator.userAgent
return serverSubscriptions.some(
(sub) =>
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
)
} }
function urlB64ToUint8Array(base64String: string) { function urlB64ToUint8Array(base64String: string) {
@@ -183,5 +175,5 @@ export function parsePushSubscriptions(subscriptionsAsString: string | undefined
function isServerSubscription(subscription: unknown): subscription is ServerSubscription { function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
if (typeof subscription !== 'object' || subscription === null) return false if (typeof subscription !== 'object' || subscription === null) return false
const s = subscription as Record<string, unknown> const s = subscription as Record<string, unknown>
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null) return typeof s.endpoint === 'string'
} }

View File

@@ -0,0 +1,7 @@
export const DEPLOYMENT_MODE = import.meta.env.PROD
? import.meta.env.MODE === 'development' ||
import.meta.env.MODE === 'staging' ||
import.meta.env.MODE === 'production'
? import.meta.env.MODE
: 'development'
: 'development'

View File

@@ -1,4 +1,6 @@
import type { NotificationData, NotificationPayload } from './serverEventsTypes' import { DEPLOYMENT_MODE } from './envVariables'
import type { NotificationData, NotificationPayload } from '../serverEventsTypes'
export type CustomNotificationOptions = NotificationOptions & { export type CustomNotificationOptions = NotificationOptions & {
actions?: { action: string; title: string; icon?: string }[] actions?: { action: string; title: string; icon?: string }[]
@@ -6,14 +8,24 @@ export type CustomNotificationOptions = NotificationOptions & {
data: NotificationData data: NotificationData
} }
export function makeNotificationOptions( export function makeBrowserNotificationTitle(title?: string | null) {
const prefix = DEPLOYMENT_MODE === 'development' ? '[DEV] ' : DEPLOYMENT_MODE === 'staging' ? '[PRE] ' : ''
return `${prefix}${title ?? 'New Notification'}`
}
export function makeBrowserNotificationOptions(
payload: NotificationPayload | null, payload: NotificationPayload | null,
options: { removeActions?: boolean } = {} options: { removeActions?: boolean } = {}
) { ) {
const defaultOptions: CustomNotificationOptions = { const defaultOptions: CustomNotificationOptions = {
body: 'You have a new notification', body: 'You have a new notification',
lang: 'en-US', lang: 'en-US',
icon: '/favicon.svg', icon:
DEPLOYMENT_MODE === 'development'
? '/favicon-dev.svg'
: DEPLOYMENT_MODE === 'staging'
? '/favicon-stage.svg'
: '/favicon.svg',
badge: '/notification-icon.svg', badge: '/notification-icon.svg',
requireInteraction: false, requireInteraction: false,
silent: false, silent: false,

View File

@@ -1,5 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { prisma } from './prisma'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
export const MAX_COMMENT_DEPTH = 12 export const MAX_COMMENT_DEPTH = 12
@@ -75,12 +77,13 @@ export type CommentWithRepliesPopulated = CommentWithReplies<{
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest') export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
export type CommentSortOption = z.infer<typeof commentSortSchema> export type CommentSortOption = z.infer<typeof commentSortSchema>
export function makeCommentsNestedQuery({ export async function makeCommentsNestedQuery({
depth = 0, depth = 0,
user, user,
showPending, showPending,
serviceId, serviceId,
sort, sort,
highlightedCommentId,
}: { }: {
depth?: number depth?: number
user: Prisma.UserGetPayload<{ user: Prisma.UserGetPayload<{
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
showPending?: boolean showPending?: boolean
serviceId: number serviceId: number
sort: CommentSortOption sort: CommentSortOption
highlightedCommentId?: number | null
}) { }) {
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = [] const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
} }
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
const baseQuery = { const baseQuery = {
...commentReplyQuery, ...commentReplyQuery,
orderBy: orderByClause, orderBy: orderByClause,
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
: ({ : ({
status: { in: ['APPROVED', 'VERIFIED'] }, status: { in: ['APPROVED', 'VERIFIED'] },
} as const satisfies Prisma.CommentWhereInput), } as const satisfies Prisma.CommentWhereInput),
...(highlightedBranchIds.length > 0
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
: []),
], ],
parentId: null, parentId: null,
serviceId, serviceId,
@@ -161,6 +170,47 @@ export function makeRepliesQuery<T extends Prisma.CommentFindManyArgs>(
} }
} }
async function findAllParentIds(commentId: number, depth: number) {
const commentwithManyParents = await prisma.comment.findFirst({
where: { id: commentId },
select: makeParentQuerySelect(depth),
})
return extractParentIds(commentwithManyParents, [commentId])
}
type ParentQueryRecursive = {
parent: {
select: {
id: true
parent: false | { select: ParentQueryRecursive }
}
}
}
function makeParentQuerySelect(depth: number): ParentQueryRecursive {
return {
parent: {
select: {
id: true,
parent: depth <= 0 ? false : { select: makeParentQuerySelect(depth - 1) },
},
},
} as const satisfies Prisma.CommentSelect
}
function extractParentIds(
comment: Prisma.CommentGetPayload<{ select: ParentQueryRecursive }> | null,
acc: number[] = []
) {
if (!comment?.parent?.id) return acc
return extractParentIds(comment.parent as Prisma.CommentGetPayload<{ select: ParentQueryRecursive }>, [
...acc,
comment.parent.id,
])
}
export function makeCommentUrl({ export function makeCommentUrl({
serviceSlug, serviceSlug,
commentId, commentId,

View File

@@ -1,5 +0,0 @@
import { z } from 'astro/zod'
const schema = z.enum(['development', 'staging', 'production'])
export const DEPLOYMENT_MODE = schema.parse(import.meta.env.PROD ? import.meta.env.MODE : 'development')

View File

@@ -39,7 +39,6 @@ export async function getService(slug: string | undefined): Promise<
const service = const service =
(await prisma.service.findFirst({ (await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
slug, slug,
}, },
@@ -47,7 +46,6 @@ export async function getService(slug: string | undefined): Promise<
})) ?? })) ??
(await prisma.service.findFirst({ (await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: slug }, previousSlugs: { has: slug },
}, },
@@ -175,7 +173,6 @@ export async function getEvents(): Promise<
where: { where: {
visible: true, visible: true,
service: { service: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
}, },
}, },

View File

@@ -29,7 +29,7 @@ export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'l
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
await redisImpersonationSessions.delete(sessionId) await redisImpersonationSessions.delete(sessionId)
context.cookies.delete(IMPERSONATION_SESSION_COOKIE) context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
context.locals.user = context.locals.actualUser context.locals.user = context.locals.actualUser ?? context.locals.user
context.locals.actualUser = null context.locals.actualUser = null
} }

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

@@ -1,4 +1,4 @@
import { addDays, format, isBefore, isToday, isYesterday } from 'date-fns' import { addDays, differenceInDays, format, isBefore, isToday, isYesterday } from 'date-fns'
import TimeAgo from 'javascript-time-ago' import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en' import en from 'javascript-time-ago/locale/en'
@@ -47,3 +47,10 @@ export function formatDateShort(
return transformCase(text, caseType) return transformCase(text, caseType)
} }
export function formatDaysAgo(approvedAt: Date) {
const days = differenceInDays(new Date(), approvedAt)
if (days === 0) return 'today'
if (days === 1) return 'yesterday'
return `${days.toLocaleString()} days ago`
}

View File

@@ -53,7 +53,7 @@ export async function removeUserSessionIdCookie(cookies: AstroCookies) {
cookies.delete(COOKIE_NAME, { path: '/' }) cookies.delete(COOKIE_NAME, { path: '/' })
} }
export async function logout(context: Pick<APIContext, 'cookies' | 'locals'>) { export async function logout(context: Pick<APIContext, 'cookies' | 'locals' | 'request' | 'url'>) {
await stopImpersonating(context) await stopImpersonating(context)
await removeUserSessionIdCookie(context.cookies) await removeUserSessionIdCookie(context.cookies)

View File

@@ -10,7 +10,7 @@ import {
} from '../constants/characters' } from '../constants/characters'
import { getRandom, typedJoin } from './arrays' import { getRandom, typedJoin } from './arrays'
import { DEPLOYMENT_MODE } from './envVariables' import { DEPLOYMENT_MODE } from './client/envVariables'
import { transformCase } from './strings' import { transformCase } from './strings'
const DIGEST = 'sha512' const DIGEST = 'sha512'

View File

@@ -5,7 +5,7 @@ import { LOGS_UI_URL } from 'astro:env/server'
import { SUPPORT_EMAIL } from '../constants/project' import { SUPPORT_EMAIL } from '../constants/project'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../lib/envVariables' import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
type Props = { type Props = {

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
@@ -165,7 +181,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
##### Overall Score ##### Overall Score
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust. The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` truncated. This provides a combined measure of privacy and trust.
#### Terms of Service Reviews #### Terms of Service Reviews
@@ -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,9 +20,11 @@ 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'
import { urlDomain } from '../../lib/urls'
const userId = Astro.locals.user?.id const userId = Astro.locals.user?.id
if (!userId) { if (!userId) {
@@ -48,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,
@@ -157,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
@@ -393,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>
@@ -423,7 +458,7 @@ if (!user) return Astro.rewrite('/404')
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-blue-400 hover:underline" class="text-blue-400 hover:underline"
> >
{user.verifiedLink} {urlDomain(user.verifiedLink)}
</a> </a>
</div> </div>
</li> </li>
@@ -857,12 +892,7 @@ if (!user) return Astro.rewrite('/404')
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span <span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
class={cn(
'border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" /> <Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label} {statusInfo.label}
</span> </span>
@@ -966,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

@@ -271,7 +271,6 @@ if (toggleResult?.error) {
label: type.label, label: type.label,
value: type.value, value: type.value,
icon: type.icon, icon: type.icon,
noTransitionPersist: false,
}))} }))}
cardSize="sm" cardSize="sm"
required required
@@ -306,8 +305,8 @@ if (toggleResult?.error) {
label="Status" label="Status"
error={createInputErrors.isActive} error={createInputErrors.isActive}
options={[ options={[
{ label: 'Active', value: 'true', noTransitionPersist: true }, { label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false', noTransitionPersist: true }, { label: 'Inactive', value: 'false' },
]} ]}
selectedValue={newAnnouncement.isActive ? 'true' : 'false'} selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
cardSize="sm" cardSize="sm"
@@ -628,7 +627,6 @@ if (toggleResult?.error) {
label: type.label, label: type.label,
value: type.value, value: type.value,
icon: type.icon, icon: type.icon,
noTransitionPersist: true,
}))} }))}
cardSize="sm" cardSize="sm"
required required
@@ -661,8 +659,8 @@ if (toggleResult?.error) {
name="isActive" name="isActive"
label="Status" label="Status"
options={[ options={[
{ label: 'Active', value: 'true', noTransitionPersist: true }, { label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false', noTransitionPersist: true }, { label: 'Inactive', value: 'false' },
]} ]}
selectedValue={announcement.isActive ? 'true' : 'false'} selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm" cardSize="sm"

View File

@@ -692,12 +692,12 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<Button <Button
as="button"
color="gray" color="gray"
variant="faded" variant="faded"
size="sm" size="sm"
label="Cancel" label="Cancel"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`} data-cancel-button
data-cancel-form-id={`edit-form-${index}`}
/> />
<Button <Button
as="button" as="button"
@@ -721,3 +721,22 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>
<script>
////////////////////////////////////////////////////////////
// Optional script for cancel buttons in attribute forms. //
// Hides the edit form when cancel button is clicked. //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLButtonElement>('[data-cancel-button]').forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault()
const formId = button.getAttribute('data-cancel-form-id')
if (!formId) throw new Error('Form ID not found')
const form = document.getElementById(formId)
if (!form) throw new Error('Form not found')
form.classList.add('hidden')
})
})
})
</script>

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'
@@ -35,7 +37,7 @@ import {
verificationStepStatuses, verificationStepStatuses,
} from '../../../../constants/verificationStepStatus' } from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro' import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables' import { DEPLOYMENT_MODE } from '../../../../lib/client/envVariables'
import { listFiles } from '../../../../lib/fileStorage' import { listFiles } from '../../../../lib/fileStorage'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo' import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize' import { pluralize } from '../../../../lib/pluralize'
@@ -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"
@@ -441,7 +484,6 @@ const apiCalls = await Astro.locals.banners.try(
value: kycLevel.id.toString(), value: kycLevel.id.toString(),
icon: kycLevel.icon, icon: kycLevel.icon,
description: kycLevel.description, description: kycLevel.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.kycLevel.toString()} selectedValue={service.kycLevel.toString()}
iconSize="md" iconSize="md"
@@ -458,7 +500,6 @@ const apiCalls = await Astro.locals.banners.try(
value: clarification.value, value: clarification.value,
icon: clarification.icon, icon: clarification.icon,
description: clarification.description, description: clarification.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.kycLevelClarification} selectedValue={service.kycLevelClarification}
iconSize="sm" iconSize="sm"
@@ -475,7 +516,6 @@ const apiCalls = await Astro.locals.banners.try(
icon: status.icon, icon: status.icon,
iconClass: status.classNames.icon, iconClass: status.classNames.icon,
description: status.description, description: status.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.verificationStatus} selectedValue={service.verificationStatus}
error={serviceInputErrors.verificationStatus} error={serviceInputErrors.verificationStatus}
@@ -491,7 +531,6 @@ const apiCalls = await Astro.locals.banners.try(
label: currency.name, label: currency.name,
value: currency.id, value: currency.id,
icon: currency.icon, icon: currency.icon,
noTransitionPersist: true,
}))} }))}
selectedValue={service.acceptedCurrencies} selectedValue={service.acceptedCurrencies}
error={serviceInputErrors.acceptedCurrencies} error={serviceInputErrors.acceptedCurrencies}
@@ -532,13 +571,30 @@ const apiCalls = await Astro.locals.banners.try(
icon: visibility.icon, icon: visibility.icon,
iconClass: visibility.iconClass, iconClass: visibility.iconClass,
description: visibility.description, description: visibility.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.serviceVisibility} selectedValue={service.serviceVisibility}
error={serviceInputErrors.serviceVisibility} error={serviceInputErrors.serviceVisibility}
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

@@ -230,26 +230,22 @@ if (!user) return Astro.rewrite('/404')
label: 'Admin', label: 'Admin',
value: 'admin', value: 'admin',
icon: 'ri:shield-star-fill', icon: 'ri:shield-star-fill',
noTransitionPersist: true,
}, },
{ {
label: 'Moderator', label: 'Moderator',
value: 'moderator', value: 'moderator',
icon: 'ri:graduation-cap-fill', icon: 'ri:graduation-cap-fill',
noTransitionPersist: true,
}, },
{ {
label: 'Spammer', label: 'Spammer',
value: 'spammer', value: 'spammer',
icon: 'ri:alert-fill', icon: 'ri:alert-fill',
noTransitionPersist: true,
}, },
{ {
label: 'Verified', label: 'Verified',
value: 'verified', value: 'verified',
icon: 'ri:verified-badge-fill', icon: 'ri:verified-badge-fill',
disabled: true, disabled: true,
noTransitionPersist: true,
}, },
]} ]}
selectedValue={[ selectedValue={[
@@ -434,7 +430,6 @@ if (!user) return Astro.rewrite('/404')
label: role.label, label: role.label,
value: role.value, value: role.value,
icon: role.icon, icon: role.icon,
noTransitionPersist: true,
}))} }))}
required required
cardSize="sm" cardSize="sm"
@@ -466,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">

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