Compare commits

...

15 Commits

Author SHA1 Message Date
pluja
9e0193fc3c Release 202507092007 2025-07-09 20:07:42 +00:00
pluja
a68523fc73 Release 202507090857 2025-07-09 08:57:55 +00:00
pluja
a465849a76 Release 202507080951 2025-07-08 09:51:46 +00:00
pluja
25f6dba3eb Release 202507080939 2025-07-08 09:39:11 +00:00
pluja
7e7046e7d2 Release 202507080931 2025-07-08 09:31:10 +00:00
pluja
a5d1fb9a5d Release 202507061906 2025-07-06 19:06:17 +00:00
pluja
28b84a7d9b Release 202507061859 2025-07-06 18:59:23 +00:00
pluja
7a294cb0a1 Release 202507061803 2025-07-06 18:03:45 +00:00
pluja
349c26a4df Release 202507031546 2025-07-03 15:46:21 +00:00
pluja
86b1afb2c7 Release 202507031255 2025-07-03 12:55:03 +00:00
pluja
99bc1f4e0f Release 202507031129 2025-07-03 11:29:46 +00:00
pluja
3166349dfb Release 202507031117 2025-07-03 11:17:39 +00:00
pluja
5a54352d95 Release 202507031107 2025-07-03 11:07:41 +00:00
pluja
a545726abf Release 202507030838 2025-07-03 08:38:11 +00:00
pluja
01488b8b3b Release 202507010806 2025-07-01 08:06:36 +00:00
58 changed files with 2285 additions and 988 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
23
24

View File

@@ -14,6 +14,8 @@ import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
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,19 @@ 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':
SITE_URL === 'http://localhost:4321' ? undefined : 'max-age=31536000; includeSubdomains; preload;',
},
},
image: {
domains: [new URL(SITE_URL).hostname],

618
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,23 +27,24 @@
"@astrojs/check": "0.9.4",
"@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2",
"@astrojs/node": "9.3.0",
"@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.6",
"@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.10.1",
"@prisma/client": "6.11.1",
"@tailwindcss/vite": "4.1.11",
"@types/mime-types": "3.0.1",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.10.1",
"@vercel/og": "0.7.2",
"astro": "5.9.0",
"astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4",
"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",
@@ -53,7 +54,7 @@
"pg": "8.16.3",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.5.6",
"redis": "5.6.0",
"schema-dts": "1.1.5",
"seedrandom": "3.0.5",
"sharp": "0.34.2",
@@ -66,42 +67,43 @@
"web-push": "3.6.7"
},
"devDependencies": {
"@eslint/js": "9.30.0",
"@faker-js/faker": "9.8.0",
"@iconify-json/material-symbols": "1.2.28",
"@eslint/js": "9.30.1",
"@faker-js/faker": "9.9.0",
"@iconify-json/material-symbols": "1.2.29",
"@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "5.1.0",
"@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",
"@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.35.1",
"@typescript-eslint/parser": "8.36.0",
"@vite-pwa/assets-generator": "1.0.0",
"@vite-pwa/astro": "1.1.0",
"astro-icon": "1.1.5",
"date-fns": "4.1.0",
"esbuild": "0.25.5",
"eslint": "9.30.0",
"esbuild": "0.25.6",
"eslint": "9.30.1",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.2.0",
"globals": "16.3.0",
"prettier": "3.6.2",
"prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.13",
"prisma": "6.10.1",
"prisma-json-types-generator": "3.5.0",
"prisma": "6.11.1",
"prisma-json-types-generator": "3.5.1",
"tailwind-htmx": "0.1.2",
"ts-essentials": "10.1.1",
"ts-toolbelt": "9.6.0",
"tsx": "4.20.3",
"typescript-eslint": "8.35.1",
"typescript-eslint": "8.36.0",
"vite-plugin-devtools-json": "0.2.1",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {
@@ -402,6 +406,9 @@ model Service {
Notification Notification[]
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
strictCommentingEnabled Boolean @default(false)
commentSectionMessage String?
@@index([listedAt])
@@index([approvedAt])
@@index([verifiedAt])
@@ -498,20 +505,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 +618,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

View File

@@ -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',
},
@@ -704,6 +720,8 @@ const generateFakeService = (users: User[]) => {
}),
{ probability: 0.33 }
),
strictCommentingEnabled: faker.datatype.boolean(0.33333),
commentSectionMessage: faker.helpers.maybe(() => faker.lorem.paragraph(), { probability: 0.3 }),
} as const satisfies Prisma.ServiceCreateInput
}

