Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
b7ae6dc22c Release 202507010740 2025-07-01 07:40:26 +00:00
pluja
e4a5fa8fa7 Release 202506241430 2025-06-24 14:30:07 +00:00
pluja
6ed07c8386 Release 202506171537 2025-06-17 15:37:10 +00:00
pluja
6a9f5f5e99 Release 202506151438 2025-06-15 14:38:24 +00:00
30 changed files with 1428 additions and 918 deletions

View File

@@ -95,6 +95,12 @@ def parse_args(args: List[str]) -> argparse.Namespace:
help="Recalculate scores for all services (ignores --service-id)", 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) return parser.parse_args(args)
@@ -358,6 +364,13 @@ def run_service_score_recalc_task(
close_db_pool() close_db_pool()
def run_service_score_recalc_all_task() -> int:
"""
Run the service score recalculation task for all services.
"""
return run_service_score_recalc_task(all_services=True)
def run_worker_mode() -> int: def run_worker_mode() -> int:
""" """
Run in worker mode, scheduling tasks to run periodically. Run in worker mode, scheduling tasks to run periodically.
@@ -396,6 +409,12 @@ def run_worker_mode() -> int:
scheduler.register_task( scheduler.register_task(
task_name, cron_expression, run_service_score_recalc_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: else:
logger.warning(f"Unknown task '{task_name}', skipping") logger.warning(f"Unknown task '{task_name}', skipping")
@@ -405,6 +424,12 @@ def run_worker_mode() -> int:
"*/5 * * * *", "*/5 * * * *",
run_service_score_recalc_task, 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 # Start the scheduler if tasks were registered
if scheduler.tasks: if scheduler.tasks:
@@ -457,6 +482,8 @@ def main() -> int:
return run_service_score_recalc_task( return run_service_score_recalc_task(
args.service_id, getattr(args, "all", False) args.service_id, getattr(args, "all", False)
) )
elif args.task == "service-score-recalc-all":
return run_service_score_recalc_all_task()
elif args.task: elif args.task:
logger.error(f"Unknown task: {args.task}") logger.error(f"Unknown task: {args.task}")
return 1 return 1

View File

@@ -62,25 +62,27 @@ class TaskScheduler:
cron_expression: Cron expression defining the schedule. cron_expression: Cron expression defining the schedule.
task_func: Function to execute. task_func: Function to execute.
*args: Arguments to pass to the task function. *args: Arguments to pass to the task function.
**kwargs: Keyword arguments to pass to the task function. **kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg.
""" """
instantiate = kwargs.pop("instantiate", True)
# Declare task_instance variable with type annotation upfront # Declare task_instance variable with type annotation upfront
task_instance: Any = None task_instance: Any = None
# Initialize the appropriate task class based on the task name if instantiate:
if task_name.lower() == "tosreview": # Initialize the appropriate task class based on the task name
task_instance = TosReviewTask() if task_name.lower() == "tosreview":
elif task_name.lower() == "user_sentiment": task_instance = TosReviewTask()
task_instance = UserSentimentTask() elif task_name.lower() == "user_sentiment":
elif task_name.lower() == "comment_moderation": task_instance = UserSentimentTask()
task_instance = CommentModerationTask() elif task_name.lower() == "comment_moderation":
elif task_name.lower() == "force_triggers": task_instance = CommentModerationTask()
task_instance = ForceTriggersTask() elif task_name.lower() == "force_triggers":
elif task_name.lower() == "service_score_recalc": task_instance = ForceTriggersTask()
task_instance = ServiceScoreRecalculationTask() elif task_name.lower() == "service_score_recalc":
else: task_instance = ServiceScoreRecalculationTask()
self.logger.warning(f"Unknown task '{task_name}', skipping") else:
return self.logger.warning(f"Unknown task '{task_name}', skipping")
return
self.tasks[task_name] = { self.tasks[task_name] = {
"cron": cron_expression, "cron": cron_expression,
@@ -126,8 +128,12 @@ class TaskScheduler:
self.logger.info(f"Running task '{task_name}'") self.logger.info(f"Running task '{task_name}'")
# Use task instance as a context manager to ensure # Use task instance as a context manager to ensure
# a single database connection is used for the entire task # a single database connection is used for the entire task
with task_info["instance"]: if task_info["instance"]:
# Execute the registered task function with its arguments with task_info["instance"]:
# Execute the registered task function with its arguments
task_info["func"](*task_info["args"], **task_info["kwargs"])
else:
# Execute the registered task function without a context manager
task_info["func"](*task_info["args"], **task_info["kwargs"]) task_info["func"](*task_info["args"], **task_info["kwargs"])
self.logger.info(f"Task '{task_name}' completed") self.logger.info(f"Task '{task_name}' completed")
except Exception as e: except Exception as e:

835
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,26 +31,26 @@
"@astrojs/rss": "4.0.12", "@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8", "@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.6",
"@fontsource/space-grotesk": "5.2.8", "@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.9.0", "@prisma/client": "6.10.1",
"@tailwindcss/vite": "4.1.8", "@tailwindcss/vite": "4.1.11",
"@types/mime-types": "3.0.0", "@types/mime-types": "3.0.1",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"astro": "5.9.0", "astro": "5.10.1",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4", "astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0", "astro-seo-schema": "5.0.0",
"canvas": "3.1.0", "canvas": "3.1.2",
"clsx": "2.1.1", "clsx": "2.1.1",
"htmx.org": "1.9.12", "htmx.org": "2.0.6",
"javascript-time-ago": "2.5.11", "javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.9", "libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"pg": "8.16.0", "pg": "8.16.3",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.5.6", "redis": "5.5.6",
@@ -58,52 +58,51 @@
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"sharp": "0.34.2", "sharp": "0.34.2",
"slugify": "1.6.6", "slugify": "1.6.6",
"tailwind-merge": "3.3.0", "tailwind-merge": "3.3.1",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
"tailwindcss": "4.1.8", "tailwindcss": "4.1.11",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7", "web-push": "3.6.7"
"zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.28.0", "@eslint/js": "9.30.0",
"@faker-js/faker": "9.8.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/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5", "@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.4.1", "@stylistic/eslint-plugin": "5.1.0",
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4", "@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.33.1", "@typescript-eslint/parser": "8.35.1",
"@vite-pwa/assets-generator": "1.0.0", "@vite-pwa/assets-generator": "1.0.0",
"@vite-pwa/astro": "1.1.0", "@vite-pwa/astro": "1.1.0",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"esbuild": "0.25.5", "esbuild": "0.25.5",
"eslint": "9.28.0", "eslint": "9.30.0",
"eslint-import-resolver-typescript": "4.4.3", "eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-astro": "1.3.1", "eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.2.0", "globals": "16.2.0",
"prettier": "3.5.3", "prettier": "3.6.2",
"prettier-plugin-astro": "0.14.1", "prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.12", "prettier-plugin-tailwindcss": "0.6.13",
"prisma": "6.9.0", "prisma": "6.10.1",
"prisma-json-types-generator": "3.4.2", "prisma-json-types-generator": "3.5.0",
"tailwind-htmx": "0.1.2", "tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4", "ts-essentials": "10.1.1",
"ts-toolbelt": "9.6.0", "ts-toolbelt": "9.6.0",
"tsx": "4.19.4", "tsx": "4.20.3",
"typescript-eslint": "8.33.1", "typescript-eslint": "8.35.1",
"vite-plugin-devtools-json": "0.1.1", "vite-plugin-devtools-json": "0.2.1",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
} }

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);

View File

@@ -349,6 +349,8 @@ model Service {
categories Category[] @relation("ServiceToCategory") categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4) kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE) 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) overallScore Int @default(0)
privacyScore Int @default(0) privacyScore Int @default(0)
trustScore Int @default(0) trustScore Int @default(0)

