Compare commits
16 Commits
release-70
...
release-88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7ae6dc22c | ||
|
|
e4a5fa8fa7 | ||
|
|
6ed07c8386 | ||
|
|
6a9f5f5e99 | ||
|
|
e6edee2dbe | ||
|
|
c7ee1606e4 | ||
|
|
f3c9b92ddb | ||
|
|
effb6689d7 | ||
|
|
cf5f3b3228 | ||
|
|
5a41816ac8 | ||
|
|
bf30a6cb2b | ||
|
|
4ca9b9a5c2 | ||
|
|
03abdef4f1 | ||
|
|
d9880fd83d | ||
|
|
39afcad089 | ||
|
|
99cb730bc0 |
@@ -96,7 +96,7 @@ Tasks will run according to their configured cron schedules.
|
||||
### Force Triggers Task
|
||||
|
||||
- Maintains database triggers by forcing them to run under certain conditions
|
||||
- Currently handles updating the "isRecentlyListed" flag for services after 15 days
|
||||
- Currently handles updating the "isRecentlyApproved" flag for services after 15 days
|
||||
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
|
||||
|
||||
### Service Score Recalculation Task
|
||||
|
||||
@@ -89,6 +89,17 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
||||
score_recalc_parser.add_argument(
|
||||
"--service-id", type=int, help="Specific service ID to process (optional)"
|
||||
)
|
||||
score_recalc_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Recalculate scores for all services (ignores --service-id)",
|
||||
)
|
||||
|
||||
# Service Score Recalculation task for all services
|
||||
subparsers.add_parser(
|
||||
"service-score-recalc-all",
|
||||
help="Recalculate service scores for all services",
|
||||
)
|
||||
|
||||
return parser.parse_args(args)
|
||||
|
||||
@@ -295,12 +306,15 @@ def run_force_triggers_task() -> int:
|
||||
close_db_pool()
|
||||
|
||||
|
||||
def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
def run_service_score_recalc_task(
|
||||
service_id: Optional[int] = None, all_services: bool = False
|
||||
) -> int:
|
||||
"""
|
||||
Run the service score recalculation task.
|
||||
|
||||
Args:
|
||||
service_id: Optional specific service ID to process.
|
||||
all_services: Whether to recalculate scores for all services.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
@@ -310,7 +324,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
try:
|
||||
# Initialize task and use as context manager
|
||||
with ServiceScoreRecalculationTask() as task: # type: ignore
|
||||
result = task.run(service_id) # type: ignore
|
||||
if all_services:
|
||||
queued = task.recalculate_all_services() # type: ignore
|
||||
if not queued:
|
||||
logger.warning(
|
||||
"Failed to queue recalculation jobs for all services"
|
||||
)
|
||||
|
||||
# Continuously process queued jobs in batches until none remain
|
||||
while True:
|
||||
_ = task.run() # type: ignore
|
||||
|
||||
# Check if there are still unprocessed jobs
|
||||
remaining = 0
|
||||
if task.conn:
|
||||
with task.conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'SELECT COUNT(*) FROM "ServiceScoreRecalculationJob" WHERE "processedAt" IS NULL'
|
||||
)
|
||||
remaining = cursor.fetchone()[0]
|
||||
|
||||
if remaining == 0:
|
||||
break
|
||||
|
||||
result = True # All jobs processed successfully
|
||||
|
||||
else:
|
||||
result = task.run(service_id) # type: ignore
|
||||
|
||||
if result:
|
||||
logger.info("Successfully recalculated service scores")
|
||||
else:
|
||||
@@ -323,6 +364,13 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
close_db_pool()
|
||||
|
||||
|
||||
def run_service_score_recalc_all_task() -> int:
|
||||
"""
|
||||
Run the service score recalculation task for all services.
|
||||
"""
|
||||
return run_service_score_recalc_task(all_services=True)
|
||||
|
||||
|
||||
def run_worker_mode() -> int:
|
||||
"""
|
||||
Run in worker mode, scheduling tasks to run periodically.
|
||||
@@ -361,15 +409,27 @@ def run_worker_mode() -> int:
|
||||
scheduler.register_task(
|
||||
task_name, cron_expression, run_service_score_recalc_task
|
||||
)
|
||||
elif task_name.lower() == "service_score_recalc_all":
|
||||
scheduler.register_task(
|
||||
task_name,
|
||||
cron_expression,
|
||||
run_service_score_recalc_all_task,
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unknown task '{task_name}', skipping")
|
||||
|
||||
# Register service score recalculation task (every 5 minutes)
|
||||
scheduler.register_task(
|
||||
"service-score-recalc",
|
||||
"service_score_recalc",
|
||||
"*/5 * * * *",
|
||||
run_service_score_recalc_task,
|
||||
)
|
||||
# Register daily service score recalculation for all services
|
||||
scheduler.register_task(
|
||||
"service_score_recalc_all",
|
||||
"0 0 * * *",
|
||||
run_service_score_recalc_all_task,
|
||||
)
|
||||
|
||||
# Start the scheduler if tasks were registered
|
||||
if scheduler.tasks:
|
||||
@@ -419,7 +479,11 @@ def main() -> int:
|
||||
elif args.task == "force-triggers":
|
||||
return run_force_triggers_task()
|
||||
elif args.task == "service-score-recalc":
|
||||
return run_service_score_recalc_task(args.service_id)
|
||||
return run_service_score_recalc_task(
|
||||
args.service_id, getattr(args, "all", False)
|
||||
)
|
||||
elif args.task == "service-score-recalc-all":
|
||||
return run_service_score_recalc_all_task()
|
||||
elif args.task:
|
||||
logger.error(f"Unknown task: {args.task}")
|
||||
return 1
|
||||
|
||||
@@ -333,28 +333,32 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
|
||||
|
||||
|
||||
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
|
||||
"""
|
||||
Save a TOS review for a specific service.
|
||||
"""Persist a TOS review and/or update the timestamp for a service.
|
||||
|
||||
Args:
|
||||
service_id: The ID of the service.
|
||||
review: A TypedDict containing the review data.
|
||||
If *review* is ``None`` the existing review (if any) is preserved while
|
||||
only the ``tosReviewAt`` column is updated. This ensures we still track
|
||||
when the review task last ran even if the review generation failed or
|
||||
produced no changes.
|
||||
"""
|
||||
try:
|
||||
# Only serialize to JSON if review is not None
|
||||
review_json = json.dumps(review) if review is not None else None
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE "Service"
|
||||
SET "tosReview" = %s, "tosReviewAt" = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(review_json, service_id),
|
||||
)
|
||||
if review is None:
|
||||
cursor.execute(
|
||||
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
|
||||
(service_id,),
|
||||
)
|
||||
else:
|
||||
review_json = json.dumps(review)
|
||||
cursor.execute(
|
||||
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
|
||||
(review_json, service_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"Successfully saved TOS review for service {service_id}")
|
||||
logger.info(
|
||||
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving TOS review for service {service_id}: {e}")
|
||||
|
||||
|
||||
@@ -62,25 +62,27 @@ class TaskScheduler:
|
||||
cron_expression: Cron expression defining the schedule.
|
||||
task_func: Function to execute.
|
||||
*args: Arguments to pass to the task function.
|
||||
**kwargs: Keyword arguments to pass to the task function.
|
||||
**kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg.
|
||||
"""
|
||||
instantiate = kwargs.pop("instantiate", True)
|
||||
# Declare task_instance variable with type annotation upfront
|
||||
task_instance: Any = None
|
||||
|
||||
# Initialize the appropriate task class based on the task name
|
||||
if task_name.lower() == "tosreview":
|
||||
task_instance = TosReviewTask()
|
||||
elif task_name.lower() == "user_sentiment":
|
||||
task_instance = UserSentimentTask()
|
||||
elif task_name.lower() == "comment_moderation":
|
||||
task_instance = CommentModerationTask()
|
||||
elif task_name.lower() == "force_triggers":
|
||||
task_instance = ForceTriggersTask()
|
||||
elif task_name.lower() == "service_score_recalc":
|
||||
task_instance = ServiceScoreRecalculationTask()
|
||||
else:
|
||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||
return
|
||||
if instantiate:
|
||||
# Initialize the appropriate task class based on the task name
|
||||
if task_name.lower() == "tosreview":
|
||||
task_instance = TosReviewTask()
|
||||
elif task_name.lower() == "user_sentiment":
|
||||
task_instance = UserSentimentTask()
|
||||
elif task_name.lower() == "comment_moderation":
|
||||
task_instance = CommentModerationTask()
|
||||
elif task_name.lower() == "force_triggers":
|
||||
task_instance = ForceTriggersTask()
|
||||
elif task_name.lower() == "service_score_recalc":
|
||||
task_instance = ServiceScoreRecalculationTask()
|
||||
else:
|
||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||
return
|
||||
|
||||
self.tasks[task_name] = {
|
||||
"cron": cron_expression,
|
||||
@@ -126,8 +128,12 @@ class TaskScheduler:
|
||||
self.logger.info(f"Running task '{task_name}'")
|
||||
# Use task instance as a context manager to ensure
|
||||
# a single database connection is used for the entire task
|
||||
with task_info["instance"]:
|
||||
# Execute the registered task function with its arguments
|
||||
if task_info["instance"]:
|
||||
with task_info["instance"]:
|
||||
# Execute the registered task function with its arguments
|
||||
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||
else:
|
||||
# Execute the registered task function without a context manager
|
||||
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||
self.logger.info(f"Task '{task_name}' completed")
|
||||
except Exception as e:
|
||||
|
||||
@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
|
||||
Force triggers to run under certain conditions.
|
||||
"""
|
||||
|
||||
RECENT_LISTED_INTERVAL_DAYS = 15
|
||||
RECENT_APPROVED_INTERVAL_DAYS = 15
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("force_triggers")
|
||||
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
|
||||
|
||||
update_query = f"""
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW()
|
||||
WHERE "isRecentlyListed" = TRUE
|
||||
AND "listedAt" IS NOT NULL
|
||||
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days'
|
||||
SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
|
||||
WHERE "isRecentlyApproved" = TRUE
|
||||
AND "approvedAt" IS NOT NULL
|
||||
AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
|
||||
"""
|
||||
try:
|
||||
with self.conn.cursor() as cursor:
|
||||
|
||||
@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM "Service"
|
||||
WHERE "isActive" = TRUE
|
||||
FROM "Service"
|
||||
"""
|
||||
)
|
||||
services = cursor.fetchall()
|
||||
|
||||
@@ -34,8 +34,8 @@ class TosReviewTask(Task):
|
||||
service_name = service["name"]
|
||||
verification_status = service.get("verificationStatus")
|
||||
|
||||
# Only process verified or approved services
|
||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]:
|
||||
# Only process verified, approved, or community contributed services
|
||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
|
||||
self.logger.info(
|
||||
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
||||
)
|
||||
|
||||
@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
|
||||
{"role": "user", "content": content},
|
||||
]
|
||||
|
||||
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
|
||||
result_dict = query_openai_json(
|
||||
messages, model="openai/gemini-2.5-flash-preview-05-20"
|
||||
)
|
||||
|
||||
return cast(TosReviewCheck, result_dict)
|
||||
|
||||
|
||||
@@ -35,9 +35,11 @@ export default defineConfig({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'KYCnot.me',
|
||||
short_name: 'KYCnot.me',
|
||||
description: 'Find services that respect your privacy',
|
||||
theme_color: '#040505',
|
||||
background_color: '#171c1b',
|
||||
display: 'minimal-ui',
|
||||
},
|
||||
pwaAssets: {
|
||||
image: './public/favicon.svg',
|
||||
@@ -107,8 +109,9 @@ export default defineConfig({
|
||||
'/service/[...slug]/proof': '/service/[...slug]/#verification',
|
||||
'/attribute/[...slug]': '/attributes',
|
||||
'/attr/[...slug]': '/attributes',
|
||||
'/service/[...slug]/review': '/service/[...slug]#comments',
|
||||
// #endregion
|
||||
|
||||
'/service/[...slug]/review': '/service/[...slug]#comments',
|
||||
},
|
||||
env: {
|
||||
schema: {
|
||||
@@ -122,7 +125,7 @@ export default defineConfig({
|
||||
}),
|
||||
// Public URLs (can be accessed from both server and client)
|
||||
SOURCE_CODE_URL: envField.string({
|
||||
context: 'server',
|
||||
context: 'client',
|
||||
access: 'public',
|
||||
url: true,
|
||||
optional: false,
|
||||
|
||||
883
web/package-lock.json
generated
@@ -31,26 +31,26 @@
|
||||
"@astrojs/rss": "4.0.12",
|
||||
"@astrojs/sitemap": "3.4.1",
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
"@fontsource/inter": "5.2.5",
|
||||
"@fontsource/inter": "5.2.6",
|
||||
"@fontsource/space-grotesk": "5.2.8",
|
||||
"@prisma/client": "6.9.0",
|
||||
"@tailwindcss/vite": "4.1.8",
|
||||
"@types/mime-types": "3.0.0",
|
||||
"@prisma/client": "6.10.1",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.9.0",
|
||||
"astro": "5.10.1",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"astro-seo-schema": "5.0.0",
|
||||
"canvas": "3.1.0",
|
||||
"canvas": "3.1.2",
|
||||
"clsx": "2.1.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"htmx.org": "2.0.6",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
"lodash-es": "4.17.21",
|
||||
"mime-types": "3.0.1",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"pg": "8.16.0",
|
||||
"pg": "8.16.3",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.5.6",
|
||||
@@ -58,52 +58,51 @@
|
||||
"seedrandom": "3.0.5",
|
||||
"sharp": "0.34.2",
|
||||
"slugify": "1.6.6",
|
||||
"tailwind-merge": "3.3.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.8",
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.3",
|
||||
"unique-username-generator": "1.4.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod-form-data": "2.0.7"
|
||||
"web-push": "3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.28.0",
|
||||
"@eslint/js": "9.30.0",
|
||||
"@faker-js/faker": "9.8.0",
|
||||
"@iconify-json/material-symbols": "1.2.24",
|
||||
"@iconify-json/material-symbols": "1.2.28",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
"@stylistic/eslint-plugin": "4.4.1",
|
||||
"@stylistic/eslint-plugin": "5.1.0",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.33.1",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
"@vite-pwa/assets-generator": "1.0.0",
|
||||
"@vite-pwa/astro": "1.1.0",
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-import-resolver-typescript": "4.4.3",
|
||||
"eslint": "9.30.0",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"globals": "16.2.0",
|
||||
"prettier": "3.5.3",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.12",
|
||||
"prisma": "6.9.0",
|
||||
"prisma-json-types-generator": "3.4.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.13",
|
||||
"prisma": "6.10.1",
|
||||
"prisma-json-types-generator": "3.5.0",
|
||||
"tailwind-htmx": "0.1.2",
|
||||
"ts-essentials": "10.0.4",
|
||||
"ts-essentials": "10.1.1",
|
||||
"ts-toolbelt": "9.6.0",
|
||||
"tsx": "4.19.4",
|
||||
"typescript-eslint": "8.33.1",
|
||||
"vite-plugin-devtools-json": "0.1.1",
|
||||
"tsx": "4.20.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"vite-plugin-devtools-json": "0.2.1",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "spamAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `isRecentlyListed` on the `Service` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" DROP COLUMN "isRecentlyListed",
|
||||
ADD COLUMN "isRecentlyApproved" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_approvedAt_idx" ON "Service"("approvedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_verifiedAt_idx" ON "Service"("verifiedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_spamAt_idx" ON "Service"("spamAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_serviceVisibility_idx" ON "Service"("serviceVisibility");
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);
|
||||
@@ -349,12 +349,12 @@ model Service {
|
||||
categories Category[] @relation("ServiceToCategory")
|
||||
kycLevel Int @default(4)
|
||||
kycLevelClarification KycLevelClarification @default(NONE)
|
||||
/// The first known date when the service started operating. Used for New/Mature service attributes.
|
||||
operatingSince DateTime?
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
isRecentlyListed Boolean @default(false)
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
averageUserRating Float?
|
||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||
@@ -363,8 +363,6 @@ model Service {
|
||||
verificationSummary String?
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
verificationProofMd String?
|
||||
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||
verifiedAt DateTime?
|
||||
/// [UserSentiment]
|
||||
userSentiment Json?
|
||||
userSentimentAt DateTime?
|
||||
@@ -380,7 +378,16 @@ model Service {
|
||||
tosReviewAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
|
||||
listedAt DateTime?
|
||||
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
|
||||
approvedAt DateTime?
|
||||
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||
verifiedAt DateTime?
|
||||
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
|
||||
spamAt DateTime?
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
isRecentlyApproved Boolean @default(false)
|
||||
comments Comment[]
|
||||
events Event[]
|
||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||
@@ -396,6 +403,9 @@ model Service {
|
||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||
|
||||
@@index([listedAt])
|
||||
@@index([approvedAt])
|
||||
@@index([verifiedAt])
|
||||
@@index([spamAt])
|
||||
@@index([overallScore])
|
||||
@@index([privacyScore])
|
||||
@@index([trustScore])
|
||||
@@ -407,6 +417,7 @@ model Service {
|
||||
@@index([updatedAt])
|
||||
@@index([slug])
|
||||
@@index([previousSlugs])
|
||||
@@index([serviceVisibility])
|
||||
}
|
||||
|
||||
model ServiceContactMethod {
|
||||
@@ -578,6 +589,7 @@ enum VerificationStepStatus {
|
||||
IN_PROGRESS
|
||||
PASSED
|
||||
FAILED
|
||||
WARNING
|
||||
}
|
||||
|
||||
model VerificationStep {
|
||||
@@ -677,8 +689,6 @@ model PushSubscription {
|
||||
p256dh String
|
||||
/// Authentication secret
|
||||
auth String
|
||||
/// To identify different devices
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -13,14 +13,15 @@ import {
|
||||
PrismaClient,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceUserRole,
|
||||
VerificationStatus,
|
||||
type Prisma,
|
||||
type User,
|
||||
type ServiceVisibility,
|
||||
ServiceSuggestionType,
|
||||
KycLevelClarification,
|
||||
VerificationStepStatus,
|
||||
type VerificationStatus,
|
||||
} from '@prisma/client'
|
||||
import { differenceInDays, isPast } from 'date-fns'
|
||||
import { omit, uniqBy } from 'lodash-es'
|
||||
import { generateUsername } from 'unique-username-generator'
|
||||
|
||||
@@ -614,6 +615,14 @@ const generateFakeService = (users: User[]) => {
|
||||
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
|
||||
probability: 0.8,
|
||||
})
|
||||
const serviceVisibility = faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||
{ weight: 80, value: 'PUBLIC' },
|
||||
{ weight: 10, value: 'UNLISTED' },
|
||||
{ weight: 5, value: 'HIDDEN' },
|
||||
{ weight: 5, value: 'ARCHIVED' },
|
||||
])
|
||||
const approvedAt =
|
||||
status === 'APPROVED' || status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null
|
||||
|
||||
return {
|
||||
name,
|
||||
@@ -629,12 +638,7 @@ const generateFakeService = (users: User[]) => {
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||
{ weight: 80, value: 'PUBLIC' },
|
||||
{ weight: 10, value: 'UNLISTED' },
|
||||
{ weight: 5, value: 'HIDDEN' },
|
||||
{ weight: 5, value: 'ARCHIVED' },
|
||||
]),
|
||||
serviceVisibility,
|
||||
verificationStatus: status,
|
||||
verificationSummary:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||
@@ -677,8 +681,14 @@ const generateFakeService = (users: User[]) => {
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||
listedAt: faker.date.past(),
|
||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
||||
listedAt:
|
||||
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
|
||||
? faker.date.recent({ days: 30 })
|
||||
: null,
|
||||
verifiedAt: status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null,
|
||||
spamAt: status === 'VERIFICATION_FAILED' ? faker.date.recent({ days: 30 }) : null,
|
||||
approvedAt,
|
||||
isRecentlyApproved: !!approvedAt && isPast(approvedAt) && differenceInDays(new Date(), approvedAt) < 15,
|
||||
tosReview,
|
||||
tosReviewAt: tosReview
|
||||
? faker.date.recent()
|
||||
@@ -908,7 +918,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
||||
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
||||
},
|
||||
{
|
||||
label: faker.lorem.word({ length: 2 }),
|
||||
label: 'Custom label',
|
||||
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||
},
|
||||
{
|
||||
@@ -918,7 +928,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
||||
value: faker.internet.url(),
|
||||
},
|
||||
{
|
||||
label: faker.lorem.word({ length: 2 }),
|
||||
label: 'Custom label',
|
||||
value: faker.internet.url(),
|
||||
},
|
||||
{
|
||||
@@ -1307,7 +1317,7 @@ async function main() {
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
|
||||
@@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
DECLARE
|
||||
is_user_related_to_service BOOLEAN;
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
1,
|
||||
'COMMENT_APPROVED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been approved!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 1);
|
||||
-- Check if the user is related to the service (e.g., owns/manages it)
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM "ServiceUser"
|
||||
WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId"
|
||||
) INTO is_user_related_to_service;
|
||||
|
||||
-- Check if the user is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."authorId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
|
||||
-- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator
|
||||
IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
1,
|
||||
'COMMENT_APPROVED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been approved!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 1);
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
DECLARE
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
5,
|
||||
'COMMENT_VERIFIED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been verified!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 5);
|
||||
-- Check if the comment author is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."authorId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
|
||||
-- Only award karma if the user is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
5,
|
||||
'COMMENT_VERIFIED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been verified!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 5);
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -146,12 +175,19 @@ DECLARE
|
||||
comment_author_id INT;
|
||||
service_name TEXT;
|
||||
upvote_change INT := 0; -- Variable to track change in upvotes
|
||||
is_author_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
-- Get comment author and service info
|
||||
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
||||
FROM "Comment" c
|
||||
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
|
||||
|
||||
-- Check if the comment author is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = comment_author_id
|
||||
INTO is_author_admin_or_moderator;
|
||||
|
||||
-- Calculate karma impact based on vote type
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- New vote
|
||||
@@ -181,16 +217,19 @@ BEGIN
|
||||
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
||||
END IF;
|
||||
|
||||
-- Record karma transaction and update user karma
|
||||
PERFORM insert_karma_transaction(
|
||||
comment_author_id,
|
||||
karma_points,
|
||||
vote_action,
|
||||
COALESCE(NEW."commentId", OLD."commentId"),
|
||||
vote_description
|
||||
);
|
||||
|
||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||
-- Only award karma if the author is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_author_admin_or_moderator, false) THEN
|
||||
-- Record karma transaction and update user karma
|
||||
PERFORM insert_karma_transaction(
|
||||
comment_author_id,
|
||||
karma_points,
|
||||
vote_action,
|
||||
COALESCE(NEW."commentId", OLD."commentId"),
|
||||
vote_description
|
||||
);
|
||||
|
||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||
END IF;
|
||||
|
||||
-- Update comment's upvotes count incrementally
|
||||
UPDATE "Comment"
|
||||
@@ -236,26 +275,36 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
service_name TEXT;
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
-- Award karma for first approval
|
||||
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
||||
-- and ensure it wasn't already APPROVED.
|
||||
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
||||
-- Fetch service name for the description
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
||||
-- Check if the user is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."userId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
|
||||
-- Only award karma if the user is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
-- Fetch service name for the description
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
||||
|
||||
-- Insert karma transaction, linking it to the suggestion
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."userId",
|
||||
10,
|
||||
'SUGGESTION_APPROVED',
|
||||
NULL, -- p_comment_id (not applicable)
|
||||
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
||||
NEW.id -- p_suggestion_id
|
||||
);
|
||||
-- Insert karma transaction, linking it to the suggestion
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."userId",
|
||||
10,
|
||||
'SUGGESTION_APPROVED',
|
||||
NULL, -- p_comment_id (not applicable)
|
||||
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
||||
NEW.id -- p_suggestion_id
|
||||
);
|
||||
|
||||
-- Update user's total karma
|
||||
PERFORM update_user_karma(NEW."userId", 10);
|
||||
-- Update user's total karma
|
||||
PERFORM update_user_karma(NEW."userId", 10);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
||||
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
||||
-- for services when services or their attributes change.
|
||||
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days.
|
||||
-- 2. Updates the isRecentlyApproved flag for services approved within the last 15 days.
|
||||
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
||||
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
||||
-- efficient handling of widespread score updates.
|
||||
@@ -24,12 +24,9 @@ RETURNS INT AS $$
|
||||
DECLARE
|
||||
privacy_score INT := 0;
|
||||
kyc_factor INT;
|
||||
onion_factor INT := 0;
|
||||
i2p_factor INT := 0;
|
||||
clarification_factor INT := 0;
|
||||
onion_or_i2p_factor INT := 0;
|
||||
monero_factor INT := 0;
|
||||
open_source_factor INT := 0;
|
||||
p2p_factor INT := 0;
|
||||
decentralized_factor INT := 0;
|
||||
attributes_score INT := 0;
|
||||
BEGIN
|
||||
-- Get service data
|
||||
@@ -46,20 +43,22 @@ BEGIN
|
||||
FROM "Service"
|
||||
WHERE "id" = service_id;
|
||||
|
||||
-- Check for onion URLs
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
|
||||
) THEN
|
||||
onion_factor := 5;
|
||||
END IF;
|
||||
-- Adjust score based on KYC level clarification modifiers
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
|
||||
ELSE 0 -- Default modifier when no clarification or unrecognized value
|
||||
END
|
||||
INTO clarification_factor
|
||||
FROM "Service"
|
||||
WHERE "id" = service_id;
|
||||
|
||||
-- Check for i2p URLs
|
||||
-- Check for onion or i2p URLs
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
|
||||
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
|
||||
) THEN
|
||||
i2p_factor := 5;
|
||||
onion_or_i2p_factor := 5;
|
||||
END IF;
|
||||
|
||||
-- Check for Monero acceptance
|
||||
@@ -75,10 +74,10 @@ BEGIN
|
||||
INTO attributes_score
|
||||
FROM "ServiceAttribute" sa
|
||||
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
||||
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
||||
WHERE sa."serviceId" = service_id;
|
||||
|
||||
-- Calculate final privacy score (base 100)
|
||||
privacy_score := 50 + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
||||
privacy_score := 50 + kyc_factor + clarification_factor + onion_or_i2p_factor + monero_factor + attributes_score;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||
@@ -94,8 +93,9 @@ DECLARE
|
||||
trust_score INT := 0;
|
||||
verification_factor INT;
|
||||
attributes_score INT := 0;
|
||||
recently_listed_factor INT := 0;
|
||||
recently_approved_factor INT := 0;
|
||||
tos_penalty_factor INT := 0;
|
||||
operating_since_factor INT := 0;
|
||||
BEGIN
|
||||
-- Get verification status factor
|
||||
SELECT
|
||||
@@ -115,26 +115,26 @@ BEGIN
|
||||
INTO attributes_score
|
||||
FROM "ServiceAttribute" sa
|
||||
JOIN "Attribute" a ON sa."attributeId" = a.id
|
||||
WHERE sa."serviceId" = service_id AND a.category = 'TRUST';
|
||||
WHERE sa."serviceId" = service_id;
|
||||
|
||||
-- Apply penalty if service was listed within the last 15 days
|
||||
-- Apply penalty if service was approved within the last 15 days
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "Service"
|
||||
WHERE id = service_id
|
||||
AND "listedAt" IS NOT NULL
|
||||
AND "approvedAt" IS NOT NULL
|
||||
AND "verificationStatus" = 'APPROVED'
|
||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
||||
AND (NOW() - "approvedAt") <= INTERVAL '15 days'
|
||||
) THEN
|
||||
recently_listed_factor := -10;
|
||||
-- Update the isRecentlyListed flag to true
|
||||
recently_approved_factor := -10;
|
||||
-- Update the isRecentlyApproved flag to true
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = TRUE
|
||||
SET "isRecentlyApproved" = TRUE
|
||||
WHERE id = service_id;
|
||||
ELSE
|
||||
-- Update the isRecentlyListed flag to false
|
||||
-- Update the isRecentlyApproved flag to false
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = FALSE
|
||||
SET "isRecentlyApproved" = FALSE
|
||||
WHERE id = service_id;
|
||||
END IF;
|
||||
|
||||
@@ -149,8 +149,20 @@ BEGIN
|
||||
tos_penalty_factor := -3;
|
||||
END IF;
|
||||
|
||||
-- Determine trust adjustment based on operatingSince
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "operatingSince" IS NULL THEN 0
|
||||
WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty
|
||||
WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus
|
||||
ELSE 0
|
||||
END
|
||||
INTO operating_since_factor
|
||||
FROM "Service"
|
||||
WHERE id = service_id;
|
||||
|
||||
-- Calculate final trust score (base 100)
|
||||
trust_score := 50 + verification_factor + attributes_score + recently_listed_factor + tos_penalty_factor;
|
||||
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||
@@ -165,7 +177,7 @@ RETURNS INT AS $$
|
||||
DECLARE
|
||||
overall_score INT;
|
||||
BEGIN
|
||||
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT);
|
||||
overall_score := CAST(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0 AS INT);
|
||||
RETURN GREATEST(0, LEAST(10, overall_score));
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
|
||||
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
|
||||
-- are set or cleared appropriately when a service's verification status is updated.
|
||||
|
||||
CREATE OR REPLACE FUNCTION manage_service_timestamps()
|
||||
CREATE OR REPLACE FUNCTION manage_service_visibility_timestamps()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Manage listedAt timestamp
|
||||
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
||||
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
|
||||
IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
|
||||
IF OLD."listedAt" IS NULL THEN
|
||||
NEW."listedAt" := NOW();
|
||||
NEW."isRecentlyListed" := TRUE;
|
||||
END IF;
|
||||
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
||||
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
|
||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
||||
ELSE
|
||||
NEW."listedAt" := NULL;
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
END IF;
|
||||
|
||||
-- Manage verifiedAt timestamp
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
|
||||
NEW."verifiedAt" := NOW();
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
|
||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
||||
NEW."verifiedAt" := NULL;
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the old trigger first if it exists under the old name
|
||||
CREATE OR REPLACE FUNCTION manage_service_verification_timestamps()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF (NEW."verificationStatus" = 'APPROVED' OR NEW."verificationStatus" = 'VERIFICATION_SUCCESS') THEN
|
||||
IF OLD."approvedAt" IS NULL THEN
|
||||
NEW."approvedAt" := NOW();
|
||||
NEW."isRecentlyApproved" := TRUE;
|
||||
END IF;
|
||||
ELSE
|
||||
NEW."approvedAt" := NULL;
|
||||
NEW."isRecentlyApproved" := FALSE;
|
||||
END IF;
|
||||
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
NEW."verifiedAt" := NOW();
|
||||
ELSE
|
||||
NEW."verifiedAt" := NULL;
|
||||
END IF;
|
||||
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_FAILED' THEN
|
||||
NEW."spamAt" := NOW();
|
||||
ELSE
|
||||
NEW."spamAt" := NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the old triggers TODO: remove this some day
|
||||
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
|
||||
-- Drop the trigger if it exists under the new name
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_timestamps
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_visibility_timestamps ON "Service";
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_verification_timestamps ON "Service";
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_visibility_timestamps
|
||||
BEFORE UPDATE OF "serviceVisibility" ON "Service"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION manage_service_visibility_timestamps();
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_verification_timestamps
|
||||
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
||||
FOR EACH ROW
|
||||
-- Only execute the function if the verificationStatus value has actually changed
|
||||
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
|
||||
EXECUTE FUNCTION manage_service_timestamps();
|
||||
EXECUTE FUNCTION manage_service_verification_timestamps();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||
import { createAccount } from '../lib/accountCreate'
|
||||
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { startImpersonating } from '../lib/impersonation'
|
||||
import { startImpersonating, stopImpersonating } from '../lib/impersonation'
|
||||
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
||||
@@ -225,4 +226,36 @@ export const accountActions = {
|
||||
return { user }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z
|
||||
.object({
|
||||
...captchaFormSchemaProperties,
|
||||
})
|
||||
.superRefine(captchaFormSchemaSuperRefine),
|
||||
handler: async (_input, context) => {
|
||||
if (context.locals.user.admin || context.locals.user.moderator) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Admins and moderators cannot delete their own accounts.',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: context.locals.user.id },
|
||||
})
|
||||
|
||||
const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture'])
|
||||
|
||||
if (context.locals.actualUser) {
|
||||
await stopImpersonating(context)
|
||||
} else {
|
||||
await logout(context)
|
||||
}
|
||||
|
||||
return { deletedUser }
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ const serviceSchemaBase = z.object({
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(null),
|
||||
operatingSince: z.coerce.date().optional().nullable(),
|
||||
imageFile: imageFileSchema,
|
||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
@@ -126,7 +127,8 @@ export const adminServiceActions = {
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
@@ -149,6 +151,7 @@ export const adminServiceActions = {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
operatingSince: input.operatingSince,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -244,7 +247,8 @@ export const adminServiceActions = {
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
@@ -256,7 +260,6 @@ export const adminServiceActions = {
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
imageUrl,
|
||||
categories: {
|
||||
connect: categoriesToAdd.map((id) => ({ id })),
|
||||
@@ -273,6 +276,7 @@ export const adminServiceActions = {
|
||||
attributeId,
|
||||
})),
|
||||
},
|
||||
operatingSince: input.operatingSince,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -65,13 +65,13 @@ export const apiServiceActions = {
|
||||
tosUrls: true,
|
||||
referral: true,
|
||||
listedAt: true,
|
||||
approvedAt: true,
|
||||
verifiedAt: true,
|
||||
serviceVisibility: true,
|
||||
} as const satisfies Prisma.ServiceSelect
|
||||
|
||||
let service = await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
OR: [
|
||||
@@ -92,7 +92,6 @@ export const apiServiceActions = {
|
||||
if (!service && input.slug) {
|
||||
service = await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
previousSlugs: { has: input.slug },
|
||||
@@ -105,9 +104,7 @@ export const apiServiceActions = {
|
||||
!service ||
|
||||
(service.serviceVisibility !== 'PUBLIC' &&
|
||||
service.serviceVisibility !== 'ARCHIVED' &&
|
||||
service.serviceVisibility !== 'UNLISTED') ||
|
||||
!service.listedAt ||
|
||||
service.listedAt > new Date()
|
||||
service.serviceVisibility !== 'UNLISTED')
|
||||
) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
@@ -130,6 +127,7 @@ export const apiServiceActions = {
|
||||
'description',
|
||||
]),
|
||||
verifiedAt: service.verifiedAt,
|
||||
approvedAt: service.approvedAt,
|
||||
kycLevel: service.kycLevel,
|
||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||
kycLevelClarification: service.kycLevelClarification,
|
||||
|
||||
@@ -270,6 +270,18 @@ export const commentActions = {
|
||||
}
|
||||
}
|
||||
|
||||
const isRelatedToService = !!(await tx.serviceUser.findUnique({
|
||||
where: {
|
||||
userId_serviceId: {
|
||||
userId: context.locals.user.id,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}))
|
||||
|
||||
// Prepare data object with proper type safety
|
||||
const commentData: Prisma.CommentCreateInput = {
|
||||
content: input.content,
|
||||
@@ -277,7 +289,12 @@ export const commentActions = {
|
||||
author: { connect: { id: context.locals.user.id } },
|
||||
|
||||
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
||||
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
|
||||
status:
|
||||
context.locals.user.admin || context.locals.user.moderator || isRelatedToService
|
||||
? 'APPROVED'
|
||||
: isIssueReport
|
||||
? 'HUMAN_PENDING'
|
||||
: 'PENDING',
|
||||
requiresAdminReview,
|
||||
orderId: input.orderId?.trim() ?? null,
|
||||
kycRequested: input.issueKycRequested === true,
|
||||
|
||||
@@ -32,7 +32,6 @@ export const notificationActions = {
|
||||
endpoint: z.string(),
|
||||
p256dhKey: z.string(),
|
||||
authKey: z.string(),
|
||||
userAgent: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.pushSubscription.upsert({
|
||||
@@ -43,14 +42,12 @@ export const notificationActions = {
|
||||
update: {
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -58,25 +55,17 @@ export const notificationActions = {
|
||||
|
||||
unsubscribe: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'user',
|
||||
permissions: 'guest',
|
||||
input: z.object({
|
||||
endpoint: z.string().optional(),
|
||||
endpoint: z.string(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
if (input.endpoint) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
await prisma.pushSubscription.delete({
|
||||
where: {
|
||||
userId: context.locals.user?.id ?? undefined,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -36,7 +36,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||
},
|
||||
@@ -166,6 +165,7 @@ export const serviceSuggestionActions = {
|
||||
message: 'You must accept the suggestion rules and process to continue',
|
||||
}),
|
||||
}),
|
||||
operatingSince: z.coerce.date().optional(),
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
skipDuplicateCheck: z
|
||||
@@ -240,6 +240,7 @@ export const serviceSuggestionActions = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
operatingSince: input.operatingSince,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
@@ -252,7 +253,6 @@ export const serviceSuggestionActions = {
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
listedAt: new Date(),
|
||||
serviceVisibility: 'UNLISTED',
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
8
web/src/assets/review-badge/long-black.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#BEBEBE"
|
||||
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#3BDB78"
|
||||
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
8
web/src/assets/review-badge/long-white.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||
<rect width="431" height="63" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
|
||||
<path fill="#28AE5B" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#3F3F3F"
|
||||
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#28AE5B"
|
||||
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
8
web/src/assets/review-badge/short-black.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
|
||||
<rect width="255" height="95" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||
<path fill="#3BDB78" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#BEBEBE"
|
||||
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#3BDB78"
|
||||
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
8
web/src/assets/review-badge/short-white.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
|
||||
<rect width="255" height="95" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
|
||||
<path fill="#28AE5B" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#3F3F3F"
|
||||
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#28AE5B"
|
||||
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
24
web/src/components/AdminNavigationFixScript.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
if (!Astro.locals.user?.admin) return
|
||||
---
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Script that adds data-astro-reload to all admin links or all links if on an admin page. //
|
||||
// This is a workaround to prevent the client router messing up inputs. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLAnchorElement | HTMLFormElement>('a,form').forEach((element) => {
|
||||
const isAdminPage = window.location.pathname.startsWith('/admin')
|
||||
if (isAdminPage) {
|
||||
element.setAttribute('data-astro-reload', '')
|
||||
}
|
||||
|
||||
const url = element.href ? new URL(element.href) : null
|
||||
if (url?.pathname.startsWith('/admin')) {
|
||||
element.setAttribute('data-astro-reload', '')
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -6,8 +6,10 @@ import { pwaAssetsHead } from 'virtual:pwa-assets/head'
|
||||
import { pwaInfo } from 'virtual:pwa-info'
|
||||
|
||||
import { isNotArray } from '../lib/arrays'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
|
||||
import AdminNavigationFixScript from './AdminNavigationFixScript.astro'
|
||||
import DevToolsMessageScript from './DevToolsMessageScript.astro'
|
||||
import DynamicFavicon from './DynamicFavicon.astro'
|
||||
import HtmxScript from './HtmxScript.astro'
|
||||
import NotificationEventsScript from './NotificationEventsScript.astro'
|
||||
@@ -106,8 +108,9 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
|
||||
<DynamicFavicon />
|
||||
|
||||
<!-- Components -->
|
||||
<ClientRouter />
|
||||
<AdminNavigationFixScript />
|
||||
|
||||
<LoadingIndicator color="green" />
|
||||
<TailwindJsPluggin />
|
||||
{htmx && <HtmxScript />}
|
||||
@@ -147,3 +150,5 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<DevToolsMessageScript />
|
||||
|
||||
@@ -288,6 +288,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||
aria-disabled={disabled}
|
||||
aria-label={label}
|
||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||
{...htmlProps}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../lib/commentsWithReplies'
|
||||
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { formatDateShort } from '../lib/timeAgo'
|
||||
import { urlDomain } from '../lib/urls'
|
||||
|
||||
import BadgeSmall from './BadgeSmall.astro'
|
||||
import CommentModeration from './CommentModeration.astro'
|
||||
@@ -150,13 +151,13 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
checked={comment.suspicious}
|
||||
/>
|
||||
|
||||
<div class="comment-header scrollbar-w-none -mt-10 flex items-center gap-2 overflow-auto pt-10 text-sm">
|
||||
<div class="comment-header flex items-center gap-2 text-sm">
|
||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||
<span class="collapse-symbol text-xs"></span>
|
||||
<span class="sr-only">Toggle comment visibility</span>
|
||||
</label>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="flex min-w-16 items-center gap-1">
|
||||
<UserBadge
|
||||
user={comment.author}
|
||||
size="md"
|
||||
@@ -170,7 +171,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
comment.author.admin || comment.author.moderator
|
||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||
: ''
|
||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
||||
}${comment.author.verifiedLink ? `Related to ${urlDomain(comment.author.verifiedLink)}` : ''}`}
|
||||
>
|
||||
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
||||
</Tooltip>
|
||||
@@ -179,7 +180,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
</span>
|
||||
|
||||
{/* User badges - more compact but still with text */}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<div class="flex w-min grow flex-wrap items-center gap-1">
|
||||
{
|
||||
comment.author.admin && (
|
||||
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
|
||||
|
||||
@@ -71,12 +71,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
|
||||
'Failed to fetch comments',
|
||||
async () =>
|
||||
await prisma.comment.findMany(
|
||||
makeCommentsNestedQuery({
|
||||
await makeCommentsNestedQuery({
|
||||
depth: MAX_COMMENT_DEPTH,
|
||||
user,
|
||||
showPending: params.showPending,
|
||||
serviceId: service.id,
|
||||
sort: params.sort,
|
||||
highlightedCommentId: params.comment,
|
||||
})
|
||||
),
|
||||
[],
|
||||
|
||||
51
web/src/components/DevToolsMessageScript.astro
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||
|
||||
const logoStyle = `
|
||||
padding: 0 119.5px;
|
||||
display: block;
|
||||
line-height: 64px;
|
||||
background-size: auto 64px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50% 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 240 64'%3E%3Crect width='239' height='63' x='.5' y='.5' fill='%23101413' stroke='%23292B2A' rx='7.5'/%3E%3Cpath fill='%233BDB78' d='M19 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1H19Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.9 10.8a1 1 0 0 0-.1.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L47 23.5a1 1 0 0 1 .8-1.5ZM75 22h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H74v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H75a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L186 37.2l-6.7-14.6a1 1 0 0 0-1-.6H175Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z'/%3E%3C/svg%3E");
|
||||
`
|
||||
|
||||
setTimeout(
|
||||
console.log.bind(
|
||||
console,
|
||||
`\n%c \n%c\n 👋%c Hi there! %c\n\n‣ We included source maps, so you can easily inspect the code. 🕵🏻♂️\n‣ Everything works with JavaScript disabled.\n‣ Source code: ${SOURCE_CODE_URL}`,
|
||||
logoStyle,
|
||||
`
|
||||
font-family: cursive;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
`,
|
||||
`
|
||||
font-family: cursive;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
#d97706 0%,
|
||||
#f59e0b 20%,
|
||||
#f97316 40%,
|
||||
#ea580c 60%,
|
||||
#f97316 80%,
|
||||
#f59e0b 100%
|
||||
) -100%/ 200%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
`,
|
||||
`
|
||||
font-size: 1rem;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
`
|
||||
)
|
||||
)
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
|
||||
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||
import { I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
|
||||
import { sample } from 'lodash-es'
|
||||
|
||||
import { splashTexts } from '../constants/splashTexts'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { cn } from '../lib/cn'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
||||
|
||||
import AdminOnly from './AdminOnly.astro'
|
||||
@@ -123,6 +123,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
||||
transition:name="header-admin-link"
|
||||
text="Admin Dashboard"
|
||||
position="left"
|
||||
aria-label="Admin Dashboard"
|
||||
>
|
||||
<Icon name="ri:home-gear-line" class="size-10" />
|
||||
</Tooltip>
|
||||
|
||||
@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
|
||||
iconClass?: string
|
||||
description?: MarkdownString
|
||||
disabled?: boolean
|
||||
noTransitionPersist?: boolean
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValue?: Multiple extends true ? string[] : string
|
||||
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
||||
transition:persist
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
|
||||
<script>
|
||||
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
|
||||
import { makeNotificationOptions } from '../lib/notificationOptions'
|
||||
import {
|
||||
makeBrowserNotificationOptions,
|
||||
makeBrowserNotificationTitle,
|
||||
} from '../lib/client/notificationOptions'
|
||||
|
||||
document.addEventListener('sse:new-notification', (event) => {
|
||||
if (isBrowserNotificationsEnabled()) {
|
||||
const payload = event.detail
|
||||
const notification = showBrowserNotification(
|
||||
payload.title,
|
||||
makeNotificationOptions(payload, { removeActions: true })
|
||||
makeBrowserNotificationTitle(payload.title),
|
||||
makeBrowserNotificationOptions(payload, { removeActions: true })
|
||||
)
|
||||
|
||||
// Handle notification click
|
||||
|
||||
151
web/src/components/PressAssets.astro
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
import favicon from '../../public/favicon.svg'
|
||||
import logoMiniFull from '../assets/logo/logo-mini-full.svg'
|
||||
import logoNormal from '../assets/logo/logo-normal.svg'
|
||||
import logoSmall from '../assets/logo/logo-small.svg'
|
||||
import reviewBadgeLongBlack from '../assets/review-badge/long-black.svg'
|
||||
import reviewBadgeLongWhite from '../assets/review-badge/long-white.svg'
|
||||
import reviewBadgeShortBlack from '../assets/review-badge/short-black.svg'
|
||||
import reviewBadgeShortWhite from '../assets/review-badge/short-white.svg'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import MyPicture from './MyPicture.astro'
|
||||
|
||||
const categories: {
|
||||
title: string
|
||||
assets: {
|
||||
name: string
|
||||
path: typeof logoNormal
|
||||
alt: string
|
||||
}[]
|
||||
}[] = [
|
||||
{
|
||||
title: 'Logos',
|
||||
assets: [
|
||||
{
|
||||
name: 'Logo',
|
||||
path: logoNormal,
|
||||
alt: 'KYCnot.me logo normal version',
|
||||
},
|
||||
{
|
||||
name: 'Logo small',
|
||||
path: logoSmall,
|
||||
alt: 'KYCnot.me logo small version',
|
||||
},
|
||||
{
|
||||
name: 'Logo mini',
|
||||
path: logoMiniFull,
|
||||
alt: 'KYCnot.me logo mini version',
|
||||
},
|
||||
{
|
||||
name: 'Logo icon',
|
||||
path: favicon,
|
||||
alt: 'KYCnot.me logo icon version',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Review badges',
|
||||
assets: [
|
||||
{
|
||||
name: 'Review badge (long black)',
|
||||
path: reviewBadgeLongBlack,
|
||||
alt: 'KYCnot.me review badge long black version',
|
||||
},
|
||||
{
|
||||
name: 'Review badge (long white)',
|
||||
path: reviewBadgeLongWhite,
|
||||
alt: 'KYCnot.me review badge long white version',
|
||||
},
|
||||
{
|
||||
name: 'Review badge (short black)',
|
||||
path: reviewBadgeShortBlack,
|
||||
alt: 'KYCnot.me review badge short black version',
|
||||
},
|
||||
{
|
||||
name: 'Review badge (short white)',
|
||||
path: reviewBadgeShortWhite,
|
||||
alt: 'KYCnot.me review badge short white version',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
---
|
||||
|
||||
<div class="not-prose mb-16 space-y-8">
|
||||
{
|
||||
categories.map((category) => {
|
||||
return (
|
||||
<>
|
||||
<h3 class="font-title mb-2 text-center text-xl font-semibold text-white">{category.title}</h3>
|
||||
<ul class="xs:grid-cols-2 grid grid-cols-1 gap-6">
|
||||
{category.assets.map((asset) => (
|
||||
<li>
|
||||
<div
|
||||
class="bg-transparency-grid mx-auto flex aspect-[3/1] max-w-sm items-center justify-center rounded-lg p-4"
|
||||
style={{
|
||||
'--transparency-grid-color-1': 'var(--color-night-600)',
|
||||
'--transparency-grid-color-2': 'var(--color-night-500)',
|
||||
'--transparency-grid-size': 'calc(var(--spacing) * 4)',
|
||||
}}
|
||||
>
|
||||
<MyPicture
|
||||
src={asset.path}
|
||||
alt={asset.alt}
|
||||
pictureAttributes={{
|
||||
class: 'contents',
|
||||
}}
|
||||
class="max-h-full min-h-8 max-w-full min-w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 text-center">
|
||||
<Button
|
||||
as="a"
|
||||
href={asset.path.src}
|
||||
download={asset.name}
|
||||
label={asset.name}
|
||||
size="sm"
|
||||
color="white"
|
||||
variant="faded"
|
||||
icon="ri:download-line"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg-transparency-grid {
|
||||
--transparency-grid-color-1: #fff;
|
||||
--transparency-grid-color-2: #ccc;
|
||||
--transparency-grid-size: calc(var(--spacing) * 8);
|
||||
|
||||
background-color: var(--transparency-grid-color-1);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--transparency-grid-color-2) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--transparency-grid-color-2) 75%,
|
||||
var(--transparency-grid-color-2)
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--transparency-grid-color-2) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--transparency-grid-color-2) 75%,
|
||||
var(--transparency-grid-color-2)
|
||||
);
|
||||
background-size: var(--transparency-grid-size) var(--transparency-grid-size);
|
||||
background-position:
|
||||
0 0,
|
||||
calc(var(--transparency-grid-size) / 2) calc(var(--transparency-grid-size) / 2);
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,6 @@ type Props = HTMLAttributes<'div'> & {
|
||||
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||
select: {
|
||||
endpoint: true
|
||||
userAgent: true
|
||||
}
|
||||
}>[]
|
||||
}
|
||||
|
||||
@@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { HTMLTag, Polymorphic } from 'astro/types'
|
||||
import type { Review, WithContext } from 'schema-dts'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
type Props = Polymorphic<{
|
||||
as: HTMLTag
|
||||
score: number
|
||||
label: string
|
||||
total?: number
|
||||
itemReviewedId?: string
|
||||
}
|
||||
showInfo?: boolean
|
||||
children?: never
|
||||
}>
|
||||
|
||||
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
score,
|
||||
label,
|
||||
total = 100,
|
||||
class: className,
|
||||
itemReviewedId,
|
||||
showInfo = false,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
|
||||
|
||||
@@ -65,13 +77,13 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
<Tag
|
||||
{...htmlProps}
|
||||
class={cn(
|
||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
role={htmlProps.role ?? 'group'}
|
||||
>
|
||||
<div
|
||||
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
||||
@@ -166,10 +178,14 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
||||
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
||||
class="stroke-night-700"></path>
|
||||
|
||||
<!-- Info icon -->
|
||||
<!-- <path
|
||||
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
||||
fill="white"
|
||||
fill-opacity="0.67"></path> -->
|
||||
{
|
||||
showInfo && (
|
||||
<path
|
||||
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
||||
class="text-current/60"
|
||||
fill="currentColor"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
</Tag>
|
||||
|
||||
@@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { HTMLTag, Polymorphic } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
type Props = Polymorphic<{
|
||||
as: HTMLTag
|
||||
score: number
|
||||
label: string
|
||||
total?: number
|
||||
itemReviewedId?: string
|
||||
}
|
||||
showInfo?: boolean
|
||||
children?: never
|
||||
}>
|
||||
|
||||
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
score,
|
||||
label,
|
||||
total = 10,
|
||||
class: className,
|
||||
itemReviewedId,
|
||||
showInfo = false,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
---
|
||||
|
||||
<div
|
||||
<Tag
|
||||
{...htmlProps}
|
||||
class={cn(
|
||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
role={htmlProps.role ?? 'group'}
|
||||
>
|
||||
{
|
||||
!!itemReviewedId && (
|
||||
@@ -48,15 +60,17 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
/>
|
||||
)
|
||||
}
|
||||
<!-- <svg
|
||||
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z"
|
||||
></path>
|
||||
</svg> -->
|
||||
{
|
||||
showInfo && (
|
||||
<svg
|
||||
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
@@ -77,4 +91,4 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
</div>
|
||||
|
||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
||||
</div>
|
||||
</Tag>
|
||||
|
||||
@@ -27,6 +27,12 @@ if (!Astro.locals.user) return
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
// NOTE: Disable sse: events when user is not logged in
|
||||
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||
stopServerEventsListener()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data as string)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import MyPicture from './MyPicture.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
@@ -23,6 +22,8 @@ type Props = HTMLAttributes<'a'> & {
|
||||
slug: true
|
||||
description: true
|
||||
overallScore: true
|
||||
privacyScore: true
|
||||
trustScore: true
|
||||
kycLevel: true
|
||||
imageUrl: true
|
||||
verificationStatus: true
|
||||
@@ -45,6 +46,8 @@ const {
|
||||
slug,
|
||||
description,
|
||||
overallScore,
|
||||
privacyScore,
|
||||
trustScore,
|
||||
kycLevel,
|
||||
imageUrl,
|
||||
categories,
|
||||
@@ -163,7 +166,7 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
'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)`}
|
||||
text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
|
||||
>
|
||||
{overallScoreInfo.formattedScore}
|
||||
</Tooltip>
|
||||
|
||||
@@ -4,13 +4,7 @@
|
||||
|
||||
<script>
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Window {
|
||||
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||
}
|
||||
}
|
||||
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
|
||||
|
||||
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
|
||||
|
||||
@@ -40,7 +34,7 @@
|
||||
|
||||
function shouldSkipAutoReload() {
|
||||
const currentPath = window.location.pathname
|
||||
const isErrorPage = document.querySelector('[data-is-error-page]') !== null
|
||||
const isErrorPage = document.body.hasAttribute('data-is-error-page')
|
||||
|
||||
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
|
||||
}
|
||||
@@ -51,4 +45,15 @@
|
||||
void updateSW(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
document.addEventListener('astro:page-load', async () => {
|
||||
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||
await unsubscribeFromPushNotifications()
|
||||
window.__SW_REGISTRATION__?.unregister()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { differenceInDays, isPast } from 'date-fns'
|
||||
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import TimeFormatted from './TimeFormatted.astro'
|
||||
import { formatDaysAgo } from '../lib/timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const RECENTLY_ADDED_DAYS = 7
|
||||
|
||||
type Props = {
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
verificationProofMd: true
|
||||
verificationSummary: true
|
||||
listedAt: true
|
||||
approvedAt: true
|
||||
isRecentlyApproved: true
|
||||
createdAt: true
|
||||
verificationSteps: {
|
||||
select: {
|
||||
@@ -29,9 +27,6 @@ type Props = {
|
||||
}
|
||||
|
||||
const { service } = Astro.props
|
||||
|
||||
const listedDate = service.listedAt ?? service.createdAt
|
||||
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
|
||||
---
|
||||
|
||||
{
|
||||
@@ -57,23 +52,23 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
|
||||
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
|
||||
<span>
|
||||
Community-contributed. Information not reviewed.
|
||||
Community contributed. Information not reviewed.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
href="/about#community-contributed"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : wasRecentlyAdded ? (
|
||||
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
|
||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
||||
) : service.isRecentlyApproved ? (
|
||||
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
|
||||
This service was approved
|
||||
{service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
|
||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||
caution.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
href="/about#approved"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
@@ -83,10 +78,10 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
|
||||
<Icon name="ri:information-line" class="size-5 text-blue-400" />
|
||||
<span>
|
||||
Basic checks passed, but not fully verified.
|
||||
Basic checks passed, but service is not verified.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
href="/about#approved"
|
||||
class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
@@ -98,14 +93,29 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
{
|
||||
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
||||
service.verificationSteps.some((step) => step.status === 'FAILED') && (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
<a
|
||||
href="#verification"
|
||||
class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400 transition-colors hover:bg-red-900/60"
|
||||
>
|
||||
<Icon
|
||||
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
|
||||
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
|
||||
/>
|
||||
<span>
|
||||
This service has failed one or more verification steps. Review the verification details carefully.
|
||||
</span>
|
||||
</div>
|
||||
<span>Some verification steps failed. Please review the details below.</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
||||
!service.verificationSteps.some((step) => step.status === 'FAILED') &&
|
||||
service.verificationSteps.some((step) => step.status === 'WARNING') && (
|
||||
<a
|
||||
href="#verification"
|
||||
class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200 transition-colors hover:bg-yellow-600/40"
|
||||
>
|
||||
<Icon name={verificationStepStatusesByValue.WARNING.icon} class={cn('size-5 text-yellow-400')} />
|
||||
<span>Some verification steps are marked as warnings.</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||
description: string
|
||||
classNames: {
|
||||
dot: string
|
||||
banner?: string
|
||||
}
|
||||
icon: string
|
||||
color: TailwindColor
|
||||
@@ -34,6 +35,7 @@ export const {
|
||||
description: '',
|
||||
classNames: {
|
||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
|
||||
},
|
||||
icon: 'ri:question-fill',
|
||||
color: 'gray',
|
||||
@@ -48,6 +50,7 @@ export const {
|
||||
description: 'Potential issues that users should be aware of',
|
||||
classNames: {
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
|
||||
},
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'yellow',
|
||||
@@ -61,6 +64,7 @@ export const {
|
||||
description: 'A previously reported warning has been solved',
|
||||
classNames: {
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
|
||||
},
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'green',
|
||||
@@ -74,6 +78,7 @@ export const {
|
||||
description: 'Critical issues affecting service functionality',
|
||||
classNames: {
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
|
||||
},
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'red',
|
||||
@@ -87,6 +92,7 @@ export const {
|
||||
description: 'A previously reported alert has been solved',
|
||||
classNames: {
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
|
||||
},
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'green',
|
||||
@@ -100,6 +106,7 @@ export const {
|
||||
description: 'General information about the service',
|
||||
classNames: {
|
||||
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
|
||||
banner: 'bg-blue-900/50 text-blue-300 hover:bg-blue-800/60 focus-visible:bg-blue-800/60',
|
||||
},
|
||||
icon: 'ri:information-fill',
|
||||
color: 'sky',
|
||||
@@ -113,6 +120,7 @@ export const {
|
||||
description: 'Regular service update or announcement',
|
||||
classNames: {
|
||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
|
||||
},
|
||||
icon: 'ri:notification-fill',
|
||||
color: 'green',
|
||||
@@ -126,6 +134,7 @@ export const {
|
||||
description: 'Service details were updated on kycnot.me',
|
||||
classNames: {
|
||||
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
|
||||
banner: 'bg-sky-900/50 text-sky-300 hover:bg-sky-800/60 focus-visible:bg-sky-800/60',
|
||||
},
|
||||
icon: 'ri:pencil-fill',
|
||||
color: 'sky',
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { KycLevelClarification } from '@prisma/client'
|
||||
import type { AttributeType, KycLevelClarification } from '@prisma/client'
|
||||
|
||||
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
slug: string
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
privacyPoints: number
|
||||
attributeType: AttributeType
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -18,22 +21,31 @@ export const {
|
||||
'value',
|
||||
(value): KycLevelClarificationInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase().replace('_', '-') : '',
|
||||
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||
description: '',
|
||||
icon: 'ri:question-line',
|
||||
privacyPoints: 0,
|
||||
attributeType: 'INFO',
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'NONE',
|
||||
slug: 'none',
|
||||
label: 'None',
|
||||
description: 'No clarification needed.',
|
||||
icon: 'ri:file-copy-line',
|
||||
privacyPoints: 0,
|
||||
attributeType: 'INFO',
|
||||
},
|
||||
{
|
||||
value: 'DEPENDS_ON_PARTNERS',
|
||||
slug: 'depends-on-partners',
|
||||
label: 'Depends on partners',
|
||||
description: 'May vary across partners.',
|
||||
icon: 'ri:share-forward-line',
|
||||
privacyPoints: -5,
|
||||
attributeType: 'WARNING',
|
||||
},
|
||||
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ type VerificationStatusInfo<T extends string | null | undefined = string> = {
|
||||
}
|
||||
|
||||
export const READ_MORE_SENTENCE_LINK =
|
||||
'Read more about the [suggestion review process](/about#suggestion-review-process).' satisfies MarkdownString
|
||||
'Read more about the [listing statuses](/about#listing-statuses).' satisfies MarkdownString
|
||||
|
||||
export const {
|
||||
dataArray: verificationStatuses,
|
||||
|
||||
@@ -42,6 +42,12 @@ export const {
|
||||
icon: 'ri:alert-line',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
value: 'WARNING',
|
||||
label: 'Warning',
|
||||
icon: 'ri:alert-line',
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
value: 'PENDING',
|
||||
label: 'Pending',
|
||||
|
||||
1
web/src/env.d.ts
vendored
@@ -17,6 +17,7 @@ declare global {
|
||||
|
||||
interface Window {
|
||||
htmx?: typeof htmx
|
||||
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||
}
|
||||
|
||||
namespace PrismaJson {
|
||||
|
||||
@@ -81,7 +81,8 @@ const announcement = await Astro.locals.banners.try(
|
||||
</head>
|
||||
<body
|
||||
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
|
||||
data-is-error-page={isErrorPage}
|
||||
data-is-error-page={isErrorPage ? '' : undefined}
|
||||
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
|
||||
>
|
||||
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||
<Header
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../components/OgImage'
|
||||
import TimeFormatted from '../components/TimeFormatted.astro'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
|
||||
import BaseLayout from './BaseLayout.astro'
|
||||
@@ -13,21 +14,19 @@ type Props = ComponentProps<typeof BaseLayout> &
|
||||
MarkdownLayoutProps<{
|
||||
children: AstroChildren
|
||||
title: string
|
||||
author: string
|
||||
pubDate: string
|
||||
updatedAt?: string
|
||||
description: string
|
||||
icon?: string
|
||||
}>
|
||||
|
||||
const { frontmatter, schemas, ...baseLayoutProps } = Astro.props
|
||||
const publishDate = frontmatter.pubDate ? new Date(frontmatter.pubDate) : null
|
||||
const publishDate = frontmatter.updatedAt ? new Date(frontmatter.updatedAt) : null
|
||||
const ogImageTemplateData = {
|
||||
template: 'generic',
|
||||
title: frontmatter.title,
|
||||
description: frontmatter.description,
|
||||
icon: frontmatter.icon,
|
||||
} satisfies OgImageAllTemplatesWithProps
|
||||
const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -44,14 +43,7 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
||||
datePublished: publishDate?.toISOString(),
|
||||
dateModified: publishDate?.toISOString(),
|
||||
image: makeOgImageUrl(ogImageTemplateData, Astro.url),
|
||||
author: frontmatter.author
|
||||
? weAreAuthor
|
||||
? KYCNOTME_SCHEMA_MINI
|
||||
: {
|
||||
'@type': 'Person',
|
||||
name: frontmatter.author,
|
||||
}
|
||||
: undefined,
|
||||
author: KYCNOTME_SCHEMA_MINI,
|
||||
publisher: KYCNOTME_SCHEMA_MINI,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
@@ -61,15 +53,26 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
||||
...(schemas ?? []),
|
||||
]}
|
||||
>
|
||||
<div class="bg-dots-fade absolute inset-x-0 top-0 -z-1 h-128 opacity-15"></div>
|
||||
<div
|
||||
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
|
||||
>
|
||||
<h1>{frontmatter.title}</h1>
|
||||
<p class="text-gray-500">
|
||||
{frontmatter.author && `by ${frontmatter.author}`}
|
||||
{frontmatter.pubDate && ` | ${new Date(frontmatter.pubDate).toLocaleDateString()}`}
|
||||
<h1 class="mb-0!">{frontmatter.title}</h1>
|
||||
<p class="mt-2! opacity-70">
|
||||
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.bg-dots-fade {
|
||||
background:
|
||||
radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space,
|
||||
linear-gradient(to bottom, #888, #fff);
|
||||
background-blend-mode: multiply;
|
||||
mix-blend-mode: multiply;
|
||||
filter: contrast(21);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { differenceInMonths, differenceInYears } from 'date-fns'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { getKycLevelClarificationInfo } from '../constants/kycLevelClarifications'
|
||||
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
|
||||
import { formatDateShort } from './timeAgo'
|
||||
import { formatDaysAgo } from './timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
@@ -35,8 +36,8 @@ type NonDbAttributeFull = NonDbAttribute & {
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
isRecentlyApproved: true
|
||||
approvedAt: true
|
||||
createdAt: true
|
||||
tosReviewAt: true
|
||||
tosReview: true
|
||||
@@ -45,6 +46,7 @@ type NonDbAttributeFull = NonDbAttribute & {
|
||||
acceptedCurrencies: true
|
||||
kycLevel: true
|
||||
kycLevelClarification: true
|
||||
operatingSince: true
|
||||
}
|
||||
}>
|
||||
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||
@@ -156,16 +158,25 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => {
|
||||
const clarification = getKycLevelClarificationInfo(service.kycLevelClarification)
|
||||
return {
|
||||
show: service.kycLevel === kycLevel.value,
|
||||
title: kycLevel.name + (clarification.value !== 'NONE' ? ` (${clarification.label})` : ''),
|
||||
description:
|
||||
kycLevel.description + (clarification.value !== 'NONE' ? ` ${clarification.description}` : ''),
|
||||
}
|
||||
},
|
||||
customize: (service) => ({
|
||||
show: service.kycLevel === kycLevel.value,
|
||||
}),
|
||||
})),
|
||||
...kycLevelClarifications
|
||||
.filter((clarification) => clarification.value !== 'NONE')
|
||||
.map<NonDbAttributeFull>((clarification) => ({
|
||||
slug: `kyc-clarification-${clarification.slug}`,
|
||||
title: `KYC ${clarification.label}`,
|
||||
type: clarification.attributeType,
|
||||
category: 'PRIVACY',
|
||||
description: clarification.description,
|
||||
privacyPoints: clarification.privacyPoints,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.kycLevelClarification === clarification.value,
|
||||
}),
|
||||
})),
|
||||
{
|
||||
slug: 'archived',
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
@@ -180,17 +191,17 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'recently-listed',
|
||||
title: 'Recently listed',
|
||||
slug: 'recently-approved',
|
||||
title: 'Recently approved',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: 'Listed on KYCnot.me less than 15 days ago. Proceed with caution.',
|
||||
description: 'Approved on KYCnot.me less than 15 days ago. Proceed with caution.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -5,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.isRecentlyListed,
|
||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
||||
show: service.isRecentlyApproved,
|
||||
description: `Approved on KYCnot.me less than 15 days ago${service.approvedAt ? ` (${formatDaysAgo(service.approvedAt)})` : ''}. Proceed with caution.`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -208,41 +219,22 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'has-onion-urls',
|
||||
title: 'Has Onion URLs',
|
||||
slug: 'has-onion-or-i2p-urls',
|
||||
title: 'Has Onion or I2P URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
|
||||
description: 'Onion (Tor) and I2P URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?onion=true',
|
||||
url: '/?networks=onion&networks=i2p',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.onionUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'has-i2p-urls',
|
||||
title: 'Has I2P URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'I2P URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?i2p=true',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.i2pUrls.length > 0,
|
||||
show: service.onionUrls.length > 0 || service.i2pUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -265,6 +257,55 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
show: service.acceptedCurrencies.includes('MONERO'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'new-service',
|
||||
title: 'New service',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description:
|
||||
'The service started operations less than a year ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -4,
|
||||
links: [],
|
||||
customize: (service) => {
|
||||
if (!service.operatingSince) return { show: false }
|
||||
|
||||
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
|
||||
if (yearsOperated >= 1) return { show: false }
|
||||
|
||||
const monthsOperated = differenceInMonths(new Date(), service.operatingSince)
|
||||
return {
|
||||
show: true,
|
||||
description: `The service started operations ${
|
||||
monthsOperated === 0
|
||||
? 'less than a month'
|
||||
: `${String(monthsOperated)} month${monthsOperated > 1 ? 's' : ''}`
|
||||
} ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).`,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'mature-service',
|
||||
title: 'Mature service',
|
||||
type: 'GOOD',
|
||||
category: 'TRUST',
|
||||
description:
|
||||
'This service has been operational for at least 2 years. While this indicates stability, it is not a future-proof guarantee.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: 5,
|
||||
links: [],
|
||||
customize: (service) => {
|
||||
if (!service.operatingSince) return { show: false }
|
||||
|
||||
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
|
||||
return {
|
||||
show: yearsOperated >= 2,
|
||||
description: `This service has been operational for **${String(
|
||||
yearsOperated
|
||||
)} years**. While this indicates stability, it is not a future-proof guarantee.`,
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function sortAttributes<
|
||||
|
||||
@@ -7,11 +7,15 @@ export function supportsBrowserNotifications() {
|
||||
}
|
||||
|
||||
export function isBrowserNotificationsEnabled() {
|
||||
return (
|
||||
supportsBrowserNotifications() &&
|
||||
Notification.permission === 'granted' &&
|
||||
typedLocalStorage.browserNotificationsEnabled.get()
|
||||
)
|
||||
const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
|
||||
if (!browserNotificationsEnabled) return false
|
||||
|
||||
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||
typedLocalStorage.browserNotificationsEnabled.set(false)
|
||||
return false
|
||||
}
|
||||
|
||||
return supportsBrowserNotifications() && Notification.permission === 'granted'
|
||||
}
|
||||
|
||||
export async function enableBrowserNotifications(): Promise<SafeResult> {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { actions } from 'astro:actions'
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
export type SafeResult =
|
||||
@@ -45,7 +44,6 @@ export async function subscribeToPushNotifications(vapidPublicKey: string): Prom
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
@@ -131,13 +129,7 @@ export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubsc
|
||||
const currentSubscription = await getCurrentSubscription()
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
|
||||
}
|
||||
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
@@ -183,5 +175,5 @@ export function parsePushSubscriptions(subscriptionsAsString: string | undefined
|
||||
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
|
||||
if (typeof subscription !== 'object' || subscription === null) return false
|
||||
const s = subscription as Record<string, unknown>
|
||||
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null)
|
||||
return typeof s.endpoint === 'string'
|
||||
}
|
||||
|
||||
7
web/src/lib/client/envVariables.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const DEPLOYMENT_MODE = import.meta.env.PROD
|
||||
? import.meta.env.MODE === 'development' ||
|
||||
import.meta.env.MODE === 'staging' ||
|
||||
import.meta.env.MODE === 'production'
|
||||
? import.meta.env.MODE
|
||||
: 'development'
|
||||
: 'development'
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { NotificationData, NotificationPayload } from './serverEventsTypes'
|
||||
import { DEPLOYMENT_MODE } from './envVariables'
|
||||
|
||||
import type { NotificationData, NotificationPayload } from '../serverEventsTypes'
|
||||
|
||||
export type CustomNotificationOptions = NotificationOptions & {
|
||||
actions?: { action: string; title: string; icon?: string }[]
|
||||
@@ -6,14 +8,24 @@ export type CustomNotificationOptions = NotificationOptions & {
|
||||
data: NotificationData
|
||||
}
|
||||
|
||||
export function makeNotificationOptions(
|
||||
export function makeBrowserNotificationTitle(title?: string | null) {
|
||||
const prefix = DEPLOYMENT_MODE === 'development' ? '[DEV] ' : DEPLOYMENT_MODE === 'staging' ? '[PRE] ' : ''
|
||||
return `${prefix}${title ?? 'New Notification'}`
|
||||
}
|
||||
|
||||
export function makeBrowserNotificationOptions(
|
||||
payload: NotificationPayload | null,
|
||||
options: { removeActions?: boolean } = {}
|
||||
) {
|
||||
const defaultOptions: CustomNotificationOptions = {
|
||||
body: 'You have a new notification',
|
||||
lang: 'en-US',
|
||||
icon: '/favicon.svg',
|
||||
icon:
|
||||
DEPLOYMENT_MODE === 'development'
|
||||
? '/favicon-dev.svg'
|
||||
: DEPLOYMENT_MODE === 'staging'
|
||||
? '/favicon-stage.svg'
|
||||
: '/favicon.svg',
|
||||
badge: '/notification-icon.svg',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { prisma } from './prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export const MAX_COMMENT_DEPTH = 12
|
||||
@@ -75,12 +77,13 @@ export type CommentWithRepliesPopulated = CommentWithReplies<{
|
||||
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
|
||||
export type CommentSortOption = z.infer<typeof commentSortSchema>
|
||||
|
||||
export function makeCommentsNestedQuery({
|
||||
export async function makeCommentsNestedQuery({
|
||||
depth = 0,
|
||||
user,
|
||||
showPending,
|
||||
serviceId,
|
||||
sort,
|
||||
highlightedCommentId,
|
||||
}: {
|
||||
depth?: number
|
||||
user: Prisma.UserGetPayload<{
|
||||
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
|
||||
showPending?: boolean
|
||||
serviceId: number
|
||||
sort: CommentSortOption
|
||||
highlightedCommentId?: number | null
|
||||
}) {
|
||||
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
|
||||
|
||||
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
|
||||
}
|
||||
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
|
||||
|
||||
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
|
||||
|
||||
const baseQuery = {
|
||||
...commentReplyQuery,
|
||||
orderBy: orderByClause,
|
||||
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
|
||||
: ({
|
||||
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||
} as const satisfies Prisma.CommentWhereInput),
|
||||
...(highlightedBranchIds.length > 0
|
||||
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
|
||||
: []),
|
||||
],
|
||||
parentId: null,
|
||||
serviceId,
|
||||
@@ -161,6 +170,47 @@ export function makeRepliesQuery<T extends Prisma.CommentFindManyArgs>(
|
||||
}
|
||||
}
|
||||
|
||||
async function findAllParentIds(commentId: number, depth: number) {
|
||||
const commentwithManyParents = await prisma.comment.findFirst({
|
||||
where: { id: commentId },
|
||||
select: makeParentQuerySelect(depth),
|
||||
})
|
||||
|
||||
return extractParentIds(commentwithManyParents, [commentId])
|
||||
}
|
||||
|
||||
type ParentQueryRecursive = {
|
||||
parent: {
|
||||
select: {
|
||||
id: true
|
||||
parent: false | { select: ParentQueryRecursive }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeParentQuerySelect(depth: number): ParentQueryRecursive {
|
||||
return {
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
parent: depth <= 0 ? false : { select: makeParentQuerySelect(depth - 1) },
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.CommentSelect
|
||||
}
|
||||
|
||||
function extractParentIds(
|
||||
comment: Prisma.CommentGetPayload<{ select: ParentQueryRecursive }> | null,
|
||||
acc: number[] = []
|
||||
) {
|
||||
if (!comment?.parent?.id) return acc
|
||||
|
||||
return extractParentIds(comment.parent as Prisma.CommentGetPayload<{ select: ParentQueryRecursive }>, [
|
||||
...acc,
|
||||
comment.parent.id,
|
||||
])
|
||||
}
|
||||
|
||||
export function makeCommentUrl({
|
||||
serviceSlug,
|
||||
commentId,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
const schema = z.enum(['development', 'staging', 'production'])
|
||||
|
||||
export const DEPLOYMENT_MODE = schema.parse(import.meta.env.PROD ? import.meta.env.MODE : 'development')
|
||||
@@ -39,7 +39,6 @@ export async function getService(slug: string | undefined): Promise<
|
||||
const service =
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
slug,
|
||||
},
|
||||
@@ -47,7 +46,6 @@ export async function getService(slug: string | undefined): Promise<
|
||||
})) ??
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
previousSlugs: { has: slug },
|
||||
},
|
||||
@@ -175,7 +173,6 @@ export async function getEvents(): Promise<
|
||||
where: {
|
||||
visible: true,
|
||||
service: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'l
|
||||
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
|
||||
await redisImpersonationSessions.delete(sessionId)
|
||||
context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
|
||||
context.locals.user = context.locals.actualUser
|
||||
context.locals.user = context.locals.actualUser ?? context.locals.user
|
||||
context.locals.actualUser = null
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ function prismaClientSingleton() {
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prisma: ReturnType<typeof prismaClientSingleton> | undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { addDays, format, isBefore, isToday, isYesterday } from 'date-fns'
|
||||
import { addDays, differenceInDays, format, isBefore, isToday, isYesterday } from 'date-fns'
|
||||
import TimeAgo from 'javascript-time-ago'
|
||||
import en from 'javascript-time-ago/locale/en'
|
||||
|
||||
@@ -47,3 +47,10 @@ export function formatDateShort(
|
||||
|
||||
return transformCase(text, caseType)
|
||||
}
|
||||
|
||||
export function formatDaysAgo(approvedAt: Date) {
|
||||
const days = differenceInDays(new Date(), approvedAt)
|
||||
if (days === 0) return 'today'
|
||||
if (days === 1) return 'yesterday'
|
||||
return `${days.toLocaleString()} days ago`
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function removeUserSessionIdCookie(cookies: AstroCookies) {
|
||||
cookies.delete(COOKIE_NAME, { path: '/' })
|
||||
}
|
||||
|
||||
export async function logout(context: Pick<APIContext, 'cookies' | 'locals'>) {
|
||||
export async function logout(context: Pick<APIContext, 'cookies' | 'locals' | 'request' | 'url'>) {
|
||||
await stopImpersonating(context)
|
||||
|
||||
await removeUserSessionIdCookie(context.cookies)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../constants/characters'
|
||||
|
||||
import { getRandom, typedJoin } from './arrays'
|
||||
import { DEPLOYMENT_MODE } from './envVariables'
|
||||
import { DEPLOYMENT_MODE } from './client/envVariables'
|
||||
import { transformCase } from './strings'
|
||||
|
||||
const DIGEST = 'sha512'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LOGS_UI_URL } from 'astro:env/server'
|
||||
|
||||
import { SUPPORT_EMAIL } from '../constants/project'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
---
|
||||
layout: ../layouts/MarkdownLayout.astro
|
||||
title: About
|
||||
author: KYCnot.me
|
||||
pubDate: 2025-05-15
|
||||
updatedAt: 2025-05-15
|
||||
description: 'Learn how KYCnot.me website works and about our mission to protect privacy in cryptocurrency.'
|
||||
icon: 'ri:information-line'
|
||||
---
|
||||
|
||||
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?
|
||||
|
||||
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.
|
||||
@@ -53,9 +67,9 @@ Users earn karma by participating in the community. When your comments get appro
|
||||
|
||||
### 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
|
||||
|
||||
@@ -72,12 +86,13 @@ To list a new service, it must fulfill these requirements:
|
||||
- Offer a service
|
||||
- Publicly available website explaining what the service is about
|
||||
- Terms of service or FAQ document
|
||||
- The service must have been operating for at least 6 months.
|
||||
|
||||
Examples of non-valid services:
|
||||
|
||||
- Just a Telegram link
|
||||
- A cryptocurrency project
|
||||
- A cryptocurrency wallet
|
||||
- A chat link without any information about the service, or review page.
|
||||
|
||||
#### Suggestion Review Process
|
||||
|
||||
@@ -146,7 +161,7 @@ These categories **directly influence** a service's Privacy and Trust scores, wh
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -166,7 +181,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
|
||||
|
||||
##### 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
|
||||
|
||||
@@ -185,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.
|
||||
|
||||
### 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 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.
|
||||
@@ -195,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**.
|
||||
|
||||
### 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.
|
||||
|
||||
#### 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
|
||||
|
||||
You can access basic service data via our public API.
|
||||
|
||||
See the [API page](/docs/api) for more details.
|
||||
You can access basic service data through our [public API](/docs/api).
|
||||
|
||||
## Support
|
||||
|
||||
If you like this project, you can **support** it through these methods:
|
||||
You can **support** our work through these methods:
|
||||
|
||||
<DonationAddress
|
||||
cryptoName="Monero"
|
||||
@@ -219,10 +277,14 @@ If you like this project, you can **support** it through these methods:
|
||||
|
||||
## 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)
|
||||
|
||||
## Downloads and Assets
|
||||
|
||||
For logos and brand assets, visit our [downloads page](/downloads).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This website is strictly for informational purposes regarding privacy technology in the cryptocurrency space. We unequivocally condemn and do not endorse, support, or facilitate money laundering, terrorist financing, or any other illegal financial activities. The use of any information or service mentioned herein for such purposes is strictly prohibited and contrary to the core principles of this project.
|
||||
|
||||
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>
|
||||
@@ -22,6 +22,7 @@ import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
import { formatDateShort } from '../../lib/timeAgo'
|
||||
import { urlDomain } from '../../lib/urls'
|
||||
|
||||
const userId = Astro.locals.user?.id
|
||||
if (!userId) {
|
||||
@@ -423,7 +424,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-400 hover:underline"
|
||||
>
|
||||
{user.verifiedLink}
|
||||
{urlDomain(user.verifiedLink)}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@@ -857,12 +858,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
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
|
||||
)}
|
||||
>
|
||||
<span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
|
||||
<Icon name={statusInfo.icon} class="mr-1 size-3" />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
@@ -966,7 +962,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout
|
||||
</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"
|
||||
>
|
||||
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account
|
||||
|
||||
@@ -271,7 +271,6 @@ if (toggleResult?.error) {
|
||||
label: type.label,
|
||||
value: type.value,
|
||||
icon: type.icon,
|
||||
noTransitionPersist: false,
|
||||
}))}
|
||||
cardSize="sm"
|
||||
required
|
||||
@@ -306,8 +305,8 @@ if (toggleResult?.error) {
|
||||
label="Status"
|
||||
error={createInputErrors.isActive}
|
||||
options={[
|
||||
{ label: 'Active', value: 'true', noTransitionPersist: true },
|
||||
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
|
||||
{ label: 'Active', value: 'true' },
|
||||
{ label: 'Inactive', value: 'false' },
|
||||
]}
|
||||
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
|
||||
cardSize="sm"
|
||||
@@ -628,7 +627,6 @@ if (toggleResult?.error) {
|
||||
label: type.label,
|
||||
value: type.value,
|
||||
icon: type.icon,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
cardSize="sm"
|
||||
required
|
||||
@@ -661,8 +659,8 @@ if (toggleResult?.error) {
|
||||
name="isActive"
|
||||
label="Status"
|
||||
options={[
|
||||
{ label: 'Active', value: 'true', noTransitionPersist: true },
|
||||
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
|
||||
{ label: 'Active', value: 'true' },
|
||||
{ label: 'Inactive', value: 'false' },
|
||||
]}
|
||||
selectedValue={announcement.isActive ? 'true' : 'false'}
|
||||
cardSize="sm"
|
||||
|
||||
@@ -692,12 +692,12 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<Button
|
||||
as="button"
|
||||
color="gray"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
label="Cancel"
|
||||
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
|
||||
data-cancel-button
|
||||
data-cancel-form-id={`edit-form-${index}`}
|
||||
/>
|
||||
<Button
|
||||
as="button"
|
||||
@@ -721,3 +721,22 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
verificationStepStatuses,
|
||||
} from '../../../../constants/verificationStepStatus'
|
||||
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 { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
||||
import { pluralize } from '../../../../lib/pluralize'
|
||||
@@ -361,6 +361,17 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
value={service.tosUrls.join('\n')}
|
||||
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
|
||||
label="Referral link path"
|
||||
name="referral"
|
||||
@@ -441,7 +452,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
value: kycLevel.id.toString(),
|
||||
icon: kycLevel.icon,
|
||||
description: kycLevel.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.kycLevel.toString()}
|
||||
iconSize="md"
|
||||
@@ -458,7 +468,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
value: clarification.value,
|
||||
icon: clarification.icon,
|
||||
description: clarification.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.kycLevelClarification}
|
||||
iconSize="sm"
|
||||
@@ -475,7 +484,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
icon: status.icon,
|
||||
iconClass: status.classNames.icon,
|
||||
description: status.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.verificationStatus}
|
||||
error={serviceInputErrors.verificationStatus}
|
||||
@@ -491,7 +499,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
label: currency.name,
|
||||
value: currency.id,
|
||||
icon: currency.icon,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.acceptedCurrencies}
|
||||
error={serviceInputErrors.acceptedCurrencies}
|
||||
@@ -532,7 +539,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
icon: visibility.icon,
|
||||
iconClass: visibility.iconClass,
|
||||
description: visibility.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.serviceVisibility}
|
||||
error={serviceInputErrors.serviceVisibility}
|
||||
|
||||
@@ -230,26 +230,22 @@ if (!user) return Astro.rewrite('/404')
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
icon: 'ri:shield-star-fill',
|
||||
noTransitionPersist: true,
|
||||
},
|
||||
{
|
||||
label: 'Moderator',
|
||||
value: 'moderator',
|
||||
icon: 'ri:graduation-cap-fill',
|
||||
noTransitionPersist: true,
|
||||
},
|
||||
{
|
||||
label: 'Spammer',
|
||||
value: 'spammer',
|
||||
icon: 'ri:alert-fill',
|
||||
noTransitionPersist: true,
|
||||
},
|
||||
{
|
||||
label: 'Verified',
|
||||
value: 'verified',
|
||||
icon: 'ri:verified-badge-fill',
|
||||
disabled: true,
|
||||
noTransitionPersist: true,
|
||||
},
|
||||
]}
|
||||
selectedValue={[
|
||||
@@ -434,7 +430,6 @@ if (!user) return Astro.rewrite('/404')
|
||||
label: role.label,
|
||||
value: role.value,
|
||||
icon: role.icon,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
required
|
||||
cardSize="sm"
|
||||
@@ -466,6 +461,25 @@ if (!user) return Astro.rewrite('/404')
|
||||
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
|
||||
73
web/src/pages/assets.mdx
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
layout: ../layouts/MarkdownLayout.astro
|
||||
title: Assets & Downloads
|
||||
author: KYCnot.me
|
||||
pubDate: 2025-06-11
|
||||
description: 'Download KYCnot.me logos and assets.'
|
||||
icon: 'ri:image-line'
|
||||
---
|
||||
|
||||
import PressAssets from '../components/PressAssets.astro'
|
||||
|
||||
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
|
||||
|
||||
<PressAssets
|
||||
title="KYCnot.me Brand Kit"
|
||||
description="Complete collection of our logos and 'Review on KYCnot.me' badges in various formats and styles."
|
||||
zipPath="/kycnotme-press-assets.zip"
|
||||
assets={[
|
||||
{
|
||||
name: 'Logo - Normal',
|
||||
path: '/logo/logo-normal.svg',
|
||||
alt: 'KYCnot.me logo normal version',
|
||||
category: 'logo',
|
||||
},
|
||||
{
|
||||
name: 'Logo - Small',
|
||||
path: '/logo/logo-small.svg',
|
||||
alt: 'KYCnot.me logo small version',
|
||||
category: 'logo',
|
||||
},
|
||||
{
|
||||
name: 'Logo - Mini',
|
||||
path: '/logo/logo-mini.svg',
|
||||
alt: 'KYCnot.me logo mini version',
|
||||
category: 'logo',
|
||||
},
|
||||
{
|
||||
name: 'Logo - Mini Full',
|
||||
path: '/logo/logo-mini-full.svg',
|
||||
alt: 'KYCnot.me logo mini full version',
|
||||
category: 'logo',
|
||||
},
|
||||
{
|
||||
name: 'Badge - Long Black',
|
||||
path: '/review-on-kycnotme/long-black.svg',
|
||||
alt: 'Review on KYCnot.me badge - long black version',
|
||||
category: 'badge',
|
||||
},
|
||||
{
|
||||
name: 'Badge - Long White',
|
||||
path: '/review-on-kycnotme/long-white.svg',
|
||||
alt: 'Review on KYCnot.me badge - long white version',
|
||||
category: 'badge',
|
||||
},
|
||||
{
|
||||
name: 'Badge - Short Black',
|
||||
path: '/review-on-kycnotme/short-black.svg',
|
||||
alt: 'Review on KYCnot.me badge - short black version',
|
||||
category: 'badge',
|
||||
},
|
||||
{
|
||||
name: 'Badge - Short White',
|
||||
path: '/review-on-kycnotme/short-white.svg',
|
||||
alt: 'Review on KYCnot.me badge - short white version',
|
||||
category: 'badge',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
## Brand design
|
||||
|
||||
- Brand color: `#3bdb78`
|
||||
- Font: [Space Grotesk](https://floriankarsten.github.io/space-grotesk/) and [Inter](https://rsms.me/inter/).
|
||||
@@ -1,13 +1,12 @@
|
||||
---
|
||||
layout: ../../layouts/MarkdownLayout.astro
|
||||
title: API
|
||||
author: KYCnot.me
|
||||
pubDate: 2025-05-31
|
||||
updatedAt: 2025-05-31
|
||||
description: 'Access basic service data via our public API.'
|
||||
icon: 'ri:plug-line'
|
||||
---
|
||||
|
||||
import { SOURCE_CODE_URL } from 'astro:env/server'
|
||||
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import { verificationStatuses } from '../../constants/verificationStatus'
|
||||
import { serviceVisibilities } from '../../constants/serviceVisibility'
|
||||
@@ -53,6 +52,7 @@ type ServiceResponse = {
|
||||
description: string
|
||||
}
|
||||
verifiedAt: Date | null
|
||||
approvedAt: Date | null
|
||||
kycLevel: 0 | 1 | 2 | 3 | 4
|
||||
kycLevelInfo: {
|
||||
value: 0 | 1 | 2 | 3 | 4
|
||||
@@ -146,7 +146,8 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||
"labelShort": "Verified",
|
||||
"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,
|
||||
"kycLevelInfo": {
|
||||
"value": 0,
|
||||
@@ -164,7 +165,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||
"slug": "exchange"
|
||||
}
|
||||
],
|
||||
"listedAt": "2025-05-31T19:09:18.043Z",
|
||||
"listedAt": "2025-04-20T07:12:29.393Z",
|
||||
"serviceUrls": [
|
||||
"https://example.com",
|
||||
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
|
||||
|
||||
@@ -3,8 +3,7 @@ layout: ../../layouts/MarkdownLayout.astro
|
||||
title: How does karma work?
|
||||
description: "KYCnot.me has a user karma system, here's how it works"
|
||||
icon: 'ri:hearts-line'
|
||||
author: KYCnot.me
|
||||
pubDate: 2025-05-15
|
||||
updatedAt: 2025-05-15
|
||||
---
|
||||
|
||||
import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
|
||||
@@ -16,55 +15,53 @@ import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
|
||||
There are several ways to earn karma points:
|
||||
|
||||
1. **Comment Approval** (+1 point)
|
||||
|
||||
- When your comment moves from 'unmoderated' to 'approved' status
|
||||
- This is the basic reward for contributing a valid comment
|
||||
- When your comment moves from 'unmoderated' to 'approved' status.
|
||||
- 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)
|
||||
|
||||
- When your comment is marked as 'verified'
|
||||
- This is a significant reward for providing particularly valuable or verified information
|
||||
- When your comment is marked as 'verified'.
|
||||
- This is a significant reward for providing particularly valuable or verified information.
|
||||
|
||||
3. **Upvotes**
|
||||
- Each upvote on your comment adds +1 to your karma
|
||||
- Similarly, each downvote reduces your karma by -1
|
||||
- This allows the community to reward helpful contributions
|
||||
- Each upvote on your comment adds +1 to your karma.
|
||||
- Similarly, each downvote reduces your karma by -1.
|
||||
- This allows the community to reward helpful contributions.
|
||||
|
||||
## Karma Penalties
|
||||
|
||||
The system also includes penalties to discourage spam and low-quality content:
|
||||
|
||||
1. **Spam Detection** (-10 points)
|
||||
- If your comment is marked as suspicious/spam
|
||||
- This is a significant penalty to discourage spam behavior
|
||||
- If the spam mark is removed, the 10 points are restored
|
||||
- If your comment is marked as suspicious/spam.
|
||||
- This is a significant penalty to discourage spam behavior.
|
||||
- If the spam mark is removed, the 10 points are restored.
|
||||
|
||||
## Karma Tracking
|
||||
|
||||
The system maintains a detailed record of all karma changes through:
|
||||
|
||||
1. **Karma Transactions**
|
||||
|
||||
- Every karma change is recorded as a transaction
|
||||
- Every karma change is recorded as a transaction.
|
||||
- Each transaction includes:
|
||||
- The action that triggered it
|
||||
- The number of points awarded/deducted
|
||||
- A description of why the karma changed
|
||||
- The related comment (if applicable)
|
||||
- The action that triggered it.
|
||||
- The number of points awarded/deducted.
|
||||
- A description of why the karma changed.
|
||||
- The related comment (if applicable).
|
||||
|
||||
2. **Total Karma**
|
||||
- Your total karma is displayed on your profile
|
||||
- It's the sum of all your karma transactions
|
||||
- This score helps establish your reputation in the community
|
||||
- Your total karma is displayed on your profile.
|
||||
- It's the sum of all your karma transactions.
|
||||
- This score helps establish your reputation in the community.
|
||||
|
||||
## 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:
|
||||
|
||||
- Active participation in the community
|
||||
- History of providing valuable information
|
||||
- Trustworthiness of your contributions
|
||||
- Commitment to community standards
|
||||
- Active participation in the community.
|
||||
- History of providing valuable information.
|
||||
- Trustworthiness of your contributions.
|
||||
- Commitment to community standards.
|
||||
|
||||
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.
|
||||
|
||||
|
||||
21
web/src/pages/downloads.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
layout: ../layouts/MarkdownLayout.astro
|
||||
title: Downloads and assets
|
||||
author: KYCnot.me
|
||||
updatedAt: 2025-06-12
|
||||
description: 'Download KYCnot.me logos and assets.'
|
||||
icon: 'ri:image-line'
|
||||
---
|
||||
|
||||
import PressAssets from '../components/PressAssets.astro'
|
||||
|
||||
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
|
||||
|
||||
<PressAssets />
|
||||
|
||||
Review service link format: `https://kycnot.me/service/[slug]/review`
|
||||
|
||||
## Brand design
|
||||
|
||||
- Brand color: `#3bdb78`
|
||||
- Font: [Space Grotesk](https://floriankarsten.github.io/space-grotesk/) and [Inter](https://rsms.me/inter/).
|
||||
@@ -45,7 +45,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
||||
async () =>
|
||||
prisma.service.findMany({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
select: {
|
||||
@@ -72,7 +71,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
||||
},
|
||||
service: {
|
||||
slug: params.service ?? undefined,
|
||||
listedAt: params.service ? undefined : { lte: new Date() },
|
||||
serviceVisibility: {
|
||||
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
|
||||
@@ -219,7 +219,6 @@ const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : n
|
||||
|
||||
const where = {
|
||||
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
|
||||
listedAt: { lte: new Date() },
|
||||
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
|
||||
verificationStatus: {
|
||||
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
||||
@@ -317,7 +316,6 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
listedAt: { lte: new Date() },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -163,7 +163,6 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
endpoint: true,
|
||||
userAgent: true,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
|
||||
@@ -238,17 +238,29 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line. AI review uses the first working URL only."
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
placeholder: 'example.com/tos',
|
||||
required: true,
|
||||
class: 'min-h-10',
|
||||
}}
|
||||
error={inputErrors.tosUrls}
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line. AI review uses the first working URL only."
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
placeholder: 'example.com/tos',
|
||||
required: true,
|
||||
class: 'min-h-10',
|
||||
}}
|
||||
error={inputErrors.tosUrls}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Operating since"
|
||||
name="operatingSince"
|
||||
inputProps={{
|
||||
type: 'date',
|
||||
max: new Date().toISOString().slice(0, 10),
|
||||
}}
|
||||
error={inputErrors.operatingSince}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevel"
|
||||
@@ -311,6 +323,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
|
||||
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
|
||||
}))}
|
||||
description="See list of [all attributes](/attributes) and their scoring."
|
||||
error={inputErrors.attributes}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
@@ -70,7 +70,6 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
where: {
|
||||
slug,
|
||||
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
|
||||
listedAt: { lte: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -93,14 +92,16 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
referral: true,
|
||||
imageUrl: true,
|
||||
listedAt: true,
|
||||
approvedAt: true,
|
||||
createdAt: true,
|
||||
acceptedCurrencies: true,
|
||||
operatingSince: true,
|
||||
tosReview: true,
|
||||
tosReviewAt: true,
|
||||
userSentiment: true,
|
||||
userSentimentAt: true,
|
||||
averageUserRating: true,
|
||||
isRecentlyListed: true,
|
||||
isRecentlyApproved: true,
|
||||
contactMethods: {
|
||||
select: {
|
||||
value: true,
|
||||
@@ -230,7 +231,6 @@ if (!service) {
|
||||
where: {
|
||||
previousSlugs: { has: slug },
|
||||
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
|
||||
listedAt: { lte: new Date() },
|
||||
},
|
||||
select: { slug: true },
|
||||
})
|
||||
@@ -294,6 +294,8 @@ const statusIcon = {
|
||||
APPROVED: undefined,
|
||||
}[service.verificationStatus]
|
||||
|
||||
const isScam = service.verificationStatus === 'VERIFICATION_FAILED'
|
||||
|
||||
const shuffledLinks = {
|
||||
clearnet: shuffle(service.serviceUrls),
|
||||
onion: shuffle(service.onionUrls),
|
||||
@@ -385,6 +387,13 @@ const getVerificationStepStatusInfo = (status: VerificationStepStatus) => {
|
||||
color: 'red',
|
||||
timelineIconClass: 'text-red-400',
|
||||
} as const
|
||||
case VerificationStepStatus.WARNING:
|
||||
return {
|
||||
text: 'Warning',
|
||||
icon: 'ri:alert-line',
|
||||
color: 'yellow',
|
||||
timelineIconClass: 'text-yellow-400',
|
||||
} as const
|
||||
default:
|
||||
return {
|
||||
text: 'Unknown',
|
||||
@@ -407,9 +416,12 @@ const ogImageTemplateData = {
|
||||
|
||||
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
|
||||
|
||||
const activeAlertOrWarningEvents = service.events.filter(
|
||||
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
|
||||
)
|
||||
const activeAlertOrWarningEvents = service.events
|
||||
.map((event) => ({
|
||||
...event,
|
||||
typeInfo: getEventTypeInfo(event.type),
|
||||
}))
|
||||
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
|
||||
const activeEventToShow =
|
||||
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
|
||||
---
|
||||
@@ -518,15 +530,10 @@ const activeEventToShow =
|
||||
href="#events"
|
||||
class={cn(
|
||||
'group mb-4 block rounded-md px-3 py-2 text-sm transition-colors duration-200',
|
||||
activeEventToShow.type === EventType.ALERT
|
||||
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60'
|
||||
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60'
|
||||
activeEventToShow.typeInfo.classNames.banner
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={activeEventToShow.type === EventType.ALERT ? 'ri:alert-fill' : 'ri:alarm-warning-fill'}
|
||||
class="me-1.5 inline-block size-4 align-[-0.15em]"
|
||||
/>
|
||||
<Icon name={activeEventToShow.typeInfo.icon} class="me-1.5 inline-block size-4 align-[-0.15em]" />
|
||||
<span class="font-bold">{activeEventToShow.title}</span> — {activeEventToShow.content}
|
||||
{activeAlertOrWarningEvents.length >= 2 && <>+{activeAlertOrWarningEvents.length - 1} more events.</>}
|
||||
<span class="underline">Go to events</span>
|
||||
@@ -758,11 +765,18 @@ const activeEventToShow =
|
||||
<ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2">
|
||||
{shownLinks.map((url) => (
|
||||
<li>
|
||||
<ServiceLinkButton
|
||||
url={url}
|
||||
referral={service.referral}
|
||||
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
|
||||
/>
|
||||
{isScam ? (
|
||||
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
|
||||
<Icon name="ri:alert-line" class="size-4 text-red-400" />
|
||||
{urlDomain(url)}
|
||||
</span>
|
||||
) : (
|
||||
<ServiceLinkButton
|
||||
url={url}
|
||||
referral={service.referral}
|
||||
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -786,11 +800,18 @@ const activeEventToShow =
|
||||
|
||||
{hiddenLinks.map((url) => (
|
||||
<li class="hidden peer-checked:block">
|
||||
<ServiceLinkButton
|
||||
url={url}
|
||||
referral={service.referral}
|
||||
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
|
||||
/>
|
||||
{isScam ? (
|
||||
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
|
||||
<Icon name="ri:alert-line" class="size-4 text-red-400" />
|
||||
{urlDomain(url)}
|
||||
</span>
|
||||
) : (
|
||||
<ServiceLinkButton
|
||||
url={url}
|
||||
referral={service.referral}
|
||||
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -881,13 +902,34 @@ const activeEventToShow =
|
||||
</h2>
|
||||
<div class="xs:gap-8 flex flex-row flex-wrap items-stretch justify-center gap-4 sm:justify-between">
|
||||
<div class="flex max-w-84 flex-grow flex-row items-center justify-evenly gap-4">
|
||||
<ScoreSquare score={service.overallScore} label="Overall" itemReviewedId={itemReviewedId} />
|
||||
<ScoreSquare
|
||||
as="a"
|
||||
href="/about#service-scores"
|
||||
score={service.overallScore}
|
||||
label="Overall"
|
||||
itemReviewedId={itemReviewedId}
|
||||
showInfo
|
||||
/>
|
||||
|
||||
<div class="bg-day-800 h-12 w-px flex-shrink-0 self-center"></div>
|
||||
|
||||
<ScoreGauge score={service.privacyScore} label="Privacy" itemReviewedId={itemReviewedId} />
|
||||
<ScoreGauge
|
||||
as="a"
|
||||
href="/about#service-scores"
|
||||
score={service.privacyScore}
|
||||
label="Privacy"
|
||||
itemReviewedId={itemReviewedId}
|
||||
showInfo
|
||||
/>
|
||||
|
||||
<ScoreGauge score={service.trustScore} label="Trust" itemReviewedId={itemReviewedId} />
|
||||
<ScoreGauge
|
||||
as="a"
|
||||
href="/about#service-scores"
|
||||
score={service.trustScore}
|
||||
label="Trust"
|
||||
itemReviewedId={itemReviewedId}
|
||||
showInfo
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="@container flex max-w-112 flex-shrink-0 flex-grow-100 basis-64 flex-row items-start rounded-lg bg-white px-3 py-2 text-black"
|
||||
@@ -984,15 +1026,23 @@ const activeEventToShow =
|
||||
attribute.typeInfo.classNames.text
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={attribute.categoryInfo.icon}
|
||||
class={cn('mr-2 size-4 flex-shrink-0', attribute.typeInfo.classNames.icon)}
|
||||
/>
|
||||
<span>{attribute.title}</span>
|
||||
<span class="mr-auto ml-1">{attribute.title}</span>
|
||||
<>
|
||||
{weights.map((w) => (
|
||||
<span
|
||||
class={cn(
|
||||
'ml-2 text-center text-sm',
|
||||
w.value === 0 ? 'text-current/30' : 'text-current/60'
|
||||
)}
|
||||
>
|
||||
{formatNumber(w.value, w.formatOptions)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
<Icon
|
||||
name="ri:arrow-down-s-line"
|
||||
class={cn(
|
||||
'ml-auto size-5 group-open/attribute:rotate-180',
|
||||
'ml-1 size-5 group-open/attribute:rotate-180',
|
||||
attribute.typeInfo.classNames.icon
|
||||
)}
|
||||
/>
|
||||
@@ -1059,17 +1109,17 @@ const activeEventToShow =
|
||||
baseScoreType.classNames.text
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={baseScoreType.icon}
|
||||
class={cn('mr-2 size-4 flex-shrink-0', baseScoreType.classNames.icon)}
|
||||
/>
|
||||
<span class="font-title">{baseScoreType.label}</span>
|
||||
<span class={cn('mr-1 ml-auto', baseScoreType.classNames.icon)}>+50</span>
|
||||
<span class="font-title mr-auto ml-1">{baseScoreType.label}</span>
|
||||
<span class="mr-2 text-current/60">+50</span>
|
||||
<span class="mr-1 text-current/60">+50</span>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="text-day-400 mt-3 text-center text-xs">
|
||||
<span class="hover:text-day-200 transition-colors">Overall = 60% Privacy + 40% Trust (Truncated)</span>
|
||||
</p>
|
||||
<div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs">
|
||||
<a
|
||||
href="/about#service-scores"
|
||||
@@ -1083,7 +1133,7 @@ const activeEventToShow =
|
||||
class="text-day-400 hover:text-day-200 inline-flex items-center gap-1 transition-colors hover:underline"
|
||||
>
|
||||
<Icon name="ri:information-line" class="size-3" />
|
||||
Attributes list
|
||||
All attributes list
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1194,15 +1244,15 @@ const activeEventToShow =
|
||||
<p class="text-day-500 mt-1 text-sm text-balance">
|
||||
Maybe due to captchas, client side rendering, DDoS protections, or non-text format.
|
||||
</p>
|
||||
{service.tosUrls.length > 0 && (
|
||||
<p class="mt-2 text-xs">
|
||||
{service.tosUrls.map((url) => (
|
||||
<a href={url} class="hover:underline">
|
||||
{urlDomain(url)}
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p class="mt-2 text-xs">
|
||||
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
|
||||
{service.tosUrls.length > 0 && 'from'}
|
||||
{service.tosUrls.map((url) => (
|
||||
<a href={url} class="hover:underline">
|
||||
{urlDomain(url)}
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -25,6 +25,7 @@ import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../../lib/schema'
|
||||
import { formatDateShort } from '../../lib/timeAgo'
|
||||
import { urlDomain } from '../../lib/urls'
|
||||
|
||||
import type { ProfilePage, WithContext } from 'schema-dts'
|
||||
|
||||
@@ -96,9 +97,6 @@ const user = await Astro.locals.banners.try('user', async () => {
|
||||
},
|
||||
where: {
|
||||
service: {
|
||||
listedAt: {
|
||||
lte: new Date(),
|
||||
},
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
@@ -395,7 +393,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span>
|
||||
<div>
|
||||
<p class="text-day-500 text-xs">Karma</p>
|
||||
<p class="text-day-300">{user.totalKarma.toLocaleString()}</p>
|
||||
<p class="text-day-300">
|
||||
{!user.admin && !user.moderator ? user.totalKarma.toLocaleString() : '∞'}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -511,7 +511,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-400 hover:underline"
|
||||
>
|
||||
{user.verifiedLink}
|
||||
{urlDomain(user.verifiedLink)}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@@ -577,187 +577,130 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
</section>
|
||||
</AdminOnly>
|
||||
|
||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||
<header>
|
||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||
<Icon name="ri:building-4-line" class="mr-2 inline-block size-5" />
|
||||
Service Affiliations
|
||||
</h2>
|
||||
</header>
|
||||
{
|
||||
!user.admin && !user.moderator && (
|
||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||
<header>
|
||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||
<Icon name="ri:building-4-line" class="mr-2 inline-block size-5" />
|
||||
Service Affiliations
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
{
|
||||
user.serviceAffiliations.length > 0 ? (
|
||||
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
|
||||
{user.serviceAffiliations.map((affiliation) => {
|
||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||
const statusIcon = {
|
||||
...verificationStatusesByValue,
|
||||
APPROVED: undefined,
|
||||
}[affiliation.service.verificationStatus]
|
||||
{user.serviceAffiliations.length > 0 ? (
|
||||
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
|
||||
{user.serviceAffiliations.map((affiliation) => {
|
||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||
const statusIcon = {
|
||||
...verificationStatusesByValue,
|
||||
APPROVED: undefined,
|
||||
}[affiliation.service.verificationStatus]
|
||||
|
||||
return (
|
||||
<li class="shrink-0">
|
||||
<a
|
||||
href={`/service/${affiliation.service.slug}`}
|
||||
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
|
||||
>
|
||||
<MyPicture
|
||||
src={affiliation.service.imageUrl}
|
||||
fallback="service"
|
||||
alt={affiliation.service.name}
|
||||
width={40}
|
||||
height={40}
|
||||
class="size-10 shrink-0 rounded-lg"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
|
||||
<span class="text-day-500">of</span>
|
||||
return (
|
||||
<li class="shrink-0">
|
||||
<a
|
||||
href={`/service/${affiliation.service.slug}`}
|
||||
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
|
||||
>
|
||||
<MyPicture
|
||||
src={affiliation.service.imageUrl}
|
||||
fallback="service"
|
||||
alt={affiliation.service.name}
|
||||
width={40}
|
||||
height={40}
|
||||
class="size-10 shrink-0 rounded-lg"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
|
||||
<span class="text-day-500">of</span>
|
||||
</div>
|
||||
|
||||
<div class="text-day-300 flex items-center gap-1 font-semibold">
|
||||
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap group-hover:underline">
|
||||
{affiliation.service.name}
|
||||
</span>
|
||||
{statusIcon && (
|
||||
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0">
|
||||
<Icon
|
||||
name={statusIcon.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1',
|
||||
statusIcon.classNames.icon
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p class="text-day-400 mb-6">No service affiliations yet.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="text-day-300 flex items-center gap-1 font-semibold">
|
||||
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap group-hover:underline">
|
||||
{affiliation.service.name}
|
||||
</span>
|
||||
{statusIcon && (
|
||||
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0">
|
||||
<Icon
|
||||
name={statusIcon.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1',
|
||||
statusIcon.classNames.icon
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p class="text-day-400 mb-6">No service affiliations yet.</p>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
{
|
||||
!user.admin && !user.moderator && (
|
||||
<section
|
||||
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
|
||||
id="karma-unlocks"
|
||||
>
|
||||
<header>
|
||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||
<Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" />
|
||||
Karma Unlocks
|
||||
</h2>
|
||||
|
||||
<section
|
||||
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
|
||||
id="karma-unlocks"
|
||||
>
|
||||
<header>
|
||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||
<Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" />
|
||||
Karma Unlocks
|
||||
</h2>
|
||||
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
||||
<p class="text-day-300">
|
||||
Earn karma to unlock features and privileges.{' '}
|
||||
<a href="/docs/karma" class="text-day-200 hover:underline">
|
||||
Learn about karma
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
||||
<p class="text-day-300">
|
||||
Earn karma to unlock features and privileges. <a
|
||||
href="/docs/karma"
|
||||
class="text-day-200 hover:underline">Learn about karma</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-3">
|
||||
<input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
|
||||
<label
|
||||
for="positive-unlocks-toggle"
|
||||
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
|
||||
>
|
||||
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
|
||||
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-3">
|
||||
<input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
|
||||
<label
|
||||
for="positive-unlocks-toggle"
|
||||
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
|
||||
>
|
||||
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
|
||||
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
|
||||
</label>
|
||||
|
||||
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
|
||||
{
|
||||
sortBy(
|
||||
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
|
||||
'karma'
|
||||
).map((unlock) => (
|
||||
<div
|
||||
class={cn(
|
||||
'flex items-center justify-between rounded-md border p-3',
|
||||
user.karmaUnlocks[unlock.id]
|
||||
? 'border-green-500/30 bg-green-500/10'
|
||||
: 'border-night-500 bg-night-800'
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
|
||||
<Icon name={unlock.icon} class="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
class={cn(
|
||||
'font-medium',
|
||||
user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
|
||||
)}
|
||||
>
|
||||
{unlock.name}
|
||||
</p>
|
||||
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{user.karmaUnlocks[unlock.id] ? (
|
||||
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
|
||||
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
|
||||
</span>
|
||||
) : (
|
||||
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
|
||||
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
|
||||
|
||||
<label
|
||||
for="negative-unlocks-toggle"
|
||||
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
|
||||
>
|
||||
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
|
||||
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
|
||||
</label>
|
||||
|
||||
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
|
||||
{
|
||||
sortBy(
|
||||
karmaUnlocks.filter((unlock) => unlock.karma < 0),
|
||||
'karma'
|
||||
)
|
||||
.reverse()
|
||||
.map((unlock) => (
|
||||
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
|
||||
{sortBy(
|
||||
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
|
||||
'karma'
|
||||
).map((unlock) => (
|
||||
<div
|
||||
class={cn(
|
||||
'flex items-center justify-between rounded-md border p-3',
|
||||
user.karmaUnlocks[unlock.id]
|
||||
? 'border-red-500/30 bg-red-500/10'
|
||||
? 'border-green-500/30 bg-green-500/10'
|
||||
: 'border-night-500 bg-night-800'
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
|
||||
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
|
||||
<Icon name={unlock.icon} class="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
class={cn(
|
||||
'font-medium',
|
||||
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
|
||||
user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
|
||||
)}
|
||||
>
|
||||
{unlock.name}
|
||||
@@ -767,28 +710,89 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
</div>
|
||||
<div>
|
||||
{user.karmaUnlocks[unlock.id] ? (
|
||||
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
|
||||
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
|
||||
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
|
||||
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
|
||||
</span>
|
||||
) : (
|
||||
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
|
||||
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
|
||||
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
|
||||
<Icon name="ri:information-line" class="inline-block size-4" />
|
||||
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
|
||||
avoid penalties.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
|
||||
|
||||
<label
|
||||
for="negative-unlocks-toggle"
|
||||
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
|
||||
>
|
||||
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
|
||||
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
|
||||
</label>
|
||||
|
||||
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
|
||||
{sortBy(
|
||||
karmaUnlocks.filter((unlock) => unlock.karma < 0),
|
||||
'karma'
|
||||
)
|
||||
.reverse()
|
||||
.map((unlock) => (
|
||||
<div
|
||||
class={cn(
|
||||
'flex items-center justify-between rounded-md border p-3',
|
||||
user.karmaUnlocks[unlock.id]
|
||||
? 'border-red-500/30 bg-red-500/10'
|
||||
: 'border-night-500 bg-night-800'
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}
|
||||
>
|
||||
<Icon name={unlock.icon} class="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
class={cn(
|
||||
'font-medium',
|
||||
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
|
||||
)}
|
||||
>
|
||||
{unlock.name}
|
||||
</p>
|
||||
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{user.karmaUnlocks[unlock.id] ? (
|
||||
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
|
||||
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
|
||||
</span>
|
||||
) : (
|
||||
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
|
||||
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
|
||||
<Icon name="ri:information-line" class="inline-block size-4" />
|
||||
Negative karma leads to restrictions. <br class="hidden sm:block" />
|
||||
Keep interactions positive to avoid penalties.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="space-y-6">
|
||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||
@@ -931,144 +935,143 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
)
|
||||
}
|
||||
|
||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||
<Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" />
|
||||
Recent Suggestions
|
||||
</h2>
|
||||
</header>
|
||||
{
|
||||
!user.admin && !user.moderator && (
|
||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||
<Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" />
|
||||
Recent Suggestions
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
{
|
||||
user.suggestions.length === 0 ? (
|
||||
<p class="text-day-400">No suggestions yet.</p>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="divide-night-400/20 w-full min-w-full divide-y">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
|
||||
<th class="text-day-400 px-4 py-3 text-left text-xs">Type</th>
|
||||
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
|
||||
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-night-400/10 divide-y">
|
||||
{user.suggestions.map((suggestion) => {
|
||||
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
|
||||
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
|
||||
{user.suggestions.length === 0 ? (
|
||||
<p class="text-day-400">No suggestions yet.</p>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="divide-night-400/20 w-full min-w-full divide-y">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
|
||||
<th class="text-day-400 px-4 py-3 text-left text-xs">Type</th>
|
||||
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
|
||||
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-night-400/10 divide-y">
|
||||
{user.suggestions.map((suggestion) => {
|
||||
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
|
||||
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
|
||||
|
||||
return (
|
||||
<tr class="hover:bg-night-500/5">
|
||||
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
|
||||
<a
|
||||
href={`/service-suggestion/${suggestion.id}`}
|
||||
class="text-blue-400 hover:underline"
|
||||
>
|
||||
{suggestion.service.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
|
||||
<span class="inline-flex items-center">
|
||||
<Icon name={typeInfo.icon} class="mr-1 size-4" />
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
return (
|
||||
<tr class="hover:bg-night-500/5">
|
||||
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
|
||||
<a
|
||||
href={`/service-suggestion/${suggestion.id}`}
|
||||
class="text-blue-400 hover:underline"
|
||||
>
|
||||
{suggestion.service.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
|
||||
<span class="inline-flex items-center">
|
||||
<Icon name={typeInfo.icon} class="mr-1 size-4" />
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
|
||||
<Icon name={statusInfo.icon} class="mr-1 size-3" />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
|
||||
<TimeFormatted
|
||||
date={suggestion.createdAt}
|
||||
prefix={false}
|
||||
hourPrecision
|
||||
caseType="sentence"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!user.admin && !user.moderator && (
|
||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
|
||||
Recent Karma Transactions
|
||||
</h2>
|
||||
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
|
||||
</header>
|
||||
|
||||
{user.karmaTransactions.length === 0 ? (
|
||||
<p class="text-day-400">No karma transactions yet.</p>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="divide-night-400/20 w-full min-w-full divide-y">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-day-400 px-4 py-3 text-left text-xs">Action</th>
|
||||
<th class="text-day-400 px-4 py-3 text-left text-xs">Description</th>
|
||||
<th class="text-day-400 px-4 py-3 text-right text-xs">Points</th>
|
||||
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-night-400/10 divide-y">
|
||||
{user.karmaTransactions.map((transaction) => {
|
||||
const actionInfo = getKarmaTransactionActionInfo(transaction.action)
|
||||
return (
|
||||
<tr class="hover:bg-night-500/5">
|
||||
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Icon name={actionInfo.icon} class="size-4" />
|
||||
{actionInfo.label}
|
||||
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
|
||||
<>
|
||||
<span class="text-day-500">from</span>
|
||||
<UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
|
||||
<td
|
||||
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
|
||||
'px-4 py-3 text-right text-xs whitespace-nowrap',
|
||||
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
<Icon name={statusInfo.icon} class="mr-1 size-3" />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
|
||||
<TimeFormatted
|
||||
date={suggestion.createdAt}
|
||||
prefix={false}
|
||||
hourPrecision
|
||||
caseType="sentence"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
|
||||
Recent Karma Transactions
|
||||
</h2>
|
||||
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
|
||||
</header>
|
||||
|
||||
{
|
||||
user.karmaTransactions.length === 0 ? (
|
||||
<p class="text-day-400">No karma transactions yet.</p>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="divide-night-400/20 w-full min-w-full divide-y">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-day-400 px-4 py-3 text-left text-xs">Action</th>
|
||||
<th class="text-day-400 px-4 py-3 text-left text-xs">Description</th>
|
||||
<th class="text-day-400 px-4 py-3 text-right text-xs">Points</th>
|
||||
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-night-400/10 divide-y">
|
||||
{user.karmaTransactions.map((transaction) => {
|
||||
const actionInfo = getKarmaTransactionActionInfo(transaction.action)
|
||||
return (
|
||||
<tr class="hover:bg-night-500/5">
|
||||
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Icon name={actionInfo.icon} class="size-4" />
|
||||
{actionInfo.label}
|
||||
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
|
||||
<>
|
||||
<span class="text-day-500">from</span>
|
||||
<UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
|
||||
<td
|
||||
class={cn(
|
||||
'px-4 py-3 text-right text-xs whitespace-nowrap',
|
||||
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{transaction.points >= 0 && '+'}
|
||||
{transaction.points}
|
||||
</td>
|
||||
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
|
||||
<TimeFormatted
|
||||
date={transaction.createdAt}
|
||||
prefix={false}
|
||||
hourPrecision
|
||||
caseType="sentence"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
{transaction.points >= 0 && '+'}
|
||||
{transaction.points}
|
||||
</td>
|
||||
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
|
||||
<TimeFormatted
|
||||
date={transaction.createdAt}
|
||||
prefix={false}
|
||||
hourPrecision
|
||||
caseType="sentence"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -103,10 +103,6 @@
|
||||
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
|
||||
}
|
||||
|
||||
@utility scrollbar-w-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@utility checkbox-force-checked {
|
||||
&:not(:checked) {
|
||||
@apply border-transparent! bg-current/50!;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
|
||||
import { makeNotificationOptions } from './lib/notificationOptions'
|
||||
import {
|
||||
makeBrowserNotificationOptions,
|
||||
makeBrowserNotificationTitle,
|
||||
} from './lib/client/notificationOptions'
|
||||
|
||||
import type { NotificationData, NotificationPayload } from './lib/serverEventsTypes'
|
||||
|
||||
@@ -59,8 +62,8 @@ async function handleNotificationClick(url: string) {
|
||||
|
||||
async function showPushNotification(payload: NotificationPayload | null) {
|
||||
await self.registration.showNotification(
|
||||
payload?.title ?? 'New Notification',
|
||||
makeNotificationOptions(payload)
|
||||
makeBrowserNotificationTitle(payload?.title),
|
||||
makeBrowserNotificationOptions(payload)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||