Release 202507030838
This commit is contained in:
13
.env.example
13
.env.example
@@ -39,8 +39,11 @@ OPENAI_BASE_URL="https://your-openai-base-url.example.com"
|
||||
OPENAI_MODEL=your_openai_model
|
||||
OPENAI_RETRY=3
|
||||
|
||||
# Pyworker Crons
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day
|
||||
# Task schedules ---------------------------------------------------
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||
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
|
||||
|
||||
@@ -14,8 +14,11 @@ OPENAI_BASE_URL="https://xxxxxx/api/v1"
|
||||
OPENAI_MODEL="xxxxxxxxx"
|
||||
OPENAI_RETRY=3
|
||||
|
||||
CRON_TOSREVIEW_TASK=0 0 1 * * # Every month
|
||||
CRON_USER_SENTIMENT_TASK=0 0 * * * # Every day
|
||||
CRON_COMMENT_MODERATION_TASK=0 0 * * * # Every hour
|
||||
CRON_FORCE_TRIGGERS_TASK=0 2 * * * # Every day
|
||||
CRON_SERVICE_SCORE_RECALC_TASK=*/5 * * * * # Every 10 minutes
|
||||
# Task schedules ---------------------------------------------------
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||
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
|
||||
@@ -38,6 +38,7 @@ Required environment variables:
|
||||
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
||||
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers 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
|
||||
|
||||
@@ -60,6 +61,9 @@ uv run -m pyworker force-triggers
|
||||
|
||||
# Run service score recalculation task
|
||||
uv run -m pyworker service-score-recalc [--service-id ID]
|
||||
|
||||
# Run inactive users cleanup task
|
||||
uv run -m pyworker inactive-users
|
||||
```
|
||||
|
||||
### Worker Mode
|
||||
@@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules.
|
||||
- Calculates privacy, trust, and overall scores
|
||||
- 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
|
||||
|
||||
### Project Structure
|
||||
@@ -124,6 +137,7 @@ pyworker/
|
||||
│ │ ├── base.py
|
||||
│ │ ├── comment_moderation.py
|
||||
│ │ ├── force_triggers.py
|
||||
│ │ ├── inactive_users.py
|
||||
│ │ ├── service_score_recalc.py
|
||||
│ │ ├── tos_review.py
|
||||
│ │ └── user_sentiment.py
|
||||
|
||||
@@ -17,6 +17,7 @@ from pyworker.scheduler import TaskScheduler
|
||||
from .tasks import (
|
||||
CommentModerationTask,
|
||||
ForceTriggersTask,
|
||||
InactiveUsersTask,
|
||||
ServiceScoreRecalculationTask,
|
||||
TosReviewTask,
|
||||
UserSentimentTask,
|
||||
@@ -101,6 +102,12 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
||||
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)
|
||||
|
||||
|
||||
@@ -371,6 +378,30 @@ def run_service_score_recalc_all_task() -> int:
|
||||
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:
|
||||
"""
|
||||
Run in worker mode, scheduling tasks to run periodically.
|
||||
@@ -382,54 +413,37 @@ def run_worker_mode() -> int:
|
||||
|
||||
# Get task schedules from config
|
||||
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(
|
||||
"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
|
||||
|
||||
logger.info(
|
||||
f"Found {len(task_schedules)} scheduled tasks: {', '.join(task_schedules.keys())}"
|
||||
)
|
||||
|
||||
# Initialize the scheduler
|
||||
scheduler = TaskScheduler()
|
||||
|
||||
# Register tasks with their schedules
|
||||
for task_name, cron_expression in task_schedules.items():
|
||||
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
|
||||
)
|
||||
elif task_name.lower() == "service_score_recalc_all":
|
||||
scheduler.register_task(
|
||||
task_name,
|
||||
cron_expression,
|
||||
run_service_score_recalc_all_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,
|
||||
)
|
||||
# Register daily service score recalculation for all services
|
||||
scheduler.register_task(
|
||||
"service_score_recalc_all",
|
||||
"0 0 * * *",
|
||||
run_service_score_recalc_all_task,
|
||||
)
|
||||
for task_name, task_callable in required_tasks.items():
|
||||
scheduler.register_task(task_name, task_schedules[task_name], task_callable)
|
||||
|
||||
# Start the scheduler if tasks were registered
|
||||
if scheduler.tasks:
|
||||
@@ -484,6 +498,8 @@ def main() -> int:
|
||||
)
|
||||
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:
|
||||
logger.error(f"Unknown task: {args.task}")
|
||||
return 1
|
||||
|
||||
@@ -14,6 +14,7 @@ from pyworker.database import close_db_pool
|
||||
from .tasks import (
|
||||
CommentModerationTask,
|
||||
ForceTriggersTask,
|
||||
InactiveUsersTask,
|
||||
ServiceScoreRecalculationTask,
|
||||
TosReviewTask,
|
||||
UserSentimentTask,
|
||||
@@ -80,6 +81,8 @@ class TaskScheduler:
|
||||
task_instance = ForceTriggersTask()
|
||||
elif task_name.lower() == "service_score_recalc":
|
||||
task_instance = ServiceScoreRecalculationTask()
|
||||
elif task_name.lower() == "inactive_users":
|
||||
task_instance = InactiveUsersTask()
|
||||
else:
|
||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from .base import Task
|
||||
from .comment_moderation import CommentModerationTask
|
||||
from .force_triggers import ForceTriggersTask
|
||||
from .inactive_users import InactiveUsersTask
|
||||
from .service_score_recalc import ServiceScoreRecalculationTask
|
||||
from .tos_review import TosReviewTask
|
||||
from .user_sentiment import UserSentimentTask
|
||||
@@ -11,6 +12,7 @@ __all__ = [
|
||||
"Task",
|
||||
"CommentModerationTask",
|
||||
"ForceTriggersTask",
|
||||
"InactiveUsersTask",
|
||||
"ServiceScoreRecalculationTask",
|
||||
"TosReviewTask",
|
||||
"UserSentimentTask",
|
||||
|
||||
258
pyworker/pyworker/tasks/inactive_users.py
Normal file
258
pyworker/pyworker/tasks/inactive_users.py
Normal 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
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Category" ADD COLUMN "namePluralLong" TEXT;
|
||||
@@ -144,6 +144,10 @@ enum NotificationType {
|
||||
ACCOUNT_STATUS_CHANGE
|
||||
EVENT_CREATED
|
||||
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 {
|
||||
@@ -512,6 +516,8 @@ model User {
|
||||
feedId String @unique @default(cuid(2))
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
totalKarma Int @default(0)
|
||||
/// Date when user is scheduled for deletion due to inactivity
|
||||
scheduledDeletionAt DateTime? @db.Date
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -611,6 +617,7 @@ model VerificationStep {
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
namePluralLong String?
|
||||
icon String
|
||||
slug String @unique
|
||||
|
||||
|
||||
@@ -141,81 +141,97 @@ const generateFakeAttribute = () => {
|
||||
const categoriesToCreate = [
|
||||
{
|
||||
name: 'Exchange',
|
||||
namePluralLong: 'Exchanges',
|
||||
slug: 'exchange',
|
||||
icon: 'ri:arrow-left-right-fill',
|
||||
},
|
||||
{
|
||||
name: 'VPN',
|
||||
namePluralLong: 'VPNs',
|
||||
slug: 'vpn',
|
||||
icon: 'ri:door-lock-fill',
|
||||
},
|
||||
{
|
||||
name: 'Email',
|
||||
namePluralLong: 'Email providers',
|
||||
slug: 'email',
|
||||
icon: 'ri:mail-fill',
|
||||
},
|
||||
{
|
||||
name: 'Hosting',
|
||||
namePluralLong: 'Hostings',
|
||||
slug: 'hosting',
|
||||
icon: 'ri:server-fill',
|
||||
},
|
||||
{
|
||||
name: 'VPS',
|
||||
namePluralLong: 'VPS providers',
|
||||
slug: 'vps',
|
||||
icon: 'ri:function-add-fill',
|
||||
},
|
||||
{
|
||||
name: 'Gift Cards',
|
||||
namePluralLong: 'Gift cards',
|
||||
slug: 'gift-cards',
|
||||
icon: 'ri:gift-line',
|
||||
},
|
||||
{
|
||||
name: 'Goods',
|
||||
namePluralLong: 'Goods providers',
|
||||
slug: 'goods',
|
||||
icon: 'ri:shopping-basket-fill',
|
||||
},
|
||||
{
|
||||
name: 'Travel',
|
||||
namePluralLong: 'Travel services',
|
||||
slug: 'travel',
|
||||
icon: 'ri:plane-fill',
|
||||
},
|
||||
{
|
||||
name: 'SMS',
|
||||
namePluralLong: 'SMS providers',
|
||||
slug: 'sms',
|
||||
icon: 'ri:message-2-fill',
|
||||
},
|
||||
{
|
||||
name: 'Store',
|
||||
namePluralLong: 'Stores',
|
||||
slug: 'store',
|
||||
icon: 'ri:store-2-line',
|
||||
},
|
||||
{
|
||||
name: 'Tool',
|
||||
namePluralLong: 'Tools',
|
||||
slug: 'tool',
|
||||
icon: 'ri:tools-fill',
|
||||
},
|
||||
{
|
||||
name: 'Market',
|
||||
namePluralLong: 'Markets',
|
||||
slug: 'market',
|
||||
icon: 'ri:price-tag-3-line',
|
||||
},
|
||||
{
|
||||
name: 'Aggregator',
|
||||
namePluralLong: 'Aggregators',
|
||||
slug: 'aggregator',
|
||||
icon: 'ri:list-ordered',
|
||||
},
|
||||
{
|
||||
name: 'AI',
|
||||
namePluralLong: 'AI services',
|
||||
slug: 'ai',
|
||||
icon: 'ri:ai-generate-2',
|
||||
},
|
||||
{
|
||||
name: 'CEX',
|
||||
namePluralLong: 'CEXs',
|
||||
slug: 'cex',
|
||||
icon: 'ri:rotate-lock-fill',
|
||||
},
|
||||
{
|
||||
name: 'DEX',
|
||||
namePluralLong: 'DEXs',
|
||||
slug: 'dex',
|
||||
icon: 'ri:fediverse-line',
|
||||
},
|
||||
|
||||
@@ -275,12 +275,18 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
service_name TEXT;
|
||||
service_visibility "ServiceVisibility";
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
-- Award karma for first approval
|
||||
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
||||
-- and ensure it wasn't already APPROVED.
|
||||
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
||||
-- Fetch service details for the description
|
||||
SELECT name, visibility 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"
|
||||
@@ -289,9 +295,6 @@ BEGIN
|
||||
|
||||
-- Only award karma if the user is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
-- Fetch service name for the description
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
||||
|
||||
-- Insert karma transaction, linking it to the suggestion
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."userId",
|
||||
@@ -306,6 +309,7 @@ BEGIN
|
||||
PERFORM update_user_karma(NEW."userId", 10);
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
||||
END;
|
||||
|
||||
@@ -49,13 +49,22 @@ 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,
|
||||
skipKeys: (keyof T)[] = []
|
||||
): string => {
|
||||
return Object.entries(input)
|
||||
.filter(([key]) => !skipKeys.includes(key as keyof T))
|
||||
.map(([key, value]) => {
|
||||
skipKeys: NK[] = [],
|
||||
mapKeys: { [P in Exclude<Extract<keyof T, string>, NK>]?: (value: T[P]) => unknown } = {}
|
||||
): Promise<string> => {
|
||||
return (
|
||||
await Promise.all(
|
||||
Object.entries(input)
|
||||
.filter(
|
||||
(
|
||||
entry
|
||||
): entry is [Exclude<Extract<keyof T, string>, NK>, T[Exclude<Extract<keyof T, string>, NK>]] =>
|
||||
!skipKeys.some((k) => k === entry[0])
|
||||
)
|
||||
.map(async ([key, originalValue]) => {
|
||||
const value = mapKeys[key] ? await mapKeys[key](originalValue) : originalValue
|
||||
let serializedValue = ''
|
||||
if (typeof value === 'string') {
|
||||
serializedValue = value
|
||||
@@ -63,7 +72,15 @@ const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
serializedValue = ''
|
||||
} else if (Array.isArray(value)) {
|
||||
serializedValue = value.map((item) => String(item)).join(', ')
|
||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
||||
} else if (typeof value === 'object' && value instanceof Date) {
|
||||
serializedValue = value.toISOString()
|
||||
} else if (
|
||||
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 {
|
||||
@@ -75,7 +92,8 @@ const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
}
|
||||
return `- ${key}: ${serializedValue}`
|
||||
})
|
||||
.join('\n')
|
||||
)
|
||||
).join('\n')
|
||||
}
|
||||
|
||||
export const serviceSuggestionActions = {
|
||||
@@ -200,14 +218,37 @@ export const serviceSuggestionActions = {
|
||||
return {
|
||||
hasDuplicates: true,
|
||||
possibleDuplicates,
|
||||
extraNotes: serializeExtraNotes(input, [
|
||||
extraNotes: await serializeExtraNotes(
|
||||
input,
|
||||
[
|
||||
'skipDuplicateCheck',
|
||||
'message',
|
||||
'imageFile',
|
||||
'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,
|
||||
service: undefined,
|
||||
} as const
|
||||
|
||||
@@ -6,9 +6,11 @@ import { cn } from '../lib/cn'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { O } from 'ts-toolbelt'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
announcement: Prisma.AnnouncementGetPayload<{
|
||||
announcement: O.Optional<
|
||||
Prisma.AnnouncementGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
content: true
|
||||
@@ -19,7 +21,9 @@ type Props = HTMLAttributes<'div'> & {
|
||||
endDate: true
|
||||
isActive: true
|
||||
}
|
||||
}>
|
||||
}>,
|
||||
'link' | 'linkText'
|
||||
>
|
||||
}
|
||||
|
||||
const { announcement, class: className, ...props } = Astro.props
|
||||
@@ -31,7 +35,9 @@ const Tag = announcement.link ? 'a' : 'div'
|
||||
|
||||
<Tag
|
||||
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"
|
||||
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',
|
||||
@@ -78,15 +84,15 @@ const Tag = announcement.link ? 'a' : 'div'
|
||||
</span>
|
||||
</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]"
|
||||
>
|
||||
<span class="2xs:inline-block hidden">
|
||||
{announcement.linkText}
|
||||
</span>
|
||||
{
|
||||
!!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">{announcement.linkText}</span>
|
||||
<Icon
|
||||
name="ri:arrow-right-line"
|
||||
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
---
|
||||
|
||||
<script>
|
||||
import * as htmx from 'htmx.org'
|
||||
import htmx from 'htmx.org'
|
||||
|
||||
htmx.config.globalViewTransitions = false
|
||||
|
||||
|
||||
@@ -11,9 +11,19 @@ type Props = HTMLAttributes<'a'> & {
|
||||
searchParamValue?: string
|
||||
icon?: 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 url = new URL(Astro.url)
|
||||
@@ -30,7 +40,7 @@ const makeUrlWithoutFilter = (filter: string, value?: string) => {
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <Icon name={icon} class={cn('size-4', iconClass)} />}
|
||||
{icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
|
||||
{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>
|
||||
|
||||
182
web/src/components/ServiceFiltersPillsRow.astro
Normal file
182
web/src/components/ServiceFiltersPillsRow.astro
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
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 { AttributeOption, ServicesFiltersObject, ServicesFiltersOptions } 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[]
|
||||
}
|
||||
|
||||
const {
|
||||
class: className,
|
||||
filters,
|
||||
filtersOptions,
|
||||
categories,
|
||||
attributes,
|
||||
attributeOptions,
|
||||
...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
|
||||
)}
|
||||
{...divProps}
|
||||
>
|
||||
{
|
||||
filters.q && (
|
||||
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
filters.categories.map((categorySlug) => {
|
||||
const category = categories.find((c) => c.slug === categorySlug)
|
||||
if (!category) return null
|
||||
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={category.name}
|
||||
icon={category.icon}
|
||||
searchParamName="categories"
|
||||
searchParamValue={categorySlug}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
filters.currencies.map((currencyId) => {
|
||||
const currency = getCurrencyInfo(currencyId)
|
||||
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={currency.name}
|
||||
searchParamName="currencies"
|
||||
searchParamValue={currency.slug}
|
||||
icon={currency.icon}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
filters.networks.map((network) => {
|
||||
const networkOption = getNetworkInfo(network)
|
||||
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={networkOption.name}
|
||||
icon={networkOption.icon}
|
||||
searchParamName="networks"
|
||||
searchParamValue={network}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
{
|
||||
filters['max-kyc'] < 4 && (
|
||||
<ServiceFiltersPill
|
||||
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
||||
icon="ri:shield-keyhole-line"
|
||||
searchParamName="max-kyc"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
filters['user-rating'] > 0 && (
|
||||
<ServiceFiltersPill
|
||||
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
||||
icon="ri:star-fill"
|
||||
searchParamName="user-rating"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
filters['min-score'] > 0 && (
|
||||
<ServiceFiltersPill
|
||||
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
||||
icon="ri:medal-line"
|
||||
searchParamName="min-score"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && (
|
||||
<ServiceFiltersPill
|
||||
text="Attributes: AND"
|
||||
icon="ri:filter-3-line"
|
||||
searchParamName="attribute-mode"
|
||||
searchParamValue="and"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
filters.attr &&
|
||||
Object.entries(filters.attr)
|
||||
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
|
||||
.map(([attributeId, attributeValue]) => {
|
||||
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
|
||||
if (!attribute) return null
|
||||
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
|
||||
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={`${prefix}: ${attribute.title}`}
|
||||
searchParamName={`attr-${attributeId}`}
|
||||
searchParamValue={attributeValue}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
!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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
@@ -1,16 +1,22 @@
|
||||
---
|
||||
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 { getKycLevelInfo } from '../constants/kycLevels'
|
||||
import { verificationStatusesByValue, getVerificationStatusInfo } from '../constants/verificationStatus'
|
||||
import { areEqualArraysWithoutOrder } from '../lib/arrays'
|
||||
import { cn } from '../lib/cn'
|
||||
import { pluralize } from '../lib/pluralize'
|
||||
import { transformCase } from '../lib/strings'
|
||||
import { createPageUrl, urlWithParams } from '../lib/urls'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import ServiceCard from './ServiceCard.astro'
|
||||
import ServiceFiltersPillsRow from './ServiceFiltersPillsRow.astro'
|
||||
|
||||
import type { ServicesFiltersObject } from '../pages/index.astro'
|
||||
import type { AttributeOption, ServicesFiltersObject, ServicesFiltersOptions } from '../pages/index.astro'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
@@ -23,6 +29,22 @@ type Props = HTMLAttributes<'div'> & {
|
||||
filters: ServicesFiltersObject
|
||||
countCommunityOnly: number | null
|
||||
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 {
|
||||
@@ -36,6 +58,10 @@ const {
|
||||
filters,
|
||||
countCommunityOnly,
|
||||
inlineIcons,
|
||||
categories,
|
||||
filtersOptions,
|
||||
attributes,
|
||||
attributeOptions,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
|
||||
@@ -55,9 +81,117 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug,
|
||||
]),
|
||||
})
|
||||
|
||||
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) {
|
||||
const kycLevelInfo = getKycLevelInfo(String(filters['max-kyc']))
|
||||
kycLevel = `with ${kycLevelInfo.name}`
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
{total.toLocaleString()}
|
||||
|
||||
@@ -76,5 +76,25 @@ export const {
|
||||
label: 'Service verification changed',
|
||||
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>[]
|
||||
)
|
||||
|
||||
2
web/src/env.d.ts
vendored
2
web/src/env.d.ts
vendored
@@ -3,7 +3,7 @@
|
||||
import type { ErrorBanners } from './lib/errorBanners'
|
||||
import type { KarmaUnlocks } from './lib/karmaUnlocks'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type * as htmx from 'htmx.org'
|
||||
import type htmx from 'htmx.org'
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
---
|
||||
import { differenceInCalendarDays } from 'date-fns'
|
||||
|
||||
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
|
||||
import BaseHead from '../components/BaseHead.astro'
|
||||
import Footer from '../components/Footer.astro'
|
||||
import Header from '../components/Header.astro'
|
||||
import { cn } from '../lib/cn'
|
||||
import { pluralize } from '../lib/pluralize'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
import '@fontsource-variable/space-grotesk'
|
||||
@@ -71,6 +75,26 @@ const announcement = await Astro.locals.banners.try(
|
||||
}),
|
||||
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">
|
||||
@@ -85,6 +109,14 @@ const announcement = await Astro.locals.banners.try(
|
||||
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
|
||||
>
|
||||
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||
{
|
||||
deletionAnnouncement && (
|
||||
<AnnouncementBanner
|
||||
announcement={deletionAnnouncement}
|
||||
transition:name="deletion-warning-announcement"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<Header
|
||||
classNames={{
|
||||
nav: cn(
|
||||
|
||||
@@ -185,6 +185,18 @@ export function makeNotificationTitle(
|
||||
serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange]
|
||||
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
|
||||
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`,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ const knownPlurals = {
|
||||
singular: 'Request',
|
||||
plural: 'Requests',
|
||||
},
|
||||
day: {
|
||||
singular: 'Day',
|
||||
plural: 'Days',
|
||||
},
|
||||
something: {
|
||||
singular: 'Something',
|
||||
plural: 'Somethings',
|
||||
|
||||
@@ -20,7 +20,7 @@ export const areSameNormalized = (str1: string, str2: string): boolean => {
|
||||
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.
|
||||
@@ -31,6 +31,7 @@ export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'u
|
||||
* transformCase('hello WORLD', 'sentence') // 'Hello world'
|
||||
* transformCase('hello WORLD', 'title') // 'Hello World'
|
||||
* transformCase('hello WORLD', 'original') // 'hello WORLD'
|
||||
* transformCase('Hello WORLD', 'first-upper') // 'Hello WORLD'
|
||||
*/
|
||||
export const transformCase = <T extends string, C extends TransformCaseType>(
|
||||
str: T,
|
||||
@@ -43,6 +44,8 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
|
||||
? Capitalize<Lowercase<T>>
|
||||
: C extends 'title'
|
||||
? Capitalize<Lowercase<T>>
|
||||
: C extends 'first-upper'
|
||||
? Capitalize<T>
|
||||
: T => {
|
||||
switch (caseType) {
|
||||
case 'lower':
|
||||
@@ -54,6 +57,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
|
||||
case 'sentence':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
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':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return str
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
import { differenceInCalendarDays } from 'date-fns'
|
||||
import { sortBy } from 'lodash-es'
|
||||
|
||||
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||
@@ -19,6 +20,7 @@ import { verificationStatusesByValue } from '../../constants/verificationStatus'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
||||
import { pluralize } from '../../lib/pluralize'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
import { formatDateShort } from '../../lib/timeAgo'
|
||||
@@ -49,6 +51,7 @@ const user = await Astro.locals.banners.try('user', async () => {
|
||||
verifiedLink: true,
|
||||
totalKarma: true,
|
||||
createdAt: true,
|
||||
scheduledDeletionAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
comments: true,
|
||||
@@ -158,6 +161,10 @@ const user = await Astro.locals.banners.try('user', async () => {
|
||||
})
|
||||
|
||||
if (!user) return Astro.rewrite('/404')
|
||||
|
||||
const daysUntilDeletion = user.scheduledDeletionAt
|
||||
? differenceInCalendarDays(user.scheduledDeletionAt, new Date())
|
||||
: null
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -394,6 +401,33 @@ if (!user) return Astro.rewrite('/404')
|
||||
</div>
|
||||
</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()} {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">
|
||||
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
|
||||
<div>
|
||||
|
||||
@@ -28,6 +28,11 @@ There are several ways to earn karma points:
|
||||
- Similarly, each downvote reduces your karma by -1.
|
||||
- This allows the community to reward helpful contributions.
|
||||
|
||||
4. **Suggestion Approval** (+10 points)
|
||||
- When your suggestion to add or edit a service is approved and the service is listed publicly.
|
||||
- Suggestions on non-listed services do not earn karma.
|
||||
- This rewards users for helping to expand and improve the service directory.
|
||||
|
||||
## Karma Penalties
|
||||
|
||||
The system also includes penalties to discourage spam and low-quality content:
|
||||
|
||||
@@ -6,20 +6,14 @@ import seedrandom from 'seedrandom'
|
||||
import Button from '../components/Button.astro'
|
||||
import InputText from '../components/InputText.astro'
|
||||
import Pagination from '../components/Pagination.astro'
|
||||
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro'
|
||||
import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.astro'
|
||||
import ServicesFilters from '../components/ServicesFilters.astro'
|
||||
import ServicesSearchResults from '../components/ServicesSearchResults.astro'
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { currencies, currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies'
|
||||
import { networks } from '../constants/networks'
|
||||
import {
|
||||
currencies,
|
||||
currenciesZodEnumBySlug,
|
||||
currencySlugToId,
|
||||
getCurrencyInfo,
|
||||
} from '../constants/currencies'
|
||||
import { getNetworkInfo, networks } from '../constants/networks'
|
||||
import {
|
||||
getVerificationStatusInfo,
|
||||
verificationStatuses,
|
||||
verificationStatusesZodEnumBySlug,
|
||||
verificationStatusSlugToId,
|
||||
@@ -32,7 +26,6 @@ import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { makeSortSeed } from '../lib/sortSeed'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
@@ -115,23 +108,29 @@ const modeOptions = [
|
||||
label: string
|
||||
}[]
|
||||
|
||||
export type AttributeOption = {
|
||||
value: string
|
||||
prefix: string
|
||||
prefixWith: string
|
||||
}
|
||||
|
||||
const attributeOptions = [
|
||||
{
|
||||
value: 'yes',
|
||||
prefix: 'Has',
|
||||
prefixWith: 'with',
|
||||
},
|
||||
{
|
||||
value: 'no',
|
||||
prefix: 'Not',
|
||||
prefixWith: 'without',
|
||||
},
|
||||
{
|
||||
value: '',
|
||||
prefix: '',
|
||||
prefixWith: '',
|
||||
},
|
||||
] as const satisfies {
|
||||
value: string
|
||||
prefix: string
|
||||
}[]
|
||||
] as const satisfies AttributeOption[]
|
||||
|
||||
const ignoredKeysForDefaultData = ['sort-seed']
|
||||
|
||||
@@ -309,6 +308,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
prisma.category.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
namePluralLong: true,
|
||||
slug: true,
|
||||
icon: true,
|
||||
_count: {
|
||||
@@ -322,6 +322,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
],
|
||||
[
|
||||
'Unable to load services.',
|
||||
@@ -507,7 +508,7 @@ const attributesByCategory = orderBy(
|
||||
)
|
||||
|
||||
const categoriesSorted = orderBy(
|
||||
categories?.map((category) => {
|
||||
categories.map((category) => {
|
||||
const checked = filters.categories.includes(category.slug)
|
||||
|
||||
return {
|
||||
@@ -584,115 +585,14 @@ const showFiltersId = 'show-filters'
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<div class="-ml-4 flex flex-1 items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4">
|
||||
{filters.q && (
|
||||
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
|
||||
)}
|
||||
|
||||
{!areEqualArraysWithoutOrder(
|
||||
filters.verification,
|
||||
filtersOptions.verification
|
||||
.filter((verification) => verification.default)
|
||||
.map((verification) => verification.value)
|
||||
) &&
|
||||
filters.verification.map((verificationStatus) => {
|
||||
const verificationStatusInfo = getVerificationStatusInfo(verificationStatus)
|
||||
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={verificationStatusInfo.label}
|
||||
icon={verificationStatusInfo.icon}
|
||||
iconClass={verificationStatusInfo.classNames.icon}
|
||||
searchParamName="verification"
|
||||
searchParamValue={verificationStatusInfo.slug}
|
||||
<ServiceFiltersPillsRow
|
||||
filters={filters}
|
||||
filtersOptions={filtersOptions}
|
||||
categories={categories}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{filters.categories.map((categorySlug) => {
|
||||
const category = categories?.find((c) => c.slug === categorySlug)
|
||||
if (!category) return null
|
||||
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={category.name}
|
||||
icon={category.icon}
|
||||
searchParamName="categories"
|
||||
searchParamValue={categorySlug}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{filters.currencies.map((currencyId) => {
|
||||
const currency = getCurrencyInfo(currencyId)
|
||||
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={currency.name}
|
||||
searchParamName="currencies"
|
||||
searchParamValue={currency.slug}
|
||||
icon={currency.icon}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{filters.networks.map((network) => {
|
||||
const networkOption = getNetworkInfo(network)
|
||||
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={networkOption.name}
|
||||
icon={networkOption.icon}
|
||||
searchParamName="networks"
|
||||
searchParamValue={network}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{filters['max-kyc'] < 4 && (
|
||||
<ServiceFiltersPill
|
||||
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
||||
icon="ri:shield-keyhole-line"
|
||||
searchParamName="max-kyc"
|
||||
/>
|
||||
)}
|
||||
{filters['user-rating'] > 0 && (
|
||||
<ServiceFiltersPill
|
||||
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
||||
icon="ri:star-fill"
|
||||
searchParamName="user-rating"
|
||||
/>
|
||||
)}
|
||||
{filters['min-score'] > 0 && (
|
||||
<ServiceFiltersPill
|
||||
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
||||
icon="ri:medal-line"
|
||||
searchParamName="min-score"
|
||||
/>
|
||||
)}
|
||||
{filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && (
|
||||
<ServiceFiltersPill
|
||||
text="Attributes: AND"
|
||||
icon="ri:filter-3-line"
|
||||
searchParamName="attribute-mode"
|
||||
searchParamValue="and"
|
||||
/>
|
||||
)}
|
||||
{filters.attr &&
|
||||
Object.entries(filters.attr)
|
||||
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
|
||||
.map(([attributeId, attributeValue]) => {
|
||||
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
|
||||
if (!attribute) return null
|
||||
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
|
||||
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
|
||||
return (
|
||||
<ServiceFiltersPill
|
||||
text={`${prefix}: ${attribute.title}`}
|
||||
searchParamName={`attr-${attributeId}`}
|
||||
searchParamValue={attributeValue}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<Button
|
||||
@@ -739,6 +639,10 @@ const showFiltersId = 'show-filters'
|
||||
filters={filters}
|
||||
countCommunityOnly={countCommunityOnly}
|
||||
inlineIcons
|
||||
categories={categories}
|
||||
filtersOptions={filtersOptions}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -110,6 +110,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
& {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
--animate-text-gradient: text-gradient 4s linear 0s infinite normal forwards running;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user