View File

@@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
NEW RECORD, NEW RECORD,
OLD RECORD OLD RECORD
) RETURNS VOID AS $$ ) RETURNS VOID AS $$
DECLARE
is_user_related_to_service BOOLEAN;
is_user_admin_or_moderator BOOLEAN;
BEGIN BEGIN
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
PERFORM insert_karma_transaction( -- Check if the user is related to the service (e.g., owns/manages it)
NEW."authorId", SELECT EXISTS(
1, SELECT 1 FROM "ServiceUser"
'COMMENT_APPROVED', WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId"
NEW.id, ) INTO is_user_related_to_service;
format('Your comment #comment-%s in %s has been approved!',
-- Check if the user is an admin or moderator
SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = NEW."authorId"
INTO is_user_admin_or_moderator;
-- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator
IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN
PERFORM insert_karma_transaction(
NEW."authorId",
1,
'COMMENT_APPROVED',
NEW.id, NEW.id,
(SELECT name FROM "Service" WHERE id = NEW."serviceId")) format('Your comment #comment-%s in %s has been approved!',
); NEW.id,
PERFORM update_user_karma(NEW."authorId", 1); (SELECT name FROM "Service" WHERE id = NEW."serviceId"))
);
PERFORM update_user_karma(NEW."authorId", 1);
END IF;
END IF; END IF;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
NEW RECORD, NEW RECORD,
OLD RECORD OLD RECORD
) RETURNS VOID AS $$ ) RETURNS VOID AS $$
DECLARE
is_user_admin_or_moderator BOOLEAN;
BEGIN BEGIN
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
PERFORM insert_karma_transaction( -- Check if the comment author is an admin or moderator
NEW."authorId", SELECT (admin = true OR moderator = true)
5, FROM "User"
'COMMENT_VERIFIED', WHERE id = NEW."authorId"
NEW.id, INTO is_user_admin_or_moderator;
format('Your comment #comment-%s in %s has been verified!',
-- Only award karma if the user is NOT an admin/moderator
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
PERFORM insert_karma_transaction(
NEW."authorId",
5,
'COMMENT_VERIFIED',
NEW.id, NEW.id,
(SELECT name FROM "Service" WHERE id = NEW."serviceId")) format('Your comment #comment-%s in %s has been verified!',
); NEW.id,
PERFORM update_user_karma(NEW."authorId", 5); (SELECT name FROM "Service" WHERE id = NEW."serviceId"))
);
PERFORM update_user_karma(NEW."authorId", 5);
END IF;
END IF; END IF;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -146,12 +175,19 @@ DECLARE
comment_author_id INT; comment_author_id INT;
service_name TEXT; service_name TEXT;
upvote_change INT := 0; -- Variable to track change in upvotes upvote_change INT := 0; -- Variable to track change in upvotes
is_author_admin_or_moderator BOOLEAN;
BEGIN BEGIN
-- Get comment author and service info -- Get comment author and service info
SELECT c."authorId", s.name INTO comment_author_id, service_name SELECT c."authorId", s.name INTO comment_author_id, service_name
FROM "Comment" c FROM "Comment" c
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id; JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
-- Check if the comment author is an admin or moderator
SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = comment_author_id
INTO is_author_admin_or_moderator;
-- Calculate karma impact based on vote type -- Calculate karma impact based on vote type
IF TG_OP = 'INSERT' THEN IF TG_OP = 'INSERT' THEN
-- New vote -- New vote
@@ -181,16 +217,19 @@ BEGIN
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
END IF; END IF;
-- Record karma transaction and update user karma -- Only award karma if the author is NOT an admin/moderator
PERFORM insert_karma_transaction( IF NOT COALESCE(is_author_admin_or_moderator, false) THEN
comment_author_id, -- Record karma transaction and update user karma
karma_points, PERFORM insert_karma_transaction(
vote_action, comment_author_id,
COALESCE(NEW."commentId", OLD."commentId"), karma_points,
vote_description vote_action,
); COALESCE(NEW."commentId", OLD."commentId"),
vote_description
);
PERFORM update_user_karma(comment_author_id, karma_points); PERFORM update_user_karma(comment_author_id, karma_points);
END IF;
-- Update comment's upvotes count incrementally -- Update comment's upvotes count incrementally
UPDATE "Comment" UPDATE "Comment"
@@ -236,26 +275,36 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
DECLARE DECLARE
service_name TEXT; service_name TEXT;
is_user_admin_or_moderator BOOLEAN;
BEGIN BEGIN
-- Award karma for first approval -- Award karma for first approval
-- Check that OLD.status is not NULL to handle the initial creation case if needed, -- Check that OLD.status is not NULL to handle the initial creation case if needed,
-- and ensure it wasn't already APPROVED. -- and ensure it wasn't already APPROVED.
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
-- Fetch service name for the description -- Check if the user is an admin or moderator
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId"; SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = NEW."userId"
INTO is_user_admin_or_moderator;
-- Insert karma transaction, linking it to the suggestion -- Only award karma if the user is NOT an admin/moderator
PERFORM insert_karma_transaction( IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
NEW."userId", -- Fetch service name for the description
10, SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
'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 -- Insert karma transaction, linking it to the suggestion
PERFORM update_user_karma(NEW."userId", 10); PERFORM insert_karma_transaction(
NEW."userId",
10,
'SUGGESTION_APPROVED',
NULL, -- p_comment_id (not applicable)
format('Your suggestion for service ''%s'' has been approved!', service_name),
NEW.id -- p_suggestion_id
);
-- Update user's total karma
PERFORM update_user_karma(NEW."userId", 10);
END IF;
END IF; END IF;
RETURN NEW; -- Result is ignored since this is an AFTER trigger RETURN NEW; -- Result is ignored since this is an AFTER trigger

View File

@@ -95,6 +95,7 @@ DECLARE
attributes_score INT := 0; attributes_score INT := 0;
recently_approved_factor INT := 0; recently_approved_factor INT := 0;
tos_penalty_factor INT := 0; tos_penalty_factor INT := 0;
operating_since_factor INT := 0;
BEGIN BEGIN
-- Get verification status factor -- Get verification status factor
SELECT SELECT
@@ -148,8 +149,20 @@ BEGIN
tos_penalty_factor := -3; tos_penalty_factor := -3;
END IF; END IF;
-- Determine trust adjustment based on operatingSince
SELECT
CASE
WHEN "operatingSince" IS NULL THEN 0
WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty
WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus
ELSE 0
END
INTO operating_since_factor
FROM "Service"
WHERE id = service_id;
-- Calculate final trust score (base 100) -- Calculate final trust score (base 100)
trust_score := 50 + verification_factor + attributes_score + recently_approved_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) -- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score)); trust_score := GREATEST(0, LEAST(100, trust_score));

