Compare commits
27 Commits
release-79
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9285d952a5 | ||
|
|
fd5c7ab475 | ||
|
|
9a78a9b377 | ||
|
|
9e0193fc3c | ||
|
|
a68523fc73 | ||
|
|
a465849a76 | ||
|
|
25f6dba3eb | ||
|
|
7e7046e7d2 | ||
|
|
a5d1fb9a5d | ||
|
|
28b84a7d9b | ||
|
|
7a294cb0a1 | ||
|
|
349c26a4df | ||
|
|
86b1afb2c7 | ||
|
|
99bc1f4e0f | ||
|
|
3166349dfb | ||
|
|
5a54352d95 | ||
|
|
a545726abf | ||
|
|
01488b8b3b | ||
|
|
b456af9448 | ||
|
|
b7ae6dc22c | ||
|
|
e4a5fa8fa7 | ||
|
|
6ed07c8386 | ||
|
|
6a9f5f5e99 | ||
|
|
e6edee2dbe | ||
|
|
c7ee1606e4 | ||
|
|
f3c9b92ddb | ||
|
|
effb6689d7 |
13
.env.example
13
.env.example
@@ -39,8 +39,11 @@ OPENAI_BASE_URL="https://your-openai-base-url.example.com"
|
|||||||
OPENAI_MODEL=your_openai_model
|
OPENAI_MODEL=your_openai_model
|
||||||
OPENAI_RETRY=3
|
OPENAI_RETRY=3
|
||||||
|
|
||||||
# Pyworker Crons
|
# Task schedules ---------------------------------------------------
|
||||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
|
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
|
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
|
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day
|
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
|
||||||
|
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
|
||||||
|
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
|
||||||
|
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily
|
||||||
|
|||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -7,7 +7,8 @@
|
|||||||
"golang.go",
|
"golang.go",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss",
|
||||||
"craigrbroughton.htmx-attributes",
|
"craigrbroughton.htmx-attributes",
|
||||||
"nefrob.vscode-just-syntax"
|
"nefrob.vscode-just-syntax",
|
||||||
|
"prisma.prisma"
|
||||||
],
|
],
|
||||||
"unwantedRecommendations": []
|
"unwantedRecommendations": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ OPENAI_BASE_URL="https://xxxxxx/api/v1"
|
|||||||
OPENAI_MODEL="xxxxxxxxx"
|
OPENAI_MODEL="xxxxxxxxx"
|
||||||
OPENAI_RETRY=3
|
OPENAI_RETRY=3
|
||||||
|
|
||||||
CRON_TOSREVIEW_TASK=0 0 1 * * # Every month
|
# Task schedules ---------------------------------------------------
|
||||||
CRON_USER_SENTIMENT_TASK=0 0 * * * # Every day
|
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||||
CRON_COMMENT_MODERATION_TASK=0 0 * * * # Every hour
|
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||||
CRON_FORCE_TRIGGERS_TASK=0 2 * * * # Every day
|
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||||
CRON_SERVICE_SCORE_RECALC_TASK=*/5 * * * * # Every 10 minutes
|
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
|
||||||
|
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
|
||||||
|
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
|
||||||
|
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily
|
||||||
@@ -38,6 +38,7 @@ Required environment variables:
|
|||||||
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
||||||
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
|
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
|
||||||
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
|
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
|
||||||
|
- `CRON_INACTIVE_USERS_TASK`: Cron expression for inactive users cleanup task
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ uv run -m pyworker force-triggers
|
|||||||
|
|
||||||
# Run service score recalculation task
|
# Run service score recalculation task
|
||||||
uv run -m pyworker service-score-recalc [--service-id ID]
|
uv run -m pyworker service-score-recalc [--service-id ID]
|
||||||
|
|
||||||
|
# Run inactive users cleanup task
|
||||||
|
uv run -m pyworker inactive-users
|
||||||
```
|
```
|
||||||
|
|
||||||
### Worker Mode
|
### Worker Mode
|
||||||
@@ -96,7 +100,7 @@ Tasks will run according to their configured cron schedules.
|
|||||||
### Force Triggers Task
|
### Force Triggers Task
|
||||||
|
|
||||||
- Maintains database triggers by forcing them to run under certain conditions
|
- 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`
|
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
|
||||||
|
|
||||||
### Service Score Recalculation Task
|
### Service Score Recalculation Task
|
||||||
@@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules.
|
|||||||
- Calculates privacy, trust, and overall scores
|
- Calculates privacy, trust, and overall scores
|
||||||
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
|
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
|
||||||
|
|
||||||
|
### Inactive Users Task
|
||||||
|
|
||||||
|
- Handles cleanup of inactive user accounts
|
||||||
|
- Identifies users who have been inactive for 1 year (no comments, votes, suggestions, and 0 karma)
|
||||||
|
- Sends deletion warning notifications at 30, 15, 5, and 1 day intervals
|
||||||
|
- Deletes accounts that remain inactive after the warning period
|
||||||
|
- Cancels deletion for users who become active again
|
||||||
|
- Scheduled via `CRON_INACTIVE_USERS_TASK`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
@@ -124,6 +137,7 @@ pyworker/
|
|||||||
│ │ ├── base.py
|
│ │ ├── base.py
|
||||||
│ │ ├── comment_moderation.py
|
│ │ ├── comment_moderation.py
|
||||||
│ │ ├── force_triggers.py
|
│ │ ├── force_triggers.py
|
||||||
|
│ │ ├── inactive_users.py
|
||||||
│ │ ├── service_score_recalc.py
|
│ │ ├── service_score_recalc.py
|
||||||
│ │ ├── tos_review.py
|
│ │ ├── tos_review.py
|
||||||
│ │ └── user_sentiment.py
|
│ │ └── user_sentiment.py
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from pyworker.scheduler import TaskScheduler
|
|||||||
from .tasks import (
|
from .tasks import (
|
||||||
CommentModerationTask,
|
CommentModerationTask,
|
||||||
ForceTriggersTask,
|
ForceTriggersTask,
|
||||||
|
InactiveUsersTask,
|
||||||
ServiceScoreRecalculationTask,
|
ServiceScoreRecalculationTask,
|
||||||
TosReviewTask,
|
TosReviewTask,
|
||||||
UserSentimentTask,
|
UserSentimentTask,
|
||||||
@@ -89,6 +90,23 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
|||||||
score_recalc_parser.add_argument(
|
score_recalc_parser.add_argument(
|
||||||
"--service-id", type=int, help="Specific service ID to process (optional)"
|
"--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)
|
return parser.parse_args(args)
|
||||||
|
|
||||||
@@ -295,12 +313,15 @@ def run_force_triggers_task() -> int:
|
|||||||
close_db_pool()
|
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.
|
Run the service score recalculation task.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service_id: Optional specific service ID to process.
|
service_id: Optional specific service ID to process.
|
||||||
|
all_services: Whether to recalculate scores for all services.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Exit code.
|
Exit code.
|
||||||
@@ -310,7 +331,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
|||||||
try:
|
try:
|
||||||
# Initialize task and use as context manager
|
# Initialize task and use as context manager
|
||||||
with ServiceScoreRecalculationTask() as task: # type: ignore
|
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:
|
if result:
|
||||||
logger.info("Successfully recalculated service scores")
|
logger.info("Successfully recalculated service scores")
|
||||||
else:
|
else:
|
||||||
@@ -323,6 +371,37 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
|||||||
close_db_pool()
|
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:
|
def run_worker_mode() -> int:
|
||||||
"""
|
"""
|
||||||
Run in worker mode, scheduling tasks to run periodically.
|
Run in worker mode, scheduling tasks to run periodically.
|
||||||
@@ -334,42 +413,37 @@ def run_worker_mode() -> int:
|
|||||||
|
|
||||||
# Get task schedules from config
|
# Get task schedules from config
|
||||||
task_schedules = config.task_schedules
|
task_schedules = config.task_schedules
|
||||||
if not task_schedules:
|
logger.info(
|
||||||
|
"Found %s cron schedule%s from environment variables: %s",
|
||||||
|
len(task_schedules),
|
||||||
|
"s" if len(task_schedules) != 1 else "",
|
||||||
|
", ".join(task_schedules.keys()) if task_schedules else "<none>",
|
||||||
|
)
|
||||||
|
|
||||||
|
required_tasks: dict[str, Any] = {
|
||||||
|
"tosreview": run_tos_task,
|
||||||
|
"user_sentiment": run_sentiment_task,
|
||||||
|
"comment_moderation": run_moderation_task,
|
||||||
|
"force_triggers": run_force_triggers_task,
|
||||||
|
"inactive_users": run_inactive_users_task,
|
||||||
|
"service_score_recalc": run_service_score_recalc_task,
|
||||||
|
"service_score_recalc_all": run_service_score_recalc_all_task,
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_tasks = [t for t in required_tasks if t not in task_schedules]
|
||||||
|
if missing_tasks:
|
||||||
logger.error(
|
logger.error(
|
||||||
"No task schedules defined. Set CRON_TASKNAME_TASK environment variables."
|
"Missing cron schedule for task%s: %s. Set the corresponding CRON_<TASKNAME>_TASK environment variable%s.",
|
||||||
|
"s" if len(missing_tasks) != 1 else "",
|
||||||
|
", ".join(missing_tasks),
|
||||||
|
"s" if len(missing_tasks) != 1 else "",
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Found {len(task_schedules)} scheduled tasks: {', '.join(task_schedules.keys())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize the scheduler
|
|
||||||
scheduler = TaskScheduler()
|
scheduler = TaskScheduler()
|
||||||
|
|
||||||
# Register tasks with their schedules
|
for task_name, task_callable in required_tasks.items():
|
||||||
for task_name, cron_expression in task_schedules.items():
|
scheduler.register_task(task_name, task_schedules[task_name], task_callable)
|
||||||
if task_name.lower() == "tosreview":
|
|
||||||
scheduler.register_task(task_name, cron_expression, run_tos_task)
|
|
||||||
elif task_name.lower() == "user_sentiment":
|
|
||||||
scheduler.register_task(task_name, cron_expression, run_sentiment_task)
|
|
||||||
elif task_name.lower() == "comment_moderation":
|
|
||||||
scheduler.register_task(task_name, cron_expression, run_moderation_task)
|
|
||||||
elif task_name.lower() == "force_triggers":
|
|
||||||
scheduler.register_task(task_name, cron_expression, run_force_triggers_task)
|
|
||||||
elif task_name.lower() == "service_score_recalc":
|
|
||||||
scheduler.register_task(
|
|
||||||
task_name, cron_expression, run_service_score_recalc_task
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start the scheduler if tasks were registered
|
# Start the scheduler if tasks were registered
|
||||||
if scheduler.tasks:
|
if scheduler.tasks:
|
||||||
@@ -419,7 +493,13 @@ def main() -> int:
|
|||||||
elif args.task == "force-triggers":
|
elif args.task == "force-triggers":
|
||||||
return run_force_triggers_task()
|
return run_force_triggers_task()
|
||||||
elif args.task == "service-score-recalc":
|
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:
|
elif args.task:
|
||||||
logger.error(f"Unknown task: {args.task}")
|
logger.error(f"Unknown task: {args.task}")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pyworker.database import close_db_pool
|
|||||||
from .tasks import (
|
from .tasks import (
|
||||||
CommentModerationTask,
|
CommentModerationTask,
|
||||||
ForceTriggersTask,
|
ForceTriggersTask,
|
||||||
|
InactiveUsersTask,
|
||||||
ServiceScoreRecalculationTask,
|
ServiceScoreRecalculationTask,
|
||||||
TosReviewTask,
|
TosReviewTask,
|
||||||
UserSentimentTask,
|
UserSentimentTask,
|
||||||
@@ -62,25 +63,29 @@ class TaskScheduler:
|
|||||||
cron_expression: Cron expression defining the schedule.
|
cron_expression: Cron expression defining the schedule.
|
||||||
task_func: Function to execute.
|
task_func: Function to execute.
|
||||||
*args: Arguments to pass to the task function.
|
*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
|
# Declare task_instance variable with type annotation upfront
|
||||||
task_instance: Any = None
|
task_instance: Any = None
|
||||||
|
|
||||||
# Initialize the appropriate task class based on the task name
|
if instantiate:
|
||||||
if task_name.lower() == "tosreview":
|
# Initialize the appropriate task class based on the task name
|
||||||
task_instance = TosReviewTask()
|
if task_name.lower() == "tosreview":
|
||||||
elif task_name.lower() == "user_sentiment":
|
task_instance = TosReviewTask()
|
||||||
task_instance = UserSentimentTask()
|
elif task_name.lower() == "user_sentiment":
|
||||||
elif task_name.lower() == "comment_moderation":
|
task_instance = UserSentimentTask()
|
||||||
task_instance = CommentModerationTask()
|
elif task_name.lower() == "comment_moderation":
|
||||||
elif task_name.lower() == "force_triggers":
|
task_instance = CommentModerationTask()
|
||||||
task_instance = ForceTriggersTask()
|
elif task_name.lower() == "force_triggers":
|
||||||
elif task_name.lower() == "service_score_recalc":
|
task_instance = ForceTriggersTask()
|
||||||
task_instance = ServiceScoreRecalculationTask()
|
elif task_name.lower() == "service_score_recalc":
|
||||||
else:
|
task_instance = ServiceScoreRecalculationTask()
|
||||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
elif task_name.lower() == "inactive_users":
|
||||||
return
|
task_instance = InactiveUsersTask()
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||||
|
return
|
||||||
|
|
||||||
self.tasks[task_name] = {
|
self.tasks[task_name] = {
|
||||||
"cron": cron_expression,
|
"cron": cron_expression,
|
||||||
@@ -126,8 +131,12 @@ class TaskScheduler:
|
|||||||
self.logger.info(f"Running task '{task_name}'")
|
self.logger.info(f"Running task '{task_name}'")
|
||||||
# Use task instance as a context manager to ensure
|
# Use task instance as a context manager to ensure
|
||||||
# a single database connection is used for the entire task
|
# a single database connection is used for the entire task
|
||||||
with task_info["instance"]:
|
if task_info["instance"]:
|
||||||
# Execute the registered task function with its arguments
|
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"])
|
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||||
self.logger.info(f"Task '{task_name}' completed")
|
self.logger.info(f"Task '{task_name}' completed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from .base import Task
|
from .base import Task
|
||||||
from .comment_moderation import CommentModerationTask
|
from .comment_moderation import CommentModerationTask
|
||||||
from .force_triggers import ForceTriggersTask
|
from .force_triggers import ForceTriggersTask
|
||||||
|
from .inactive_users import InactiveUsersTask
|
||||||
from .service_score_recalc import ServiceScoreRecalculationTask
|
from .service_score_recalc import ServiceScoreRecalculationTask
|
||||||
from .tos_review import TosReviewTask
|
from .tos_review import TosReviewTask
|
||||||
from .user_sentiment import UserSentimentTask
|
from .user_sentiment import UserSentimentTask
|
||||||
@@ -11,6 +12,7 @@ __all__ = [
|
|||||||
"Task",
|
"Task",
|
||||||
"CommentModerationTask",
|
"CommentModerationTask",
|
||||||
"ForceTriggersTask",
|
"ForceTriggersTask",
|
||||||
|
"InactiveUsersTask",
|
||||||
"ServiceScoreRecalculationTask",
|
"ServiceScoreRecalculationTask",
|
||||||
"TosReviewTask",
|
"TosReviewTask",
|
||||||
"UserSentimentTask",
|
"UserSentimentTask",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
|
|||||||
Force triggers to run under certain conditions.
|
Force triggers to run under certain conditions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RECENT_LISTED_INTERVAL_DAYS = 15
|
RECENT_APPROVED_INTERVAL_DAYS = 15
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("force_triggers")
|
super().__init__("force_triggers")
|
||||||
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
|
|||||||
|
|
||||||
update_query = f"""
|
update_query = f"""
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW()
|
SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
|
||||||
WHERE "isRecentlyListed" = TRUE
|
WHERE "isRecentlyApproved" = TRUE
|
||||||
AND "listedAt" IS NOT NULL
|
AND "approvedAt" IS NOT NULL
|
||||||
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days'
|
AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with self.conn.cursor() as cursor:
|
with self.conn.cursor() as cursor:
|
||||||
|
|||||||
258
pyworker/pyworker/tasks/inactive_users.py
Normal file
258
pyworker/pyworker/tasks/inactive_users.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Task for handling inactive users - sending deletion warnings and cleaning up accounts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pyworker.database import execute_db_command, run_db_query
|
||||||
|
from pyworker.tasks.base import Task
|
||||||
|
|
||||||
|
|
||||||
|
class InactiveUsersTask(Task):
|
||||||
|
"""Task for handling inactive users"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the inactive users task."""
|
||||||
|
super().__init__("inactive_users")
|
||||||
|
|
||||||
|
def run(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Run the inactive users task.
|
||||||
|
|
||||||
|
This task:
|
||||||
|
1. Identifies users who have been inactive for 1 year
|
||||||
|
2. Schedules them for deletion
|
||||||
|
3. Sends warning notifications at 30, 15, 5, and 1 day intervals
|
||||||
|
4. Deletes accounts that have reached their deletion date
|
||||||
|
"""
|
||||||
|
results = {
|
||||||
|
"users_scheduled_for_deletion": 0,
|
||||||
|
"notifications_sent": 0,
|
||||||
|
"accounts_deleted": 0,
|
||||||
|
"deletions_cancelled": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 1: Cancel deletion for users who became active again
|
||||||
|
results["deletions_cancelled"] = self._cancel_deletion_for_active_users()
|
||||||
|
|
||||||
|
# Step 2: Schedule new inactive users for deletion
|
||||||
|
results["users_scheduled_for_deletion"] = self._schedule_inactive_users_for_deletion()
|
||||||
|
|
||||||
|
# Step 3: Send warning notifications
|
||||||
|
results["notifications_sent"] = self._send_deletion_warnings()
|
||||||
|
|
||||||
|
# Step 4: Delete accounts that have reached their deletion date
|
||||||
|
results["accounts_deleted"] = self._delete_scheduled_accounts()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Inactive users task completed. "
|
||||||
|
f"Deletions cancelled: {results['deletions_cancelled']}, "
|
||||||
|
f"Scheduled: {results['users_scheduled_for_deletion']}, "
|
||||||
|
f"Notifications sent: {results['notifications_sent']}, "
|
||||||
|
f"Accounts deleted: {results['accounts_deleted']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _schedule_inactive_users_for_deletion(self) -> int:
|
||||||
|
"""
|
||||||
|
Schedule inactive users for deletion.
|
||||||
|
|
||||||
|
A user is considered inactive if:
|
||||||
|
- Account was created more than 1 year ago
|
||||||
|
- Has 0 karma
|
||||||
|
- Has no comments, comment votes, or service suggestions
|
||||||
|
- Is not scheduled for deletion already
|
||||||
|
- Is not an admin or moderator
|
||||||
|
"""
|
||||||
|
one_year_ago = datetime.now() - timedelta(days=365)
|
||||||
|
deletion_date = date.today() + timedelta(days=30) # 30 days from today
|
||||||
|
|
||||||
|
# Find inactive users
|
||||||
|
query = """
|
||||||
|
UPDATE "User"
|
||||||
|
SET "scheduledDeletionAt" = %s, "updatedAt" = NOW()
|
||||||
|
WHERE "id" IN (
|
||||||
|
SELECT u."id"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."createdAt" < %s
|
||||||
|
AND u."scheduledDeletionAt" IS NULL
|
||||||
|
AND u."admin" = false
|
||||||
|
AND u."moderator" = false
|
||||||
|
AND u."totalKarma" = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Comment" c WHERE c."authorId" = u."id"
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "CommentVote" cv WHERE cv."userId" = u."id"
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "ServiceSuggestion" ss WHERE ss."userId" = u."id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
count = execute_db_command(query, (deletion_date, one_year_ago))
|
||||||
|
self.logger.info(f"Scheduled {count} inactive users for deletion on {deletion_date}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _send_deletion_warnings(self) -> int:
|
||||||
|
"""
|
||||||
|
Send deletion warning notifications to users at appropriate intervals.
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
notifications_sent = 0
|
||||||
|
|
||||||
|
# Define warning intervals and their corresponding notification types
|
||||||
|
warning_intervals = [
|
||||||
|
(30, 'ACCOUNT_DELETION_WARNING_30_DAYS'),
|
||||||
|
(15, 'ACCOUNT_DELETION_WARNING_15_DAYS'),
|
||||||
|
(5, 'ACCOUNT_DELETION_WARNING_5_DAYS'),
|
||||||
|
(1, 'ACCOUNT_DELETION_WARNING_1_DAY'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for days_before, notification_type in warning_intervals:
|
||||||
|
# Find users who should receive this warning (exact date match)
|
||||||
|
target_date = today + timedelta(days=days_before)
|
||||||
|
|
||||||
|
# Check if user is still inactive (no recent activity)
|
||||||
|
users_query = """
|
||||||
|
SELECT u."id", u."name", u."scheduledDeletionAt"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."scheduledDeletionAt" = %s
|
||||||
|
AND u."admin" = false
|
||||||
|
AND u."moderator" = false
|
||||||
|
AND u."totalKarma" = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Notification" n
|
||||||
|
WHERE n."userId" = u."id"
|
||||||
|
AND n."type" = %s
|
||||||
|
AND n."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
-- Still check if user is inactive (no activity since being scheduled)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Comment" c
|
||||||
|
WHERE c."authorId" = u."id"
|
||||||
|
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "CommentVote" cv
|
||||||
|
WHERE cv."userId" = u."id"
|
||||||
|
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "ServiceSuggestion" ss
|
||||||
|
WHERE ss."userId" = u."id"
|
||||||
|
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
users = run_db_query(users_query, (target_date, notification_type))
|
||||||
|
|
||||||
|
# Create notifications for these users
|
||||||
|
for user in users:
|
||||||
|
insert_notification_query = """
|
||||||
|
INSERT INTO "Notification" ("userId", "type", "createdAt", "updatedAt")
|
||||||
|
VALUES (%s, %s, NOW(), NOW())
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute_db_command(insert_notification_query, (user['id'], notification_type))
|
||||||
|
notifications_sent += 1
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Sent {notification_type} notification to user {user['name']} "
|
||||||
|
f"(ID: {user['id']}) scheduled for deletion on {user['scheduledDeletionAt']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return notifications_sent
|
||||||
|
|
||||||
|
def _delete_scheduled_accounts(self) -> int:
|
||||||
|
"""
|
||||||
|
Delete accounts that have reached their scheduled deletion date and are still inactive.
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# Find users scheduled for deletion who are still inactive
|
||||||
|
users_to_delete_query = """
|
||||||
|
SELECT u."id", u."name", u."scheduledDeletionAt"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."scheduledDeletionAt" <= %s
|
||||||
|
AND u."admin" = false
|
||||||
|
AND u."moderator" = false
|
||||||
|
AND u."totalKarma" = 0
|
||||||
|
-- Double-check they're still inactive
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Comment" c
|
||||||
|
WHERE c."authorId" = u."id"
|
||||||
|
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "CommentVote" cv
|
||||||
|
WHERE cv."userId" = u."id"
|
||||||
|
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "ServiceSuggestion" ss
|
||||||
|
WHERE ss."userId" = u."id"
|
||||||
|
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
users_to_delete = run_db_query(users_to_delete_query, (today,))
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
for user in users_to_delete:
|
||||||
|
try:
|
||||||
|
# Delete the user (this will cascade and delete related records)
|
||||||
|
delete_query = 'DELETE FROM "User" WHERE "id" = %s'
|
||||||
|
execute_db_command(delete_query, (user['id'],))
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Deleted inactive user {user['name']} (ID: {user['id']}) "
|
||||||
|
f"scheduled for deletion on {user['scheduledDeletionAt']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to delete user {user['name']} (ID: {user['id']}): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
def _cancel_deletion_for_active_users(self) -> int:
|
||||||
|
"""
|
||||||
|
Cancel scheduled deletion for users who have become active again.
|
||||||
|
"""
|
||||||
|
# Find users scheduled for deletion who have recent activity or gained karma
|
||||||
|
query = """
|
||||||
|
UPDATE "User"
|
||||||
|
SET "scheduledDeletionAt" = NULL, "updatedAt" = NOW()
|
||||||
|
WHERE "scheduledDeletionAt" IS NOT NULL
|
||||||
|
AND (
|
||||||
|
"totalKarma" > 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM "Comment" c
|
||||||
|
WHERE c."authorId" = "User"."id"
|
||||||
|
AND c."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM "CommentVote" cv
|
||||||
|
WHERE cv."userId" = "User"."id"
|
||||||
|
AND cv."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM "ServiceSuggestion" ss
|
||||||
|
WHERE ss."userId" = "User"."id"
|
||||||
|
AND ss."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
count = execute_db_command(query)
|
||||||
|
if count > 0:
|
||||||
|
self.logger.info(f"Cancelled deletion for {count} users who became active again or gained karma")
|
||||||
|
|
||||||
|
return count
|
||||||
@@ -206,7 +206,6 @@ class ServiceScoreRecalculationTask(Task):
|
|||||||
"""
|
"""
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE "isActive" = TRUE
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
services = cursor.fetchall()
|
services = cursor.fetchall()
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ class TosReviewTask(Task):
|
|||||||
service_name = service["name"]
|
service_name = service["name"]
|
||||||
verification_status = service.get("verificationStatus")
|
verification_status = service.get("verificationStatus")
|
||||||
|
|
||||||
# Only process verified or approved services
|
# Only process verified, approved, or community contributed services
|
||||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]:
|
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
23
|
24
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { postgresListener } from './src/lib/postgresListenerIntegration'
|
|||||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||||
|
|
||||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||||
|
const ONION_ADDRESS = getServerEnvVariable('ONION_ADDRESS')
|
||||||
|
const I2P_ADDRESS = getServerEnvVariable('I2P_ADDRESS')
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: SITE_URL,
|
site: SITE_URL,
|
||||||
@@ -95,6 +97,19 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
open: false,
|
open: false,
|
||||||
allowedHosts: [new URL(SITE_URL).hostname],
|
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: {
|
image: {
|
||||||
domains: [new URL(SITE_URL).hostname],
|
domains: [new URL(SITE_URL).hostname],
|
||||||
|
|||||||
1266
web/package-lock.json
generated
1266
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,83 +27,86 @@
|
|||||||
"@astrojs/check": "0.9.4",
|
"@astrojs/check": "0.9.4",
|
||||||
"@astrojs/db": "0.15.0",
|
"@astrojs/db": "0.15.0",
|
||||||
"@astrojs/mdx": "4.3.0",
|
"@astrojs/mdx": "4.3.0",
|
||||||
"@astrojs/node": "9.2.2",
|
"@astrojs/node": "9.3.0",
|
||||||
"@astrojs/rss": "4.0.12",
|
"@astrojs/rss": "4.0.12",
|
||||||
"@astrojs/sitemap": "3.4.1",
|
"@astrojs/sitemap": "3.4.1",
|
||||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||||
"@fontsource/inter": "5.2.5",
|
"@fontsource/inter": "5.2.6",
|
||||||
"@fontsource/space-grotesk": "5.2.8",
|
"@fontsource/space-grotesk": "5.2.8",
|
||||||
"@prisma/client": "6.9.0",
|
"@prisma/client": "6.11.1",
|
||||||
"@tailwindcss/vite": "4.1.8",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@types/mime-types": "3.0.0",
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/pg": "8.15.4",
|
"@types/pg": "8.15.4",
|
||||||
"@vercel/og": "0.6.8",
|
"@vercel/og": "0.7.2",
|
||||||
"astro": "5.9.0",
|
"astro": "5.9.0",
|
||||||
"astro-loading-indicator": "0.7.0",
|
"astro-loading-indicator": "0.7.0",
|
||||||
"astro-remote": "0.3.4",
|
"astro-remote": "0.3.4",
|
||||||
"astro-seo-schema": "5.0.0",
|
"astro-seo-schema": "5.0.0",
|
||||||
"canvas": "3.1.0",
|
"canvas": "3.1.2",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"htmx.org": "1.9.12",
|
"countries-list": "3.1.1",
|
||||||
|
"country-flag-icons": "1.5.19",
|
||||||
|
"he": "1.2.0",
|
||||||
|
"htmx.org": "2.0.6",
|
||||||
"javascript-time-ago": "2.5.11",
|
"javascript-time-ago": "2.5.11",
|
||||||
"libphonenumber-js": "1.12.9",
|
"libphonenumber-js": "1.12.9",
|
||||||
"lodash-es": "4.17.21",
|
"lodash-es": "4.17.21",
|
||||||
"mime-types": "3.0.1",
|
"mime-types": "3.0.1",
|
||||||
"object-to-formdata": "4.5.1",
|
"object-to-formdata": "4.5.1",
|
||||||
"pg": "8.16.0",
|
"pg": "8.16.3",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"redis": "5.5.6",
|
"redis": "5.6.0",
|
||||||
"schema-dts": "1.1.5",
|
"schema-dts": "1.1.5",
|
||||||
"seedrandom": "3.0.5",
|
"seedrandom": "3.0.5",
|
||||||
"sharp": "0.34.2",
|
"sharp": "0.34.2",
|
||||||
"slugify": "1.6.6",
|
"slugify": "1.6.6",
|
||||||
"tailwind-merge": "3.3.0",
|
"tailwind-merge": "3.3.1",
|
||||||
"tailwind-variants": "1.0.0",
|
"tailwind-variants": "1.0.0",
|
||||||
"tailwindcss": "4.1.8",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"unique-username-generator": "1.4.0",
|
"unique-username-generator": "1.4.0",
|
||||||
"web-push": "3.6.7",
|
"web-push": "3.6.7"
|
||||||
"zod-form-data": "2.0.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.28.0",
|
"@eslint/js": "9.30.1",
|
||||||
"@faker-js/faker": "9.8.0",
|
"@faker-js/faker": "9.9.0",
|
||||||
"@iconify-json/material-symbols": "1.2.24",
|
"@iconify-json/material-symbols": "1.2.29",
|
||||||
"@iconify-json/mdi": "1.2.3",
|
"@iconify-json/mdi": "1.2.3",
|
||||||
"@iconify-json/ri": "1.2.5",
|
"@iconify-json/ri": "1.2.5",
|
||||||
"@stylistic/eslint-plugin": "4.4.1",
|
"@stylistic/eslint-plugin": "5.1.0",
|
||||||
"@tailwindcss/forms": "0.5.10",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.16",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@types/eslint__js": "9.14.0",
|
"@types/eslint__js": "9.14.0",
|
||||||
|
"@types/he": "1.2.3",
|
||||||
"@types/lodash-es": "4.17.12",
|
"@types/lodash-es": "4.17.12",
|
||||||
"@types/qrcode": "1.5.5",
|
"@types/qrcode": "1.5.5",
|
||||||
"@types/react": "19.1.6",
|
"@types/react": "19.1.8",
|
||||||
"@types/seedrandom": "3.0.8",
|
"@types/seedrandom": "3.0.8",
|
||||||
"@types/web-push": "3.6.4",
|
"@types/web-push": "3.6.4",
|
||||||
"@typescript-eslint/parser": "8.33.1",
|
"@typescript-eslint/parser": "8.36.0",
|
||||||
"@vite-pwa/assets-generator": "1.0.0",
|
"@vite-pwa/assets-generator": "1.0.0",
|
||||||
"@vite-pwa/astro": "1.1.0",
|
"@vite-pwa/astro": "1.1.0",
|
||||||
"astro-icon": "1.1.5",
|
"astro-icon": "1.1.5",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"esbuild": "0.25.5",
|
"esbuild": "0.25.6",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.30.1",
|
||||||
"eslint-import-resolver-typescript": "4.4.3",
|
"eslint-import-resolver-typescript": "4.4.4",
|
||||||
"eslint-plugin-astro": "1.3.1",
|
"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",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"globals": "16.2.0",
|
"globals": "16.3.0",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.6.2",
|
||||||
"prettier-plugin-astro": "0.14.1",
|
"prettier-plugin-astro": "0.14.1",
|
||||||
"prettier-plugin-tailwindcss": "0.6.12",
|
"prettier-plugin-tailwindcss": "0.6.13",
|
||||||
"prisma": "6.9.0",
|
"prisma": "6.11.1",
|
||||||
"prisma-json-types-generator": "3.4.2",
|
"prisma-json-types-generator": "3.5.1",
|
||||||
"tailwind-htmx": "0.1.2",
|
"tailwind-htmx": "0.1.2",
|
||||||
"ts-essentials": "10.0.4",
|
"ts-essentials": "10.1.1",
|
||||||
"ts-toolbelt": "9.6.0",
|
"ts-toolbelt": "9.6.0",
|
||||||
"tsx": "4.19.4",
|
"tsx": "4.20.3",
|
||||||
"typescript-eslint": "8.33.1",
|
"typescript-eslint": "8.36.0",
|
||||||
"vite-plugin-devtools-json": "0.1.1",
|
"vite-plugin-devtools-json": "0.2.1",
|
||||||
"workbox-core": "7.3.0",
|
"workbox-core": "7.3.0",
|
||||||
"workbox-precaching": "7.3.0"
|
"workbox-precaching": "7.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "registrationCountryCode" VARCHAR(2);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "registeredCompanyName" TEXT;
|
||||||
@@ -144,6 +144,10 @@ enum NotificationType {
|
|||||||
ACCOUNT_STATUS_CHANGE
|
ACCOUNT_STATUS_CHANGE
|
||||||
EVENT_CREATED
|
EVENT_CREATED
|
||||||
SERVICE_VERIFICATION_STATUS_CHANGE
|
SERVICE_VERIFICATION_STATUS_CHANGE
|
||||||
|
ACCOUNT_DELETION_WARNING_30_DAYS
|
||||||
|
ACCOUNT_DELETION_WARNING_15_DAYS
|
||||||
|
ACCOUNT_DELETION_WARNING_5_DAYS
|
||||||
|
ACCOUNT_DELETION_WARNING_1_DAY
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CommentStatusChange {
|
enum CommentStatusChange {
|
||||||
@@ -341,53 +345,64 @@ model ServiceSuggestionMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Service {
|
model Service {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
previousSlugs String[] @default([])
|
previousSlugs String[] @default([])
|
||||||
description String
|
description String
|
||||||
categories Category[] @relation("ServiceToCategory")
|
categories Category[] @relation("ServiceToCategory")
|
||||||
kycLevel Int @default(4)
|
kycLevel Int @default(4)
|
||||||
kycLevelClarification KycLevelClarification @default(NONE)
|
kycLevelClarification KycLevelClarification @default(NONE)
|
||||||
overallScore Int @default(0)
|
/// Date only, no time.
|
||||||
privacyScore Int @default(0)
|
operatingSince DateTime? @db.Date
|
||||||
trustScore Int @default(0)
|
overallScore Int @default(0)
|
||||||
|
privacyScore Int @default(0)
|
||||||
|
trustScore Int @default(0)
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
isRecentlyListed Boolean @default(false)
|
averageUserRating Float?
|
||||||
/// Computed via trigger. Do not update through prisma.
|
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||||
averageUserRating Float?
|
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
serviceInfoBannerNotes String?
|
||||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
||||||
serviceInfoBannerNotes String?
|
verificationSummary String?
|
||||||
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
verificationRequests ServiceVerificationRequest[]
|
||||||
verificationSummary String?
|
verificationProofMd String?
|
||||||
verificationRequests ServiceVerificationRequest[]
|
|
||||||
verificationProofMd String?
|
|
||||||
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
|
|
||||||
verifiedAt DateTime?
|
|
||||||
/// [UserSentiment]
|
/// [UserSentiment]
|
||||||
userSentiment Json?
|
userSentiment Json?
|
||||||
userSentimentAt DateTime?
|
userSentimentAt DateTime?
|
||||||
referral String?
|
referral String?
|
||||||
acceptedCurrencies Currency[] @default([])
|
acceptedCurrencies Currency[] @default([])
|
||||||
serviceUrls String[]
|
serviceUrls String[]
|
||||||
tosUrls String[] @default([])
|
tosUrls String[] @default([])
|
||||||
onionUrls String[] @default([])
|
onionUrls String[] @default([])
|
||||||
i2pUrls String[] @default([])
|
i2pUrls String[] @default([])
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
|
/// ISO 3166-1 alpha-2 country code where the service company is registered
|
||||||
|
registrationCountryCode String? @db.VarChar(2)
|
||||||
|
/// Official name of the registered company
|
||||||
|
registeredCompanyName String?
|
||||||
/// [TosReview]
|
/// [TosReview]
|
||||||
tosReview Json?
|
tosReview Json?
|
||||||
tosReviewAt DateTime?
|
tosReviewAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
listedAt DateTime?
|
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
|
||||||
comments Comment[]
|
listedAt DateTime?
|
||||||
events Event[]
|
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
|
||||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
approvedAt DateTime?
|
||||||
attributes ServiceAttribute[]
|
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||||
verificationSteps VerificationStep[]
|
verifiedAt DateTime?
|
||||||
suggestions ServiceSuggestion[]
|
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
|
||||||
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
spamAt DateTime?
|
||||||
|
/// Computed via trigger. Do not update through prisma.
|
||||||
|
isRecentlyApproved Boolean @default(false)
|
||||||
|
comments Comment[]
|
||||||
|
events Event[]
|
||||||
|
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||||
|
attributes ServiceAttribute[]
|
||||||
|
verificationSteps VerificationStep[]
|
||||||
|
suggestions ServiceSuggestion[]
|
||||||
|
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
||||||
|
|
||||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||||
@@ -395,7 +410,13 @@ model Service {
|
|||||||
Notification Notification[]
|
Notification Notification[]
|
||||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||||
|
|
||||||
|
strictCommentingEnabled Boolean @default(false)
|
||||||
|
commentSectionMessage String?
|
||||||
|
|
||||||
@@index([listedAt])
|
@@index([listedAt])
|
||||||
|
@@index([approvedAt])
|
||||||
|
@@index([verifiedAt])
|
||||||
|
@@index([spamAt])
|
||||||
@@index([overallScore])
|
@@index([overallScore])
|
||||||
@@index([privacyScore])
|
@@index([privacyScore])
|
||||||
@@index([trustScore])
|
@@index([trustScore])
|
||||||
@@ -407,6 +428,7 @@ model Service {
|
|||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([previousSlugs])
|
@@index([previousSlugs])
|
||||||
|
@@index([serviceVisibility])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ServiceContactMethod {
|
model ServiceContactMethod {
|
||||||
@@ -487,20 +509,22 @@ model InternalServiceNote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
displayName String?
|
displayName String?
|
||||||
link String?
|
link String?
|
||||||
picture String?
|
picture String?
|
||||||
spammer Boolean @default(false)
|
spammer Boolean @default(false)
|
||||||
verified Boolean @default(false)
|
verified Boolean @default(false)
|
||||||
admin Boolean @default(false)
|
admin Boolean @default(false)
|
||||||
moderator Boolean @default(false)
|
moderator Boolean @default(false)
|
||||||
verifiedLink String?
|
verifiedLink String?
|
||||||
secretTokenHash String @unique
|
secretTokenHash String @unique
|
||||||
feedId String @unique @default(cuid(2))
|
feedId String @unique @default(cuid(2))
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
totalKarma Int @default(0)
|
totalKarma Int @default(0)
|
||||||
|
/// Date when user is scheduled for deletion due to inactivity
|
||||||
|
scheduledDeletionAt DateTime? @db.Date
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
@@ -598,10 +622,11 @@ model VerificationStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
icon String
|
namePluralLong String?
|
||||||
slug String @unique
|
icon String
|
||||||
|
slug String @unique
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
@@ -678,8 +703,6 @@ model PushSubscription {
|
|||||||
p256dh String
|
p256dh String
|
||||||
/// Authentication secret
|
/// Authentication secret
|
||||||
auth String
|
auth String
|
||||||
/// To identify different devices
|
|
||||||
userAgent String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,22 @@ import {
|
|||||||
PrismaClient,
|
PrismaClient,
|
||||||
ServiceSuggestionStatus,
|
ServiceSuggestionStatus,
|
||||||
ServiceUserRole,
|
ServiceUserRole,
|
||||||
VerificationStatus,
|
|
||||||
type Prisma,
|
type Prisma,
|
||||||
type User,
|
type User,
|
||||||
type ServiceVisibility,
|
type ServiceVisibility,
|
||||||
ServiceSuggestionType,
|
ServiceSuggestionType,
|
||||||
KycLevelClarification,
|
KycLevelClarification,
|
||||||
VerificationStepStatus,
|
VerificationStepStatus,
|
||||||
|
type VerificationStatus,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
|
import { differenceInDays, isPast } from 'date-fns'
|
||||||
import { omit, uniqBy } from 'lodash-es'
|
import { omit, uniqBy } from 'lodash-es'
|
||||||
import { generateUsername } from 'unique-username-generator'
|
import { generateUsername } from 'unique-username-generator'
|
||||||
|
|
||||||
import { kycLevels } from '../src/constants/kycLevels'
|
import { kycLevels } from '../src/constants/kycLevels'
|
||||||
import { undefinedIfEmpty } from '../src/lib/arrays'
|
import { undefinedIfEmpty } from '../src/lib/arrays'
|
||||||
import { transformCase } from '../src/lib/strings'
|
import { transformCase } from '../src/lib/strings'
|
||||||
|
import { countries } from '../src/constants/countries'
|
||||||
|
|
||||||
// Exit if not in development mode
|
// Exit if not in development mode
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@@ -140,81 +142,97 @@ const generateFakeAttribute = () => {
|
|||||||
const categoriesToCreate = [
|
const categoriesToCreate = [
|
||||||
{
|
{
|
||||||
name: 'Exchange',
|
name: 'Exchange',
|
||||||
|
namePluralLong: 'Exchanges',
|
||||||
slug: 'exchange',
|
slug: 'exchange',
|
||||||
icon: 'ri:arrow-left-right-fill',
|
icon: 'ri:arrow-left-right-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'VPN',
|
name: 'VPN',
|
||||||
|
namePluralLong: 'VPNs',
|
||||||
slug: 'vpn',
|
slug: 'vpn',
|
||||||
icon: 'ri:door-lock-fill',
|
icon: 'ri:door-lock-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Email',
|
name: 'Email',
|
||||||
|
namePluralLong: 'Email providers',
|
||||||
slug: 'email',
|
slug: 'email',
|
||||||
icon: 'ri:mail-fill',
|
icon: 'ri:mail-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Hosting',
|
name: 'Hosting',
|
||||||
|
namePluralLong: 'Hostings',
|
||||||
slug: 'hosting',
|
slug: 'hosting',
|
||||||
icon: 'ri:server-fill',
|
icon: 'ri:server-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'VPS',
|
name: 'VPS',
|
||||||
|
namePluralLong: 'VPS providers',
|
||||||
slug: 'vps',
|
slug: 'vps',
|
||||||
icon: 'ri:function-add-fill',
|
icon: 'ri:function-add-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Gift Cards',
|
name: 'Gift Cards',
|
||||||
|
namePluralLong: 'Gift cards',
|
||||||
slug: 'gift-cards',
|
slug: 'gift-cards',
|
||||||
icon: 'ri:gift-line',
|
icon: 'ri:gift-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Goods',
|
name: 'Goods',
|
||||||
|
namePluralLong: 'Goods providers',
|
||||||
slug: 'goods',
|
slug: 'goods',
|
||||||
icon: 'ri:shopping-basket-fill',
|
icon: 'ri:shopping-basket-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Travel',
|
name: 'Travel',
|
||||||
|
namePluralLong: 'Travel services',
|
||||||
slug: 'travel',
|
slug: 'travel',
|
||||||
icon: 'ri:plane-fill',
|
icon: 'ri:plane-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'SMS',
|
name: 'SMS',
|
||||||
|
namePluralLong: 'SMS providers',
|
||||||
slug: 'sms',
|
slug: 'sms',
|
||||||
icon: 'ri:message-2-fill',
|
icon: 'ri:message-2-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Store',
|
name: 'Store',
|
||||||
|
namePluralLong: 'Stores',
|
||||||
slug: 'store',
|
slug: 'store',
|
||||||
icon: 'ri:store-2-line',
|
icon: 'ri:store-2-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Tool',
|
name: 'Tool',
|
||||||
|
namePluralLong: 'Tools',
|
||||||
slug: 'tool',
|
slug: 'tool',
|
||||||
icon: 'ri:tools-fill',
|
icon: 'ri:tools-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Market',
|
name: 'Market',
|
||||||
|
namePluralLong: 'Markets',
|
||||||
slug: 'market',
|
slug: 'market',
|
||||||
icon: 'ri:price-tag-3-line',
|
icon: 'ri:price-tag-3-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Aggregator',
|
name: 'Aggregator',
|
||||||
|
namePluralLong: 'Aggregators',
|
||||||
slug: 'aggregator',
|
slug: 'aggregator',
|
||||||
icon: 'ri:list-ordered',
|
icon: 'ri:list-ordered',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AI',
|
name: 'AI',
|
||||||
|
namePluralLong: 'AI services',
|
||||||
slug: 'ai',
|
slug: 'ai',
|
||||||
icon: 'ri:ai-generate-2',
|
icon: 'ri:ai-generate-2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CEX',
|
name: 'CEX',
|
||||||
|
namePluralLong: 'CEXs',
|
||||||
slug: 'cex',
|
slug: 'cex',
|
||||||
icon: 'ri:rotate-lock-fill',
|
icon: 'ri:rotate-lock-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'DEX',
|
name: 'DEX',
|
||||||
|
namePluralLong: 'DEXs',
|
||||||
slug: 'dex',
|
slug: 'dex',
|
||||||
icon: 'ri:fediverse-line',
|
icon: 'ri:fediverse-line',
|
||||||
},
|
},
|
||||||
@@ -614,6 +632,14 @@ const generateFakeService = (users: User[]) => {
|
|||||||
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
|
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
|
||||||
probability: 0.8,
|
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 {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -629,12 +655,7 @@ const generateFakeService = (users: User[]) => {
|
|||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
serviceVisibility,
|
||||||
{ weight: 80, value: 'PUBLIC' },
|
|
||||||
{ weight: 10, value: 'UNLISTED' },
|
|
||||||
{ weight: 5, value: 'HIDDEN' },
|
|
||||||
{ weight: 5, value: 'ARCHIVED' },
|
|
||||||
]),
|
|
||||||
verificationStatus: status,
|
verificationStatus: status,
|
||||||
verificationSummary:
|
verificationSummary:
|
||||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||||
@@ -677,8 +698,20 @@ const generateFakeService = (users: User[]) => {
|
|||||||
{ count: { min: 0, max: 2 } }
|
{ count: { min: 0, max: 2 } }
|
||||||
),
|
),
|
||||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||||
listedAt: faker.date.past(),
|
registrationCountryCode: faker.helpers.maybe(() => faker.helpers.arrayElement(countries).code, {
|
||||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
probability: 0.7,
|
||||||
|
}),
|
||||||
|
registeredCompanyName: faker.helpers.maybe(() => faker.company.name(), {
|
||||||
|
probability: 0.6,
|
||||||
|
}),
|
||||||
|
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,
|
tosReview,
|
||||||
tosReviewAt: tosReview
|
tosReviewAt: tosReview
|
||||||
? faker.date.recent()
|
? faker.date.recent()
|
||||||
@@ -694,6 +727,8 @@ const generateFakeService = (users: User[]) => {
|
|||||||
}),
|
}),
|
||||||
{ probability: 0.33 }
|
{ probability: 0.33 }
|
||||||
),
|
),
|
||||||
|
strictCommentingEnabled: faker.datatype.boolean(0.33333),
|
||||||
|
commentSectionMessage: faker.helpers.maybe(() => faker.lorem.paragraph(), { probability: 0.3 }),
|
||||||
} as const satisfies Prisma.ServiceCreateInput
|
} as const satisfies Prisma.ServiceCreateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,7 +943,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
|||||||
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
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`,
|
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -918,7 +953,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
|||||||
value: faker.internet.url(),
|
value: faker.internet.url(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: faker.lorem.word({ length: 2 }),
|
label: 'Custom label',
|
||||||
value: faker.internet.url(),
|
value: faker.internet.url(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1307,7 +1342,7 @@ async function main() {
|
|||||||
const service = await prisma.service.create({
|
const service = await prisma.service.create({
|
||||||
data: {
|
data: {
|
||||||
...serviceData,
|
...serviceData,
|
||||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||||
categories: {
|
categories: {
|
||||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
|
|||||||
NEW RECORD,
|
NEW RECORD,
|
||||||
OLD RECORD
|
OLD RECORD
|
||||||
) RETURNS VOID AS $$
|
) RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
is_user_related_to_service BOOLEAN;
|
||||||
|
is_user_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
||||||
PERFORM insert_karma_transaction(
|
-- Check if the user is related to the service (e.g., owns/manages it)
|
||||||
NEW."authorId",
|
SELECT EXISTS(
|
||||||
1,
|
SELECT 1 FROM "ServiceUser"
|
||||||
'COMMENT_APPROVED',
|
WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId"
|
||||||
NEW.id,
|
) INTO is_user_related_to_service;
|
||||||
format('Your comment #comment-%s in %s has been approved!',
|
|
||||||
|
-- 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,
|
NEW.id,
|
||||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
format('Your comment #comment-%s in %s has been approved!',
|
||||||
);
|
NEW.id,
|
||||||
PERFORM update_user_karma(NEW."authorId", 1);
|
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||||
|
);
|
||||||
|
PERFORM update_user_karma(NEW."authorId", 1);
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
|
|||||||
NEW RECORD,
|
NEW RECORD,
|
||||||
OLD RECORD
|
OLD RECORD
|
||||||
) RETURNS VOID AS $$
|
) RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
is_user_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
||||||
PERFORM insert_karma_transaction(
|
-- Check if the comment author is an admin or moderator
|
||||||
NEW."authorId",
|
SELECT (admin = true OR moderator = true)
|
||||||
5,
|
FROM "User"
|
||||||
'COMMENT_VERIFIED',
|
WHERE id = NEW."authorId"
|
||||||
NEW.id,
|
INTO is_user_admin_or_moderator;
|
||||||
format('Your comment #comment-%s in %s has been verified!',
|
|
||||||
|
-- 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,
|
NEW.id,
|
||||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
format('Your comment #comment-%s in %s has been verified!',
|
||||||
);
|
NEW.id,
|
||||||
PERFORM update_user_karma(NEW."authorId", 5);
|
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||||
|
);
|
||||||
|
PERFORM update_user_karma(NEW."authorId", 5);
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -146,12 +175,19 @@ DECLARE
|
|||||||
comment_author_id INT;
|
comment_author_id INT;
|
||||||
service_name TEXT;
|
service_name TEXT;
|
||||||
upvote_change INT := 0; -- Variable to track change in upvotes
|
upvote_change INT := 0; -- Variable to track change in upvotes
|
||||||
|
is_author_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get comment author and service info
|
-- Get comment author and service info
|
||||||
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
||||||
FROM "Comment" c
|
FROM "Comment" c
|
||||||
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
|
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
|
-- Calculate karma impact based on vote type
|
||||||
IF TG_OP = 'INSERT' THEN
|
IF TG_OP = 'INSERT' THEN
|
||||||
-- New vote
|
-- 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
|
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Record karma transaction and update user karma
|
-- Only award karma if the author is NOT an admin/moderator
|
||||||
PERFORM insert_karma_transaction(
|
IF NOT COALESCE(is_author_admin_or_moderator, false) THEN
|
||||||
comment_author_id,
|
-- Record karma transaction and update user karma
|
||||||
karma_points,
|
PERFORM insert_karma_transaction(
|
||||||
vote_action,
|
comment_author_id,
|
||||||
COALESCE(NEW."commentId", OLD."commentId"),
|
karma_points,
|
||||||
vote_description
|
vote_action,
|
||||||
);
|
COALESCE(NEW."commentId", OLD."commentId"),
|
||||||
|
vote_description
|
||||||
|
);
|
||||||
|
|
||||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- Update comment's upvotes count incrementally
|
-- Update comment's upvotes count incrementally
|
||||||
UPDATE "Comment"
|
UPDATE "Comment"
|
||||||
@@ -236,26 +275,40 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
|
|||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
service_name TEXT;
|
service_name TEXT;
|
||||||
|
service_visibility "ServiceVisibility";
|
||||||
|
is_user_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Award karma for first approval
|
-- Award karma for first approval
|
||||||
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
||||||
-- and ensure it wasn't already APPROVED.
|
-- and ensure it wasn't already APPROVED.
|
||||||
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
||||||
-- Fetch service name for the description
|
-- Fetch service details for the description
|
||||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
SELECT name, "serviceVisibility" INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId";
|
||||||
|
|
||||||
-- Insert karma transaction, linking it to the suggestion
|
-- Only award karma if the service is public
|
||||||
PERFORM insert_karma_transaction(
|
IF service_visibility = 'PUBLIC' THEN
|
||||||
NEW."userId",
|
-- Check if the user is an admin or moderator
|
||||||
10,
|
SELECT (admin = true OR moderator = true)
|
||||||
'SUGGESTION_APPROVED',
|
FROM "User"
|
||||||
NULL, -- p_comment_id (not applicable)
|
WHERE id = NEW."userId"
|
||||||
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
INTO is_user_admin_or_moderator;
|
||||||
NEW.id -- p_suggestion_id
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Update user's total karma
|
-- Only award karma if the user is NOT an admin/moderator
|
||||||
PERFORM update_user_karma(NEW."userId", 10);
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update user's total karma
|
||||||
|
PERFORM update_user_karma(NEW."userId", 10);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
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:
|
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
||||||
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
||||||
-- for services when services or their attributes change.
|
-- 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"
|
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
||||||
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
||||||
-- efficient handling of widespread score updates.
|
-- efficient handling of widespread score updates.
|
||||||
@@ -25,12 +25,8 @@ DECLARE
|
|||||||
privacy_score INT := 0;
|
privacy_score INT := 0;
|
||||||
kyc_factor INT;
|
kyc_factor INT;
|
||||||
clarification_factor INT := 0;
|
clarification_factor INT := 0;
|
||||||
onion_factor INT := 0;
|
onion_or_i2p_factor INT := 0;
|
||||||
i2p_factor INT := 0;
|
|
||||||
monero_factor INT := 0;
|
monero_factor INT := 0;
|
||||||
open_source_factor INT := 0;
|
|
||||||
p2p_factor INT := 0;
|
|
||||||
decentralized_factor INT := 0;
|
|
||||||
attributes_score INT := 0;
|
attributes_score INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get service data
|
-- Get service data
|
||||||
@@ -57,20 +53,12 @@ BEGIN
|
|||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE "id" = service_id;
|
WHERE "id" = service_id;
|
||||||
|
|
||||||
-- Check for onion URLs
|
-- Check for onion or i2p URLs
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM "Service"
|
SELECT 1 FROM "Service"
|
||||||
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
|
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
|
||||||
) THEN
|
) THEN
|
||||||
onion_factor := 5;
|
onion_or_i2p_factor := 5;
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Check for i2p URLs
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM "Service"
|
|
||||||
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
|
|
||||||
) THEN
|
|
||||||
i2p_factor := 5;
|
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Check for Monero acceptance
|
-- Check for Monero acceptance
|
||||||
@@ -86,10 +74,10 @@ BEGIN
|
|||||||
INTO attributes_score
|
INTO attributes_score
|
||||||
FROM "ServiceAttribute" sa
|
FROM "ServiceAttribute" sa
|
||||||
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
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)
|
-- Calculate final privacy score (base 100)
|
||||||
privacy_score := 50 + kyc_factor + clarification_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)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||||
@@ -105,8 +93,10 @@ DECLARE
|
|||||||
trust_score INT := 0;
|
trust_score INT := 0;
|
||||||
verification_factor INT;
|
verification_factor INT;
|
||||||
attributes_score INT := 0;
|
attributes_score INT := 0;
|
||||||
recently_listed_factor INT := 0;
|
recently_approved_factor INT := 0;
|
||||||
tos_penalty_factor INT := 0;
|
tos_penalty_factor INT := 0;
|
||||||
|
operating_since_factor INT := 0;
|
||||||
|
legally_registered_factor INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get verification status factor
|
-- Get verification status factor
|
||||||
SELECT
|
SELECT
|
||||||
@@ -126,26 +116,26 @@ BEGIN
|
|||||||
INTO attributes_score
|
INTO attributes_score
|
||||||
FROM "ServiceAttribute" sa
|
FROM "ServiceAttribute" sa
|
||||||
JOIN "Attribute" a ON sa."attributeId" = a.id
|
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 (
|
IF EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE id = service_id
|
WHERE id = service_id
|
||||||
AND "listedAt" IS NOT NULL
|
AND "approvedAt" IS NOT NULL
|
||||||
AND "verificationStatus" = 'APPROVED'
|
AND "verificationStatus" = 'APPROVED'
|
||||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
AND (NOW() - "approvedAt") <= INTERVAL '15 days'
|
||||||
) THEN
|
) THEN
|
||||||
recently_listed_factor := -10;
|
recently_approved_factor := -10;
|
||||||
-- Update the isRecentlyListed flag to true
|
-- Update the isRecentlyApproved flag to true
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = TRUE
|
SET "isRecentlyApproved" = TRUE
|
||||||
WHERE id = service_id;
|
WHERE id = service_id;
|
||||||
ELSE
|
ELSE
|
||||||
-- Update the isRecentlyListed flag to false
|
-- Update the isRecentlyApproved flag to false
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = FALSE
|
SET "isRecentlyApproved" = FALSE
|
||||||
WHERE id = service_id;
|
WHERE id = service_id;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
@@ -160,8 +150,28 @@ BEGIN
|
|||||||
tos_penalty_factor := -3;
|
tos_penalty_factor := -3;
|
||||||
END IF;
|
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;
|
||||||
|
|
||||||
|
-- Check for legal registration (country code or company name)
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM "Service"
|
||||||
|
WHERE id = service_id AND ("registrationCountryCode" IS NOT NULL OR "registeredCompanyName" IS NOT NULL)
|
||||||
|
) THEN
|
||||||
|
legally_registered_factor := 2;
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- Calculate final trust score (base 100)
|
-- 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 + legally_registered_factor;
|
||||||
|
|
||||||
-- Ensure the score is in reasonable bounds (0-100)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||||
@@ -176,7 +186,7 @@ RETURNS INT AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
overall_score INT;
|
overall_score INT;
|
||||||
BEGIN
|
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));
|
RETURN GREATEST(0, LEAST(10, overall_score));
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|||||||
@@ -1,48 +1,60 @@
|
|||||||
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
|
CREATE OR REPLACE FUNCTION manage_service_visibility_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()
|
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Manage listedAt timestamp
|
IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
|
||||||
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
|
||||||
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
|
|
||||||
IF OLD."listedAt" IS NULL THEN
|
IF OLD."listedAt" IS NULL THEN
|
||||||
NEW."listedAt" := NOW();
|
NEW."listedAt" := NOW();
|
||||||
NEW."isRecentlyListed" := TRUE;
|
|
||||||
END IF;
|
END IF;
|
||||||
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
ELSE
|
||||||
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
|
|
||||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
|
||||||
NEW."listedAt" := NULL;
|
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;
|
END IF;
|
||||||
|
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ 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 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";
|
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"
|
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
-- Only execute the function if the verificationStatus value has actually changed
|
EXECUTE FUNCTION manage_service_verification_timestamps();
|
||||||
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
|
|
||||||
EXECUTE FUNCTION manage_service_timestamps();
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { z } from 'astro:content'
|
import { z } from 'astro:content'
|
||||||
|
import { pick } from 'lodash-es'
|
||||||
|
|
||||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||||
import { createAccount } from '../lib/accountCreate'
|
import { createAccount } from '../lib/accountCreate'
|
||||||
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
|
|||||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||||
import { startImpersonating } from '../lib/impersonation'
|
import { startImpersonating, stopImpersonating } from '../lib/impersonation'
|
||||||
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
||||||
@@ -225,4 +226,36 @@ export const accountActions = {
|
|||||||
return { user }
|
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 }
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { ActionError } from 'astro:actions'
|
|||||||
import { uniq } from 'lodash-es'
|
import { uniq } from 'lodash-es'
|
||||||
import slugify from 'slugify'
|
import slugify from 'slugify'
|
||||||
|
|
||||||
|
import { countriesZodEnumById } from '../../constants/countries'
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { separateServiceUrlsByType } from '../../lib/urls'
|
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||||
import {
|
import {
|
||||||
imageFileSchema,
|
imageFileSchema,
|
||||||
|
stringListOfContactMethodsSchema,
|
||||||
stringListOfUrlsSchemaRequired,
|
stringListOfUrlsSchemaRequired,
|
||||||
zodCohercedNumber,
|
zodCohercedNumber,
|
||||||
zodContactMethod,
|
zodContactMethod,
|
||||||
@@ -44,6 +46,7 @@ const serviceSchemaBase = z.object({
|
|||||||
description: z.string().min(1),
|
description: z.string().min(1),
|
||||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
|
contactMethods: stringListOfContactMethodsSchema,
|
||||||
kycLevel: z.coerce.number().int().min(0).max(4),
|
kycLevel: z.coerce.number().int().min(0).max(4),
|
||||||
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
|
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
|
||||||
attributes: z.array(z.coerce.number().int().positive()),
|
attributes: z.array(z.coerce.number().int().positive()),
|
||||||
@@ -51,17 +54,27 @@ const serviceSchemaBase = z.object({
|
|||||||
verificationStatus: z.nativeEnum(VerificationStatus),
|
verificationStatus: z.nativeEnum(VerificationStatus),
|
||||||
verificationSummary: z.string().optional().nullable().default(null),
|
verificationSummary: z.string().optional().nullable().default(null),
|
||||||
verificationProofMd: z.string().optional().nullable().default(null),
|
verificationProofMd: z.string().optional().nullable().default(null),
|
||||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
||||||
referral: z
|
referral: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
||||||
.optional()
|
.optional()
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null),
|
.default(null),
|
||||||
|
operatingSince: z.coerce.date().optional().nullable(),
|
||||||
|
registrationCountryCode: z
|
||||||
|
.union([countriesZodEnumById, z.literal('')])
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
|
||||||
|
message: 'Country code must be a valid 2-character code or empty',
|
||||||
|
}),
|
||||||
|
registeredCompanyName: z.string().trim().max(100).optional().nullable(),
|
||||||
imageFile: imageFileSchema,
|
imageFile: imageFileSchema,
|
||||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
|
||||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||||
internalNote: z.string().optional(),
|
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
|
// Define schema for the create action input
|
||||||
@@ -126,11 +139,12 @@ export const adminServiceActions = {
|
|||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
|
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||||
|
commentSectionMessage: input.commentSectionMessage,
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
referral: input.referral || null,
|
referral: input.referral || null,
|
||||||
serviceVisibility: input.serviceVisibility,
|
serviceVisibility: input.serviceVisibility,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
overallScore: input.overallScore,
|
|
||||||
categories: {
|
categories: {
|
||||||
connect: input.categories.map((id) => ({ id })),
|
connect: input.categories.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
@@ -141,6 +155,11 @@ export const adminServiceActions = {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
contactMethods: {
|
||||||
|
create: input.contactMethods.map((value) => ({
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
imageUrl,
|
imageUrl,
|
||||||
internalNotes: input.internalNote
|
internalNotes: input.internalNote
|
||||||
? {
|
? {
|
||||||
@@ -150,6 +169,9 @@ export const adminServiceActions = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
operatingSince: input.operatingSince,
|
||||||
|
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||||
|
registeredCompanyName: input.registeredCompanyName,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -245,11 +267,12 @@ export const adminServiceActions = {
|
|||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
|
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||||
|
commentSectionMessage: input.commentSectionMessage,
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
referral: input.referral || null,
|
referral: input.referral || null,
|
||||||
serviceVisibility: input.serviceVisibility,
|
serviceVisibility: input.serviceVisibility,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
overallScore: input.overallScore,
|
|
||||||
previousSlugs:
|
previousSlugs:
|
||||||
existingService.slug !== input.slug
|
existingService.slug !== input.slug
|
||||||
? {
|
? {
|
||||||
@@ -258,7 +281,6 @@ export const adminServiceActions = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
imageUrl,
|
imageUrl,
|
||||||
categories: {
|
categories: {
|
||||||
connect: categoriesToAdd.map((id) => ({ id })),
|
connect: categoriesToAdd.map((id) => ({ id })),
|
||||||
@@ -275,6 +297,9 @@ export const adminServiceActions = {
|
|||||||
attributeId,
|
attributeId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
operatingSince: input.operatingSince,
|
||||||
|
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||||
|
registeredCompanyName: input.registeredCompanyName,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -335,7 +360,6 @@ export const adminServiceActions = {
|
|||||||
await prisma.serviceContactMethod.delete({
|
await prisma.serviceContactMethod.delete({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
})
|
})
|
||||||
return { success: true }
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -461,7 +485,6 @@ export const adminServiceActions = {
|
|||||||
input: evidenceImageDeleteSchema,
|
input: evidenceImageDeleteSchema,
|
||||||
handler: async (input) => {
|
handler: async (input) => {
|
||||||
await deleteFileLocally(input.fileUrl)
|
await deleteFileLocally(input.fileUrl)
|
||||||
return { success: true }
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ export const apiServiceActions = {
|
|||||||
tosUrls: true,
|
tosUrls: true,
|
||||||
referral: true,
|
referral: true,
|
||||||
listedAt: true,
|
listedAt: true,
|
||||||
|
approvedAt: true,
|
||||||
verifiedAt: true,
|
verifiedAt: true,
|
||||||
serviceVisibility: true,
|
serviceVisibility: true,
|
||||||
} as const satisfies Prisma.ServiceSelect
|
} as const satisfies Prisma.ServiceSelect
|
||||||
|
|
||||||
let service = await prisma.service.findFirst({
|
let service = await prisma.service.findFirst({
|
||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
OR: [
|
OR: [
|
||||||
@@ -92,7 +92,6 @@ export const apiServiceActions = {
|
|||||||
if (!service && input.slug) {
|
if (!service && input.slug) {
|
||||||
service = await prisma.service.findFirst({
|
service = await prisma.service.findFirst({
|
||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
previousSlugs: { has: input.slug },
|
previousSlugs: { has: input.slug },
|
||||||
@@ -105,9 +104,7 @@ export const apiServiceActions = {
|
|||||||
!service ||
|
!service ||
|
||||||
(service.serviceVisibility !== 'PUBLIC' &&
|
(service.serviceVisibility !== 'PUBLIC' &&
|
||||||
service.serviceVisibility !== 'ARCHIVED' &&
|
service.serviceVisibility !== 'ARCHIVED' &&
|
||||||
service.serviceVisibility !== 'UNLISTED') ||
|
service.serviceVisibility !== 'UNLISTED')
|
||||||
!service.listedAt ||
|
|
||||||
service.listedAt > new Date()
|
|
||||||
) {
|
) {
|
||||||
throw new ActionError({
|
throw new ActionError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
@@ -130,12 +127,13 @@ export const apiServiceActions = {
|
|||||||
'description',
|
'description',
|
||||||
]),
|
]),
|
||||||
verifiedAt: service.verifiedAt,
|
verifiedAt: service.verifiedAt,
|
||||||
|
approvedAt: service.approvedAt,
|
||||||
kycLevel: service.kycLevel,
|
kycLevel: service.kycLevel,
|
||||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||||
kycLevelClarification: service.kycLevelClarification,
|
kycLevelClarification: service.kycLevelClarification,
|
||||||
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||||
'value',
|
'value',
|
||||||
'name',
|
'label',
|
||||||
'description',
|
'description',
|
||||||
]),
|
]),
|
||||||
categories: service.categories,
|
categories: service.categories,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { CommentStatus, Prisma } from '@prisma/client'
|
|||||||
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
|
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
|
||||||
const MAX_COMMENTS_PER_WINDOW = 1
|
const MAX_COMMENTS_PER_WINDOW = 1
|
||||||
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
|
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
|
||||||
|
export const COMMENT_ORDER_ID_MAX_LENGTH = 600
|
||||||
|
|
||||||
export const commentActions = {
|
export const commentActions = {
|
||||||
vote: defineProtectedAction({
|
vote: defineProtectedAction({
|
||||||
@@ -103,7 +104,7 @@ export const commentActions = {
|
|||||||
issueFundsBlocked: z.coerce.boolean().optional(),
|
issueFundsBlocked: z.coerce.boolean().optional(),
|
||||||
issueScam: z.coerce.boolean().optional(),
|
issueScam: z.coerce.boolean().optional(),
|
||||||
issueDetails: z.string().max(120).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) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.rating && data.parentId) {
|
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
|
// Prepare data object with proper type safety
|
||||||
const commentData: Prisma.CommentCreateInput = {
|
const commentData: Prisma.CommentCreateInput = {
|
||||||
content: input.content,
|
content: input.content,
|
||||||
@@ -277,7 +290,12 @@ export const commentActions = {
|
|||||||
author: { connect: { id: context.locals.user.id } },
|
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
|
// 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,
|
requiresAdminReview,
|
||||||
orderId: input.orderId?.trim() ?? null,
|
orderId: input.orderId?.trim() ?? null,
|
||||||
kycRequested: input.issueKycRequested === true,
|
kycRequested: input.issueKycRequested === true,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const notificationActions = {
|
|||||||
endpoint: z.string(),
|
endpoint: z.string(),
|
||||||
p256dhKey: z.string(),
|
p256dhKey: z.string(),
|
||||||
authKey: z.string(),
|
authKey: z.string(),
|
||||||
userAgent: z.string().optional(),
|
|
||||||
}),
|
}),
|
||||||
handler: async (input, context) => {
|
handler: async (input, context) => {
|
||||||
await prisma.pushSubscription.upsert({
|
await prisma.pushSubscription.upsert({
|
||||||
@@ -43,14 +42,12 @@ export const notificationActions = {
|
|||||||
update: {
|
update: {
|
||||||
p256dh: input.p256dhKey,
|
p256dh: input.p256dhKey,
|
||||||
auth: input.authKey,
|
auth: input.authKey,
|
||||||
userAgent: input.userAgent,
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user.id,
|
||||||
endpoint: input.endpoint,
|
endpoint: input.endpoint,
|
||||||
p256dh: input.p256dhKey,
|
p256dh: input.p256dhKey,
|
||||||
auth: input.authKey,
|
auth: input.authKey,
|
||||||
userAgent: input.userAgent,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -58,25 +55,17 @@ export const notificationActions = {
|
|||||||
|
|
||||||
unsubscribe: defineProtectedAction({
|
unsubscribe: defineProtectedAction({
|
||||||
accept: 'json',
|
accept: 'json',
|
||||||
permissions: 'user',
|
permissions: 'guest',
|
||||||
input: z.object({
|
input: z.object({
|
||||||
endpoint: z.string().optional(),
|
endpoint: z.string(),
|
||||||
}),
|
}),
|
||||||
handler: async (input, context) => {
|
handler: async (input, context) => {
|
||||||
if (input.endpoint) {
|
await prisma.pushSubscription.delete({
|
||||||
await prisma.pushSubscription.deleteMany({
|
where: {
|
||||||
where: {
|
userId: context.locals.user?.id ?? undefined,
|
||||||
userId: context.locals.user.id,
|
endpoint: input.endpoint,
|
||||||
endpoint: input.endpoint,
|
},
|
||||||
},
|
})
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await prisma.pushSubscription.deleteMany({
|
|
||||||
where: {
|
|
||||||
userId: context.locals.user.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'astro/zod'
|
|||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { formatDistanceStrict } from 'date-fns'
|
import { formatDistanceStrict } from 'date-fns'
|
||||||
|
|
||||||
|
import { countriesZodEnumById } from '../constants/countries'
|
||||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
@@ -36,7 +37,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
|||||||
id: {
|
id: {
|
||||||
in: matches.map(({ id }) => id),
|
in: matches.map(({ id }) => id),
|
||||||
},
|
},
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: {
|
serviceVisibility: {
|
||||||
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||||
},
|
},
|
||||||
@@ -50,33 +50,51 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
const serializeExtraNotes = async <T extends Record<string, unknown>, NK extends keyof T>(
|
||||||
input: T,
|
input: T,
|
||||||
skipKeys: (keyof T)[] = []
|
skipKeys: NK[] = [],
|
||||||
): string => {
|
mapKeys: { [P in Exclude<Extract<keyof T, string>, NK>]?: (value: T[P]) => unknown } = {}
|
||||||
return Object.entries(input)
|
): Promise<string> => {
|
||||||
.filter(([key]) => !skipKeys.includes(key as keyof T))
|
return (
|
||||||
.map(([key, value]) => {
|
await Promise.all(
|
||||||
let serializedValue = ''
|
Object.entries(input)
|
||||||
if (typeof value === 'string') {
|
.filter(
|
||||||
serializedValue = value
|
(
|
||||||
} else if (value === undefined || value === null) {
|
entry
|
||||||
serializedValue = ''
|
): entry is [Exclude<Extract<keyof T, string>, NK>, T[Exclude<Extract<keyof T, string>, NK>]] =>
|
||||||
} else if (Array.isArray(value)) {
|
!skipKeys.some((k) => k === entry[0])
|
||||||
serializedValue = value.map((item) => String(item)).join(', ')
|
)
|
||||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
.map(async ([key, originalValue]) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
const value = mapKeys[key] ? await mapKeys[key](originalValue) : originalValue
|
||||||
serializedValue = value.toString()
|
let serializedValue = ''
|
||||||
} else {
|
if (typeof value === 'string') {
|
||||||
try {
|
serializedValue = value
|
||||||
serializedValue = JSON.stringify(value)
|
} else if (value === undefined || value === null) {
|
||||||
} catch (error) {
|
serializedValue = ''
|
||||||
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
} else if (Array.isArray(value)) {
|
||||||
}
|
serializedValue = value.map((item) => String(item)).join(', ')
|
||||||
}
|
} else if (typeof value === 'object' && value instanceof Date) {
|
||||||
return `- ${key}: ${serializedValue}`
|
serializedValue = value.toISOString()
|
||||||
})
|
} else if (
|
||||||
.join('\n')
|
typeof value === 'object' &&
|
||||||
|
'toString' in value &&
|
||||||
|
typeof value.toString === 'function' &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||||
|
value.toString() !== '[object Object]'
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||||
|
serializedValue = value.toString()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
serializedValue = JSON.stringify(value)
|
||||||
|
} catch (error) {
|
||||||
|
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `- ${key}: ${serializedValue}`
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serviceSuggestionActions = {
|
export const serviceSuggestionActions = {
|
||||||
@@ -166,6 +184,15 @@ export const serviceSuggestionActions = {
|
|||||||
message: 'You must accept the suggestion rules and process to continue',
|
message: 'You must accept the suggestion rules and process to continue',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
operatingSince: z.coerce.date().optional(),
|
||||||
|
registrationCountryCode: z
|
||||||
|
.union([countriesZodEnumById, z.literal('')])
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
|
||||||
|
message: 'Country code must be a valid 2-character code or empty',
|
||||||
|
}),
|
||||||
|
registeredCompanyName: z.string().trim().max(100).optional(),
|
||||||
/** @deprecated Honey pot field, do not use */
|
/** @deprecated Honey pot field, do not use */
|
||||||
message: z.unknown().optional(),
|
message: z.unknown().optional(),
|
||||||
skipDuplicateCheck: z
|
skipDuplicateCheck: z
|
||||||
@@ -200,14 +227,37 @@ export const serviceSuggestionActions = {
|
|||||||
return {
|
return {
|
||||||
hasDuplicates: true,
|
hasDuplicates: true,
|
||||||
possibleDuplicates,
|
possibleDuplicates,
|
||||||
extraNotes: serializeExtraNotes(input, [
|
extraNotes: await serializeExtraNotes(
|
||||||
'skipDuplicateCheck',
|
input,
|
||||||
'message',
|
[
|
||||||
'imageFile',
|
'skipDuplicateCheck',
|
||||||
'captcha-value',
|
'message',
|
||||||
'captcha-solution-hash',
|
'imageFile',
|
||||||
'rulesConfirm',
|
'captcha-value',
|
||||||
]),
|
'captcha-solution-hash',
|
||||||
|
'rulesConfirm',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
attributes: async (value) => {
|
||||||
|
const dbAttributes = await prisma.attribute.findMany({
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
where: { id: { in: value } },
|
||||||
|
})
|
||||||
|
return dbAttributes.map((attribute) => `\n - ${attribute.title}`).join('')
|
||||||
|
},
|
||||||
|
categories: async (value) => {
|
||||||
|
const dbCategories = await prisma.category.findMany({
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
where: { id: { in: value } },
|
||||||
|
})
|
||||||
|
return dbCategories.map((category) => category.name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
serviceSuggestion: undefined,
|
serviceSuggestion: undefined,
|
||||||
service: undefined,
|
service: undefined,
|
||||||
} as const
|
} as const
|
||||||
@@ -240,6 +290,9 @@ export const serviceSuggestionActions = {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
operatingSince: input.operatingSince,
|
||||||
|
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||||
|
registeredCompanyName: input.registeredCompanyName,
|
||||||
serviceUrls,
|
serviceUrls,
|
||||||
tosUrls: input.tosUrls,
|
tosUrls: input.tosUrls,
|
||||||
onionUrls,
|
onionUrls,
|
||||||
@@ -252,7 +305,6 @@ export const serviceSuggestionActions = {
|
|||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
listedAt: new Date(),
|
|
||||||
serviceVisibility: 'UNLISTED',
|
serviceVisibility: 'UNLISTED',
|
||||||
categories: {
|
categories: {
|
||||||
connect: input.categories.map((id) => ({ id })),
|
connect: input.categories.map((id) => ({ id })),
|
||||||
|
|||||||
24
web/src/components/AdminNavigationFixScript.astro
Normal file
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 { Prisma } from '@prisma/client'
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
import type { O } from 'ts-toolbelt'
|
||||||
|
|
||||||
type Props = HTMLAttributes<'div'> & {
|
type Props = HTMLAttributes<'div'> & {
|
||||||
announcement: Prisma.AnnouncementGetPayload<{
|
announcement: O.Optional<
|
||||||
select: {
|
Prisma.AnnouncementGetPayload<{
|
||||||
id: true
|
select: {
|
||||||
content: true
|
id: true
|
||||||
type: true
|
content: true
|
||||||
link: true
|
type: true
|
||||||
linkText: true
|
link: true
|
||||||
startDate: true
|
linkText: true
|
||||||
endDate: true
|
startDate: true
|
||||||
isActive: true
|
endDate: true
|
||||||
}
|
isActive: true
|
||||||
}>
|
}
|
||||||
|
}>,
|
||||||
|
'link' | 'linkText'
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
const { announcement, class: className, ...props } = Astro.props
|
const { announcement, class: className, ...props } = Astro.props
|
||||||
@@ -31,12 +35,15 @@ const Tag = announcement.link ? 'a' : 'div'
|
|||||||
|
|
||||||
<Tag
|
<Tag
|
||||||
href={announcement.link}
|
href={announcement.link}
|
||||||
target={announcement.link ? '_blank' : undefined}
|
target={announcement.link && new URL(announcement.link, Astro.url.origin).origin !== Astro.url.origin
|
||||||
|
? '_blank'
|
||||||
|
: undefined}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class={cn(
|
class={cn(
|
||||||
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
|
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-label="Announcement banner"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -78,15 +85,15 @@ const Tag = announcement.link ? 'a' : 'div'
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{
|
||||||
class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]"
|
!!announcement.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">
|
<span class="2xs:inline-block hidden">{announcement.linkText}</span>
|
||||||
{announcement.linkText}
|
<Icon
|
||||||
</span>
|
name="ri:arrow-right-line"
|
||||||
<Icon
|
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||||
name="ri:arrow-right-line"
|
/>
|
||||||
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
|
</div>
|
||||||
/>
|
)
|
||||||
</div>
|
}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
---
|
---
|
||||||
import LoadingIndicator from 'astro-loading-indicator/component'
|
import LoadingIndicator from 'astro-loading-indicator/component'
|
||||||
import { Schema } from 'astro-seo-schema'
|
import { Schema } from 'astro-seo-schema'
|
||||||
|
import { ONION_ADDRESS } from 'astro:env/server'
|
||||||
import { ClientRouter } from 'astro:transitions'
|
import { ClientRouter } from 'astro:transitions'
|
||||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
|
||||||
import { pwaInfo } from 'virtual:pwa-info'
|
import { pwaInfo } from 'virtual:pwa-info'
|
||||||
|
|
||||||
import { isNotArray } from '../lib/arrays'
|
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 DevToolsMessageScript from './DevToolsMessageScript.astro'
|
||||||
import DynamicFavicon from './DynamicFavicon.astro'
|
import DynamicFavicon from './DynamicFavicon.astro'
|
||||||
import HtmxScript from './HtmxScript.astro'
|
import HtmxScript from './HtmxScript.astro'
|
||||||
@@ -77,50 +79,49 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
|
|||||||
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
{/* Primary Meta Tags */}
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<title>{fullTitle}</title>
|
<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:type" content="website" />
|
||||||
<meta property="og:url" content={Astro.url} />
|
<meta property="og:url" content={Astro.url} />
|
||||||
<meta property="og:title" content={fullTitle} />
|
<meta property="og:title" content={fullTitle} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||||
|
|
||||||
<!-- Twitter -->
|
{/* Twitter */}
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:url" content={Astro.url} />
|
<meta property="twitter:url" content={Astro.url} />
|
||||||
<meta property="twitter:title" content={fullTitle} />
|
<meta property="twitter:title" content={fullTitle} />
|
||||||
<meta property="twitter:description" content={description} />
|
<meta property="twitter:description" content={description} />
|
||||||
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
|
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
|
||||||
|
|
||||||
<!-- Other -->
|
{/* Other */}
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<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.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
|
||||||
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
|
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
|
||||||
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
||||||
|
|
||||||
<DynamicFavicon />
|
<DynamicFavicon />
|
||||||
|
|
||||||
<!-- Components -->
|
<ClientRouter />
|
||||||
{
|
<AdminNavigationFixScript />
|
||||||
!Astro.url.pathname.startsWith('/admin') && (
|
|
||||||
<ClientRouter />
|
|
||||||
) /* Disable to prevent bugs in important admin forms */
|
|
||||||
}
|
|
||||||
<LoadingIndicator color="green" />
|
<LoadingIndicator color="green" />
|
||||||
<TailwindJsPluggin />
|
<TailwindJsPluggin />
|
||||||
{htmx && <HtmxScript />}
|
{htmx && <HtmxScript />}
|
||||||
|
|
||||||
<!-- JSON-LD Schemas -->
|
{/* JSON-LD Schemas */}
|
||||||
{schemas?.map((item) => <Schema item={item} />)}
|
{schemas?.map((item) => <Schema item={item} />)}
|
||||||
|
|
||||||
<!-- Breadcrumbs -->
|
{/* Breadcrumbs */}
|
||||||
{
|
{
|
||||||
breadcrumbLists.map((breadcrumbList) => (
|
breadcrumbLists.map((breadcrumbList) => (
|
||||||
<Schema
|
<Schema
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../lib/commentsWithReplies'
|
} from '../lib/commentsWithReplies'
|
||||||
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||||
import { formatDateShort } from '../lib/timeAgo'
|
import { formatDateShort } from '../lib/timeAgo'
|
||||||
|
import { urlDomain } from '../lib/urls'
|
||||||
|
|
||||||
import BadgeSmall from './BadgeSmall.astro'
|
import BadgeSmall from './BadgeSmall.astro'
|
||||||
import CommentModeration from './CommentModeration.astro'
|
import CommentModeration from './CommentModeration.astro'
|
||||||
@@ -32,6 +33,7 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
highlightedCommentId: number | null
|
highlightedCommentId: number | null
|
||||||
serviceSlug: string
|
serviceSlug: string
|
||||||
itemReviewedId: string
|
itemReviewedId: string
|
||||||
|
strictCommentingEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -41,6 +43,7 @@ const {
|
|||||||
highlightedCommentId = null,
|
highlightedCommentId = null,
|
||||||
serviceSlug,
|
serviceSlug,
|
||||||
itemReviewedId,
|
itemReviewedId,
|
||||||
|
strictCommentingEnabled,
|
||||||
class: className,
|
class: className,
|
||||||
...htmlProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
@@ -170,7 +173,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
comment.author.admin || comment.author.moderator
|
comment.author.admin || comment.author.moderator
|
||||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
? `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" />
|
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -491,6 +494,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
serviceId={comment.serviceId}
|
serviceId={comment.serviceId}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
|
strictCommentingEnabled={strictCommentingEnabled}
|
||||||
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
|
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { Markdown } from 'astro-remote'
|
||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
import { COMMENT_ORDER_ID_MAX_LENGTH } from '../actions/comment'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
|||||||
serviceId: number
|
serviceId: number
|
||||||
parentId?: number
|
parentId?: number
|
||||||
commentId?: number
|
commentId?: number
|
||||||
|
strictCommentingEnabled?: boolean
|
||||||
|
commentSectionMessage?: string | null
|
||||||
activeRatingComment?: Prisma.CommentGetPayload<{
|
activeRatingComment?: Prisma.CommentGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
id: true
|
id: true
|
||||||
@@ -28,7 +32,16 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
|||||||
}> | null
|
}> | 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
|
const MIN_COMMENT_LENGTH = parentId ? 10 : 30
|
||||||
|
|
||||||
@@ -88,69 +101,83 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!parentId ? (
|
{!parentId ? (
|
||||||
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
<>
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
||||||
<InputRating name="rating" label="Rating" />
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<InputRating name="rating" label="Rating" />
|
||||||
|
|
||||||
<InputWrapper label="I experienced..." name="tags">
|
<InputWrapper label="I experienced..." name="tags">
|
||||||
<label class="flex cursor-pointer items-center gap-2">
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||||
<Icon name="ri:user-forbid-fill" class="size-3" />
|
<Icon name="ri:user-forbid-fill" class="size-3" />
|
||||||
KYC Issue
|
KYC Issue
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex cursor-pointer items-center gap-2">
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
||||||
<span class="flex items-center gap-1 text-xs text-orange-400">
|
<span class="flex items-center gap-1 text-xs text-orange-400">
|
||||||
<Icon name="ri:wallet-3-fill" class="size-3" />
|
<Icon name="ri:wallet-3-fill" class="size-3" />
|
||||||
Funds Blocked
|
Funds Blocked
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</InputWrapper>
|
</InputWrapper>
|
||||||
|
|
||||||
<InputText
|
<InputText
|
||||||
label="Order ID"
|
label="Order ID"
|
||||||
name="orderId"
|
name="orderId"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
maxlength: 100,
|
maxlength: COMMENT_ORDER_ID_MAX_LENGTH,
|
||||||
placeholder: 'Order ID / URL / Proof',
|
placeholder: 'Order ID / URL / Proof',
|
||||||
class: 'bg-night-800',
|
class: 'bg-night-800',
|
||||||
}}
|
required: strictCommentingEnabled,
|
||||||
descriptionLabel="Only visible to admins, to verify your comment"
|
}}
|
||||||
class="grow"
|
descriptionLabel="Only visible to admins, to verify your comment"
|
||||||
/>
|
class="grow"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-start justify-end gap-2">
|
<div class="mt-4 flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
|
||||||
{!!activeRatingComment?.rating && (
|
{!!activeRatingComment?.rating && (
|
||||||
<div
|
<div
|
||||||
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
class="mt-1 rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
||||||
data-show-if-rating
|
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"
|
|
||||||
>
|
>
|
||||||
Your previous rating
|
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
|
||||||
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
<a
|
||||||
</a>
|
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
|
||||||
of
|
class="inline-flex items-center gap-1 underline"
|
||||||
{[
|
target="_blank"
|
||||||
activeRatingComment.rating.toLocaleString(),
|
rel="noopener noreferrer"
|
||||||
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
|
>
|
||||||
]}
|
Your previous rating
|
||||||
won't count for the total.
|
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
||||||
</div>
|
</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>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<Button type="submit" label="Reply" icon="ri:reply-line" />
|
<Button type="submit" label="Reply" icon="ri:reply-line" />
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type Props = {
|
|||||||
name: true
|
name: true
|
||||||
description: true
|
description: true
|
||||||
createdAt: true
|
createdAt: true
|
||||||
|
strictCommentingEnabled: true
|
||||||
|
commentSectionMessage: true
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@@ -71,12 +73,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
|
|||||||
'Failed to fetch comments',
|
'Failed to fetch comments',
|
||||||
async () =>
|
async () =>
|
||||||
await prisma.comment.findMany(
|
await prisma.comment.findMany(
|
||||||
makeCommentsNestedQuery({
|
await makeCommentsNestedQuery({
|
||||||
depth: MAX_COMMENT_DEPTH,
|
depth: MAX_COMMENT_DEPTH,
|
||||||
user,
|
user,
|
||||||
showPending: params.showPending,
|
showPending: params.showPending,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
sort: params.sort,
|
sort: params.sort,
|
||||||
|
highlightedCommentId: params.comment,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
@@ -172,7 +175,13 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
|||||||
comment: comments.map(makeReplySchema),
|
comment: comments.map(makeReplySchema),
|
||||||
} as WithContext<DiscussionForumPosting>}
|
} 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="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -257,6 +266,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
|||||||
showPending={params.showPending}
|
showPending={params.showPending}
|
||||||
serviceSlug={service.slug}
|
serviceSlug={service.slug}
|
||||||
itemReviewedId={itemReviewedId}
|
itemReviewedId={itemReviewedId}
|
||||||
|
strictCommentingEnabled={service.strictCommentingEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
|
||||||
const user = Astro.locals.user
|
const user = Astro.locals.user
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import { sample } from 'lodash-es'
|
import { sample } from 'lodash-es'
|
||||||
|
|
||||||
import { splashTexts } from '../constants/splashTexts'
|
import { splashTexts } from '../constants/splashTexts'
|
||||||
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
|
||||||
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
||||||
|
|
||||||
import AdminOnly from './AdminOnly.astro'
|
import AdminOnly from './AdminOnly.astro'
|
||||||
@@ -37,6 +37,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
transition:name="header-container"
|
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)}>
|
<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">
|
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as htmx from 'htmx.org'
|
import htmx from 'htmx.org'
|
||||||
|
|
||||||
htmx.config.globalViewTransitions = false
|
htmx.config.globalViewTransitions = false
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
|
|||||||
iconClass?: string
|
iconClass?: string
|
||||||
description?: MarkdownString
|
description?: MarkdownString
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
noTransitionPersist?: boolean
|
|
||||||
}[]
|
}[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
selectedValue?: Multiple extends true ? string[] : string
|
selectedValue?: Multiple extends true ? string[] : string
|
||||||
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
transition:persist
|
||||||
type={multiple ? 'checkbox' : 'radio'}
|
type={multiple ? 'checkbox' : 'radio'}
|
||||||
name={wrapperProps.name}
|
name={wrapperProps.name}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { ComponentProps } from 'astro/types'
|
|||||||
|
|
||||||
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
|
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
checked?: boolean
|
||||||
|
descriptionInline?: string
|
||||||
id?: 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
|
const hasError = !!error && error.length > 0
|
||||||
---
|
---
|
||||||
|
|
||||||
{}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -41,9 +41,11 @@ const hasError = !!error && error.length > 0
|
|||||||
name={name}
|
name={name}
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
checked={checked}
|
||||||
class={cn(disabled && 'opacity-50')}
|
class={cn(disabled && 'opacity-50')}
|
||||||
/>
|
/>
|
||||||
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
|
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
|
||||||
|
{descriptionInline && <p class="text-day-400 text-xs">{descriptionInline}</p>}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,14 +4,17 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
|
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) => {
|
document.addEventListener('sse:new-notification', (event) => {
|
||||||
if (isBrowserNotificationsEnabled()) {
|
if (isBrowserNotificationsEnabled()) {
|
||||||
const payload = event.detail
|
const payload = event.detail
|
||||||
const notification = showBrowserNotification(
|
const notification = showBrowserNotification(
|
||||||
payload.title,
|
makeBrowserNotificationTitle(payload.title),
|
||||||
makeNotificationOptions(payload, { removeActions: true })
|
makeBrowserNotificationOptions(payload, { removeActions: true })
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle notification click
|
// Handle notification click
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import defaultOGImage from '../assets/ogimage.png'
|
|||||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||||
import { urlWithParams } from '../lib/urls'
|
import { urlWithParams } from '../lib/urls'
|
||||||
|
|
||||||
|
import type { VerificationStatus } from '@prisma/client'
|
||||||
import type { APIContext } from 'astro'
|
import type { APIContext } from 'astro'
|
||||||
import type { Prettify } from 'ts-essentials'
|
import type { Prettify } from 'ts-essentials'
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ export const ogImageTemplates = {
|
|||||||
categories,
|
categories,
|
||||||
score,
|
score,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
verificationStatus,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -116,6 +118,7 @@ export const ogImageTemplates = {
|
|||||||
}[]
|
}[]
|
||||||
score: number
|
score: number
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
|
verificationStatus: VerificationStatus | null
|
||||||
},
|
},
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
@@ -272,6 +275,37 @@ export const ogImageTemplates = {
|
|||||||
>
|
>
|
||||||
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 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-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 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-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-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-7zm-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-1z" />
|
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 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-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 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-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-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-7zm-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-1z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{verificationStatus === 'VERIFICATION_FAILED' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
transform: 'rotate(-20deg)',
|
||||||
|
fontSize: 200,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'red',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
boxShadow: '0 0 15px 30px rgba(0, 0, 0, 0.5)',
|
||||||
|
border: '15px solid red',
|
||||||
|
borderRadius: 15,
|
||||||
|
padding: '10px 50px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SCAM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
defaultOptions
|
defaultOptions
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
endpoint: true
|
endpoint: true
|
||||||
userAgent: true
|
|
||||||
}
|
}
|
||||||
}>[]
|
}>[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers'
|
|||||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||||
import { transformCase } from '../lib/strings'
|
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'
|
import type { Review, WithContext } from 'schema-dts'
|
||||||
|
|
||||||
export type Props = HTMLAttributes<'div'> & {
|
type Props = Polymorphic<{
|
||||||
|
as: HTMLTag
|
||||||
score: number
|
score: number
|
||||||
label: string
|
label: string
|
||||||
total?: number
|
total?: number
|
||||||
itemReviewedId?: string
|
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)
|
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}
|
{...htmlProps}
|
||||||
class={cn(
|
class={cn(
|
||||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="group"
|
role={htmlProps.role ?? 'group'}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
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}
|
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
||||||
class="stroke-night-700"></path>
|
class="stroke-night-700"></path>
|
||||||
|
|
||||||
<!-- Info icon -->
|
{
|
||||||
<!-- <path
|
showInfo && (
|
||||||
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"
|
<path
|
||||||
fill="white"
|
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-opacity="0.67"></path> -->
|
class="text-current/60"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Tag>
|
||||||
|
|||||||
@@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore'
|
|||||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||||
import { transformCase } from '../lib/strings'
|
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
|
score: number
|
||||||
label: string
|
label: string
|
||||||
total?: number
|
total?: number
|
||||||
itemReviewedId?: string
|
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)
|
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<Tag
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
class={cn(
|
class={cn(
|
||||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="group"
|
role={htmlProps.role ?? 'group'}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
!!itemReviewedId && (
|
!!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"
|
showInfo && (
|
||||||
viewBox="0 0 12 12"
|
<svg
|
||||||
fill="currentColor"
|
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||||
>
|
viewBox="0 0 12 12"
|
||||||
<path
|
fill="currentColor"
|
||||||
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>
|
<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> -->
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -77,4 +91,4 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
<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) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
// NOTE: Disable sse: events when user is not logged in
|
||||||
|
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||||
|
stopServerEventsListener()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data as string)
|
const data = JSON.parse(event.data as string)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
|||||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||||
import { transformCase } from '../lib/strings'
|
|
||||||
|
|
||||||
import MyPicture from './MyPicture.astro'
|
import MyPicture from './MyPicture.astro'
|
||||||
import Tooltip from './Tooltip.astro'
|
import Tooltip from './Tooltip.astro'
|
||||||
@@ -14,7 +13,7 @@ import Tooltip from './Tooltip.astro'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
type Props = HTMLAttributes<'a'> & {
|
type Props = HTMLAttributes<'article'> & {
|
||||||
inlineIcons?: boolean
|
inlineIcons?: boolean
|
||||||
withoutLink?: boolean
|
withoutLink?: boolean
|
||||||
service: Prisma.ServiceGetPayload<{
|
service: Prisma.ServiceGetPayload<{
|
||||||
@@ -23,6 +22,8 @@ type Props = HTMLAttributes<'a'> & {
|
|||||||
slug: true
|
slug: true
|
||||||
description: true
|
description: true
|
||||||
overallScore: true
|
overallScore: true
|
||||||
|
privacyScore: true
|
||||||
|
trustScore: true
|
||||||
kycLevel: true
|
kycLevel: true
|
||||||
imageUrl: true
|
imageUrl: true
|
||||||
verificationStatus: true
|
verificationStatus: true
|
||||||
@@ -45,6 +46,8 @@ const {
|
|||||||
slug,
|
slug,
|
||||||
description,
|
description,
|
||||||
overallScore,
|
overallScore,
|
||||||
|
privacyScore,
|
||||||
|
trustScore,
|
||||||
kycLevel,
|
kycLevel,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
categories,
|
categories,
|
||||||
@@ -54,7 +57,7 @@ const {
|
|||||||
},
|
},
|
||||||
class: className,
|
class: className,
|
||||||
withoutLink = false,
|
withoutLink = false,
|
||||||
...aProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
const statusIcon = {
|
const statusIcon = {
|
||||||
@@ -67,127 +70,129 @@ const Element = withoutLink ? 'div' : 'a'
|
|||||||
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||||
---
|
---
|
||||||
|
|
||||||
<Element
|
<article {...htmlProps}>
|
||||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
<Element
|
||||||
{...aProps}
|
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||||
class={cn(
|
aria-label={Element === 'a' ? name : undefined}
|
||||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
class={cn(
|
||||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
className
|
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||||
)}
|
className
|
||||||
>
|
)}
|
||||||
<!-- Header with Icon and Title -->
|
>
|
||||||
<div class="flex items-center gap-(--gap)">
|
<!-- Header with Icon and Title -->
|
||||||
<MyPicture
|
<div class="flex items-center gap-(--gap)">
|
||||||
src={imageUrl}
|
<MyPicture
|
||||||
fallback="service"
|
src={imageUrl}
|
||||||
alt={name || 'Service logo'}
|
fallback="service"
|
||||||
class={cn(
|
alt="Logo"
|
||||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
class={cn(
|
||||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
)}
|
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||||
width={48}
|
)}
|
||||||
height={48}
|
width={48}
|
||||||
/>
|
height={48}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
<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">
|
<h1 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||||
{name}{
|
{name}{
|
||||||
statusIcon && (
|
statusIcon && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={statusIcon.label}
|
text={statusIcon.label}
|
||||||
position="right"
|
position="right"
|
||||||
class="-my-2 shrink-0 whitespace-nowrap"
|
class="-my-2 shrink-0 whitespace-nowrap"
|
||||||
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
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
|
<Icon
|
||||||
is:inline={inlineIcons}
|
is:inline={inlineIcons}
|
||||||
name={statusIcon.icon}
|
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||||
class={cn(
|
class={cn(
|
||||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||||
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||||
statusIcon.classNames.icon
|
|
||||||
)}
|
)}
|
||||||
/>,
|
/>
|
||||||
verificationStatus === 'VERIFICATION_FAILED' && (
|
</Tooltip>
|
||||||
<span class="text-sm font-bold text-red-500">SCAM</span>
|
)
|
||||||
),
|
}
|
||||||
]}
|
</h1>
|
||||||
</Tooltip>
|
<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)]">
|
||||||
}{
|
{
|
||||||
serviceVisibility === 'ARCHIVED' && (
|
categories.map((category) => (
|
||||||
<Tooltip
|
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||||
position="right"
|
<span>{category.name}</span>
|
||||||
class="-my-2 shrink-0 whitespace-nowrap"
|
</span>
|
||||||
>
|
))
|
||||||
<Icon
|
}
|
||||||
is:inline={inlineIcons}
|
</div>
|
||||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
</div>
|
||||||
class={cn(
|
</div>
|
||||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
|
||||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
<div class="flex-1">
|
||||||
)}
|
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||||
/>
|
{description}
|
||||||
</Tooltip>
|
</p>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
</h3>
|
<div class="flex items-center justify-start">
|
||||||
<div class="max-h-2 flex-1"></div>
|
<Tooltip
|
||||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
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) => (
|
currencies.map((currency) => {
|
||||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
|
||||||
<span>{category.name}</span>
|
return (
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</Element>
|
||||||
|
</article>
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ type Props = HTMLAttributes<'a'> & {
|
|||||||
searchParamValue?: string
|
searchParamValue?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
iconClass?: string
|
iconClass?: string
|
||||||
|
inlineIcons?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, searchParamName, searchParamValue, icon, iconClass, class: className, ...aProps } = Astro.props
|
const {
|
||||||
|
text,
|
||||||
|
searchParamName,
|
||||||
|
searchParamValue,
|
||||||
|
icon,
|
||||||
|
iconClass,
|
||||||
|
inlineIcons = true,
|
||||||
|
class: className,
|
||||||
|
...aProps
|
||||||
|
} = Astro.props
|
||||||
|
|
||||||
const makeUrlWithoutFilter = (filter: string, value?: string) => {
|
const makeUrlWithoutFilter = (filter: string, value?: string) => {
|
||||||
const url = new URL(Astro.url)
|
const url = new URL(Astro.url)
|
||||||
@@ -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',
|
'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
|
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}
|
{text}
|
||||||
<Icon name="ri:close-large-line" class="text-day-400 size-4" />
|
<Icon name="ri:close-large-line" class="text-day-400 size-4" is:inline={inlineIcons} />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
200
web/src/components/ServiceFiltersPillsRow.astro
Normal file
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>
|
||||||
@@ -7,13 +7,21 @@ import { cn } from '../lib/cn'
|
|||||||
|
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
type Props = Omit<HTMLAttributes<'a'>, 'href' | 'rel' | 'target'> & {
|
type Props = Omit<HTMLAttributes<'a' | 'span'>, 'href' | 'rel' | 'target'> & {
|
||||||
url: string
|
url: string
|
||||||
referral: string | null
|
referral: string | null
|
||||||
enableMinWidth?: boolean
|
enableMinWidth?: boolean
|
||||||
|
isScam?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url: baseUrl, referral, class: className, enableMinWidth = false, ...htmlProps } = Astro.props
|
const {
|
||||||
|
url: baseUrl,
|
||||||
|
referral,
|
||||||
|
class: className,
|
||||||
|
enableMinWidth = false,
|
||||||
|
isScam = false,
|
||||||
|
...htmlProps
|
||||||
|
} = Astro.props
|
||||||
|
|
||||||
function makeLink(url: string, referral: string | null) {
|
function makeLink(url: string, referral: string | null) {
|
||||||
const hostname = new URL(url).hostname
|
const hostname = new URL(url).hostname
|
||||||
@@ -124,28 +132,39 @@ const link = makeLink(baseUrl, referral)
|
|||||||
if (!z.string().url().safeParse(link.url).success) {
|
if (!z.string().url().safeParse(link.url).success) {
|
||||||
console.error(`Invalid service URL with referral: ${link.url}`)
|
console.error(`Invalid service URL with referral: ${link.url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Tag = isScam ? 'span' : 'a'
|
||||||
---
|
---
|
||||||
|
|
||||||
<a
|
<Tag
|
||||||
href={link.url}
|
href={isScam ? undefined : link.url}
|
||||||
target="_blank"
|
target={isScam ? undefined : '_blank'}
|
||||||
rel="noopener noreferrer"
|
rel={isScam ? undefined : 'noopener noreferrer'}
|
||||||
class={cn(
|
class={cn(
|
||||||
'2xs:text-sm 2xs:h-8 2xs:gap-2 focus-visible:ring-offset-night-700 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black focus-visible:ring-4 focus-visible:ring-orange-500 focus-visible:ring-offset-2 focus-visible:outline-none',
|
'2xs:text-sm 2xs:h-8 2xs:gap-2 focus-visible:ring-offset-night-700 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black focus-visible:ring-4 focus-visible:ring-orange-500 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||||
|
isScam && 'bg-day-800 cursor-not-allowed text-red-300',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
title={link.url}
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
>
|
>
|
||||||
<Icon name={link.icon} class="2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4" />
|
<Icon
|
||||||
|
name={isScam ? 'ri:alert-line' : link.icon}
|
||||||
|
class={cn('2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4', isScam && 'text-red-400')}
|
||||||
|
/>
|
||||||
<span class={cn('font-title font-bold', { 'min-w-[29ch]': enableMinWidth })}>
|
<span class={cn('font-title font-bold', { 'min-w-[29ch]': enableMinWidth })}>
|
||||||
{
|
{
|
||||||
link.textBits.map((textBit) => (
|
link.textBits.map((textBit) => (
|
||||||
<span class={cn(textBit.style === 'irrelevant' && 'text-zinc-500')}>{textBit.text}</span>
|
<span class={cn(textBit.style === 'irrelevant' && 'opacity-60')}>{textBit.text}</span>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<Icon
|
{
|
||||||
name="ri:arrow-right-line"
|
!isScam && (
|
||||||
class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white"
|
<Icon
|
||||||
/>
|
name="ri:arrow-right-line"
|
||||||
</a>
|
class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Tag>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { registerSW } from 'virtual:pwa-register'
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
|
||||||
|
|
||||||
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
|
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
|
|
||||||
function shouldSkipAutoReload() {
|
function shouldSkipAutoReload() {
|
||||||
const currentPath = window.location.pathname
|
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)
|
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
|
||||||
}
|
}
|
||||||
@@ -48,4 +49,11 @@
|
|||||||
window.addEventListener('beforeinstallprompt', (event) => {
|
window.addEventListener('beforeinstallprompt', (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
document.addEventListener('astro:page-load', async () => {
|
||||||
|
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||||
|
await unsubscribeFromPushNotifications()
|
||||||
|
window.__SW_REGISTRATION__?.unregister()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
|
||||||
import { kycLevels } from '../constants/kycLevels'
|
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
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 Button from './Button.astro'
|
||||||
import PillsRadioGroup from './PillsRadioGroup.astro'
|
import PillsRadioGroup from './PillsRadioGroup.astro'
|
||||||
import Tooltip from './Tooltip.astro'
|
import Tooltip from './Tooltip.astro'
|
||||||
|
|
||||||
|
import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions'
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
export type Props = HTMLAttributes<'form'> & {
|
export type Props = HTMLAttributes<'form'> & {
|
||||||
@@ -111,7 +111,7 @@ const {
|
|||||||
<legend class="font-title mb-3 leading-none text-green-500">Type</legend>
|
<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">
|
<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}>
|
<li data-show-always={category.showAlways ? '' : undefined}>
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||||
<input
|
<input
|
||||||
@@ -252,7 +252,7 @@ const {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
|
<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">
|
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||||
{level.value}
|
{level.value}
|
||||||
<Icon name={level.icon} class="ms-1 size-3 shrink-0" />
|
<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">
|
<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">
|
<fieldset class="relative flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
|
||||||
<legend class="sr-only">
|
<legend class="sr-only">
|
||||||
{attribute.title} ({attribute._count?.services})
|
{attribute.title} ({attribute._count.services})
|
||||||
</legend>
|
</legend>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -414,10 +414,13 @@ const {
|
|||||||
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
|
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.typeInfo.classNames.icon)}
|
||||||
aria-hidden="true"
|
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}
|
{attribute.title}
|
||||||
</span>
|
</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>
|
||||||
<label
|
<label
|
||||||
for={emptyId}
|
for={emptyId}
|
||||||
@@ -429,10 +432,13 @@ const {
|
|||||||
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
|
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.typeInfo.classNames.icon)}
|
||||||
aria-hidden="true"
|
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}
|
{attribute.title}
|
||||||
</span>
|
</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>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { uniq } from 'lodash-es'
|
import { uniq, orderBy } from 'lodash-es'
|
||||||
|
|
||||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
import { getCurrencyInfo } from '../constants/currencies'
|
||||||
|
import { verificationStatusesByValue, getVerificationStatusInfo } from '../constants/verificationStatus'
|
||||||
|
import { areEqualArraysWithoutOrder } from '../lib/arrays'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { pluralize } from '../lib/pluralize'
|
import { pluralize } from '../lib/pluralize'
|
||||||
|
import { transformCase } from '../lib/strings'
|
||||||
import { createPageUrl, urlWithParams } from '../lib/urls'
|
import { createPageUrl, urlWithParams } from '../lib/urls'
|
||||||
|
|
||||||
import Button from './Button.astro'
|
import Button from './Button.astro'
|
||||||
import ServiceCard from './ServiceCard.astro'
|
import ServiceCard from './ServiceCard.astro'
|
||||||
|
import ServiceFiltersPillsRow from './ServiceFiltersPillsRow.astro'
|
||||||
|
|
||||||
import type { ServicesFiltersObject } from '../pages/index.astro'
|
import type { 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'
|
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
type Props = HTMLAttributes<'div'> & {
|
type Props = HTMLAttributes<'div'> & {
|
||||||
@@ -23,6 +29,22 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
filters: ServicesFiltersObject
|
filters: ServicesFiltersObject
|
||||||
countCommunityOnly: number | null
|
countCommunityOnly: number | null
|
||||||
inlineIcons?: boolean
|
inlineIcons?: boolean
|
||||||
|
filtersOptions: ServicesFiltersOptions
|
||||||
|
categories: Prisma.CategoryGetPayload<{
|
||||||
|
select: {
|
||||||
|
name: true
|
||||||
|
namePluralLong: true
|
||||||
|
slug: true
|
||||||
|
icon: true
|
||||||
|
}
|
||||||
|
}>[]
|
||||||
|
attributes: Prisma.AttributeGetPayload<{
|
||||||
|
select: {
|
||||||
|
title: true
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
}>[]
|
||||||
|
attributeOptions: AttributeOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -36,6 +58,10 @@ const {
|
|||||||
filters,
|
filters,
|
||||||
countCommunityOnly,
|
countCommunityOnly,
|
||||||
inlineIcons,
|
inlineIcons,
|
||||||
|
categories,
|
||||||
|
filtersOptions,
|
||||||
|
attributes,
|
||||||
|
attributeOptions,
|
||||||
...divProps
|
...divProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
@@ -55,9 +81,118 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug,
|
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">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-day-500 xs:gap-x-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm sm:gap-x-6">
|
<span class="text-day-500 xs:gap-x-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm sm:gap-x-6">
|
||||||
{total.toLocaleString()}
|
{total.toLocaleString()}
|
||||||
@@ -67,6 +202,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
name="ri:loader-4-line"
|
name="ri:loader-4-line"
|
||||||
id="search-indicator"
|
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"
|
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}
|
is:inline={inlineIcons}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -212,11 +348,9 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
</div>
|
</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) => (
|
{services.map((service, i) => (
|
||||||
<ServiceCard
|
<li
|
||||||
inlineIcons={inlineIcons}
|
|
||||||
service={service}
|
|
||||||
data-hx-search-results-card
|
data-hx-search-results-card
|
||||||
{...(i === services.length - 1 && currentPage < totalPages
|
{...(i === services.length - 1 && currentPage < totalPages
|
||||||
? {
|
? {
|
||||||
@@ -227,9 +361,11 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
'hx-indicator': '#infinite-scroll-indicator',
|
'hx-indicator': '#infinite-scroll-indicator',
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
>
|
||||||
|
<ServiceCard inlineIcons={inlineIcons} service={service} />
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ol>
|
||||||
|
|
||||||
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
|
<div class="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">
|
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const {
|
|||||||
enabled && (
|
enabled && (
|
||||||
<span
|
<span
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
class={cn(
|
class={cn(
|
||||||
'pointer-events-none hidden select-none group-hover/tooltip:flex',
|
'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',
|
'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,24 +1,21 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { differenceInDays, isPast } from 'date-fns'
|
|
||||||
|
|
||||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||||
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
|
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
|
import { formatDaysAgo } from '../lib/timeAgo'
|
||||||
import TimeFormatted from './TimeFormatted.astro'
|
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
const RECENTLY_ADDED_DAYS = 7
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
service: Prisma.ServiceGetPayload<{
|
service: Prisma.ServiceGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
verificationStatus: true
|
verificationStatus: true
|
||||||
verificationProofMd: true
|
verificationProofMd: true
|
||||||
verificationSummary: true
|
verificationSummary: true
|
||||||
listedAt: true
|
approvedAt: true
|
||||||
|
isRecentlyApproved: true
|
||||||
createdAt: true
|
createdAt: true
|
||||||
verificationSteps: {
|
verificationSteps: {
|
||||||
select: {
|
select: {
|
||||||
@@ -30,9 +27,6 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { service } = Astro.props
|
const { service } = Astro.props
|
||||||
|
|
||||||
const listedDate = service.listedAt ?? service.createdAt
|
|
||||||
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
|
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -58,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">
|
<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" />
|
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
|
||||||
<span>
|
<span>
|
||||||
Community-contributed. Information not reviewed.
|
Community contributed. Information not reviewed.
|
||||||
<a
|
<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"
|
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : wasRecentlyAdded ? (
|
) : service.isRecentlyApproved ? (
|
||||||
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
|
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
|
||||||
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
|
This service was approved
|
||||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
{service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
|
||||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||||
caution.
|
caution.
|
||||||
<a
|
<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"
|
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
@@ -84,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">
|
<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" />
|
<Icon name="ri:information-line" class="size-5 text-blue-400" />
|
||||||
<span>
|
<span>
|
||||||
Basic checks passed, but not fully verified.
|
Basic checks passed, but service is not verified.
|
||||||
<a
|
<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"
|
class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
76
web/src/constants/countries.ts
Normal file
76
web/src/constants/countries.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { countries as countriesData, type TCountryCode } from 'countries-list'
|
||||||
|
import { countries as flagCountries } from 'country-flag-icons'
|
||||||
|
import getUnicodeFlagIcon from 'country-flag-icons/unicode'
|
||||||
|
|
||||||
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
|
|
||||||
|
type CountryInfo<T extends string | null | undefined = string> = {
|
||||||
|
code: T
|
||||||
|
name: string
|
||||||
|
flag: string
|
||||||
|
slug: string
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert countries-list data to our format, ensuring we only use countries that have flags
|
||||||
|
const countriesArray = Object.entries(countriesData)
|
||||||
|
.filter(([code]) => flagCountries.includes(code as TCountryCode))
|
||||||
|
.map(([code, data]) => ({
|
||||||
|
code: code as TCountryCode,
|
||||||
|
name: data.name,
|
||||||
|
flag: getUnicodeFlagIcon(code) || '🏳️',
|
||||||
|
slug: code.toLowerCase(),
|
||||||
|
order: data.name.charCodeAt(0), // Sort alphabetically by first letter
|
||||||
|
}))
|
||||||
|
// Pre-sort the array alphabetically by name for performance
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
// Create a map for efficient lookups
|
||||||
|
const countriesMap = new Map(countriesArray.map((country) => [country.code, country]))
|
||||||
|
|
||||||
|
export const {
|
||||||
|
dataArray: countries,
|
||||||
|
dataObject: countriesById,
|
||||||
|
getFn: getCountryInfo,
|
||||||
|
getFnSlug: getCountryInfoBySlug,
|
||||||
|
zodEnumBySlug: countriesZodEnumBySlug,
|
||||||
|
zodEnumById: countriesZodEnumById,
|
||||||
|
keyToSlug: countryCodeToSlug,
|
||||||
|
slugToKey: countrySlugToCode,
|
||||||
|
} = makeHelpersForOptions(
|
||||||
|
'code',
|
||||||
|
(code): CountryInfo<typeof code> => {
|
||||||
|
// For null, undefined, or empty string, return a default "No Country" object
|
||||||
|
if (!code) {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
name: 'No Country',
|
||||||
|
flag: '🏳️',
|
||||||
|
slug: '',
|
||||||
|
order: 999,
|
||||||
|
} as CountryInfo<typeof code>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the country in our pre-built map
|
||||||
|
const country = countriesMap.get(code as TCountryCode)
|
||||||
|
|
||||||
|
// If found, return it; otherwise, return a default "Unknown Country" object
|
||||||
|
if (country) {
|
||||||
|
return country as CountryInfo
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
name: 'Unknown Country',
|
||||||
|
flag: '🏳️',
|
||||||
|
slug: code.toLowerCase(),
|
||||||
|
order: 999,
|
||||||
|
} as CountryInfo
|
||||||
|
}
|
||||||
|
},
|
||||||
|
countriesArray
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function to validate country code
|
||||||
|
export const isValidCountryCode = (code: string): code is TCountryCode => {
|
||||||
|
return code in countriesData && flagCountries.includes(code as TCountryCode)
|
||||||
|
}
|
||||||
@@ -76,5 +76,25 @@ export const {
|
|||||||
label: 'Service verification changed',
|
label: 'Service verification changed',
|
||||||
icon: 'ri:verified-badge-line',
|
icon: 'ri:verified-badge-line',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'ACCOUNT_DELETION_WARNING_30_DAYS',
|
||||||
|
label: 'Account deletion warning - 30 days',
|
||||||
|
icon: 'ri:alarm-warning-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ACCOUNT_DELETION_WARNING_15_DAYS',
|
||||||
|
label: 'Account deletion warning - 15 days',
|
||||||
|
icon: 'ri:alarm-warning-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ACCOUNT_DELETION_WARNING_5_DAYS',
|
||||||
|
label: 'Account deletion warning - 5 days',
|
||||||
|
icon: 'ri:alarm-warning-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ACCOUNT_DELETION_WARNING_1_DAY',
|
||||||
|
label: 'Account deletion warning - 1 day',
|
||||||
|
icon: 'ri:alarm-warning-line',
|
||||||
|
},
|
||||||
] as const satisfies NotificationTypeInfo<NotificationType>[]
|
] as const satisfies NotificationTypeInfo<NotificationType>[]
|
||||||
)
|
)
|
||||||
|
|||||||
33
web/src/constants/readStatus.ts
Normal file
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 =
|
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 {
|
export const {
|
||||||
dataArray: verificationStatuses,
|
dataArray: verificationStatuses,
|
||||||
|
|||||||
2
web/src/env.d.ts
vendored
2
web/src/env.d.ts
vendored
@@ -3,7 +3,7 @@
|
|||||||
import type { ErrorBanners } from './lib/errorBanners'
|
import type { ErrorBanners } from './lib/errorBanners'
|
||||||
import type { KarmaUnlocks } from './lib/karmaUnlocks'
|
import type { KarmaUnlocks } from './lib/karmaUnlocks'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type * as htmx from 'htmx.org'
|
import type htmx from 'htmx.org'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
---
|
---
|
||||||
|
import { differenceInCalendarDays } from 'date-fns'
|
||||||
|
|
||||||
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
|
import AnnouncementBanner from '../components/AnnouncementBanner.astro'
|
||||||
import BaseHead from '../components/BaseHead.astro'
|
import BaseHead from '../components/BaseHead.astro'
|
||||||
import Footer from '../components/Footer.astro'
|
import Footer from '../components/Footer.astro'
|
||||||
import Header from '../components/Header.astro'
|
import Header from '../components/Header.astro'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
|
import { pluralize } from '../lib/pluralize'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
|
||||||
import type { AstroChildren } from '../lib/astro'
|
import type { AstroChildren } from '../lib/astro'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { ComponentProps } from 'astro/types'
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
import '@fontsource-variable/space-grotesk'
|
import '@fontsource-variable/space-grotesk'
|
||||||
@@ -71,6 +75,26 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
}),
|
}),
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function getDeletionAnnouncement(
|
||||||
|
user: Prisma.UserGetPayload<{ select: { scheduledDeletionAt: true } }> | null,
|
||||||
|
currentDate: Date = new Date()
|
||||||
|
) {
|
||||||
|
if (!user?.scheduledDeletionAt) return null
|
||||||
|
const daysUntilDeletion = differenceInCalendarDays(user.scheduledDeletionAt, currentDate)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
content: `Your account will be deleted ${daysUntilDeletion <= 0 ? 'today' : `in ${daysUntilDeletion.toLocaleString()} ${pluralize('day', daysUntilDeletion)}`} due to inactivity.`,
|
||||||
|
type: 'ALERT' as const,
|
||||||
|
link: '/account',
|
||||||
|
linkText: 'Prevent deletion',
|
||||||
|
startDate: currentDate,
|
||||||
|
endDate: null,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deletionAnnouncement = getDeletionAnnouncement(Astro.locals.user, currentDate)
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en" transition:name="root" transition:animate="none">
|
<html lang="en" transition:name="root" transition:animate="none">
|
||||||
@@ -81,9 +105,18 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.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" />}
|
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||||
|
{
|
||||||
|
deletionAnnouncement && (
|
||||||
|
<AnnouncementBanner
|
||||||
|
announcement={deletionAnnouncement}
|
||||||
|
transition:name="deletion-warning-announcement"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
<Header
|
<Header
|
||||||
classNames={{
|
classNames={{
|
||||||
nav: cn(
|
nav: cn(
|
||||||
|
|||||||
@@ -58,9 +58,13 @@ const ogImageTemplateData = {
|
|||||||
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
|
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
|
||||||
>
|
>
|
||||||
<h1 class="mb-0!">{frontmatter.title}</h1>
|
<h1 class="mb-0!">{frontmatter.title}</h1>
|
||||||
<p class="mt-2! opacity-70">
|
{
|
||||||
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />}
|
!!frontmatter.updatedAt && (
|
||||||
</p>
|
<p class="mt-2! opacity-70">
|
||||||
|
Updated <TimeFormatted date={new Date(frontmatter.updatedAt)} />
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
import { differenceInMonths, differenceInYears } from 'date-fns'
|
||||||
|
import he from 'he'
|
||||||
import { orderBy } from 'lodash-es'
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||||
|
import { getCountryInfo } from '../constants/countries'
|
||||||
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
|
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
|
||||||
import { kycLevels } from '../constants/kycLevels'
|
import { kycLevels } from '../constants/kycLevels'
|
||||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||||
|
|
||||||
import { formatDateShort } from './timeAgo'
|
import { formatDaysAgo } from './timeAgo'
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
@@ -35,8 +38,8 @@ type NonDbAttributeFull = NonDbAttribute & {
|
|||||||
select: {
|
select: {
|
||||||
verificationStatus: true
|
verificationStatus: true
|
||||||
serviceVisibility: true
|
serviceVisibility: true
|
||||||
isRecentlyListed: true
|
isRecentlyApproved: true
|
||||||
listedAt: true
|
approvedAt: true
|
||||||
createdAt: true
|
createdAt: true
|
||||||
tosReviewAt: true
|
tosReviewAt: true
|
||||||
tosReview: true
|
tosReview: true
|
||||||
@@ -45,6 +48,9 @@ type NonDbAttributeFull = NonDbAttribute & {
|
|||||||
acceptedCurrencies: true
|
acceptedCurrencies: true
|
||||||
kycLevel: true
|
kycLevel: true
|
||||||
kycLevelClarification: true
|
kycLevelClarification: true
|
||||||
|
operatingSince: true
|
||||||
|
registrationCountryCode: true
|
||||||
|
registeredCompanyName: true
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||||
@@ -189,17 +195,17 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'recently-listed',
|
slug: 'recently-approved',
|
||||||
title: 'Recently listed',
|
title: 'Recently approved',
|
||||||
type: 'WARNING',
|
type: 'WARNING',
|
||||||
category: 'TRUST',
|
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,
|
privacyPoints: 0,
|
||||||
trustPoints: -5,
|
trustPoints: -5,
|
||||||
links: [],
|
links: [],
|
||||||
customize: (service) => ({
|
customize: (service) => ({
|
||||||
show: service.isRecentlyListed,
|
show: service.isRecentlyApproved,
|
||||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
description: `Approved on KYCnot.me less than 15 days ago${service.approvedAt ? ` (${formatDaysAgo(service.approvedAt)})` : ''}. Proceed with caution.`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -217,41 +223,22 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'has-onion-urls',
|
slug: 'has-onion-or-i2p-urls',
|
||||||
title: 'Has Onion URLs',
|
title: 'Has Onion or I2P URLs',
|
||||||
type: 'GOOD',
|
type: 'GOOD',
|
||||||
category: 'PRIVACY',
|
category: 'PRIVACY',
|
||||||
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
|
description: 'Onion (Tor) and I2P URLs enhance privacy and anonymity.',
|
||||||
privacyPoints: 5,
|
privacyPoints: 5,
|
||||||
trustPoints: 0,
|
trustPoints: 0,
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
url: '/?onion=true',
|
url: '/?networks=onion&networks=i2p',
|
||||||
label: 'Search with this',
|
label: 'Search with this',
|
||||||
icon: 'ri:search-line',
|
icon: 'ri:search-line',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
customize: (service) => ({
|
customize: (service) => ({
|
||||||
show: service.onionUrls.length > 0,
|
show: service.onionUrls.length > 0 || service.i2pUrls.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,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -274,6 +261,91 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
|||||||
show: service.acceptedCurrencies.includes('MONERO'),
|
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.`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'legally-registered',
|
||||||
|
title: 'Legally registered',
|
||||||
|
type: 'INFO',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: 'This service is legally registered as a company.',
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: 2,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => {
|
||||||
|
const countryCode = service.registrationCountryCode
|
||||||
|
const companyName = service.registeredCompanyName
|
||||||
|
|
||||||
|
if (!countryCode && !companyName) {
|
||||||
|
return { show: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryInfo = countryCode ? getCountryInfo(countryCode) : null
|
||||||
|
const flagTitle = countryInfo ? `${countryInfo.flag} Legally registered` : 'Legally registered'
|
||||||
|
|
||||||
|
let description = 'Legally registered.'
|
||||||
|
if (companyName && countryCode && countryInfo) {
|
||||||
|
description = `Legally registered as **${he.escape(companyName)}** in **${countryInfo.name}**.`
|
||||||
|
} else if (companyName) {
|
||||||
|
description = `Legally registered as **${he.escape(companyName)}**.`
|
||||||
|
} else if (countryCode && countryInfo) {
|
||||||
|
description = `Legally registered in **${countryInfo.name}**.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
title: flagTitle,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function sortAttributes<
|
export function sortAttributes<
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ export function supportsBrowserNotifications() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isBrowserNotificationsEnabled() {
|
export function isBrowserNotificationsEnabled() {
|
||||||
return (
|
const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
|
||||||
supportsBrowserNotifications() &&
|
if (!browserNotificationsEnabled) return false
|
||||||
Notification.permission === 'granted' &&
|
|
||||||
typedLocalStorage.browserNotificationsEnabled.get()
|
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> {
|
export async function enableBrowserNotifications(): Promise<SafeResult> {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { actions } from 'astro:actions'
|
|||||||
|
|
||||||
type ServerSubscription = {
|
type ServerSubscription = {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
userAgent: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SafeResult =
|
export type SafeResult =
|
||||||
@@ -45,7 +44,6 @@ export async function subscribeToPushNotifications(vapidPublicKey: string): Prom
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
endpoint: subscription.endpoint,
|
endpoint: subscription.endpoint,
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||||
@@ -131,13 +129,7 @@ export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubsc
|
|||||||
const currentSubscription = await getCurrentSubscription()
|
const currentSubscription = await getCurrentSubscription()
|
||||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||||
|
|
||||||
const currentEndpoint = currentSubscription.endpoint
|
return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
|
||||||
const currentUserAgent = navigator.userAgent
|
|
||||||
|
|
||||||
return serverSubscriptions.some(
|
|
||||||
(sub) =>
|
|
||||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlB64ToUint8Array(base64String: string) {
|
function urlB64ToUint8Array(base64String: string) {
|
||||||
@@ -183,5 +175,5 @@ export function parsePushSubscriptions(subscriptionsAsString: string | undefined
|
|||||||
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
|
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
|
||||||
if (typeof subscription !== 'object' || subscription === null) return false
|
if (typeof subscription !== 'object' || subscription === null) return false
|
||||||
const s = subscription as Record<string, unknown>
|
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
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 & {
|
export type CustomNotificationOptions = NotificationOptions & {
|
||||||
actions?: { action: string; title: string; icon?: string }[]
|
actions?: { action: string; title: string; icon?: string }[]
|
||||||
@@ -6,14 +8,24 @@ export type CustomNotificationOptions = NotificationOptions & {
|
|||||||
data: NotificationData
|
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,
|
payload: NotificationPayload | null,
|
||||||
options: { removeActions?: boolean } = {}
|
options: { removeActions?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const defaultOptions: CustomNotificationOptions = {
|
const defaultOptions: CustomNotificationOptions = {
|
||||||
body: 'You have a new notification',
|
body: 'You have a new notification',
|
||||||
lang: 'en-US',
|
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',
|
badge: '/notification-icon.svg',
|
||||||
requireInteraction: false,
|
requireInteraction: false,
|
||||||
silent: false,
|
silent: false,
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
export const MAX_COMMENT_DEPTH = 12
|
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 const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
|
||||||
export type CommentSortOption = z.infer<typeof commentSortSchema>
|
export type CommentSortOption = z.infer<typeof commentSortSchema>
|
||||||
|
|
||||||
export function makeCommentsNestedQuery({
|
export async function makeCommentsNestedQuery({
|
||||||
depth = 0,
|
depth = 0,
|
||||||
user,
|
user,
|
||||||
showPending,
|
showPending,
|
||||||
serviceId,
|
serviceId,
|
||||||
sort,
|
sort,
|
||||||
|
highlightedCommentId,
|
||||||
}: {
|
}: {
|
||||||
depth?: number
|
depth?: number
|
||||||
user: Prisma.UserGetPayload<{
|
user: Prisma.UserGetPayload<{
|
||||||
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
|
|||||||
showPending?: boolean
|
showPending?: boolean
|
||||||
serviceId: number
|
serviceId: number
|
||||||
sort: CommentSortOption
|
sort: CommentSortOption
|
||||||
|
highlightedCommentId?: number | null
|
||||||
}) {
|
}) {
|
||||||
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
|
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
|
||||||
|
|
||||||
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
|
|||||||
}
|
}
|
||||||
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
|
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
|
||||||
|
|
||||||
|
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
|
||||||
|
|
||||||
const baseQuery = {
|
const baseQuery = {
|
||||||
...commentReplyQuery,
|
...commentReplyQuery,
|
||||||
orderBy: orderByClause,
|
orderBy: orderByClause,
|
||||||
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
|
|||||||
: ({
|
: ({
|
||||||
status: { in: ['APPROVED', 'VERIFIED'] },
|
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||||
} as const satisfies Prisma.CommentWhereInput),
|
} as const satisfies Prisma.CommentWhereInput),
|
||||||
|
...(highlightedBranchIds.length > 0
|
||||||
|
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
parentId: null,
|
parentId: null,
|
||||||
serviceId,
|
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({
|
export function makeCommentUrl({
|
||||||
serviceSlug,
|
serviceSlug,
|
||||||
commentId,
|
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 =
|
const service =
|
||||||
(await prisma.service.findFirst({
|
(await prisma.service.findFirst({
|
||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
slug,
|
slug,
|
||||||
},
|
},
|
||||||
@@ -47,7 +46,6 @@ export async function getService(slug: string | undefined): Promise<
|
|||||||
})) ??
|
})) ??
|
||||||
(await prisma.service.findFirst({
|
(await prisma.service.findFirst({
|
||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
previousSlugs: { has: slug },
|
previousSlugs: { has: slug },
|
||||||
},
|
},
|
||||||
@@ -175,7 +173,6 @@ export async function getEvents(): Promise<
|
|||||||
where: {
|
where: {
|
||||||
visible: true,
|
visible: true,
|
||||||
service: {
|
service: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
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
|
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
|
||||||
await redisImpersonationSessions.delete(sessionId)
|
await redisImpersonationSessions.delete(sessionId)
|
||||||
context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
|
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
|
context.locals.actualUser = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,18 @@ export function makeNotificationTitle(
|
|||||||
serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange]
|
serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange]
|
||||||
return `${serviceName} ${statusChange.notificationTitle}`
|
return `${serviceName} ${statusChange.notificationTitle}`
|
||||||
}
|
}
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_30_DAYS': {
|
||||||
|
return 'Account deletion warning - 30 days remaining'
|
||||||
|
}
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_15_DAYS': {
|
||||||
|
return 'Account deletion warning - 15 days remaining'
|
||||||
|
}
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_5_DAYS': {
|
||||||
|
return 'Account deletion warning - 5 days remaining'
|
||||||
|
}
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
|
||||||
|
return 'Account deletion warning - 1 day remaining'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +263,12 @@ export function makeNotificationContent(
|
|||||||
if (!notification.aboutEvent) return null
|
if (!notification.aboutEvent) return null
|
||||||
return notification.aboutEvent.title
|
return notification.aboutEvent.title
|
||||||
}
|
}
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_30_DAYS':
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_15_DAYS':
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_5_DAYS':
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
|
||||||
|
return 'Your account will be deleted due to inactivity. Log in and perform any activity (comment, vote, or create a suggestion) to prevent deletion.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +429,19 @@ export function makeNotificationActions(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_30_DAYS':
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_15_DAYS':
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_5_DAYS':
|
||||||
|
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
action: 'login',
|
||||||
|
title: 'Login & Stay Active',
|
||||||
|
...iconNameAndUrl('ri:login-box-line'),
|
||||||
|
url: `${origin}/login`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const knownPlurals = {
|
|||||||
singular: 'Request',
|
singular: 'Request',
|
||||||
plural: 'Requests',
|
plural: 'Requests',
|
||||||
},
|
},
|
||||||
|
day: {
|
||||||
|
singular: 'Day',
|
||||||
|
plural: 'Days',
|
||||||
|
},
|
||||||
something: {
|
something: {
|
||||||
singular: 'Something',
|
singular: 'Something',
|
||||||
plural: 'Somethings',
|
plural: 'Somethings',
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ function prismaClientSingleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var prisma: ReturnType<typeof prismaClientSingleton> | undefined
|
var prisma: ReturnType<typeof prismaClientSingleton> | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
187
web/src/lib/searchFiltersOptions.ts
Normal file
187
web/src/lib/searchFiltersOptions.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { orderBy, groupBy } from 'lodash-es'
|
||||||
|
|
||||||
|
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||||
|
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||||
|
import { currencies } from '../constants/currencies'
|
||||||
|
import { kycLevels } from '../constants/kycLevels'
|
||||||
|
import { networks } from '../constants/networks'
|
||||||
|
import { verificationStatuses } from '../constants/verificationStatus'
|
||||||
|
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
const MIN_CATEGORIES_TO_SHOW = 8
|
||||||
|
const MIN_ATTRIBUTES_TO_SHOW = 8
|
||||||
|
|
||||||
|
export const sortOptions = [
|
||||||
|
{
|
||||||
|
value: 'score-desc',
|
||||||
|
label: 'Score (High → Low)',
|
||||||
|
orderBy: {
|
||||||
|
key: 'overallScore',
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'score-asc',
|
||||||
|
label: 'Score (Low → High)',
|
||||||
|
orderBy: {
|
||||||
|
key: 'overallScore',
|
||||||
|
direction: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'name-asc',
|
||||||
|
label: 'Name (A → Z)',
|
||||||
|
orderBy: {
|
||||||
|
key: 'name',
|
||||||
|
direction: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'name-desc',
|
||||||
|
label: 'Name (Z → A)',
|
||||||
|
orderBy: {
|
||||||
|
key: 'name',
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'recent',
|
||||||
|
label: 'Date listed (New → Old)',
|
||||||
|
orderBy: {
|
||||||
|
key: 'listedAt',
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'oldest',
|
||||||
|
label: 'Date listed (Old → New)',
|
||||||
|
orderBy: {
|
||||||
|
key: 'listedAt',
|
||||||
|
direction: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const satisfies {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
orderBy: {
|
||||||
|
key: keyof Prisma.ServiceSelect
|
||||||
|
direction: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
|
||||||
|
export const defaultSortOption = sortOptions[0]
|
||||||
|
|
||||||
|
export const modeOptions = [
|
||||||
|
{
|
||||||
|
value: 'or',
|
||||||
|
label: 'OR',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'and',
|
||||||
|
label: 'AND',
|
||||||
|
},
|
||||||
|
] as const satisfies {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}[]
|
||||||
|
|
||||||
|
export function makeSearchFiltersOptions({
|
||||||
|
filters,
|
||||||
|
categories,
|
||||||
|
attributes,
|
||||||
|
}: {
|
||||||
|
filters: {
|
||||||
|
categories: string[]
|
||||||
|
attr: Record<number, '' | 'no' | 'yes'> | undefined
|
||||||
|
} | null
|
||||||
|
categories: Prisma.CategoryGetPayload<{
|
||||||
|
select: {
|
||||||
|
name: true
|
||||||
|
namePluralLong: true
|
||||||
|
slug: true
|
||||||
|
icon: true
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
services: {
|
||||||
|
where: {
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>[]
|
||||||
|
attributes: Prisma.AttributeGetPayload<{
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
slug: true
|
||||||
|
title: true
|
||||||
|
category: true
|
||||||
|
type: true
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
services: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>[]
|
||||||
|
}) {
|
||||||
|
const attributesByCategory = orderBy(
|
||||||
|
Object.entries(
|
||||||
|
groupBy(
|
||||||
|
attributes.map((attr) => {
|
||||||
|
return {
|
||||||
|
typeInfo: getAttributeTypeInfo(attr.type),
|
||||||
|
...attr,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
value: filters?.attr?.[attr.id] || undefined,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'category'
|
||||||
|
)
|
||||||
|
).map(([category, attributes]) => ({
|
||||||
|
category,
|
||||||
|
categoryInfo: getAttributeCategoryInfo(category),
|
||||||
|
attributes: orderBy(
|
||||||
|
attributes,
|
||||||
|
['value', 'type', '_count.services', 'title'],
|
||||||
|
['asc', 'asc', 'desc', 'asc']
|
||||||
|
).map((attr, i) => ({
|
||||||
|
...attr,
|
||||||
|
showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
['category'],
|
||||||
|
['asc']
|
||||||
|
)
|
||||||
|
|
||||||
|
const categoriesSorted = orderBy(
|
||||||
|
categories.map((category) => {
|
||||||
|
const checked = filters?.categories.includes(category.slug) ?? false
|
||||||
|
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
checked,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
['checked', '_count.services', 'name'],
|
||||||
|
['desc', 'desc', 'asc']
|
||||||
|
).map((category, i) => ({
|
||||||
|
...category,
|
||||||
|
showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
currencies,
|
||||||
|
categories: categoriesSorted,
|
||||||
|
sort: sortOptions,
|
||||||
|
modeOptions,
|
||||||
|
network: networks,
|
||||||
|
verification: verificationStatuses,
|
||||||
|
attributesByCategory,
|
||||||
|
kycLevels,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServicesFiltersOptions = ReturnType<typeof makeSearchFiltersOptions>
|
||||||
@@ -20,7 +20,7 @@ export const areSameNormalized = (str1: string, str2: string): boolean => {
|
|||||||
return normalize(str1) === normalize(str2)
|
return normalize(str1) === normalize(str2)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'upper'
|
export type TransformCaseType = 'first-upper' | 'lower' | 'original' | 'sentence' | 'title' | 'upper'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a string to a different case.
|
* Transform a string to a different case.
|
||||||
@@ -31,6 +31,7 @@ export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'u
|
|||||||
* transformCase('hello WORLD', 'sentence') // 'Hello world'
|
* transformCase('hello WORLD', 'sentence') // 'Hello world'
|
||||||
* transformCase('hello WORLD', 'title') // 'Hello World'
|
* transformCase('hello WORLD', 'title') // 'Hello World'
|
||||||
* transformCase('hello WORLD', 'original') // 'hello WORLD'
|
* transformCase('hello WORLD', 'original') // 'hello WORLD'
|
||||||
|
* transformCase('Hello WORLD', 'first-upper') // 'Hello WORLD'
|
||||||
*/
|
*/
|
||||||
export const transformCase = <T extends string, C extends TransformCaseType>(
|
export const transformCase = <T extends string, C extends TransformCaseType>(
|
||||||
str: T,
|
str: T,
|
||||||
@@ -43,7 +44,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
|
|||||||
? Capitalize<Lowercase<T>>
|
? Capitalize<Lowercase<T>>
|
||||||
: C extends 'title'
|
: C extends 'title'
|
||||||
? Capitalize<Lowercase<T>>
|
? Capitalize<Lowercase<T>>
|
||||||
: T => {
|
: C extends 'first-upper'
|
||||||
|
? Capitalize<T>
|
||||||
|
: T => {
|
||||||
switch (caseType) {
|
switch (caseType) {
|
||||||
case 'lower':
|
case 'lower':
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
@@ -54,6 +57,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
|
|||||||
case 'sentence':
|
case 'sentence':
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
return (str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) as any
|
return (str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) as any
|
||||||
|
case 'first-upper':
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return (str.charAt(0).toUpperCase() + str.slice(1)) as any
|
||||||
case 'title':
|
case 'title':
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
return str
|
return str
|
||||||
|
|||||||
@@ -1,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 TimeAgo from 'javascript-time-ago'
|
||||||
import en from 'javascript-time-ago/locale/en'
|
import en from 'javascript-time-ago/locale/en'
|
||||||
|
|
||||||
@@ -47,3 +47,10 @@ export function formatDateShort(
|
|||||||
|
|
||||||
return transformCase(text, caseType)
|
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: '/' })
|
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 stopImpersonating(context)
|
||||||
|
|
||||||
await removeUserSessionIdCookie(context.cookies)
|
await removeUserSessionIdCookie(context.cookies)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '../constants/characters'
|
} from '../constants/characters'
|
||||||
|
|
||||||
import { getRandom, typedJoin } from './arrays'
|
import { getRandom, typedJoin } from './arrays'
|
||||||
import { DEPLOYMENT_MODE } from './envVariables'
|
import { DEPLOYMENT_MODE } from './client/envVariables'
|
||||||
import { transformCase } from './strings'
|
import { transformCase } from './strings'
|
||||||
|
|
||||||
const DIGEST = 'sha512'
|
const DIGEST = 'sha512'
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { LOGS_UI_URL } from 'astro:env/server'
|
|||||||
|
|
||||||
import { SUPPORT_EMAIL } from '../constants/project'
|
import { SUPPORT_EMAIL } from '../constants/project'
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ icon: 'ri:information-line'
|
|||||||
|
|
||||||
import DonationAddress from '../components/DonationAddress.astro'
|
import DonationAddress from '../components/DonationAddress.astro'
|
||||||
|
|
||||||
|
- [What is KYC?](#what-is-kyc)
|
||||||
|
- [Why does this site exist?](#why-does-this-site-exist)
|
||||||
|
- [Why only Bitcoin and Monero?](#why-only-bitcoin-and-monero)
|
||||||
|
- [User Accounts](#user-accounts)
|
||||||
|
- [Verified and Affiliated Users](#verified-and-affiliated-users)
|
||||||
|
- [Listings](#listings)
|
||||||
|
- [Suggesting a new listing](#suggesting-a-new-listing)
|
||||||
|
- [Listing statuses](#listing-statuses)
|
||||||
|
- [Reviews and Comments](#reviews-and-comments)
|
||||||
|
- [Moderation](#moderation)
|
||||||
|
- [API](#api)
|
||||||
|
- [Donate](#support)
|
||||||
|
- [Contact](#contact)
|
||||||
|
- [Downloads and Assets](#downloads-and-assets)
|
||||||
|
|
||||||
## What is this page?
|
## What is this page?
|
||||||
|
|
||||||
KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy.
|
KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy.
|
||||||
@@ -52,9 +67,9 @@ Users earn karma by participating in the community. When your comments get appro
|
|||||||
|
|
||||||
### Verified and Affiliated Users
|
### Verified and Affiliated Users
|
||||||
|
|
||||||
Some users are **verified**, this means that the moderators have confirmed that the user is related to a specific link or service. The verification is directly linked to the URL, ensuring that the person behind the username has a legitimate connection to the service they claim to represent. This verification process is reserved for individuals with established reputation.
|
**Verified users** have proven their identity by linking their account to a specific website. This verification confirms they are legitimate representatives of that site, whether it's a personal website, blog, social media profile, or service page. You can request verification [in your profile](/account).
|
||||||
|
|
||||||
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us.
|
**Affiliated users** are users who represent a service listed in the directory, such as owners, support staff, or team members. If you represent a service and want to become affiliated, you can request it [in your profile](/account).
|
||||||
|
|
||||||
## Listings
|
## Listings
|
||||||
|
|
||||||
@@ -71,12 +86,13 @@ To list a new service, it must fulfill these requirements:
|
|||||||
- Offer a service
|
- Offer a service
|
||||||
- Publicly available website explaining what the service is about
|
- Publicly available website explaining what the service is about
|
||||||
- Terms of service or FAQ document
|
- Terms of service or FAQ document
|
||||||
|
- The service must have been operating for at least 6 months.
|
||||||
|
|
||||||
Examples of non-valid services:
|
Examples of non-valid services:
|
||||||
|
|
||||||
- Just a Telegram link
|
|
||||||
- A cryptocurrency project
|
- A cryptocurrency project
|
||||||
- A cryptocurrency wallet
|
- A cryptocurrency wallet
|
||||||
|
- A chat link without any information about the service, or review page.
|
||||||
|
|
||||||
#### Suggestion Review Process
|
#### Suggestion Review Process
|
||||||
|
|
||||||
@@ -145,7 +161,7 @@ These categories **directly influence** a service's Privacy and Trust scores, wh
|
|||||||
|
|
||||||
#### Service Scores
|
#### Service Scores
|
||||||
|
|
||||||
Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
|
Scores are calculated **automatically** using clear, fixed rules based on the attributes of the service ([See all attributes](/attributes)). We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
|
||||||
|
|
||||||
##### Privacy Score
|
##### Privacy Score
|
||||||
|
|
||||||
@@ -165,7 +181,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
|
|||||||
|
|
||||||
##### Overall Score
|
##### Overall Score
|
||||||
|
|
||||||
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust.
|
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` truncated. This provides a combined measure of privacy and trust.
|
||||||
|
|
||||||
#### Terms of Service Reviews
|
#### Terms of Service Reviews
|
||||||
|
|
||||||
@@ -184,6 +200,32 @@ There are two types of events:
|
|||||||
|
|
||||||
You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
|
You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
|
||||||
|
|
||||||
|
### Listing Statuses
|
||||||
|
|
||||||
|
#### **Unlisted**
|
||||||
|
|
||||||
|
Initial state after the service is submitted. The service will **not appear in the list or search results**. Only accessible with a **direct link**. An **initial review** is done by the team to ensure the service is **not spam or inappropriate**.
|
||||||
|
|
||||||
|
#### **Community Contributed**
|
||||||
|
|
||||||
|
The service is **listed** in the directory, but it has **not been reviewed** by our team. The information **may be inaccurate, incomplete, or fraudulent**. Users should use these services **with caution**.
|
||||||
|
|
||||||
|
#### **Approved**
|
||||||
|
|
||||||
|
The service is listed in the directory and has been **reviewed by our team**. The information is **accurate and complete**. Initial tests were **successful**, but there is **not enough trust** to be verified.
|
||||||
|
|
||||||
|
#### **Verified**
|
||||||
|
|
||||||
|
The service has been listed for a while and has **not been reported** as a scam, user reviews are **mostly positive**, and the service is **not under any investigation**. Further tests and checks have been **successfully completed**. Contact with support or admins was also successful.
|
||||||
|
|
||||||
|
#### **Scam**
|
||||||
|
|
||||||
|
The service is a **scam**. User reports, negative reviews, or **failed internal testing** and other red flags were found. Evidence is provided in the **verification section** of the service page.
|
||||||
|
|
||||||
|
#### **Archived**
|
||||||
|
|
||||||
|
The service is **no longer available**. It may have been **shut down, acquired, or otherwise discontinued**. Still **visible** in the directory **for reference**.
|
||||||
|
|
||||||
## Reviews and Comments
|
## Reviews and Comments
|
||||||
|
|
||||||
Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one.
|
Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one.
|
||||||
@@ -194,21 +236,38 @@ If you've used the service, you can add an **order ID** or proof—this is only
|
|||||||
|
|
||||||
Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
|
Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
|
||||||
|
|
||||||
### Note on moderation
|
### Moderation
|
||||||
|
|
||||||
**All comments are moderated.** First, an AI checks each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a human to review. We only remove comments that are spam, nonsense, unsupported accusations, doxxing, or clear rule violations.
|
**All comments are moderated.** First, an **AI checks** each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a **human review**. We only remove comments that do not follow the guidelines.
|
||||||
|
|
||||||
|
Comments from [**users affiliated with a service**](/about#verified-and-affiliated-users) are automatically approved on their own service page.
|
||||||
|
|
||||||
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
|
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
|
||||||
|
|
||||||
|
#### Comment Guidelines
|
||||||
|
|
||||||
|
We welcome honest, constructive discussion. However, any comment or review that contains the following will be **rejected**:
|
||||||
|
|
||||||
|
- **Spam** – promotional content, fake reviews, or coordinated campaigns.
|
||||||
|
- **Doxxing** – harassment, threats, or disclosure of private information.
|
||||||
|
- **Illegal content** – anything that encourages illegal activity.
|
||||||
|
- **AI-generated text** – AI-written content is not allowed.
|
||||||
|
- **Unrelated content** – content that is not related to the service.
|
||||||
|
- **Personal discussion** – discussions not related to the service.
|
||||||
|
|
||||||
|
A review score may be **disabled** if it:
|
||||||
|
|
||||||
|
- Is not based on your own first-hand experience.
|
||||||
|
- Contains demonstrably false or unverified claims.
|
||||||
|
- Is not relevant to the service being reviewed.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
You can access basic service data via our public API.
|
You can access basic service data through our [public API](/docs/api).
|
||||||
|
|
||||||
See the [API page](/docs/api) for more details.
|
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you like this project, you can **support** it through these methods:
|
You can **support** our work through these methods:
|
||||||
|
|
||||||
<DonationAddress
|
<DonationAddress
|
||||||
cryptoName="Monero"
|
cryptoName="Monero"
|
||||||
@@ -218,11 +277,11 @@ If you like this project, you can **support** it through these methods:
|
|||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
You can contact via direct chat:
|
You can contact us through SimpleX Chat:
|
||||||
|
|
||||||
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
|
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
|
||||||
|
|
||||||
## Downloads and assets
|
## Downloads and Assets
|
||||||
|
|
||||||
For logos and brand assets, visit our [downloads page](/downloads).
|
For logos and brand assets, visit our [downloads page](/downloads).
|
||||||
|
|
||||||
|
|||||||
141
web/src/pages/account/delete.astro
Normal file
141
web/src/pages/account/delete.astro
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
import Captcha from '../../components/Captcha.astro'
|
||||||
|
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||||
|
import UserBadge from '../../components/UserBadge.astro'
|
||||||
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||||
|
|
||||||
|
const deleteResult = Astro.getActionResult(actions.account.delete)
|
||||||
|
if (deleteResult && !deleteResult.error) {
|
||||||
|
Astro.locals.banners.addIfSuccess(deleteResult, 'Account deleted successfully')
|
||||||
|
return Astro.redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Astro.locals.user?.id
|
||||||
|
if (!userId) {
|
||||||
|
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to delete your account' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await Astro.locals.banners.try('Failed to load user profile', () =>
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
admin: true,
|
||||||
|
moderator: true,
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
picture: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
comments: true,
|
||||||
|
commentVotes: true,
|
||||||
|
suggestions: true,
|
||||||
|
suggestionMessages: true,
|
||||||
|
verificationRequests: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!user) return Astro.rewrite('/404')
|
||||||
|
---
|
||||||
|
|
||||||
|
<MiniLayout
|
||||||
|
pageTitle="Delete account"
|
||||||
|
description="Delete your account"
|
||||||
|
ogImage={{
|
||||||
|
template: 'generic',
|
||||||
|
title: 'Delete account',
|
||||||
|
description: 'Delete your account',
|
||||||
|
icon: 'ri:delete-bin-line',
|
||||||
|
}}
|
||||||
|
layoutHeader={{
|
||||||
|
icon: 'ri:delete-bin-line',
|
||||||
|
title: 'Delete account',
|
||||||
|
subtitle: 'Are you sure? This is irreversible.',
|
||||||
|
}}
|
||||||
|
breadcrumbs={[
|
||||||
|
{
|
||||||
|
name: 'Accounts',
|
||||||
|
url: '/account',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete account',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<h2 class="font-title text-day-200 mb-2 text-lg font-semibold">What will be deleted:</h2>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/account"
|
||||||
|
class="group relative mb-2 flex flex-col items-center rounded-xl border border-red-500/10 bg-red-500/10 p-4"
|
||||||
|
>
|
||||||
|
<UserBadge user={user} size="lg" noLink classNames={{ text: 'group-hover:underline' }} />
|
||||||
|
<Icon
|
||||||
|
name="ri:forbid-line"
|
||||||
|
class="2xs:block absolute top-1/2 left-3 hidden size-8 -translate-y-1/2 text-red-300/10"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name="ri:forbid-2-line"
|
||||||
|
class="2xs:block absolute top-1/2 right-3 hidden size-8 -translate-y-1/2 text-red-300/10"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="2xs:grid-cols-2 mb-8 grid grid-cols-1 gap-x-1 gap-y-0">
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold">1</span>
|
||||||
|
<span class="text-day-400 text-sm">User</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.comments.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Comments <span class="text-xs">(+ their replies)</span></span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.commentVotes.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Comment Votes</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.suggestions.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Service Suggestions</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.suggestionMessages.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Suggestion Messages</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.verificationRequests.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Verification Requests</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{
|
||||||
|
user.admin || user.moderator ? (
|
||||||
|
<p class="text-center text-balance text-red-300">Admins and moderators cannot be deleted.</p>
|
||||||
|
) : (
|
||||||
|
<form method="POST" action={actions.account.delete}>
|
||||||
|
<Captcha action={actions.account.delete} class="mb-6" />
|
||||||
|
<InputSubmitButton label="Delete account" color="danger" icon="ri:delete-bin-line" />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</MiniLayout>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
|
import { differenceInCalendarDays } from 'date-fns'
|
||||||
import { sortBy } from 'lodash-es'
|
import { sortBy } from 'lodash-es'
|
||||||
|
|
||||||
import BadgeSmall from '../../components/BadgeSmall.astro'
|
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||||
@@ -19,9 +20,11 @@ import { verificationStatusesByValue } from '../../constants/verificationStatus'
|
|||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../../lib/cn'
|
import { cn } from '../../lib/cn'
|
||||||
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
||||||
|
import { pluralize } from '../../lib/pluralize'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||||
import { formatDateShort } from '../../lib/timeAgo'
|
import { formatDateShort } from '../../lib/timeAgo'
|
||||||
|
import { urlDomain } from '../../lib/urls'
|
||||||
|
|
||||||
const userId = Astro.locals.user?.id
|
const userId = Astro.locals.user?.id
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -48,6 +51,7 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
verifiedLink: true,
|
verifiedLink: true,
|
||||||
totalKarma: true,
|
totalKarma: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
scheduledDeletionAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
comments: true,
|
comments: true,
|
||||||
@@ -157,6 +161,10 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!user) return Astro.rewrite('/404')
|
if (!user) return Astro.rewrite('/404')
|
||||||
|
|
||||||
|
const daysUntilDeletion = user.scheduledDeletionAt
|
||||||
|
? differenceInCalendarDays(user.scheduledDeletionAt, new Date())
|
||||||
|
: null
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -393,6 +401,33 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{
|
||||||
|
daysUntilDeletion && (
|
||||||
|
<li class="flex items-start">
|
||||||
|
<span class="text-day-500 mt-0.5 mr-2">
|
||||||
|
<Icon name="ri:delete-bin-line" class="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-day-500 text-xs">Deletion Status</p>
|
||||||
|
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
|
||||||
|
Scheduled for deletion
|
||||||
|
{daysUntilDeletion <= 0 ? (
|
||||||
|
'today'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
in {daysUntilDeletion.toLocaleString()} {pluralize('day', daysUntilDeletion)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<p class="text-day-400 mt-2 text-xs">
|
||||||
|
To prevent deletion, take any action such as voting, commenting, or suggesting a
|
||||||
|
service.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
|
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
|
||||||
<div>
|
<div>
|
||||||
@@ -423,7 +458,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-blue-400 hover:underline"
|
class="text-blue-400 hover:underline"
|
||||||
>
|
>
|
||||||
{user.verifiedLink}
|
{urlDomain(user.verifiedLink)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -857,12 +892,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-4 py-3 text-center">
|
||||||
<span
|
<span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
|
||||||
class={cn(
|
|
||||||
'border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs',
|
|
||||||
statusInfo.iconClass
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon name={statusInfo.icon} class="mr-1 size-3" />
|
<Icon name={statusInfo.icon} class="mr-1 size-3" />
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -966,7 +996,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout
|
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${SUPPORT_EMAIL}`}
|
href="/account/delete"
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
||||||
>
|
>
|
||||||
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account
|
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account
|
||||||
|
|||||||
@@ -271,7 +271,6 @@ if (toggleResult?.error) {
|
|||||||
label: type.label,
|
label: type.label,
|
||||||
value: type.value,
|
value: type.value,
|
||||||
icon: type.icon,
|
icon: type.icon,
|
||||||
noTransitionPersist: false,
|
|
||||||
}))}
|
}))}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
required
|
required
|
||||||
@@ -306,8 +305,8 @@ if (toggleResult?.error) {
|
|||||||
label="Status"
|
label="Status"
|
||||||
error={createInputErrors.isActive}
|
error={createInputErrors.isActive}
|
||||||
options={[
|
options={[
|
||||||
{ label: 'Active', value: 'true', noTransitionPersist: true },
|
{ label: 'Active', value: 'true' },
|
||||||
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
|
{ label: 'Inactive', value: 'false' },
|
||||||
]}
|
]}
|
||||||
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
|
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
@@ -628,7 +627,6 @@ if (toggleResult?.error) {
|
|||||||
label: type.label,
|
label: type.label,
|
||||||
value: type.value,
|
value: type.value,
|
||||||
icon: type.icon,
|
icon: type.icon,
|
||||||
noTransitionPersist: true,
|
|
||||||
}))}
|
}))}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
required
|
required
|
||||||
@@ -661,8 +659,8 @@ if (toggleResult?.error) {
|
|||||||
name="isActive"
|
name="isActive"
|
||||||
label="Status"
|
label="Status"
|
||||||
options={[
|
options={[
|
||||||
{ label: 'Active', value: 'true', noTransitionPersist: true },
|
{ label: 'Active', value: 'true' },
|
||||||
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
|
{ label: 'Inactive', value: 'false' },
|
||||||
]}
|
]}
|
||||||
selectedValue={announcement.isActive ? 'true' : 'false'}
|
selectedValue={announcement.isActive ? 'true' : 'false'}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
|
|||||||
@@ -692,12 +692,12 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
|
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<Button
|
<Button
|
||||||
as="button"
|
|
||||||
color="gray"
|
color="gray"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
size="sm"
|
size="sm"
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
|
data-cancel-button
|
||||||
|
data-cancel-form-id={`edit-form-${index}`}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
as="button"
|
as="button"
|
||||||
@@ -721,3 +721,22 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
// Optional script for cancel buttons in attribute forms. //
|
||||||
|
// Hides the edit form when cancel button is clicked. //
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
document.addEventListener('astro:page-load', () => {
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-cancel-button]').forEach((button) => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const formId = button.getAttribute('data-cancel-form-id')
|
||||||
|
if (!formId) throw new Error('Form ID not found')
|
||||||
|
const form = document.getElementById(formId)
|
||||||
|
if (!form) throw new Error('Form not found')
|
||||||
|
form.classList.add('hidden')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import BadgeSmall from '../../components/BadgeSmall.astro'
|
import BadgeSmall from '../../components/BadgeSmall.astro'
|
||||||
import CommentModeration from '../../components/CommentModeration.astro'
|
import CommentModeration from '../../components/CommentModeration.astro'
|
||||||
import MyPicture from '../../components/MyPicture.astro'
|
import MyPicture from '../../components/MyPicture.astro'
|
||||||
|
import Pagination from '../../components/Pagination.astro'
|
||||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||||
import UserBadge from '../../components/UserBadge.astro'
|
import UserBadge from '../../components/UserBadge.astro'
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +28,7 @@ if (!user || (!user.admin && !user.moderator)) {
|
|||||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||||
{
|
{
|
||||||
status: commentStatusFiltersZodEnum.default('all'),
|
status: commentStatusFiltersZodEnum.default('all'),
|
||||||
page: z.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
},
|
},
|
||||||
Astro
|
Astro
|
||||||
)
|
)
|
||||||
@@ -241,29 +242,5 @@ const totalPages = Math.ceil(totalComments / PAGE_SIZE)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{
|
{totalPages > 1 && <Pagination currentPage={params.page} totalPages={totalPages} class="mt-8" />}
|
||||||
totalPages > 1 && (
|
|
||||||
<div class="mt-8 flex justify-center gap-2">
|
|
||||||
{params.page > 1 && (
|
|
||||||
<a
|
|
||||||
href={urlWithParams(Astro.url, { page: params.page - 1 })}
|
|
||||||
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<span class="font-title px-3 py-1 text-sm">
|
|
||||||
Page {params.page} of {totalPages}
|
|
||||||
</span>
|
|
||||||
{params.page < totalPages && (
|
|
||||||
<a
|
|
||||||
href={urlWithParams(Astro.url, { page: params.page + 1 })}
|
|
||||||
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Button from '../../../../components/Button.astro'
|
|||||||
import FormSection from '../../../../components/FormSection.astro'
|
import FormSection from '../../../../components/FormSection.astro'
|
||||||
import FormSubSection from '../../../../components/FormSubSection.astro'
|
import FormSubSection from '../../../../components/FormSubSection.astro'
|
||||||
import InputCardGroup from '../../../../components/InputCardGroup.astro'
|
import InputCardGroup from '../../../../components/InputCardGroup.astro'
|
||||||
|
import InputCheckbox from '../../../../components/InputCheckbox.astro'
|
||||||
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
|
import InputCheckboxGroup from '../../../../components/InputCheckboxGroup.astro'
|
||||||
import InputImageFile from '../../../../components/InputImageFile.astro'
|
import InputImageFile from '../../../../components/InputImageFile.astro'
|
||||||
import InputSelect from '../../../../components/InputSelect.astro'
|
import InputSelect from '../../../../components/InputSelect.astro'
|
||||||
@@ -24,6 +25,7 @@ import UserBadge from '../../../../components/UserBadge.astro'
|
|||||||
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
|
||||||
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
|
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
|
||||||
|
import { countries } from '../../../../constants/countries'
|
||||||
import { currencies } from '../../../../constants/currencies'
|
import { currencies } from '../../../../constants/currencies'
|
||||||
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
||||||
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
|
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
|
||||||
@@ -35,7 +37,7 @@ import {
|
|||||||
verificationStepStatuses,
|
verificationStepStatuses,
|
||||||
} from '../../../../constants/verificationStepStatus'
|
} from '../../../../constants/verificationStepStatus'
|
||||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||||
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
|
import { DEPLOYMENT_MODE } from '../../../../lib/client/envVariables'
|
||||||
import { listFiles } from '../../../../lib/fileStorage'
|
import { listFiles } from '../../../../lib/fileStorage'
|
||||||
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
||||||
import { pluralize } from '../../../../lib/pluralize'
|
import { pluralize } from '../../../../lib/pluralize'
|
||||||
@@ -361,6 +363,17 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
value={service.tosUrls.join('\n')}
|
value={service.tosUrls.join('\n')}
|
||||||
error={serviceInputErrors.tosUrls}
|
error={serviceInputErrors.tosUrls}
|
||||||
/>
|
/>
|
||||||
|
<InputText
|
||||||
|
label="Operating since"
|
||||||
|
name="operatingSince"
|
||||||
|
description="Date the service started operating"
|
||||||
|
inputProps={{
|
||||||
|
type: 'date',
|
||||||
|
value: service.operatingSince?.toISOString().slice(0, 10),
|
||||||
|
max: new Date().toISOString().slice(0, 10),
|
||||||
|
}}
|
||||||
|
error={serviceInputErrors.operatingSince}
|
||||||
|
/>
|
||||||
<InputText
|
<InputText
|
||||||
label="Referral link path"
|
label="Referral link path"
|
||||||
name="referral"
|
name="referral"
|
||||||
@@ -374,6 +387,36 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<InputText
|
||||||
|
label="Registered Company Name"
|
||||||
|
name="registeredCompanyName"
|
||||||
|
description="Official name of the registered company"
|
||||||
|
inputProps={{
|
||||||
|
value: service.registeredCompanyName,
|
||||||
|
placeholder: 'e.g. Example Corp Ltd.',
|
||||||
|
}}
|
||||||
|
error={serviceInputErrors.registeredCompanyName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputSelect
|
||||||
|
name="registrationCountryCode"
|
||||||
|
label="Company Registration Country"
|
||||||
|
description="Country where the service company is legally registered"
|
||||||
|
options={[
|
||||||
|
{ label: 'Not registered', value: '' },
|
||||||
|
...countries
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((country) => ({
|
||||||
|
label: `${country.flag} ${country.name}`,
|
||||||
|
value: country.code,
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
selectedValue={service.registrationCountryCode || ''}
|
||||||
|
error={serviceInputErrors.registrationCountryCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<InputImageFile
|
<InputImageFile
|
||||||
label="Image"
|
label="Image"
|
||||||
@@ -441,7 +484,6 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
value: kycLevel.id.toString(),
|
value: kycLevel.id.toString(),
|
||||||
icon: kycLevel.icon,
|
icon: kycLevel.icon,
|
||||||
description: kycLevel.description,
|
description: kycLevel.description,
|
||||||
noTransitionPersist: true,
|
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.kycLevel.toString()}
|
selectedValue={service.kycLevel.toString()}
|
||||||
iconSize="md"
|
iconSize="md"
|
||||||
@@ -458,7 +500,6 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
value: clarification.value,
|
value: clarification.value,
|
||||||
icon: clarification.icon,
|
icon: clarification.icon,
|
||||||
description: clarification.description,
|
description: clarification.description,
|
||||||
noTransitionPersist: true,
|
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.kycLevelClarification}
|
selectedValue={service.kycLevelClarification}
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
@@ -475,7 +516,6 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
icon: status.icon,
|
icon: status.icon,
|
||||||
iconClass: status.classNames.icon,
|
iconClass: status.classNames.icon,
|
||||||
description: status.description,
|
description: status.description,
|
||||||
noTransitionPersist: true,
|
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.verificationStatus}
|
selectedValue={service.verificationStatus}
|
||||||
error={serviceInputErrors.verificationStatus}
|
error={serviceInputErrors.verificationStatus}
|
||||||
@@ -491,7 +531,6 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
label: currency.name,
|
label: currency.name,
|
||||||
value: currency.id,
|
value: currency.id,
|
||||||
icon: currency.icon,
|
icon: currency.icon,
|
||||||
noTransitionPersist: true,
|
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.acceptedCurrencies}
|
selectedValue={service.acceptedCurrencies}
|
||||||
error={serviceInputErrors.acceptedCurrencies}
|
error={serviceInputErrors.acceptedCurrencies}
|
||||||
@@ -532,13 +571,30 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
icon: visibility.icon,
|
icon: visibility.icon,
|
||||||
iconClass: visibility.iconClass,
|
iconClass: visibility.iconClass,
|
||||||
description: visibility.description,
|
description: visibility.description,
|
||||||
noTransitionPersist: true,
|
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.serviceVisibility}
|
selectedValue={service.serviceVisibility}
|
||||||
error={serviceInputErrors.serviceVisibility}
|
error={serviceInputErrors.serviceVisibility}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InputCheckbox
|
||||||
|
label="Strict Commenting"
|
||||||
|
name="strictCommentingEnabled"
|
||||||
|
checked={service.strictCommentingEnabled}
|
||||||
|
descriptionInline="Require proof of being a client for comments."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputTextArea
|
||||||
|
label="Comment Section Message"
|
||||||
|
name="commentSectionMessage"
|
||||||
|
value={service.commentSectionMessage ?? ''}
|
||||||
|
description="Markdown supported"
|
||||||
|
inputProps={{
|
||||||
|
rows: 4,
|
||||||
|
}}
|
||||||
|
error={serviceInputErrors.commentSectionMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
|
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
|
||||||
</form>
|
</form>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|||||||
@@ -1,23 +1,57 @@
|
|||||||
---
|
---
|
||||||
import { AttributeCategory, Currency, VerificationStatus } from '@prisma/client'
|
import { ServiceVisibility, VerificationStatus } from '@prisma/client'
|
||||||
import { Icon } from 'astro-icon/components'
|
|
||||||
import { actions, isInputError } from 'astro:actions'
|
import { actions, isInputError } from 'astro:actions'
|
||||||
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
|
import InputCardGroup from '../../../components/InputCardGroup.astro'
|
||||||
|
import InputCheckbox from '../../../components/InputCheckbox.astro'
|
||||||
|
import InputCheckboxGroup from '../../../components/InputCheckboxGroup.astro'
|
||||||
|
import InputImageFile from '../../../components/InputImageFile.astro'
|
||||||
|
import InputSelect from '../../../components/InputSelect.astro'
|
||||||
|
import InputSubmitButton from '../../../components/InputSubmitButton.astro'
|
||||||
|
import InputText from '../../../components/InputText.astro'
|
||||||
|
import InputTextArea from '../../../components/InputTextArea.astro'
|
||||||
|
import { getAttributeCategoryInfo } from '../../../constants/attributeCategories'
|
||||||
|
import { getAttributeTypeInfo } from '../../../constants/attributeTypes'
|
||||||
|
import { contactMethodUrlTypes } from '../../../constants/contactMethods'
|
||||||
|
import { countries } from '../../../constants/countries'
|
||||||
|
import { currencies } from '../../../constants/currencies'
|
||||||
|
import { kycLevelClarifications } from '../../../constants/kycLevelClarifications'
|
||||||
|
import { kycLevels } from '../../../constants/kycLevels'
|
||||||
|
import { serviceVisibilities } from '../../../constants/serviceVisibility'
|
||||||
|
import { verificationStatuses } from '../../../constants/verificationStatus'
|
||||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../../../lib/cn'
|
|
||||||
import { prisma } from '../../../lib/prisma'
|
import { prisma } from '../../../lib/prisma'
|
||||||
|
|
||||||
const categories = await Astro.locals.banners.try('Failed to fetch categories', () =>
|
const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||||
prisma.category.findMany({
|
[
|
||||||
orderBy: { name: 'asc' },
|
'Failed to fetch categories',
|
||||||
})
|
() =>
|
||||||
)
|
prisma.category.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
const attributes = await Astro.locals.banners.try('Failed to fetch attributes', () =>
|
select: {
|
||||||
prisma.attribute.findMany({
|
id: true,
|
||||||
orderBy: { category: 'asc' },
|
name: true,
|
||||||
})
|
icon: true,
|
||||||
)
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Failed to fetch attributes',
|
||||||
|
() =>
|
||||||
|
prisma.attribute.findMany({
|
||||||
|
orderBy: { category: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
category: true,
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
const result = Astro.getActionResult(actions.admin.service.create)
|
const result = Astro.getActionResult(actions.admin.service.create)
|
||||||
Astro.locals.banners.addIfSuccess(result, 'Service created successfully')
|
Astro.locals.banners.addIfSuccess(result, 'Service created successfully')
|
||||||
@@ -27,353 +61,287 @@ if (result && !result.error) {
|
|||||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout pageTitle="Create Service" widthClassName="max-w-screen-sm">
|
<BaseLayout
|
||||||
<section class="mb-8">
|
pageTitle="Create Service"
|
||||||
<div class="font-title mb-4">
|
description="Create a new service for KYCnot.me"
|
||||||
<span class="text-sm text-green-500">service.create</span>
|
widthClassName="max-w-screen-md"
|
||||||
|
>
|
||||||
|
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Create Service</h1>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={actions.admin.service.create}
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<InputText
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
inputProps={{
|
||||||
|
required: true,
|
||||||
|
maxlength: 40,
|
||||||
|
}}
|
||||||
|
error={inputErrors.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputTextArea
|
||||||
|
label="Description"
|
||||||
|
name="description"
|
||||||
|
inputProps={{
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
error={inputErrors.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<InputTextArea
|
||||||
|
label="Service URLs"
|
||||||
|
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
|
||||||
|
name="allServiceUrls"
|
||||||
|
inputProps={{
|
||||||
|
placeholder: 'example1.com\nexample2.onion\nexample3.b32.i2p',
|
||||||
|
class: 'md:min-h-20 min-h-24 h-full',
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
class="flex flex-col self-stretch"
|
||||||
|
error={inputErrors.allServiceUrls}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputTextArea
|
||||||
|
label="Contact Methods"
|
||||||
|
description={[
|
||||||
|
'One per line.',
|
||||||
|
`Accepts: ${contactMethodUrlTypes.map((type: any) => type.labelPlural).join(', ')}`,
|
||||||
|
].join('\n')}
|
||||||
|
name="contactMethods"
|
||||||
|
inputProps={{
|
||||||
|
placeholder: 'contact@example.com\nt.me/example\n+123 456 7890',
|
||||||
|
class: 'h-full',
|
||||||
|
}}
|
||||||
|
class="flex flex-col self-stretch"
|
||||||
|
error={inputErrors.contactMethods}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
method="POST"
|
<InputTextArea
|
||||||
action={actions.admin.service.create}
|
label="ToS URLs"
|
||||||
class="space-y-4 rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
|
description="One per line. AI review uses the first working URL only."
|
||||||
enctype="multipart/form-data"
|
name="tosUrls"
|
||||||
>
|
inputProps={{
|
||||||
<div>
|
placeholder: 'example.com/tos',
|
||||||
<label for="name" class="font-title mb-2 block text-sm text-green-500">name</label>
|
required: true,
|
||||||
<input
|
class: 'min-h-10',
|
||||||
transition:persist
|
}}
|
||||||
type="text"
|
error={inputErrors.tosUrls}
|
||||||
name="name"
|
/>
|
||||||
id="name"
|
|
||||||
required
|
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
inputErrors.name && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.name.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputText
|
||||||
<label for="description" class="font-title mb-2 block text-sm text-green-500">description</label>
|
label="Operating since"
|
||||||
<textarea
|
name="operatingSince"
|
||||||
transition:persist
|
inputProps={{
|
||||||
name="description"
|
type: 'date',
|
||||||
id="description"
|
max: new Date().toISOString().slice(0, 10),
|
||||||
required
|
}}
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
error={inputErrors.operatingSince}
|
||||||
set:text=""
|
/>
|
||||||
/>
|
</div>
|
||||||
{
|
|
||||||
inputErrors.description && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.description.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
|
<InputText
|
||||||
<textarea
|
label="Registered Company Name"
|
||||||
transition:persist
|
name="registeredCompanyName"
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
description="Official name of the registered company (optional)"
|
||||||
name="allServiceUrls"
|
inputProps={{
|
||||||
id="allServiceUrls"
|
placeholder: 'e.g. Example Corp Ltd.',
|
||||||
rows={3}
|
}}
|
||||||
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
|
/>
|
||||||
set:text=""
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
inputErrors.allServiceUrls && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputSelect
|
||||||
<label for="tosUrls" class="font-title mb-2 block text-sm text-green-500">tosUrls</label>
|
name="registrationCountryCode"
|
||||||
<textarea
|
label="Company Registration Country"
|
||||||
transition:persist
|
description="Country where the service company is legally registered (optional)"
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
options={[
|
||||||
name="tosUrls"
|
{ label: 'Not registered', value: '' },
|
||||||
id="tosUrls"
|
...countries
|
||||||
rows={3}
|
.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||||
placeholder="https://example1.com/tos https://example2.com/tos"
|
.map((country: any) => ({
|
||||||
set:text=""
|
label: `${country.flag} ${country.name}`,
|
||||||
/>
|
value: country.code,
|
||||||
{
|
}))
|
||||||
inputErrors.tosUrls && (
|
]}
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.tosUrls.join(', ')}</p>
|
selectedValue=""
|
||||||
)
|
/>
|
||||||
}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputCardGroup
|
||||||
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
|
name="kycLevel"
|
||||||
<div class="space-y-2">
|
label="KYC Level"
|
||||||
<input
|
options={kycLevels.map((kycLevel: any) => ({
|
||||||
transition:persist
|
label: kycLevel.name,
|
||||||
type="file"
|
value: kycLevel.id.toString(),
|
||||||
name="imageFile"
|
icon: kycLevel.icon,
|
||||||
id="imageFile"
|
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/4_`,
|
||||||
accept="image/*"
|
}))}
|
||||||
required
|
iconSize="md"
|
||||||
class="font-title file:font-title block w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 file:mr-3 file:rounded-md file:border-0 file:bg-green-500/30 file:px-3 file:py-1 file:text-gray-300 focus:border-green-500 focus:ring-green-500"
|
cardSize="md"
|
||||||
/>
|
required
|
||||||
<p class="font-title text-xs text-gray-400">
|
error={inputErrors.kycLevel}
|
||||||
Upload a square image for best results. Supported formats: JPG, PNG, WebP, SVG.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
inputErrors.imageFile && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.imageFile.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputCardGroup
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="categories">categories</label>
|
name="kycLevelClarification"
|
||||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
label="KYC Level Clarification"
|
||||||
{
|
options={kycLevelClarifications.map((clarification: any) => ({
|
||||||
categories?.map((category) => (
|
label: clarification.label,
|
||||||
<label class="inline-flex items-center">
|
value: clarification.value,
|
||||||
<input
|
icon: clarification.icon,
|
||||||
transition:persist
|
description: clarification.description,
|
||||||
type="checkbox"
|
}))}
|
||||||
name="categories"
|
selectedValue="NONE"
|
||||||
value={category.id}
|
iconSize="sm"
|
||||||
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
|
cardSize="sm"
|
||||||
/>
|
error={inputErrors.kycLevelClarification}
|
||||||
<span class="font-title ml-2 flex items-center gap-2 text-gray-300">
|
/>
|
||||||
<Icon class="h-3 w-3 text-green-500" name={category.icon} />
|
|
||||||
{category.name}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
inputErrors.categories && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.categories.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
|
||||||
<label for="kycLevel" class="font-title mb-2 block text-sm text-green-500">kycLevel</label>
|
<InputCheckboxGroup
|
||||||
<input
|
name="categories"
|
||||||
transition:persist
|
label="Categories"
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
required
|
||||||
type="number"
|
options={categories.map((category: any) => ({
|
||||||
name="kycLevel"
|
label: category.name,
|
||||||
id="kycLevel"
|
value: category.id.toString(),
|
||||||
min={0}
|
icon: category.icon,
|
||||||
max={4}
|
}))}
|
||||||
value={4}
|
size="lg"
|
||||||
required
|
error={inputErrors.categories}
|
||||||
/>
|
class="min-w-auto"
|
||||||
{
|
/>
|
||||||
inputErrors.kycLevel && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.kycLevel.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputCheckboxGroup
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="attributes">attributes</label>
|
name="attributes"
|
||||||
<div class="space-y-4">
|
label="Attributes"
|
||||||
{
|
options={orderBy(
|
||||||
Object.values(AttributeCategory).map((category) => (
|
attributes.map((attribute: any) => ({
|
||||||
<div class="rounded-md border border-green-500/20 bg-black/30 p-4">
|
...attribute,
|
||||||
<h4 class="font-title mb-3 text-green-400">{category}</h4>
|
categoryInfo: getAttributeCategoryInfo(attribute.category),
|
||||||
<div class="grid grid-cols-1 gap-2">
|
typeInfo: getAttributeTypeInfo(attribute.type),
|
||||||
{attributes
|
})),
|
||||||
?.filter((attr) => attr.category === category)
|
['categoryInfo.order', 'typeInfo.order']
|
||||||
.map((attr) => (
|
).map((attribute: any) => ({
|
||||||
<label class="inline-flex items-center">
|
label: attribute.title,
|
||||||
<input
|
value: attribute.id.toString(),
|
||||||
transition:persist
|
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
|
||||||
type="checkbox"
|
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
|
||||||
name="attributes"
|
}))}
|
||||||
value={attr.id}
|
description="See list of [all attributes](/attributes) and their scoring."
|
||||||
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
|
error={inputErrors.attributes}
|
||||||
/>
|
size="lg"
|
||||||
<span class="font-title ml-2 flex items-center gap-2 text-gray-300">
|
/>
|
||||||
{attr.title}
|
</div>
|
||||||
<span
|
|
||||||
class={cn('font-title rounded-sm px-1.5 py-0.5 text-xs', {
|
|
||||||
'border border-green-500/50 bg-green-500/20 text-green-400':
|
|
||||||
attr.type === 'GOOD',
|
|
||||||
'border border-red-500/50 bg-red-500/20 text-red-400': attr.type === 'BAD',
|
|
||||||
'border border-yellow-500/50 bg-yellow-500/20 text-yellow-400':
|
|
||||||
attr.type === 'WARNING',
|
|
||||||
'border border-blue-500/50 bg-blue-500/20 text-blue-400': attr.type === 'INFO',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{attr.type}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{inputErrors.attributes && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.attributes.join(', ')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputCardGroup
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="verificationStatus"
|
name="acceptedCurrencies"
|
||||||
>verificationStatus</label
|
label="Accepted Currencies"
|
||||||
>
|
options={currencies.map((currency: any) => ({
|
||||||
<select
|
label: currency.name,
|
||||||
transition:persist
|
value: currency.id,
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
|
icon: currency.icon,
|
||||||
name="verificationStatus"
|
}))}
|
||||||
id="verificationStatus"
|
error={inputErrors.acceptedCurrencies}
|
||||||
required
|
multiple
|
||||||
>
|
/>
|
||||||
{Object.values(VerificationStatus).map((status) => <option value={status}>{status}</option>)}
|
|
||||||
</select>
|
|
||||||
{
|
|
||||||
inputErrors.verificationStatus && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationStatus.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputImageFile
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="verificationSummary"
|
label="Service Image"
|
||||||
>verificationSummary</label
|
name="imageFile"
|
||||||
>
|
description="Square image. At least 192x192px. Transparency supported."
|
||||||
<textarea
|
error={inputErrors.imageFile}
|
||||||
transition:persist
|
square
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
required
|
||||||
name="verificationSummary"
|
/>
|
||||||
id="verificationSummary"
|
|
||||||
rows={3}
|
|
||||||
set:text=""
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
inputErrors.verificationSummary && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationSummary.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputSelect
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="verificationProofMd"
|
name="verificationStatus"
|
||||||
>verificationProofMd</label
|
label="Verification Status"
|
||||||
>
|
options={Object.values(VerificationStatus).map((status) => ({
|
||||||
<textarea
|
label: status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l: string) => l.toUpperCase()),
|
||||||
transition:persist
|
value: status,
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
}))}
|
||||||
name="verificationProofMd"
|
error={inputErrors.verificationStatus}
|
||||||
id="verificationProofMd"
|
/>
|
||||||
rows={10}
|
|
||||||
set:text=""
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
inputErrors.verificationProofMd && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationProofMd.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputTextArea
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="acceptedCurrencies"
|
label="Verification Summary"
|
||||||
>acceptedCurrencies</label
|
name="verificationSummary"
|
||||||
>
|
error={inputErrors.verificationSummary}
|
||||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
/>
|
||||||
{
|
|
||||||
Object.values(Currency).map((currency) => (
|
|
||||||
<label class="inline-flex items-center">
|
|
||||||
<input
|
|
||||||
transition:persist
|
|
||||||
type="checkbox"
|
|
||||||
name="acceptedCurrencies"
|
|
||||||
value={currency}
|
|
||||||
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
|
|
||||||
/>
|
|
||||||
<span class="font-title ml-2 text-gray-300">{currency}</span>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
inputErrors.acceptedCurrencies && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.acceptedCurrencies.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputTextArea
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="overallScore">overallScore</label>
|
label="Verification Proof (Markdown)"
|
||||||
<input
|
name="verificationProofMd"
|
||||||
transition:persist
|
inputProps={{
|
||||||
type="number"
|
rows: 10,
|
||||||
name="overallScore"
|
}}
|
||||||
id="overallScore"
|
error={inputErrors.verificationProofMd}
|
||||||
value={0}
|
/>
|
||||||
min={0}
|
|
||||||
max={10}
|
|
||||||
required
|
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
inputErrors.overallScore && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.overallScore.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputText
|
||||||
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral link path</label>
|
label="Referral Link Path"
|
||||||
<input
|
name="referral"
|
||||||
transition:persist
|
inputProps={{
|
||||||
type="text"
|
placeholder: 'e.g. ?ref=123 or /ref/123',
|
||||||
name="referral"
|
}}
|
||||||
id="referral"
|
error={inputErrors.referral}
|
||||||
placeholder="e.g. ?ref=123 or /ref/123"
|
description="Will be appended to the service URL"
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
/>
|
||||||
/>
|
|
||||||
{
|
|
||||||
inputErrors.referral && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.referral.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<InputCardGroup
|
||||||
<label for="internalNote" class="font-title mb-2 block text-sm text-green-500">internalNote</label>
|
name="serviceVisibility"
|
||||||
<div class="space-y-2">
|
label="Service Visibility"
|
||||||
<textarea
|
options={serviceVisibilities.map((visibility: any) => ({
|
||||||
transition:persist
|
label: visibility.label,
|
||||||
name="internalNote"
|
value: visibility.value,
|
||||||
id="internalNote"
|
icon: visibility.icon,
|
||||||
rows={4}
|
iconClass: visibility.iconClass,
|
||||||
placeholder="Markdown supported"
|
description: visibility.description,
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
}))}
|
||||||
set:text=""
|
selectedValue="PUBLIC"
|
||||||
/>
|
error={inputErrors.serviceVisibility}
|
||||||
</div>
|
cardSize="sm"
|
||||||
{
|
/>
|
||||||
inputErrors.internalNote && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.internalNote.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<InputTextArea
|
||||||
type="submit"
|
label="Internal Note"
|
||||||
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
name="internalNote"
|
||||||
>
|
description="Markdown supported. Internal notes for admins."
|
||||||
Create Service
|
inputProps={{
|
||||||
</button>
|
rows: 4,
|
||||||
</form>
|
}}
|
||||||
</section>
|
error={inputErrors.internalNote}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputCheckbox
|
||||||
|
label="Strict Commenting"
|
||||||
|
name="strictCommentingEnabled"
|
||||||
|
checked={false}
|
||||||
|
descriptionInline="Require proof of being a client for comments."
|
||||||
|
error={inputErrors.strictCommentingEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputTextArea
|
||||||
|
label="Comment Section Message"
|
||||||
|
name="commentSectionMessage"
|
||||||
|
description="Markdown supported. This message will be displayed in the comment section for root comments."
|
||||||
|
inputProps={{
|
||||||
|
rows: 4,
|
||||||
|
}}
|
||||||
|
error={inputErrors.commentSectionMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputSubmitButton label="Create Service" icon="ri:add-line" hideCancel />
|
||||||
|
</form>
|
||||||
|
</FormSection>
|
||||||
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -230,26 +230,22 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
label: 'Admin',
|
label: 'Admin',
|
||||||
value: 'admin',
|
value: 'admin',
|
||||||
icon: 'ri:shield-star-fill',
|
icon: 'ri:shield-star-fill',
|
||||||
noTransitionPersist: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Moderator',
|
label: 'Moderator',
|
||||||
value: 'moderator',
|
value: 'moderator',
|
||||||
icon: 'ri:graduation-cap-fill',
|
icon: 'ri:graduation-cap-fill',
|
||||||
noTransitionPersist: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Spammer',
|
label: 'Spammer',
|
||||||
value: 'spammer',
|
value: 'spammer',
|
||||||
icon: 'ri:alert-fill',
|
icon: 'ri:alert-fill',
|
||||||
noTransitionPersist: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Verified',
|
label: 'Verified',
|
||||||
value: 'verified',
|
value: 'verified',
|
||||||
icon: 'ri:verified-badge-fill',
|
icon: 'ri:verified-badge-fill',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
noTransitionPersist: true,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
selectedValue={[
|
selectedValue={[
|
||||||
@@ -434,7 +430,6 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
label: role.label,
|
label: role.label,
|
||||||
value: role.value,
|
value: role.value,
|
||||||
icon: role.icon,
|
icon: role.icon,
|
||||||
noTransitionPersist: true,
|
|
||||||
}))}
|
}))}
|
||||||
required
|
required
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
@@ -466,6 +461,25 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
|
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
|
||||||
</form>
|
</form>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title="Delete User">
|
||||||
|
{
|
||||||
|
user.admin || user.moderator ? (
|
||||||
|
<p class="text-center text-balance">This user can't be deleted</p>
|
||||||
|
) : (
|
||||||
|
<p class="text-center text-balance">
|
||||||
|
To delete this user,
|
||||||
|
<a
|
||||||
|
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account/delete`}
|
||||||
|
data-astro-prefetch="tap"
|
||||||
|
class="underline"
|
||||||
|
>
|
||||||
|
impersonate it and go to /account/delete
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</FormSection>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
<span
|
<span
|
||||||
class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`}
|
class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`}
|
||||||
>
|
>
|
||||||
{user.totalKarma}
|
{user.totalKarma.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center text-sm">
|
<td class="px-4 py-3 text-center text-sm">
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type ServiceResponse = {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
verifiedAt: Date | null
|
verifiedAt: Date | null
|
||||||
|
approvedAt: Date | null
|
||||||
kycLevel: 0 | 1 | 2 | 3 | 4
|
kycLevel: 0 | 1 | 2 | 3 | 4
|
||||||
kycLevelInfo: {
|
kycLevelInfo: {
|
||||||
value: 0 | 1 | 2 | 3 | 4
|
value: 0 | 1 | 2 | 3 | 4
|
||||||
@@ -61,7 +62,7 @@ type ServiceResponse = {
|
|||||||
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
||||||
kycLevelClarificationInfo: {
|
kycLevelClarificationInfo: {
|
||||||
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
||||||
name: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
categories: {
|
categories: {
|
||||||
@@ -134,8 +135,10 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"id": 123,
|
||||||
"name": "My Example Service",
|
"name": "My Example Service",
|
||||||
"description": "This is a description of my example service",
|
"description": "This is a description of my example service",
|
||||||
|
"slug": "my-example-service",
|
||||||
"serviceVisibility": "PUBLIC",
|
"serviceVisibility": "PUBLIC",
|
||||||
"verificationStatus": "VERIFICATION_SUCCESS",
|
"verificationStatus": "VERIFICATION_SUCCESS",
|
||||||
"verificationStatusInfo": {
|
"verificationStatusInfo": {
|
||||||
@@ -145,7 +148,8 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
"labelShort": "Verified",
|
"labelShort": "Verified",
|
||||||
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
|
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
|
||||||
},
|
},
|
||||||
"verifiedAt": "2025-01-20T07:12:29.393Z",
|
"verifiedAt": "2025-06-14T11:02:39.294Z",
|
||||||
|
"approvedAt": "2025-05-31T19:09:18.043Z",
|
||||||
"kycLevel": 0,
|
"kycLevel": 0,
|
||||||
"kycLevelInfo": {
|
"kycLevelInfo": {
|
||||||
"value": 0,
|
"value": 0,
|
||||||
@@ -155,6 +159,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
"kycLevelClarification": "NONE",
|
"kycLevelClarification": "NONE",
|
||||||
"kycLevelClarificationInfo": {
|
"kycLevelClarificationInfo": {
|
||||||
"value": "NONE",
|
"value": "NONE",
|
||||||
|
"label": "None",
|
||||||
"description": "No clarification needed."
|
"description": "No clarification needed."
|
||||||
},
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
@@ -163,7 +168,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
"slug": "exchange"
|
"slug": "exchange"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"listedAt": "2025-05-31T19:09:18.043Z",
|
"listedAt": "2025-04-20T07:12:29.393Z",
|
||||||
"serviceUrls": [
|
"serviceUrls": [
|
||||||
"https://example.com",
|
"https://example.com",
|
||||||
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
|
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
|
||||||
|
|||||||
@@ -15,55 +15,58 @@ import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
|
|||||||
There are several ways to earn karma points:
|
There are several ways to earn karma points:
|
||||||
|
|
||||||
1. **Comment Approval** (+1 point)
|
1. **Comment Approval** (+1 point)
|
||||||
|
- When your comment moves from 'unmoderated' to 'approved' status.
|
||||||
- When your comment moves from 'unmoderated' to 'approved' status
|
- This is the basic reward for contributing a valid comment.
|
||||||
- This is the basic reward for contributing a valid comment
|
- Users related to the service (e.g. owners, admins, etc.) do not get karma for their comments.
|
||||||
|
|
||||||
2. **Comment Verification** (+5 points)
|
2. **Comment Verification** (+5 points)
|
||||||
|
- When your comment is marked as 'verified'.
|
||||||
- When your comment is marked as 'verified'
|
- This is a significant reward for providing particularly valuable or verified information.
|
||||||
- This is a significant reward for providing particularly valuable or verified information
|
|
||||||
|
|
||||||
3. **Upvotes**
|
3. **Upvotes**
|
||||||
- Each upvote on your comment adds +1 to your karma
|
- Each upvote on your comment adds +1 to your karma.
|
||||||
- Similarly, each downvote reduces your karma by -1
|
- Similarly, each downvote reduces your karma by -1.
|
||||||
- This allows the community to reward helpful contributions
|
- This allows the community to reward helpful contributions.
|
||||||
|
|
||||||
|
4. **Suggestion Approval** (+10 points)
|
||||||
|
- When your suggestion to add or edit a service is approved and the service is listed publicly.
|
||||||
|
- Suggestions on non-listed services do not earn karma.
|
||||||
|
- This rewards users for helping to expand and improve the service directory.
|
||||||
|
|
||||||
## Karma Penalties
|
## Karma Penalties
|
||||||
|
|
||||||
The system also includes penalties to discourage spam and low-quality content:
|
The system also includes penalties to discourage spam and low-quality content:
|
||||||
|
|
||||||
1. **Spam Detection** (-10 points)
|
1. **Spam Detection** (-10 points)
|
||||||
- If your comment is marked as suspicious/spam
|
- If your comment is marked as suspicious/spam.
|
||||||
- This is a significant penalty to discourage spam behavior
|
- This is a significant penalty to discourage spam behavior.
|
||||||
- If the spam mark is removed, the 10 points are restored
|
- If the spam mark is removed, the 10 points are restored.
|
||||||
|
|
||||||
## Karma Tracking
|
## Karma Tracking
|
||||||
|
|
||||||
The system maintains a detailed record of all karma changes through:
|
The system maintains a detailed record of all karma changes through:
|
||||||
|
|
||||||
1. **Karma Transactions**
|
1. **Karma Transactions**
|
||||||
|
- Every karma change is recorded as a transaction.
|
||||||
- Every karma change is recorded as a transaction
|
|
||||||
- Each transaction includes:
|
- Each transaction includes:
|
||||||
- The action that triggered it
|
- The action that triggered it.
|
||||||
- The number of points awarded/deducted
|
- The number of points awarded/deducted.
|
||||||
- A description of why the karma changed
|
- A description of why the karma changed.
|
||||||
- The related comment (if applicable)
|
- The related comment (if applicable).
|
||||||
|
|
||||||
2. **Total Karma**
|
2. **Total Karma**
|
||||||
- Your total karma is displayed on your profile
|
- Your total karma is displayed on your profile.
|
||||||
- It's the sum of all your karma transactions
|
- It's the sum of all your karma transactions.
|
||||||
- This score helps establish your reputation in the community
|
- This score helps establish your reputation in the community.
|
||||||
|
|
||||||
## Impact of Karma
|
## Impact of Karma
|
||||||
|
|
||||||
Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate:
|
Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate:
|
||||||
|
|
||||||
- Active participation in the community
|
- Active participation in the community.
|
||||||
- History of providing valuable information
|
- History of providing valuable information.
|
||||||
- Trustworthiness of your contributions
|
- Trustworthiness of your contributions.
|
||||||
- Commitment to community standards
|
- Commitment to community standards.
|
||||||
|
|
||||||
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.
|
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user