View File

@@ -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, "serviceVisibility" 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;

View File

@@ -63,6 +63,8 @@ const serviceSchemaBase = z.object({
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
strictCommentingEnabled: z.boolean().optional().default(false),
commentSectionMessage: z.string().trim().min(3).max(1000).optional().nullable().default(null),
})
// Define schema for the create action input
@@ -127,6 +129,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
strictCommentingEnabled: input.strictCommentingEnabled,
commentSectionMessage: input.commentSectionMessage,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
@@ -247,6 +251,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
strictCommentingEnabled: input.strictCommentingEnabled,
commentSectionMessage: input.commentSectionMessage,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,

View File

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

View File

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

View File

@@ -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,12 +35,15 @@ 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',
className
)}
aria-label="Announcement banner"
{...props}
>
<div
@@ -78,15 +85,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.link && !!announcement.linkText && (
<div class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]">
<span class="2xs:inline-block hidden">{announcement.linkText}</span>
<Icon
name="ri:arrow-right-line"
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
/>
</div>
)
}
</Tag>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
@@ -29,8 +39,9 @@ const makeUrlWithoutFilter = (filter: string, value?: string) => {
'bg-night-800 hover:bg-night-900 border-night-400 flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm text-white',
className
)}
aria-label={`Remove filter: ${text}`}
>
{icon && <Icon name={icon} class={cn('size-4', iconClass)} />}
{icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
{text}
<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>

View File

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

View File

@@ -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"
@@ -414,10 +414,13 @@ const {
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
aria-hidden="true"
/>
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
<span
class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
title={attribute.title}
>
{attribute.title}
</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}
@@ -429,10 +432,13 @@ const {
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
aria-hidden="true"
/>
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
<span
class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
title={attribute.title}
>
{attribute.title}
</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>

View File

@@ -1,16 +1,22 @@
---
import { Icon } from 'astro-icon/components'
import { uniq } from 'lodash-es'
import { uniq, orderBy } from 'lodash-es'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { getCurrencyInfo } from '../constants/currencies'
import { 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 +29,22 @@ type Props = HTMLAttributes<'div'> & {
filters: ServicesFiltersObject
countCommunityOnly: number | null
inlineIcons?: boolean
filtersOptions: ServicesFiltersOptions
categories: Prisma.CategoryGetPayload<{
select: {
name: true
namePluralLong: true
slug: true
icon: true
}
}>[]
attributes: Prisma.AttributeGetPayload<{
select: {
title: true
id: true
}
}>[]
attributeOptions: AttributeOption[]
}
const {
@@ -36,6 +58,10 @@ const {
filters,
countCommunityOnly,
inlineIcons,
categories,
filtersOptions,
attributes,
attributeOptions,
...divProps
} = Astro.props
@@ -55,9 +81,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) {
kycLevel = 'without KYC'
prefix = ''
} else if (filters['max-kyc'] <= 3) {
kycLevel = `with KYC level ${filters['max-kyc']} or better`
prefix = ''
}
if (filters['min-score'] >= 1) {
score = `with score ${filters['min-score'].toLocaleString()} or more`
prefix = ''
}
const finalArray = [attributesPart, currencies, kycLevel, score].filter((str) => !!str)
const title = transformCase(`${prefix} ${base} ${finalArray.join('; ')}`.trim(), 'first-upper')
return title.length > 60 ? 'Filtered Services' : title
})()
---
<div {...divProps} class={cn('flex-1', className)}>
<div {...divProps} class={cn('min-w-0 flex-1', className)}>
<div class="hidden flex-wrap items-center justify-between gap-x-4 sm:flex">
<h1 class="font-title text-day-100 mb-2 text-xl leading-tight lg:text-2xl">
{searchTitle}
</h1>
<ServiceFiltersPillsRow
class="mask-l-from-[calc(100%-var(--spacing)*4)] pb-2"
filters={filters}
filtersOptions={filtersOptions}
categories={categories}
attributes={attributes}
attributeOptions={attributeOptions}
inlineIcons={inlineIcons}
/>
</div>
<div class="flex items-center justify-between">
<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()}
@@ -67,6 +202,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
name="ri:loader-4-line"
id="search-indicator"
class="htmx-request:opacity-100 xs:-mx-1.5 -mx-1 inline-block size-4 animate-spin text-white opacity-0 transition-opacity duration-500 sm:-mx-3"
aria-hidden="true"
is:inline={inlineIcons}
/>
@@ -212,11 +348,9 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
</div>
) : (
<>
<div class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
<ol class="mt-6 grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
{services.map((service, i) => (
<ServiceCard
inlineIcons={inlineIcons}
service={service}
<li
data-hx-search-results-card
{...(i === services.length - 1 && currentPage < totalPages
? {
@@ -227,9 +361,11 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
'hx-indicator': '#infinite-scroll-indicator',
}
: {})}
/>
>
<ServiceCard inlineIcons={inlineIcons} service={service} />
</li>
))}
</div>
</ol>
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">

View File

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

View File

@@ -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>[]
)