View File

@@ -1,5 +1,6 @@
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { pick } from 'lodash-es'
import { karmaUnlocksById } from '../constants/karmaUnlocks' import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { createAccount } from '../lib/accountCreate' import { createAccount } from '../lib/accountCreate'
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
import { handleHoneypotTrap } from '../lib/honeypot' import { handleHoneypotTrap } from '../lib/honeypot'
import { startImpersonating } from '../lib/impersonation' import { startImpersonating, stopImpersonating } from '../lib/impersonation'
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks' import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens' import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
@@ -225,4 +226,36 @@ export const accountActions = {
return { user } return { user }
}, },
}), }),
delete: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z
.object({
...captchaFormSchemaProperties,
})
.superRefine(captchaFormSchemaSuperRefine),
handler: async (_input, context) => {
if (context.locals.user.admin || context.locals.user.moderator) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Admins and moderators cannot delete their own accounts.',
})
}
await prisma.user.delete({
where: { id: context.locals.user.id },
})
const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture'])
if (context.locals.actualUser) {
await stopImpersonating(context)
} else {
await logout(context)
}
return { deletedUser }
},
}),
} }

View File

@@ -58,6 +58,7 @@ const serviceSchemaBase = z.object({
.optional() .optional()
.nullable() .nullable()
.default(null), .default(null),
operatingSince: z.coerce.date().optional().nullable(),
imageFile: imageFileSchema, imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility), serviceVisibility: z.nativeEnum(ServiceVisibility),
@@ -150,6 +151,7 @@ export const adminServiceActions = {
}, },
} }
: undefined, : undefined,
operatingSince: input.operatingSince,
}, },
select: { select: {
id: true, id: true,
@@ -258,7 +260,6 @@ export const adminServiceActions = {
), ),
} }
: undefined, : undefined,
imageUrl, imageUrl,
categories: { categories: {
connect: categoriesToAdd.map((id) => ({ id })), connect: categoriesToAdd.map((id) => ({ id })),
@@ -275,6 +276,7 @@ export const adminServiceActions = {
attributeId, attributeId,
})), })),
}, },
operatingSince: input.operatingSince,
}, },
}) })

View File

@@ -1,4 +1,3 @@
import { ActionError } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
@@ -58,28 +57,15 @@ export const notificationActions = {
accept: 'json', accept: 'json',
permissions: 'guest', permissions: 'guest',
input: z.object({ input: z.object({
endpoint: z.string().optional(), endpoint: z.string(),
}), }),
handler: async (input, context) => { handler: async (input, context) => {
if (input.endpoint) { await prisma.pushSubscription.delete({
await prisma.pushSubscription.deleteMany({ where: {
where: { userId: context.locals.user?.id ?? undefined,
userId: context.locals.user?.id ?? undefined, endpoint: input.endpoint,
endpoint: input.endpoint, },
}, })
})
} else if (context.locals.user) {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
},
})
} else {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Endpoint is required when user is not logged in.',
})
}
}, },
}), }),
}, },

