Compare commits
4 Commits
release-84
...
release-89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b456af9448 | ||
|
|
b7ae6dc22c | ||
|
|
e4a5fa8fa7 | ||
|
|
6ed07c8386 |
@@ -95,6 +95,12 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
||||
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)
|
||||
|
||||
|
||||
@@ -358,6 +364,13 @@ def run_service_score_recalc_task(
|
||||
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.
|
||||
@@ -396,6 +409,12 @@ 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")
|
||||
|
||||
@@ -405,6 +424,12 @@ def run_worker_mode() -> int:
|
||||
"*/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:
|
||||
@@ -457,6 +482,8 @@ def main() -> int:
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
835
web/package-lock.json
generated
835
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ALTER COLUMN "operatingSince" SET DATA TYPE DATE;
|
||||
@@ -349,6 +349,8 @@ model Service {
|
||||
categories Category[] @relation("ServiceToCategory")
|
||||
kycLevel Int @default(4)
|
||||
kycLevelClarification KycLevelClarification @default(NONE)
|
||||
/// Date only, no time.
|
||||
operatingSince DateTime? @db.Date
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -95,6 +95,7 @@ DECLARE
|
||||
attributes_score INT := 0;
|
||||
recently_approved_factor INT := 0;
|
||||
tos_penalty_factor INT := 0;
|
||||
operating_since_factor INT := 0;
|
||||
BEGIN
|
||||
-- Get verification status factor
|
||||
SELECT
|
||||
@@ -148,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_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)
|
||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||
|
||||
@@ -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),
|
||||
@@ -150,6 +151,7 @@ export const adminServiceActions = {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
operatingSince: input.operatingSince,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -258,7 +260,6 @@ export const adminServiceActions = {
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
imageUrl,
|
||||
categories: {
|
||||
connect: categoriesToAdd.map((id) => ({ id })),
|
||||
@@ -275,6 +276,7 @@ export const adminServiceActions = {
|
||||
attributeId,
|
||||
})),
|
||||
},
|
||||
operatingSince: input.operatingSince,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ export const apiServiceActions = {
|
||||
kycLevelClarification: service.kycLevelClarification,
|
||||
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||
'value',
|
||||
'name',
|
||||
'label',
|
||||
'description',
|
||||
]),
|
||||
categories: service.categories,
|
||||
|
||||
@@ -165,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
|
||||
@@ -239,6 +240,7 @@ export const serviceSuggestionActions = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
operatingSince: input.operatingSince,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
|
||||
24
web/src/components/AdminNavigationFixScript.astro
Normal file
24
web/src/components/AdminNavigationFixScript.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
if (!Astro.locals.user?.admin) return
|
||||
---
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Script that adds data-astro-reload to all admin links or all links if on an admin page. //
|
||||
// This is a workaround to prevent the client router messing up inputs. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLAnchorElement | HTMLFormElement>('a,form').forEach((element) => {
|
||||
const isAdminPage = window.location.pathname.startsWith('/admin')
|
||||
if (isAdminPage) {
|
||||
element.setAttribute('data-astro-reload', '')
|
||||
}
|
||||
|
||||
const url = element.href ? new URL(element.href) : null
|
||||
if (url?.pathname.startsWith('/admin')) {
|
||||
element.setAttribute('data-astro-reload', '')
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -8,6 +8,7 @@ import { pwaInfo } from 'virtual:pwa-info'
|
||||
import { isNotArray } from '../lib/arrays'
|
||||
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'
|
||||
@@ -108,6 +109,7 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
<DynamicFavicon />
|
||||
|
||||
<ClientRouter />
|
||||
<AdminNavigationFixScript />
|
||||
|
||||
<LoadingIndicator color="green" />
|
||||
<TailwindJsPluggin />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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
|
||||
@@ -68,7 +68,7 @@ const { service } = Astro.props
|
||||
{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
|
||||
@@ -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">
|
||||
<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"
|
||||
href="/about#approved"
|
||||
class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { differenceInMonths, differenceInYears } from 'date-fns'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
@@ -45,6 +46,7 @@ type NonDbAttributeFull = NonDbAttribute & {
|
||||
acceptedCurrencies: true
|
||||
kycLevel: true
|
||||
kycLevelClarification: true
|
||||
operatingSince: true
|
||||
}
|
||||
}>
|
||||
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||
@@ -255,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<
|
||||
|
||||
@@ -45,7 +45,6 @@ function prismaClientSingleton() {
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prisma: ReturnType<typeof prismaClientSingleton> | undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,21 @@ 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.
|
||||
@@ -52,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
|
||||
|
||||
@@ -71,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
|
||||
|
||||
@@ -145,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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.
|
||||
@@ -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**.
|
||||
|
||||
### 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"
|
||||
@@ -218,11 +277,11 @@ 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
|
||||
## Downloads and Assets
|
||||
|
||||
For logos and brand assets, visit our [downloads page](/downloads).
|
||||
|
||||
|
||||
141
web/src/pages/account/delete.astro
Normal file
141
web/src/pages/account/delete.astro
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import Captcha from '../../components/Captcha.astro'
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import UserBadge from '../../components/UserBadge.astro'
|
||||
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
|
||||
const deleteResult = Astro.getActionResult(actions.account.delete)
|
||||
if (deleteResult && !deleteResult.error) {
|
||||
Astro.locals.banners.addIfSuccess(deleteResult, 'Account deleted successfully')
|
||||
return Astro.redirect('/')
|
||||
}
|
||||
|
||||
const userId = Astro.locals.user?.id
|
||||
if (!userId) {
|
||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to delete your account' }))
|
||||
}
|
||||
|
||||
const user = await Astro.locals.banners.try('Failed to load user profile', () =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
admin: true,
|
||||
moderator: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
picture: true,
|
||||
_count: {
|
||||
select: {
|
||||
comments: true,
|
||||
commentVotes: true,
|
||||
suggestions: true,
|
||||
suggestionMessages: true,
|
||||
verificationRequests: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
if (!user) return Astro.rewrite('/404')
|
||||
---
|
||||
|
||||
<MiniLayout
|
||||
pageTitle="Delete account"
|
||||
description="Delete your account"
|
||||
ogImage={{
|
||||
template: 'generic',
|
||||
title: 'Delete account',
|
||||
description: 'Delete your account',
|
||||
icon: 'ri:delete-bin-line',
|
||||
}}
|
||||
layoutHeader={{
|
||||
icon: 'ri:delete-bin-line',
|
||||
title: 'Delete account',
|
||||
subtitle: 'Are you sure? This is irreversible.',
|
||||
}}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: 'Accounts',
|
||||
url: '/account',
|
||||
},
|
||||
{
|
||||
name: 'Delete account',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<h2 class="font-title text-day-200 mb-2 text-lg font-semibold">What will be deleted:</h2>
|
||||
|
||||
<a
|
||||
href="/account"
|
||||
class="group relative mb-2 flex flex-col items-center rounded-xl border border-red-500/10 bg-red-500/10 p-4"
|
||||
>
|
||||
<UserBadge user={user} size="lg" noLink classNames={{ text: 'group-hover:underline' }} />
|
||||
<Icon
|
||||
name="ri:forbid-line"
|
||||
class="2xs:block absolute top-1/2 left-3 hidden size-8 -translate-y-1/2 text-red-300/10"
|
||||
/>
|
||||
<Icon
|
||||
name="ri:forbid-2-line"
|
||||
class="2xs:block absolute top-1/2 right-3 hidden size-8 -translate-y-1/2 text-red-300/10"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<ul class="2xs:grid-cols-2 mb-8 grid grid-cols-1 gap-x-1 gap-y-0">
|
||||
<li>
|
||||
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold">1</span>
|
||||
<span class="text-day-400 text-sm">User</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||
>{user._count.comments.toLocaleString()}</span
|
||||
>
|
||||
<span class="text-day-400 text-sm">Comments <span class="text-xs">(+ their replies)</span></span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||
>{user._count.commentVotes.toLocaleString()}</span
|
||||
>
|
||||
<span class="text-day-400 text-sm">Comment Votes</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||
>{user._count.suggestions.toLocaleString()}</span
|
||||
>
|
||||
<span class="text-day-400 text-sm">Service Suggestions</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||
>{user._count.suggestionMessages.toLocaleString()}</span
|
||||
>
|
||||
<span class="text-day-400 text-sm">Suggestion Messages</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||
>{user._count.verificationRequests.toLocaleString()}</span
|
||||
>
|
||||
<span class="text-day-400 text-sm">Verification Requests</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{
|
||||
user.admin || user.moderator ? (
|
||||
<p class="text-center text-balance text-red-300">Admins and moderators cannot be deleted.</p>
|
||||
) : (
|
||||
<form method="POST" action={actions.account.delete}>
|
||||
<Captcha action={actions.account.delete} class="mb-6" />
|
||||
<InputSubmitButton label="Delete account" color="danger" icon="ri:delete-bin-line" />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
</MiniLayout>
|
||||
@@ -962,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -461,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>
|
||||
|
||||
@@ -62,7 +62,7 @@ type ServiceResponse = {
|
||||
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
||||
kycLevelClarificationInfo: {
|
||||
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
categories: {
|
||||
@@ -135,8 +135,10 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"name": "My Example Service",
|
||||
"description": "This is a description of my example service",
|
||||
"slug": "my-example-service",
|
||||
"serviceVisibility": "PUBLIC",
|
||||
"verificationStatus": "VERIFICATION_SUCCESS",
|
||||
"verificationStatusInfo": {
|
||||
@@ -157,6 +159,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||
"kycLevelClarification": "NONE",
|
||||
"kycLevelClarificationInfo": {
|
||||
"value": "NONE",
|
||||
"label": "None",
|
||||
"description": "No clarification needed."
|
||||
},
|
||||
"categories": [
|
||||
|
||||
@@ -15,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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -95,6 +95,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
approvedAt: true,
|
||||
createdAt: true,
|
||||
acceptedCurrencies: true,
|
||||
operatingSince: true,
|
||||
tosReview: true,
|
||||
tosReviewAt: true,
|
||||
userSentiment: true,
|
||||
@@ -901,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"
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
@@ -575,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}
|
||||
@@ -765,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">
|
||||
@@ -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">
|
||||
<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 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>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
<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.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" />
|
||||
</>
|
||||
{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'
|
||||
)}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user