Compare commits
3 Commits
release-89
...
release-92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a54352d95 | ||
|
|
a545726abf | ||
|
|
01488b8b3b |
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
|
||||
@@ -14,6 +14,8 @@ import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
const ONION_ADDRESS = getServerEnvVariable('ONION_ADDRESS')
|
||||
const I2P_ADDRESS = getServerEnvVariable('I2P_ADDRESS')
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
@@ -95,6 +97,18 @@ export default defineConfig({
|
||||
server: {
|
||||
open: false,
|
||||
allowedHosts: [new URL(SITE_URL).hostname],
|
||||
headers: {
|
||||
'Onion-Location': ONION_ADDRESS,
|
||||
'X-I2P-Location': I2P_ADDRESS,
|
||||
'X-Frame-Options': 'DENY',
|
||||
// Astro is working on this feature, when it's stable use it instead of this.
|
||||
// https://astro.build/blog/astro-590/#experimental-content-security-policy-support
|
||||
'Content-Security-Policy':
|
||||
SITE_URL === 'http://localhost:4321'
|
||||
? "frame-ancestors 'none'; upgrade-insecure-requests"
|
||||
: "default-src 'self'; img-src 'self' *; frame-ancestors 'none'; upgrade-insecure-requests",
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubdomains; preload;',
|
||||
},
|
||||
},
|
||||
image: {
|
||||
domains: [new URL(SITE_URL).hostname],
|
||||
|
||||
18
web/package-lock.json
generated
18
web/package-lock.json
generated
@@ -28,6 +28,7 @@
|
||||
"astro-seo-schema": "5.0.0",
|
||||
"canvas": "3.1.2",
|
||||
"clsx": "2.1.1",
|
||||
"he": "1.2.0",
|
||||
"htmx.org": "2.0.6",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
@@ -59,6 +60,7 @@
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/he": "1.2.3",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.8",
|
||||
@@ -4763,6 +4765,13 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/he": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
|
||||
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -10820,6 +10829,15 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hex-rgb": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"astro-seo-schema": "5.0.0",
|
||||
"canvas": "3.1.2",
|
||||
"clsx": "2.1.1",
|
||||
"he": "1.2.0",
|
||||
"htmx.org": "2.0.6",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
@@ -75,6 +76,7 @@
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/he": "1.2.3",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.8",
|
||||
|
||||
@@ -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 {
|
||||
@@ -498,20 +502,22 @@ model InternalServiceNote {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
displayName String?
|
||||
link String?
|
||||
picture String?
|
||||
spammer Boolean @default(false)
|
||||
verified Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
moderator Boolean @default(false)
|
||||
verifiedLink String?
|
||||
secretTokenHash String @unique
|
||||
feedId String @unique @default(cuid(2))
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
displayName String?
|
||||
link String?
|
||||
picture String?
|
||||
spammer Boolean @default(false)
|
||||
verified Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
moderator Boolean @default(false)
|
||||
verifiedLink String?
|
||||
secretTokenHash String @unique
|
||||
feedId String @unique @default(cuid(2))
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
totalKarma Int @default(0)
|
||||
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
|
||||
@@ -609,10 +615,11 @@ model VerificationStep {
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
icon String
|
||||
slug String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
namePluralLong String?
|
||||
icon String
|
||||
slug String @unique
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -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,35 +275,39 @@ 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
|
||||
-- Check if the user is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."userId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
-- 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 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";
|
||||
-- Only award karma if the service is public
|
||||
IF service_visibility = 'PUBLIC' THEN
|
||||
-- Check if the user is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."userId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
|
||||
-- Only award karma if the user is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
-- Insert karma transaction, linking it to the suggestion
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."userId",
|
||||
10,
|
||||
'SUGGESTION_APPROVED',
|
||||
NULL, -- p_comment_id (not applicable)
|
||||
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
||||
NEW.id -- p_suggestion_id
|
||||
);
|
||||
|
||||
-- Insert karma transaction, linking it to the suggestion
|
||||
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
|
||||
);
|
||||
|
||||
-- Update user's total karma
|
||||
PERFORM update_user_karma(NEW."userId", 10);
|
||||
-- Update user's total karma
|
||||
PERFORM update_user_karma(NEW."userId", 10);
|
||||
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,
|
||||
skipKeys: (keyof T)[] = []
|
||||
): string => {
|
||||
return Object.entries(input)
|
||||
.filter(([key]) => !skipKeys.includes(key as keyof T))
|
||||
.map(([key, value]) => {
|
||||
let serializedValue = ''
|
||||
if (typeof value === 'string') {
|
||||
serializedValue = value
|
||||
} else if (value === undefined || value === null) {
|
||||
serializedValue = ''
|
||||
} else if (Array.isArray(value)) {
|
||||
serializedValue = value.map((item) => String(item)).join(', ')
|
||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
serializedValue = value.toString()
|
||||
} else {
|
||||
try {
|
||||
serializedValue = JSON.stringify(value)
|
||||
} catch (error) {
|
||||
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
return `- ${key}: ${serializedValue}`
|
||||
})
|
||||
.join('\n')
|
||||
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
|
||||
} else if (value === undefined || value === null) {
|
||||
serializedValue = ''
|
||||
} else if (Array.isArray(value)) {
|
||||
serializedValue = value.map((item) => String(item)).join(', ')
|
||||
} 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 {
|
||||
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 = {
|
||||
@@ -200,14 +218,37 @@ export const serviceSuggestionActions = {
|
||||
return {
|
||||
hasDuplicates: true,
|
||||
possibleDuplicates,
|
||||
extraNotes: serializeExtraNotes(input, [
|
||||
'skipDuplicateCheck',
|
||||
'message',
|
||||
'imageFile',
|
||||
'captcha-value',
|
||||
'captcha-solution-hash',
|
||||
'rulesConfirm',
|
||||
]),
|
||||
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,20 +6,24 @@ 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<{
|
||||
select: {
|
||||
id: true
|
||||
content: true
|
||||
type: true
|
||||
link: true
|
||||
linkText: true
|
||||
startDate: true
|
||||
endDate: true
|
||||
isActive: true
|
||||
}
|
||||
}>
|
||||
announcement: O.Optional<
|
||||
Prisma.AnnouncementGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
content: true
|
||||
type: true
|
||||
link: true
|
||||
linkText: true
|
||||
startDate: true
|
||||
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>
|
||||
<Icon
|
||||
name="ri:arrow-right-line"
|
||||
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
!!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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import LoadingIndicator from 'astro-loading-indicator/component'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { ONION_ADDRESS } from 'astro:env/server'
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
|
||||
import { pwaInfo } from 'virtual:pwa-info'
|
||||
@@ -78,30 +79,32 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
|
||||
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
---
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
{/* Primary Meta Tags */}
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="description" content={description} />
|
||||
<title>{fullTitle}</title>
|
||||
<!-- {canonicalUrl && <link rel="canonical" href={canonicalUrl} />} -->
|
||||
{/* canonicalUrl && <link rel="canonical" href={canonicalUrl} /> */}
|
||||
<meta http-equiv="onion-location" content={ONION_ADDRESS} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||
|
||||
<!-- Twitter -->
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={fullTitle} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
|
||||
|
||||
<!-- Other -->
|
||||
{/* Other */}
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="sitemap" href="/sitemaps/search.xml" />
|
||||
|
||||
<!-- PWA -->
|
||||
{/* PWA */}
|
||||
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
|
||||
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
|
||||
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
||||
@@ -115,10 +118,10 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
<TailwindJsPluggin />
|
||||
{htmx && <HtmxScript />}
|
||||
|
||||
<!-- JSON-LD Schemas -->
|
||||
{/* JSON-LD Schemas */}
|
||||
{schemas?.map((item) => <Schema item={item} />)}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
{/* Breadcrumbs */}
|
||||
{
|
||||
breadcrumbLists.map((breadcrumbList) => (
|
||||
<Schema
|
||||
|
||||
@@ -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>
|
||||
|
||||
183
web/src/components/ServiceFiltersPillsRow.astro
Normal file
183
web/src/components/ServiceFiltersPillsRow.astro
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import { getCurrencyInfo } from '../constants/currencies'
|
||||
import { getNetworkInfo } from '../constants/networks'
|
||||
import { getVerificationStatusInfo } from '../constants/verificationStatus'
|
||||
import { areEqualArraysWithoutOrder } from '../lib/arrays'
|
||||
import { cn } from '../lib/cn'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import ServiceFiltersPill from './ServiceFiltersPill.astro'
|
||||
|
||||
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
|
||||
import type { AttributeOption, ServicesFiltersObject } from '../pages/index.astro'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
filters: ServicesFiltersObject
|
||||
filtersOptions: ServicesFiltersOptions
|
||||
categories: Prisma.CategoryGetPayload<{
|
||||
select: {
|
||||
name: true
|
||||
slug: true
|
||||
icon: true
|
||||
}
|
||||
}>[]
|
||||
attributes: Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
title: true
|
||||
id: true
|
||||
}
|
||||
}>[]
|
||||
attributeOptions: AttributeOption[]
|
||||
}
|
||||
|
||||
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,15 +1,15 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro'
|
||||
import { type ServicesFiltersObject } from '../pages/index.astro'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import PillsRadioGroup from './PillsRadioGroup.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'form'> & {
|
||||
@@ -111,7 +111,7 @@ const {
|
||||
<legend class="font-title mb-3 leading-none text-green-500">Type</legend>
|
||||
<ul class="[&:not(:has(~_.peer:checked))]:[&>li:not([data-show-always])]:hidden">
|
||||
{
|
||||
options.categories?.map((category) => (
|
||||
options.categories.map((category) => (
|
||||
<li data-show-always={category.showAlways ? '' : undefined}>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
@@ -252,7 +252,7 @@ const {
|
||||
</div>
|
||||
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
|
||||
{
|
||||
kycLevels.map((level) => (
|
||||
options.kycLevels.map((level) => (
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
{level.value}
|
||||
<Icon name={level.icon} class="ms-1 size-3 shrink-0" />
|
||||
@@ -334,7 +334,7 @@ const {
|
||||
<li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer">
|
||||
<fieldset class="relative flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
|
||||
<legend class="sr-only">
|
||||
{attribute.title} ({attribute._count?.services})
|
||||
{attribute.title} ({attribute._count.services})
|
||||
</legend>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -417,7 +417,7 @@ const {
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{attribute.title}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
|
||||
</label>
|
||||
<label
|
||||
for={emptyId}
|
||||
@@ -432,7 +432,7 @@ const {
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{attribute.title}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count.services}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</li>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
---
|
||||
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 { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
|
||||
import type { AttributeOption, ServicesFiltersObject } from '../pages/index.astro'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
@@ -23,6 +30,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 +59,10 @@ const {
|
||||
filters,
|
||||
countCommunityOnly,
|
||||
inlineIcons,
|
||||
categories,
|
||||
filtersOptions,
|
||||
attributes,
|
||||
attributeOptions,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
|
||||
@@ -55,9 +82,118 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug,
|
||||
]),
|
||||
})
|
||||
|
||||
// NOTE: If you make changes to this function, remember to update the sitemap: src/pages/sitemaps/search.xml.ts
|
||||
const searchTitle = (() => {
|
||||
if (filters.q) {
|
||||
return `Search results for “${filters.q}”`
|
||||
}
|
||||
const listOrformatter = new Intl.ListFormat('en', { style: 'long', type: 'disjunction' })
|
||||
const listAndformatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' })
|
||||
|
||||
let [prefix, base, attributesPart, currencies, kycLevel, score] = ['', 'services', '', '', '', '']
|
||||
|
||||
if (!hasDefaultFilters) {
|
||||
prefix = 'filtered'
|
||||
}
|
||||
|
||||
const attributesFilters = Object.entries(filters.attr ?? {})
|
||||
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
|
||||
.map(([attributeId, attributeValue]) => {
|
||||
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
|
||||
if (!attribute) return null
|
||||
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
|
||||
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
|
||||
const prefixWith = valueInfo?.prefixWith ?? transformCase(attributeValue, 'title')
|
||||
return {
|
||||
prefix,
|
||||
prefixWith,
|
||||
attribute,
|
||||
valueInfo,
|
||||
value: attributeValue,
|
||||
}
|
||||
})
|
||||
.filter((attr) => !!attr)
|
||||
|
||||
if (attributesFilters.length === 1 || attributesFilters.length === 2) {
|
||||
const formatter = filters['attribute-mode'] === 'and' ? listAndformatter : listOrformatter
|
||||
attributesPart = formatter.format(
|
||||
attributesFilters.map((attr) => `${attr.prefixWith} ${attr.attribute.title}`)
|
||||
)
|
||||
prefix = ''
|
||||
}
|
||||
|
||||
if (
|
||||
filters.verification.length === 1 ||
|
||||
(!attributesFilters.length &&
|
||||
!filters.currencies.length &&
|
||||
!(filters['max-kyc'] <= 3) &&
|
||||
!(filters['min-score'] >= 1) &&
|
||||
areEqualArraysWithoutOrder(filters.verification, ['APPROVED', 'VERIFICATION_SUCCESS']))
|
||||
) {
|
||||
base = `${listAndformatter.format(
|
||||
orderBy(
|
||||
filters.verification.map((v) => getVerificationStatusInfo(v)),
|
||||
'order',
|
||||
'desc'
|
||||
).map((v) => v.label)
|
||||
)} services`
|
||||
prefix = ''
|
||||
}
|
||||
|
||||
if (filters.categories.length >= 1) {
|
||||
base = listAndformatter.format(
|
||||
filters.categories.map((c) => {
|
||||
const cat = categories.find((cat) => cat.slug === c)
|
||||
if (!cat) return c
|
||||
return cat.namePluralLong ?? cat.name
|
||||
})
|
||||
)
|
||||
prefix = ''
|
||||
}
|
||||
|
||||
if (filters.currencies.length >= 1) {
|
||||
const currenciesList = filters.currencies.map((c) => getCurrencyInfo(c).name)
|
||||
const formatter = filters['currency-mode'] === 'and' ? listAndformatter : listOrformatter
|
||||
currencies = `that accept ${formatter.format(currenciesList)}`
|
||||
prefix = ''
|
||||
}
|
||||
|
||||
if (filters['max-kyc'] === 0) {
|
||||
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',
|
||||
|
||||
187
web/src/lib/searchFiltersOptions.ts
Normal file
187
web/src/lib/searchFiltersOptions.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { orderBy, groupBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { networks } from '../constants/networks'
|
||||
import { verificationStatuses } from '../constants/verificationStatus'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const MIN_CATEGORIES_TO_SHOW = 8
|
||||
const MIN_ATTRIBUTES_TO_SHOW = 8
|
||||
|
||||
export const sortOptions = [
|
||||
{
|
||||
value: 'score-desc',
|
||||
label: 'Score (High → Low)',
|
||||
orderBy: {
|
||||
key: 'overallScore',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'score-asc',
|
||||
label: 'Score (Low → High)',
|
||||
orderBy: {
|
||||
key: 'overallScore',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'name-asc',
|
||||
label: 'Name (A → Z)',
|
||||
orderBy: {
|
||||
key: 'name',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'name-desc',
|
||||
label: 'Name (Z → A)',
|
||||
orderBy: {
|
||||
key: 'name',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'recent',
|
||||
label: 'Date listed (New → Old)',
|
||||
orderBy: {
|
||||
key: 'listedAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'oldest',
|
||||
label: 'Date listed (Old → New)',
|
||||
orderBy: {
|
||||
key: 'listedAt',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
] as const satisfies {
|
||||
value: string
|
||||
label: string
|
||||
orderBy: {
|
||||
key: keyof Prisma.ServiceSelect
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
}[]
|
||||
|
||||
export const defaultSortOption = sortOptions[0]
|
||||
|
||||
export const modeOptions = [
|
||||
{
|
||||
value: 'or',
|
||||
label: 'OR',
|
||||
},
|
||||
{
|
||||
value: 'and',
|
||||
label: 'AND',
|
||||
},
|
||||
] as const satisfies {
|
||||
value: string
|
||||
label: string
|
||||
}[]
|
||||
|
||||
export function makeSearchFiltersOptions({
|
||||
filters,
|
||||
categories,
|
||||
attributes,
|
||||
}: {
|
||||
filters: {
|
||||
categories: string[]
|
||||
attr: Record<number, '' | 'no' | 'yes'> | undefined
|
||||
} | null
|
||||
categories: Prisma.CategoryGetPayload<{
|
||||
select: {
|
||||
name: true
|
||||
namePluralLong: true
|
||||
slug: true
|
||||
icon: true
|
||||
_count: {
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}>[]
|
||||
attributes: Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
slug: true
|
||||
title: true
|
||||
category: true
|
||||
type: true
|
||||
_count: {
|
||||
select: {
|
||||
services: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}>[]
|
||||
}) {
|
||||
const attributesByCategory = orderBy(
|
||||
Object.entries(
|
||||
groupBy(
|
||||
attributes.map((attr) => {
|
||||
return {
|
||||
typeInfo: getAttributeTypeInfo(attr.type),
|
||||
...attr,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
value: filters?.attr?.[attr.id] || undefined,
|
||||
}
|
||||
}),
|
||||
'category'
|
||||
)
|
||||
).map(([category, attributes]) => ({
|
||||
category,
|
||||
categoryInfo: getAttributeCategoryInfo(category),
|
||||
attributes: orderBy(
|
||||
attributes,
|
||||
['value', 'type', '_count.services', 'title'],
|
||||
['asc', 'asc', 'desc', 'asc']
|
||||
).map((attr, i) => ({
|
||||
...attr,
|
||||
showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined,
|
||||
})),
|
||||
})),
|
||||
['category'],
|
||||
['asc']
|
||||
)
|
||||
|
||||
const categoriesSorted = orderBy(
|
||||
categories.map((category) => {
|
||||
const checked = filters?.categories.includes(category.slug) ?? false
|
||||
|
||||
return {
|
||||
...category,
|
||||
checked,
|
||||
}
|
||||
}),
|
||||
['checked', '_count.services', 'name'],
|
||||
['desc', 'desc', 'asc']
|
||||
).map((category, i) => ({
|
||||
...category,
|
||||
showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked,
|
||||
}))
|
||||
|
||||
return {
|
||||
currencies,
|
||||
categories: categoriesSorted,
|
||||
sort: sortOptions,
|
||||
modeOptions,
|
||||
network: networks,
|
||||
verification: verificationStatuses,
|
||||
attributesByCategory,
|
||||
kycLevels,
|
||||
} as const
|
||||
}
|
||||
|
||||
export type ServicesFiltersOptions = ReturnType<typeof makeSearchFiltersOptions>
|
||||
@@ -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,7 +44,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
|
||||
? Capitalize<Lowercase<T>>
|
||||
: C extends 'title'
|
||||
? Capitalize<Lowercase<T>>
|
||||
: T => {
|
||||
: C extends 'first-upper'
|
||||
? Capitalize<T>
|
||||
: T => {
|
||||
switch (caseType) {
|
||||
case 'lower':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
@@ -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>
|
||||
|
||||
@@ -319,7 +319,7 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
<span
|
||||
class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`}
|
||||
>
|
||||
{user.totalKarma}
|
||||
{user.totalKarma.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm">
|
||||
|
||||
@@ -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,12 @@ 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 { 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,
|
||||
@@ -31,107 +23,41 @@ import { parseIntWithFallback } from '../lib/numbers'
|
||||
import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import {
|
||||
defaultSortOption,
|
||||
makeSearchFiltersOptions,
|
||||
modeOptions,
|
||||
sortOptions,
|
||||
} from '../lib/searchFiltersOptions'
|
||||
import { makeSortSeed } from '../lib/sortSeed'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const MIN_CATEGORIES_TO_SHOW = 8
|
||||
const MIN_ATTRIBUTES_TO_SHOW = 8
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
const sortOptions = [
|
||||
{
|
||||
value: 'score-desc',
|
||||
label: 'Score (High → Low)',
|
||||
orderBy: {
|
||||
key: 'overallScore',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'score-asc',
|
||||
label: 'Score (Low → High)',
|
||||
orderBy: {
|
||||
key: 'overallScore',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'name-asc',
|
||||
label: 'Name (A → Z)',
|
||||
orderBy: {
|
||||
key: 'name',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'name-desc',
|
||||
label: 'Name (Z → A)',
|
||||
orderBy: {
|
||||
key: 'name',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'recent',
|
||||
label: 'Date listed (New → Old)',
|
||||
orderBy: {
|
||||
key: 'listedAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'oldest',
|
||||
label: 'Date listed (Old → New)',
|
||||
orderBy: {
|
||||
key: 'listedAt',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
] as const satisfies {
|
||||
export type AttributeOption = {
|
||||
value: string
|
||||
label: string
|
||||
orderBy: {
|
||||
key: keyof Prisma.ServiceSelect
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
}[]
|
||||
|
||||
const defaultSortOption = sortOptions[0]
|
||||
|
||||
const modeOptions = [
|
||||
{
|
||||
value: 'or',
|
||||
label: 'OR',
|
||||
},
|
||||
{
|
||||
value: 'and',
|
||||
label: 'AND',
|
||||
},
|
||||
] as const satisfies {
|
||||
value: string
|
||||
label: string
|
||||
}[]
|
||||
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 +235,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
prisma.category.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
namePluralLong: true,
|
||||
slug: true,
|
||||
icon: true,
|
||||
_count: {
|
||||
@@ -322,6 +249,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
],
|
||||
[
|
||||
'Unable to load services.',
|
||||
@@ -477,62 +405,11 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
],
|
||||
])
|
||||
|
||||
const attributesByCategory = orderBy(
|
||||
Object.entries(
|
||||
groupBy(
|
||||
attributes.map((attr) => {
|
||||
return {
|
||||
typeInfo: getAttributeTypeInfo(attr.type),
|
||||
...attr,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
value: filters.attr?.[attr.id] || undefined,
|
||||
}
|
||||
}),
|
||||
'category'
|
||||
)
|
||||
).map(([category, attributes]) => ({
|
||||
category,
|
||||
categoryInfo: getAttributeCategoryInfo(category),
|
||||
attributes: orderBy(
|
||||
attributes,
|
||||
['value', 'type', '_count.services', 'title'],
|
||||
['asc', 'asc', 'desc', 'asc']
|
||||
).map((attr, i) => ({
|
||||
...attr,
|
||||
showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined,
|
||||
})),
|
||||
})),
|
||||
['category'],
|
||||
['asc']
|
||||
)
|
||||
|
||||
const categoriesSorted = orderBy(
|
||||
categories?.map((category) => {
|
||||
const checked = filters.categories.includes(category.slug)
|
||||
|
||||
return {
|
||||
...category,
|
||||
checked,
|
||||
}
|
||||
}),
|
||||
['checked', '_count.services', 'name'],
|
||||
['desc', 'desc', 'asc']
|
||||
).map((category, i) => ({
|
||||
...category,
|
||||
showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked,
|
||||
}))
|
||||
|
||||
const filtersOptions = {
|
||||
currencies,
|
||||
categories: categoriesSorted,
|
||||
sort: sortOptions,
|
||||
modeOptions,
|
||||
network: networks,
|
||||
verification: verificationStatuses,
|
||||
attributesByCategory,
|
||||
} as const
|
||||
|
||||
export type ServicesFiltersOptions = typeof filtersOptions
|
||||
const filtersOptions = makeSearchFiltersOptions({
|
||||
filters,
|
||||
categories,
|
||||
attributes,
|
||||
})
|
||||
|
||||
const searchResultsId = 'search-results'
|
||||
const showFiltersId = 'show-filters'
|
||||
@@ -584,114 +461,13 @@ 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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{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>
|
||||
<ServiceFiltersPillsRow
|
||||
filters={filters}
|
||||
filtersOptions={filtersOptions}
|
||||
categories={categories}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -739,6 +515,10 @@ const showFiltersId = 'show-filters'
|
||||
filters={filters}
|
||||
countCommunityOnly={countCommunityOnly}
|
||||
inlineIcons
|
||||
categories={categories}
|
||||
filtersOptions={filtersOptions}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
|
||||
192
web/src/pages/sitemaps/search.xml.ts
Normal file
192
web/src/pages/sitemaps/search.xml.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/* eslint-disable import/no-named-as-default-member */
|
||||
import he from 'he'
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeSearchFiltersOptions } from '../../lib/searchFiltersOptions'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
if (!site) {
|
||||
return new Response('Site URL not configured', { status: 500 })
|
||||
}
|
||||
|
||||
const searchUrls = await generateSEOSitemapUrls(site.href)
|
||||
|
||||
const result = `
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${searchUrls.map((url) => `<url><loc>${he.encode(url)}</loc></url>`).join('\n')}
|
||||
</urlset>
|
||||
`.trim()
|
||||
|
||||
return new Response(result, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function generateSEOSitemapUrls(siteUrl: string) {
|
||||
try {
|
||||
const [categories, attributes] = await Promise.all([
|
||||
prisma.category.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
namePluralLong: true,
|
||||
slug: true,
|
||||
icon: true,
|
||||
_count: {
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.attribute.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
category: true,
|
||||
type: true,
|
||||
_count: {
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
service: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const filtersOptions = makeSearchFiltersOptions({
|
||||
filters: null,
|
||||
categories,
|
||||
attributes,
|
||||
})
|
||||
|
||||
const byCategory = filtersOptions.categories.map(
|
||||
(category) =>
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
})
|
||||
)
|
||||
|
||||
const byVerificationStatus = filtersOptions.verification.map(
|
||||
(status) =>
|
||||
new URLSearchParams({
|
||||
verification: status.slug,
|
||||
})
|
||||
)
|
||||
|
||||
const byKycLevel = filtersOptions.kycLevels.map(
|
||||
(level) =>
|
||||
new URLSearchParams({
|
||||
'max-kyc': level.id,
|
||||
})
|
||||
)
|
||||
|
||||
const byCurrency = filtersOptions.currencies.map(
|
||||
(currency) =>
|
||||
new URLSearchParams({
|
||||
currencies: currency.slug,
|
||||
})
|
||||
)
|
||||
|
||||
const withOneAttribute = filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.map(
|
||||
(attribute) =>
|
||||
new URLSearchParams({
|
||||
[`attr-${attribute.id.toString()}`]: 'yes',
|
||||
})
|
||||
)
|
||||
const withoutOneAttribute = filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.map(
|
||||
(attribute) =>
|
||||
new URLSearchParams({
|
||||
[`attr-${attribute.id.toString()}`]: 'no',
|
||||
})
|
||||
)
|
||||
|
||||
const byCategoryAndCurrency = filtersOptions.categories.flatMap((category) =>
|
||||
filtersOptions.currencies.map(
|
||||
(currency) =>
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
currencies: currency.slug,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const byCategoryAndAttributes = filtersOptions.categories.flatMap((category) =>
|
||||
filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.flatMap((attribute) => [
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
[`attr-${attribute.id.toString()}`]: 'yes',
|
||||
}),
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
[`attr-${attribute.id.toString()}`]: 'no',
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
const relevantCurrencies = [
|
||||
'xmr',
|
||||
'btc',
|
||||
] as const satisfies (typeof filtersOptions.currencies)[number]['slug'][]
|
||||
|
||||
const byCategoryAndAttributesAndRelevantCurrency = filtersOptions.categories.flatMap((category) =>
|
||||
filtersOptions.attributesByCategory
|
||||
.flatMap(({ attributes }) => attributes)
|
||||
.flatMap((attribute) =>
|
||||
relevantCurrencies.map(
|
||||
(currency) =>
|
||||
new URLSearchParams({
|
||||
categories: category.slug,
|
||||
currencies: currency,
|
||||
[`attr-${attribute.id.toString()}`]:
|
||||
attribute.type === 'GOOD' || attribute.type === 'INFO' ? 'yes' : 'no',
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const allQueryParams = [
|
||||
...byCategory,
|
||||
...byVerificationStatus,
|
||||
...byKycLevel,
|
||||
...byCurrency,
|
||||
...withOneAttribute,
|
||||
...withoutOneAttribute,
|
||||
|
||||
...byCategoryAndCurrency,
|
||||
...byCategoryAndAttributes,
|
||||
...byCategoryAndAttributesAndRelevantCurrency,
|
||||
] satisfies URLSearchParams[]
|
||||
|
||||
return allQueryParams.map((queryParams) => {
|
||||
const url = new URL(siteUrl)
|
||||
url.search = queryParams.toString()
|
||||
return url.href
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to generate SEO sitemap URLs:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
const getRobotsTxt = (sitemapURL: URL) => `
|
||||
const getRobotsTxt = (sitemaps: `/${string}`[], siteUrl: URL) => `
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
|
||||
Sitemap: ${sitemapURL.href}
|
||||
${sitemaps.map((sitemap) => `Sitemap: ${new URL(sitemap, siteUrl).href}`).join('\n')}
|
||||
`
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const sitemapURL = new URL('sitemap-index.xml', site)
|
||||
return new Response(getRobotsTxt(sitemapURL))
|
||||
if (!site) return new Response('Site URL not configured', { status: 500 })
|
||||
|
||||
return new Response(getRobotsTxt(['/sitemap-index.xml', '/sitemaps/search.xml'], site))
|
||||
}
|
||||
|
||||
@@ -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