View File

@@ -165,6 +165,7 @@ export const serviceSuggestionActions = {
message: 'You must accept the suggestion rules and process to continue', message: 'You must accept the suggestion rules and process to continue',
}), }),
}), }),
operatingSince: z.coerce.date().optional(),
/** @deprecated Honey pot field, do not use */ /** @deprecated Honey pot field, do not use */
message: z.unknown().optional(), message: z.unknown().optional(),
skipDuplicateCheck: z skipDuplicateCheck: z
@@ -239,6 +240,7 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
operatingSince: input.operatingSince,
serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls, onionUrls,

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

View File

@@ -8,6 +8,7 @@ import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays' import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/client/envVariables' import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import AdminNavigationFixScript from './AdminNavigationFixScript.astro'
import DevToolsMessageScript from './DevToolsMessageScript.astro' import DevToolsMessageScript from './DevToolsMessageScript.astro'
import DynamicFavicon from './DynamicFavicon.astro' import DynamicFavicon from './DynamicFavicon.astro'
import HtmxScript from './HtmxScript.astro' import HtmxScript from './HtmxScript.astro'
@@ -108,6 +109,7 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<DynamicFavicon /> <DynamicFavicon />
<ClientRouter /> <ClientRouter />
<AdminNavigationFixScript />
<LoadingIndicator color="green" /> <LoadingIndicator color="green" />
<TailwindJsPluggin /> <TailwindJsPluggin />

View File

@@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type { HTMLAttributes } from 'astro/types' import type { HTMLTag, Polymorphic } from 'astro/types'
import type { Review, WithContext } from 'schema-dts' import type { Review, WithContext } from 'schema-dts'
export type Props = HTMLAttributes<'div'> & { type Props = Polymorphic<{
as: HTMLTag
score: number score: number
label: string label: string
total?: number total?: number
itemReviewedId?: string itemReviewedId?: string
} showInfo?: boolean
children?: never
}>
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props const {
as: Tag = 'div',
score,
label,
total = 100,
class: className,
itemReviewedId,
showInfo = false,
...htmlProps
} = Astro.props
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1) const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
@@ -65,13 +77,13 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
) )
} }
<div <Tag
{...htmlProps} {...htmlProps}
class={cn( class={cn(
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white', '2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
className className
)} )}
role="group" role={htmlProps.role ?? 'group'}
> >
<div <div
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', { class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
@@ -166,10 +178,14 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined} transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
class="stroke-night-700"></path> class="stroke-night-700"></path>
<!-- Info icon --> {
<!-- <path showInfo && (
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z" <path
fill="white" d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
fill-opacity="0.67"></path> --> class="text-current/60"
fill="currentColor"
/>
)
}
</svg> </svg>
</div> </Tag>

View File

