Compare commits
30 Commits
release-70
...
release-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a465849a76 | ||
|
|
25f6dba3eb | ||
|
|
7e7046e7d2 | ||
|
|
a5d1fb9a5d | ||
|
|
28b84a7d9b | ||
|
|
7a294cb0a1 | ||
|
|
349c26a4df | ||
|
|
86b1afb2c7 | ||
|
|
99bc1f4e0f | ||
|
|
3166349dfb | ||
|
|
5a54352d95 | ||
|
|
a545726abf | ||
|
|
01488b8b3b | ||
|
|
b456af9448 | ||
|
|
b7ae6dc22c | ||
|
|
e4a5fa8fa7 | ||
|
|
6ed07c8386 | ||
|
|
6a9f5f5e99 | ||
|
|
e6edee2dbe | ||
|
|
c7ee1606e4 | ||
|
|
f3c9b92ddb | ||
|
|
effb6689d7 | ||
|
|
cf5f3b3228 | ||
|
|
5a41816ac8 | ||
|
|
bf30a6cb2b | ||
|
|
4ca9b9a5c2 | ||
|
|
03abdef4f1 | ||
|
|
d9880fd83d | ||
|
|
39afcad089 | ||
|
|
99cb730bc0 |
13
.env.example
@@ -39,8 +39,11 @@ OPENAI_BASE_URL="https://your-openai-base-url.example.com"
|
||||
OPENAI_MODEL=your_openai_model
|
||||
OPENAI_RETRY=3
|
||||
|
||||
# Pyworker Crons
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day
|
||||
# Task schedules ---------------------------------------------------
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
|
||||
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
|
||||
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
|
||||
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily
|
||||
|
||||
3
.vscode/extensions.json
vendored
@@ -7,7 +7,8 @@
|
||||
"golang.go",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"craigrbroughton.htmx-attributes",
|
||||
"nefrob.vscode-just-syntax"
|
||||
"nefrob.vscode-just-syntax",
|
||||
"prisma.prisma"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ OPENAI_BASE_URL="https://xxxxxx/api/v1"
|
||||
OPENAI_MODEL="xxxxxxxxx"
|
||||
OPENAI_RETRY=3
|
||||
|
||||
CRON_TOSREVIEW_TASK=0 0 1 * * # Every month
|
||||
CRON_USER_SENTIMENT_TASK=0 0 * * * # Every day
|
||||
CRON_COMMENT_MODERATION_TASK=0 0 * * * # Every hour
|
||||
CRON_FORCE_TRIGGERS_TASK=0 2 * * * # Every day
|
||||
CRON_SERVICE_SCORE_RECALC_TASK=*/5 * * * * # Every 10 minutes
|
||||
# Task schedules ---------------------------------------------------
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
|
||||
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
|
||||
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
|
||||
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily
|
||||
@@ -38,6 +38,7 @@ Required environment variables:
|
||||
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
||||
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
|
||||
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
|
||||
- `CRON_INACTIVE_USERS_TASK`: Cron expression for inactive users cleanup task
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -60,6 +61,9 @@ uv run -m pyworker force-triggers
|
||||
|
||||
# Run service score recalculation task
|
||||
uv run -m pyworker service-score-recalc [--service-id ID]
|
||||
|
||||
# Run inactive users cleanup task
|
||||
uv run -m pyworker inactive-users
|
||||
```
|
||||
|
||||
### Worker Mode
|
||||
@@ -96,7 +100,7 @@ Tasks will run according to their configured cron schedules.
|
||||
### Force Triggers Task
|
||||
|
||||
- Maintains database triggers by forcing them to run under certain conditions
|
||||
- Currently handles updating the "isRecentlyListed" flag for services after 15 days
|
||||
- Currently handles updating the "isRecentlyApproved" flag for services after 15 days
|
||||
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
|
||||
|
||||
### Service Score Recalculation Task
|
||||
@@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules.
|
||||
- Calculates privacy, trust, and overall scores
|
||||
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
|
||||
|
||||
### Inactive Users Task
|
||||
|
||||
- Handles cleanup of inactive user accounts
|
||||
- Identifies users who have been inactive for 1 year (no comments, votes, suggestions, and 0 karma)
|
||||
- Sends deletion warning notifications at 30, 15, 5, and 1 day intervals
|
||||
- Deletes accounts that remain inactive after the warning period
|
||||
- Cancels deletion for users who become active again
|
||||
- Scheduled via `CRON_INACTIVE_USERS_TASK`
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
@@ -124,6 +137,7 @@ pyworker/
|
||||
│ │ ├── base.py
|
||||
│ │ ├── comment_moderation.py
|
||||
│ │ ├── force_triggers.py
|
||||
│ │ ├── inactive_users.py
|
||||
│ │ ├── service_score_recalc.py
|
||||
│ │ ├── tos_review.py
|
||||
│ │ └── user_sentiment.py
|
||||
|
||||
@@ -17,6 +17,7 @@ from pyworker.scheduler import TaskScheduler
|
||||
from .tasks import (
|
||||
CommentModerationTask,
|
||||
ForceTriggersTask,
|
||||
InactiveUsersTask,
|
||||
ServiceScoreRecalculationTask,
|
||||
TosReviewTask,
|
||||
UserSentimentTask,
|
||||
@@ -89,6 +90,23 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
||||
score_recalc_parser.add_argument(
|
||||
"--service-id", type=int, help="Specific service ID to process (optional)"
|
||||
)
|
||||
score_recalc_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Recalculate scores for all services (ignores --service-id)",
|
||||
)
|
||||
|
||||
# Service Score Recalculation task for all services
|
||||
subparsers.add_parser(
|
||||
"service-score-recalc-all",
|
||||
help="Recalculate service scores for all services",
|
||||
)
|
||||
|
||||
# Inactive users task
|
||||
subparsers.add_parser(
|
||||
"inactive-users",
|
||||
help="Handle inactive users - send deletion warnings and clean up accounts",
|
||||
)
|
||||
|
||||
return parser.parse_args(args)
|
||||
|
||||
@@ -295,12 +313,15 @@ def run_force_triggers_task() -> int:
|
||||
close_db_pool()
|
||||
|
||||
|
||||
def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
def run_service_score_recalc_task(
|
||||
service_id: Optional[int] = None, all_services: bool = False
|
||||
) -> int:
|
||||
"""
|
||||
Run the service score recalculation task.
|
||||
|
||||
Args:
|
||||
service_id: Optional specific service ID to process.
|
||||
all_services: Whether to recalculate scores for all services.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
@@ -310,7 +331,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
try:
|
||||
# Initialize task and use as context manager
|
||||
with ServiceScoreRecalculationTask() as task: # type: ignore
|
||||
result = task.run(service_id) # type: ignore
|
||||
if all_services:
|
||||
queued = task.recalculate_all_services() # type: ignore
|
||||
if not queued:
|
||||
logger.warning(
|
||||
"Failed to queue recalculation jobs for all services"
|
||||
)
|
||||
|
||||
# Continuously process queued jobs in batches until none remain
|
||||
while True:
|
||||
_ = task.run() # type: ignore
|
||||
|
||||
# Check if there are still unprocessed jobs
|
||||
remaining = 0
|
||||
if task.conn:
|
||||
with task.conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'SELECT COUNT(*) FROM "ServiceScoreRecalculationJob" WHERE "processedAt" IS NULL'
|
||||
)
|
||||
remaining = cursor.fetchone()[0]
|
||||
|
||||
if remaining == 0:
|
||||
break
|
||||
|
||||
result = True # All jobs processed successfully
|
||||
|
||||
else:
|
||||
result = task.run(service_id) # type: ignore
|
||||
|
||||
if result:
|
||||
logger.info("Successfully recalculated service scores")
|
||||
else:
|
||||
@@ -323,6 +371,37 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
close_db_pool()
|
||||
|
||||
|
||||
def run_service_score_recalc_all_task() -> int:
|
||||
"""
|
||||
Run the service score recalculation task for all services.
|
||||
"""
|
||||
return run_service_score_recalc_task(all_services=True)
|
||||
|
||||
|
||||
def run_inactive_users_task() -> int:
|
||||
"""
|
||||
Run the inactive users task.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
logger.info("Starting inactive users task")
|
||||
|
||||
try:
|
||||
# Initialize task and use as context manager
|
||||
with InactiveUsersTask() as task: # type: ignore
|
||||
result = task.run() # type: ignore
|
||||
logger.info(f"Inactive users task completed. Results: {result}")
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.exception(f"Error running inactive users task: {e}")
|
||||
return 1
|
||||
finally:
|
||||
# Ensure connection pool is closed even if an error occurs
|
||||
close_db_pool()
|
||||
|
||||
|
||||
def run_worker_mode() -> int:
|
||||
"""
|
||||
Run in worker mode, scheduling tasks to run periodically.
|
||||
@@ -334,42 +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
|
||||
)
|
||||
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,
|
||||
)
|
||||
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:
|
||||
@@ -419,7 +493,13 @@ def main() -> int:
|
||||
elif args.task == "force-triggers":
|
||||
return run_force_triggers_task()
|
||||
elif args.task == "service-score-recalc":
|
||||
return run_service_score_recalc_task(args.service_id)
|
||||
return run_service_score_recalc_task(
|
||||
args.service_id, getattr(args, "all", False)
|
||||
)
|
||||
elif args.task == "service-score-recalc-all":
|
||||
return run_service_score_recalc_all_task()
|
||||
elif args.task == "inactive-users":
|
||||
return run_inactive_users_task()
|
||||
elif args.task:
|
||||
logger.error(f"Unknown task: {args.task}")
|
||||
return 1
|
||||
|
||||
@@ -333,28 +333,32 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
|
||||
|
||||
|
||||
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
|
||||
"""
|
||||
Save a TOS review for a specific service.
|
||||
"""Persist a TOS review and/or update the timestamp for a service.
|
||||
|
||||
Args:
|
||||
service_id: The ID of the service.
|
||||
review: A TypedDict containing the review data.
|
||||
If *review* is ``None`` the existing review (if any) is preserved while
|
||||
only the ``tosReviewAt`` column is updated. This ensures we still track
|
||||
when the review task last ran even if the review generation failed or
|
||||
produced no changes.
|
||||
"""
|
||||
try:
|
||||
# Only serialize to JSON if review is not None
|
||||
review_json = json.dumps(review) if review is not None else None
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE "Service"
|
||||
SET "tosReview" = %s, "tosReviewAt" = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(review_json, service_id),
|
||||
)
|
||||
if review is None:
|
||||
cursor.execute(
|
||||
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
|
||||
(service_id,),
|
||||
)
|
||||
else:
|
||||
review_json = json.dumps(review)
|
||||
cursor.execute(
|
||||
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
|
||||
(review_json, service_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"Successfully saved TOS review for service {service_id}")
|
||||
logger.info(
|
||||
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving TOS review for service {service_id}: {e}")
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from pyworker.database import close_db_pool
|
||||
from .tasks import (
|
||||
CommentModerationTask,
|
||||
ForceTriggersTask,
|
||||
InactiveUsersTask,
|
||||
ServiceScoreRecalculationTask,
|
||||
TosReviewTask,
|
||||
UserSentimentTask,
|
||||
@@ -62,25 +63,29 @@ class TaskScheduler:
|
||||
cron_expression: Cron expression defining the schedule.
|
||||
task_func: Function to execute.
|
||||
*args: Arguments to pass to the task function.
|
||||
**kwargs: Keyword arguments to pass to the task function.
|
||||
**kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg.
|
||||
"""
|
||||
instantiate = kwargs.pop("instantiate", True)
|
||||
# Declare task_instance variable with type annotation upfront
|
||||
task_instance: Any = None
|
||||
|
||||
# Initialize the appropriate task class based on the task name
|
||||
if task_name.lower() == "tosreview":
|
||||
task_instance = TosReviewTask()
|
||||
elif task_name.lower() == "user_sentiment":
|
||||
task_instance = UserSentimentTask()
|
||||
elif task_name.lower() == "comment_moderation":
|
||||
task_instance = CommentModerationTask()
|
||||
elif task_name.lower() == "force_triggers":
|
||||
task_instance = ForceTriggersTask()
|
||||
elif task_name.lower() == "service_score_recalc":
|
||||
task_instance = ServiceScoreRecalculationTask()
|
||||
else:
|
||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||
return
|
||||
if instantiate:
|
||||
# Initialize the appropriate task class based on the task name
|
||||
if task_name.lower() == "tosreview":
|
||||
task_instance = TosReviewTask()
|
||||
elif task_name.lower() == "user_sentiment":
|
||||
task_instance = UserSentimentTask()
|
||||
elif task_name.lower() == "comment_moderation":
|
||||
task_instance = CommentModerationTask()
|
||||
elif task_name.lower() == "force_triggers":
|
||||
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
|
||||
|
||||
self.tasks[task_name] = {
|
||||
"cron": cron_expression,
|
||||
@@ -126,8 +131,12 @@ class TaskScheduler:
|
||||
self.logger.info(f"Running task '{task_name}'")
|
||||
# Use task instance as a context manager to ensure
|
||||
# a single database connection is used for the entire task
|
||||
with task_info["instance"]:
|
||||
# Execute the registered task function with its arguments
|
||||
if task_info["instance"]:
|
||||
with task_info["instance"]:
|
||||
# Execute the registered task function with its arguments
|
||||
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||
else:
|
||||
# Execute the registered task function without a context manager
|
||||
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||
self.logger.info(f"Task '{task_name}' completed")
|
||||
except Exception as e:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
|
||||
Force triggers to run under certain conditions.
|
||||
"""
|
||||
|
||||
RECENT_LISTED_INTERVAL_DAYS = 15
|
||||
RECENT_APPROVED_INTERVAL_DAYS = 15
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("force_triggers")
|
||||
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
|
||||
|
||||
update_query = f"""
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW()
|
||||
WHERE "isRecentlyListed" = TRUE
|
||||
AND "listedAt" IS NOT NULL
|
||||
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days'
|
||||
SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
|
||||
WHERE "isRecentlyApproved" = TRUE
|
||||
AND "approvedAt" IS NOT NULL
|
||||
AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
|
||||
"""
|
||||
try:
|
||||
with self.conn.cursor() as cursor:
|
||||
|
||||
258
pyworker/pyworker/tasks/inactive_users.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Task for handling inactive users - sending deletion warnings and cleaning up accounts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pyworker.database import execute_db_command, run_db_query
|
||||
from pyworker.tasks.base import Task
|
||||
|
||||
|
||||
class InactiveUsersTask(Task):
|
||||
"""Task for handling inactive users"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the inactive users task."""
|
||||
super().__init__("inactive_users")
|
||||
|
||||
def run(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the inactive users task.
|
||||
|
||||
This task:
|
||||
1. Identifies users who have been inactive for 1 year
|
||||
2. Schedules them for deletion
|
||||
3. Sends warning notifications at 30, 15, 5, and 1 day intervals
|
||||
4. Deletes accounts that have reached their deletion date
|
||||
"""
|
||||
results = {
|
||||
"users_scheduled_for_deletion": 0,
|
||||
"notifications_sent": 0,
|
||||
"accounts_deleted": 0,
|
||||
"deletions_cancelled": 0,
|
||||
}
|
||||
|
||||
# Step 1: Cancel deletion for users who became active again
|
||||
results["deletions_cancelled"] = self._cancel_deletion_for_active_users()
|
||||
|
||||
# Step 2: Schedule new inactive users for deletion
|
||||
results["users_scheduled_for_deletion"] = self._schedule_inactive_users_for_deletion()
|
||||
|
||||
# Step 3: Send warning notifications
|
||||
results["notifications_sent"] = self._send_deletion_warnings()
|
||||
|
||||
# Step 4: Delete accounts that have reached their deletion date
|
||||
results["accounts_deleted"] = self._delete_scheduled_accounts()
|
||||
|
||||
self.logger.info(
|
||||
f"Inactive users task completed. "
|
||||
f"Deletions cancelled: {results['deletions_cancelled']}, "
|
||||
f"Scheduled: {results['users_scheduled_for_deletion']}, "
|
||||
f"Notifications sent: {results['notifications_sent']}, "
|
||||
f"Accounts deleted: {results['accounts_deleted']}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _schedule_inactive_users_for_deletion(self) -> int:
|
||||
"""
|
||||
Schedule inactive users for deletion.
|
||||
|
||||
A user is considered inactive if:
|
||||
- Account was created more than 1 year ago
|
||||
- Has 0 karma
|
||||
- Has no comments, comment votes, or service suggestions
|
||||
- Is not scheduled for deletion already
|
||||
- Is not an admin or moderator
|
||||
"""
|
||||
one_year_ago = datetime.now() - timedelta(days=365)
|
||||
deletion_date = date.today() + timedelta(days=30) # 30 days from today
|
||||
|
||||
# Find inactive users
|
||||
query = """
|
||||
UPDATE "User"
|
||||
SET "scheduledDeletionAt" = %s, "updatedAt" = NOW()
|
||||
WHERE "id" IN (
|
||||
SELECT u."id"
|
||||
FROM "User" u
|
||||
WHERE u."createdAt" < %s
|
||||
AND u."scheduledDeletionAt" IS NULL
|
||||
AND u."admin" = false
|
||||
AND u."moderator" = false
|
||||
AND u."totalKarma" = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Comment" c WHERE c."authorId" = u."id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "CommentVote" cv WHERE cv."userId" = u."id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ServiceSuggestion" ss WHERE ss."userId" = u."id"
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
count = execute_db_command(query, (deletion_date, one_year_ago))
|
||||
self.logger.info(f"Scheduled {count} inactive users for deletion on {deletion_date}")
|
||||
return count
|
||||
|
||||
def _send_deletion_warnings(self) -> int:
|
||||
"""
|
||||
Send deletion warning notifications to users at appropriate intervals.
|
||||
"""
|
||||
today = date.today()
|
||||
notifications_sent = 0
|
||||
|
||||
# Define warning intervals and their corresponding notification types
|
||||
warning_intervals = [
|
||||
(30, 'ACCOUNT_DELETION_WARNING_30_DAYS'),
|
||||
(15, 'ACCOUNT_DELETION_WARNING_15_DAYS'),
|
||||
(5, 'ACCOUNT_DELETION_WARNING_5_DAYS'),
|
||||
(1, 'ACCOUNT_DELETION_WARNING_1_DAY'),
|
||||
]
|
||||
|
||||
for days_before, notification_type in warning_intervals:
|
||||
# Find users who should receive this warning (exact date match)
|
||||
target_date = today + timedelta(days=days_before)
|
||||
|
||||
# Check if user is still inactive (no recent activity)
|
||||
users_query = """
|
||||
SELECT u."id", u."name", u."scheduledDeletionAt"
|
||||
FROM "User" u
|
||||
WHERE u."scheduledDeletionAt" = %s
|
||||
AND u."admin" = false
|
||||
AND u."moderator" = false
|
||||
AND u."totalKarma" = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Notification" n
|
||||
WHERE n."userId" = u."id"
|
||||
AND n."type" = %s
|
||||
AND n."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
-- Still check if user is inactive (no activity since being scheduled)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Comment" c
|
||||
WHERE c."authorId" = u."id"
|
||||
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "CommentVote" cv
|
||||
WHERE cv."userId" = u."id"
|
||||
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ServiceSuggestion" ss
|
||||
WHERE ss."userId" = u."id"
|
||||
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
"""
|
||||
|
||||
users = run_db_query(users_query, (target_date, notification_type))
|
||||
|
||||
# Create notifications for these users
|
||||
for user in users:
|
||||
insert_notification_query = """
|
||||
INSERT INTO "Notification" ("userId", "type", "createdAt", "updatedAt")
|
||||
VALUES (%s, %s, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
|
||||
execute_db_command(insert_notification_query, (user['id'], notification_type))
|
||||
notifications_sent += 1
|
||||
|
||||
self.logger.info(
|
||||
f"Sent {notification_type} notification to user {user['name']} "
|
||||
f"(ID: {user['id']}) scheduled for deletion on {user['scheduledDeletionAt']}"
|
||||
)
|
||||
|
||||
return notifications_sent
|
||||
|
||||
def _delete_scheduled_accounts(self) -> int:
|
||||
"""
|
||||
Delete accounts that have reached their scheduled deletion date and are still inactive.
|
||||
"""
|
||||
today = date.today()
|
||||
|
||||
# Find users scheduled for deletion who are still inactive
|
||||
users_to_delete_query = """
|
||||
SELECT u."id", u."name", u."scheduledDeletionAt"
|
||||
FROM "User" u
|
||||
WHERE u."scheduledDeletionAt" <= %s
|
||||
AND u."admin" = false
|
||||
AND u."moderator" = false
|
||||
AND u."totalKarma" = 0
|
||||
-- Double-check they're still inactive
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Comment" c
|
||||
WHERE c."authorId" = u."id"
|
||||
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "CommentVote" cv
|
||||
WHERE cv."userId" = u."id"
|
||||
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ServiceSuggestion" ss
|
||||
WHERE ss."userId" = u."id"
|
||||
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
"""
|
||||
|
||||
users_to_delete = run_db_query(users_to_delete_query, (today,))
|
||||
deleted_count = 0
|
||||
|
||||
for user in users_to_delete:
|
||||
try:
|
||||
# Delete the user (this will cascade and delete related records)
|
||||
delete_query = 'DELETE FROM "User" WHERE "id" = %s'
|
||||
execute_db_command(delete_query, (user['id'],))
|
||||
deleted_count += 1
|
||||
|
||||
self.logger.info(
|
||||
f"Deleted inactive user {user['name']} (ID: {user['id']}) "
|
||||
f"scheduled for deletion on {user['scheduledDeletionAt']}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to delete user {user['name']} (ID: {user['id']}): {e}"
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
def _cancel_deletion_for_active_users(self) -> int:
|
||||
"""
|
||||
Cancel scheduled deletion for users who have become active again.
|
||||
"""
|
||||
# Find users scheduled for deletion who have recent activity or gained karma
|
||||
query = """
|
||||
UPDATE "User"
|
||||
SET "scheduledDeletionAt" = NULL, "updatedAt" = NOW()
|
||||
WHERE "scheduledDeletionAt" IS NOT NULL
|
||||
AND (
|
||||
"totalKarma" > 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "Comment" c
|
||||
WHERE c."authorId" = "User"."id"
|
||||
AND c."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "CommentVote" cv
|
||||
WHERE cv."userId" = "User"."id"
|
||||
AND cv."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "ServiceSuggestion" ss
|
||||
WHERE ss."userId" = "User"."id"
|
||||
AND ss."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
count = execute_db_command(query)
|
||||
if count > 0:
|
||||
self.logger.info(f"Cancelled deletion for {count} users who became active again or gained karma")
|
||||
|
||||
return count
|
||||
@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM "Service"
|
||||
WHERE "isActive" = TRUE
|
||||
FROM "Service"
|
||||
"""
|
||||
)
|
||||
services = cursor.fetchall()
|
||||
|
||||
@@ -34,8 +34,8 @@ class TosReviewTask(Task):
|
||||
service_name = service["name"]
|
||||
verification_status = service.get("verificationStatus")
|
||||
|
||||
# Only process verified or approved services
|
||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]:
|
||||
# Only process verified, approved, or community contributed services
|
||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
|
||||
self.logger.info(
|
||||
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
||||
)
|
||||
|
||||
@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
|
||||
{"role": "user", "content": content},
|
||||
]
|
||||
|
||||
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
|
||||
result_dict = query_openai_json(
|
||||
messages, model="openai/gemini-2.5-flash-preview-05-20"
|
||||
)
|
||||
|
||||
return cast(TosReviewCheck, result_dict)
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -35,9 +37,11 @@ export default defineConfig({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'KYCnot.me',
|
||||
short_name: 'KYCnot.me',
|
||||
description: 'Find services that respect your privacy',
|
||||
theme_color: '#040505',
|
||||
background_color: '#171c1b',
|
||||
display: 'minimal-ui',
|
||||
},
|
||||
pwaAssets: {
|
||||
image: './public/favicon.svg',
|
||||
@@ -93,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],
|
||||
@@ -107,8 +124,9 @@ export default defineConfig({
|
||||
'/service/[...slug]/proof': '/service/[...slug]/#verification',
|
||||
'/attribute/[...slug]': '/attributes',
|
||||
'/attr/[...slug]': '/attributes',
|
||||
'/service/[...slug]/review': '/service/[...slug]#comments',
|
||||
// #endregion
|
||||
|
||||
'/service/[...slug]/review': '/service/[...slug]#comments',
|
||||
},
|
||||
env: {
|
||||
schema: {
|
||||
@@ -122,7 +140,7 @@ export default defineConfig({
|
||||
}),
|
||||
// Public URLs (can be accessed from both server and client)
|
||||
SOURCE_CODE_URL: envField.string({
|
||||
context: 'server',
|
||||
context: 'client',
|
||||
access: 'public',
|
||||
url: true,
|
||||
optional: false,
|
||||
|
||||
925
web/package-lock.json
generated
@@ -27,30 +27,31 @@
|
||||
"@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.5",
|
||||
"@fontsource/inter": "5.2.6",
|
||||
"@fontsource/space-grotesk": "5.2.8",
|
||||
"@prisma/client": "6.9.0",
|
||||
"@tailwindcss/vite": "4.1.8",
|
||||
"@types/mime-types": "3.0.0",
|
||||
"@prisma/client": "6.10.1",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.9.0",
|
||||
"astro": "5.11.0",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"astro-seo-schema": "5.0.0",
|
||||
"canvas": "3.1.0",
|
||||
"canvas": "3.1.2",
|
||||
"clsx": "2.1.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"he": "1.2.0",
|
||||
"htmx.org": "2.0.6",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
"lodash-es": "4.17.21",
|
||||
"mime-types": "3.0.1",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"pg": "8.16.0",
|
||||
"pg": "8.16.3",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.5.6",
|
||||
@@ -58,52 +59,52 @@
|
||||
"seedrandom": "3.0.5",
|
||||
"sharp": "0.34.2",
|
||||
"slugify": "1.6.6",
|
||||
"tailwind-merge": "3.3.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.8",
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.3",
|
||||
"unique-username-generator": "1.4.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod-form-data": "2.0.7"
|
||||
"web-push": "3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.28.0",
|
||||
"@faker-js/faker": "9.8.0",
|
||||
"@iconify-json/material-symbols": "1.2.24",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@faker-js/faker": "9.9.0",
|
||||
"@iconify-json/material-symbols": "1.2.28",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
"@stylistic/eslint-plugin": "4.4.1",
|
||||
"@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.6",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.33.1",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
"@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.28.0",
|
||||
"eslint-import-resolver-typescript": "4.4.3",
|
||||
"eslint": "9.30.1",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"globals": "16.2.0",
|
||||
"prettier": "3.5.3",
|
||||
"globals": "16.3.0",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.12",
|
||||
"prisma": "6.9.0",
|
||||
"prisma-json-types-generator": "3.4.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.13",
|
||||
"prisma": "6.10.1",
|
||||
"prisma-json-types-generator": "3.5.0",
|
||||
"tailwind-htmx": "0.1.2",
|
||||
"ts-essentials": "10.0.4",
|
||||
"ts-essentials": "10.1.1",
|
||||
"ts-toolbelt": "9.6.0",
|
||||
"tsx": "4.19.4",
|
||||
"typescript-eslint": "8.33.1",
|
||||
"vite-plugin-devtools-json": "0.1.1",
|
||||
"tsx": "4.20.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"vite-plugin-devtools-json": "0.2.1",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "spamAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `isRecentlyListed` on the `Service` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" DROP COLUMN "isRecentlyListed",
|
||||
ADD COLUMN "isRecentlyApproved" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_approvedAt_idx" ON "Service"("approvedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_verifiedAt_idx" ON "Service"("verifiedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_spamAt_idx" ON "Service"("spamAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_serviceVisibility_idx" ON "Service"("serviceVisibility");
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ALTER COLUMN "operatingSince" SET DATA TYPE DATE;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_30_DAYS';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_15_DAYS';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_5_DAYS';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_1_DAY';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "scheduledDeletionAt" DATE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Category" ADD COLUMN "namePluralLong" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "strictCommentingEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "commentSectionMessage" TEXT;
|
||||
@@ -144,6 +144,10 @@ enum NotificationType {
|
||||
ACCOUNT_STATUS_CHANGE
|
||||
EVENT_CREATED
|
||||
SERVICE_VERIFICATION_STATUS_CHANGE
|
||||
ACCOUNT_DELETION_WARNING_30_DAYS
|
||||
ACCOUNT_DELETION_WARNING_15_DAYS
|
||||
ACCOUNT_DELETION_WARNING_5_DAYS
|
||||
ACCOUNT_DELETION_WARNING_1_DAY
|
||||
}
|
||||
|
||||
enum CommentStatusChange {
|
||||
@@ -349,12 +353,12 @@ model Service {
|
||||
categories Category[] @relation("ServiceToCategory")
|
||||
kycLevel Int @default(4)
|
||||
kycLevelClarification KycLevelClarification @default(NONE)
|
||||
/// Date only, no time.
|
||||
operatingSince DateTime? @db.Date
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
isRecentlyListed Boolean @default(false)
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
averageUserRating Float?
|
||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||
@@ -363,8 +367,6 @@ model Service {
|
||||
verificationSummary String?
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
verificationProofMd String?
|
||||
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||
verifiedAt DateTime?
|
||||
/// [UserSentiment]
|
||||
userSentiment Json?
|
||||
userSentimentAt DateTime?
|
||||
@@ -380,7 +382,16 @@ model Service {
|
||||
tosReviewAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
|
||||
listedAt DateTime?
|
||||
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
|
||||
approvedAt DateTime?
|
||||
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||
verifiedAt DateTime?
|
||||
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
|
||||
spamAt DateTime?
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
isRecentlyApproved Boolean @default(false)
|
||||
comments Comment[]
|
||||
events Event[]
|
||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||
@@ -395,7 +406,13 @@ model Service {
|
||||
Notification Notification[]
|
||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||
|
||||
strictCommentingEnabled Boolean @default(false)
|
||||
commentSectionMessage String?
|
||||
|
||||
@@index([listedAt])
|
||||
@@index([approvedAt])
|
||||
@@index([verifiedAt])
|
||||
@@index([spamAt])
|
||||
@@index([overallScore])
|
||||
@@index([privacyScore])
|
||||
@@index([trustScore])
|
||||
@@ -407,6 +424,7 @@ model Service {
|
||||
@@index([updatedAt])
|
||||
@@index([slug])
|
||||
@@index([previousSlugs])
|
||||
@@index([serviceVisibility])
|
||||
}
|
||||
|
||||
model ServiceContactMethod {
|
||||
@@ -487,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
|
||||
@@ -578,6 +598,7 @@ enum VerificationStepStatus {
|
||||
IN_PROGRESS
|
||||
PASSED
|
||||
FAILED
|
||||
WARNING
|
||||
}
|
||||
|
||||
model VerificationStep {
|
||||
@@ -597,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
|
||||
@@ -677,8 +699,6 @@ model PushSubscription {
|
||||
p256dh String
|
||||
/// Authentication secret
|
||||
auth String
|
||||
/// To identify different devices
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -13,14 +13,15 @@ import {
|
||||
PrismaClient,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceUserRole,
|
||||
VerificationStatus,
|
||||
type Prisma,
|
||||
type User,
|
||||
type ServiceVisibility,
|
||||
ServiceSuggestionType,
|
||||
KycLevelClarification,
|
||||
VerificationStepStatus,
|
||||
type VerificationStatus,
|
||||
} from '@prisma/client'
|
||||
import { differenceInDays, isPast } from 'date-fns'
|
||||
import { omit, uniqBy } from 'lodash-es'
|
||||
import { generateUsername } from 'unique-username-generator'
|
||||
|
||||
@@ -140,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',
|
||||
},
|
||||
@@ -614,6 +631,14 @@ const generateFakeService = (users: User[]) => {
|
||||
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
|
||||
probability: 0.8,
|
||||
})
|
||||
const serviceVisibility = faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||
{ weight: 80, value: 'PUBLIC' },
|
||||
{ weight: 10, value: 'UNLISTED' },
|
||||
{ weight: 5, value: 'HIDDEN' },
|
||||
{ weight: 5, value: 'ARCHIVED' },
|
||||
])
|
||||
const approvedAt =
|
||||
status === 'APPROVED' || status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null
|
||||
|
||||
return {
|
||||
name,
|
||||
@@ -629,12 +654,7 @@ const generateFakeService = (users: User[]) => {
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||
{ weight: 80, value: 'PUBLIC' },
|
||||
{ weight: 10, value: 'UNLISTED' },
|
||||
{ weight: 5, value: 'HIDDEN' },
|
||||
{ weight: 5, value: 'ARCHIVED' },
|
||||
]),
|
||||
serviceVisibility,
|
||||
verificationStatus: status,
|
||||
verificationSummary:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||
@@ -677,8 +697,14 @@ const generateFakeService = (users: User[]) => {
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||
listedAt: faker.date.past(),
|
||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
||||
listedAt:
|
||||
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
|
||||
? faker.date.recent({ days: 30 })
|
||||
: null,
|
||||
verifiedAt: status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null,
|
||||
spamAt: status === 'VERIFICATION_FAILED' ? faker.date.recent({ days: 30 }) : null,
|
||||
approvedAt,
|
||||
isRecentlyApproved: !!approvedAt && isPast(approvedAt) && differenceInDays(new Date(), approvedAt) < 15,
|
||||
tosReview,
|
||||
tosReviewAt: tosReview
|
||||
? faker.date.recent()
|
||||
@@ -694,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
|
||||
}
|
||||
|
||||
@@ -908,7 +936,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
||||
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
||||
},
|
||||
{
|
||||
label: faker.lorem.word({ length: 2 }),
|
||||
label: 'Custom label',
|
||||
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||
},
|
||||
{
|
||||
@@ -918,7 +946,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
||||
value: faker.internet.url(),
|
||||
},
|
||||
{
|
||||
label: faker.lorem.word({ length: 2 }),
|
||||
label: 'Custom label',
|
||||
value: faker.internet.url(),
|
||||
},
|
||||
{
|
||||
@@ -1307,7 +1335,7 @@ async function main() {
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
|
||||
@@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
DECLARE
|
||||
is_user_related_to_service BOOLEAN;
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
1,
|
||||
'COMMENT_APPROVED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been approved!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 1);
|
||||
-- Check if the user is related to the service (e.g., owns/manages it)
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM "ServiceUser"
|
||||
WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId"
|
||||
) INTO is_user_related_to_service;
|
||||
|
||||
-- Check if the user is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."authorId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
|
||||
-- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator
|
||||
IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
1,
|
||||
'COMMENT_APPROVED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been approved!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 1);
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
DECLARE
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
5,
|
||||
'COMMENT_VERIFIED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been verified!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 5);
|
||||
-- Check if the comment author is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."authorId"
|
||||
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
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
5,
|
||||
'COMMENT_VERIFIED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been verified!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 5);
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -146,12 +175,19 @@ DECLARE
|
||||
comment_author_id INT;
|
||||
service_name TEXT;
|
||||
upvote_change INT := 0; -- Variable to track change in upvotes
|
||||
is_author_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
-- Get comment author and service info
|
||||
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
||||
FROM "Comment" c
|
||||
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
|
||||
|
||||
-- Check if the comment author is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = comment_author_id
|
||||
INTO is_author_admin_or_moderator;
|
||||
|
||||
-- Calculate karma impact based on vote type
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- New vote
|
||||
@@ -181,16 +217,19 @@ BEGIN
|
||||
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
||||
END IF;
|
||||
|
||||
-- Record karma transaction and update user karma
|
||||
PERFORM insert_karma_transaction(
|
||||
comment_author_id,
|
||||
karma_points,
|
||||
vote_action,
|
||||
COALESCE(NEW."commentId", OLD."commentId"),
|
||||
vote_description
|
||||
);
|
||||
|
||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||
-- Only award karma if the author is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_author_admin_or_moderator, false) THEN
|
||||
-- Record karma transaction and update user karma
|
||||
PERFORM insert_karma_transaction(
|
||||
comment_author_id,
|
||||
karma_points,
|
||||
vote_action,
|
||||
COALESCE(NEW."commentId", OLD."commentId"),
|
||||
vote_description
|
||||
);
|
||||
|
||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||
END IF;
|
||||
|
||||
-- Update comment's upvotes count incrementally
|
||||
UPDATE "Comment"
|
||||
@@ -236,26 +275,40 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
service_name TEXT;
|
||||
service_visibility "ServiceVisibility";
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
-- Award karma for first approval
|
||||
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
||||
-- and ensure it wasn't already APPROVED.
|
||||
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
||||
-- Fetch service name for the description
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
||||
-- 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 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;
|
||||
|
||||
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
||||
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
||||
-- for services when services or their attributes change.
|
||||
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days.
|
||||
-- 2. Updates the isRecentlyApproved flag for services approved within the last 15 days.
|
||||
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
||||
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
||||
-- efficient handling of widespread score updates.
|
||||
@@ -24,12 +24,9 @@ RETURNS INT AS $$
|
||||
DECLARE
|
||||
privacy_score INT := 0;
|
||||
kyc_factor INT;
|
||||
onion_factor INT := 0;
|
||||
i2p_factor INT := 0;
|
||||
clarification_factor INT := 0;
|
||||
onion_or_i2p_factor INT := 0;
|
||||
monero_factor INT := 0;
|
||||
open_source_factor INT := 0;
|
||||
p2p_factor INT := 0;
|
||||
decentralized_factor INT := 0;
|
||||
attributes_score INT := 0;
|
||||
BEGIN
|
||||
-- Get service data
|
||||
@@ -46,20 +43,22 @@ BEGIN
|
||||
FROM "Service"
|
||||
WHERE "id" = service_id;
|
||||
|
||||
-- Check for onion URLs
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
|
||||
) THEN
|
||||
onion_factor := 5;
|
||||
END IF;
|
||||
-- Adjust score based on KYC level clarification modifiers
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
|
||||
ELSE 0 -- Default modifier when no clarification or unrecognized value
|
||||
END
|
||||
INTO clarification_factor
|
||||
FROM "Service"
|
||||
WHERE "id" = service_id;
|
||||
|
||||
-- Check for i2p URLs
|
||||
-- Check for onion or i2p URLs
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
|
||||
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
|
||||
) THEN
|
||||
i2p_factor := 5;
|
||||
onion_or_i2p_factor := 5;
|
||||
END IF;
|
||||
|
||||
-- Check for Monero acceptance
|
||||
@@ -75,10 +74,10 @@ BEGIN
|
||||
INTO attributes_score
|
||||
FROM "ServiceAttribute" sa
|
||||
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
||||
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
||||
WHERE sa."serviceId" = service_id;
|
||||
|
||||
-- Calculate final privacy score (base 100)
|
||||
privacy_score := 50 + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
||||
privacy_score := 50 + kyc_factor + clarification_factor + onion_or_i2p_factor + monero_factor + attributes_score;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||
@@ -94,8 +93,9 @@ DECLARE
|
||||
trust_score INT := 0;
|
||||
verification_factor INT;
|
||||
attributes_score INT := 0;
|
||||
recently_listed_factor INT := 0;
|
||||
recently_approved_factor INT := 0;
|
||||
tos_penalty_factor INT := 0;
|
||||
operating_since_factor INT := 0;
|
||||
BEGIN
|
||||
-- Get verification status factor
|
||||
SELECT
|
||||
@@ -115,26 +115,26 @@ BEGIN
|
||||
INTO attributes_score
|
||||
FROM "ServiceAttribute" sa
|
||||
JOIN "Attribute" a ON sa."attributeId" = a.id
|
||||
WHERE sa."serviceId" = service_id AND a.category = 'TRUST';
|
||||
WHERE sa."serviceId" = service_id;
|
||||
|
||||
-- Apply penalty if service was listed within the last 15 days
|
||||
-- Apply penalty if service was approved within the last 15 days
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "Service"
|
||||
WHERE id = service_id
|
||||
AND "listedAt" IS NOT NULL
|
||||
AND "approvedAt" IS NOT NULL
|
||||
AND "verificationStatus" = 'APPROVED'
|
||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
||||
AND (NOW() - "approvedAt") <= INTERVAL '15 days'
|
||||
) THEN
|
||||
recently_listed_factor := -10;
|
||||
-- Update the isRecentlyListed flag to true
|
||||
recently_approved_factor := -10;
|
||||
-- Update the isRecentlyApproved flag to true
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = TRUE
|
||||
SET "isRecentlyApproved" = TRUE
|
||||
WHERE id = service_id;
|
||||
ELSE
|
||||
-- Update the isRecentlyListed flag to false
|
||||
-- Update the isRecentlyApproved flag to false
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = FALSE
|
||||
SET "isRecentlyApproved" = FALSE
|
||||
WHERE id = service_id;
|
||||
END IF;
|
||||
|
||||
@@ -149,8 +149,20 @@ BEGIN
|
||||
tos_penalty_factor := -3;
|
||||
END IF;
|
||||
|
||||
-- Determine trust adjustment based on operatingSince
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "operatingSince" IS NULL THEN 0
|
||||
WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty
|
||||
WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus
|
||||
ELSE 0
|
||||
END
|
||||
INTO operating_since_factor
|
||||
FROM "Service"
|
||||
WHERE id = service_id;
|
||||
|
||||
-- Calculate final trust score (base 100)
|
||||
trust_score := 50 + verification_factor + attributes_score + recently_listed_factor + tos_penalty_factor;
|
||||
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||
@@ -165,7 +177,7 @@ RETURNS INT AS $$
|
||||
DECLARE
|
||||
overall_score INT;
|
||||
BEGIN
|
||||
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT);
|
||||
overall_score := CAST(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0 AS INT);
|
||||
RETURN GREATEST(0, LEAST(10, overall_score));
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
|
||||
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
|
||||
-- are set or cleared appropriately when a service's verification status is updated.
|
||||
|
||||
CREATE OR REPLACE FUNCTION manage_service_timestamps()
|
||||
CREATE OR REPLACE FUNCTION manage_service_visibility_timestamps()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Manage listedAt timestamp
|
||||
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
||||
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
|
||||
IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
|
||||
IF OLD."listedAt" IS NULL THEN
|
||||
NEW."listedAt" := NOW();
|
||||
NEW."isRecentlyListed" := TRUE;
|
||||
END IF;
|
||||
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
||||
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
|
||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
||||
ELSE
|
||||
NEW."listedAt" := NULL;
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
END IF;
|
||||
|
||||
-- Manage verifiedAt timestamp
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
|
||||
NEW."verifiedAt" := NOW();
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
|
||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
||||
NEW."verifiedAt" := NULL;
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the old trigger first if it exists under the old name
|
||||
CREATE OR REPLACE FUNCTION manage_service_verification_timestamps()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF (NEW."verificationStatus" = 'APPROVED' OR NEW."verificationStatus" = 'VERIFICATION_SUCCESS') THEN
|
||||
IF OLD."approvedAt" IS NULL THEN
|
||||
NEW."approvedAt" := NOW();
|
||||
NEW."isRecentlyApproved" := TRUE;
|
||||
END IF;
|
||||
ELSE
|
||||
NEW."approvedAt" := NULL;
|
||||
NEW."isRecentlyApproved" := FALSE;
|
||||
END IF;
|
||||
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
NEW."verifiedAt" := NOW();
|
||||
ELSE
|
||||
NEW."verifiedAt" := NULL;
|
||||
END IF;
|
||||
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_FAILED' THEN
|
||||
NEW."spamAt" := NOW();
|
||||
ELSE
|
||||
NEW."spamAt" := NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the old triggers TODO: remove this some day
|
||||
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
|
||||
-- Drop the trigger if it exists under the new name
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_timestamps
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_visibility_timestamps ON "Service";
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_verification_timestamps ON "Service";
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_visibility_timestamps
|
||||
BEFORE UPDATE OF "serviceVisibility" ON "Service"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION manage_service_visibility_timestamps();
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_verification_timestamps
|
||||
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
||||
FOR EACH ROW
|
||||
-- Only execute the function if the verificationStatus value has actually changed
|
||||
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
|
||||
EXECUTE FUNCTION manage_service_timestamps();
|
||||
EXECUTE FUNCTION manage_service_verification_timestamps();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||
import { createAccount } from '../lib/accountCreate'
|
||||
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { startImpersonating } from '../lib/impersonation'
|
||||
import { startImpersonating, stopImpersonating } from '../lib/impersonation'
|
||||
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
||||
@@ -225,4 +226,36 @@ export const accountActions = {
|
||||
return { user }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z
|
||||
.object({
|
||||
...captchaFormSchemaProperties,
|
||||
})
|
||||
.superRefine(captchaFormSchemaSuperRefine),
|
||||
handler: async (_input, context) => {
|
||||
if (context.locals.user.admin || context.locals.user.moderator) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Admins and moderators cannot delete their own accounts.',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: context.locals.user.id },
|
||||
})
|
||||
|
||||
const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture'])
|
||||
|
||||
if (context.locals.actualUser) {
|
||||
await stopImpersonating(context)
|
||||
} else {
|
||||
await logout(context)
|
||||
}
|
||||
|
||||
return { deletedUser }
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -58,10 +58,13 @@ const serviceSchemaBase = z.object({
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(null),
|
||||
operatingSince: z.coerce.date().optional().nullable(),
|
||||
imageFile: imageFileSchema,
|
||||
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
|
||||
@@ -126,7 +129,10 @@ export const adminServiceActions = {
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||
commentSectionMessage: input.commentSectionMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
@@ -149,6 +155,7 @@ export const adminServiceActions = {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
operatingSince: input.operatingSince,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -244,7 +251,10 @@ export const adminServiceActions = {
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||
commentSectionMessage: input.commentSectionMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
@@ -256,7 +266,6 @@ export const adminServiceActions = {
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
imageUrl,
|
||||
categories: {
|
||||
connect: categoriesToAdd.map((id) => ({ id })),
|
||||
@@ -273,6 +282,7 @@ export const adminServiceActions = {
|
||||
attributeId,
|
||||
})),
|
||||
},
|
||||
operatingSince: input.operatingSince,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -65,13 +65,13 @@ export const apiServiceActions = {
|
||||
tosUrls: true,
|
||||
referral: true,
|
||||
listedAt: true,
|
||||
approvedAt: true,
|
||||
verifiedAt: true,
|
||||
serviceVisibility: true,
|
||||
} as const satisfies Prisma.ServiceSelect
|
||||
|
||||
let service = await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
OR: [
|
||||
@@ -92,7 +92,6 @@ export const apiServiceActions = {
|
||||
if (!service && input.slug) {
|
||||
service = await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
previousSlugs: { has: input.slug },
|
||||
@@ -105,9 +104,7 @@ export const apiServiceActions = {
|
||||
!service ||
|
||||
(service.serviceVisibility !== 'PUBLIC' &&
|
||||
service.serviceVisibility !== 'ARCHIVED' &&
|
||||
service.serviceVisibility !== 'UNLISTED') ||
|
||||
!service.listedAt ||
|
||||
service.listedAt > new Date()
|
||||
service.serviceVisibility !== 'UNLISTED')
|
||||
) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
@@ -130,12 +127,13 @@ export const apiServiceActions = {
|
||||
'description',
|
||||
]),
|
||||
verifiedAt: service.verifiedAt,
|
||||
approvedAt: service.approvedAt,
|
||||
kycLevel: service.kycLevel,
|
||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||
kycLevelClarification: service.kycLevelClarification,
|
||||
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||
'value',
|
||||
'name',
|
||||
'label',
|
||||
'description',
|
||||
]),
|
||||
categories: service.categories,
|
||||
|
||||
@@ -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) {
|
||||
@@ -270,6 +271,18 @@ export const commentActions = {
|
||||
}
|
||||
}
|
||||
|
||||
const isRelatedToService = !!(await tx.serviceUser.findUnique({
|
||||
where: {
|
||||
userId_serviceId: {
|
||||
userId: context.locals.user.id,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}))
|
||||
|
||||
// Prepare data object with proper type safety
|
||||
const commentData: Prisma.CommentCreateInput = {
|
||||
content: input.content,
|
||||
@@ -277,7 +290,12 @@ export const commentActions = {
|
||||
author: { connect: { id: context.locals.user.id } },
|
||||
|
||||
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
||||
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
|
||||
status:
|
||||
context.locals.user.admin || context.locals.user.moderator || isRelatedToService
|
||||
? 'APPROVED'
|
||||
: isIssueReport
|
||||
? 'HUMAN_PENDING'
|
||||
: 'PENDING',
|
||||
requiresAdminReview,
|
||||
orderId: input.orderId?.trim() ?? null,
|
||||
kycRequested: input.issueKycRequested === true,
|
||||
|
||||
@@ -32,7 +32,6 @@ export const notificationActions = {
|
||||
endpoint: z.string(),
|
||||
p256dhKey: z.string(),
|
||||
authKey: z.string(),
|
||||
userAgent: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.pushSubscription.upsert({
|
||||
@@ -43,14 +42,12 @@ export const notificationActions = {
|
||||
update: {
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -58,25 +55,17 @@ export const notificationActions = {
|
||||
|
||||
unsubscribe: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'user',
|
||||
permissions: 'guest',
|
||||
input: z.object({
|
||||
endpoint: z.string().optional(),
|
||||
endpoint: z.string(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
if (input.endpoint) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
await prisma.pushSubscription.delete({
|
||||
where: {
|
||||
userId: context.locals.user?.id ?? undefined,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -36,7 +36,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||
},
|
||||
@@ -50,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 = {
|
||||
@@ -166,6 +183,7 @@ export const serviceSuggestionActions = {
|
||||
message: 'You must accept the suggestion rules and process to continue',
|
||||
}),
|
||||
}),
|
||||
operatingSince: z.coerce.date().optional(),
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
skipDuplicateCheck: z
|
||||
@@ -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
|
||||
@@ -240,6 +281,7 @@ export const serviceSuggestionActions = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
operatingSince: input.operatingSince,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
@@ -252,7 +294,6 @@ export const serviceSuggestionActions = {
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
listedAt: new Date(),
|
||||
serviceVisibility: 'UNLISTED',
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
8
web/src/assets/review-badge/long-black.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#BEBEBE"
|
||||
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#3BDB78"
|
||||
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
8
web/src/assets/review-badge/long-white.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||
<rect width="431" height="63" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
|
||||
<path fill="#28AE5B" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#3F3F3F"
|
||||
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#28AE5B"
|
||||
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
8
web/src/assets/review-badge/short-black.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
|
||||
<rect width="255" height="95" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||
<path fill="#3BDB78" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#BEBEBE"
|
||||
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#3BDB78"
|
||||
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
8
web/src/assets/review-badge/short-white.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
|
||||
<rect width="255" height="95" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
|
||||
<path fill="#28AE5B" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#3F3F3F"
|
||||
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#28AE5B"
|
||||
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
24
web/src/components/AdminNavigationFixScript.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
if (!Astro.locals.user?.admin) return
|
||||
---
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Script that adds data-astro-reload to all admin links or all links if on an admin page. //
|
||||
// This is a workaround to prevent the client router messing up inputs. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLAnchorElement | HTMLFormElement>('a,form').forEach((element) => {
|
||||
const isAdminPage = window.location.pathname.startsWith('/admin')
|
||||
if (isAdminPage) {
|
||||
element.setAttribute('data-astro-reload', '')
|
||||
}
|
||||
|
||||
const url = element.href ? new URL(element.href) : null
|
||||
if (url?.pathname.startsWith('/admin')) {
|
||||
element.setAttribute('data-astro-reload', '')
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
---
|
||||
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'
|
||||
|
||||
import { isNotArray } from '../lib/arrays'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
|
||||
import AdminNavigationFixScript from './AdminNavigationFixScript.astro'
|
||||
import DevToolsMessageScript from './DevToolsMessageScript.astro'
|
||||
import DynamicFavicon from './DynamicFavicon.astro'
|
||||
import HtmxScript from './HtmxScript.astro'
|
||||
import NotificationEventsScript from './NotificationEventsScript.astro'
|
||||
@@ -76,46 +79,49 @@ 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} />}
|
||||
|
||||
<DynamicFavicon />
|
||||
|
||||
<!-- Components -->
|
||||
<ClientRouter />
|
||||
<AdminNavigationFixScript />
|
||||
|
||||
<LoadingIndicator color="green" />
|
||||
<TailwindJsPluggin />
|
||||
{htmx && <HtmxScript />}
|
||||
|
||||
<!-- JSON-LD Schemas -->
|
||||
{/* JSON-LD Schemas */}
|
||||
{schemas?.map((item) => <Schema item={item} />)}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
{/* Breadcrumbs */}
|
||||
{
|
||||
breadcrumbLists.map((breadcrumbList) => (
|
||||
<Schema
|
||||
@@ -147,3 +153,5 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<DevToolsMessageScript />
|
||||
|
||||
@@ -288,6 +288,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||
aria-disabled={disabled}
|
||||
aria-label={label}
|
||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||
{...htmlProps}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../lib/commentsWithReplies'
|
||||
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { formatDateShort } from '../lib/timeAgo'
|
||||
import { urlDomain } from '../lib/urls'
|
||||
|
||||
import BadgeSmall from './BadgeSmall.astro'
|
||||
import CommentModeration from './CommentModeration.astro'
|
||||
@@ -32,6 +33,7 @@ type Props = HTMLAttributes<'div'> & {
|
||||
highlightedCommentId: number | null
|
||||
serviceSlug: string
|
||||
itemReviewedId: string
|
||||
strictCommentingEnabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -41,6 +43,7 @@ const {
|
||||
highlightedCommentId = null,
|
||||
serviceSlug,
|
||||
itemReviewedId,
|
||||
strictCommentingEnabled,
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
@@ -150,13 +153,13 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
checked={comment.suspicious}
|
||||
/>
|
||||
|
||||
<div class="comment-header scrollbar-w-none -mt-10 flex items-center gap-2 overflow-auto pt-10 text-sm">
|
||||
<div class="comment-header flex items-center gap-2 text-sm">
|
||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||
<span class="collapse-symbol text-xs"></span>
|
||||
<span class="sr-only">Toggle comment visibility</span>
|
||||
</label>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="flex min-w-16 items-center gap-1">
|
||||
<UserBadge
|
||||
user={comment.author}
|
||||
size="md"
|
||||
@@ -170,7 +173,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
comment.author.admin || comment.author.moderator
|
||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||
: ''
|
||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
||||
}${comment.author.verifiedLink ? `Related to ${urlDomain(comment.author.verifiedLink)}` : ''}`}
|
||||
>
|
||||
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
||||
</Tooltip>
|
||||
@@ -179,7 +182,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
</span>
|
||||
|
||||
{/* User badges - more compact but still with text */}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<div class="flex w-min grow flex-wrap items-center gap-1">
|
||||
{
|
||||
comment.author.admin && (
|
||||
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
|
||||
@@ -491,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"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -35,6 +35,8 @@ type Props = {
|
||||
name: true
|
||||
description: true
|
||||
createdAt: true
|
||||
strictCommentingEnabled: true
|
||||
commentSectionMessage: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
@@ -71,12 +73,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
|
||||
'Failed to fetch comments',
|
||||
async () =>
|
||||
await prisma.comment.findMany(
|
||||
makeCommentsNestedQuery({
|
||||
await makeCommentsNestedQuery({
|
||||
depth: MAX_COMMENT_DEPTH,
|
||||
user,
|
||||
showPending: params.showPending,
|
||||
serviceId: service.id,
|
||||
sort: params.sort,
|
||||
highlightedCommentId: params.comment,
|
||||
})
|
||||
),
|
||||
[],
|
||||
@@ -172,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">
|
||||
@@ -257,6 +266,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
||||
showPending={params.showPending}
|
||||
serviceSlug={service.slug}
|
||||
itemReviewedId={itemReviewedId}
|
||||
strictCommentingEnabled={service.strictCommentingEnabled}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
51
web/src/components/DevToolsMessageScript.astro
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||
|
||||
const logoStyle = `
|
||||
padding: 0 119.5px;
|
||||
display: block;
|
||||
line-height: 64px;
|
||||
background-size: auto 64px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50% 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 240 64'%3E%3Crect width='239' height='63' x='.5' y='.5' fill='%23101413' stroke='%23292B2A' rx='7.5'/%3E%3Cpath fill='%233BDB78' d='M19 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1H19Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.9 10.8a1 1 0 0 0-.1.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L47 23.5a1 1 0 0 1 .8-1.5ZM75 22h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H74v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H75a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L186 37.2l-6.7-14.6a1 1 0 0 0-1-.6H175Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z'/%3E%3C/svg%3E");
|
||||
`
|
||||
|
||||
setTimeout(
|
||||
console.log.bind(
|
||||
console,
|
||||
`\n%c \n%c\n 👋%c Hi there! %c\n\n‣ We included source maps, so you can easily inspect the code. 🕵🏻♂️\n‣ Everything works with JavaScript disabled.\n‣ Source code: ${SOURCE_CODE_URL}`,
|
||||
logoStyle,
|
||||
`
|
||||
font-family: cursive;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
`,
|
||||
`
|
||||
font-family: cursive;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
#d97706 0%,
|
||||
#f59e0b 20%,
|
||||
#f97316 40%,
|
||||
#ea580c 60%,
|
||||
#f97316 80%,
|
||||
#f59e0b 100%
|
||||
) -100%/ 200%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
`,
|
||||
`
|
||||
font-size: 1rem;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
`
|
||||
)
|
||||
)
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
|
||||
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||
import { I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
|
||||
import { sample } from 'lodash-es'
|
||||
|
||||
import { splashTexts } from '../constants/splashTexts'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { cn } from '../lib/cn'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
||||
|
||||
import AdminOnly from './AdminOnly.astro'
|
||||
@@ -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">
|
||||
@@ -123,6 +124,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
||||
transition:name="header-admin-link"
|
||||
text="Admin Dashboard"
|
||||
position="left"
|
||||
aria-label="Admin Dashboard"
|
||||
>
|
||||
<Icon name="ri:home-gear-line" class="size-10" />
|
||||
</Tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
---
|
||||
|
||||
<script>
|
||||
import * as htmx from 'htmx.org'
|
||||
import htmx from 'htmx.org'
|
||||
|
||||
htmx.config.globalViewTransitions = false
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
|
||||
iconClass?: string
|
||||
description?: MarkdownString
|
||||
disabled?: boolean
|
||||
noTransitionPersist?: boolean
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValue?: Multiple extends true ? string[] : string
|
||||
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
||||
transition:persist
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
|
||||
<script>
|
||||
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
|
||||
import { makeNotificationOptions } from '../lib/notificationOptions'
|
||||
import {
|
||||
makeBrowserNotificationOptions,
|
||||
makeBrowserNotificationTitle,
|
||||
} from '../lib/client/notificationOptions'
|
||||
|
||||
document.addEventListener('sse:new-notification', (event) => {
|
||||
if (isBrowserNotificationsEnabled()) {
|
||||
const payload = event.detail
|
||||
const notification = showBrowserNotification(
|
||||
payload.title,
|
||||
makeNotificationOptions(payload, { removeActions: true })
|
||||
makeBrowserNotificationTitle(payload.title),
|
||||
makeBrowserNotificationOptions(payload, { removeActions: true })
|
||||
)
|
||||
|
||||
// Handle notification click
|
||||
|
||||
151
web/src/components/PressAssets.astro
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
import favicon from '../../public/favicon.svg'
|
||||
import logoMiniFull from '../assets/logo/logo-mini-full.svg'
|
||||
import logoNormal from '../assets/logo/logo-normal.svg'
|
||||
import logoSmall from '../assets/logo/logo-small.svg'
|
||||
import reviewBadgeLongBlack from '../assets/review-badge/long-black.svg'
|
||||
import reviewBadgeLongWhite from '../assets/review-badge/long-white.svg'
|
||||
import reviewBadgeShortBlack from '../assets/review-badge/short-black.svg'
|
||||
import reviewBadgeShortWhite from '../assets/review-badge/short-white.svg'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import MyPicture from './MyPicture.astro'
|
||||
|
||||
const categories: {
|
||||
title: string
|
||||
assets: {
|
||||
name: string
|
||||
path: typeof logoNormal
|
||||
alt: string
|
||||
}[]
|
||||
}[] = [
|
||||
{
|
||||
title: 'Logos',
|
||||
assets: [
|
||||
{
|
||||
name: 'Logo',
|
||||
path: logoNormal,
|
||||
alt: 'KYCnot.me logo normal version',
|
||||
},
|
||||
{
|
||||
name: 'Logo small',
|
||||
path: logoSmall,
|
||||
alt: 'KYCnot.me logo small version',
|
||||
},
|
||||
{
|
||||
name: 'Logo mini',
|
||||
path: logoMiniFull,
|
||||
alt: 'KYCnot.me logo mini version',
|
||||
},
|
||||
{
|
||||
name: 'Logo icon',
|
||||
path: favicon,
|
||||
alt: 'KYCnot.me logo icon version',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Review badges',
|
||||
assets: [
|
||||
{
|
||||
name: 'Review badge (long black)',
|
||||
path: reviewBadgeLongBlack,
|
||||
alt: 'KYCnot.me review badge long black version',
|
||||
},
|
||||
{
|
||||
name: 'Review badge (long white)',
|
||||
path: reviewBadgeLongWhite,
|
||||
alt: 'KYCnot.me review badge long white version',
|
||||
},
|
||||
{
|
||||
name: 'Review badge (short black)',
|
||||
path: reviewBadgeShortBlack,
|
||||
alt: 'KYCnot.me review badge short black version',
|
||||
},
|
||||
{
|
||||
name: 'Review badge (short white)',
|
||||
path: reviewBadgeShortWhite,
|
||||
alt: 'KYCnot.me review badge short white version',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<div class="not-prose mb-16 space-y-8">
|
||||
{
|
||||
categories.map((category) => {
|
||||
return (
|
||||
<>
|
||||
<h3 class="font-title mb-2 text-center text-xl font-semibold text-white">{category.title}</h3>
|
||||
<ul class="xs:grid-cols-2 grid grid-cols-1 gap-6">
|
||||
{category.assets.map((asset) => (
|
||||
<li>
|
||||
<div
|
||||
class="bg-transparency-grid mx-auto flex aspect-[3/1] max-w-sm items-center justify-center rounded-lg p-4"
|
||||
style={{
|
||||
'--transparency-grid-color-1': 'var(--color-night-600)',
|
||||
'--transparency-grid-color-2': 'var(--color-night-500)',
|
||||
'--transparency-grid-size': 'calc(var(--spacing) * 4)',
|
||||
}}
|
||||
>
|
||||
<MyPicture
|
||||
src={asset.path}
|
||||
alt={asset.alt}
|
||||
pictureAttributes={{
|
||||
class: 'contents',
|
||||
}}
|
||||
class="max-h-full min-h-8 max-w-full min-w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 text-center">
|
||||
<Button
|
||||
as="a"
|
||||
href={asset.path.src}
|
||||
download={asset.name}
|
||||
label={asset.name}
|
||||
size="sm"
|
||||
color="white"
|
||||
variant="faded"
|
||||
icon="ri:download-line"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg-transparency-grid {
|
||||
--transparency-grid-color-1: #fff;
|
||||
--transparency-grid-color-2: #ccc;
|
||||
--transparency-grid-size: calc(var(--spacing) * 8);
|
||||
|
||||
background-color: var(--transparency-grid-color-1);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--transparency-grid-color-2) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--transparency-grid-color-2) 75%,
|
||||
var(--transparency-grid-color-2)
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--transparency-grid-color-2) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--transparency-grid-color-2) 75%,
|
||||
var(--transparency-grid-color-2)
|
||||
);
|
||||
background-size: var(--transparency-grid-size) var(--transparency-grid-size);
|
||||
background-position:
|
||||
0 0,
|
||||
calc(var(--transparency-grid-size) / 2) calc(var(--transparency-grid-size) / 2);
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,6 @@ type Props = HTMLAttributes<'div'> & {
|
||||
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||
select: {
|
||||
endpoint: true
|
||||
userAgent: true
|
||||
}
|
||||
}>[]
|
||||
}
|
||||
|
||||
@@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { HTMLTag, Polymorphic } from 'astro/types'
|
||||
import type { Review, WithContext } from 'schema-dts'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
type Props = Polymorphic<{
|
||||
as: HTMLTag
|
||||
score: number
|
||||
label: string
|
||||
total?: number
|
||||
itemReviewedId?: string
|
||||
}
|
||||
showInfo?: boolean
|
||||
children?: never
|
||||
}>
|
||||
|
||||
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
score,
|
||||
label,
|
||||
total = 100,
|
||||
class: className,
|
||||
itemReviewedId,
|
||||
showInfo = false,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
|
||||
|
||||
@@ -65,13 +77,13 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
<Tag
|
||||
{...htmlProps}
|
||||
class={cn(
|
||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
role={htmlProps.role ?? 'group'}
|
||||
>
|
||||
<div
|
||||
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
||||
@@ -166,10 +178,14 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
||||
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
||||
class="stroke-night-700"></path>
|
||||
|
||||
<!-- Info icon -->
|
||||
<!-- <path
|
||||
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
||||
fill="white"
|
||||
fill-opacity="0.67"></path> -->
|
||||
{
|
||||
showInfo && (
|
||||
<path
|
||||
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
||||
class="text-current/60"
|
||||
fill="currentColor"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
</Tag>
|
||||
|
||||
@@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { HTMLTag, Polymorphic } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
type Props = Polymorphic<{
|
||||
as: HTMLTag
|
||||
score: number
|
||||
label: string
|
||||
total?: number
|
||||
itemReviewedId?: string
|
||||
}
|
||||
showInfo?: boolean
|
||||
children?: never
|
||||
}>
|
||||
|
||||
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
score,
|
||||
label,
|
||||
total = 10,
|
||||
class: className,
|
||||
itemReviewedId,
|
||||
showInfo = false,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
---
|
||||
|
||||
<div
|
||||
<Tag
|
||||
{...htmlProps}
|
||||
class={cn(
|
||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
role={htmlProps.role ?? 'group'}
|
||||
>
|
||||
{
|
||||
!!itemReviewedId && (
|
||||
@@ -48,15 +60,17 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
/>
|
||||
)
|
||||
}
|
||||
<!-- <svg
|
||||
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z"
|
||||
></path>
|
||||
</svg> -->
|
||||
{
|
||||
showInfo && (
|
||||
<svg
|
||||
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
@@ -77,4 +91,4 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
</div>
|
||||
|
||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
||||
</div>
|
||||
</Tag>
|
||||
|
||||
@@ -27,6 +27,12 @@ if (!Astro.locals.user) return
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
// NOTE: Disable sse: events when user is not logged in
|
||||
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||
stopServerEventsListener()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data as string)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import MyPicture from './MyPicture.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
@@ -14,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<{
|
||||
@@ -23,6 +22,8 @@ type Props = HTMLAttributes<'a'> & {
|
||||
slug: true
|
||||
description: true
|
||||
overallScore: true
|
||||
privacyScore: true
|
||||
trustScore: true
|
||||
kycLevel: true
|
||||
imageUrl: true
|
||||
verificationStatus: true
|
||||
@@ -45,6 +46,8 @@ const {
|
||||
slug,
|
||||
description,
|
||||
overallScore,
|
||||
privacyScore,
|
||||
trustScore,
|
||||
kycLevel,
|
||||
imageUrl,
|
||||
categories,
|
||||
@@ -54,7 +57,7 @@ const {
|
||||
},
|
||||
class: className,
|
||||
withoutLink = false,
|
||||
...aProps
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const statusIcon = {
|
||||
@@ -67,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 {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={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`}
|
||||
>
|
||||
{overallScoreInfo.formattedScore}
|
||||
</Tooltip>
|
||||
|
||||
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
||||
KYC {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
200
web/src/components/ServiceFiltersPillsRow.astro
Normal 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>
|
||||
@@ -4,13 +4,7 @@
|
||||
|
||||
<script>
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Window {
|
||||
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||
}
|
||||
}
|
||||
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
|
||||
|
||||
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
|
||||
|
||||
@@ -40,7 +34,7 @@
|
||||
|
||||
function shouldSkipAutoReload() {
|
||||
const currentPath = window.location.pathname
|
||||
const isErrorPage = document.querySelector('[data-is-error-page]') !== null
|
||||
const isErrorPage = document.body.hasAttribute('data-is-error-page')
|
||||
|
||||
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
|
||||
}
|
||||
@@ -51,4 +45,15 @@
|
||||
void updateSW(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
document.addEventListener('astro:page-load', async () => {
|
||||
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||
await unsubscribeFromPushNotifications()
|
||||
window.__SW_REGISTRATION__?.unregister()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,24 +348,26 @@ 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}
|
||||
data-hx-search-results-card
|
||||
{...(i === services.length - 1 && currentPage < totalPages
|
||||
? {
|
||||
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
|
||||
'hx-trigger': 'revealed',
|
||||
'hx-swap': 'afterend',
|
||||
'hx-select': '[data-hx-search-results-card]',
|
||||
'hx-indicator': '#infinite-scroll-indicator',
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
<li>
|
||||
<ServiceCard
|
||||
inlineIcons={inlineIcons}
|
||||
service={service}
|
||||
data-hx-search-results-card
|
||||
{...(i === services.length - 1 && currentPage < totalPages
|
||||
? {
|
||||
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
|
||||
'hx-trigger': 'revealed',
|
||||
'hx-swap': 'afterend',
|
||||
'hx-select': '[data-hx-search-results-card]',
|
||||
'hx-indicator': '#infinite-scroll-indicator',
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { differenceInDays, isPast } from 'date-fns'
|
||||
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import TimeFormatted from './TimeFormatted.astro'
|
||||
import { formatDaysAgo } from '../lib/timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const RECENTLY_ADDED_DAYS = 7
|
||||
|
||||
type Props = {
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
verificationProofMd: true
|
||||
verificationSummary: true
|
||||
listedAt: true
|
||||
approvedAt: true
|
||||
isRecentlyApproved: true
|
||||
createdAt: true
|
||||
verificationSteps: {
|
||||
select: {
|
||||
@@ -29,9 +27,6 @@ type Props = {
|
||||
}
|
||||
|
||||
const { service } = Astro.props
|
||||
|
||||
const listedDate = service.listedAt ?? service.createdAt
|
||||
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
|
||||
---
|
||||
|
||||
{
|
||||
@@ -57,23 +52,23 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
|
||||
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
|
||||
<span>
|
||||
Community-contributed. Information not reviewed.
|
||||
Community contributed. Information not reviewed.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
href="/about#community-contributed"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : wasRecentlyAdded ? (
|
||||
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
|
||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
||||
) : service.isRecentlyApproved ? (
|
||||
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
|
||||
This service was approved
|
||||
{service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
|
||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||
caution.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
href="/about#approved"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
@@ -83,10 +78,10 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
|
||||
<Icon name="ri:information-line" class="size-5 text-blue-400" />
|
||||
<span>
|
||||
Basic checks passed, but not fully verified.
|
||||
Basic checks passed, but service is not verified.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
href="/about#approved"
|
||||
class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
@@ -98,14 +93,29 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
{
|
||||
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
||||
service.verificationSteps.some((step) => step.status === 'FAILED') && (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
<a
|
||||
href="#verification"
|
||||
class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400 transition-colors hover:bg-red-900/60"
|
||||
>
|
||||
<Icon
|
||||
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
|
||||
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
|
||||
/>
|
||||
<span>
|
||||
This service has failed one or more verification steps. Review the verification details carefully.
|
||||
</span>
|
||||
</div>
|
||||
<span>Some verification steps failed. Please review the details below.</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
||||
!service.verificationSteps.some((step) => step.status === 'FAILED') &&
|
||||
service.verificationSteps.some((step) => step.status === 'WARNING') && (
|
||||
<a
|
||||
href="#verification"
|
||||
class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200 transition-colors hover:bg-yellow-600/40"
|
||||
>
|
||||
<Icon name={verificationStepStatusesByValue.WARNING.icon} class={cn('size-5 text-yellow-400')} />
|
||||
<span>Some verification steps are marked as warnings.</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||
description: string
|
||||
classNames: {
|
||||
dot: string
|
||||
banner?: string
|
||||
}
|
||||
icon: string
|
||||
color: TailwindColor
|
||||
@@ -34,6 +35,7 @@ export const {
|
||||
description: '',
|
||||
classNames: {
|
||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
|
||||
},
|
||||
icon: 'ri:question-fill',
|
||||
color: 'gray',
|
||||
@@ -48,6 +50,7 @@ export const {
|
||||
description: 'Potential issues that users should be aware of',
|
||||
classNames: {
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
|
||||
},
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'yellow',
|
||||
@@ -61,6 +64,7 @@ export const {
|
||||
description: 'A previously reported warning has been solved',
|
||||
classNames: {
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
|
||||
},
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'green',
|
||||
@@ -74,6 +78,7 @@ export const {
|
||||
description: 'Critical issues affecting service functionality',
|
||||
classNames: {
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
|
||||
},
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'red',
|
||||
@@ -87,6 +92,7 @@ export const {
|
||||
description: 'A previously reported alert has been solved',
|
||||
classNames: {
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
|
||||
},
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'green',
|
||||
@@ -100,6 +106,7 @@ export const {
|
||||
description: 'General information about the service',
|
||||
classNames: {
|
||||
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
|
||||
banner: 'bg-blue-900/50 text-blue-300 hover:bg-blue-800/60 focus-visible:bg-blue-800/60',
|
||||
},
|
||||
icon: 'ri:information-fill',
|
||||
color: 'sky',
|
||||
@@ -113,6 +120,7 @@ export const {
|
||||
description: 'Regular service update or announcement',
|
||||
classNames: {
|
||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
|
||||
},
|
||||
icon: 'ri:notification-fill',
|
||||
color: 'green',
|
||||
@@ -126,6 +134,7 @@ export const {
|
||||
description: 'Service details were updated on kycnot.me',
|
||||
classNames: {
|
||||
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
|
||||
banner: 'bg-sky-900/50 text-sky-300 hover:bg-sky-800/60 focus-visible:bg-sky-800/60',
|
||||
},
|
||||
icon: 'ri:pencil-fill',
|
||||
color: 'sky',
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { KycLevelClarification } from '@prisma/client'
|
||||
import type { AttributeType, KycLevelClarification } from '@prisma/client'
|
||||
|
||||
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
slug: string
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
privacyPoints: number
|
||||
attributeType: AttributeType
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -18,22 +21,31 @@ export const {
|
||||
'value',
|
||||
(value): KycLevelClarificationInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase().replace('_', '-') : '',
|
||||
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||
description: '',
|
||||
icon: 'ri:question-line',
|
||||
privacyPoints: 0,
|
||||
attributeType: 'INFO',
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'NONE',
|
||||
slug: 'none',
|
||||
label: 'None',
|
||||
description: 'No clarification needed.',
|
||||
icon: 'ri:file-copy-line',
|
||||
privacyPoints: 0,
|
||||
attributeType: 'INFO',
|
||||
},
|
||||
{
|
||||
value: 'DEPENDS_ON_PARTNERS',
|
||||
slug: 'depends-on-partners',
|
||||
label: 'Depends on partners',
|
||||
description: 'May vary across partners.',
|
||||
icon: 'ri:share-forward-line',
|
||||
privacyPoints: -5,
|
||||
attributeType: 'WARNING',
|
||||
},
|
||||
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
|
||||
)
|
||||
|
||||
@@ -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>[]
|
||||
)
|
||||
|
||||
33
web/src/constants/readStatus.ts
Normal 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[]
|
||||
)
|
||||
@@ -26,7 +26,7 @@ type VerificationStatusInfo<T extends string | null | undefined = string> = {
|
||||
}
|
||||
|
||||
export const READ_MORE_SENTENCE_LINK =
|
||||
'Read more about the [suggestion review process](/about#suggestion-review-process).' satisfies MarkdownString
|
||||
'Read more about the [listing statuses](/about#listing-statuses).' satisfies MarkdownString
|
||||
|
||||
export const {
|
||||
dataArray: verificationStatuses,
|
||||
|
||||
@@ -42,6 +42,12 @@ export const {
|
||||
icon: 'ri:alert-line',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
value: 'WARNING',
|
||||
label: 'Warning',
|
||||
icon: 'ri:alert-line',
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
value: 'PENDING',
|
||||
label: 'Pending',
|
||||
|
||||
3
web/src/env.d.ts
vendored
@@ -3,7 +3,7 @@
|
||||
import type { ErrorBanners } from './lib/errorBanners'
|
||||
import type { KarmaUnlocks } from './lib/karmaUnlocks'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type * as htmx from 'htmx.org'
|
||||
import type htmx from 'htmx.org'
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
@@ -17,6 +17,7 @@ declare global {
|
||||
|
||||
interface Window {
|
||||
htmx?: typeof htmx
|
||||
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||
}
|
||||
|
||||
namespace PrismaJson {
|
||||
|
||||
@@ -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">
|
||||
@@ -81,9 +105,18 @@ const announcement = await Astro.locals.banners.try(
|
||||
</head>
|
||||
<body
|
||||
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
|
||||
data-is-error-page={isErrorPage}
|
||||
data-is-error-page={isErrorPage ? '' : undefined}
|
||||
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
|
||||
>
|
||||
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||
{
|
||||
deletionAnnouncement && (
|
||||
<AnnouncementBanner
|
||||
announcement={deletionAnnouncement}
|
||||
transition:name="deletion-warning-announcement"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<Header
|
||||
classNames={{
|
||||
nav: cn(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../components/OgImage'
|
||||
import TimeFormatted from '../components/TimeFormatted.astro'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
|
||||
import BaseLayout from './BaseLayout.astro'
|
||||
@@ -13,21 +14,19 @@ type Props = ComponentProps<typeof BaseLayout> &
|
||||
MarkdownLayoutProps<{
|
||||
children: AstroChildren
|
||||
title: string
|
||||
author: string
|
||||
pubDate: string
|
||||
updatedAt?: string
|
||||
description: string
|
||||
icon?: string
|
||||
}>
|
||||
|
||||
const { frontmatter, schemas, ...baseLayoutProps } = Astro.props
|
||||
const publishDate = frontmatter.pubDate ? new Date(frontmatter.pubDate) : null
|
||||
const publishDate = frontmatter.updatedAt ? new Date(frontmatter.updatedAt) : null
|
||||
const ogImageTemplateData = {
|
||||
template: 'generic',
|
||||
title: frontmatter.title,
|
||||
description: frontmatter.description,
|
||||
icon: frontmatter.icon,
|
||||
} satisfies OgImageAllTemplatesWithProps
|
||||
const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -44,14 +43,7 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
||||
datePublished: publishDate?.toISOString(),
|
||||
dateModified: publishDate?.toISOString(),
|
||||
image: makeOgImageUrl(ogImageTemplateData, Astro.url),
|
||||
author: frontmatter.author
|
||||
? weAreAuthor
|
||||
? KYCNOTME_SCHEMA_MINI
|
||||
: {
|
||||
'@type': 'Person',
|
||||
name: frontmatter.author,
|
||||
}
|
||||
: undefined,
|
||||
author: KYCNOTME_SCHEMA_MINI,
|
||||
publisher: KYCNOTME_SCHEMA_MINI,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
@@ -61,15 +53,26 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
||||
...(schemas ?? []),
|
||||
]}
|
||||
>
|
||||
<div class="bg-dots-fade absolute inset-x-0 top-0 -z-1 h-128 opacity-15"></div>
|
||||
<div
|
||||
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>{frontmatter.title}</h1>
|
||||
<p class="text-gray-500">
|
||||
{frontmatter.author && `by ${frontmatter.author}`}
|
||||
{frontmatter.pubDate && ` | ${new Date(frontmatter.pubDate).toLocaleDateString()}`}
|
||||
<h1 class="mb-0!">{frontmatter.title}</h1>
|
||||
<p class="mt-2! opacity-70">
|
||||
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.bg-dots-fade {
|
||||
background:
|
||||
radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space,
|
||||
linear-gradient(to bottom, #888, #fff);
|
||||
background-blend-mode: multiply;
|
||||
mix-blend-mode: multiply;
|
||||
filter: contrast(21);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { differenceInMonths, differenceInYears } from 'date-fns'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { getKycLevelClarificationInfo } from '../constants/kycLevelClarifications'
|
||||
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
|
||||
import { formatDateShort } from './timeAgo'
|
||||
import { formatDaysAgo } from './timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
@@ -35,8 +36,8 @@ type NonDbAttributeFull = NonDbAttribute & {
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
isRecentlyApproved: true
|
||||
approvedAt: true
|
||||
createdAt: true
|
||||
tosReviewAt: true
|
||||
tosReview: true
|
||||
@@ -45,6 +46,7 @@ type NonDbAttributeFull = NonDbAttribute & {
|
||||
acceptedCurrencies: true
|
||||
kycLevel: true
|
||||
kycLevelClarification: true
|
||||
operatingSince: true
|
||||
}
|
||||
}>
|
||||
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||
@@ -156,16 +158,25 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => {
|
||||
const clarification = getKycLevelClarificationInfo(service.kycLevelClarification)
|
||||
return {
|
||||
show: service.kycLevel === kycLevel.value,
|
||||
title: kycLevel.name + (clarification.value !== 'NONE' ? ` (${clarification.label})` : ''),
|
||||
description:
|
||||
kycLevel.description + (clarification.value !== 'NONE' ? ` ${clarification.description}` : ''),
|
||||
}
|
||||
},
|
||||
customize: (service) => ({
|
||||
show: service.kycLevel === kycLevel.value,
|
||||
}),
|
||||
})),
|
||||
...kycLevelClarifications
|
||||
.filter((clarification) => clarification.value !== 'NONE')
|
||||
.map<NonDbAttributeFull>((clarification) => ({
|
||||
slug: `kyc-clarification-${clarification.slug}`,
|
||||
title: `KYC ${clarification.label}`,
|
||||
type: clarification.attributeType,
|
||||
category: 'PRIVACY',
|
||||
description: clarification.description,
|
||||
privacyPoints: clarification.privacyPoints,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.kycLevelClarification === clarification.value,
|
||||
}),
|
||||
})),
|
||||
{
|
||||
slug: 'archived',
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
@@ -180,17 +191,17 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'recently-listed',
|
||||
title: 'Recently listed',
|
||||
slug: 'recently-approved',
|
||||
title: 'Recently approved',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: 'Listed on KYCnot.me less than 15 days ago. Proceed with caution.',
|
||||
description: 'Approved on KYCnot.me less than 15 days ago. Proceed with caution.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -5,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.isRecentlyListed,
|
||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
||||
show: service.isRecentlyApproved,
|
||||
description: `Approved on KYCnot.me less than 15 days ago${service.approvedAt ? ` (${formatDaysAgo(service.approvedAt)})` : ''}. Proceed with caution.`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -208,41 +219,22 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'has-onion-urls',
|
||||
title: 'Has Onion URLs',
|
||||
slug: 'has-onion-or-i2p-urls',
|
||||
title: 'Has Onion or I2P URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
|
||||
description: 'Onion (Tor) and I2P URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?onion=true',
|
||||
url: '/?networks=onion&networks=i2p',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.onionUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'has-i2p-urls',
|
||||
title: 'Has I2P URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'I2P URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?i2p=true',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.i2pUrls.length > 0,
|
||||
show: service.onionUrls.length > 0 || service.i2pUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -265,6 +257,55 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
show: service.acceptedCurrencies.includes('MONERO'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'new-service',
|
||||
title: 'New service',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description:
|
||||
'The service started operations less than a year ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -4,
|
||||
links: [],
|
||||
customize: (service) => {
|
||||
if (!service.operatingSince) return { show: false }
|
||||
|
||||
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
|
||||
if (yearsOperated >= 1) return { show: false }
|
||||
|
||||
const monthsOperated = differenceInMonths(new Date(), service.operatingSince)
|
||||
return {
|
||||
show: true,
|
||||
description: `The service started operations ${
|
||||
monthsOperated === 0
|
||||
? 'less than a month'
|
||||
: `${String(monthsOperated)} month${monthsOperated > 1 ? 's' : ''}`
|
||||
} ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).`,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'mature-service',
|
||||
title: 'Mature service',
|
||||
type: 'GOOD',
|
||||
category: 'TRUST',
|
||||
description:
|
||||
'This service has been operational for at least 2 years. While this indicates stability, it is not a future-proof guarantee.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: 5,
|
||||
links: [],
|
||||
customize: (service) => {
|
||||
if (!service.operatingSince) return { show: false }
|
||||
|
||||
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
|
||||
return {
|
||||
show: yearsOperated >= 2,
|
||||
description: `This service has been operational for **${String(
|
||||
yearsOperated
|
||||
)} years**. While this indicates stability, it is not a future-proof guarantee.`,
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function sortAttributes<
|
||||
|
||||
@@ -7,11 +7,15 @@ export function supportsBrowserNotifications() {
|
||||
}
|
||||
|
||||
export function isBrowserNotificationsEnabled() {
|
||||
return (
|
||||
supportsBrowserNotifications() &&
|
||||
Notification.permission === 'granted' &&
|
||||
typedLocalStorage.browserNotificationsEnabled.get()
|
||||
)
|
||||
const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
|
||||
if (!browserNotificationsEnabled) return false
|
||||
|
||||
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||
typedLocalStorage.browserNotificationsEnabled.set(false)
|
||||
return false
|
||||
}
|
||||
|
||||
return supportsBrowserNotifications() && Notification.permission === 'granted'
|
||||
}
|
||||
|
||||
export async function enableBrowserNotifications(): Promise<SafeResult> {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { actions } from 'astro:actions'
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
export type SafeResult =
|
||||
@@ -45,7 +44,6 @@ export async function subscribeToPushNotifications(vapidPublicKey: string): Prom
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
@@ -131,13 +129,7 @@ export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubsc
|
||||
const currentSubscription = await getCurrentSubscription()
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
|
||||
}
|
||||
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
@@ -183,5 +175,5 @@ export function parsePushSubscriptions(subscriptionsAsString: string | undefined
|
||||
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
|
||||
if (typeof subscription !== 'object' || subscription === null) return false
|
||||
const s = subscription as Record<string, unknown>
|
||||
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null)
|
||||
return typeof s.endpoint === 'string'
|
||||
}
|
||||
|
||||
7
web/src/lib/client/envVariables.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const DEPLOYMENT_MODE = import.meta.env.PROD
|
||||
? import.meta.env.MODE === 'development' ||
|
||||
import.meta.env.MODE === 'staging' ||
|
||||
import.meta.env.MODE === 'production'
|
||||
? import.meta.env.MODE
|
||||
: 'development'
|
||||
: 'development'
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { NotificationData, NotificationPayload } from './serverEventsTypes'
|
||||
import { DEPLOYMENT_MODE } from './envVariables'
|
||||
|
||||
import type { NotificationData, NotificationPayload } from '../serverEventsTypes'
|
||||
|
||||
export type CustomNotificationOptions = NotificationOptions & {
|
||||
actions?: { action: string; title: string; icon?: string }[]
|
||||
@@ -6,14 +8,24 @@ export type CustomNotificationOptions = NotificationOptions & {
|
||||
data: NotificationData
|
||||
}
|
||||
|
||||
export function makeNotificationOptions(
|
||||
export function makeBrowserNotificationTitle(title?: string | null) {
|
||||
const prefix = DEPLOYMENT_MODE === 'development' ? '[DEV] ' : DEPLOYMENT_MODE === 'staging' ? '[PRE] ' : ''
|
||||
return `${prefix}${title ?? 'New Notification'}`
|
||||
}
|
||||
|
||||
export function makeBrowserNotificationOptions(
|
||||
payload: NotificationPayload | null,
|
||||
options: { removeActions?: boolean } = {}
|
||||
) {
|
||||
const defaultOptions: CustomNotificationOptions = {
|
||||
body: 'You have a new notification',
|
||||
lang: 'en-US',
|
||||
icon: '/favicon.svg',
|
||||
icon:
|
||||
DEPLOYMENT_MODE === 'development'
|
||||
? '/favicon-dev.svg'
|
||||
: DEPLOYMENT_MODE === 'staging'
|
||||
? '/favicon-stage.svg'
|
||||
: '/favicon.svg',
|
||||
badge: '/notification-icon.svg',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { prisma } from './prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export const MAX_COMMENT_DEPTH = 12
|
||||
@@ -75,12 +77,13 @@ export type CommentWithRepliesPopulated = CommentWithReplies<{
|
||||
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
|
||||
export type CommentSortOption = z.infer<typeof commentSortSchema>
|
||||
|
||||
export function makeCommentsNestedQuery({
|
||||
export async function makeCommentsNestedQuery({
|
||||
depth = 0,
|
||||
user,
|
||||
showPending,
|
||||
serviceId,
|
||||
sort,
|
||||
highlightedCommentId,
|
||||
}: {
|
||||
depth?: number
|
||||
user: Prisma.UserGetPayload<{
|
||||
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
|
||||
showPending?: boolean
|
||||
serviceId: number
|
||||
sort: CommentSortOption
|
||||
highlightedCommentId?: number | null
|
||||
}) {
|
||||
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
|
||||
|
||||
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
|
||||
}
|
||||
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
|
||||
|
||||
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
|
||||
|
||||
const baseQuery = {
|
||||
...commentReplyQuery,
|
||||
orderBy: orderByClause,
|
||||
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
|
||||
: ({
|
||||
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||
} as const satisfies Prisma.CommentWhereInput),
|
||||
...(highlightedBranchIds.length > 0
|
||||
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
|
||||
: []),
|
||||
],
|
||||
parentId: null,
|
||||
serviceId,
|
||||
@@ -161,6 +170,47 @@ export function makeRepliesQuery<T extends Prisma.CommentFindManyArgs>(
|
||||
}
|
||||
}
|
||||
|
||||
async function findAllParentIds(commentId: number, depth: number) {
|
||||
const commentwithManyParents = await prisma.comment.findFirst({
|
||||
where: { id: commentId },
|
||||
select: makeParentQuerySelect(depth),
|
||||
})
|
||||
|
||||
return extractParentIds(commentwithManyParents, [commentId])
|
||||
}
|
||||
|
||||
type ParentQueryRecursive = {
|
||||
parent: {
|
||||
select: {
|
||||
id: true
|
||||
parent: false | { select: ParentQueryRecursive }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeParentQuerySelect(depth: number): ParentQueryRecursive {
|
||||
return {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
parent: depth <= 0 ? false : { select: makeParentQuerySelect(depth - 1) },
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.CommentSelect
|
||||
}
|
||||
|
||||
function extractParentIds(
|
||||
comment: Prisma.CommentGetPayload<{ select: ParentQueryRecursive }> | null,
|
||||
acc: number[] = []
|
||||
) {
|
||||
if (!comment?.parent?.id) return acc
|
||||
|
||||
return extractParentIds(comment.parent as Prisma.CommentGetPayload<{ select: ParentQueryRecursive }>, [
|
||||
...acc,
|
||||
comment.parent.id,
|
||||
])
|
||||
}
|
||||
|
||||
export function makeCommentUrl({
|
||||
serviceSlug,
|
||||
commentId,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
const schema = z.enum(['development', 'staging', 'production'])
|
||||
|
||||
export const DEPLOYMENT_MODE = schema.parse(import.meta.env.PROD ? import.meta.env.MODE : 'development')
|
||||
@@ -39,7 +39,6 @@ export async function getService(slug: string | undefined): Promise<
|
||||
const service =
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
slug,
|
||||
},
|
||||
@@ -47,7 +46,6 @@ export async function getService(slug: string | undefined): Promise<
|
||||
})) ??
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
previousSlugs: { has: slug },
|
||||
},
|
||||
@@ -175,7 +173,6 @@ export async function getEvents(): Promise<
|
||||
where: {
|
||||
visible: true,
|
||||
service: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'l
|
||||
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
|
||||
await redisImpersonationSessions.delete(sessionId)
|
||||
context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
|
||||
context.locals.user = context.locals.actualUser
|
||||
context.locals.user = context.locals.actualUser ?? context.locals.user
|
||||
context.locals.actualUser = null
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,18 @@ export function makeNotificationTitle(
|
||||
serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange]
|
||||
return `${serviceName} ${statusChange.notificationTitle}`
|
||||
}
|
||||
case 'ACCOUNT_DELETION_WARNING_30_DAYS': {
|
||||
return 'Account deletion warning - 30 days remaining'
|
||||
}
|
||||
case 'ACCOUNT_DELETION_WARNING_15_DAYS': {
|
||||
return 'Account deletion warning - 15 days remaining'
|
||||
}
|
||||
case 'ACCOUNT_DELETION_WARNING_5_DAYS': {
|
||||
return 'Account deletion warning - 5 days remaining'
|
||||
}
|
||||
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
|
||||
return 'Account deletion warning - 1 day remaining'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +263,12 @@ export function makeNotificationContent(
|
||||
if (!notification.aboutEvent) return null
|
||||
return notification.aboutEvent.title
|
||||
}
|
||||
case 'ACCOUNT_DELETION_WARNING_30_DAYS':
|
||||
case 'ACCOUNT_DELETION_WARNING_15_DAYS':
|
||||
case 'ACCOUNT_DELETION_WARNING_5_DAYS':
|
||||
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
|
||||
return 'Your account will be deleted due to inactivity. Log in and perform any activity (comment, vote, or create a suggestion) to prevent deletion.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +429,19 @@ export function makeNotificationActions(
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'ACCOUNT_DELETION_WARNING_30_DAYS':
|
||||
case 'ACCOUNT_DELETION_WARNING_15_DAYS':
|
||||
case 'ACCOUNT_DELETION_WARNING_5_DAYS':
|
||||
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
|
||||
return [
|
||||
{
|
||||
action: 'login',
|
||||
title: 'Login & Stay Active',
|
||||
...iconNameAndUrl('ri:login-box-line'),
|
||||
url: `${origin}/login`,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ const knownPlurals = {
|
||||
singular: 'Request',
|
||||
plural: 'Requests',
|
||||
},
|
||||
day: {
|
||||
singular: 'Day',
|
||||
plural: 'Days',
|
||||
},
|
||||
something: {
|
||||
singular: 'Something',
|
||||
plural: 'Somethings',
|
||||
|
||||
@@ -45,7 +45,6 @@ function prismaClientSingleton() {
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prisma: ReturnType<typeof prismaClientSingleton> | undefined
|
||||
}
|
||||
|
||||
|
||||
187
web/src/lib/searchFiltersOptions.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { orderBy, groupBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { networks } from '../constants/networks'
|
||||
import { verificationStatuses } from '../constants/verificationStatus'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const MIN_CATEGORIES_TO_SHOW = 8
|
||||
const MIN_ATTRIBUTES_TO_SHOW = 8
|
||||
|
||||
export const sortOptions = [
|
||||
{
|
||||
value: 'score-desc',
|
||||
label: 'Score (High → Low)',
|
||||
orderBy: {
|
||||
key: 'overallScore',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'score-asc',
|
||||
label: 'Score (Low → High)',
|
||||
orderBy: {
|
||||
key: 'overallScore',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'name-asc',
|
||||
label: 'Name (A → Z)',
|
||||
orderBy: {
|
||||
key: 'name',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'name-desc',
|
||||
label: 'Name (Z → A)',
|
||||
orderBy: {
|
||||
key: 'name',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'recent',
|
||||
label: 'Date listed (New → Old)',
|
||||
orderBy: {
|
||||
key: 'listedAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'oldest',
|
||||
label: 'Date listed (Old → New)',
|
||||
orderBy: {
|
||||
key: 'listedAt',
|
||||
direction: 'asc',
|
||||
},
|
||||
},
|
||||
] as const satisfies {
|
||||
value: string
|
||||
label: string
|
||||
orderBy: {
|
||||
key: keyof Prisma.ServiceSelect
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
}[]
|
||||
|
||||
export const defaultSortOption = sortOptions[0]
|
||||
|
||||
export const modeOptions = [
|
||||
{
|
||||
value: 'or',
|
||||
label: 'OR',
|
||||
},
|
||||
{
|
||||
value: 'and',
|
||||
label: 'AND',
|
||||
},
|
||||
] as const satisfies {
|
||||
value: string
|
||||
label: string
|
||||
}[]
|
||||
|
||||
export function makeSearchFiltersOptions({
|
||||
filters,
|
||||
categories,
|
||||
attributes,
|
||||
}: {
|
||||
filters: {
|
||||
categories: string[]
|
||||
attr: Record<number, '' | 'no' | 'yes'> | undefined
|
||||
} | null
|
||||
categories: Prisma.CategoryGetPayload<{
|
||||
select: {
|
||||
name: true
|
||||
namePluralLong: true
|
||||
slug: true
|
||||
icon: true
|
||||
_count: {
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}>[]
|
||||
attributes: Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
slug: true
|
||||
title: true
|
||||
category: true
|
||||
type: true
|
||||
_count: {
|
||||
select: {
|
||||
services: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}>[]
|
||||
}) {
|
||||
const attributesByCategory = orderBy(
|
||||
Object.entries(
|
||||
groupBy(
|
||||
attributes.map((attr) => {
|
||||
return {
|
||||
typeInfo: getAttributeTypeInfo(attr.type),
|
||||
...attr,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
value: filters?.attr?.[attr.id] || undefined,
|
||||
}
|
||||
}),
|
||||
'category'
|
||||
)
|
||||
).map(([category, attributes]) => ({
|
||||
category,
|
||||
categoryInfo: getAttributeCategoryInfo(category),
|
||||
attributes: orderBy(
|
||||
attributes,
|
||||
['value', 'type', '_count.services', 'title'],
|
||||
['asc', 'asc', 'desc', 'asc']
|
||||
).map((attr, i) => ({
|
||||
...attr,
|
||||
showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined,
|
||||
})),
|
||||
})),
|
||||
['category'],
|
||||
['asc']
|
||||
)
|
||||
|
||||
const categoriesSorted = orderBy(
|
||||
categories.map((category) => {
|
||||
const checked = filters?.categories.includes(category.slug) ?? false
|
||||
|
||||
return {
|
||||
...category,
|
||||
checked,
|
||||
}
|
||||
}),
|
||||
['checked', '_count.services', 'name'],
|
||||
['desc', 'desc', 'asc']
|
||||
).map((category, i) => ({
|
||||
...category,
|
||||
showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked,
|
||||
}))
|
||||
|
||||
return {
|
||||
currencies,
|
||||
categories: categoriesSorted,
|
||||
sort: sortOptions,
|
||||
modeOptions,
|
||||
network: networks,
|
||||
verification: verificationStatuses,
|
||||
attributesByCategory,
|
||||
kycLevels,
|
||||
} as const
|
||||
}
|
||||
|
||||
export type ServicesFiltersOptions = ReturnType<typeof makeSearchFiltersOptions>
|
||||
@@ -20,7 +20,7 @@ export const areSameNormalized = (str1: string, str2: string): boolean => {
|
||||
return normalize(str1) === normalize(str2)
|
||||
}
|
||||
|
||||
export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'upper'
|
||||
export type TransformCaseType = 'first-upper' | 'lower' | 'original' | 'sentence' | 'title' | 'upper'
|
||||
|
||||
/**
|
||||
* Transform a string to a different case.
|
||||
@@ -31,6 +31,7 @@ export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'u
|
||||
* transformCase('hello WORLD', 'sentence') // 'Hello world'
|
||||
* transformCase('hello WORLD', 'title') // 'Hello World'
|
||||
* transformCase('hello WORLD', 'original') // 'hello WORLD'
|
||||
* transformCase('Hello WORLD', 'first-upper') // 'Hello WORLD'
|
||||
*/
|
||||
export const transformCase = <T extends string, C extends TransformCaseType>(
|
||||
str: T,
|
||||
@@ -43,7 +44,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
|
||||
? Capitalize<Lowercase<T>>
|
||||
: C extends 'title'
|
||||
? Capitalize<Lowercase<T>>
|
||||
: T => {
|
||||
: C extends 'first-upper'
|
||||
? Capitalize<T>
|
||||
: T => {
|
||||
switch (caseType) {
|
||||
case 'lower':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
@@ -54,6 +57,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
|
||||
case 'sentence':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return (str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) as any
|
||||
case 'first-upper':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return (str.charAt(0).toUpperCase() + str.slice(1)) as any
|
||||
case 'title':
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return str
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { addDays, format, isBefore, isToday, isYesterday } from 'date-fns'
|
||||
import { addDays, differenceInDays, format, isBefore, isToday, isYesterday } from 'date-fns'
|
||||
import TimeAgo from 'javascript-time-ago'
|
||||
import en from 'javascript-time-ago/locale/en'
|
||||
|
||||
@@ -47,3 +47,10 @@ export function formatDateShort(
|
||||
|
||||
return transformCase(text, caseType)
|
||||
}
|
||||
|
||||
export function formatDaysAgo(approvedAt: Date) {
|
||||
const days = differenceInDays(new Date(), approvedAt)
|
||||
if (days === 0) return 'today'
|
||||
if (days === 1) return 'yesterday'
|
||||
return `${days.toLocaleString()} days ago`
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function removeUserSessionIdCookie(cookies: AstroCookies) {
|
||||
cookies.delete(COOKIE_NAME, { path: '/' })
|
||||
}
|
||||
|
||||
export async function logout(context: Pick<APIContext, 'cookies' | 'locals'>) {
|
||||
export async function logout(context: Pick<APIContext, 'cookies' | 'locals' | 'request' | 'url'>) {
|
||||
await stopImpersonating(context)
|
||||
|
||||
await removeUserSessionIdCookie(context.cookies)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../constants/characters'
|
||||
|
||||
import { getRandom, typedJoin } from './arrays'
|
||||
import { DEPLOYMENT_MODE } from './envVariables'
|
||||
import { DEPLOYMENT_MODE } from './client/envVariables'
|
||||
import { transformCase } from './strings'
|
||||
|
||||
const DIGEST = 'sha512'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LOGS_UI_URL } from 'astro:env/server'
|
||||
|
||||
import { SUPPORT_EMAIL } from '../constants/project'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
|
||||
type Props = {
|
||||
|
||||