Compare commits
23 Commits
release-59
...
release-83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6edee2dbe | ||
|
|
c7ee1606e4 | ||
|
|
f3c9b92ddb | ||
|
|
effb6689d7 | ||
|
|
cf5f3b3228 | ||
|
|
5a41816ac8 | ||
|
|
bf30a6cb2b | ||
|
|
4ca9b9a5c2 | ||
|
|
03abdef4f1 | ||
|
|
d9880fd83d | ||
|
|
39afcad089 | ||
|
|
99cb730bc0 | ||
|
|
d43402e162 | ||
|
|
9bb316b85f | ||
|
|
4aea68ee58 | ||
|
|
2f88c43236 | ||
|
|
ad3c561419 | ||
|
|
812937d2c7 | ||
|
|
459d7c91f7 | ||
|
|
b8b2dee4a4 | ||
|
|
eb0af871e1 | ||
|
|
3ccd7fd395 | ||
|
|
87f0f36aa1 |
@@ -10,7 +10,11 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
|
test:
|
||||||
|
[
|
||||||
|
'CMD-SHELL',
|
||||||
|
'pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}',
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -26,7 +30,7 @@ services:
|
|||||||
crawl4ai:
|
crawl4ai:
|
||||||
image: unclecode/crawl4ai:basic-amd64
|
image: unclecode/crawl4ai:basic-amd64
|
||||||
expose:
|
expose:
|
||||||
- "11235"
|
- '11235'
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
@@ -42,7 +46,7 @@ services:
|
|||||||
image: redis:latest
|
image: redis:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -62,7 +66,15 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- 4321
|
- 4321
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321/health"]
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'curl',
|
||||||
|
'-k',
|
||||||
|
'--silent',
|
||||||
|
'--fail',
|
||||||
|
'http://localhost:4321/internal-api/healthcheck',
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ Tasks will run according to their configured cron schedules.
|
|||||||
### Force Triggers Task
|
### Force Triggers Task
|
||||||
|
|
||||||
- Maintains database triggers by forcing them to run under certain conditions
|
- Maintains database triggers by forcing them to run under certain conditions
|
||||||
- Currently handles updating the "isRecentlyListed" flag for services after 15 days
|
- Currently handles updating the "isRecentlyApproved" flag for services after 15 days
|
||||||
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
|
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
|
||||||
|
|
||||||
### Service Score Recalculation Task
|
### Service Score Recalculation Task
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
|||||||
score_recalc_parser.add_argument(
|
score_recalc_parser.add_argument(
|
||||||
"--service-id", type=int, help="Specific service ID to process (optional)"
|
"--service-id", type=int, help="Specific service ID to process (optional)"
|
||||||
)
|
)
|
||||||
|
score_recalc_parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Recalculate scores for all services (ignores --service-id)",
|
||||||
|
)
|
||||||
|
|
||||||
return parser.parse_args(args)
|
return parser.parse_args(args)
|
||||||
|
|
||||||
@@ -295,12 +300,15 @@ def run_force_triggers_task() -> int:
|
|||||||
close_db_pool()
|
close_db_pool()
|
||||||
|
|
||||||
|
|
||||||
def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
def run_service_score_recalc_task(
|
||||||
|
service_id: Optional[int] = None, all_services: bool = False
|
||||||
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Run the service score recalculation task.
|
Run the service score recalculation task.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service_id: Optional specific service ID to process.
|
service_id: Optional specific service ID to process.
|
||||||
|
all_services: Whether to recalculate scores for all services.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Exit code.
|
Exit code.
|
||||||
@@ -310,7 +318,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
|||||||
try:
|
try:
|
||||||
# Initialize task and use as context manager
|
# Initialize task and use as context manager
|
||||||
with ServiceScoreRecalculationTask() as task: # type: ignore
|
with ServiceScoreRecalculationTask() as task: # type: ignore
|
||||||
result = task.run(service_id) # type: ignore
|
if all_services:
|
||||||
|
queued = task.recalculate_all_services() # type: ignore
|
||||||
|
if not queued:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to queue recalculation jobs for all services"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Continuously process queued jobs in batches until none remain
|
||||||
|
while True:
|
||||||
|
_ = task.run() # type: ignore
|
||||||
|
|
||||||
|
# Check if there are still unprocessed jobs
|
||||||
|
remaining = 0
|
||||||
|
if task.conn:
|
||||||
|
with task.conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT COUNT(*) FROM "ServiceScoreRecalculationJob" WHERE "processedAt" IS NULL'
|
||||||
|
)
|
||||||
|
remaining = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if remaining == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
result = True # All jobs processed successfully
|
||||||
|
|
||||||
|
else:
|
||||||
|
result = task.run(service_id) # type: ignore
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info("Successfully recalculated service scores")
|
logger.info("Successfully recalculated service scores")
|
||||||
else:
|
else:
|
||||||
@@ -366,7 +401,7 @@ def run_worker_mode() -> int:
|
|||||||
|
|
||||||
# Register service score recalculation task (every 5 minutes)
|
# Register service score recalculation task (every 5 minutes)
|
||||||
scheduler.register_task(
|
scheduler.register_task(
|
||||||
"service-score-recalc",
|
"service_score_recalc",
|
||||||
"*/5 * * * *",
|
"*/5 * * * *",
|
||||||
run_service_score_recalc_task,
|
run_service_score_recalc_task,
|
||||||
)
|
)
|
||||||
@@ -419,7 +454,9 @@ def main() -> int:
|
|||||||
elif args.task == "force-triggers":
|
elif args.task == "force-triggers":
|
||||||
return run_force_triggers_task()
|
return run_force_triggers_task()
|
||||||
elif args.task == "service-score-recalc":
|
elif args.task == "service-score-recalc":
|
||||||
return run_service_score_recalc_task(args.service_id)
|
return run_service_score_recalc_task(
|
||||||
|
args.service_id, getattr(args, "all", False)
|
||||||
|
)
|
||||||
elif args.task:
|
elif args.task:
|
||||||
logger.error(f"Unknown task: {args.task}")
|
logger.error(f"Unknown task: {args.task}")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -332,29 +332,33 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
|
|||||||
return remove_service_attribute(service_id, attribute_id)
|
return remove_service_attribute(service_id, attribute_id)
|
||||||
|
|
||||||
|
|
||||||
def save_tos_review(service_id: int, review: TosReviewType):
|
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
|
||||||
"""
|
"""Persist a TOS review and/or update the timestamp for a service.
|
||||||
Save a TOS review for a specific service.
|
|
||||||
|
|
||||||
Args:
|
If *review* is ``None`` the existing review (if any) is preserved while
|
||||||
service_id: The ID of the service.
|
only the ``tosReviewAt`` column is updated. This ensures we still track
|
||||||
review: A TypedDict containing the review data.
|
when the review task last ran even if the review generation failed or
|
||||||
|
produced no changes.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Serialize the dictionary to a JSON string for the database
|
|
||||||
review_json = json.dumps(review)
|
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
with conn.cursor(row_factory=dict_row) as cursor:
|
with conn.cursor(row_factory=dict_row) as cursor:
|
||||||
cursor.execute(
|
if review is None:
|
||||||
"""
|
cursor.execute(
|
||||||
UPDATE "Service"
|
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
|
||||||
SET "tosReview" = %s, "tosReviewAt" = NOW()
|
(service_id,),
|
||||||
WHERE id = %s
|
)
|
||||||
""",
|
else:
|
||||||
(review_json, service_id),
|
review_json = json.dumps(review)
|
||||||
)
|
cursor.execute(
|
||||||
|
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
|
||||||
|
(review_json, service_id),
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info(f"Successfully saved TOS review for service {service_id}")
|
logger.info(
|
||||||
|
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving TOS review for service {service_id}: {e}")
|
logger.error(f"Error saving TOS review for service {service_id}: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
|
|||||||
Force triggers to run under certain conditions.
|
Force triggers to run under certain conditions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RECENT_LISTED_INTERVAL_DAYS = 15
|
RECENT_APPROVED_INTERVAL_DAYS = 15
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("force_triggers")
|
super().__init__("force_triggers")
|
||||||
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
|
|||||||
|
|
||||||
update_query = f"""
|
update_query = f"""
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW()
|
SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
|
||||||
WHERE "isRecentlyListed" = TRUE
|
WHERE "isRecentlyApproved" = TRUE
|
||||||
AND "listedAt" IS NOT NULL
|
AND "approvedAt" IS NOT NULL
|
||||||
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days'
|
AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with self.conn.cursor() as cursor:
|
with self.conn.cursor() as cursor:
|
||||||
|
|||||||
@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE "isActive" = TRUE
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
services = cursor.fetchall()
|
services = cursor.fetchall()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Task for retrieving Terms of Service (TOS) text.
|
|||||||
import hashlib
|
import hashlib
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
|
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
|
||||||
from pyworker.tasks.base import Task
|
from pyworker.tasks.base import Task
|
||||||
from pyworker.utils.ai import prompt_check_tos_review, prompt_tos_review
|
from pyworker.utils.ai import prompt_check_tos_review, prompt_tos_review
|
||||||
@@ -32,8 +34,8 @@ class TosReviewTask(Task):
|
|||||||
service_name = service["name"]
|
service_name = service["name"]
|
||||||
verification_status = service.get("verificationStatus")
|
verification_status = service.get("verificationStatus")
|
||||||
|
|
||||||
# Only process verified or approved services
|
# Only process verified, approved, or community contributed services
|
||||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]:
|
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
||||||
)
|
)
|
||||||
@@ -52,65 +54,97 @@ class TosReviewTask(Task):
|
|||||||
)
|
)
|
||||||
self.logger.info(f"TOS URLs: {tos_urls}")
|
self.logger.info(f"TOS URLs: {tos_urls}")
|
||||||
|
|
||||||
|
review = self.get_tos_review(tos_urls, service.get("tosReview"))
|
||||||
|
|
||||||
|
# Always update the processed timestamp, even if review is None
|
||||||
|
save_tos_review(service_id, review)
|
||||||
|
|
||||||
|
if review is None:
|
||||||
|
self.logger.warning(
|
||||||
|
f"TOS review could not be generated for service {service_name} (ID: {service_id})"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update the KYC level based on the review, when present
|
||||||
|
if "kycLevel" in review:
|
||||||
|
new_level = review["kycLevel"]
|
||||||
|
old_level = service.get("kycLevel")
|
||||||
|
|
||||||
|
# Update DB
|
||||||
|
if update_kyc_level(service_id, new_level):
|
||||||
|
msg = f"{service.get('slug', service_name)}: kycLevel {old_level} -> {new_level}"
|
||||||
|
|
||||||
|
# Log to console
|
||||||
|
self.logger.info(msg)
|
||||||
|
|
||||||
|
# Send notification via ntfy
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
"https://ntfy.sh/knm-kyc-lvl-changes-knm", data=msg.encode()
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to send ntfy notification for KYC level change: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return review
|
||||||
|
|
||||||
|
def get_tos_review(
|
||||||
|
self, tos_urls: list[str], current_review: Optional[TosReviewType]
|
||||||
|
) -> Optional[TosReviewType]:
|
||||||
|
"""
|
||||||
|
Get TOS review from a list of URLs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tos_urls: List of TOS URLs to check
|
||||||
|
current_review: Current review data from the database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing:
|
||||||
|
- status: Literal["skipped", "failed", "success"]
|
||||||
|
- review: Optional[TosReviewType] - The review data if successful
|
||||||
|
"""
|
||||||
|
all_skipped = True
|
||||||
|
|
||||||
for tos_url in tos_urls:
|
for tos_url in tos_urls:
|
||||||
api_url = f"{tos_url}"
|
api_url = f"{tos_url}"
|
||||||
self.logger.info(f"Fetching TOS from URL: {api_url}")
|
self.logger.info(f"Fetching TOS from URL: {api_url}")
|
||||||
|
|
||||||
# Sleep for 1 second to avoid rate limiting
|
|
||||||
content = fetch_markdown(api_url)
|
content = fetch_markdown(api_url)
|
||||||
|
|
||||||
if content:
|
if not content:
|
||||||
# Hash the content to avoid repeating the same content
|
|
||||||
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
|
||||||
self.logger.info(f"Content hash: {content_hash}")
|
|
||||||
|
|
||||||
# service.get("tosReview") can be None if the DB field is NULL.
|
|
||||||
# Default to an empty dict to prevent AttributeError on .get()
|
|
||||||
tos_review_data_from_service: Optional[Dict[str, Any]] = service.get(
|
|
||||||
"tosReview"
|
|
||||||
)
|
|
||||||
tos_review: Dict[str, Any] = (
|
|
||||||
tos_review_data_from_service
|
|
||||||
if tos_review_data_from_service is not None
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
|
|
||||||
stored_hash = tos_review.get("contentHash")
|
|
||||||
|
|
||||||
# Skip processing if we've seen this content before
|
|
||||||
if stored_hash == content_hash:
|
|
||||||
self.logger.info(
|
|
||||||
f"Skipping already processed TOS content with hash: {content_hash}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip incomplete TOS content
|
|
||||||
check = prompt_check_tos_review(content)
|
|
||||||
if not check:
|
|
||||||
continue
|
|
||||||
elif not check["isComplete"]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Query OpenAI to summarize the content
|
|
||||||
review = prompt_tos_review(content)
|
|
||||||
|
|
||||||
if review:
|
|
||||||
review["contentHash"] = content_hash
|
|
||||||
# Save the review to the database
|
|
||||||
save_tos_review(service_id, review)
|
|
||||||
|
|
||||||
# Update the KYC level based on the review
|
|
||||||
if "kycLevel" in review:
|
|
||||||
kyc_level = review["kycLevel"]
|
|
||||||
self.logger.info(
|
|
||||||
f"Updating KYC level to {kyc_level} for service {service_name}"
|
|
||||||
)
|
|
||||||
update_kyc_level(service_id, kyc_level)
|
|
||||||
# no need to check other TOS URLs
|
|
||||||
break
|
|
||||||
|
|
||||||
return review
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"Failed to retrieve TOS content for URL: {tos_url}"
|
f"Failed to retrieve TOS content for URL: {tos_url}"
|
||||||
)
|
)
|
||||||
|
all_skipped = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Hash the content to avoid repeating the same content
|
||||||
|
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
self.logger.info(f"Content hash: {content_hash}")
|
||||||
|
|
||||||
|
# Skip processing if we've seen this content before
|
||||||
|
if current_review and current_review.get("contentHash") == content_hash:
|
||||||
|
self.logger.info(
|
||||||
|
f"Skipping already processed TOS content with hash: {content_hash}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_skipped = False
|
||||||
|
|
||||||
|
# Skip incomplete TOS content
|
||||||
|
check = prompt_check_tos_review(content)
|
||||||
|
if not check or not check["isComplete"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Query OpenAI to summarize the content
|
||||||
|
review = prompt_tos_review(content)
|
||||||
|
|
||||||
|
if review:
|
||||||
|
review["contentHash"] = content_hash
|
||||||
|
return review
|
||||||
|
|
||||||
|
if all_skipped:
|
||||||
|
return current_review
|
||||||
|
|
||||||
|
return None
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
|
|||||||
{"role": "user", "content": content},
|
{"role": "user", "content": content},
|
||||||
]
|
]
|
||||||
|
|
||||||
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
|
result_dict = query_openai_json(
|
||||||
|
messages, model="openai/gemini-2.5-flash-preview-05-20"
|
||||||
|
)
|
||||||
|
|
||||||
return cast(TosReviewCheck, result_dict)
|
return cast(TosReviewCheck, result_dict)
|
||||||
|
|
||||||
@@ -173,12 +175,12 @@ type TosReview = {
|
|||||||
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
|
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
|
||||||
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
|
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
|
||||||
rating: 'negative' | 'neutral' | 'positive'
|
rating: 'negative' | 'neutral' | 'positive'
|
||||||
}[]
|
}[] // max 8 highlights, try to provide at least 3.
|
||||||
}
|
}
|
||||||
|
|
||||||
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
|
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
|
||||||
|
|
||||||
Be concise but thorough, and make sure your output is properly formatted JSON.
|
Focus on the most important information for the user. Be concise and thorough, and make sure your output is properly formatted JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ ARG ASTRO_BUILD_MODE=production
|
|||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
|
RUN npm run build:astro -- --mode ${ASTRO_BUILD_MODE} && npm run build:server-init
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=4321
|
ENV PORT=4321
|
||||||
@@ -26,4 +26,4 @@ EXPOSE 4321
|
|||||||
COPY web/migrate.sh /usr/local/bin/knm-migrate
|
COPY web/migrate.sh /usr/local/bin/knm-migrate
|
||||||
RUN chmod +x /usr/local/bin/knm-migrate
|
RUN chmod +x /usr/local/bin/knm-migrate
|
||||||
|
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
CMD ["sh", "-c", "node ./dist/server/server-init.js & node ./dist/server/entry.mjs"]
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import mdx from '@astrojs/mdx'
|
|||||||
import node from '@astrojs/node'
|
import node from '@astrojs/node'
|
||||||
import sitemap from '@astrojs/sitemap'
|
import sitemap from '@astrojs/sitemap'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { minimal2023Preset } from '@vite-pwa/assets-generator/config'
|
||||||
|
import AstroPWA from '@vite-pwa/astro'
|
||||||
import { defineConfig, envField } from 'astro/config'
|
import { defineConfig, envField } from 'astro/config'
|
||||||
import icon from 'astro-icon'
|
import icon from 'astro-icon'
|
||||||
|
import devtoolsJson from 'vite-plugin-devtools-json'
|
||||||
|
|
||||||
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||||
@@ -16,15 +19,65 @@ export default defineConfig({
|
|||||||
site: SITE_URL,
|
site: SITE_URL,
|
||||||
vite: {
|
vite: {
|
||||||
build: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true, // Enable sourcemaps on production, so users can inspect the code
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [tailwindcss()],
|
plugins: [devtoolsJson(), tailwindcss()],
|
||||||
},
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
postgresListener(),
|
postgresListener(),
|
||||||
icon(),
|
icon(),
|
||||||
mdx(),
|
mdx(),
|
||||||
|
AstroPWA({
|
||||||
|
mode: 'development',
|
||||||
|
base: '/',
|
||||||
|
scope: '/',
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'KYCnot.me',
|
||||||
|
short_name: 'KYCnot.me',
|
||||||
|
description: 'Find services that respect your privacy',
|
||||||
|
theme_color: '#040505',
|
||||||
|
background_color: '#171c1b',
|
||||||
|
display: 'minimal-ui',
|
||||||
|
},
|
||||||
|
pwaAssets: {
|
||||||
|
image: './public/favicon.svg',
|
||||||
|
preset: {
|
||||||
|
...minimal2023Preset,
|
||||||
|
maskable: {
|
||||||
|
...minimal2023Preset.maskable,
|
||||||
|
padding: 0.1,
|
||||||
|
resizeOptions: {
|
||||||
|
...minimal2023Preset.maskable.resizeOptions,
|
||||||
|
background: '#3bdb78',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apple: {
|
||||||
|
...minimal2023Preset.apple,
|
||||||
|
padding: 0.1,
|
||||||
|
resizeOptions: {
|
||||||
|
...minimal2023Preset.apple.resizeOptions,
|
||||||
|
background: '#3bdb78',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/404',
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,jpg,jpeg,png,svg,webp,avif}'],
|
||||||
|
},
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.ts',
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'module',
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
directoryAndTrailingSlashHandler: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
sitemap({
|
sitemap({
|
||||||
filter: (page) => {
|
filter: (page) => {
|
||||||
const url = new URL(page)
|
const url = new URL(page)
|
||||||
@@ -57,6 +110,8 @@ export default defineConfig({
|
|||||||
'/attribute/[...slug]': '/attributes',
|
'/attribute/[...slug]': '/attributes',
|
||||||
'/attr/[...slug]': '/attributes',
|
'/attr/[...slug]': '/attributes',
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
'/service/[...slug]/review': '/service/[...slug]#comments',
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
schema: {
|
schema: {
|
||||||
@@ -70,7 +125,7 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
// Public URLs (can be accessed from both server and client)
|
// Public URLs (can be accessed from both server and client)
|
||||||
SOURCE_CODE_URL: envField.string({
|
SOURCE_CODE_URL: envField.string({
|
||||||
context: 'server',
|
context: 'client',
|
||||||
access: 'public',
|
access: 'public',
|
||||||
url: true,
|
url: true,
|
||||||
optional: false,
|
optional: false,
|
||||||
@@ -95,35 +150,6 @@ export default defineConfig({
|
|||||||
startsWith: 'redis://',
|
startsWith: 'redis://',
|
||||||
default: 'redis://redis:6379',
|
default: 'redis://redis:6379',
|
||||||
}),
|
}),
|
||||||
REDIS_USER_SESSION_EXPIRY_SECONDS: envField.number({
|
|
||||||
context: 'server',
|
|
||||||
access: 'secret',
|
|
||||||
int: true,
|
|
||||||
gt: 0,
|
|
||||||
default: 60 * 60 * 24, // 24 hours in seconds
|
|
||||||
}),
|
|
||||||
REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS: envField.number({
|
|
||||||
context: 'server',
|
|
||||||
access: 'secret',
|
|
||||||
int: true,
|
|
||||||
gt: 0,
|
|
||||||
default: 60 * 60 * 24, // 24 hours in seconds
|
|
||||||
}),
|
|
||||||
REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS: envField.number({
|
|
||||||
context: 'server',
|
|
||||||
access: 'secret',
|
|
||||||
int: true,
|
|
||||||
gt: 0,
|
|
||||||
default: 60 * 5, // 5 minutes in seconds
|
|
||||||
}),
|
|
||||||
|
|
||||||
REDIS_ACTIONS_SESSION_EXPIRY_SECONDS: envField.number({
|
|
||||||
context: 'server',
|
|
||||||
access: 'secret',
|
|
||||||
int: true,
|
|
||||||
gt: 0,
|
|
||||||
default: 60 * 5, // 5 minutes in seconds
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Development tokens
|
// Development tokens
|
||||||
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({
|
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default tseslint.config(
|
|||||||
'import/first': 'error',
|
'import/first': 'error',
|
||||||
'import/newline-after-import': 'error',
|
'import/newline-after-import': 'error',
|
||||||
'import/no-duplicates': 'error',
|
'import/no-duplicates': 'error',
|
||||||
'import/no-unresolved': ['error', { ignore: ['^astro:'] }],
|
'import/no-unresolved': ['error', { ignore: ['^astro:', '^virtual:'] }],
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
|
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
|
||||||
'import/namespace': 'off',
|
'import/namespace': 'off',
|
||||||
|
|||||||
3969
web/package-lock.json
generated
@@ -4,8 +4,10 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build --remote",
|
"build": "npm run build:astro && npm run build:server-init",
|
||||||
"preview": "astro preview",
|
"build:astro": "astro build --remote",
|
||||||
|
"build:server-init": "esbuild src/server-init.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server/server-init.js",
|
||||||
|
"preview": "node dist/server/server-init.js & astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"db-admin": "prisma studio --browser=none",
|
"db-admin": "prisma studio --browser=none",
|
||||||
"db-gen": "prisma generate",
|
"db-gen": "prisma generate",
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
"@astrojs/db": "0.15.0",
|
"@astrojs/db": "0.15.0",
|
||||||
"@astrojs/mdx": "4.3.0",
|
"@astrojs/mdx": "4.3.0",
|
||||||
"@astrojs/node": "9.2.2",
|
"@astrojs/node": "9.2.2",
|
||||||
|
"@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.5",
|
||||||
@@ -79,8 +82,11 @@
|
|||||||
"@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.33.1",
|
||||||
|
"@vite-pwa/assets-generator": "1.0.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",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-import-resolver-typescript": "4.4.3",
|
"eslint-import-resolver-typescript": "4.4.3",
|
||||||
"eslint-plugin-astro": "1.3.1",
|
"eslint-plugin-astro": "1.3.1",
|
||||||
@@ -96,6 +102,9 @@
|
|||||||
"ts-essentials": "10.0.4",
|
"ts-essentials": "10.0.4",
|
||||||
"ts-toolbelt": "9.6.0",
|
"ts-toolbelt": "9.6.0",
|
||||||
"tsx": "4.19.4",
|
"tsx": "4.19.4",
|
||||||
"typescript-eslint": "8.33.1"
|
"typescript-eslint": "8.33.1",
|
||||||
|
"vite-plugin-devtools-json": "0.1.1",
|
||||||
|
"workbox-core": "7.3.0",
|
||||||
|
"workbox-precaching": "7.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[feedId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "feedId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_feedId_key" ON "User"("feedId");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `feedId` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "feedId" SET NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "spamAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `isRecentlyListed` on the `Service` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" DROP COLUMN "isRecentlyListed",
|
||||||
|
ADD COLUMN "isRecentlyApproved" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_approvedAt_idx" ON "Service"("approvedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_verifiedAt_idx" ON "Service"("verifiedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_spamAt_idx" ON "Service"("spamAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_serviceVisibility_idx" ON "Service"("serviceVisibility");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";
|
||||||
@@ -135,6 +135,7 @@ enum NotificationType {
|
|||||||
COMMUNITY_NOTE_ADDED
|
COMMUNITY_NOTE_ADDED
|
||||||
/// Comment that is not a reply. May include a rating.
|
/// Comment that is not a reply. May include a rating.
|
||||||
ROOT_COMMENT_CREATED
|
ROOT_COMMENT_CREATED
|
||||||
|
SUGGESTION_CREATED
|
||||||
SUGGESTION_MESSAGE
|
SUGGESTION_MESSAGE
|
||||||
SUGGESTION_STATUS_CHANGE
|
SUGGESTION_STATUS_CHANGE
|
||||||
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||||
@@ -352,8 +353,6 @@ model Service {
|
|||||||
privacyScore Int @default(0)
|
privacyScore Int @default(0)
|
||||||
trustScore Int @default(0)
|
trustScore Int @default(0)
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
isRecentlyListed Boolean @default(false)
|
|
||||||
/// Computed via trigger. Do not update through prisma.
|
|
||||||
averageUserRating Float?
|
averageUserRating Float?
|
||||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||||
@@ -362,8 +361,6 @@ model Service {
|
|||||||
verificationSummary String?
|
verificationSummary String?
|
||||||
verificationRequests ServiceVerificationRequest[]
|
verificationRequests ServiceVerificationRequest[]
|
||||||
verificationProofMd String?
|
verificationProofMd String?
|
||||||
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
|
|
||||||
verifiedAt DateTime?
|
|
||||||
/// [UserSentiment]
|
/// [UserSentiment]
|
||||||
userSentiment Json?
|
userSentiment Json?
|
||||||
userSentimentAt DateTime?
|
userSentimentAt DateTime?
|
||||||
@@ -379,7 +376,16 @@ model Service {
|
|||||||
tosReviewAt DateTime?
|
tosReviewAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
|
||||||
listedAt DateTime?
|
listedAt DateTime?
|
||||||
|
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
|
||||||
|
approvedAt DateTime?
|
||||||
|
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||||
|
verifiedAt DateTime?
|
||||||
|
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
|
||||||
|
spamAt DateTime?
|
||||||
|
/// Computed via trigger. Do not update through prisma.
|
||||||
|
isRecentlyApproved Boolean @default(false)
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
events Event[]
|
events Event[]
|
||||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||||
@@ -395,6 +401,9 @@ model Service {
|
|||||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||||
|
|
||||||
@@index([listedAt])
|
@@index([listedAt])
|
||||||
|
@@index([approvedAt])
|
||||||
|
@@index([verifiedAt])
|
||||||
|
@@index([spamAt])
|
||||||
@@index([overallScore])
|
@@index([overallScore])
|
||||||
@@index([privacyScore])
|
@@index([privacyScore])
|
||||||
@@index([trustScore])
|
@@index([trustScore])
|
||||||
@@ -406,6 +415,7 @@ model Service {
|
|||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([previousSlugs])
|
@@index([previousSlugs])
|
||||||
|
@@index([serviceVisibility])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ServiceContactMethod {
|
model ServiceContactMethod {
|
||||||
@@ -497,6 +507,7 @@ model User {
|
|||||||
moderator Boolean @default(false)
|
moderator Boolean @default(false)
|
||||||
verifiedLink String?
|
verifiedLink String?
|
||||||
secretTokenHash String @unique
|
secretTokenHash String @unique
|
||||||
|
feedId String @unique @default(cuid(2))
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
totalKarma Int @default(0)
|
totalKarma Int @default(0)
|
||||||
|
|
||||||
@@ -576,6 +587,7 @@ enum VerificationStepStatus {
|
|||||||
IN_PROGRESS
|
IN_PROGRESS
|
||||||
PASSED
|
PASSED
|
||||||
FAILED
|
FAILED
|
||||||
|
WARNING
|
||||||
}
|
}
|
||||||
|
|
||||||
model VerificationStep {
|
model VerificationStep {
|
||||||
@@ -675,8 +687,6 @@ model PushSubscription {
|
|||||||
p256dh String
|
p256dh String
|
||||||
/// Authentication secret
|
/// Authentication secret
|
||||||
auth String
|
auth String
|
||||||
/// To identify different devices
|
|
||||||
userAgent String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ import {
|
|||||||
PrismaClient,
|
PrismaClient,
|
||||||
ServiceSuggestionStatus,
|
ServiceSuggestionStatus,
|
||||||
ServiceUserRole,
|
ServiceUserRole,
|
||||||
VerificationStatus,
|
|
||||||
type Prisma,
|
type Prisma,
|
||||||
type User,
|
type User,
|
||||||
type ServiceVisibility,
|
type ServiceVisibility,
|
||||||
ServiceSuggestionType,
|
ServiceSuggestionType,
|
||||||
KycLevelClarification,
|
KycLevelClarification,
|
||||||
|
VerificationStepStatus,
|
||||||
|
type VerificationStatus,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
|
import { differenceInDays, isPast } from 'date-fns'
|
||||||
import { omit, uniqBy } from 'lodash-es'
|
import { omit, uniqBy } from 'lodash-es'
|
||||||
import { generateUsername } from 'unique-username-generator'
|
import { generateUsername } from 'unique-username-generator'
|
||||||
|
|
||||||
@@ -610,6 +612,18 @@ const generateFakeService = (users: User[]) => {
|
|||||||
const name = faker.helpers.arrayElement(serviceNames)
|
const name = faker.helpers.arrayElement(serviceNames)
|
||||||
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
||||||
|
|
||||||
|
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
|
||||||
|
probability: 0.8,
|
||||||
|
})
|
||||||
|
const serviceVisibility = faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||||
|
{ weight: 80, value: 'PUBLIC' },
|
||||||
|
{ weight: 10, value: 'UNLISTED' },
|
||||||
|
{ weight: 5, value: 'HIDDEN' },
|
||||||
|
{ weight: 5, value: 'ARCHIVED' },
|
||||||
|
])
|
||||||
|
const approvedAt =
|
||||||
|
status === 'APPROVED' || status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
@@ -624,12 +638,7 @@ const generateFakeService = (users: User[]) => {
|
|||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
serviceVisibility,
|
||||||
{ weight: 80, value: 'PUBLIC' },
|
|
||||||
{ weight: 10, value: 'UNLISTED' },
|
|
||||||
{ weight: 5, value: 'HIDDEN' },
|
|
||||||
{ weight: 5, value: 'ARCHIVED' },
|
|
||||||
]),
|
|
||||||
verificationStatus: status,
|
verificationStatus: status,
|
||||||
verificationSummary:
|
verificationSummary:
|
||||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||||
@@ -643,6 +652,19 @@ const generateFakeService = (users: User[]) => {
|
|||||||
},
|
},
|
||||||
verificationProofMd:
|
verificationProofMd:
|
||||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
||||||
|
verificationSteps:
|
||||||
|
(status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED') && faker.datatype.boolean(0.75)
|
||||||
|
? {
|
||||||
|
create: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
|
||||||
|
title: faker.lorem.sentence(),
|
||||||
|
description: faker.lorem.paragraph(),
|
||||||
|
status: faker.helpers.arrayElement(Object.values(VerificationStepStatus)),
|
||||||
|
evidenceMd: faker.lorem.paragraph(),
|
||||||
|
createdAt: faker.date.recent(),
|
||||||
|
updatedAt: faker.date.recent(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
referral: faker.helpers.arrayElement([
|
referral: faker.helpers.arrayElement([
|
||||||
`?ref=${faker.string.alphanumeric(6)}`,
|
`?ref=${faker.string.alphanumeric(6)}`,
|
||||||
`/ref/${faker.string.alphanumeric(6)}`,
|
`/ref/${faker.string.alphanumeric(6)}`,
|
||||||
@@ -659,10 +681,18 @@ const generateFakeService = (users: User[]) => {
|
|||||||
{ count: { min: 0, max: 2 } }
|
{ count: { min: 0, max: 2 } }
|
||||||
),
|
),
|
||||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||||
listedAt: faker.date.past(),
|
listedAt:
|
||||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
|
||||||
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
? faker.date.recent({ days: 30 })
|
||||||
tosReviewAt: faker.date.past(),
|
: null,
|
||||||
|
verifiedAt: status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null,
|
||||||
|
spamAt: status === 'VERIFICATION_FAILED' ? faker.date.recent({ days: 30 }) : null,
|
||||||
|
approvedAt,
|
||||||
|
isRecentlyApproved: !!approvedAt && isPast(approvedAt) && differenceInDays(new Date(), approvedAt) < 15,
|
||||||
|
tosReview,
|
||||||
|
tosReviewAt: tosReview
|
||||||
|
? faker.date.recent()
|
||||||
|
: faker.helpers.maybe(() => faker.date.recent(), { probability: 0.5 }),
|
||||||
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
||||||
userSentimentAt: faker.date.recent(),
|
userSentimentAt: faker.date.recent(),
|
||||||
internalNotes: faker.helpers.maybe(
|
internalNotes: faker.helpers.maybe(
|
||||||
@@ -888,7 +918,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
|||||||
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: faker.lorem.word({ length: 2 }),
|
label: 'Custom label',
|
||||||
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -898,7 +928,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
|||||||
value: faker.internet.url(),
|
value: faker.internet.url(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: faker.lorem.word({ length: 2 }),
|
label: 'Custom label',
|
||||||
value: faker.internet.url(),
|
value: faker.internet.url(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1123,7 +1153,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let users = await Promise.all(
|
let users = await Promise.all(
|
||||||
Array.from({ length: 10 }, async () => {
|
Array.from({ length: 570 }, async () => {
|
||||||
const { user } = await createAccount()
|
const { user } = await createAccount()
|
||||||
return user
|
return user
|
||||||
})
|
})
|
||||||
@@ -1287,7 +1317,7 @@ async function main() {
|
|||||||
const service = await prisma.service.create({
|
const service = await prisma.service.create({
|
||||||
data: {
|
data: {
|
||||||
...serviceData,
|
...serviceData,
|
||||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||||
categories: {
|
categories: {
|
||||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
||||||
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
||||||
-- for services when services or their attributes change.
|
-- for services when services or their attributes change.
|
||||||
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days.
|
-- 2. Updates the isRecentlyApproved flag for services approved within the last 15 days.
|
||||||
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
||||||
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
||||||
-- efficient handling of widespread score updates.
|
-- efficient handling of widespread score updates.
|
||||||
@@ -22,14 +22,11 @@ DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
|
|||||||
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
||||||
RETURNS INT AS $$
|
RETURNS INT AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
privacy_score INT := 50; -- Start from middle value (50)
|
privacy_score INT := 0;
|
||||||
kyc_factor INT;
|
kyc_factor INT;
|
||||||
onion_factor INT := 0;
|
clarification_factor INT := 0;
|
||||||
i2p_factor INT := 0;
|
onion_or_i2p_factor INT := 0;
|
||||||
monero_factor INT := 0;
|
monero_factor INT := 0;
|
||||||
open_source_factor INT := 0;
|
|
||||||
p2p_factor INT := 0;
|
|
||||||
decentralized_factor INT := 0;
|
|
||||||
attributes_score INT := 0;
|
attributes_score INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get service data
|
-- Get service data
|
||||||
@@ -46,20 +43,22 @@ BEGIN
|
|||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE "id" = service_id;
|
WHERE "id" = service_id;
|
||||||
|
|
||||||
-- Check for onion URLs
|
-- Adjust score based on KYC level clarification modifiers
|
||||||
IF EXISTS (
|
SELECT
|
||||||
SELECT 1 FROM "Service"
|
CASE
|
||||||
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
|
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
|
||||||
) THEN
|
ELSE 0 -- Default modifier when no clarification or unrecognized value
|
||||||
onion_factor := 5;
|
END
|
||||||
END IF;
|
INTO clarification_factor
|
||||||
|
FROM "Service"
|
||||||
|
WHERE "id" = service_id;
|
||||||
|
|
||||||
-- Check for i2p URLs
|
-- Check for onion or i2p URLs
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM "Service"
|
SELECT 1 FROM "Service"
|
||||||
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
|
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
|
||||||
) THEN
|
) THEN
|
||||||
i2p_factor := 5;
|
onion_or_i2p_factor := 5;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Check for Monero acceptance
|
-- Check for Monero acceptance
|
||||||
@@ -75,10 +74,10 @@ BEGIN
|
|||||||
INTO attributes_score
|
INTO attributes_score
|
||||||
FROM "ServiceAttribute" sa
|
FROM "ServiceAttribute" sa
|
||||||
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
||||||
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
WHERE sa."serviceId" = service_id;
|
||||||
|
|
||||||
-- Calculate final privacy score (base 100)
|
-- Calculate final privacy score (base 100)
|
||||||
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
privacy_score := 50 + kyc_factor + clarification_factor + onion_or_i2p_factor + monero_factor + attributes_score;
|
||||||
|
|
||||||
-- Ensure the score is in reasonable bounds (0-100)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||||
@@ -91,9 +90,11 @@ $$ LANGUAGE plpgsql;
|
|||||||
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
||||||
RETURNS INT AS $$
|
RETURNS INT AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
trust_score INT := 50; -- Start from middle value (50)
|
trust_score INT := 0;
|
||||||
verification_factor INT;
|
verification_factor INT;
|
||||||
attributes_score INT := 0;
|
attributes_score INT := 0;
|
||||||
|
recently_approved_factor INT := 0;
|
||||||
|
tos_penalty_factor INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get verification status factor
|
-- Get verification status factor
|
||||||
SELECT
|
SELECT
|
||||||
@@ -113,32 +114,43 @@ BEGIN
|
|||||||
INTO attributes_score
|
INTO attributes_score
|
||||||
FROM "ServiceAttribute" sa
|
FROM "ServiceAttribute" sa
|
||||||
JOIN "Attribute" a ON sa."attributeId" = a.id
|
JOIN "Attribute" a ON sa."attributeId" = a.id
|
||||||
WHERE sa."serviceId" = service_id AND a.category = 'TRUST';
|
WHERE sa."serviceId" = service_id;
|
||||||
|
|
||||||
-- Apply penalty if service was listed within the last 15 days
|
-- Apply penalty if service was approved within the last 15 days
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE id = service_id
|
WHERE id = service_id
|
||||||
AND "listedAt" IS NOT NULL
|
AND "approvedAt" IS NOT NULL
|
||||||
AND "verificationStatus" = 'APPROVED'
|
AND "verificationStatus" = 'APPROVED'
|
||||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
AND (NOW() - "approvedAt") <= INTERVAL '15 days'
|
||||||
) THEN
|
) THEN
|
||||||
trust_score := trust_score - 10;
|
recently_approved_factor := -10;
|
||||||
-- Update the isRecentlyListed flag to true
|
-- Update the isRecentlyApproved flag to true
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = TRUE
|
SET "isRecentlyApproved" = TRUE
|
||||||
WHERE id = service_id;
|
WHERE id = service_id;
|
||||||
ELSE
|
ELSE
|
||||||
-- Update the isRecentlyListed flag to false
|
-- Update the isRecentlyApproved flag to false
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = FALSE
|
SET "isRecentlyApproved" = FALSE
|
||||||
WHERE id = service_id;
|
WHERE id = service_id;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- Apply penalty if ToS cannot be analyzed
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "Service"
|
||||||
|
WHERE id = service_id
|
||||||
|
AND "tosReviewAt" IS NOT NULL
|
||||||
|
AND "tosReview" IS NULL
|
||||||
|
) THEN
|
||||||
|
tos_penalty_factor := -3;
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- Calculate final trust score (base 100)
|
-- Calculate final trust score (base 100)
|
||||||
trust_score := trust_score + verification_factor + attributes_score;
|
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_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));
|
||||||
|
|
||||||
@@ -152,7 +164,7 @@ RETURNS INT AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
overall_score INT;
|
overall_score INT;
|
||||||
BEGIN
|
BEGIN
|
||||||
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT);
|
overall_score := CAST(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0 AS INT);
|
||||||
RETURN GREATEST(0, LEAST(10, overall_score));
|
RETURN GREATEST(0, LEAST(10, overall_score));
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|||||||
@@ -1,48 +1,60 @@
|
|||||||
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
|
CREATE OR REPLACE FUNCTION manage_service_visibility_timestamps()
|
||||||
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
|
|
||||||
-- are set or cleared appropriately when a service's verification status is updated.
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION manage_service_timestamps()
|
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Manage listedAt timestamp
|
IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
|
||||||
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
|
||||||
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
|
|
||||||
IF OLD."listedAt" IS NULL THEN
|
IF OLD."listedAt" IS NULL THEN
|
||||||
NEW."listedAt" := NOW();
|
NEW."listedAt" := NOW();
|
||||||
NEW."isRecentlyListed" := TRUE;
|
|
||||||
END IF;
|
END IF;
|
||||||
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
ELSE
|
||||||
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
|
|
||||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
|
||||||
NEW."listedAt" := NULL;
|
NEW."listedAt" := NULL;
|
||||||
NEW."isRecentlyListed" := FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Manage verifiedAt timestamp
|
|
||||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
|
||||||
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
|
|
||||||
NEW."verifiedAt" := NOW();
|
|
||||||
NEW."isRecentlyListed" := FALSE;
|
|
||||||
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
|
||||||
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
|
|
||||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
|
||||||
NEW."verifiedAt" := NULL;
|
|
||||||
NEW."isRecentlyListed" := FALSE;
|
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
-- Drop the old trigger first if it exists under the old name
|
CREATE OR REPLACE FUNCTION manage_service_verification_timestamps()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (NEW."verificationStatus" = 'APPROVED' OR NEW."verificationStatus" = 'VERIFICATION_SUCCESS') THEN
|
||||||
|
IF OLD."approvedAt" IS NULL THEN
|
||||||
|
NEW."approvedAt" := NOW();
|
||||||
|
NEW."isRecentlyApproved" := TRUE;
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
NEW."approvedAt" := NULL;
|
||||||
|
NEW."isRecentlyApproved" := FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||||
|
NEW."verifiedAt" := NOW();
|
||||||
|
ELSE
|
||||||
|
NEW."verifiedAt" := NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW."verificationStatus" = 'VERIFICATION_FAILED' THEN
|
||||||
|
NEW."spamAt" := NOW();
|
||||||
|
ELSE
|
||||||
|
NEW."spamAt" := NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Drop the old triggers TODO: remove this some day
|
||||||
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
|
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
|
||||||
-- Drop the trigger if it exists under the new name
|
|
||||||
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
|
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
|
||||||
|
|
||||||
CREATE TRIGGER trigger_manage_service_timestamps
|
DROP TRIGGER IF EXISTS trigger_manage_service_visibility_timestamps ON "Service";
|
||||||
|
DROP TRIGGER IF EXISTS trigger_manage_service_verification_timestamps ON "Service";
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_manage_service_visibility_timestamps
|
||||||
|
BEFORE UPDATE OF "serviceVisibility" ON "Service"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION manage_service_visibility_timestamps();
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_manage_service_verification_timestamps
|
||||||
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
-- Only execute the function if the verificationStatus value has actually changed
|
EXECUTE FUNCTION manage_service_verification_timestamps();
|
||||||
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
|
|
||||||
EXECUTE FUNCTION manage_service_timestamps();
|
|
||||||
|
|||||||
@@ -3,7 +3,20 @@ RETURNS TRIGGER AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
suggestion_status_change "ServiceSuggestionStatusChange";
|
suggestion_status_change "ServiceSuggestionStatusChange";
|
||||||
BEGIN
|
BEGIN
|
||||||
IF TG_OP = 'INSERT' THEN -- Corresponds to ServiceSuggestionMessage insert
|
IF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestion' THEN -- Corresponds to ServiceSuggestion insert
|
||||||
|
-- Notify all admins when a new suggestion is created
|
||||||
|
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId")
|
||||||
|
SELECT u."id", 'SUGGESTION_CREATED', NEW."id"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."admin" = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Notification" n
|
||||||
|
WHERE n."userId" = u."id"
|
||||||
|
AND n."type" = 'SUGGESTION_CREATED'
|
||||||
|
AND n."aboutServiceSuggestionId" = NEW."id"
|
||||||
|
);
|
||||||
|
|
||||||
|
ELSIF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestionMessage' THEN -- Corresponds to ServiceSuggestionMessage insert
|
||||||
-- Notify suggestion author (if not the sender)
|
-- Notify suggestion author (if not the sender)
|
||||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
||||||
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
||||||
@@ -55,6 +68,13 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger for new suggestions
|
||||||
|
DROP TRIGGER IF EXISTS service_suggestion_created_notifications_trigger ON "ServiceSuggestion";
|
||||||
|
CREATE TRIGGER service_suggestion_created_notifications_trigger
|
||||||
|
AFTER INSERT ON "ServiceSuggestion"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION trigger_service_suggestion_notifications();
|
||||||
|
|
||||||
-- Trigger for new messages
|
-- Trigger for new messages
|
||||||
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
||||||
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
||||||
|
|||||||
8
web/public/favicon-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#00bfff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#3BDB78" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 619 B |
8
web/public/favicon-dev-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#FF0040" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 616 B |
8
web/public/favicon-dev-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#FF0040" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 613 B |
7
web/public/favicon-dev-lightmode.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
|
||||||
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#fff" d="M4 4h24v24H4z" />
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 607 B |
@@ -1,5 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#FF0000" viewBox="0 0 32 32" height="32" width="32">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
|
||||||
<title>KYCnot.me logo</title>
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#040505" d="M4 4h24v24H4z" />
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 610 B |
8
web/public/favicon-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#0080FF" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#33BE00" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 616 B |
@@ -1,5 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32">
|
||||||
<title>KYCnot.me logo</title>
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#fff" d="M4 4h24v24H4z" />
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 607 B |
8
web/public/favicon-stage-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#00ffff" class="a" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 626 B |
8
web/public/favicon-stage-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#0080ff" class="a" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 623 B |
7
web/public/favicon-stage-lightmode.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
||||||
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#fff" class="b" d="M4 4h24v24H4z" />
|
||||||
|
<path fill="#0080ff" class="a" fill-rule="evenodd"
|
||||||
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 639 B |
@@ -1,13 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
||||||
<title>KYCnot.me logo</title>
|
<title>KYCnot.me logo</title>
|
||||||
<style>
|
<path fill="#040505" class="b" d="M4 4h24v24H4z" />
|
||||||
@media (prefers-color-scheme: light) {
|
<path fill="#00ffff" class="a" fill-rule="evenodd"
|
||||||
path {
|
|
||||||
fill: #0080ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<path fill="#00ffff" fill-rule="evenodd"
|
|
||||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 692 B After Width: | Height: | Size: 642 B |
@@ -1,5 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
||||||
<title>KYCnot.me logo</title>
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#040505" d="M4 4h24v24H4z" />
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 610 B |
6
web/public/notification-icon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
||||||
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M30 26a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h20a4 4 0 0 1 4 4v20ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 566 B |
114
web/public/sw.js
@@ -1,114 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
/// <reference lib="webworker" />
|
|
||||||
|
|
||||||
/** @type {ServiceWorkerGlobalScope} */
|
|
||||||
// @ts-expect-error
|
|
||||||
const typedSelf = self
|
|
||||||
|
|
||||||
const CACHE_NAME = 'kycnot-sw-push-notifications-v2'
|
|
||||||
|
|
||||||
/** @typedef {import('../src/lib/webPush').NotificationPayload} NotificationPayload */
|
|
||||||
/** @typedef {{defaultActionUrl: string, payload: NotificationPayload | null}} NotificationData */
|
|
||||||
/** @typedef {NotificationOptions & { actions: { action: string; title: string; icon?: string }[], timestamp: number, data: NotificationData } } CustomNotificationOptions */
|
|
||||||
|
|
||||||
typedSelf.addEventListener('install', (event) => {
|
|
||||||
console.log('Service Worker installing')
|
|
||||||
typedSelf.skipWaiting()
|
|
||||||
})
|
|
||||||
|
|
||||||
typedSelf.addEventListener('activate', (event) => {
|
|
||||||
console.log('Service Worker activating')
|
|
||||||
event.waitUntil(typedSelf.clients.claim())
|
|
||||||
})
|
|
||||||
|
|
||||||
typedSelf.addEventListener('push', (event) => {
|
|
||||||
console.log('Push event received:', event)
|
|
||||||
|
|
||||||
if (!event.data) {
|
|
||||||
console.error('Push event but no data')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let title = 'New Notification'
|
|
||||||
/** @type {CustomNotificationOptions} */
|
|
||||||
let options = {
|
|
||||||
body: 'You have a new notification',
|
|
||||||
lang: 'en-US',
|
|
||||||
icon: '/favicon.svg',
|
|
||||||
badge: '/favicon.svg',
|
|
||||||
requireInteraction: false,
|
|
||||||
silent: false,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
action: 'view',
|
|
||||||
title: 'View',
|
|
||||||
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
timestamp: Date.now(),
|
|
||||||
data: {
|
|
||||||
defaultActionUrl: '/notifications',
|
|
||||||
payload: null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
/** @type {NotificationPayload} */
|
|
||||||
const rawData = event.data.json()
|
|
||||||
if (typeof rawData !== 'object' || rawData === null) throw new Error('Invalid push data, not an object')
|
|
||||||
if (!('title' in rawData) || typeof rawData.title !== 'string')
|
|
||||||
throw new Error('Invalid push data, no title')
|
|
||||||
title = rawData.title
|
|
||||||
|
|
||||||
options = {
|
|
||||||
...options,
|
|
||||||
body: rawData.body || undefined,
|
|
||||||
actions: rawData.actions.map((action) => ({
|
|
||||||
action: action.action,
|
|
||||||
title: action.title,
|
|
||||||
icon: action.icon,
|
|
||||||
})),
|
|
||||||
data: {
|
|
||||||
...options.data,
|
|
||||||
payload: rawData,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing push data:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
event.waitUntil(typedSelf.registration.showNotification(title, options))
|
|
||||||
})
|
|
||||||
|
|
||||||
typedSelf.addEventListener('notificationclick', (event) => {
|
|
||||||
console.log('Notification clicked:', event)
|
|
||||||
|
|
||||||
event.notification.close()
|
|
||||||
|
|
||||||
/** @type {NotificationData} */
|
|
||||||
const data = event.notification.data
|
|
||||||
|
|
||||||
// @ts-expect-error I already use optional chaining
|
|
||||||
const url = data.payload?.[event.action]?.url || data.defaultActionUrl
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
|
||||||
// If a window is already open, focus it
|
|
||||||
for (const client of clientList) {
|
|
||||||
if (client.url === url && 'focus' in client) {
|
|
||||||
return client.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, open a new window
|
|
||||||
if (typedSelf.clients.openWindow) {
|
|
||||||
return typedSelf.clients.openWindow(url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
typedSelf.addEventListener('notificationclose', (event) => {
|
|
||||||
console.log('Notification closed:', event)
|
|
||||||
})
|
|
||||||
@@ -126,7 +126,8 @@ export const adminServiceActions = {
|
|||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
referral: input.referral,
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
referral: input.referral || null,
|
||||||
serviceVisibility: input.serviceVisibility,
|
serviceVisibility: input.serviceVisibility,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
overallScore: input.overallScore,
|
overallScore: input.overallScore,
|
||||||
@@ -244,7 +245,8 @@ export const adminServiceActions = {
|
|||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
referral: input.referral,
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
referral: input.referral || null,
|
||||||
serviceVisibility: input.serviceVisibility,
|
serviceVisibility: input.serviceVisibility,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
overallScore: input.overallScore,
|
overallScore: input.overallScore,
|
||||||
@@ -307,7 +309,7 @@ export const adminServiceActions = {
|
|||||||
input: z.object({
|
input: z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
label: z.string().min(1).max(50).nullable(),
|
label: z.string().min(1).max(50).nullable(),
|
||||||
value: z.string().url(),
|
value: zodContactMethod,
|
||||||
serviceId: z.number().int().positive(),
|
serviceId: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
handler: async (input) => {
|
handler: async (input) => {
|
||||||
@@ -458,7 +460,6 @@ export const adminServiceActions = {
|
|||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: evidenceImageDeleteSchema,
|
input: evidenceImageDeleteSchema,
|
||||||
handler: async (input) => {
|
handler: async (input) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
||||||
await deleteFileLocally(input.fileUrl)
|
await deleteFileLocally(input.fileUrl)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ export const apiServiceActions = {
|
|||||||
tosUrls: true,
|
tosUrls: true,
|
||||||
referral: true,
|
referral: true,
|
||||||
listedAt: true,
|
listedAt: true,
|
||||||
|
approvedAt: true,
|
||||||
verifiedAt: true,
|
verifiedAt: true,
|
||||||
serviceVisibility: true,
|
serviceVisibility: true,
|
||||||
} as const satisfies Prisma.ServiceSelect
|
} as const satisfies Prisma.ServiceSelect
|
||||||
|
|
||||||
let service = await prisma.service.findFirst({
|
let service = await prisma.service.findFirst({
|
||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
OR: [
|
OR: [
|
||||||
@@ -92,7 +92,6 @@ export const apiServiceActions = {
|
|||||||
if (!service && input.slug) {
|
if (!service && input.slug) {
|
||||||
service = await prisma.service.findFirst({
|
service = await prisma.service.findFirst({
|
||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
previousSlugs: { has: input.slug },
|
previousSlugs: { has: input.slug },
|
||||||
@@ -105,9 +104,7 @@ export const apiServiceActions = {
|
|||||||
!service ||
|
!service ||
|
||||||
(service.serviceVisibility !== 'PUBLIC' &&
|
(service.serviceVisibility !== 'PUBLIC' &&
|
||||||
service.serviceVisibility !== 'ARCHIVED' &&
|
service.serviceVisibility !== 'ARCHIVED' &&
|
||||||
service.serviceVisibility !== 'UNLISTED') ||
|
service.serviceVisibility !== 'UNLISTED')
|
||||||
!service.listedAt ||
|
|
||||||
service.listedAt > new Date()
|
|
||||||
) {
|
) {
|
||||||
throw new ActionError({
|
throw new ActionError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
@@ -130,6 +127,7 @@ export const apiServiceActions = {
|
|||||||
'description',
|
'description',
|
||||||
]),
|
]),
|
||||||
verifiedAt: service.verifiedAt,
|
verifiedAt: service.verifiedAt,
|
||||||
|
approvedAt: service.approvedAt,
|
||||||
kycLevel: service.kycLevel,
|
kycLevel: service.kycLevel,
|
||||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||||
kycLevelClarification: service.kycLevelClarification,
|
kycLevelClarification: service.kycLevelClarification,
|
||||||
|
|||||||
@@ -270,6 +270,18 @@ export const commentActions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRelatedToService = !!(await tx.serviceUser.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_serviceId: {
|
||||||
|
userId: context.locals.user.id,
|
||||||
|
serviceId: input.serviceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
// Prepare data object with proper type safety
|
// Prepare data object with proper type safety
|
||||||
const commentData: Prisma.CommentCreateInput = {
|
const commentData: Prisma.CommentCreateInput = {
|
||||||
content: input.content,
|
content: input.content,
|
||||||
@@ -277,7 +289,12 @@ export const commentActions = {
|
|||||||
author: { connect: { id: context.locals.user.id } },
|
author: { connect: { id: context.locals.user.id } },
|
||||||
|
|
||||||
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
||||||
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
|
status:
|
||||||
|
context.locals.user.admin || context.locals.user.moderator || isRelatedToService
|
||||||
|
? 'APPROVED'
|
||||||
|
: isIssueReport
|
||||||
|
? 'HUMAN_PENDING'
|
||||||
|
: 'PENDING',
|
||||||
requiresAdminReview,
|
requiresAdminReview,
|
||||||
orderId: input.orderId?.trim() ?? null,
|
orderId: input.orderId?.trim() ?? null,
|
||||||
kycRequested: input.issueKycRequested === true,
|
kycRequested: input.issueKycRequested === true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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'
|
||||||
@@ -32,7 +33,6 @@ export const notificationActions = {
|
|||||||
endpoint: z.string(),
|
endpoint: z.string(),
|
||||||
p256dhKey: z.string(),
|
p256dhKey: z.string(),
|
||||||
authKey: z.string(),
|
authKey: z.string(),
|
||||||
userAgent: z.string().optional(),
|
|
||||||
}),
|
}),
|
||||||
handler: async (input, context) => {
|
handler: async (input, context) => {
|
||||||
await prisma.pushSubscription.upsert({
|
await prisma.pushSubscription.upsert({
|
||||||
@@ -43,14 +43,12 @@ export const notificationActions = {
|
|||||||
update: {
|
update: {
|
||||||
p256dh: input.p256dhKey,
|
p256dh: input.p256dhKey,
|
||||||
auth: input.authKey,
|
auth: input.authKey,
|
||||||
userAgent: input.userAgent,
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user.id,
|
||||||
endpoint: input.endpoint,
|
endpoint: input.endpoint,
|
||||||
p256dh: input.p256dhKey,
|
p256dh: input.p256dhKey,
|
||||||
auth: input.authKey,
|
auth: input.authKey,
|
||||||
userAgent: input.userAgent,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -58,7 +56,7 @@ export const notificationActions = {
|
|||||||
|
|
||||||
unsubscribe: defineProtectedAction({
|
unsubscribe: defineProtectedAction({
|
||||||
accept: 'json',
|
accept: 'json',
|
||||||
permissions: 'user',
|
permissions: 'guest',
|
||||||
input: z.object({
|
input: z.object({
|
||||||
endpoint: z.string().optional(),
|
endpoint: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
@@ -66,16 +64,21 @@ export const notificationActions = {
|
|||||||
if (input.endpoint) {
|
if (input.endpoint) {
|
||||||
await prisma.pushSubscription.deleteMany({
|
await prisma.pushSubscription.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user?.id ?? undefined,
|
||||||
endpoint: input.endpoint,
|
endpoint: input.endpoint,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else if (context.locals.user) {
|
||||||
await prisma.pushSubscription.deleteMany({
|
await prisma.pushSubscription.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Endpoint is required when user is not logged in.',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
|||||||
id: {
|
id: {
|
||||||
in: matches.map(({ id }) => id),
|
in: matches.map(({ id }) => id),
|
||||||
},
|
},
|
||||||
|
serviceVisibility: {
|
||||||
|
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -58,6 +61,8 @@ const serializeExtraNotes = <T extends Record<string, unknown>>(
|
|||||||
serializedValue = value
|
serializedValue = value
|
||||||
} else if (value === undefined || value === null) {
|
} else if (value === undefined || value === null) {
|
||||||
serializedValue = ''
|
serializedValue = ''
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
serializedValue = value.map((item) => String(item)).join(', ')
|
||||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||||
serializedValue = value.toString()
|
serializedValue = value.toString()
|
||||||
@@ -144,17 +149,7 @@ export const serviceSuggestionActions = {
|
|||||||
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
||||||
.regex(/^[a-z0-9-]+$/, {
|
.regex(/^[a-z0-9-]+$/, {
|
||||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||||
})
|
}),
|
||||||
.refine(
|
|
||||||
async (slug) => {
|
|
||||||
const exists = await prisma.service.findUnique({
|
|
||||||
select: { id: true },
|
|
||||||
where: { slug },
|
|
||||||
})
|
|
||||||
return !exists
|
|
||||||
},
|
|
||||||
{ message: 'Slug must be unique, try a different one' }
|
|
||||||
),
|
|
||||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
@@ -165,6 +160,11 @@ export const serviceSuggestionActions = {
|
|||||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
||||||
imageFile: imageFileSchemaRequired,
|
imageFile: imageFileSchemaRequired,
|
||||||
|
rulesConfirm: z.literal('on', {
|
||||||
|
errorMap: () => ({
|
||||||
|
message: 'You must accept the suggestion rules and process to continue',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
/** @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
|
||||||
@@ -184,8 +184,16 @@ export const serviceSuggestionActions = {
|
|||||||
location: 'serviceSuggestion.createService',
|
location: 'serviceSuggestion.createService',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const serviceWithSameSlug = await prisma.service.findUnique({
|
||||||
|
select: { id: true, name: true, slug: true, description: true },
|
||||||
|
where: { slug: input.slug },
|
||||||
|
})
|
||||||
|
|
||||||
if (!input.skipDuplicateCheck) {
|
if (!input.skipDuplicateCheck) {
|
||||||
const possibleDuplicates = await findPossibleDuplicates(input)
|
const possibleDuplicates = [
|
||||||
|
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
|
||||||
|
...(await findPossibleDuplicates(input)),
|
||||||
|
]
|
||||||
|
|
||||||
if (possibleDuplicates.length > 0) {
|
if (possibleDuplicates.length > 0) {
|
||||||
return {
|
return {
|
||||||
@@ -197,11 +205,19 @@ export const serviceSuggestionActions = {
|
|||||||
'imageFile',
|
'imageFile',
|
||||||
'captcha-value',
|
'captcha-value',
|
||||||
'captcha-solution-hash',
|
'captcha-solution-hash',
|
||||||
|
'rulesConfirm',
|
||||||
]),
|
]),
|
||||||
serviceSuggestion: undefined,
|
serviceSuggestion: undefined,
|
||||||
service: undefined,
|
service: undefined,
|
||||||
} as const
|
} as const
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (serviceWithSameSlug) {
|
||||||
|
throw new ActionError({
|
||||||
|
message: 'Slug already in use, try a different one',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
@@ -235,7 +251,6 @@ export const serviceSuggestionActions = {
|
|||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
listedAt: new Date(),
|
|
||||||
serviceVisibility: 'UNLISTED',
|
serviceVisibility: 'UNLISTED',
|
||||||
categories: {
|
categories: {
|
||||||
connect: input.categories.map((id) => ({ id })),
|
connect: input.categories.map((id) => ({ id })),
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
8
web/src/assets/review-badge/long-black.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||||
|
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||||
|
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||||
|
<path fill="#BEBEBE"
|
||||||
|
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||||
|
<path fill="#3BDB78"
|
||||||
|
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
8
web/src/assets/review-badge/long-white.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||||
|
<rect width="431" height="63" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
|
||||||
|
<path fill="#28AE5B" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||||
|
<path fill="#3F3F3F"
|
||||||
|
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||||
|
<path fill="#28AE5B"
|
||||||
|
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
8
web/src/assets/review-badge/short-black.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
|
||||||
|
<rect width="255" height="95" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||||
|
<path fill="#3BDB78" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||||
|
<path fill="#BEBEBE"
|
||||||
|
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||||
|
<path fill="#3BDB78"
|
||||||
|
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
8
web/src/assets/review-badge/short-white.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
|
||||||
|
<rect width="255" height="95" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
|
||||||
|
<path fill="#28AE5B" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||||
|
<path fill="#3F3F3F"
|
||||||
|
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||||
|
<path fill="#28AE5B"
|
||||||
|
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -2,12 +2,19 @@
|
|||||||
import LoadingIndicator from 'astro-loading-indicator/component'
|
import LoadingIndicator from 'astro-loading-indicator/component'
|
||||||
import { Schema } from 'astro-seo-schema'
|
import { Schema } from 'astro-seo-schema'
|
||||||
import { ClientRouter } from 'astro:transitions'
|
import { ClientRouter } from 'astro:transitions'
|
||||||
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
|
||||||
|
import { pwaInfo } from 'virtual:pwa-info'
|
||||||
|
|
||||||
import { isNotArray } from '../lib/arrays'
|
import { isNotArray } from '../lib/arrays'
|
||||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
|
|
||||||
|
import DevToolsMessageScript from './DevToolsMessageScript.astro'
|
||||||
|
import DynamicFavicon from './DynamicFavicon.astro'
|
||||||
import HtmxScript from './HtmxScript.astro'
|
import HtmxScript from './HtmxScript.astro'
|
||||||
|
import NotificationEventsScript from './NotificationEventsScript.astro'
|
||||||
import { makeOgImageUrl } from './OgImage'
|
import { makeOgImageUrl } from './OgImage'
|
||||||
|
import ServerEventsScript from './ServerEventsScript.astro'
|
||||||
|
import ServiceWorkerScript from './ServiceWorkerScript.astro'
|
||||||
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
||||||
|
|
||||||
import type { ComponentProps } from 'astro/types'
|
import type { ComponentProps } from 'astro/types'
|
||||||
@@ -70,12 +77,6 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
|
|||||||
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
|
|
||||||
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
|
|
||||||
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
|
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
@@ -98,10 +99,16 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
|||||||
|
|
||||||
<!-- Other -->
|
<!-- Other -->
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||||
<meta name="theme-color" content="#040505" />
|
|
||||||
|
|
||||||
<!-- Components -->
|
<!-- PWA -->
|
||||||
|
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
|
||||||
|
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
|
||||||
|
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
||||||
|
|
||||||
|
<DynamicFavicon />
|
||||||
|
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
|
|
||||||
<LoadingIndicator color="green" />
|
<LoadingIndicator color="green" />
|
||||||
<TailwindJsPluggin />
|
<TailwindJsPluggin />
|
||||||
{htmx && <HtmxScript />}
|
{htmx && <HtmxScript />}
|
||||||
@@ -131,3 +138,15 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
Astro.locals.user && (
|
||||||
|
<>
|
||||||
|
<ServerEventsScript />
|
||||||
|
<ServiceWorkerScript />
|
||||||
|
<NotificationEventsScript />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<DevToolsMessageScript />
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
|||||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||||
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../lib/commentsWithReplies'
|
} from '../lib/commentsWithReplies'
|
||||||
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||||
import { formatDateShort } from '../lib/timeAgo'
|
import { formatDateShort } from '../lib/timeAgo'
|
||||||
|
import { urlDomain } from '../lib/urls'
|
||||||
|
|
||||||
import BadgeSmall from './BadgeSmall.astro'
|
import BadgeSmall from './BadgeSmall.astro'
|
||||||
import CommentModeration from './CommentModeration.astro'
|
import CommentModeration from './CommentModeration.astro'
|
||||||
@@ -150,13 +151,13 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
checked={comment.suspicious}
|
checked={comment.suspicious}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
|
<div class="comment-header flex items-center gap-2 text-sm">
|
||||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||||
<span class="collapse-symbol text-xs"></span>
|
<span class="collapse-symbol text-xs"></span>
|
||||||
<span class="sr-only">Toggle comment visibility</span>
|
<span class="sr-only">Toggle comment visibility</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex min-w-16 items-center gap-1">
|
||||||
<UserBadge
|
<UserBadge
|
||||||
user={comment.author}
|
user={comment.author}
|
||||||
size="md"
|
size="md"
|
||||||
@@ -170,7 +171,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
comment.author.admin || comment.author.moderator
|
comment.author.admin || comment.author.moderator
|
||||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||||
: ''
|
: ''
|
||||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
}${comment.author.verifiedLink ? `Related to ${urlDomain(comment.author.verifiedLink)}` : ''}`}
|
||||||
>
|
>
|
||||||
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -179,7 +180,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* User badges - more compact but still with text */}
|
{/* User badges - more compact but still with text */}
|
||||||
<div class="flex flex-wrap items-center gap-1">
|
<div class="flex w-min grow flex-wrap items-center gap-1">
|
||||||
{
|
{
|
||||||
comment.author.admin && (
|
comment.author.admin && (
|
||||||
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
|
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
|
||||||
@@ -240,15 +241,17 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
comment.author.serviceAffiliations.map((affiliation) => {
|
comment.author.serviceAffiliations
|
||||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
.filter((affiliation) => affiliation.service.slug === serviceSlug)
|
||||||
return (
|
.map((affiliation) => {
|
||||||
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
|
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||||
{roleInfo.label} at
|
return (
|
||||||
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
|
||||||
</BadgeSmall>
|
{roleInfo.label} at
|
||||||
)
|
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
||||||
})
|
</BadgeSmall>
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,12 +71,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
|
|||||||
'Failed to fetch comments',
|
'Failed to fetch comments',
|
||||||
async () =>
|
async () =>
|
||||||
await prisma.comment.findMany(
|
await prisma.comment.findMany(
|
||||||
makeCommentsNestedQuery({
|
await makeCommentsNestedQuery({
|
||||||
depth: MAX_COMMENT_DEPTH,
|
depth: MAX_COMMENT_DEPTH,
|
||||||
user,
|
user,
|
||||||
showPending: params.showPending,
|
showPending: params.showPending,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
sort: params.sort,
|
sort: params.sort,
|
||||||
|
highlightedCommentId: params.comment,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
|
|||||||
51
web/src/components/DevToolsMessageScript.astro
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||||
|
|
||||||
|
const logoStyle = `
|
||||||
|
padding: 0 119.5px;
|
||||||
|
display: block;
|
||||||
|
line-height: 64px;
|
||||||
|
background-size: auto 64px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: 50% 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 240 64'%3E%3Crect width='239' height='63' x='.5' y='.5' fill='%23101413' stroke='%23292B2A' rx='7.5'/%3E%3Cpath fill='%233BDB78' d='M19 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1H19Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.9 10.8a1 1 0 0 0-.1.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L47 23.5a1 1 0 0 1 .8-1.5ZM75 22h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H74v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H75a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L186 37.2l-6.7-14.6a1 1 0 0 0-1-.6H175Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z'/%3E%3C/svg%3E");
|
||||||
|
`
|
||||||
|
|
||||||
|
setTimeout(
|
||||||
|
console.log.bind(
|
||||||
|
console,
|
||||||
|
`\n%c \n%c\n 👋%c Hi there! %c\n\n‣ We included source maps, so you can easily inspect the code. 🕵🏻♂️\n‣ Everything works with JavaScript disabled.\n‣ Source code: ${SOURCE_CODE_URL}`,
|
||||||
|
logoStyle,
|
||||||
|
`
|
||||||
|
font-family: cursive;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
font-family: cursive;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#d97706 0%,
|
||||||
|
#f59e0b 20%,
|
||||||
|
#f97316 40%,
|
||||||
|
#ea580c 60%,
|
||||||
|
#f97316 80%,
|
||||||
|
#f59e0b 100%
|
||||||
|
) -100%/ 200%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
79
web/src/components/DynamicFavicon.astro
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
|
||||||
|
const user = Astro.locals.user
|
||||||
|
|
||||||
|
const hasUnreadNotifications = await Astro.locals.banners.try(
|
||||||
|
'Error getting unread notification count',
|
||||||
|
async () =>
|
||||||
|
user
|
||||||
|
? !!(await prisma.notification.findFirst({
|
||||||
|
where: { userId: user.id, read: false },
|
||||||
|
select: { id: true },
|
||||||
|
}))
|
||||||
|
: false,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
function addBadgeIfUnread(href: string) {
|
||||||
|
if (hasUnreadNotifications) return href.replace('.svg', '-badge.svg')
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
DEPLOYMENT_MODE === 'production' && (
|
||||||
|
<>
|
||||||
|
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon.svg')} />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
sizes="any"
|
||||||
|
href={addBadgeIfUnread('/favicon-lightmode.svg')}
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DEPLOYMENT_MODE === 'development' && (
|
||||||
|
<>
|
||||||
|
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-dev.svg')} />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
sizes="any"
|
||||||
|
href={addBadgeIfUnread('/favicon-dev-lightmode.svg')}
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DEPLOYMENT_MODE === 'staging' && (
|
||||||
|
<>
|
||||||
|
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-stage.svg')} />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
sizes="any"
|
||||||
|
href={addBadgeIfUnread('/favicon-stage-lightmode.svg')}
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('sse:new-notification', () => {
|
||||||
|
const links = document.querySelectorAll('link[rel="icon"]')
|
||||||
|
links.forEach((link) => {
|
||||||
|
const href = link.getAttribute('href')
|
||||||
|
if (href && href.includes('favicon') && !href.endsWith('-badge.svg')) {
|
||||||
|
const newHref = href.replace('.svg', '-badge.svg')
|
||||||
|
link.setAttribute('href', newHref)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
|
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||||
|
import { I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
|
||||||
|
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ const links = [
|
|||||||
icon: 'ri:plug-line',
|
icon: 'ri:plug-line',
|
||||||
external: false,
|
external: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/feeds',
|
||||||
|
label: 'RSS',
|
||||||
|
icon: 'ri:rss-line',
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/about',
|
href: '/about',
|
||||||
label: 'About',
|
label: 'About',
|
||||||
@@ -49,7 +56,10 @@ const links = [
|
|||||||
const { class: className, ...htmlProps } = Astro.props
|
const { class: className, ...htmlProps } = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}>
|
<footer
|
||||||
|
class={cn('xs:gap-x-6 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 p-4', className)}
|
||||||
|
{...htmlProps}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
links.map(
|
links.map(
|
||||||
({ href, label, icon, external }) =>
|
({ href, label, icon, external }) =>
|
||||||
@@ -58,9 +68,9 @@ const { class: className, ...htmlProps } = Astro.props
|
|||||||
href={href}
|
href={href}
|
||||||
target={external ? '_blank' : undefined}
|
target={external ? '_blank' : undefined}
|
||||||
rel={external ? 'noopener noreferrer' : undefined}
|
rel={external ? 'noopener noreferrer' : undefined}
|
||||||
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
|
class="text-day-500 xs:gap-1 flex items-center gap-0.5 text-sm transition-colors hover:text-gray-200 hover:underline"
|
||||||
>
|
>
|
||||||
<Icon name={icon} class="h-4 w-4" />
|
<Icon name={icon} class="xs:opacity-100 h-4 w-4 opacity-40" />
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import { sample } from 'lodash-es'
|
import { sample } from 'lodash-es'
|
||||||
|
|
||||||
import { splashTexts } from '../constants/splashTexts'
|
import { splashTexts } from '../constants/splashTexts'
|
||||||
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
|
||||||
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
||||||
|
|
||||||
import AdminOnly from './AdminOnly.astro'
|
import AdminOnly from './AdminOnly.astro'
|
||||||
@@ -123,6 +123,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
|||||||
transition:name="header-admin-link"
|
transition:name="header-admin-link"
|
||||||
text="Admin Dashboard"
|
text="Admin Dashboard"
|
||||||
position="left"
|
position="left"
|
||||||
|
aria-label="Admin Dashboard"
|
||||||
>
|
>
|
||||||
<Icon name="ri:home-gear-line" class="size-10" />
|
<Icon name="ri:home-gear-line" class="size-10" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const count =
|
|||||||
user && (
|
user && (
|
||||||
<a
|
<a
|
||||||
href="/notifications"
|
href="/notifications"
|
||||||
|
data-notification-count-link
|
||||||
|
data-current-count={count}
|
||||||
class={cn(
|
class={cn(
|
||||||
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
|
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
|
||||||
className
|
className
|
||||||
@@ -35,11 +37,32 @@ const count =
|
|||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
>
|
>
|
||||||
<Icon name="material-symbols:notifications-outline" class="size-5" />
|
<Icon name="material-symbols:notifications-outline" class="size-5" />
|
||||||
{count > 0 && (
|
<span
|
||||||
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500">
|
data-notification-count-badge
|
||||||
{count > 99 ? '★' : count.toLocaleString()}
|
class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500 empty:hidden"
|
||||||
</span>
|
>
|
||||||
)}
|
{count > 0 ? (count > 99 ? '★' : count.toLocaleString()) : ''}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('sse:new-notification', () => {
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
|
||||||
|
const currentCount = Number(link.getAttribute('data-current-count') || 0)
|
||||||
|
const newCount = currentCount + 1
|
||||||
|
|
||||||
|
link.querySelectorAll<HTMLElement>('[data-notification-count-badge]').forEach((badge) => {
|
||||||
|
badge.textContent = newCount > 0 ? (newCount > 99 ? '★' : newCount.toLocaleString()) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
link.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`Go to notifications${newCount > 0 ? ` (${newCount.toLocaleString()} unread)` : ''}`
|
||||||
|
)
|
||||||
|
|
||||||
|
link.setAttribute('data-current-count', String(newCount))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
|
|||||||
iconClass?: string
|
iconClass?: string
|
||||||
description?: MarkdownString
|
description?: MarkdownString
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
noTransitionPersist?: boolean
|
|
||||||
}[]
|
}[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
selectedValue?: Multiple extends true ? string[] : string
|
selectedValue?: Multiple extends true ? string[] : string
|
||||||
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
transition:persist
|
||||||
type={multiple ? 'checkbox' : 'radio'}
|
type={multiple ? 'checkbox' : 'radio'}
|
||||||
name={wrapperProps.name}
|
name={wrapperProps.name}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
|
|||||||
61
web/src/components/InputCheckbox.astro
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
|
import type InputWrapper from './InputWrapper.astro'
|
||||||
|
import type { AstroChildren } from '../lib/astro'
|
||||||
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
|
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
|
||||||
|
disabled?: boolean
|
||||||
|
id?: string
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
label: string
|
||||||
|
children?: undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
label?: undefined
|
||||||
|
children: AstroChildren
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { disabled, name, required, error, id, label } = Astro.props
|
||||||
|
|
||||||
|
const hasError = !!error && error.length > 0
|
||||||
|
---
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
'inline-flex cursor-pointer items-center gap-2',
|
||||||
|
hasError && 'text-red-300',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
transition:persist
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
class={cn(disabled && 'opacity-50')}
|
||||||
|
/>
|
||||||
|
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{
|
||||||
|
hasError &&
|
||||||
|
(typeof error === 'string' ? (
|
||||||
|
<p class="text-sm text-red-500">{error}</p>
|
||||||
|
) : (
|
||||||
|
<ul class="text-sm text-red-500">
|
||||||
|
{error.map((e) => (
|
||||||
|
<li>{e}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -14,10 +14,11 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' |
|
|||||||
value: string
|
value: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}[]
|
}[]
|
||||||
selectProps?: Omit<HTMLAttributes<'select'>, 'name'>
|
selectProps?: Omit<HTMLAttributes<'select'>, 'name' | 'value'>
|
||||||
|
selectedValue?: string[] | string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { options, selectProps, ...wrapperProps } = Astro.props
|
const { options, selectProps, selectedValue, ...wrapperProps } = Astro.props
|
||||||
|
|
||||||
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
|
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||||
@@ -39,7 +40,15 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
>
|
>
|
||||||
{
|
{
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<option value={option.value} disabled={option.disabled}>
|
<option
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
selected={
|
||||||
|
Array.isArray(selectedValue)
|
||||||
|
? selectedValue.includes(option.value)
|
||||||
|
: selectedValue === option.value
|
||||||
|
}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
class={cn(
|
class={cn(
|
||||||
baseInputClassNames.input,
|
baseInputClassNames.input,
|
||||||
baseInputClassNames.textarea,
|
baseInputClassNames.textarea,
|
||||||
|
!!inputProps?.rows && 'h-auto',
|
||||||
inputProps?.class,
|
inputProps?.class,
|
||||||
hasError && baseInputClassNames.error,
|
hasError && baseInputClassNames.error,
|
||||||
!!inputProps?.disabled && baseInputClassNames.disabled
|
!!inputProps?.disabled && baseInputClassNames.disabled
|
||||||
|
|||||||
32
web/src/components/NotificationEventsScript.astro
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
|
||||||
|
import {
|
||||||
|
makeBrowserNotificationOptions,
|
||||||
|
makeBrowserNotificationTitle,
|
||||||
|
} from '../lib/client/notificationOptions'
|
||||||
|
|
||||||
|
document.addEventListener('sse:new-notification', (event) => {
|
||||||
|
if (isBrowserNotificationsEnabled()) {
|
||||||
|
const payload = event.detail
|
||||||
|
const notification = showBrowserNotification(
|
||||||
|
makeBrowserNotificationTitle(payload.title),
|
||||||
|
makeBrowserNotificationOptions(payload, { removeActions: true })
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle notification click
|
||||||
|
if (notification) {
|
||||||
|
notification.onclick = () => {
|
||||||
|
const defaultAction = payload.actions.find((a) => a.url) ?? payload.actions[0]
|
||||||
|
if (defaultAction?.url) {
|
||||||
|
window.open(defaultAction.url, '_blank')
|
||||||
|
}
|
||||||
|
notification.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
151
web/src/components/PressAssets.astro
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
import favicon from '../../public/favicon.svg'
|
||||||
|
import logoMiniFull from '../assets/logo/logo-mini-full.svg'
|
||||||
|
import logoNormal from '../assets/logo/logo-normal.svg'
|
||||||
|
import logoSmall from '../assets/logo/logo-small.svg'
|
||||||
|
import reviewBadgeLongBlack from '../assets/review-badge/long-black.svg'
|
||||||
|
import reviewBadgeLongWhite from '../assets/review-badge/long-white.svg'
|
||||||
|
import reviewBadgeShortBlack from '../assets/review-badge/short-black.svg'
|
||||||
|
import reviewBadgeShortWhite from '../assets/review-badge/short-white.svg'
|
||||||
|
|
||||||
|
import Button from './Button.astro'
|
||||||
|
import MyPicture from './MyPicture.astro'
|
||||||
|
|
||||||
|
const categories: {
|
||||||
|
title: string
|
||||||
|
assets: {
|
||||||
|
name: string
|
||||||
|
path: typeof logoNormal
|
||||||
|
alt: string
|
||||||
|
}[]
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
title: 'Logos',
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
name: 'Logo',
|
||||||
|
path: logoNormal,
|
||||||
|
alt: 'KYCnot.me logo normal version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Logo small',
|
||||||
|
path: logoSmall,
|
||||||
|
alt: 'KYCnot.me logo small version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Logo mini',
|
||||||
|
path: logoMiniFull,
|
||||||
|
alt: 'KYCnot.me logo mini version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Logo icon',
|
||||||
|
path: favicon,
|
||||||
|
alt: 'KYCnot.me logo icon version',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Review badges',
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
name: 'Review badge (long black)',
|
||||||
|
path: reviewBadgeLongBlack,
|
||||||
|
alt: 'KYCnot.me review badge long black version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Review badge (long white)',
|
||||||
|
path: reviewBadgeLongWhite,
|
||||||
|
alt: 'KYCnot.me review badge long white version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Review badge (short black)',
|
||||||
|
path: reviewBadgeShortBlack,
|
||||||
|
alt: 'KYCnot.me review badge short black version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Review badge (short white)',
|
||||||
|
path: reviewBadgeShortWhite,
|
||||||
|
alt: 'KYCnot.me review badge short white version',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="not-prose mb-16 space-y-8">
|
||||||
|
{
|
||||||
|
categories.map((category) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 class="font-title mb-2 text-center text-xl font-semibold text-white">{category.title}</h3>
|
||||||
|
<ul class="xs:grid-cols-2 grid grid-cols-1 gap-6">
|
||||||
|
{category.assets.map((asset) => (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
class="bg-transparency-grid mx-auto flex aspect-[3/1] max-w-sm items-center justify-center rounded-lg p-4"
|
||||||
|
style={{
|
||||||
|
'--transparency-grid-color-1': 'var(--color-night-600)',
|
||||||
|
'--transparency-grid-color-2': 'var(--color-night-500)',
|
||||||
|
'--transparency-grid-size': 'calc(var(--spacing) * 4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyPicture
|
||||||
|
src={asset.path}
|
||||||
|
alt={asset.alt}
|
||||||
|
pictureAttributes={{
|
||||||
|
class: 'contents',
|
||||||
|
}}
|
||||||
|
class="max-h-full min-h-8 max-w-full min-w-8 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-center">
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href={asset.path.src}
|
||||||
|
download={asset.name}
|
||||||
|
label={asset.name}
|
||||||
|
size="sm"
|
||||||
|
color="white"
|
||||||
|
variant="faded"
|
||||||
|
icon="ri:download-line"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-transparency-grid {
|
||||||
|
--transparency-grid-color-1: #fff;
|
||||||
|
--transparency-grid-color-2: #ccc;
|
||||||
|
--transparency-grid-size: calc(var(--spacing) * 8);
|
||||||
|
|
||||||
|
background-color: var(--transparency-grid-color-1);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--transparency-grid-color-2) 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
var(--transparency-grid-color-2) 75%,
|
||||||
|
var(--transparency-grid-color-2)
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--transparency-grid-color-2) 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
var(--transparency-grid-color-2) 75%,
|
||||||
|
var(--transparency-grid-color-2)
|
||||||
|
);
|
||||||
|
background-size: var(--transparency-grid-size) var(--transparency-grid-size);
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
calc(var(--transparency-grid-size) / 2) calc(var(--transparency-grid-size) / 2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
|
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
|
||||||
|
|
||||||
|
import { SUPPORT_EMAIL } from '../constants/project'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
import Button from './Button.astro'
|
import Button from './Button.astro'
|
||||||
@@ -15,30 +16,29 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
endpoint: true
|
endpoint: true
|
||||||
userAgent: true
|
|
||||||
}
|
}
|
||||||
}>[]
|
}>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
|
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
|
||||||
|
|
||||||
// TODO: Feature flag, enabled only for admins
|
|
||||||
if (!Astro.locals.user?.admin) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-push-notification-banner
|
data-push-notification-banner
|
||||||
data-dismissed={undefined /* Updated by client script */}
|
data-dismissed={undefined as true | undefined /* Updated by client script */}
|
||||||
data-supports-push-notifications={undefined /* Updated by client script */}
|
data-notification-supported={undefined as true | undefined /* Updated by client script */}
|
||||||
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
|
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
|
||||||
data-is-enabled={undefined /* Updated by client script */}
|
data-is-enabled={undefined as true | undefined /* Updated by client script */}
|
||||||
|
data-loaded={undefined as true | undefined /* Updated by client script */}
|
||||||
|
data-notification-permissions={undefined as
|
||||||
|
| NotificationPermission
|
||||||
|
| undefined /* Updated by client script */}
|
||||||
|
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||||
class={cn(
|
class={cn(
|
||||||
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
|
'no-js:hidden xs:grid-cols-[auto_auto] xs:justify-between relative isolate grid items-center justify-stretch gap-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4 not-data-loaded:hidden',
|
||||||
'data-dismissed:hidden',
|
'data-dismissed:hidden',
|
||||||
hideIfEnabled && 'data-is-enabled:hidden',
|
hideIfEnabled && 'data-is-enabled:hidden',
|
||||||
'not-data-supports-push-notifications:hidden',
|
'not-data-notification-supported:hidden',
|
||||||
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -60,25 +60,18 @@ if (!Astro.locals.user?.admin) {
|
|||||||
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-medium text-blue-100">
|
<h3 class="font-medium text-blue-100" data-banner-title>Turn on push notifications?</h3>
|
||||||
<span data-show-if-enabled>Push notifications enabled</span>
|
<p class="text-sm text-blue-200/80" data-banner-description>Get notifications on this device.</p>
|
||||||
<span data-show-if-disabled>Turn on push notifications?</span>
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-blue-200/80">
|
|
||||||
<span data-show-if-enabled>Turn notifications off for this device?</span>
|
|
||||||
<span data-show-if-disabled>Get notifications on this device.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="xs:justify-end flex shrink flex-wrap items-center justify-center gap-2">
|
||||||
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
|
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
|
||||||
<Button
|
<Button
|
||||||
as="span"
|
as="span"
|
||||||
label="Yes, notify me"
|
label="Yes, notify me"
|
||||||
color="white"
|
color="white"
|
||||||
data-push-action="subscribe"
|
data-push-action="subscribe"
|
||||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
|
||||||
data-show-if-disabled
|
data-show-if-disabled
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -87,10 +80,21 @@ if (!Astro.locals.user?.admin) {
|
|||||||
color="white"
|
color="white"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
data-push-action="unsubscribe"
|
data-push-action="unsubscribe"
|
||||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
|
||||||
data-show-if-enabled
|
data-show-if-enabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="col-span-full flex items-center justify-center gap-2 leading-tight text-red-500 has-[[data-error-message]:empty]:hidden"
|
||||||
|
>
|
||||||
|
<Icon name="ri:error-warning-line" class="size-5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<span data-error-message></span>
|
||||||
|
<a href={`mailto:${SUPPORT_EMAIL}`} class="text-red-300 underline hover:text-red-200">
|
||||||
|
Contact support
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -134,240 +138,128 @@ if (!Astro.locals.user?.admin) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/////////////////////////////////////////////////////////
|
///////////////////////////////////////////
|
||||||
// Script to style when notifications enabled. //
|
// Script to handle push notifications. //
|
||||||
////////////////////////////////////////////////////////
|
///////////////////////////////////////////
|
||||||
|
|
||||||
type ServerSubscription = {
|
import {
|
||||||
endpoint: string
|
subscribeToPushNotifications,
|
||||||
userAgent: string | null
|
unsubscribeFromPushNotifications,
|
||||||
}
|
parsePushSubscriptions,
|
||||||
|
isCurrentDeviceSubscribed,
|
||||||
|
supportsPushNotifications,
|
||||||
|
type SafeResult,
|
||||||
|
} from '../lib/client/clientPushNotifications'
|
||||||
|
|
||||||
/** Parse push subscriptions from string */
|
import {
|
||||||
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
enableBrowserNotifications,
|
||||||
try {
|
disableBrowserNotifications,
|
||||||
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
|
isBrowserNotificationsEnabled,
|
||||||
|
supportsBrowserNotifications,
|
||||||
|
} from '../lib/client/browserNotifications'
|
||||||
|
|
||||||
const subscriptions = JSON.parse(subscriptionsAsString)
|
async function setDataAttributes(banner: HTMLElement) {
|
||||||
|
if (!supportsPushNotifications() && !supportsBrowserNotifications()) {
|
||||||
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
|
return
|
||||||
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
|
|
||||||
throw new Error('Push subscriptions must be an array of objects')
|
|
||||||
}
|
|
||||||
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
|
|
||||||
throw new Error('Push subscriptions must be an array of objects with endpoint property')
|
|
||||||
}
|
|
||||||
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
|
|
||||||
throw new Error('Push subscriptions must be an array of objects with userAgent property')
|
|
||||||
}
|
|
||||||
|
|
||||||
return subscriptions as ServerSubscription[]
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse push subscriptions:', error)
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if current device has an active push subscription */
|
banner.dataset.notificationSupported = ''
|
||||||
async function getCurrentPushSubscription() {
|
banner.dataset.notificationPermissions = Notification.permission
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
|
||||||
if (!registration) return null
|
|
||||||
|
|
||||||
return await registration.pushManager.getSubscription()
|
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
||||||
} catch (error) {
|
const titleElement = banner.querySelector<HTMLElement>('[data-banner-title]')
|
||||||
console.error('Error getting current push subscription:', error)
|
const descriptionElement = banner.querySelector<HTMLElement>('[data-banner-description]')
|
||||||
return null
|
|
||||||
|
if (await isCurrentDeviceSubscribed(serverSubscriptions)) {
|
||||||
|
if (titleElement) titleElement.textContent = 'Push notifications enabled'
|
||||||
|
if (descriptionElement) descriptionElement.textContent = 'Turn push notifications off for this device?'
|
||||||
|
banner.dataset.isEnabled = ''
|
||||||
|
banner.dataset.loaded = ''
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if current subscription matches any server subscription */
|
if (isBrowserNotificationsEnabled()) {
|
||||||
function isCurrentDeviceSubscribed(
|
if (titleElement) titleElement.textContent = 'Browser notifications enabled'
|
||||||
currentSubscription: PushSubscription | null,
|
if (descriptionElement)
|
||||||
serverSubscriptions: ServerSubscription[]
|
descriptionElement.textContent = 'Turn off notifications? (They only work while the tab is open.)'
|
||||||
) {
|
banner.dataset.isEnabled = ''
|
||||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
banner.dataset.loaded = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const currentEndpoint = currentSubscription.endpoint
|
// Default state (disabled)
|
||||||
const currentUserAgent = navigator.userAgent
|
if (titleElement) titleElement.textContent = 'Turn on push notifications?'
|
||||||
|
if (descriptionElement) descriptionElement.textContent = 'Get notifications on this device.'
|
||||||
return serverSubscriptions.some(
|
banner.dataset.loaded = ''
|
||||||
(sub) =>
|
|
||||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('astro:page-load', async () => {
|
document.addEventListener('astro:page-load', async () => {
|
||||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
|
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
|
||||||
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
await setDataAttributes(banner)
|
||||||
const currentSubscription = await getCurrentPushSubscription()
|
|
||||||
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
|
|
||||||
|
|
||||||
if (isSubscribed) banner.dataset.isEnabled = ''
|
const vapidPublicKey = banner.dataset.vapidPublicKey
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
banner.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
||||||
/////////////////////////////////////////////////////////////
|
button.addEventListener('click', async (event) => {
|
||||||
// Script to handle push notification subscription. //
|
event.preventDefault()
|
||||||
/////////////////////////////////////////////////////////////
|
event.stopPropagation()
|
||||||
|
|
||||||
import type { actions } from 'astro:actions'
|
const action = button.dataset.pushAction
|
||||||
import type { ActionInput } from '../lib/astroActions'
|
if (action !== 'subscribe' && action !== 'unsubscribe') {
|
||||||
|
console.error('Invalid push action:', action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
/** Utility function to convert VAPID key */
|
let result: SafeResult
|
||||||
function urlB64ToUint8Array(base64String: string) {
|
|
||||||
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
|
|
||||||
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
|
||||||
const base64 = cleaned + padding
|
|
||||||
|
|
||||||
const rawData = window.atob(base64)
|
if (action === 'subscribe') {
|
||||||
const outputArray = new Uint8Array(rawData.length)
|
const pushResult = vapidPublicKey
|
||||||
|
? await subscribeToPushNotifications(vapidPublicKey)
|
||||||
|
: { success: false as const, error: 'VAPID public key not found' }
|
||||||
|
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
if (pushResult.success) {
|
||||||
outputArray[i] = rawData.charCodeAt(i)
|
result = pushResult
|
||||||
}
|
} else {
|
||||||
return outputArray
|
console.error(
|
||||||
}
|
"Can't enable push notifications, trying browser notifications.",
|
||||||
|
pushResult.error
|
||||||
|
)
|
||||||
|
const browserResult = await enableBrowserNotifications()
|
||||||
|
|
||||||
/** Check for browser support */
|
if (browserResult.success) {
|
||||||
function checkSupport() {
|
result = browserResult
|
||||||
const isSecure =
|
} else {
|
||||||
window.isSecureContext ||
|
console.error("Can't enable browser notifications:", browserResult.error)
|
||||||
window.location.hostname === 'localhost' ||
|
result = {
|
||||||
window.location.hostname === '127.0.0.1'
|
success: false,
|
||||||
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
error: `Can't enable either push or browser notifications. Push: ${pushResult.error}. Browser: ${browserResult.error}`,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pushResult = await unsubscribeFromPushNotifications()
|
||||||
|
const browserResult = disableBrowserNotifications()
|
||||||
|
|
||||||
async function registerServiceWorker() {
|
const success = pushResult.success || browserResult.success
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.register('/sw.js')
|
|
||||||
console.log('Service Worker registered:', registration)
|
|
||||||
|
|
||||||
const readyRegistration = await navigator.serviceWorker.ready
|
result = success
|
||||||
console.log('Service Worker is active and ready:', readyRegistration)
|
? { success: true }
|
||||||
|
: { success: false, error: `${pushResult.error} | ${browserResult.error}` }
|
||||||
|
}
|
||||||
|
|
||||||
return readyRegistration
|
if (!result.success) {
|
||||||
} catch (error) {
|
console.error(result.error)
|
||||||
console.error('Service Worker registration failed:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function subscribeToPush(vapidPublicKey: string) {
|
const errorMessageElement = banner.querySelector<HTMLElement>('[data-error-message]')
|
||||||
try {
|
if (errorMessageElement) {
|
||||||
if (!checkSupport()) return
|
errorMessageElement.textContent = `Failed to ${action === 'subscribe' ? 'enable' : 'disable'} notifications.`
|
||||||
|
}
|
||||||
|
|
||||||
// Request notification permission
|
return
|
||||||
const permission = await Notification.requestPermission()
|
}
|
||||||
if (permission !== 'granted') {
|
|
||||||
alert('Push notifications permission denied')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const registration = await registerServiceWorker()
|
window.location.reload()
|
||||||
|
})
|
||||||
// Subscribe to push manager
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
|
||||||
})
|
|
||||||
|
|
||||||
const p256dh = subscription.getKey('p256dh')
|
|
||||||
const auth = subscription.getKey('auth')
|
|
||||||
|
|
||||||
// Send subscription to server
|
|
||||||
const response = await fetch('/internal-api/notifications/subscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
|
||||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
|
||||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Push subscription successful')
|
|
||||||
|
|
||||||
// Reload page to update UI
|
|
||||||
window.location.reload()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Push subscription failed:', error)
|
|
||||||
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unsubscribeFromPush() {
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
|
||||||
if (!registration) {
|
|
||||||
console.log('No service worker registration found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = await registration.pushManager.getSubscription()
|
|
||||||
if (!subscription) {
|
|
||||||
console.log('No push subscription found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe from browser
|
|
||||||
await subscription.unsubscribe()
|
|
||||||
|
|
||||||
// Remove from server
|
|
||||||
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Push unsubscription successful')
|
|
||||||
|
|
||||||
// Reload page to update UI
|
|
||||||
window.location.reload()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Push unsubscription failed:', error)
|
|
||||||
alert('Failed to unsubscribe from push notifications')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('astro:page-load', () => {
|
|
||||||
const supportsPushNotifications = checkSupport()
|
|
||||||
if (supportsPushNotifications) {
|
|
||||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
|
|
||||||
element.dataset.supportsPushNotifications = ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
|
||||||
const vapidPublicKey = button.dataset.vapidPublicKey
|
|
||||||
if (!vapidPublicKey) {
|
|
||||||
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
button.addEventListener('click', async (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
const action = button.dataset.pushAction
|
|
||||||
if (action === 'subscribe') {
|
|
||||||
await subscribeToPush(vapidPublicKey)
|
|
||||||
} else if (action === 'unsubscribe') {
|
|
||||||
await unsubscribeFromPush()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
114
web/src/components/ServerEventsScript.astro
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
if (!Astro.locals.user) return
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import type { ServerEventsEvent, SSEEventMap } from '../lib/serverEventsTypes'
|
||||||
|
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 5
|
||||||
|
const RECONNECT_DELAY = 2_000
|
||||||
|
|
||||||
|
let eventSource: EventSource | null = null
|
||||||
|
let reconnectTimeout: number | null = null
|
||||||
|
let reconnectAttempts = 0
|
||||||
|
|
||||||
|
startServerEventsListener()
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function startServerEventsListener() {
|
||||||
|
stopServerEventsListener()
|
||||||
|
|
||||||
|
try {
|
||||||
|
eventSource = new EventSource('/internal-api/server-events')
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
// NOTE: Disable sse: events when user is not logged in
|
||||||
|
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||||
|
stopServerEventsListener()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data as string)
|
||||||
|
|
||||||
|
if (isServerEventsEvent(data)) {
|
||||||
|
const eventType = `sse:${data.type}` as const
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.error('Invalid server events event:', data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing server events data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('Server events error:', error)
|
||||||
|
|
||||||
|
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server events listener:', error)
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopServerEventsListener() {
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
reconnectTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close()
|
||||||
|
eventSource = null
|
||||||
|
console.info('Disconnected from server events listener')
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
console.error('Max reconnection attempts reached, giving up')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttempts)
|
||||||
|
reconnectAttempts++
|
||||||
|
|
||||||
|
const delayStr = String(delay)
|
||||||
|
const attemptsStr = String(reconnectAttempts)
|
||||||
|
const maxAttemptsStr = String(MAX_RECONNECT_ATTEMPTS)
|
||||||
|
console.info(`Attempting to reconnect in ${delayStr}ms (attempt ${attemptsStr}/${maxAttemptsStr})`)
|
||||||
|
|
||||||
|
reconnectTimeout = window.setTimeout(() => {
|
||||||
|
startServerEventsListener()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isServerEventsEvent(event: unknown): event is ServerEventsEvent {
|
||||||
|
if (typeof event !== 'object' || event === null) return false
|
||||||
|
const e = event as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
'type' in e &&
|
||||||
|
typeof e.type === 'string' &&
|
||||||
|
'data' in e &&
|
||||||
|
typeof e.data === 'object' &&
|
||||||
|
e.data !== null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
59
web/src/components/ServiceWorkerScript.astro
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
|
||||||
|
|
||||||
|
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
|
||||||
|
|
||||||
|
let hasPendingUpdate = false
|
||||||
|
|
||||||
|
const updateSW = registerSW({
|
||||||
|
immediate: true,
|
||||||
|
onRegisteredSW: (_swScriptUrl, registration) => {
|
||||||
|
if (registration) window.__SW_REGISTRATION__ = registration
|
||||||
|
|
||||||
|
document.addEventListener('astro:after-swap', checkAndApplyPendingUpdate, { passive: true })
|
||||||
|
window.addEventListener('popstate', checkAndApplyPendingUpdate, { passive: true })
|
||||||
|
},
|
||||||
|
onNeedRefresh: () => {
|
||||||
|
if (shouldSkipAutoReload()) {
|
||||||
|
void updateSW(false)
|
||||||
|
hasPendingUpdate = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateSW(true)
|
||||||
|
},
|
||||||
|
onRegisterError: (error) => {
|
||||||
|
console.error('Service Worker registration error', error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function shouldSkipAutoReload() {
|
||||||
|
const currentPath = window.location.pathname
|
||||||
|
const isErrorPage = document.body.hasAttribute('data-is-error-page')
|
||||||
|
|
||||||
|
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAndApplyPendingUpdate() {
|
||||||
|
if (hasPendingUpdate && !shouldSkipAutoReload()) {
|
||||||
|
hasPendingUpdate = false
|
||||||
|
void updateSW(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('astro:page-load', async () => {
|
||||||
|
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||||
|
await unsubscribeFromPushNotifications()
|
||||||
|
window.__SW_REGISTRATION__?.unregister()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { differenceInDays, isPast } from 'date-fns'
|
|
||||||
|
|
||||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||||
|
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
|
import { formatDaysAgo } from '../lib/timeAgo'
|
||||||
import TimeFormatted from './TimeFormatted.astro'
|
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
const RECENTLY_ADDED_DAYS = 7
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
service: Prisma.ServiceGetPayload<{
|
service: Prisma.ServiceGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
verificationStatus: true
|
verificationStatus: true
|
||||||
verificationProofMd: true
|
verificationProofMd: true
|
||||||
verificationSummary: true
|
verificationSummary: true
|
||||||
listedAt: true
|
approvedAt: true
|
||||||
|
isRecentlyApproved: true
|
||||||
createdAt: true
|
createdAt: true
|
||||||
verificationSteps: {
|
verificationSteps: {
|
||||||
select: {
|
select: {
|
||||||
@@ -29,9 +27,6 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { service } = Astro.props
|
const { service } = Astro.props
|
||||||
|
|
||||||
const listedDate = service.listedAt ?? service.createdAt
|
|
||||||
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
|
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -66,10 +61,10 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : wasRecentlyAdded ? (
|
) : service.isRecentlyApproved ? (
|
||||||
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
|
||||||
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
|
This service was approved
|
||||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
{service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
|
||||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||||
caution.
|
caution.
|
||||||
<a
|
<a
|
||||||
@@ -86,7 +81,7 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
|||||||
Basic checks passed, but not fully verified.
|
Basic checks passed, but not fully verified.
|
||||||
<a
|
<a
|
||||||
href="/about#suggestion-review-process"
|
href="/about#suggestion-review-process"
|
||||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</a>
|
</a>
|
||||||
@@ -98,14 +93,29 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
|||||||
{
|
{
|
||||||
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
||||||
service.verificationSteps.some((step) => step.status === 'FAILED') && (
|
service.verificationSteps.some((step) => step.status === 'FAILED') && (
|
||||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
<a
|
||||||
|
href="#verification"
|
||||||
|
class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400 transition-colors hover:bg-red-900/60"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
|
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
|
||||||
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
|
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>Some verification steps failed. Please review the details below.</span>
|
||||||
This service has failed one or more verification steps. Review the verification details carefully.
|
</a>
|
||||||
</span>
|
)
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
service.verificationStatus !== 'VERIFICATION_FAILED' &&
|
||||||
|
!service.verificationSteps.some((step) => step.status === 'FAILED') &&
|
||||||
|
service.verificationSteps.some((step) => step.status === 'WARNING') && (
|
||||||
|
<a
|
||||||
|
href="#verification"
|
||||||
|
class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200 transition-colors hover:bg-yellow-600/40"
|
||||||
|
>
|
||||||
|
<Icon name={verificationStepStatusesByValue.WARNING.icon} class={cn('size-5 text-yellow-400')} />
|
||||||
|
<span>Some verification steps are marked as warnings.</span>
|
||||||
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,3 +108,19 @@ export const {
|
|||||||
},
|
},
|
||||||
] as const satisfies AttributeTypeInfo<AttributeType>[]
|
] as const satisfies AttributeTypeInfo<AttributeType>[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const baseScoreType = {
|
||||||
|
value: 'BASE_SCORE',
|
||||||
|
slug: 'base-score',
|
||||||
|
label: 'Base score',
|
||||||
|
icon: 'ri:information-line',
|
||||||
|
order: 5,
|
||||||
|
classNames: {
|
||||||
|
container: 'bg-night-500',
|
||||||
|
subcontainer: '',
|
||||||
|
text: 'text-day-200',
|
||||||
|
textLight: '',
|
||||||
|
icon: '',
|
||||||
|
button: '',
|
||||||
|
},
|
||||||
|
} as const satisfies AttributeTypeInfo
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
import type { TailwindColor } from '../lib/colors'
|
||||||
import type { CommentStatus } from '@prisma/client'
|
import type { CommentStatus } from '@prisma/client'
|
||||||
import type { ComponentProps } from 'astro/types'
|
|
||||||
|
|
||||||
type CommentStatusInfo<T extends string | null | undefined = string> = {
|
type CommentStatusInfo<T extends string | null | undefined = string> = {
|
||||||
id: T
|
id: T
|
||||||
icon: string
|
icon: string
|
||||||
label: string
|
label: string
|
||||||
color: ComponentProps<typeof BadgeSmall>['color']
|
color: TailwindColor
|
||||||
creativeWorkStatus: string | undefined
|
creativeWorkStatus: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { transformCase } from '../lib/strings'
|
|||||||
|
|
||||||
import { commentStatusById } from './commentStatus'
|
import { commentStatusById } from './commentStatus'
|
||||||
|
|
||||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
import type { TailwindColor } from '../lib/colors'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { ComponentProps } from 'astro/types'
|
|
||||||
|
|
||||||
type CommentStatusFilterInfo<T extends string | null | undefined = string> = {
|
type CommentStatusFilterInfo<T extends string | null | undefined = string> = {
|
||||||
value: T
|
value: T
|
||||||
label: string
|
label: string
|
||||||
color: ComponentProps<typeof BadgeSmall>['color']
|
color: TailwindColor
|
||||||
icon: string
|
icon: string
|
||||||
whereClause: Prisma.CommentWhereInput
|
whereClause: Prisma.CommentWhereInput
|
||||||
classNames: {
|
classNames: {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const {
|
|||||||
type: 'matrix',
|
type: 'matrix',
|
||||||
label: 'Matrix',
|
label: 'Matrix',
|
||||||
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||||
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
formatter: ([, value]) => value ?? 'Matrix',
|
||||||
icon: 'ri:hashtag',
|
icon: 'ri:hashtag',
|
||||||
urlType: 'url',
|
urlType: 'url',
|
||||||
},
|
},
|
||||||
@@ -121,7 +121,7 @@ export const {
|
|||||||
{
|
{
|
||||||
type: 'simplex',
|
type: 'simplex',
|
||||||
label: 'SimpleX Chat',
|
label: 'SimpleX Chat',
|
||||||
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
|
matcher: /^https?:\/\/(?:www\.)?((?:simplex\.chat|smp\d+\.simplex\.im))\//,
|
||||||
formatter: () => 'SimpleX Chat',
|
formatter: () => 'SimpleX Chat',
|
||||||
icon: 'simplex',
|
icon: 'simplex',
|
||||||
urlType: 'url',
|
urlType: 'url',
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
import type { TailwindColor } from '../lib/colors'
|
||||||
import type { EventType } from '@prisma/client'
|
import type { EventType } from '@prisma/client'
|
||||||
import type { ComponentProps } from 'astro/types'
|
|
||||||
|
|
||||||
type EventTypeInfo<T extends string | null | undefined = string> = {
|
type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||||
id: T
|
id: T
|
||||||
@@ -12,9 +11,10 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
|||||||
description: string
|
description: string
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: string
|
dot: string
|
||||||
|
banner?: string
|
||||||
}
|
}
|
||||||
icon: string
|
icon: string
|
||||||
color: ComponentProps<typeof BadgeSmall>['color']
|
color: TailwindColor
|
||||||
isSolved: boolean
|
isSolved: boolean
|
||||||
showBanner: boolean
|
showBanner: boolean
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,7 @@ export const {
|
|||||||
description: '',
|
description: '',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||||
|
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
|
||||||
},
|
},
|
||||||
icon: 'ri:question-fill',
|
icon: 'ri:question-fill',
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
@@ -49,6 +50,7 @@ export const {
|
|||||||
description: 'Potential issues that users should be aware of',
|
description: 'Potential issues that users should be aware of',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||||
|
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
|
||||||
},
|
},
|
||||||
icon: 'ri:alert-fill',
|
icon: 'ri:alert-fill',
|
||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
@@ -62,6 +64,7 @@ export const {
|
|||||||
description: 'A previously reported warning has been solved',
|
description: 'A previously reported warning has been solved',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||||
|
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
|
||||||
},
|
},
|
||||||
icon: 'ri:alert-fill',
|
icon: 'ri:alert-fill',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
@@ -75,6 +78,7 @@ export const {
|
|||||||
description: 'Critical issues affecting service functionality',
|
description: 'Critical issues affecting service functionality',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||||
|
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
|
||||||
},
|
},
|
||||||
icon: 'ri:spam-fill',
|
icon: 'ri:spam-fill',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
@@ -88,6 +92,7 @@ export const {
|
|||||||
description: 'A previously reported alert has been solved',
|
description: 'A previously reported alert has been solved',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||||
|
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
|
||||||
},
|
},
|
||||||
icon: 'ri:spam-fill',
|
icon: 'ri:spam-fill',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
@@ -101,6 +106,7 @@ export const {
|
|||||||
description: 'General information about the service',
|
description: 'General information about the service',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
|
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
|
||||||
|
banner: 'bg-blue-900/50 text-blue-300 hover:bg-blue-800/60 focus-visible:bg-blue-800/60',
|
||||||
},
|
},
|
||||||
icon: 'ri:information-fill',
|
icon: 'ri:information-fill',
|
||||||
color: 'sky',
|
color: 'sky',
|
||||||
@@ -114,6 +120,7 @@ export const {
|
|||||||
description: 'Regular service update or announcement',
|
description: 'Regular service update or announcement',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||||
|
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
|
||||||
},
|
},
|
||||||
icon: 'ri:notification-fill',
|
icon: 'ri:notification-fill',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
@@ -127,6 +134,7 @@ export const {
|
|||||||
description: 'Service details were updated on kycnot.me',
|
description: 'Service details were updated on kycnot.me',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
|
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
|
||||||
|
banner: 'bg-sky-900/50 text-sky-300 hover:bg-sky-800/60 focus-visible:bg-sky-800/60',
|
||||||
},
|
},
|
||||||
icon: 'ri:pencil-fill',
|
icon: 'ri:pencil-fill',
|
||||||
color: 'sky',
|
color: 'sky',
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type { KycLevelClarification } from '@prisma/client'
|
import type { AttributeType, KycLevelClarification } from '@prisma/client'
|
||||||
|
|
||||||
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
|
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
|
||||||
value: T
|
value: T
|
||||||
|
slug: string
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
icon: string
|
icon: string
|
||||||
|
privacyPoints: number
|
||||||
|
attributeType: AttributeType
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -18,22 +21,31 @@ export const {
|
|||||||
'value',
|
'value',
|
||||||
(value): KycLevelClarificationInfo<typeof value> => ({
|
(value): KycLevelClarificationInfo<typeof value> => ({
|
||||||
value,
|
value,
|
||||||
|
slug: value ? value.toLowerCase().replace('_', '-') : '',
|
||||||
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||||
description: '',
|
description: '',
|
||||||
icon: 'ri:question-line',
|
icon: 'ri:question-line',
|
||||||
|
privacyPoints: 0,
|
||||||
|
attributeType: 'INFO',
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
value: 'NONE',
|
value: 'NONE',
|
||||||
|
slug: 'none',
|
||||||
label: 'None',
|
label: 'None',
|
||||||
description: 'No clarification needed.',
|
description: 'No clarification needed.',
|
||||||
icon: 'ri:file-copy-line',
|
icon: 'ri:file-copy-line',
|
||||||
|
privacyPoints: 0,
|
||||||
|
attributeType: 'INFO',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'DEPENDS_ON_PARTNERS',
|
value: 'DEPENDS_ON_PARTNERS',
|
||||||
|
slug: 'depends-on-partners',
|
||||||
label: 'Depends on partners',
|
label: 'Depends on partners',
|
||||||
description: 'May vary across partners.',
|
description: 'May vary across partners.',
|
||||||
icon: 'ri:share-forward-line',
|
icon: 'ri:share-forward-line',
|
||||||
|
privacyPoints: -5,
|
||||||
|
attributeType: 'WARNING',
|
||||||
},
|
},
|
||||||
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
|
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
|||||||
import { parseIntWithFallback } from '../lib/numbers'
|
import { parseIntWithFallback } from '../lib/numbers'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
|
import type { AttributeType } from '@prisma/client'
|
||||||
|
|
||||||
type KycLevelInfo<T extends string | null | undefined = string> = {
|
type KycLevelInfo<T extends string | null | undefined = string> = {
|
||||||
id: T
|
id: T
|
||||||
value: number
|
value: number
|
||||||
icon: string
|
icon: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
privacyPoints: number
|
||||||
|
attributeType: AttributeType
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -22,6 +26,8 @@ export const {
|
|||||||
icon: 'diamond-question',
|
icon: 'diamond-question',
|
||||||
name: `KYC ${id ? transformCase(id, 'title') : String(id)}`,
|
name: `KYC ${id ? transformCase(id, 'title') : String(id)}`,
|
||||||
description: '',
|
description: '',
|
||||||
|
privacyPoints: 0,
|
||||||
|
attributeType: 'INFO',
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -30,6 +36,8 @@ export const {
|
|||||||
icon: 'anonymous-mask',
|
icon: 'anonymous-mask',
|
||||||
name: 'Guaranteed no KYC',
|
name: 'Guaranteed no KYC',
|
||||||
description: 'Terms explicitly state KYC will never be requested.',
|
description: 'Terms explicitly state KYC will never be requested.',
|
||||||
|
privacyPoints: 25,
|
||||||
|
attributeType: 'GOOD',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
@@ -37,6 +45,8 @@ export const {
|
|||||||
icon: 'diamond-question',
|
icon: 'diamond-question',
|
||||||
name: 'No KYC mention',
|
name: 'No KYC mention',
|
||||||
description: 'No mention of current or future KYC requirements.',
|
description: 'No mention of current or future KYC requirements.',
|
||||||
|
privacyPoints: 15,
|
||||||
|
attributeType: 'GOOD',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -45,6 +55,8 @@ export const {
|
|||||||
name: 'KYC on authorities request',
|
name: 'KYC on authorities request',
|
||||||
description:
|
description:
|
||||||
'No routine KYC, but may cooperate with authorities, block funds or implement future KYC requirements.',
|
'No routine KYC, but may cooperate with authorities, block funds or implement future KYC requirements.',
|
||||||
|
privacyPoints: -5,
|
||||||
|
attributeType: 'WARNING',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@@ -52,6 +64,8 @@ export const {
|
|||||||
icon: 'gun',
|
icon: 'gun',
|
||||||
name: 'Shotgun KYC',
|
name: 'Shotgun KYC',
|
||||||
description: 'May request KYC and block funds based on automated triggers.',
|
description: 'May request KYC and block funds based on automated triggers.',
|
||||||
|
privacyPoints: -15,
|
||||||
|
attributeType: 'WARNING',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
@@ -59,6 +73,8 @@ export const {
|
|||||||
icon: 'fingerprint-detailed',
|
icon: 'fingerprint-detailed',
|
||||||
name: 'Mandatory KYC',
|
name: 'Mandatory KYC',
|
||||||
description: 'Required for key features and can be required arbitrarily at any time.',
|
description: 'Required for key features and can be required arbitrarily at any time.',
|
||||||
|
privacyPoints: -25,
|
||||||
|
attributeType: 'BAD',
|
||||||
},
|
},
|
||||||
] as const satisfies KycLevelInfo<'0' | '1' | '2' | '3' | '4'>[]
|
] as const satisfies KycLevelInfo<'0' | '1' | '2' | '3' | '4'>[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export const {
|
|||||||
label: 'New comment/rating',
|
label: 'New comment/rating',
|
||||||
icon: 'ri:chat-4-line',
|
icon: 'ri:chat-4-line',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'SUGGESTION_CREATED',
|
||||||
|
label: 'New suggestion',
|
||||||
|
icon: 'ri:lightbulb-line',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'SUGGESTION_MESSAGE',
|
id: 'SUGGESTION_MESSAGE',
|
||||||
label: 'New message in suggestion',
|
label: 'New message in suggestion',
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
import type { TailwindColor } from '../lib/colors'
|
||||||
import type { ServiceSuggestionType } from '@prisma/client'
|
import type { ServiceSuggestionType } from '@prisma/client'
|
||||||
import type { ComponentProps } from 'astro/types'
|
|
||||||
|
|
||||||
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
||||||
value: T
|
value: T
|
||||||
slug: string
|
slug: string
|
||||||
label: string
|
label: string
|
||||||
|
labelAlt: string
|
||||||
icon: string
|
icon: string
|
||||||
order: number
|
order: number
|
||||||
default: boolean
|
default: boolean
|
||||||
color: ComponentProps<typeof BadgeSmall>['color']
|
color: TailwindColor
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -34,12 +34,14 @@ export const {
|
|||||||
order: Infinity,
|
order: Infinity,
|
||||||
default: false,
|
default: false,
|
||||||
color: 'zinc',
|
color: 'zinc',
|
||||||
|
labelAlt: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
value: 'CREATE_SERVICE',
|
value: 'CREATE_SERVICE',
|
||||||
slug: 'create',
|
slug: 'create',
|
||||||
label: 'Create',
|
label: 'Create',
|
||||||
|
labelAlt: 'service',
|
||||||
icon: 'ri:add-line',
|
icon: 'ri:add-line',
|
||||||
order: 1,
|
order: 1,
|
||||||
default: true,
|
default: true,
|
||||||
@@ -49,6 +51,7 @@ export const {
|
|||||||
value: 'EDIT_SERVICE',
|
value: 'EDIT_SERVICE',
|
||||||
slug: 'edit',
|
slug: 'edit',
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
labelAlt: 'edit',
|
||||||
icon: 'ri:pencil-line',
|
icon: 'ri:pencil-line',
|
||||||
order: 2,
|
order: 2,
|
||||||
default: false,
|
default: false,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
import type { TailwindColor } from '../lib/colors'
|
||||||
import type { ServiceUserRole } from '@prisma/client'
|
import type { ServiceUserRole } from '@prisma/client'
|
||||||
import type { ComponentProps } from 'astro/types'
|
|
||||||
|
|
||||||
type ServiceUserRoleInfo<T extends string | null | undefined = string> = {
|
type ServiceUserRoleInfo<T extends string | null | undefined = string> = {
|
||||||
value: T
|
value: T
|
||||||
@@ -11,7 +10,7 @@ type ServiceUserRoleInfo<T extends string | null | undefined = string> = {
|
|||||||
label: string
|
label: string
|
||||||
icon: string
|
icon: string
|
||||||
order: number
|
order: number
|
||||||
color: NonNullable<ComponentProps<typeof BadgeSmall>['color']>
|
color: NonNullable<TailwindColor>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
import type { TailwindColor } from '../lib/colors'
|
||||||
import type { VerificationStepStatus } from '@prisma/client'
|
import type { VerificationStepStatus } from '@prisma/client'
|
||||||
import type { ComponentProps } from 'astro/types'
|
|
||||||
|
|
||||||
type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
|
type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
|
||||||
value: T
|
value: T
|
||||||
label: string
|
label: string
|
||||||
icon: string
|
icon: string
|
||||||
color: ComponentProps<typeof BadgeSmall>['color']
|
color: TailwindColor
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -43,6 +42,12 @@ export const {
|
|||||||
icon: 'ri:alert-line',
|
icon: 'ri:alert-line',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'WARNING',
|
||||||
|
label: 'Warning',
|
||||||
|
icon: 'ri:alert-line',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'PENDING',
|
value: 'PENDING',
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
|
|||||||
4
web/src/env.d.ts
vendored
@@ -1,6 +1,7 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
||||||
|
|
||||||
import type { ErrorBanners } from './lib/errorBanners'
|
import type { ErrorBanners } from './lib/errorBanners'
|
||||||
import type { KarmaUnlocks } from './lib/karmaUnlocks'
|
import type { KarmaUnlocks } from './lib/karmaUnlocks'
|
||||||
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type * as htmx from 'htmx.org'
|
import type * as htmx from 'htmx.org'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ declare global {
|
|||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
htmx?: typeof htmx
|
htmx?: typeof htmx
|
||||||
|
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace PrismaJson {
|
namespace PrismaJson {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type Props = ComponentProps<typeof BaseHead> & {
|
|||||||
children: AstroChildren
|
children: AstroChildren
|
||||||
errors?: string[]
|
errors?: string[]
|
||||||
success?: string[]
|
success?: string[]
|
||||||
className?: {
|
classNames?: {
|
||||||
body?: string
|
body?: string
|
||||||
main?: string
|
main?: string
|
||||||
footer?: string
|
footer?: string
|
||||||
@@ -31,14 +31,16 @@ type Props = ComponentProps<typeof BaseHead> & {
|
|||||||
| 'max-w-screen-sm'
|
| 'max-w-screen-sm'
|
||||||
| 'max-w-screen-xl'
|
| 'max-w-screen-xl'
|
||||||
| 'max-w-screen-xs'
|
| 'max-w-screen-xs'
|
||||||
|
isErrorPage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
errors = [],
|
errors = [],
|
||||||
success = [],
|
success = [],
|
||||||
className,
|
classNames,
|
||||||
widthClassName = 'max-w-screen-2xl',
|
widthClassName = 'max-w-screen-2xl',
|
||||||
showSplashText,
|
showSplashText,
|
||||||
|
isErrorPage,
|
||||||
...baseHeadProps
|
...baseHeadProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
@@ -77,7 +79,11 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<BaseHead {...baseHeadProps} />
|
<BaseHead {...baseHeadProps} />
|
||||||
</head>
|
</head>
|
||||||
<body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}>
|
<body
|
||||||
|
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
|
||||||
|
data-is-error-page={isErrorPage ? '' : undefined}
|
||||||
|
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
|
||||||
|
>
|
||||||
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||||
<Header
|
<Header
|
||||||
classNames={{
|
classNames={{
|
||||||
@@ -116,7 +122,7 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
<main
|
<main
|
||||||
class={cn(
|
class={cn(
|
||||||
'container mx-auto mt-4 mb-12 grow px-4',
|
'container mx-auto mt-4 mb-12 grow px-4',
|
||||||
className?.main,
|
classNames?.main,
|
||||||
(widthClassName === 'max-w-none' || widthClassName === 'max-w-screen-2xl') && 'lg:px-8 2xl:px-12',
|
(widthClassName === 'max-w-none' || widthClassName === 'max-w-screen-2xl') && 'lg:px-8 2xl:px-12',
|
||||||
widthClassName
|
widthClassName
|
||||||
)}
|
)}
|
||||||
@@ -124,6 +130,6 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer class={className?.footer} />
|
<Footer class={classNames?.footer} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../components/OgImage'
|
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../components/OgImage'
|
||||||
|
import TimeFormatted from '../components/TimeFormatted.astro'
|
||||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||||
|
|
||||||
import BaseLayout from './BaseLayout.astro'
|
import BaseLayout from './BaseLayout.astro'
|
||||||
@@ -13,21 +14,19 @@ type Props = ComponentProps<typeof BaseLayout> &
|
|||||||
MarkdownLayoutProps<{
|
MarkdownLayoutProps<{
|
||||||
children: AstroChildren
|
children: AstroChildren
|
||||||
title: string
|
title: string
|
||||||
author: string
|
updatedAt?: string
|
||||||
pubDate: string
|
|
||||||
description: string
|
description: string
|
||||||
icon?: string
|
icon?: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
const { frontmatter, schemas, ...baseLayoutProps } = Astro.props
|
const { frontmatter, schemas, ...baseLayoutProps } = Astro.props
|
||||||
const publishDate = frontmatter.pubDate ? new Date(frontmatter.pubDate) : null
|
const publishDate = frontmatter.updatedAt ? new Date(frontmatter.updatedAt) : null
|
||||||
const ogImageTemplateData = {
|
const ogImageTemplateData = {
|
||||||
template: 'generic',
|
template: 'generic',
|
||||||
title: frontmatter.title,
|
title: frontmatter.title,
|
||||||
description: frontmatter.description,
|
description: frontmatter.description,
|
||||||
icon: frontmatter.icon,
|
icon: frontmatter.icon,
|
||||||
} satisfies OgImageAllTemplatesWithProps
|
} satisfies OgImageAllTemplatesWithProps
|
||||||
const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -44,14 +43,7 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
|||||||
datePublished: publishDate?.toISOString(),
|
datePublished: publishDate?.toISOString(),
|
||||||
dateModified: publishDate?.toISOString(),
|
dateModified: publishDate?.toISOString(),
|
||||||
image: makeOgImageUrl(ogImageTemplateData, Astro.url),
|
image: makeOgImageUrl(ogImageTemplateData, Astro.url),
|
||||||
author: frontmatter.author
|
author: KYCNOTME_SCHEMA_MINI,
|
||||||
? weAreAuthor
|
|
||||||
? KYCNOTME_SCHEMA_MINI
|
|
||||||
: {
|
|
||||||
'@type': 'Person',
|
|
||||||
name: frontmatter.author,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
publisher: KYCNOTME_SCHEMA_MINI,
|
publisher: KYCNOTME_SCHEMA_MINI,
|
||||||
mainEntityOfPage: {
|
mainEntityOfPage: {
|
||||||
'@type': 'WebPage',
|
'@type': 'WebPage',
|
||||||
@@ -61,15 +53,26 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
|
|||||||
...(schemas ?? []),
|
...(schemas ?? []),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
<div class="bg-dots-fade absolute inset-x-0 top-0 -z-1 h-128 opacity-15"></div>
|
||||||
<div
|
<div
|
||||||
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
|
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
|
||||||
>
|
>
|
||||||
<h1>{frontmatter.title}</h1>
|
<h1 class="mb-0!">{frontmatter.title}</h1>
|
||||||
<p class="text-gray-500">
|
<p class="mt-2! opacity-70">
|
||||||
{frontmatter.author && `by ${frontmatter.author}`}
|
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />}
|
||||||
{frontmatter.pubDate && ` | ${new Date(frontmatter.pubDate).toLocaleDateString()}`}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-dots-fade {
|
||||||
|
background:
|
||||||
|
radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space,
|
||||||
|
linear-gradient(to bottom, #888, #fff);
|
||||||
|
background-blend-mode: multiply;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
filter: contrast(21);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -9,20 +9,30 @@ import type { ComponentProps } from 'astro/types'
|
|||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
||||||
layoutHeader: { icon: string; title: string; subtitle?: string }
|
layoutHeader: { icon: string; title: string; subtitle?: string }
|
||||||
|
size?: 'md' | 'xs'
|
||||||
}
|
}
|
||||||
|
|
||||||
const { layoutHeader, ...baseLayoutProps } = Astro.props
|
const { layoutHeader, size = 'xs', ...baseLayoutProps } = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
className={{
|
classNames={{
|
||||||
...baseLayoutProps.className,
|
main: cn(
|
||||||
main: cn('xs:items-center-safe flex grow flex-col justify-center-safe', baseLayoutProps.className?.main),
|
'flex grow flex-col justify-center-safe',
|
||||||
|
{
|
||||||
|
'xs:items-center-safe': size === 'xs',
|
||||||
|
'md:items-center-safe': size === 'md',
|
||||||
|
},
|
||||||
|
baseLayoutProps.classNames?.main
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
{...baseLayoutProps}
|
{...baseLayoutProps}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-night-800 border-night-500 xs:block xs:max-w-screen-xs contents w-full rounded-xl border p-8"
|
class={cn('bg-night-800 border-night-500 contents w-full rounded-xl border p-8', {
|
||||||
|
'xs:block xs:max-w-screen-xs': size === 'xs',
|
||||||
|
'md:block md:max-w-screen-md': size === 'md',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<div class="bg-day-200 mx-auto flex size-12 items-center justify-center rounded-lg">
|
<div class="bg-day-200 mx-auto flex size-12 items-center justify-center rounded-lg">
|
||||||
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
||||||
@@ -31,14 +41,24 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
|
|||||||
<h1
|
<h1
|
||||||
class={cn(
|
class={cn(
|
||||||
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
|
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
|
||||||
!layoutHeader.subtitle && 'xs:mb-8 mb-6'
|
!layoutHeader.subtitle && {
|
||||||
|
'xs:mb-8 mb-6': size === 'xs',
|
||||||
|
'mb-6 md:mb-8': size === 'md',
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{layoutHeader.title}
|
{layoutHeader.title}
|
||||||
</h1>
|
</h1>
|
||||||
{
|
{
|
||||||
!!layoutHeader.subtitle && (
|
!!layoutHeader.subtitle && (
|
||||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
<p
|
||||||
|
class={cn('text-day-500 mt-1 mb-6 text-center', {
|
||||||
|
'xs:mb-8': size === 'xs',
|
||||||
|
'md:mb-8': size === 'md',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{layoutHeader.subtitle}
|
||||||
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,261 @@ import { orderBy } from 'lodash-es'
|
|||||||
|
|
||||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||||
|
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
|
||||||
|
import { kycLevels } from '../constants/kycLevels'
|
||||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||||
|
|
||||||
import { formatDateShort } from './timeAgo'
|
import { formatDaysAgo } from './timeAgo'
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
type NonDbAttribute = Prisma.AttributeGetPayload<{
|
||||||
|
select: {
|
||||||
|
title: true
|
||||||
|
type: true
|
||||||
|
category: true
|
||||||
|
description: true
|
||||||
|
privacyPoints: true
|
||||||
|
trustPoints: true
|
||||||
|
}
|
||||||
|
}> & {
|
||||||
|
slug: string
|
||||||
|
links: {
|
||||||
|
url: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type NonDbAttributeFull = NonDbAttribute & {
|
||||||
|
customize: (
|
||||||
|
service: Prisma.ServiceGetPayload<{
|
||||||
|
select: {
|
||||||
|
verificationStatus: true
|
||||||
|
serviceVisibility: true
|
||||||
|
isRecentlyApproved: true
|
||||||
|
approvedAt: true
|
||||||
|
createdAt: true
|
||||||
|
tosReviewAt: true
|
||||||
|
tosReview: true
|
||||||
|
onionUrls: true
|
||||||
|
i2pUrls: true
|
||||||
|
acceptedCurrencies: true
|
||||||
|
kycLevel: true
|
||||||
|
kycLevelClarification: true
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||||
|
{
|
||||||
|
slug: 'verification-verified',
|
||||||
|
title: 'Verified',
|
||||||
|
type: 'GOOD',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||||
|
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
|
||||||
|
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?verification=verified',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
|
||||||
|
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verification-approved',
|
||||||
|
title: 'Approved',
|
||||||
|
type: 'INFO',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||||
|
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
|
||||||
|
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?verification=verified&verification=approved',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.verificationStatus === 'APPROVED',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verification-community-contributed',
|
||||||
|
title: 'Community contributed',
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||||
|
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
|
||||||
|
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?verification=community',
|
||||||
|
label: 'With this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/?verification=verified&verification=approved',
|
||||||
|
label: 'Without this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verification-scam',
|
||||||
|
title: 'Is SCAM',
|
||||||
|
type: 'BAD',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||||
|
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
|
||||||
|
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?verification=scam',
|
||||||
|
label: 'With this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/?verification=verified&verification=approved',
|
||||||
|
label: 'Without this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.verificationStatus === 'VERIFICATION_FAILED',
|
||||||
|
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
...kycLevels.map<NonDbAttributeFull>((kycLevel) => ({
|
||||||
|
slug: `kyc-level-${kycLevel.id}`,
|
||||||
|
title: kycLevel.name,
|
||||||
|
type: kycLevel.attributeType,
|
||||||
|
category: 'PRIVACY',
|
||||||
|
description: kycLevel.description,
|
||||||
|
privacyPoints: kycLevel.privacyPoints,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: `/?max-kyc=${kycLevel.id}`,
|
||||||
|
label: 'With this or better',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.kycLevel === kycLevel.value,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
...kycLevelClarifications
|
||||||
|
.filter((clarification) => clarification.value !== 'NONE')
|
||||||
|
.map<NonDbAttributeFull>((clarification) => ({
|
||||||
|
slug: `kyc-clarification-${clarification.slug}`,
|
||||||
|
title: `KYC ${clarification.label}`,
|
||||||
|
type: clarification.attributeType,
|
||||||
|
category: 'PRIVACY',
|
||||||
|
description: clarification.description,
|
||||||
|
privacyPoints: clarification.privacyPoints,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.kycLevelClarification === clarification.value,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
slug: 'archived',
|
||||||
|
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.serviceVisibility === 'ARCHIVED',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'recently-approved',
|
||||||
|
title: 'Recently approved',
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: 'Approved on KYCnot.me less than 15 days ago. Proceed with caution.',
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: -5,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.isRecentlyApproved,
|
||||||
|
description: `Approved on KYCnot.me less than 15 days ago${service.approvedAt ? ` (${formatDaysAgo(service.approvedAt)})` : ''}. Proceed with caution.`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'cannot-analyse-tos',
|
||||||
|
title: "Can't analyse ToS",
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description:
|
||||||
|
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: -3,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.tosReviewAt !== null && service.tosReview === null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'has-onion-or-i2p-urls',
|
||||||
|
title: 'Has Onion or I2P URLs',
|
||||||
|
type: 'GOOD',
|
||||||
|
category: 'PRIVACY',
|
||||||
|
description: 'Onion (Tor) and I2P URLs enhance privacy and anonymity.',
|
||||||
|
privacyPoints: 5,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?networks=onion&networks=i2p',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.onionUrls.length > 0 || service.i2pUrls.length > 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'monero-accepted',
|
||||||
|
title: 'Accepts Monero',
|
||||||
|
type: 'GOOD',
|
||||||
|
category: 'PRIVACY',
|
||||||
|
description:
|
||||||
|
'This service accepts Monero, a privacy-focused cryptocurrency that provides enhanced anonymity.',
|
||||||
|
privacyPoints: 5,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?currency=monero',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.acceptedCurrencies.includes('MONERO'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function sortAttributes<
|
export function sortAttributes<
|
||||||
T extends Prisma.AttributeGetPayload<{
|
T extends Prisma.AttributeGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
@@ -34,131 +282,15 @@ export function sortAttributes<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function makeNonDbAttributes(
|
export function makeNonDbAttributes(
|
||||||
service: Prisma.ServiceGetPayload<{
|
service: Parameters<NonDbAttributeFull['customize']>[0],
|
||||||
select: {
|
|
||||||
verificationStatus: true
|
|
||||||
serviceVisibility: true
|
|
||||||
isRecentlyListed: true
|
|
||||||
listedAt: true
|
|
||||||
createdAt: true
|
|
||||||
}
|
|
||||||
}>,
|
|
||||||
{ filter = false }: { filter?: boolean } = {}
|
{ filter = false }: { filter?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const nonDbAttributes: (Prisma.AttributeGetPayload<{
|
const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({
|
||||||
select: {
|
...attribute,
|
||||||
title: true
|
...customize(service),
|
||||||
type: true
|
}))
|
||||||
category: true
|
|
||||||
description: true
|
|
||||||
privacyPoints: true
|
|
||||||
trustPoints: true
|
|
||||||
}
|
|
||||||
}> & {
|
|
||||||
show: boolean
|
|
||||||
links: {
|
|
||||||
url: string
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
}[]
|
|
||||||
})[] = [
|
|
||||||
{
|
|
||||||
title: 'Verified',
|
|
||||||
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
|
|
||||||
type: 'GOOD',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
|
||||||
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
|
|
||||||
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
url: '/?verification=verified',
|
|
||||||
label: 'Search with this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Approved',
|
|
||||||
show: service.verificationStatus === 'APPROVED',
|
|
||||||
type: 'INFO',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
|
|
||||||
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
|
|
||||||
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
url: '/?verification=verified&verification=approved',
|
|
||||||
label: 'Search with this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Community contributed',
|
|
||||||
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
|
||||||
type: 'WARNING',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
|
|
||||||
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
|
|
||||||
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
url: '/?verification=community',
|
|
||||||
label: 'With this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/?verification=verified&verification=approved',
|
|
||||||
label: 'Without this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Is SCAM',
|
|
||||||
show: service.verificationStatus === 'VERIFICATION_FAILED',
|
|
||||||
type: 'BAD',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
|
||||||
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
|
|
||||||
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
url: '/?verification=scam',
|
|
||||||
label: 'With this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/?verification=verified&verification=approved',
|
|
||||||
label: 'Without this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
|
||||||
show: service.serviceVisibility === 'ARCHIVED',
|
|
||||||
type: 'WARNING',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
|
||||||
privacyPoints: 0,
|
|
||||||
trustPoints: 0,
|
|
||||||
links: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Recently listed',
|
|
||||||
show: service.isRecentlyListed,
|
|
||||||
type: 'WARNING',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
|
||||||
privacyPoints: 0,
|
|
||||||
trustPoints: -5,
|
|
||||||
links: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (filter) return nonDbAttributes.filter(({ show }) => show)
|
if (filter) return attributes.filter(({ show }) => show)
|
||||||
|
|
||||||
return nonDbAttributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|||||||
64
web/src/lib/client/browserNotifications.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { typedLocalStorage } from '../localstorage'
|
||||||
|
|
||||||
|
type SafeResult = { success: false; error: string } | { success: true; error?: undefined }
|
||||||
|
|
||||||
|
export function supportsBrowserNotifications() {
|
||||||
|
return 'Notification' in window
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrowserNotificationsEnabled() {
|
||||||
|
const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
|
||||||
|
if (!browserNotificationsEnabled) return false
|
||||||
|
|
||||||
|
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||||
|
typedLocalStorage.browserNotificationsEnabled.set(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return supportsBrowserNotifications() && Notification.permission === 'granted'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableBrowserNotifications(): Promise<SafeResult> {
|
||||||
|
try {
|
||||||
|
if (!supportsBrowserNotifications()) {
|
||||||
|
return { success: false, error: 'Browser notifications are not supported' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
return { success: false, error: 'Notification permission denied' }
|
||||||
|
}
|
||||||
|
|
||||||
|
typedLocalStorage.browserNotificationsEnabled.set(true)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Browser notification setup failed:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
return { success: false, error: `Browser notification setup failed: ${errorMessage}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableBrowserNotifications(): SafeResult {
|
||||||
|
try {
|
||||||
|
typedLocalStorage.browserNotificationsEnabled.set(false)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Browser notification disable failed:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
return { success: false, error: `Browser notification disable failed: ${errorMessage}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showBrowserNotification(title: string, options?: NotificationOptions) {
|
||||||
|
if (!isBrowserNotificationsEnabled()) {
|
||||||
|
console.warn('Browser notifications are not enabled')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Notification(title, options)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to show browser notification:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
179
web/src/lib/client/clientPushNotifications.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type { ActionInput } from '../astroActions'
|
||||||
|
import type { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
type ServerSubscription = {
|
||||||
|
endpoint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SafeResult =
|
||||||
|
| {
|
||||||
|
success: false
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
error?: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeToPushNotifications(vapidPublicKey: string): Promise<SafeResult> {
|
||||||
|
try {
|
||||||
|
if (!supportsPushNotifications()) {
|
||||||
|
return { success: false, error: 'Push notifications are not supported in this browser' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await getServiceWorkerRegistration()
|
||||||
|
if (!registration) return { success: false, error: 'Service worker not available' }
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
return { success: false, error: 'Notification permission denied' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
||||||
|
})
|
||||||
|
|
||||||
|
const p256dh = subscription.getKey('p256dh')
|
||||||
|
const auth = subscription.getKey('auth')
|
||||||
|
|
||||||
|
const response = await fetch('/internal-api/notifications/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||||
|
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||||
|
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return { success: false, error: `Server error: ${response.statusText} ${errorText}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Push subscription failed:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
return { success: false, error: `Subscription failed: ${errorMessage}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeFromPushNotifications(): Promise<SafeResult> {
|
||||||
|
try {
|
||||||
|
const registration = await getServiceWorkerRegistration()
|
||||||
|
if (!registration) return { success: false, error: 'Service worker not available' }
|
||||||
|
|
||||||
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
|
if (!subscription) return { success: false, error: 'No push subscription found' }
|
||||||
|
|
||||||
|
const unsubscribed = await subscription.unsubscribe()
|
||||||
|
if (!unsubscribed) return { success: false, error: 'Failed to unsubscribe from browser' }
|
||||||
|
|
||||||
|
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return { success: false, error: `Server error: ${response.statusText} ${errorText}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Push unsubscription failed:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
return { success: false, error: `Unsubscription failed: ${errorMessage}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsPushNotifications() {
|
||||||
|
const isSecure =
|
||||||
|
window.isSecureContext ||
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
window.location.hostname === '127.0.0.1'
|
||||||
|
|
||||||
|
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getServiceWorkerRegistration() {
|
||||||
|
try {
|
||||||
|
if (window.__SW_REGISTRATION__) return window.__SW_REGISTRATION__
|
||||||
|
return (await navigator.serviceWorker.getRegistration()) ?? null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting service worker registration:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentSubscription() {
|
||||||
|
try {
|
||||||
|
const registration = await getServiceWorkerRegistration()
|
||||||
|
if (!registration) return null
|
||||||
|
|
||||||
|
return await registration.pushManager.getSubscription()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting current push subscription:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubscription[]) {
|
||||||
|
const currentSubscription = await getCurrentSubscription()
|
||||||
|
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||||
|
|
||||||
|
return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlB64ToUint8Array(base64String: string) {
|
||||||
|
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
||||||
|
const base64 = cleaned + padding
|
||||||
|
|
||||||
|
const rawData = window.atob(base64)
|
||||||
|
const outputArray = new Uint8Array(rawData.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return outputArray
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
||||||
|
try {
|
||||||
|
if (typeof subscriptionsAsString !== 'string') {
|
||||||
|
console.error('Push subscriptions are not a string')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = JSON.parse(subscriptionsAsString)
|
||||||
|
|
||||||
|
if (!Array.isArray(subscriptions)) {
|
||||||
|
console.error('Push subscriptions are not an array')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscriptions.every(isServerSubscription)) {
|
||||||
|
console.error('Push subscriptions are not valid')
|
||||||
|
return subscriptions.filter(isServerSubscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscriptions
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse push subscriptions:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
|
||||||
|
if (typeof subscription !== 'object' || subscription === null) return false
|
||||||
|
const s = subscription as Record<string, unknown>
|
||||||
|
return typeof s.endpoint === 'string'
|
||||||
|
}
|
||||||
7
web/src/lib/client/envVariables.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const DEPLOYMENT_MODE = import.meta.env.PROD
|
||||||
|
? import.meta.env.MODE === 'development' ||
|
||||||
|
import.meta.env.MODE === 'staging' ||
|
||||||
|
import.meta.env.MODE === 'production'
|
||||||
|
? import.meta.env.MODE
|
||||||
|
: 'development'
|
||||||
|
: 'development'
|
||||||
68
web/src/lib/client/notificationOptions.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { DEPLOYMENT_MODE } from './envVariables'
|
||||||
|
|
||||||
|
import type { NotificationData, NotificationPayload } from '../serverEventsTypes'
|
||||||
|
|
||||||
|
export type CustomNotificationOptions = NotificationOptions & {
|
||||||
|
actions?: { action: string; title: string; icon?: string }[]
|
||||||
|
timestamp: number
|
||||||
|
data: NotificationData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeBrowserNotificationTitle(title?: string | null) {
|
||||||
|
const prefix = DEPLOYMENT_MODE === 'development' ? '[DEV] ' : DEPLOYMENT_MODE === 'staging' ? '[PRE] ' : ''
|
||||||
|
return `${prefix}${title ?? 'New Notification'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeBrowserNotificationOptions(
|
||||||
|
payload: NotificationPayload | null,
|
||||||
|
options: { removeActions?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const defaultOptions: CustomNotificationOptions = {
|
||||||
|
body: 'You have a new notification',
|
||||||
|
lang: 'en-US',
|
||||||
|
icon:
|
||||||
|
DEPLOYMENT_MODE === 'development'
|
||||||
|
? '/favicon-dev.svg'
|
||||||
|
: DEPLOYMENT_MODE === 'staging'
|
||||||
|
? '/favicon-stage.svg'
|
||||||
|
: '/favicon.svg',
|
||||||
|
badge: '/notification-icon.svg',
|
||||||
|
requireInteraction: false,
|
||||||
|
silent: false,
|
||||||
|
actions: options.removeActions
|
||||||
|
? undefined
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
title: 'View',
|
||||||
|
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: {
|
||||||
|
defaultActionUrl: '/notifications',
|
||||||
|
payload: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return defaultOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultOptions,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
body: payload.body || undefined,
|
||||||
|
actions: options.removeActions
|
||||||
|
? undefined
|
||||||
|
: payload.actions.map((action) => ({
|
||||||
|
action: action.action,
|
||||||
|
title: action.title,
|
||||||
|
icon: action.icon,
|
||||||
|
})),
|
||||||
|
data: {
|
||||||
|
...defaultOptions.data,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
} as CustomNotificationOptions
|
||||||
|
}
|
||||||
25
web/src/lib/colors.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type TailwindColor =
|
||||||
|
| 'amber'
|
||||||
|
| 'black'
|
||||||
|
| 'blue'
|
||||||
|
| 'cyan'
|
||||||
|
| 'emerald'
|
||||||
|
| 'fuchsia'
|
||||||
|
| 'gray'
|
||||||
|
| 'green'
|
||||||
|
| 'indigo'
|
||||||
|
| 'lime'
|
||||||
|
| 'neutral'
|
||||||
|
| 'orange'
|
||||||
|
| 'pink'
|
||||||
|
| 'purple'
|
||||||
|
| 'red'
|
||||||
|
| 'rose'
|
||||||
|
| 'sky'
|
||||||
|
| 'slate'
|
||||||
|
| 'stone'
|
||||||
|
| 'teal'
|
||||||
|
| 'violet'
|
||||||
|
| 'white'
|
||||||
|
| 'yellow'
|
||||||
|
| 'zinc'
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
export const MAX_COMMENT_DEPTH = 12
|
export const MAX_COMMENT_DEPTH = 12
|
||||||
@@ -75,12 +77,13 @@ export type CommentWithRepliesPopulated = CommentWithReplies<{
|
|||||||
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
|
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
|
||||||
export type CommentSortOption = z.infer<typeof commentSortSchema>
|
export type CommentSortOption = z.infer<typeof commentSortSchema>
|
||||||
|
|
||||||
export function makeCommentsNestedQuery({
|
export async function makeCommentsNestedQuery({
|
||||||
depth = 0,
|
depth = 0,
|
||||||
user,
|
user,
|
||||||
showPending,
|
showPending,
|
||||||
serviceId,
|
serviceId,
|
||||||
sort,
|
sort,
|
||||||
|
highlightedCommentId,
|
||||||
}: {
|
}: {
|
||||||
depth?: number
|
depth?: number
|
||||||
user: Prisma.UserGetPayload<{
|
user: Prisma.UserGetPayload<{
|
||||||
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
|
|||||||
showPending?: boolean
|
showPending?: boolean
|
||||||
serviceId: number
|
serviceId: number
|
||||||
sort: CommentSortOption
|
sort: CommentSortOption
|
||||||
|
highlightedCommentId?: number | null
|
||||||
}) {
|
}) {
|
||||||
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
|
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
|
||||||
|
|
||||||
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
|
|||||||
}
|
}
|
||||||
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
|
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
|
||||||
|
|
||||||
|
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
|
||||||
|
|
||||||
const baseQuery = {
|
const baseQuery = {
|
||||||
...commentReplyQuery,
|
...commentReplyQuery,
|
||||||
orderBy: orderByClause,
|
orderBy: orderByClause,
|
||||||
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
|
|||||||
: ({
|
: ({
|
||||||
status: { in: ['APPROVED', 'VERIFIED'] },
|
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||||
} as const satisfies Prisma.CommentWhereInput),
|
} as const satisfies Prisma.CommentWhereInput),
|
||||||
|
...(highlightedBranchIds.length > 0
|
||||||
|
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
parentId: null,
|
parentId: null,
|
||||||
serviceId,
|
serviceId,
|
||||||
@@ -161,6 +170,47 @@ export function makeRepliesQuery<T extends Prisma.CommentFindManyArgs>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findAllParentIds(commentId: number, depth: number) {
|
||||||
|
const commentwithManyParents = await prisma.comment.findFirst({
|
||||||
|
where: { id: commentId },
|
||||||
|
select: makeParentQuerySelect(depth),
|
||||||
|
})
|
||||||
|
|
||||||
|
return extractParentIds(commentwithManyParents, [commentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentQueryRecursive = {
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
parent: false | { select: ParentQueryRecursive }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeParentQuerySelect(depth: number): ParentQueryRecursive {
|
||||||
|
return {
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parent: depth <= 0 ? false : { select: makeParentQuerySelect(depth - 1) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Prisma.CommentSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParentIds(
|
||||||
|
comment: Prisma.CommentGetPayload<{ select: ParentQueryRecursive }> | null,
|
||||||
|
acc: number[] = []
|
||||||
|
) {
|
||||||
|
if (!comment?.parent?.id) return acc
|
||||||
|
|
||||||
|
return extractParentIds(comment.parent as Prisma.CommentGetPayload<{ select: ParentQueryRecursive }>, [
|
||||||
|
...acc,
|
||||||
|
comment.parent.id,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export function makeCommentUrl({
|
export function makeCommentUrl({
|
||||||
serviceSlug,
|
serviceSlug,
|
||||||
commentId,
|
commentId,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { z } from 'astro/zod'
|
|
||||||
|
|
||||||
const schema = z.enum(['development', 'staging', 'production'])
|
|
||||||
|
|
||||||
export const DEPLOYMENT_MODE = schema.parse(import.meta.env.PROD ? import.meta.env.MODE : 'development')
|
|
||||||
320
web/src/lib/feeds.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
type SafeResult<T> =
|
||||||
|
| {
|
||||||
|
success: false
|
||||||
|
error: { message: string; responseInit: ResponseInit }
|
||||||
|
data?: undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
error?: undefined
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const takeCounts = {
|
||||||
|
serviceComments: 100,
|
||||||
|
serviceEvents: 100,
|
||||||
|
allEvents: 100,
|
||||||
|
userNotifications: 50,
|
||||||
|
} as const satisfies Record<string, number>
|
||||||
|
|
||||||
|
const serviceSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
} as const satisfies Prisma.ServiceSelect
|
||||||
|
|
||||||
|
export async function getService(slug: string | undefined): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!slug || typeof slug !== 'string') {
|
||||||
|
return { success: false, error: { message: 'Invalid slug', responseInit: { status: 400 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const service =
|
||||||
|
(await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
select: serviceSelect,
|
||||||
|
})) ??
|
||||||
|
(await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
previousSlugs: { has: slug },
|
||||||
|
},
|
||||||
|
select: serviceSelect,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
return { success: false, error: { message: 'Service not found', responseInit: { status: 404 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: { service } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceCommentSelect = {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
rating: true,
|
||||||
|
ratingActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
verified: true,
|
||||||
|
admin: true,
|
||||||
|
moderator: true,
|
||||||
|
spammer: true,
|
||||||
|
serviceAffiliations: {
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
service: { select: { name: true, slug: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Prisma.CommentSelect
|
||||||
|
|
||||||
|
export async function getCommentsForService(slug: string | undefined): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||||
|
comments: Prisma.CommentGetPayload<{ select: typeof serviceCommentSelect }>[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const result = await getService(slug)
|
||||||
|
if (!result.success) return result
|
||||||
|
const { service } = result.data
|
||||||
|
|
||||||
|
const comments = await prisma.comment.findMany({
|
||||||
|
where: {
|
||||||
|
serviceId: service.id,
|
||||||
|
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||||
|
suspicious: false,
|
||||||
|
parentId: null, // Only root comments for the main feed
|
||||||
|
},
|
||||||
|
select: serviceCommentSelect,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: takeCounts.serviceComments,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: { service, comments } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSelect = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
type: true,
|
||||||
|
startedAt: true,
|
||||||
|
endedAt: true,
|
||||||
|
source: true,
|
||||||
|
createdAt: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Prisma.EventSelect
|
||||||
|
|
||||||
|
const serviceEventSelect = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
type: true,
|
||||||
|
startedAt: true,
|
||||||
|
endedAt: true,
|
||||||
|
source: true,
|
||||||
|
createdAt: true,
|
||||||
|
} as const satisfies Prisma.EventSelect
|
||||||
|
|
||||||
|
export async function getEventsForService(slug: string | undefined): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||||
|
events: Prisma.EventGetPayload<{ select: typeof serviceEventSelect }>[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const result = await getService(slug)
|
||||||
|
if (!result.success) return result
|
||||||
|
const { service } = result.data
|
||||||
|
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
serviceId: service.id,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
select: serviceEventSelect,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: takeCounts.serviceEvents,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: { service, events } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvents(): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
events: Prisma.EventGetPayload<{ select: typeof eventSelect }>[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
visible: true,
|
||||||
|
service: {
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: eventSelect,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: takeCounts.allEvents,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: { events } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
} as const satisfies Prisma.UserSelect
|
||||||
|
|
||||||
|
const notificationSelect = {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
createdAt: true,
|
||||||
|
aboutAccountStatusChange: true,
|
||||||
|
aboutCommentStatusChange: true,
|
||||||
|
aboutServiceVerificationStatusChange: true,
|
||||||
|
aboutSuggestionStatusChange: true,
|
||||||
|
aboutServiceSuggestionId: true,
|
||||||
|
aboutComment: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
communityNote: true,
|
||||||
|
status: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutEvent: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
type: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutService: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
verificationStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutServiceSuggestion: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutServiceSuggestionMessage: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
suggestion: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutKarmaTransaction: {
|
||||||
|
select: {
|
||||||
|
points: true,
|
||||||
|
action: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Prisma.NotificationSelect
|
||||||
|
|
||||||
|
export async function getUserNotifications(feedId: string | undefined): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
user: Prisma.UserGetPayload<{ select: typeof userSelect }>
|
||||||
|
notifications: Prisma.NotificationGetPayload<{ select: typeof notificationSelect }>[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!feedId || typeof feedId !== 'string') {
|
||||||
|
return { success: false, error: { message: 'Invalid feed ID', responseInit: { status: 400 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { feedId, spammer: false },
|
||||||
|
select: userSelect,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: { message: 'User not found', responseInit: { status: 404 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = await prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
select: notificationSelect,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: takeCounts.userNotifications,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: { user, notifications } }
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'l
|
|||||||
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
|
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
|
||||||
await redisImpersonationSessions.delete(sessionId)
|
await redisImpersonationSessions.delete(sessionId)
|
||||||
context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
|
context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
|
||||||
context.locals.user = context.locals.actualUser
|
context.locals.user = context.locals.actualUser ?? context.locals.user
|
||||||
context.locals.actualUser = null
|
context.locals.actualUser = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { z } from 'astro:content'
|
import type { z } from 'astro/zod'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
interface JSONObject {
|
interface JSONObject {
|
||||||
|
|||||||
@@ -50,4 +50,8 @@ export const typedLocalStorage = makeTypedLocalStorage({
|
|||||||
pushNotificationsBannerDismissedAt: {
|
pushNotificationsBannerDismissedAt: {
|
||||||
schema: z.coerce.date(),
|
schema: z.coerce.date(),
|
||||||
},
|
},
|
||||||
|
browserNotificationsEnabled: {
|
||||||
|
schema: z.boolean(),
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { commentStatusChangesById } from '../constants/commentStatusChange'
|
|||||||
import { eventTypesById } from '../constants/eventTypes'
|
import { eventTypesById } from '../constants/eventTypes'
|
||||||
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
|
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
|
||||||
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
|
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
|
||||||
|
import { getServiceSuggestionTypeInfo } from '../constants/serviceSuggestionType'
|
||||||
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
|
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
|
||||||
|
|
||||||
import { makeCommentUrl } from './commentsWithReplies'
|
import { makeCommentUrl } from './commentsWithReplies'
|
||||||
|
|
||||||
import type { NotificationAction } from './webPush'
|
import type { NotificationAction } from './serverEventsTypes'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
export function makeNotificationTitle(
|
export function makeNotificationTitle(
|
||||||
@@ -48,6 +49,7 @@ export function makeNotificationTitle(
|
|||||||
aboutServiceSuggestion: {
|
aboutServiceSuggestion: {
|
||||||
select: {
|
select: {
|
||||||
status: true
|
status: true
|
||||||
|
type: true
|
||||||
service: {
|
service: {
|
||||||
select: {
|
select: {
|
||||||
name: true
|
name: true
|
||||||
@@ -131,6 +133,12 @@ export function makeNotificationTitle(
|
|||||||
? `New unmoderated comment on ${service}`
|
? `New unmoderated comment on ${service}`
|
||||||
: `New comment on ${service}`
|
: `New comment on ${service}`
|
||||||
}
|
}
|
||||||
|
case 'SUGGESTION_CREATED': {
|
||||||
|
if (!notification.aboutServiceSuggestion) return 'New service suggestion'
|
||||||
|
const typeInfo = getServiceSuggestionTypeInfo(notification.aboutServiceSuggestion.type)
|
||||||
|
const service = notification.aboutServiceSuggestion.service.name
|
||||||
|
return `New ${typeInfo.labelAlt} suggestion for ${service}`
|
||||||
|
}
|
||||||
case 'SUGGESTION_MESSAGE': {
|
case 'SUGGESTION_MESSAGE': {
|
||||||
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
|
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
|
||||||
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
|
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
|
||||||
@@ -219,6 +227,7 @@ export function makeNotificationContent(
|
|||||||
if (!notification.aboutKarmaTransaction) return null
|
if (!notification.aboutKarmaTransaction) return null
|
||||||
return notification.aboutKarmaTransaction.description
|
return notification.aboutKarmaTransaction.description
|
||||||
}
|
}
|
||||||
|
case 'SUGGESTION_CREATED':
|
||||||
case 'SUGGESTION_STATUS_CHANGE':
|
case 'SUGGESTION_STATUS_CHANGE':
|
||||||
case 'ACCOUNT_STATUS_CHANGE':
|
case 'ACCOUNT_STATUS_CHANGE':
|
||||||
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
||||||
@@ -323,6 +332,17 @@ export function makeNotificationActions(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
case 'SUGGESTION_CREATED': {
|
||||||
|
if (!notification.aboutServiceSuggestionId) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
title: 'View',
|
||||||
|
...iconNameAndUrl('ri:arrow-right-line'),
|
||||||
|
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
case 'SUGGESTION_MESSAGE': {
|
case 'SUGGESTION_MESSAGE': {
|
||||||
if (!notification.aboutServiceSuggestionMessage) return []
|
if (!notification.aboutServiceSuggestionMessage) return []
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,100 +1,21 @@
|
|||||||
import { z } from 'astro/zod'
|
import { startListener, stopListener } from './postgresListeners'
|
||||||
import { Client } from 'pg'
|
|
||||||
|
|
||||||
import { zodParseJSON } from './json'
|
import type { AstroIntegration } from 'astro'
|
||||||
import { sendNotification } from './sendNotifications'
|
|
||||||
import { getServerEnvVariable } from './serverEnvVariables'
|
|
||||||
|
|
||||||
import type { AstroIntegration, HookParameters } from 'astro'
|
|
||||||
|
|
||||||
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
|
|
||||||
|
|
||||||
let pgClient: Client | null = null
|
|
||||||
|
|
||||||
const INTEGRATION_NAME = 'postgres-listener'
|
const INTEGRATION_NAME = 'postgres-listener'
|
||||||
|
|
||||||
async function handleNotificationCreated(
|
|
||||||
notificationId: number,
|
|
||||||
options: HookParameters<'astro:server:start'>
|
|
||||||
) {
|
|
||||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
|
||||||
try {
|
|
||||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
|
||||||
|
|
||||||
const results = await sendNotification(notificationId, logger)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed`
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function postgresListener(): AstroIntegration {
|
export function postgresListener(): AstroIntegration {
|
||||||
return {
|
return {
|
||||||
name: 'postgres-listener',
|
name: 'postgres-listener',
|
||||||
hooks: {
|
hooks: {
|
||||||
'astro:server:start': async (options) => {
|
'astro:server:start': (options) => {
|
||||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||||
|
void startListener(logger)
|
||||||
try {
|
|
||||||
logger.info('Starting PostgreSQL notification listener...')
|
|
||||||
|
|
||||||
pgClient = new Client({ connectionString: DATABASE_URL })
|
|
||||||
|
|
||||||
await pgClient.connect()
|
|
||||||
logger.info('Connected to PostgreSQL for notifications')
|
|
||||||
|
|
||||||
await pgClient.query('LISTEN notification_created')
|
|
||||||
logger.info('Listening for notification_created events')
|
|
||||||
|
|
||||||
pgClient.on('notification', (msg) => {
|
|
||||||
if (msg.channel === 'notification_created') {
|
|
||||||
const payload = zodParseJSON(z.object({ id: z.number().int().positive() }), msg.payload)
|
|
||||||
if (!payload) {
|
|
||||||
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Don't await to avoid blocking
|
|
||||||
void handleNotificationCreated(payload.id, options)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
pgClient.on('error', (error) => {
|
|
||||||
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
pgClient.on('end', () => {
|
|
||||||
logger.info('PostgreSQL client connection ended')
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
'astro:server:done': (options) => {
|
||||||
'astro:server:done': async ({ logger: originalLogger }) => {
|
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||||
const logger = originalLogger.fork(INTEGRATION_NAME)
|
void stopListener(logger)
|
||||||
|
|
||||||
if (pgClient) {
|
|
||||||
try {
|
|
||||||
logger.info('Stopping PostgreSQL notification listener...')
|
|
||||||
await pgClient.end()
|
|
||||||
pgClient = null
|
|
||||||
logger.info('PostgreSQL listener stopped')
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
return String(error)
|
|
||||||
}
|
|
||||||
|
|||||||