@@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type { HTMLAttributes } from 'astro/types' import type { HTMLTag, Polymorphic } from 'astro/types'
export type Props = HTMLAttributes<'div'> & { type Props = Polymorphic<{
as: HTMLTag
score: number score: number
label: string label: string
total?: number total?: number
itemReviewedId?: string itemReviewedId?: string
} showInfo?: boolean
children?: never
}>
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props const {
as: Tag = 'div',
score,
label,
total = 10,
class: className,
itemReviewedId,
showInfo = false,
...htmlProps
} = Astro.props
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total) const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
--- ---
<div <Tag
{...htmlProps} {...htmlProps}
class={cn( class={cn(
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white', '2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
className className
)} )}
role="group" role={htmlProps.role ?? 'group'}
> >
{ {
!!itemReviewedId && ( !!itemReviewedId && (
@@ -48,15 +60,17 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
/> />
) )
} }
<!-- <svg {
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60" showInfo && (
viewBox="0 0 12 12" <svg
fill="currentColor" class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
> viewBox="0 0 12 12"
<path fill="currentColor"
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" >
></path> <path d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" />
</svg> --> </svg>
)
}
<div <div
class={cn( class={cn(
@@ -77,4 +91,4 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
</div> </div>
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span> <span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
</div> </Tag>

View File

@@ -6,7 +6,6 @@ import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
import { transformCase } from '../lib/strings'
import MyPicture from './MyPicture.astro' import MyPicture from './MyPicture.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
@@ -23,6 +22,8 @@ type Props = HTMLAttributes<'a'> & {
slug: true slug: true
description: true description: true
overallScore: true overallScore: true
privacyScore: true
trustScore: true
kycLevel: true kycLevel: true
imageUrl: true imageUrl: true
verificationStatus: true verificationStatus: true
@@ -45,6 +46,8 @@ const {
slug, slug,
description, description,
overallScore, overallScore,
privacyScore,
trustScore,
kycLevel, kycLevel,
imageUrl, imageUrl,
categories, categories,
@@ -163,7 +166,7 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold', 'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
overallScoreInfo.classNameBg overallScoreInfo.classNameBg
)} )}
text={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`} text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
> >
{overallScoreInfo.formattedScore} {overallScoreInfo.formattedScore}
</Tooltip> </Tooltip>

View File

@@ -52,9 +52,9 @@ const { service } = Astro.props
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200"> <div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
<Icon name="ri:alert-line" class="size-5 text-yellow-400" /> <Icon name="ri:alert-line" class="size-5 text-yellow-400" />
<span> <span>
Community-contributed. Information not reviewed. Community contributed. Information not reviewed.
<a <a
href="/about#suggestion-review-process" href="/about#community-contributed"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100" class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
> >
Learn more Learn more
@@ -68,7 +68,7 @@ const { service } = Astro.props
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with {service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution. caution.
<a <a
href="/about#suggestion-review-process" href="/about#approved"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100" class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
> >
Learn more Learn more
@@ -78,9 +78,9 @@ const { service } = Astro.props
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200"> <div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
<Icon name="ri:information-line" class="size-5 text-blue-400" /> <Icon name="ri:information-line" class="size-5 text-blue-400" />
<span> <span>
Basic checks passed, but not fully verified. Basic checks passed, but service is not verified.
<a <a
href="/about#suggestion-review-process" href="/about#approved"
class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100" class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
> >
Learn more Learn more

View File

@@ -26,7 +26,7 @@ type VerificationStatusInfo<T extends string | null | undefined = string> = {
} }
export const READ_MORE_SENTENCE_LINK = export const READ_MORE_SENTENCE_LINK =
'Read more about the [suggestion review process](/about#suggestion-review-process).' satisfies MarkdownString 'Read more about the [listing statuses](/about#listing-statuses).' satisfies MarkdownString
export const { export const {
dataArray: verificationStatuses, dataArray: verificationStatuses,

View File

@@ -1,3 +1,4 @@
import { differenceInMonths, differenceInYears } from 'date-fns'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeCategoryInfo } from '../constants/attributeCategories'
@@ -45,6 +46,7 @@ type NonDbAttributeFull = NonDbAttribute & {
acceptedCurrencies: true acceptedCurrencies: true
kycLevel: true kycLevel: true
kycLevelClarification: true kycLevelClarification: true
operatingSince: true
} }
}> }>
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & { ) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
@@ -255,6 +257,55 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
show: service.acceptedCurrencies.includes('MONERO'), show: service.acceptedCurrencies.includes('MONERO'),
}), }),
}, },
{
slug: 'new-service',
title: 'New service',
type: 'WARNING',
category: 'TRUST',
description:
'The service started operations less than a year ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).',
privacyPoints: 0,
trustPoints: -4,
links: [],
customize: (service) => {
if (!service.operatingSince) return { show: false }
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
if (yearsOperated >= 1) return { show: false }
const monthsOperated = differenceInMonths(new Date(), service.operatingSince)
return {
show: true,
description: `The service started operations ${
monthsOperated === 0
? 'less than a month'
: `${String(monthsOperated)} month${monthsOperated > 1 ? 's' : ''}`
} ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).`,
}
},
},
{
slug: 'mature-service',
title: 'Mature service',
type: 'GOOD',
category: 'TRUST',
description:
'This service has been operational for at least 2 years. While this indicates stability, it is not a future-proof guarantee.',
privacyPoints: 0,
trustPoints: 5,
links: [],
customize: (service) => {
if (!service.operatingSince) return { show: false }
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
return {
show: yearsOperated >= 2,
description: `This service has been operational for **${String(
yearsOperated
)} years**. While this indicates stability, it is not a future-proof guarantee.`,
}
},
},
] ]
export function sortAttributes< export function sortAttributes<

View File

@@ -45,7 +45,6 @@ function prismaClientSingleton() {
} }
declare global { declare global {
// eslint-disable-next-line no-var
var prisma: ReturnType<typeof prismaClientSingleton> | undefined var prisma: ReturnType<typeof prismaClientSingleton> | undefined
} }

View File

@@ -8,6 +8,21 @@ icon: 'ri:information-line'
import DonationAddress from '../components/DonationAddress.astro' import DonationAddress from '../components/DonationAddress.astro'
- [What is KYC?](#what-is-kyc)
- [Why does this site exist?](#why-does-this-site-exist)
- [Why only Bitcoin and Monero?](#why-only-bitcoin-and-monero)
- [User Accounts](#user-accounts)
- [Verified and Affiliated Users](#verified-and-affiliated-users)
- [Listings](#listings)
- [Suggesting a new listing](#suggesting-a-new-listing)
- [Listing statuses](#listing-statuses)
- [Reviews and Comments](#reviews-and-comments)
- [Moderation](#moderation)
- [API](#api)
- [Donate](#support)
- [Contact](#contact)
- [Downloads and Assets](#downloads-and-assets)
## What is this page? ## What is this page?
KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy. KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy.
@@ -52,9 +67,9 @@ Users earn karma by participating in the community. When your comments get appro
### Verified and Affiliated Users ### Verified and Affiliated Users
Some users are **verified**, this means that the moderators have confirmed that the user is related to a specific link or service. The verification is directly linked to the URL, ensuring that the person behind the username has a legitimate connection to the service they claim to represent. This verification process is reserved for individuals with established reputation. **Verified users** have proven their identity by linking their account to a specific website. This verification confirms they are legitimate representatives of that site, whether it's a personal website, blog, social media profile, or service page. You can request verification [in your profile](/account).
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us. **Affiliated users** are users who represent a service listed in the directory, such as owners, support staff, or team members. If you represent a service and want to become affiliated, you can request it [in your profile](/account).
## Listings ## Listings
@@ -71,12 +86,13 @@ To list a new service, it must fulfill these requirements:
- Offer a service - Offer a service
- Publicly available website explaining what the service is about - Publicly available website explaining what the service is about
- Terms of service or FAQ document - Terms of service or FAQ document
- The service must have been operating for at least 6 months.
Examples of non-valid services: Examples of non-valid services:
- Just a Telegram link
- A cryptocurrency project - A cryptocurrency project
- A cryptocurrency wallet - A cryptocurrency wallet
- A chat link without any information about the service, or review page.
#### Suggestion Review Process #### Suggestion Review Process
@@ -145,7 +161,7 @@ These categories **directly influence** a service's Privacy and Trust scores, wh
#### Service Scores #### Service Scores
Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements. Scores are calculated **automatically** using clear, fixed rules based on the attributes of the service ([See all attributes](/attributes)). We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
##### Privacy Score ##### Privacy Score
@@ -184,6 +200,32 @@ There are two types of events:
You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date. You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
### Listing Statuses
#### **Unlisted**
Initial state after the service is submitted. The service will **not appear in the list or search results**. Only accessible with a **direct link**. An **initial review** is done by the team to ensure the service is **not spam or inappropriate**.
#### **Community Contributed**
The service is **listed** in the directory, but it has **not been reviewed** by our team. The information **may be inaccurate, incomplete, or fraudulent**. Users should use these services **with caution**.
#### **Approved**
The service is listed in the directory and has been **reviewed by our team**. The information is **accurate and complete**. Initial tests were **successful**, but there is **not enough trust** to be verified.
#### **Verified**
The service has been listed for a while and has **not been reported** as a scam, user reviews are **mostly positive**, and the service is **not under any investigation**. Further tests and checks have been **successfully completed**. Contact with support or admins was also successful.
#### **Scam**
The service is a **scam**. User reports, negative reviews, or **failed internal testing** and other red flags were found. Evidence is provided in the **verification section** of the service page.
#### **Archived**
The service is **no longer available**. It may have been **shut down, acquired, or otherwise discontinued**. Still **visible** in the directory **for reference**.
## Reviews and Comments ## Reviews and Comments
Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one. Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one.
@@ -194,21 +236,38 @@ If you've used the service, you can add an **order ID** or proof—this is only
Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**. Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
### Note on moderation ### Moderation
**All comments are moderated.** First, an AI checks each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a human to review. We only remove comments that are spam, nonsense, unsupported accusations, doxxing, or clear rule violations. **All comments are moderated.** First, an **AI checks** each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a **human review**. We only remove comments that do not follow the guidelines.
Comments from [**users affiliated with a service**](/about#verified-and-affiliated-users) are automatically approved on their own service page.
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
#### Comment Guidelines
We welcome honest, constructive discussion. However, any comment or review that contains the following will be **rejected**:
- **Spam** promotional content, fake reviews, or coordinated campaigns.
- **Doxxing** harassment, threats, or disclosure of private information.
- **Illegal content** anything that encourages illegal activity.
- **AI-generated text** AI-written content is not allowed.
- **Unrelated content** content that is not related to the service.
- **Personal discussion** discussions not related to the service.
A review score may be **disabled** if it:
- Is not based on your own first-hand experience.
- Contains demonstrably false or unverified claims.
- Is not relevant to the service being reviewed.
## API ## API
You can access basic service data via our public API. You can access basic service data through our [public API](/docs/api).
See the [API page](/docs/api) for more details.
## Support ## Support
If you like this project, you can **support** it through these methods: You can **support** our work through these methods:
<DonationAddress <DonationAddress
cryptoName="Monero" cryptoName="Monero"
@@ -218,11 +277,11 @@ If you like this project, you can **support** it through these methods:
## Contact ## Contact
You can contact via direct chat: You can contact us through SimpleX Chat:
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion) - [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
## Downloads and assets ## Downloads and Assets
For logos and brand assets, visit our [downloads page](/downloads). For logos and brand assets, visit our [downloads page](/downloads).

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

View File

@@ -962,7 +962,7 @@ if (!user) return Astro.rewrite('/404')
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout <Icon name="ri:logout-box-r-line" class="size-4" /> Logout
</a> </a>
<a <a
href={`mailto:${SUPPORT_EMAIL}`} href="/account/delete"
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden" class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
> >
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account <Icon name="ri:delete-bin-line" class="size-4" /> Delete account

View File

@@ -361,6 +361,17 @@ const apiCalls = await Astro.locals.banners.try(
value={service.tosUrls.join('\n')} value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls} error={serviceInputErrors.tosUrls}
/> />
<InputText
label="Operating since"
name="operatingSince"
description="Date the service started operating"
inputProps={{
type: 'date',
value: service.operatingSince?.toISOString().slice(0, 10),
max: new Date().toISOString().slice(0, 10),
}}
error={serviceInputErrors.operatingSince}
/>
<InputText <InputText
label="Referral link path" label="Referral link path"
name="referral" name="referral"

View File

@@ -461,6 +461,25 @@ if (!user) return Astro.rewrite('/404')
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel /> <InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
</form> </form>
</FormSection> </FormSection>
<FormSection title="Delete User">
{
user.admin || user.moderator ? (
<p class="text-center text-balance">This user can't be deleted</p>
) : (
<p class="text-center text-balance">
To delete this user,
<a
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account/delete`}
data-astro-prefetch="tap"
class="underline"
>
impersonate it and go to /account/delete
</a>
</p>
)
}
</FormSection>
</BaseLayout> </BaseLayout>
<script> <script>

