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_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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -38,6 +38,7 @@ Required environment variables:
|
|||||||
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
||||||
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
|
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
|
||||||
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
|
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
|
||||||
|
- `CRON_INACTIVE_USERS_TASK`: Cron expression for inactive users cleanup task
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ uv run -m pyworker force-triggers
|
|||||||
|
|
||||||
# Run service score recalculation task
|
# Run service score recalculation task
|
||||||
uv run -m pyworker service-score-recalc [--service-id ID]
|
uv run -m pyworker service-score-recalc [--service-id ID]
|
||||||
|
|
||||||
|
# Run inactive users cleanup task
|
||||||
|
uv run -m pyworker inactive-users
|
||||||
```
|
```
|
||||||
|
|
||||||
### Worker Mode
|
### Worker Mode
|
||||||
@@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules.
|
|||||||
- Calculates privacy, trust, and overall scores
|
- Calculates privacy, trust, and overall scores
|
||||||
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
|
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
|
||||||
|
|
||||||
|
### Inactive Users Task
|
||||||
|
|
||||||
|
- Handles cleanup of inactive user accounts
|
||||||
|
- Identifies users who have been inactive for 1 year (no comments, votes, suggestions, and 0 karma)
|
||||||
|
- Sends deletion warning notifications at 30, 15, 5, and 1 day intervals
|
||||||
|
- Deletes accounts that remain inactive after the warning period
|
||||||
|
- Cancels deletion for users who become active again
|
||||||
|
- Scheduled via `CRON_INACTIVE_USERS_TASK`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
@@ -124,6 +137,7 @@ pyworker/
|
|||||||
│ │ ├── base.py
|
│ │ ├── base.py
|
||||||
│ │ ├── comment_moderation.py
|
│ │ ├── comment_moderation.py
|
||||||
│ │ ├── force_triggers.py
|
│ │ ├── force_triggers.py
|
||||||
|
│ │ ├── inactive_users.py
|
||||||
│ │ ├── service_score_recalc.py
|
│ │ ├── service_score_recalc.py
|
||||||
│ │ ├── tos_review.py
|
│ │ ├── tos_review.py
|
||||||
│ │ └── user_sentiment.py
|
│ │ └── user_sentiment.py
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -101,6 +102,12 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
|||||||
help="Recalculate service scores for all services",
|
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -371,6 +378,30 @@ def run_service_score_recalc_all_task() -> int:
|
|||||||
return run_service_score_recalc_task(all_services=True)
|
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.
|
||||||
@@ -382,54 +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
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start the scheduler if tasks were registered
|
# Start the scheduler if tasks were registered
|
||||||
if scheduler.tasks:
|
if scheduler.tasks:
|
||||||
@@ -484,6 +498,8 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
elif args.task == "service-score-recalc-all":
|
elif args.task == "service-score-recalc-all":
|
||||||
return run_service_score_recalc_all_task()
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -80,6 +81,8 @@ class TaskScheduler:
|
|||||||
task_instance = ForceTriggersTask()
|
task_instance = ForceTriggersTask()
|
||||||
elif task_name.lower() == "service_score_recalc":
|
elif task_name.lower() == "service_score_recalc":
|
||||||
task_instance = ServiceScoreRecalculationTask()
|
task_instance = ServiceScoreRecalculationTask()
|
||||||
|
elif task_name.lower() == "inactive_users":
|
||||||
|
task_instance = InactiveUsersTask()
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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
|
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 {
|
||||||
@@ -498,20 +502,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
|
||||||
@@ -609,10 +615,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
|
||||||
|
|||||||
@@ -141,81 +141,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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -275,35 +275,39 @@ 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;
|
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
|
||||||
-- Check if the user is an admin or moderator
|
-- Fetch service details for the description
|
||||||
SELECT (admin = true OR moderator = true)
|
SELECT name, visibility INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId";
|
||||||
FROM "User"
|
|
||||||
WHERE id = NEW."userId"
|
|
||||||
INTO is_user_admin_or_moderator;
|
|
||||||
|
|
||||||
-- Only award karma if the user is NOT an admin/moderator
|
-- Only award karma if the service is public
|
||||||
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
IF service_visibility = 'PUBLIC' THEN
|
||||||
-- Fetch service name for the description
|
-- Check if the user is an admin or moderator
|
||||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
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,
|
|
||||||
'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;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
|||||||
@@ -49,33 +49,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 = {
|
||||||
@@ -200,14 +218,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
|
||||||
|
|||||||
@@ -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,7 +35,9 @@ 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',
|
||||||
@@ -78,15 +84,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.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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -30,7 +40,7 @@ const makeUrlWithoutFilter = (filter: string, value?: string) => {
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{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>
|
||||||
|
|||||||
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 { 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 { 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 { AttributeOption, ServicesFiltersObject, ServicesFiltersOptions } 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,117 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug,
|
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">
|
<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()}
|
||||||
|
|||||||
@@ -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>[]
|
||||||
)
|
)
|
||||||
|
|||||||
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 { 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 {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
---
|
---
|
||||||
|
import { differenceInCalendarDays } from 'date-fns'
|
||||||
|
|
||||||
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
|
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
|
||||||
import BaseHead from '../components/BaseHead.astro'
|
import BaseHead from '../components/BaseHead.astro'
|
||||||
import Footer from '../components/Footer.astro'
|
import Footer from '../components/Footer.astro'
|
||||||
import Header from '../components/Header.astro'
|
import Header from '../components/Header.astro'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
|
import { pluralize } from '../lib/pluralize'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
|
||||||
import type { AstroChildren } from '../lib/astro'
|
import type { AstroChildren } from '../lib/astro'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { ComponentProps } from 'astro/types'
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
import '@fontsource-variable/space-grotesk'
|
import '@fontsource-variable/space-grotesk'
|
||||||
@@ -71,6 +75,26 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
}),
|
}),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function getDeletionAnnouncement(
|
||||||
|
user: Prisma.UserGetPayload<{ select: { scheduledDeletionAt: true } }> | null,
|
||||||
|
currentDate: Date = new Date()
|
||||||
|
) {
|
||||||
|
if (!user?.scheduledDeletionAt) return null
|
||||||
|
const daysUntilDeletion = differenceInCalendarDays(user.scheduledDeletionAt, currentDate)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
content: `Your account will be deleted ${daysUntilDeletion <= 0 ? 'today' : `in ${daysUntilDeletion.toLocaleString()} ${pluralize('day', daysUntilDeletion)}`} due to inactivity.`,
|
||||||
|
type: 'ALERT' as const,
|
||||||
|
link: '/account',
|
||||||
|
linkText: 'Prevent deletion',
|
||||||
|
startDate: currentDate,
|
||||||
|
endDate: null,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deletionAnnouncement = getDeletionAnnouncement(Astro.locals.user, currentDate)
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en" transition:name="root" transition:animate="none">
|
<html lang="en" transition:name="root" transition:animate="none">
|
||||||
@@ -85,6 +109,14 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
|
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
|
||||||
>
|
>
|
||||||
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||||
|
{
|
||||||
|
deletionAnnouncement && (
|
||||||
|
<AnnouncementBanner
|
||||||
|
announcement={deletionAnnouncement}
|
||||||
|
transition:name="deletion-warning-announcement"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
<Header
|
<Header
|
||||||
classNames={{
|
classNames={{
|
||||||
nav: cn(
|
nav: cn(
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
|
import { differenceInCalendarDays } from 'date-fns'
|
||||||
import { sortBy } from 'lodash-es'
|
import { sortBy } from 'lodash-es'
|
||||||
|
|
||||||
import BadgeSmall from '../../components/BadgeSmall.astro'
|
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||||
@@ -19,6 +20,7 @@ import { verificationStatusesByValue } from '../../constants/verificationStatus'
|
|||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../../lib/cn'
|
import { cn } from '../../lib/cn'
|
||||||
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
||||||
|
import { pluralize } from '../../lib/pluralize'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||||
import { formatDateShort } from '../../lib/timeAgo'
|
import { formatDateShort } from '../../lib/timeAgo'
|
||||||
@@ -49,6 +51,7 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
verifiedLink: true,
|
verifiedLink: true,
|
||||||
totalKarma: true,
|
totalKarma: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
scheduledDeletionAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
comments: true,
|
comments: true,
|
||||||
@@ -158,6 +161,10 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!user) return Astro.rewrite('/404')
|
if (!user) return Astro.rewrite('/404')
|
||||||
|
|
||||||
|
const daysUntilDeletion = user.scheduledDeletionAt
|
||||||
|
? differenceInCalendarDays(user.scheduledDeletionAt, new Date())
|
||||||
|
: null
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -394,6 +401,33 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{
|
||||||
|
daysUntilDeletion && (
|
||||||
|
<li class="flex items-start">
|
||||||
|
<span class="text-day-500 mt-0.5 mr-2">
|
||||||
|
<Icon name="ri:delete-bin-line" class="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-day-500 text-xs">Deletion Status</p>
|
||||||
|
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
|
||||||
|
Scheduled for deletion
|
||||||
|
{daysUntilDeletion <= 0 ? (
|
||||||
|
'today'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
in {daysUntilDeletion.toLocaleString()} {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>
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ There are several ways to earn karma points:
|
|||||||
- Similarly, each downvote reduces your karma by -1.
|
- Similarly, each downvote reduces your karma by -1.
|
||||||
- This allows the community to reward helpful contributions.
|
- This allows the community to reward helpful contributions.
|
||||||
|
|
||||||
|
4. **Suggestion Approval** (+10 points)
|
||||||
|
- When your suggestion to add or edit a service is approved and the service is listed publicly.
|
||||||
|
- Suggestions on non-listed services do not earn karma.
|
||||||
|
- This rewards users for helping to expand and improve the service directory.
|
||||||
|
|
||||||
## Karma Penalties
|
## Karma Penalties
|
||||||
|
|
||||||
The system also includes penalties to discourage spam and low-quality content:
|
The system also includes penalties to discourage spam and low-quality content:
|
||||||
|
|||||||
@@ -6,20 +6,14 @@ import seedrandom from 'seedrandom'
|
|||||||
import Button from '../components/Button.astro'
|
import Button from '../components/Button.astro'
|
||||||
import InputText from '../components/InputText.astro'
|
import InputText from '../components/InputText.astro'
|
||||||
import Pagination from '../components/Pagination.astro'
|
import Pagination from '../components/Pagination.astro'
|
||||||
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro'
|
import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.astro'
|
||||||
import ServicesFilters from '../components/ServicesFilters.astro'
|
import ServicesFilters from '../components/ServicesFilters.astro'
|
||||||
import ServicesSearchResults from '../components/ServicesSearchResults.astro'
|
import ServicesSearchResults from '../components/ServicesSearchResults.astro'
|
||||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||||
|
import { currencies, currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies'
|
||||||
|
import { networks } from '../constants/networks'
|
||||||
import {
|
import {
|
||||||
currencies,
|
|
||||||
currenciesZodEnumBySlug,
|
|
||||||
currencySlugToId,
|
|
||||||
getCurrencyInfo,
|
|
||||||
} from '../constants/currencies'
|
|
||||||
import { getNetworkInfo, networks } from '../constants/networks'
|
|
||||||
import {
|
|
||||||
getVerificationStatusInfo,
|
|
||||||
verificationStatuses,
|
verificationStatuses,
|
||||||
verificationStatusesZodEnumBySlug,
|
verificationStatusesZodEnumBySlug,
|
||||||
verificationStatusSlugToId,
|
verificationStatusSlugToId,
|
||||||
@@ -32,7 +26,6 @@ import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
|||||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { makeSortSeed } from '../lib/sortSeed'
|
import { makeSortSeed } from '../lib/sortSeed'
|
||||||
import { transformCase } from '../lib/strings'
|
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
@@ -115,23 +108,29 @@ const modeOptions = [
|
|||||||
label: string
|
label: string
|
||||||
}[]
|
}[]
|
||||||
|
|
||||||
|
export type AttributeOption = {
|
||||||
|
value: string
|
||||||
|
prefix: string
|
||||||
|
prefixWith: string
|
||||||
|
}
|
||||||
|
|
||||||
const attributeOptions = [
|
const attributeOptions = [
|
||||||
{
|
{
|
||||||
value: 'yes',
|
value: 'yes',
|
||||||
prefix: 'Has',
|
prefix: 'Has',
|
||||||
|
prefixWith: 'with',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'no',
|
value: 'no',
|
||||||
prefix: 'Not',
|
prefix: 'Not',
|
||||||
|
prefixWith: 'without',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: '',
|
value: '',
|
||||||
prefix: '',
|
prefix: '',
|
||||||
|
prefixWith: '',
|
||||||
},
|
},
|
||||||
] as const satisfies {
|
] as const satisfies AttributeOption[]
|
||||||
value: string
|
|
||||||
prefix: string
|
|
||||||
}[]
|
|
||||||
|
|
||||||
const ignoredKeysForDefaultData = ['sort-seed']
|
const ignoredKeysForDefaultData = ['sort-seed']
|
||||||
|
|
||||||
@@ -309,6 +308,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
prisma.category.findMany({
|
prisma.category.findMany({
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
|
namePluralLong: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
icon: true,
|
icon: true,
|
||||||
_count: {
|
_count: {
|
||||||
@@ -322,6 +322,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
[],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'Unable to load services.',
|
'Unable to load services.',
|
||||||
@@ -507,7 +508,7 @@ const attributesByCategory = orderBy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const categoriesSorted = orderBy(
|
const categoriesSorted = orderBy(
|
||||||
categories?.map((category) => {
|
categories.map((category) => {
|
||||||
const checked = filters.categories.includes(category.slug)
|
const checked = filters.categories.includes(category.slug)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -584,114 +585,13 @@ const showFiltersId = 'show-filters'
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div class="-ml-4 flex flex-1 items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4">
|
<ServiceFiltersPillsRow
|
||||||
{filters.q && (
|
filters={filters}
|
||||||
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
|
filtersOptions={filtersOptions}
|
||||||
)}
|
categories={categories}
|
||||||
|
attributes={attributes}
|
||||||
{!areEqualArraysWithoutOrder(
|
attributeOptions={attributeOptions}
|
||||||
filters.verification,
|
/>
|
||||||
filtersOptions.verification
|
|
||||||
.filter((verification) => verification.default)
|
|
||||||
.map((verification) => verification.value)
|
|
||||||
) &&
|
|
||||||
filters.verification.map((verificationStatus) => {
|
|
||||||
const verificationStatusInfo = getVerificationStatusInfo(verificationStatus)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text={verificationStatusInfo.label}
|
|
||||||
icon={verificationStatusInfo.icon}
|
|
||||||
iconClass={verificationStatusInfo.classNames.icon}
|
|
||||||
searchParamName="verification"
|
|
||||||
searchParamValue={verificationStatusInfo.slug}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{filters.categories.map((categorySlug) => {
|
|
||||||
const category = categories?.find((c) => c.slug === categorySlug)
|
|
||||||
if (!category) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text={category.name}
|
|
||||||
icon={category.icon}
|
|
||||||
searchParamName="categories"
|
|
||||||
searchParamValue={categorySlug}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{filters.currencies.map((currencyId) => {
|
|
||||||
const currency = getCurrencyInfo(currencyId)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text={currency.name}
|
|
||||||
searchParamName="currencies"
|
|
||||||
searchParamValue={currency.slug}
|
|
||||||
icon={currency.icon}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{filters.networks.map((network) => {
|
|
||||||
const networkOption = getNetworkInfo(network)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text={networkOption.name}
|
|
||||||
icon={networkOption.icon}
|
|
||||||
searchParamName="networks"
|
|
||||||
searchParamValue={network}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{filters['max-kyc'] < 4 && (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
|
|
||||||
icon="ri:shield-keyhole-line"
|
|
||||||
searchParamName="max-kyc"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filters['user-rating'] > 0 && (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
|
|
||||||
icon="ri:star-fill"
|
|
||||||
searchParamName="user-rating"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filters['min-score'] > 0 && (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
|
|
||||||
icon="ri:medal-line"
|
|
||||||
searchParamName="min-score"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text="Attributes: AND"
|
|
||||||
icon="ri:filter-3-line"
|
|
||||||
searchParamName="attribute-mode"
|
|
||||||
searchParamValue="and"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filters.attr &&
|
|
||||||
Object.entries(filters.attr)
|
|
||||||
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
|
|
||||||
.map(([attributeId, attributeValue]) => {
|
|
||||||
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
|
|
||||||
if (!attribute) return null
|
|
||||||
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
|
|
||||||
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
|
|
||||||
return (
|
|
||||||
<ServiceFiltersPill
|
|
||||||
text={`${prefix}: ${attribute.title}`}
|
|
||||||
searchParamName={`attr-${attributeId}`}
|
|
||||||
searchParamValue={attributeValue}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,6 +639,10 @@ const showFiltersId = 'show-filters'
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
countCommunityOnly={countCommunityOnly}
|
countCommunityOnly={countCommunityOnly}
|
||||||
inlineIcons
|
inlineIcons
|
||||||
|
categories={categories}
|
||||||
|
filtersOptions={filtersOptions}
|
||||||
|
attributes={attributes}
|
||||||
|
attributeOptions={attributeOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -110,6 +110,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility no-scrollbar {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
& {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--animate-text-gradient: text-gradient 4s linear 0s infinite normal forwards running;
|
--animate-text-gradient: text-gradient 4s linear 0s infinite normal forwards running;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user