View File

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

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

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

View File

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

View File

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

View File

@@ -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`,
},
]
}
}
}

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ export const areSameNormalized = (str1: string, str2: string): boolean => {
return normalize(str1) === normalize(str2)
}
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

View File

@@ -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()}&nbsp;{pluralize('day', daysUntilDeletion)}
</>
)}
</span>
<p class="text-day-400 mt-2 text-xs">
To prevent deletion, take any action such as voting, commenting, or suggesting a
service.
</p>
</div>
</li>
)
}
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div>

View File

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

View File

@@ -10,6 +10,7 @@ import Button from '../../../../components/Button.astro'
import FormSection from '../../../../components/FormSection.astro'
import FormSubSection from '../../../../components/FormSubSection.astro'
import InputCardGroup from '../../../../components/InputCardGroup.astro'
import InputCheckbox from '../../../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
import InputImageFile from '../../../../components/InputImageFile.astro'
import InputSelect from '../../../../components/InputSelect.astro'
@@ -545,6 +546,24 @@ const apiCalls = await Astro.locals.banners.try(
cardSize="sm"
/>
<InputCheckbox
label="Strict Commenting"
name="strictCommentingEnabled"
checked={service.strictCommentingEnabled}
descriptionInline="Require proof of being a client for comments."
/>
<InputTextArea
label="Comment Section Message"
name="commentSectionMessage"
value={service.commentSectionMessage ?? ''}
description="Markdown supported"
inputProps={{
rows: 4,
}}
error={serviceInputErrors.commentSectionMessage}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form>
</FormSection>

View File

@@ -3,6 +3,7 @@ import { AttributeCategory, Currency, VerificationStatus } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import InputCheckbox from '../../../components/InputCheckbox.astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { prisma } from '../../../lib/prisma'
@@ -368,6 +369,35 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
}
</div>
<InputCheckbox
label="Strict Commenting"
name="strictCommentingEnabled"
checked={false}
descriptionInline="Require proof of being a client for comments."
/>
<div>
<label for="commentSectionMessage" class="font-title mb-2 block text-sm text-green-500"
>Comment Section Message</label
>
<div class="space-y-2">
<textarea
transition:persist
name="commentSectionMessage"
id="commentSectionMessage"
rows={4}
placeholder="Markdown supported. This message will be displayed in the comment section for root comments."
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
set:text=""
/>
</div>
{
inputErrors.commentSectionMessage && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.commentSectionMessage.join(', ')}</p>
)
}
</div>
<button
type="submit"
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"

View File

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

View File

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

View File

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

View File

@@ -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,14 @@ const showFiltersId = 'show-filters'
/>
</form>
) : (
<div class="-ml-4 flex flex-1 items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4">
{filters.q && (
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
)}
{!areEqualArraysWithoutOrder(
filters.verification,
filtersOptions.verification
.filter((verification) => verification.default)
.map((verification) => verification.value)
) &&
filters.verification.map((verificationStatus) => {
const verificationStatusInfo = getVerificationStatusInfo(verificationStatus)
return (
<ServiceFiltersPill
text={verificationStatusInfo.label}
icon={verificationStatusInfo.icon}
iconClass={verificationStatusInfo.classNames.icon}
searchParamName="verification"
searchParamValue={verificationStatusInfo.slug}
/>
)
})}
{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}
inlineIcons={false}
/>
)
}
@@ -716,6 +493,7 @@ const showFiltersId = 'show-filters'
/>
<div
class="bg-night-700 fixed top-0 left-0 z-50 hidden h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 max-sm:peer-checked:block sm:relative sm:z-auto sm:block sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0"
aria-label="Search filters"
>
<ServicesFilters
searchResultsId={searchResultsId}
@@ -739,6 +517,11 @@ const showFiltersId = 'show-filters'
filters={filters}
countCommunityOnly={countCommunityOnly}
inlineIcons
categories={categories}
filtersOptions={filtersOptions}
attributes={attributes}
attributeOptions={attributeOptions}
aria-label="Search results"
/>
</div>
{

View File

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

View File

@@ -102,6 +102,8 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
userSentimentAt: true,
averageUserRating: true,
isRecentlyApproved: true,
strictCommentingEnabled: true,
commentSectionMessage: true,
contactMethods: {
select: {
value: true,
@@ -424,6 +426,30 @@ const activeAlertOrWarningEvents = service.events
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
const activeEventToShow =
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
// Sort verification steps: failed first, then warnings, then others, newest first within each group
const getVerificationStepPriority = (status: VerificationStepStatus) => {
switch (status) {
case VerificationStepStatus.FAILED:
return 0 // Highest priority
case VerificationStepStatus.WARNING:
return 1
case VerificationStepStatus.IN_PROGRESS:
return 2
case VerificationStepStatus.PENDING:
return 3
case VerificationStepStatus.PASSED:
return 4 // Lowest priority
default:
return 5
}
}
const sortedVerificationSteps = orderBy(
service.verificationSteps,
[(step) => getVerificationStepPriority(step.status), (step) => step.updatedAt],
['asc', 'desc'] // Priority ascending (failed first), date descending (newest first)
)
---
<BaseLayout
@@ -1474,11 +1500,11 @@ const activeEventToShow =
)
}
{
service.verificationSteps.length > 0 && (
sortedVerificationSteps.length > 0 && (
<>
<h3 class="font-title text-md mt-6 mb-2 font-semibold">Verification Steps</h3>
<ul class="mb-8 space-y-2">
{service.verificationSteps.map((step) => {
{sortedVerificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
@@ -1533,6 +1559,20 @@ const activeEventToShow =
<li>Moderation is light.</li>
<li>Double-check before trusting.</li>
</ul>
{
service.strictCommentingEnabled && (
<p class="mt-2">
<Icon
name="ri:verified-badge-fill"
class="me-0.5 inline-block size-4 align-[-0.3em] text-orange-100/95"
/>
<span class="font-medium text-orange-100/95">Proof of being a client required</span>, for this
service.
</p>
)
}
<div class="absolute inset-y-2 right-2 flex flex-col justify-center">
<Icon name="ri:alert-line" class="xs:opacity-20 h-full max-h-16 w-auto opacity-10" />
</div>

View File

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

View File

@@ -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))
}

View File

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