View File

@@ -15,55 +15,53 @@ import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
There are several ways to earn karma points: There are several ways to earn karma points:
1. **Comment Approval** (+1 point) 1. **Comment Approval** (+1 point)
- When your comment moves from 'unmoderated' to 'approved' status.
- When your comment moves from 'unmoderated' to 'approved' status - This is the basic reward for contributing a valid comment.
- This is the basic reward for contributing a valid comment - Users related to the service (e.g. owners, admins, etc.) do not get karma for their comments.
2. **Comment Verification** (+5 points) 2. **Comment Verification** (+5 points)
- When your comment is marked as 'verified'.
- When your comment is marked as 'verified' - This is a significant reward for providing particularly valuable or verified information.
- This is a significant reward for providing particularly valuable or verified information
3. **Upvotes** 3. **Upvotes**
- Each upvote on your comment adds +1 to your karma - Each upvote on your comment adds +1 to your karma.
- Similarly, each downvote reduces your karma by -1 - Similarly, each downvote reduces your karma by -1.
- This allows the community to reward helpful contributions - This allows the community to reward helpful contributions.
## Karma Penalties ## Karma Penalties
The system also includes penalties to discourage spam and low-quality content: The system also includes penalties to discourage spam and low-quality content:
1. **Spam Detection** (-10 points) 1. **Spam Detection** (-10 points)
- If your comment is marked as suspicious/spam - If your comment is marked as suspicious/spam.
- This is a significant penalty to discourage spam behavior - This is a significant penalty to discourage spam behavior.
- If the spam mark is removed, the 10 points are restored - If the spam mark is removed, the 10 points are restored.
## Karma Tracking ## Karma Tracking
The system maintains a detailed record of all karma changes through: The system maintains a detailed record of all karma changes through:
1. **Karma Transactions** 1. **Karma Transactions**
- Every karma change is recorded as a transaction.
- Every karma change is recorded as a transaction
- Each transaction includes: - Each transaction includes:
- The action that triggered it - The action that triggered it.
- The number of points awarded/deducted - The number of points awarded/deducted.
- A description of why the karma changed - A description of why the karma changed.
- The related comment (if applicable) - The related comment (if applicable).
2. **Total Karma** 2. **Total Karma**
- Your total karma is displayed on your profile - Your total karma is displayed on your profile.
- It's the sum of all your karma transactions - It's the sum of all your karma transactions.
- This score helps establish your reputation in the community - This score helps establish your reputation in the community.
## Impact of Karma ## Impact of Karma
Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate: Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate:
- Active participation in the community - Active participation in the community.
- History of providing valuable information - History of providing valuable information.
- Trustworthiness of your contributions - Trustworthiness of your contributions.
- Commitment to community standards - Commitment to community standards.
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform. The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.

View File

@@ -238,17 +238,29 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
/> />
</div> </div>
<InputTextArea <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="ToS URLs" <InputTextArea
description="One per line. AI review uses the first working URL only." label="ToS URLs"
name="tosUrls" description="One per line. AI review uses the first working URL only."
inputProps={{ name="tosUrls"
placeholder: 'example.com/tos', inputProps={{
required: true, placeholder: 'example.com/tos',
class: 'min-h-10', required: true,
}} class: 'min-h-10',
error={inputErrors.tosUrls} }}
/> error={inputErrors.tosUrls}
/>
<InputText
label="Operating since"
name="operatingSince"
inputProps={{
type: 'date',
max: new Date().toISOString().slice(0, 10),
}}
error={inputErrors.operatingSince}
/>
</div>
<InputCardGroup <InputCardGroup
name="kycLevel" name="kycLevel"
@@ -311,6 +323,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon], icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon], iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}))} }))}
description="See list of [all attributes](/attributes) and their scoring."
error={inputErrors.attributes} error={inputErrors.attributes}
size="lg" size="lg"
/> />

View File

@@ -95,6 +95,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
approvedAt: true, approvedAt: true,
createdAt: true, createdAt: true,
acceptedCurrencies: true, acceptedCurrencies: true,
operatingSince: true,
tosReview: true, tosReview: true,
tosReviewAt: true, tosReviewAt: true,
userSentiment: true, userSentiment: true,
@@ -901,13 +902,34 @@ const activeEventToShow =
</h2> </h2>
<div class="xs:gap-8 flex flex-row flex-wrap items-stretch justify-center gap-4 sm:justify-between"> <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"> <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> <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>
<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" 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"

View File

@@ -393,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> <span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span>
<div> <div>
<p class="text-day-500 text-xs">Karma</p> <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> </div>
</li> </li>
</ul> </ul>
@@ -575,187 +577,130 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</section> </section>
</AdminOnly> </AdminOnly>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> {
<header> !user.admin && !user.moderator && (
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<Icon name="ri:building-4-line" class="mr-2 inline-block size-5" /> <header>
Service Affiliations <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
</h2> <Icon name="ri:building-4-line" class="mr-2 inline-block size-5" />
</header> Service Affiliations
</h2>
</header>
{ {user.serviceAffiliations.length > 0 ? (
user.serviceAffiliations.length > 0 ? ( <ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4"> {user.serviceAffiliations.map((affiliation) => {
{user.serviceAffiliations.map((affiliation) => { const roleInfo = getServiceUserRoleInfo(affiliation.role)
const roleInfo = getServiceUserRoleInfo(affiliation.role) const statusIcon = {
const statusIcon = { ...verificationStatusesByValue,
...verificationStatusesByValue, APPROVED: undefined,
APPROVED: undefined, }[affiliation.service.verificationStatus]
}[affiliation.service.verificationStatus]
return ( return (
<li class="shrink-0"> <li class="shrink-0">
<a <a
href={`/service/${affiliation.service.slug}`} href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm" class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
> >
<MyPicture <MyPicture
src={affiliation.service.imageUrl} src={affiliation.service.imageUrl}
fallback="service" fallback="service"
alt={affiliation.service.name} alt={affiliation.service.name}
width={40} width={40}
height={40} height={40}
class="size-10 shrink-0 rounded-lg" class="size-10 shrink-0 rounded-lg"
/> />
<div class="flex min-w-0 flex-1 flex-col justify-center"> <div class="flex min-w-0 flex-1 flex-col justify-center">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} /> <BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
<span class="text-day-500">of</span> <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> </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"> !user.admin && !user.moderator && (
{affiliation.service.name} <section
</span> class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
{statusIcon && ( id="karma-unlocks"
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0"> >
<Icon <header>
name={statusIcon.icon} <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
class={cn( <Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" />
'inline-block size-6 shrink-0 rounded-lg p-1', Karma Unlocks
statusIcon.classNames.icon </h2>
)}
/>
</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>
<section <div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs" <p class="text-day-300">
id="karma-unlocks" Earn karma to unlock features and privileges.{' '}
> <a href="/docs/karma" class="text-day-200 hover:underline">
<header> Learn about karma
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> </a>
<Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" /> </p>
Karma Unlocks </div>
</h2> </header>
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<p class="text-day-300"> <div class="space-y-3">
Earn karma to unlock features and privileges. <a <input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
href="/docs/karma" <label
class="text-day-200 hover:underline">Learn about karma</a 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"
</p> >
</div> <h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
</header> <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="mt-3 hidden space-y-3 peer-checked:block md:block">
<div class="space-y-3"> {sortBy(
<input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked /> karmaUnlocks.filter((unlock) => unlock.karma >= 0),
<label 'karma'
for="positive-unlocks-toggle" ).map((unlock) => (
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 <div
class={cn( class={cn(
'flex items-center justify-between rounded-md border p-3', 'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id] 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' : 'border-night-500 bg-night-800'
)} )}
> >
<div class="flex items-center"> <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" /> <Icon name={unlock.icon} class="size-5" />
</span> </span>
<div> <div>
<p <p
class={cn( class={cn(
'font-medium', 'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400' user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)} )}
> >
{unlock.name} {unlock.name}
@@ -765,28 +710,89 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div> </div>
<div> <div>
{user.karmaUnlocks[unlock.id] ? ( {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"> <span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active <Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span> </span>
) : ( ) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs"> <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> </span>
)} )}
</div> </div>
</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"> <div class="space-y-3">
<Icon name="ri:information-line" class="inline-block size-4" /> <input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties. <label
</p> 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>
</div> )
</section> }
<div class="space-y-6"> <div class="space-y-6">
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
@@ -929,139 +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"> !user.admin && !user.moderator && (
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" /> <header class="flex items-center justify-between">
Recent Suggestions <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
</h2> <Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" />
</header> Recent Suggestions
</h2>
</header>
{ {user.suggestions.length === 0 ? (
user.suggestions.length === 0 ? ( <p class="text-day-400">No suggestions yet.</p>
<p class="text-day-400">No suggestions yet.</p> ) : (
) : ( <div class="overflow-x-auto">
<div class="overflow-x-auto"> <table class="divide-night-400/20 w-full min-w-full divide-y">
<table class="divide-night-400/20 w-full min-w-full divide-y"> <thead>
<thead> <tr>
<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">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-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-center text-xs">Status</th> <th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th> </tr>
</tr> </thead>
</thead> <tbody class="divide-night-400/10 divide-y">
<tbody class="divide-night-400/10 divide-y"> {user.suggestions.map((suggestion) => {
{user.suggestions.map((suggestion) => { const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type) const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
return ( return (
<tr class="hover:bg-night-500/5"> <tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap"> <td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a <a
href={`/service-suggestion/${suggestion.id}`} href={`/service-suggestion/${suggestion.id}`}
class="text-blue-400 hover:underline" class="text-blue-400 hover:underline"
> >
{suggestion.service.name} {suggestion.service.name}
</a> </a>
</td> </td>
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap"> <td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center"> <span class="inline-flex items-center">
<Icon name={typeInfo.icon} class="mr-1 size-4" /> <Icon name={typeInfo.icon} class="mr-1 size-4" />
{typeInfo.label} {typeInfo.label}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center"> <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"> <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" /> <Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label} {statusInfo.label}
</span> </span>
</td> </td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap"> <td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted <TimeFormatted
date={suggestion.createdAt} date={suggestion.createdAt}
prefix={false} prefix={false}
hourPrecision hourPrecision
caseType="sentence" caseType="sentence"
/> />
</td> </td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
) )}
} </section>
</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"> !user.admin && !user.moderator && (
<h2 class="font-title text-day-200 mb-4 text-xl font-bold"> <section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" /> <header class="flex items-center justify-between">
Recent Karma Transactions <h2 class="font-title text-day-200 mb-4 text-xl font-bold">
</h2> <Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span> Recent Karma Transactions
</header> </h2>
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
</header>
{ {user.karmaTransactions.length === 0 ? (
user.karmaTransactions.length === 0 ? ( <p class="text-day-400">No karma transactions yet.</p>
<p class="text-day-400">No karma transactions yet.</p> ) : (
) : ( <div class="overflow-x-auto">
<div class="overflow-x-auto"> <table class="divide-night-400/20 w-full min-w-full divide-y">
<table class="divide-night-400/20 w-full min-w-full divide-y"> <thead>
<thead> <tr>
<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">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-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">Points</th> <th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th> </tr>
</tr> </thead>
</thead> <tbody class="divide-night-400/10 divide-y">
<tbody class="divide-night-400/10 divide-y"> {user.karmaTransactions.map((transaction) => {
{user.karmaTransactions.map((transaction) => { const actionInfo = getKarmaTransactionActionInfo(transaction.action)
const actionInfo = getKarmaTransactionActionInfo(transaction.action) return (
return ( <tr class="hover:bg-night-500/5">
<tr class="hover:bg-night-500/5"> <td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap"> <span class="inline-flex items-center gap-1">
<span class="inline-flex items-center gap-1"> <Icon name={actionInfo.icon} class="size-4" />
<Icon name={actionInfo.icon} class="size-4" /> {actionInfo.label}
{actionInfo.label} {transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && ( <>
<> <span class="text-day-500">from</span>
<span class="text-day-500">from</span> <UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
<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'
)} )}
</span> >
</td> {transaction.points >= 0 && '+'}
<td class="text-day-300 px-4 py-3">{transaction.description}</td> {transaction.points}
<td </td>
class={cn( <td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
'px-4 py-3 text-right text-xs whitespace-nowrap', <TimeFormatted
transaction.points >= 0 ? 'text-green-400' : 'text-red-400' date={transaction.createdAt}
)} prefix={false}
> hourPrecision
{transaction.points >= 0 && '+'} caseType="sentence"
{transaction.points} />
</td> </td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap"> </tr>
<TimeFormatted )
date={transaction.createdAt} })}
prefix={false} </tbody>
hourPrecision </table>
caseType="sentence" </div>
/> )}
</td> </section>
</tr> )
) }
})}
</tbody>
</table>
</div>
)
}
</section>
</div> </div>
</BaseLayout> </BaseLayout>