Compare commits
16 Commits
release-36
...
release-52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6908518d | ||
|
|
d065910ff3 | ||
|
|
490433b002 | ||
|
|
e17bc8a521 | ||
|
|
ec1215f2ae | ||
|
|
3afa824c18 | ||
|
|
9a68112e24 | ||
|
|
0c40d8eec5 | ||
|
|
e16c9b64ed | ||
|
|
22944fcdb3 | ||
|
|
f7f380c591 | ||
|
|
577c524ca2 | ||
|
|
da12e8de79 | ||
|
|
ea40f17d3c | ||
|
|
7e0d41cc7a | ||
|
|
70a097054b |
@@ -7,3 +7,7 @@ ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.o
|
|||||||
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
||||||
RELEASE_NUMBER=123
|
RELEASE_NUMBER=123
|
||||||
RELEASE_DATE="2025-05-23T19:00:00.000Z"
|
RELEASE_DATE="2025-05-23T19:00:00.000Z"
|
||||||
|
# Generated with `npx web-push generate-vapid-keys`
|
||||||
|
VAPID_PUBLIC_KEY="<vapid-public-key-placeholder>"
|
||||||
|
VAPID_PRIVATE_KEY="<vapid-private-key-placeholder>"
|
||||||
|
VAPID_SUBJECT="mailto:no-reply@kycnot.me"
|
||||||
|
|||||||
@@ -177,6 +177,12 @@ export default defineConfig({
|
|||||||
url: true,
|
url: true,
|
||||||
optional: false,
|
optional: false,
|
||||||
}),
|
}),
|
||||||
|
LOGS_UI_URL: envField.string({
|
||||||
|
context: 'server',
|
||||||
|
access: 'secret',
|
||||||
|
url: true,
|
||||||
|
optional: true,
|
||||||
|
}),
|
||||||
|
|
||||||
RELEASE_NUMBER: envField.number({
|
RELEASE_NUMBER: envField.number({
|
||||||
context: 'server',
|
context: 'server',
|
||||||
@@ -189,6 +195,27 @@ export default defineConfig({
|
|||||||
access: 'public',
|
access: 'public',
|
||||||
optional: true,
|
optional: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Generated with `npx web-push generate-vapid-keys`
|
||||||
|
VAPID_PUBLIC_KEY: envField.string({
|
||||||
|
context: 'server',
|
||||||
|
access: 'public',
|
||||||
|
min: 1,
|
||||||
|
optional: false,
|
||||||
|
}),
|
||||||
|
// Generated with `npx web-push generate-vapid-keys`
|
||||||
|
VAPID_PRIVATE_KEY: envField.string({
|
||||||
|
context: 'server',
|
||||||
|
access: 'secret',
|
||||||
|
min: 1,
|
||||||
|
optional: false,
|
||||||
|
}),
|
||||||
|
VAPID_SUBJECT: envField.string({
|
||||||
|
context: 'server',
|
||||||
|
access: 'secret',
|
||||||
|
min: 1,
|
||||||
|
optional: false,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
123
web/package-lock.json
generated
123
web/package-lock.json
generated
@@ -44,6 +44,7 @@
|
|||||||
"tailwindcss": "4.1.7",
|
"tailwindcss": "4.1.7",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"unique-username-generator": "1.4.0",
|
"unique-username-generator": "1.4.0",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"zod-form-data": "2.0.7"
|
"zod-form-data": "2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
"@types/qrcode": "1.5.5",
|
"@types/qrcode": "1.5.5",
|
||||||
"@types/react": "19.1.4",
|
"@types/react": "19.1.4",
|
||||||
"@types/seedrandom": "3.0.8",
|
"@types/seedrandom": "3.0.8",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@typescript-eslint/parser": "8.32.1",
|
"@typescript-eslint/parser": "8.32.1",
|
||||||
"astro-icon": "1.1.5",
|
"astro-icon": "1.1.5",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
@@ -3215,6 +3217,16 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
||||||
@@ -3797,6 +3809,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -4063,6 +4084,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types-flow": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||||
@@ -4822,6 +4855,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||||
|
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/boolbase": {
|
"node_modules/boolbase": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
@@ -4916,6 +4955,12 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/bundle-name": {
|
"node_modules/bundle-name": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||||
@@ -6157,6 +6202,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -8181,6 +8235,15 @@
|
|||||||
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
|
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-cache-semantics": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||||
@@ -8203,6 +8266,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -8935,6 +9011,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.0",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -10556,6 +10653,12 @@
|
|||||||
"mini-svg-data-uri": "cli.js"
|
"mini-svg-data-uri": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
@@ -12696,7 +12799,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sass-formatter": {
|
"node_modules/sass-formatter": {
|
||||||
@@ -14675,6 +14777,25 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"tailwindcss": "4.1.7",
|
"tailwindcss": "4.1.7",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"unique-username-generator": "1.4.0",
|
"unique-username-generator": "1.4.0",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"zod-form-data": "2.0.7"
|
"zod-form-data": "2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"@types/qrcode": "1.5.5",
|
"@types/qrcode": "1.5.5",
|
||||||
"@types/react": "19.1.4",
|
"@types/react": "19.1.4",
|
||||||
"@types/seedrandom": "3.0.8",
|
"@types/seedrandom": "3.0.8",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@typescript-eslint/parser": "8.32.1",
|
"@typescript-eslint/parser": "8.32.1",
|
||||||
"astro-icon": "1.1.5",
|
"astro-icon": "1.1.5",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Enable pg_trgm extension for similarity functions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "KycLevelClarification" AS ENUM ('NONE', 'DEPENDS_ON_PARTNERS');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "kycLevelClarification" "KycLevelClarification",
|
||||||
|
ADD COLUMN "kycLevelDetailsId" INTEGER;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PushSubscription" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"endpoint" TEXT NOT NULL,
|
||||||
|
"p256dh" TEXT NOT NULL,
|
||||||
|
"auth" TEXT NOT NULL,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PushSubscription_endpoint_idx" ON "PushSubscription"("endpoint");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -297,6 +297,11 @@ enum ServiceSuggestionType {
|
|||||||
EDIT_SERVICE
|
EDIT_SERVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum KycLevelClarification {
|
||||||
|
NONE
|
||||||
|
DEPENDS_ON_PARTNERS
|
||||||
|
}
|
||||||
|
|
||||||
model ServiceSuggestion {
|
model ServiceSuggestion {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
type ServiceSuggestionType
|
type ServiceSuggestionType
|
||||||
@@ -336,9 +341,11 @@ model Service {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
previousSlugs String[] @default([])
|
||||||
description String
|
description String
|
||||||
categories Category[] @relation("ServiceToCategory")
|
categories Category[] @relation("ServiceToCategory")
|
||||||
kycLevel Int @default(4)
|
kycLevel Int @default(4)
|
||||||
|
kycLevelClarification KycLevelClarification?
|
||||||
overallScore Int @default(0)
|
overallScore Int @default(0)
|
||||||
privacyScore Int @default(0)
|
privacyScore Int @default(0)
|
||||||
trustScore Int @default(0)
|
trustScore Int @default(0)
|
||||||
@@ -384,6 +391,7 @@ model Service {
|
|||||||
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
|
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
|
||||||
Notification Notification[]
|
Notification Notification[]
|
||||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||||
|
kycLevelDetailsId Int?
|
||||||
|
|
||||||
@@index([listedAt])
|
@@index([listedAt])
|
||||||
@@index([overallScore])
|
@@index([overallScore])
|
||||||
@@ -396,6 +404,7 @@ model Service {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
|
@@index([previousSlugs])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ServiceContactMethod {
|
model ServiceContactMethod {
|
||||||
@@ -506,6 +515,7 @@ model User {
|
|||||||
notifications Notification[] @relation("NotificationOwner")
|
notifications Notification[] @relation("NotificationOwner")
|
||||||
notificationPreferences NotificationPreferences?
|
notificationPreferences NotificationPreferences?
|
||||||
serviceAffiliations ServiceUser[] @relation("UserServices")
|
serviceAffiliations ServiceUser[] @relation("UserServices")
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([totalKarma])
|
@@index([totalKarma])
|
||||||
@@ -654,3 +664,21 @@ model Announcement {
|
|||||||
|
|
||||||
@@index([isActive, startDate, endDate])
|
@@index([isActive, startDate, endDate])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PushSubscription {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
endpoint String @unique
|
||||||
|
/// Public key for encryption
|
||||||
|
p256dh String
|
||||||
|
/// Authentication secret
|
||||||
|
auth String
|
||||||
|
/// To identify different devices
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([endpoint])
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
type User,
|
type User,
|
||||||
type ServiceVisibility,
|
type ServiceVisibility,
|
||||||
ServiceSuggestionType,
|
ServiceSuggestionType,
|
||||||
|
KycLevelClarification,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import { uniqBy } from 'lodash-es'
|
import { omit, uniqBy } from 'lodash-es'
|
||||||
import { generateUsername } from 'unique-username-generator'
|
import { generateUsername } from 'unique-username-generator'
|
||||||
|
|
||||||
import { kycLevels } from '../src/constants/kycLevels'
|
import { kycLevels } from '../src/constants/kycLevels'
|
||||||
@@ -612,8 +613,14 @@ const generateFakeService = (users: User[]) => {
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
|
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
|
||||||
description: faker.helpers.arrayElement(serviceDescriptions),
|
description: faker.helpers.arrayElement(serviceDescriptions),
|
||||||
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
|
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
|
||||||
|
kycLevelClarification: faker.helpers.maybe(
|
||||||
|
() =>
|
||||||
|
faker.helpers.arrayElement(omit(Object.values(KycLevelClarification), [KycLevelClarification.NONE])),
|
||||||
|
{ probability: 0.25 }
|
||||||
|
),
|
||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
@@ -916,7 +923,7 @@ const specialUsersData = {
|
|||||||
verifiedLink: 'https://kycnot.me',
|
verifiedLink: 'https://kycnot.me',
|
||||||
totalKarma: 1001,
|
totalKarma: 1001,
|
||||||
link: 'https://kycnot.me',
|
link: 'https://kycnot.me',
|
||||||
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
|
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
|
||||||
},
|
},
|
||||||
moderator: {
|
moderator: {
|
||||||
name: 'moderator_dev',
|
name: 'moderator_dev',
|
||||||
@@ -928,7 +935,7 @@ const specialUsersData = {
|
|||||||
verifiedLink: 'https://kycnot.me',
|
verifiedLink: 'https://kycnot.me',
|
||||||
totalKarma: 1001,
|
totalKarma: 1001,
|
||||||
link: 'https://kycnot.me',
|
link: 'https://kycnot.me',
|
||||||
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
|
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
|
||||||
},
|
},
|
||||||
verified: {
|
verified: {
|
||||||
name: 'verified_dev',
|
name: 'verified_dev',
|
||||||
@@ -1134,7 +1141,7 @@ async function main() {
|
|||||||
// ---- Create services ----
|
// ---- Create services ----
|
||||||
const services = await Promise.all(
|
const services = await Promise.all(
|
||||||
Array.from({ length: numServices }, async () => {
|
Array.from({ length: numServices }, async () => {
|
||||||
const serviceData = generateFakeService(users)
|
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
|
||||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||||
|
|
||||||
const service = await prisma.service.create({
|
const service = await prisma.service.create({
|
||||||
@@ -1274,7 +1281,7 @@ async function main() {
|
|||||||
// ---- Create service suggestions for normal_dev user ----
|
// ---- Create service suggestions for normal_dev user ----
|
||||||
// First create 3 CREATE_SERVICE suggestions with their services
|
// First create 3 CREATE_SERVICE suggestions with their services
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const serviceData = generateFakeService(users)
|
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
|
||||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||||
|
|
||||||
const service = await prisma.service.create({
|
const service = await prisma.service.create({
|
||||||
|
|||||||
83
web/public/sw.js
Normal file
83
web/public/sw.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
/** @type {ServiceWorkerGlobalScope} */
|
||||||
|
// @ts-expect-error
|
||||||
|
const typedSelf = self
|
||||||
|
|
||||||
|
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
|
||||||
|
|
||||||
|
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.log('Push event but no data')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationData
|
||||||
|
try {
|
||||||
|
notificationData = event.data.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing push data:', error)
|
||||||
|
notificationData = {
|
||||||
|
title: 'New Notification',
|
||||||
|
options: {
|
||||||
|
body: event.data.text() || 'You have a new notification',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, options } = notificationData
|
||||||
|
|
||||||
|
const notificationOptions = {
|
||||||
|
body: options.body || '',
|
||||||
|
icon: options.icon || '/favicon.svg',
|
||||||
|
badge: options.badge || '/favicon.svg',
|
||||||
|
data: options.data || {},
|
||||||
|
requireInteraction: false,
|
||||||
|
silent: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
|
||||||
|
})
|
||||||
|
|
||||||
|
typedSelf.addEventListener('notificationclick', (event) => {
|
||||||
|
console.log('Notification clicked:', event)
|
||||||
|
|
||||||
|
event.notification.close()
|
||||||
|
|
||||||
|
const url = event.notification.data?.url || '/'
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { type Prisma, type PrismaClient } from '@prisma/client'
|
import { type Prisma } from '@prisma/client'
|
||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
|
|
||||||
const prisma = prismaInstance as PrismaClient
|
|
||||||
|
|
||||||
const selectAnnouncementReturnFields = {
|
const selectAnnouncementReturnFields = {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { adminAnnouncementActions } from './announcement'
|
import { adminAnnouncementActions } from './announcement'
|
||||||
import { adminAttributeActions } from './attribute'
|
import { adminAttributeActions } from './attribute'
|
||||||
import { adminEventActions } from './event'
|
import { adminEventActions } from './event'
|
||||||
|
import { adminNotificationActions } from './notification'
|
||||||
import { adminServiceActions } from './service'
|
import { adminServiceActions } from './service'
|
||||||
import { adminServiceSuggestionActions } from './serviceSuggestion'
|
import { adminServiceSuggestionActions } from './serviceSuggestion'
|
||||||
import { adminUserActions } from './user'
|
import { adminUserActions } from './user'
|
||||||
import { verificationStep } from './verificationStep'
|
import { verificationStep } from './verificationStep'
|
||||||
|
|
||||||
export const adminActions = {
|
export const adminActions = {
|
||||||
attribute: adminAttributeActions,
|
|
||||||
announcement: adminAnnouncementActions,
|
announcement: adminAnnouncementActions,
|
||||||
|
attribute: adminAttributeActions,
|
||||||
event: adminEventActions,
|
event: adminEventActions,
|
||||||
|
notification: adminNotificationActions,
|
||||||
service: adminServiceActions,
|
service: adminServiceActions,
|
||||||
serviceSuggestions: adminServiceSuggestionActions,
|
serviceSuggestions: adminServiceSuggestionActions,
|
||||||
user: adminUserActions,
|
user: adminUserActions,
|
||||||
|
|||||||
80
web/src/actions/admin/notification.ts
Normal file
80
web/src/actions/admin/notification.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { z } from 'astro/zod'
|
||||||
|
import { sumBy } from 'lodash-es'
|
||||||
|
|
||||||
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { sendPushNotification } from '../../lib/webPush'
|
||||||
|
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||||
|
|
||||||
|
export const adminNotificationActions = {
|
||||||
|
webPush: {
|
||||||
|
test: defineProtectedAction({
|
||||||
|
accept: 'form',
|
||||||
|
permissions: 'admin',
|
||||||
|
input: z.object({
|
||||||
|
userNames: stringListOfSlugsSchemaRequired,
|
||||||
|
title: z.string().min(1).nullable(),
|
||||||
|
body: z.string().nullable(),
|
||||||
|
url: z.string().url().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (input) => {
|
||||||
|
const subscriptions = await prisma.pushSubscription.findMany({
|
||||||
|
where: { user: { name: { in: input.userNames } } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
endpoint: true,
|
||||||
|
p256dh: true,
|
||||||
|
auth: true,
|
||||||
|
userAgent: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
subscriptions.map(async (subscription) => {
|
||||||
|
const result = await sendPushNotification(
|
||||||
|
{
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: subscription.p256dh,
|
||||||
|
auth: subscription.auth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: input.title ?? 'Test Notification',
|
||||||
|
body: input.body ?? 'This is a test push notification from KYCNot.me',
|
||||||
|
url: input.url ?? '/',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// If subscription is invalid, remove it from database
|
||||||
|
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||||
|
await prisma.pushSubscription.delete({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
})
|
||||||
|
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.success
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
|
||||||
|
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
|
||||||
|
totalSubscriptions: subscriptions.length,
|
||||||
|
successCount,
|
||||||
|
failureCount,
|
||||||
|
sentAt: now,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,47 +1,14 @@
|
|||||||
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
|
import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
|
||||||
import { z } from 'astro/zod'
|
import { z } from 'astro/zod'
|
||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
|
import { uniq } from 'lodash-es'
|
||||||
import slugify from 'slugify'
|
import slugify from 'slugify'
|
||||||
|
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../../lib/fileStorage'
|
import { saveFileLocally } from '../../lib/fileStorage'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import {
|
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||||
imageFileSchema,
|
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
|
||||||
stringListOfUrlsSchema,
|
|
||||||
stringListOfUrlsSchemaRequired,
|
|
||||||
zodCohercedNumber,
|
|
||||||
} from '../../lib/zodUtils'
|
|
||||||
|
|
||||||
const serviceSchemaBase = z.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
slug: z
|
|
||||||
.string()
|
|
||||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
|
||||||
.optional(),
|
|
||||||
name: z.string().min(1).max(20),
|
|
||||||
description: z.string().min(1),
|
|
||||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
|
||||||
onionUrls: stringListOfUrlsSchema,
|
|
||||||
kycLevel: z.coerce.number().int().min(0).max(4),
|
|
||||||
attributes: z.array(z.coerce.number().int().positive()),
|
|
||||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
|
||||||
verificationStatus: z.nativeEnum(VerificationStatus),
|
|
||||||
verificationSummary: z.string().optional().nullable().default(null),
|
|
||||||
verificationProofMd: z.string().optional().nullable().default(null),
|
|
||||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
|
||||||
referral: z
|
|
||||||
.string()
|
|
||||||
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
|
||||||
.optional()
|
|
||||||
.nullable()
|
|
||||||
.default(null),
|
|
||||||
imageFile: imageFileSchema,
|
|
||||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
|
||||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
|
||||||
internalNote: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const addSlugIfMissing = <
|
const addSlugIfMissing = <
|
||||||
T extends {
|
T extends {
|
||||||
@@ -62,12 +29,52 @@ const addSlugIfMissing = <
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const serviceSchemaBase = z.object({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
slug: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||||
|
.optional(),
|
||||||
|
name: z.string().min(1).max(40),
|
||||||
|
description: z.string().min(1),
|
||||||
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
|
kycLevel: z.coerce.number().int().min(0).max(4),
|
||||||
|
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
|
||||||
|
attributes: z.array(z.coerce.number().int().positive()),
|
||||||
|
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||||
|
verificationStatus: z.nativeEnum(VerificationStatus),
|
||||||
|
verificationSummary: z.string().optional().nullable().default(null),
|
||||||
|
verificationProofMd: z.string().optional().nullable().default(null),
|
||||||
|
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
||||||
|
referral: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.default(null),
|
||||||
|
imageFile: imageFileSchema,
|
||||||
|
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||||
|
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||||
|
internalNote: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define schema for the create action input
|
||||||
|
const createServiceInputSchema = serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing)
|
||||||
|
|
||||||
|
// Define schema for the update action input
|
||||||
|
const updateServiceInputSchema = serviceSchemaBase
|
||||||
|
.extend({
|
||||||
|
removeImage: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.transform(addSlugIfMissing)
|
||||||
|
|
||||||
export const adminServiceActions = {
|
export const adminServiceActions = {
|
||||||
create: defineProtectedAction({
|
create: defineProtectedAction({
|
||||||
accept: 'form',
|
accept: 'form',
|
||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
|
input: createServiceInputSchema,
|
||||||
handler: async (input, context) => {
|
handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
|
||||||
const existing = await prisma.service.findUnique({
|
const existing = await prisma.service.findUnique({
|
||||||
where: {
|
where: {
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
@@ -85,14 +92,22 @@ export const adminServiceActions = {
|
|||||||
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const {
|
||||||
|
web: serviceUrls,
|
||||||
|
onion: onionUrls,
|
||||||
|
i2p: i2pUrls,
|
||||||
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||||
|
|
||||||
const service = await prisma.service.create({
|
const service = await prisma.service.create({
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
serviceUrls: input.serviceUrls,
|
serviceUrls,
|
||||||
tosUrls: input.tosUrls,
|
tosUrls: input.tosUrls,
|
||||||
onionUrls: input.onionUrls,
|
onionUrls,
|
||||||
|
i2pUrls,
|
||||||
kycLevel: input.kycLevel,
|
kycLevel: input.kycLevel,
|
||||||
|
kycLevelClarification: input.kycLevelClarification,
|
||||||
verificationStatus: input.verificationStatus,
|
verificationStatus: input.verificationStatus,
|
||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
@@ -134,12 +149,8 @@ export const adminServiceActions = {
|
|||||||
update: defineProtectedAction({
|
update: defineProtectedAction({
|
||||||
accept: 'form',
|
accept: 'form',
|
||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: serviceSchemaBase
|
input: updateServiceInputSchema,
|
||||||
.extend({
|
handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
|
||||||
removeImage: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.transform(addSlugIfMissing),
|
|
||||||
handler: async (input) => {
|
|
||||||
const anotherServiceWithNewSlug = await prisma.service.findUnique({
|
const anotherServiceWithNewSlug = await prisma.service.findUnique({
|
||||||
where: {
|
where: {
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
@@ -154,19 +165,24 @@ export const adminServiceActions = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = input.removeImage
|
|
||||||
? null
|
|
||||||
: input.imageFile
|
|
||||||
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const existingService = await prisma.service.findUnique({
|
const existingService = await prisma.service.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
select: {
|
||||||
categories: true,
|
slug: true,
|
||||||
|
previousSlugs: true,
|
||||||
|
categories: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
attributes: {
|
attributes: {
|
||||||
include: {
|
select: {
|
||||||
attribute: true,
|
attributeId: true,
|
||||||
|
attribute: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -187,15 +203,29 @@ export const adminServiceActions = {
|
|||||||
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||||
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
|
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
|
||||||
|
|
||||||
|
const imageUrl = input.removeImage
|
||||||
|
? null
|
||||||
|
: input.imageFile
|
||||||
|
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const {
|
||||||
|
web: serviceUrls,
|
||||||
|
onion: onionUrls,
|
||||||
|
i2p: i2pUrls,
|
||||||
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||||
|
|
||||||
const service = await prisma.service.update({
|
const service = await prisma.service.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
serviceUrls: input.serviceUrls,
|
serviceUrls,
|
||||||
tosUrls: input.tosUrls,
|
tosUrls: input.tosUrls,
|
||||||
onionUrls: input.onionUrls,
|
onionUrls,
|
||||||
|
i2pUrls,
|
||||||
kycLevel: input.kycLevel,
|
kycLevel: input.kycLevel,
|
||||||
|
kycLevelClarification: input.kycLevelClarification,
|
||||||
verificationStatus: input.verificationStatus,
|
verificationStatus: input.verificationStatus,
|
||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
@@ -204,6 +234,14 @@ export const adminServiceActions = {
|
|||||||
serviceVisibility: input.serviceVisibility,
|
serviceVisibility: input.serviceVisibility,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
overallScore: input.overallScore,
|
overallScore: input.overallScore,
|
||||||
|
previousSlugs:
|
||||||
|
existingService.slug !== input.slug
|
||||||
|
? {
|
||||||
|
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
|
||||||
|
(slug) => slug !== input.slug
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
|
||||||
imageUrl,
|
imageUrl,
|
||||||
categories: {
|
categories: {
|
||||||
|
|||||||
5
web/src/actions/api/index.ts
Normal file
5
web/src/actions/api/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { apiServiceActions } from './service'
|
||||||
|
|
||||||
|
export const apiActions = {
|
||||||
|
service: apiServiceActions,
|
||||||
|
}
|
||||||
143
web/src/actions/api/service.ts
Normal file
143
web/src/actions/api/service.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { z } from 'astro/zod'
|
||||||
|
import { ActionError } from 'astro:actions'
|
||||||
|
import { pick } from 'lodash-es'
|
||||||
|
|
||||||
|
import { getKycLevelInfo } from '../../constants/kycLevels'
|
||||||
|
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
|
||||||
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
|
||||||
|
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
export const apiServiceActions = {
|
||||||
|
get: defineProtectedAction({
|
||||||
|
accept: 'json',
|
||||||
|
permissions: 'guest',
|
||||||
|
input: z.object({
|
||||||
|
id: z.coerce.number().int().positive().optional(),
|
||||||
|
slug: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(2048)
|
||||||
|
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||||
|
.optional(),
|
||||||
|
url: zodUrlOptionalProtocol.optional(),
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
if (!input.id && !input.slug && !input.url) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'At least one of the following parameters is required: id, slug, url',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlVariants = input.url
|
||||||
|
? [input.url]
|
||||||
|
.flatMap((url) =>
|
||||||
|
[
|
||||||
|
url,
|
||||||
|
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
|
||||||
|
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
|
||||||
|
].filter((url) => url !== undefined)
|
||||||
|
)
|
||||||
|
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
kycLevel: true,
|
||||||
|
verificationStatus: true,
|
||||||
|
categories: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceUrls: true,
|
||||||
|
onionUrls: true,
|
||||||
|
i2pUrls: true,
|
||||||
|
tosUrls: true,
|
||||||
|
referral: true,
|
||||||
|
listedAt: true,
|
||||||
|
verifiedAt: true,
|
||||||
|
serviceVisibility: true,
|
||||||
|
} as const satisfies Prisma.ServiceSelect
|
||||||
|
|
||||||
|
let service = await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
|
OR: [
|
||||||
|
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
|
||||||
|
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
|
||||||
|
...(urlVariants
|
||||||
|
? ([
|
||||||
|
{ serviceUrls: { hasSome: urlVariants } },
|
||||||
|
{ onionUrls: { hasSome: urlVariants } },
|
||||||
|
{ i2pUrls: { hasSome: urlVariants } },
|
||||||
|
] satisfies Prisma.ServiceWhereInput[])
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!service && input.slug) {
|
||||||
|
service = await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
|
previousSlugs: { has: input.slug },
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!service ||
|
||||||
|
(service.serviceVisibility !== 'PUBLIC' &&
|
||||||
|
service.serviceVisibility !== 'ARCHIVED' &&
|
||||||
|
service.serviceVisibility !== 'UNLISTED') ||
|
||||||
|
!service.listedAt ||
|
||||||
|
service.listedAt > new Date()
|
||||||
|
) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Service not found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: service.id,
|
||||||
|
slug: service.slug,
|
||||||
|
name: service.name,
|
||||||
|
description: service.description,
|
||||||
|
serviceVisibility: service.serviceVisibility,
|
||||||
|
verificationStatus: service.verificationStatus,
|
||||||
|
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
|
||||||
|
'value',
|
||||||
|
'slug',
|
||||||
|
'label',
|
||||||
|
'labelShort',
|
||||||
|
'description',
|
||||||
|
]),
|
||||||
|
verifiedAt: service.verifiedAt,
|
||||||
|
kycLevel: service.kycLevel,
|
||||||
|
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||||
|
categories: service.categories,
|
||||||
|
listedAt: service.listedAt,
|
||||||
|
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
|
||||||
|
(url) => url + (service.referral ?? '')
|
||||||
|
),
|
||||||
|
tosUrls: service.tosUrls,
|
||||||
|
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { accountActions } from './account'
|
import { accountActions } from './account'
|
||||||
import { adminActions } from './admin'
|
import { adminActions } from './admin'
|
||||||
|
import { apiActions } from './api'
|
||||||
import { commentActions } from './comment'
|
import { commentActions } from './comment'
|
||||||
import { notificationActions } from './notifications'
|
import { notificationActions } from './notifications'
|
||||||
import { serviceActions } from './service'
|
import { serviceActions } from './service'
|
||||||
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
|
|||||||
export const server = {
|
export const server = {
|
||||||
account: accountActions,
|
account: accountActions,
|
||||||
admin: adminActions,
|
admin: adminActions,
|
||||||
|
api: apiActions,
|
||||||
comment: commentActions,
|
comment: commentActions,
|
||||||
notification: notificationActions,
|
notification: notificationActions,
|
||||||
service: serviceActions,
|
service: serviceActions,
|
||||||
|
|||||||
@@ -23,6 +23,63 @@ export const notificationActions = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
webPush: {
|
||||||
|
subscribe: defineProtectedAction({
|
||||||
|
accept: 'json',
|
||||||
|
permissions: 'user',
|
||||||
|
input: z.object({
|
||||||
|
endpoint: z.string(),
|
||||||
|
p256dhKey: z.string(),
|
||||||
|
authKey: z.string(),
|
||||||
|
userAgent: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
await prisma.pushSubscription.upsert({
|
||||||
|
where: {
|
||||||
|
userId: context.locals.user.id,
|
||||||
|
endpoint: input.endpoint,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
p256dh: input.p256dhKey,
|
||||||
|
auth: input.authKey,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: context.locals.user.id,
|
||||||
|
endpoint: input.endpoint,
|
||||||
|
p256dh: input.p256dhKey,
|
||||||
|
auth: input.authKey,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
unsubscribe: defineProtectedAction({
|
||||||
|
accept: 'json',
|
||||||
|
permissions: 'user',
|
||||||
|
input: z.object({
|
||||||
|
endpoint: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handler: async (input, context) => {
|
||||||
|
if (input.endpoint) {
|
||||||
|
await prisma.pushSubscription.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: context.locals.user.id,
|
||||||
|
endpoint: input.endpoint,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await prisma.pushSubscription.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: context.locals.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
preferences: {
|
preferences: {
|
||||||
update: defineProtectedAction({
|
update: defineProtectedAction({
|
||||||
accept: 'form',
|
accept: 'form',
|
||||||
@@ -31,7 +88,7 @@ export const notificationActions = {
|
|||||||
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
|
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
|
||||||
enableAutowatchMyComments: z.coerce.boolean().optional(),
|
enableAutowatchMyComments: z.coerce.boolean().optional(),
|
||||||
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
|
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
|
||||||
karmaNotificationThreshold: z.coerce.number().int().min(1).optional(),
|
karmaNotificationThreshold: z.coerce.number().int().min(1).max(1_000_000).optional(),
|
||||||
}),
|
}),
|
||||||
handler: async (input, context) => {
|
handler: async (input, context) => {
|
||||||
await prisma.notificationPreferences.upsert({
|
await prisma.notificationPreferences.upsert({
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { Currency, KycLevelClarification } from '@prisma/client'
|
||||||
Currency,
|
|
||||||
ServiceSuggestionStatus,
|
|
||||||
ServiceSuggestionType,
|
|
||||||
ServiceVisibility,
|
|
||||||
VerificationStatus,
|
|
||||||
} from '@prisma/client'
|
|
||||||
import { z } from 'astro/zod'
|
import { z } from 'astro/zod'
|
||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { formatDistanceStrict } from 'date-fns'
|
import { formatDistanceStrict } from 'date-fns'
|
||||||
@@ -12,14 +6,11 @@ import { formatDistanceStrict } from 'date-fns'
|
|||||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
|
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import {
|
import { separateServiceUrlsByType } from '../lib/urls'
|
||||||
imageFileSchemaRequired,
|
import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
|
||||||
stringListOfUrlsSchema,
|
|
||||||
stringListOfUrlsSchemaRequired,
|
|
||||||
zodCohercedNumber,
|
|
||||||
} from '../lib/zodUtils'
|
|
||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
@@ -33,11 +24,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
|
|||||||
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
||||||
|
|
||||||
const findPossibleDuplicates = async (input: { name: string }) => {
|
const findPossibleDuplicates = async (input: { name: string }) => {
|
||||||
const possibleDuplicates = await prisma.service.findMany({
|
const matches = await findServicesBySimilarity(input.name, 0.3)
|
||||||
|
|
||||||
|
return await prisma.service.findMany({
|
||||||
where: {
|
where: {
|
||||||
name: {
|
id: {
|
||||||
contains: input.name,
|
in: matches.map(({ id }) => id),
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -47,8 +39,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
|||||||
description: true,
|
description: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return possibleDuplicates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||||
@@ -122,9 +112,9 @@ export const serviceSuggestionActions = {
|
|||||||
|
|
||||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||||
data: {
|
data: {
|
||||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
type: 'EDIT_SERVICE',
|
||||||
notes: combinedNotes,
|
notes: combinedNotes,
|
||||||
status: ServiceSuggestionStatus.PENDING,
|
status: 'PENDING',
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user.id,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
},
|
},
|
||||||
@@ -161,10 +151,10 @@ export const serviceSuggestionActions = {
|
|||||||
{ message: 'Slug must be unique, try a different one' }
|
{ 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),
|
||||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
onionUrls: stringListOfUrlsSchema,
|
|
||||||
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
||||||
|
kycLevelClarification: z.nativeEnum(KycLevelClarification),
|
||||||
attributes: z.array(z.coerce.number().int().positive()),
|
attributes: z.array(z.coerce.number().int().positive()),
|
||||||
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),
|
||||||
@@ -210,6 +200,12 @@ export const serviceSuggestionActions = {
|
|||||||
|
|
||||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
|
|
||||||
|
const {
|
||||||
|
web: serviceUrls,
|
||||||
|
onion: onionUrls,
|
||||||
|
i2p: i2pUrls,
|
||||||
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||||
|
|
||||||
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
|
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
|
||||||
const serviceSelect = {
|
const serviceSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -221,18 +217,20 @@ export const serviceSuggestionActions = {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
serviceUrls: input.serviceUrls,
|
serviceUrls,
|
||||||
tosUrls: input.tosUrls,
|
tosUrls: input.tosUrls,
|
||||||
onionUrls: input.onionUrls,
|
onionUrls,
|
||||||
|
i2pUrls,
|
||||||
kycLevel: input.kycLevel,
|
kycLevel: input.kycLevel,
|
||||||
|
kycLevelClarification: input.kycLevelClarification,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
listedAt: new Date(),
|
listedAt: new Date(),
|
||||||
serviceVisibility: ServiceVisibility.UNLISTED,
|
serviceVisibility: 'UNLISTED',
|
||||||
categories: {
|
categories: {
|
||||||
connect: input.categories.map((id) => ({ id })),
|
connect: input.categories.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
@@ -248,8 +246,8 @@ export const serviceSuggestionActions = {
|
|||||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||||
data: {
|
data: {
|
||||||
notes: input.notes,
|
notes: input.notes,
|
||||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
type: 'CREATE_SERVICE',
|
||||||
status: ServiceSuggestionStatus.PENDING,
|
status: 'PENDING',
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user.id,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cn } from '../lib/cn'
|
|||||||
|
|
||||||
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
||||||
|
|
||||||
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
|
type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
|
||||||
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
||||||
VariantProps<typeof button> & {
|
VariantProps<typeof button> & {
|
||||||
as: Tag
|
as: Tag
|
||||||
@@ -249,7 +249,7 @@ const button = tv({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
as: Tag = 'button' as 'a' | 'button' | 'label',
|
as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
endIcon,
|
endIcon,
|
||||||
@@ -286,8 +286,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
|||||||
|
|
||||||
<ActualTag
|
<ActualTag
|
||||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||||
role={role ??
|
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||||
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
id={`comment-${comment.id.toString()}`}
|
id={`comment-${comment.id.toString()}`}
|
||||||
class={cn([
|
class={cn([
|
||||||
'group',
|
'group bg-night-700',
|
||||||
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
|
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
|
||||||
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
|
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
|
||||||
'bg-[#182a1f]',
|
'bg-[#182a1f]',
|
||||||
@@ -270,12 +270,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
|
|
||||||
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
|
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
|
||||||
|
|
||||||
{
|
|
||||||
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
|
||||||
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
comment.rating !== null && !comment.parentId && (
|
comment.rating !== null && !comment.parentId && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -320,6 +314,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
color={commentStatusById.REJECTED.color}
|
color={commentStatusById.REJECTED.color}
|
||||||
text={commentStatusById.REJECTED.label}
|
text={commentStatusById.REJECTED.label}
|
||||||
inlineIcon
|
inlineIcon
|
||||||
|
endIcon="ri:lock-line"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
||||||
|
<BadgeSmall
|
||||||
|
icon="ri:alert-fill"
|
||||||
|
color="yellow"
|
||||||
|
text="Needs admin review"
|
||||||
|
inlineIcon
|
||||||
|
endIcon="ri:lock-line"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
|
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
|||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<InputRating name="rating" label="Rating" />
|
<InputRating name="rating" label="Rating" />
|
||||||
|
|
||||||
<InputWrapper label="Tags" name="tags">
|
<InputWrapper label="I experienced..." name="tags">
|
||||||
<label class="flex cursor-pointer items-center gap-2">
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ const links = [
|
|||||||
icon: 'i2p',
|
icon: 'i2p',
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/docs/api',
|
||||||
|
label: 'API',
|
||||||
|
icon: 'ri:plug-line',
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/about',
|
href: '/about',
|
||||||
label: 'About',
|
label: 'About',
|
||||||
|
|||||||
@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
|
|||||||
|
|
||||||
import Button from './Button.astro'
|
import Button from './Button.astro'
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
type Props = HTMLAttributes<'div'> & {
|
type Props = HTMLAttributes<'div'> & {
|
||||||
hideCancel?: boolean
|
hideCancel?: boolean
|
||||||
icon?: string
|
icon?: string
|
||||||
label?: string
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
color?: ComponentProps<typeof Button>['color']
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hideCancel = false,
|
hideCancel = false,
|
||||||
icon = 'ri:send-plane-2-line',
|
icon = 'ri:send-plane-2-line',
|
||||||
label = 'Submit',
|
label = 'Submit',
|
||||||
|
disabled = false,
|
||||||
class: className,
|
class: className,
|
||||||
|
color = 'success',
|
||||||
...htmlProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
|
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
|
||||||
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
|
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
|
||||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
|
<Button type="submit" label={label} icon={icon} class="ml-auto" color={color} disabled={disabled} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
374
web/src/components/PushNotificationBanner.astro
Normal file
374
web/src/components/PushNotificationBanner.astro
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
|
||||||
|
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
|
import Button from './Button.astro'
|
||||||
|
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
|
type Props = HTMLAttributes<'div'> & {
|
||||||
|
dismissable?: boolean
|
||||||
|
hideIfEnabled?: boolean
|
||||||
|
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||||
|
select: {
|
||||||
|
endpoint: true
|
||||||
|
userAgent: true
|
||||||
|
}
|
||||||
|
}>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
data-push-notification-banner
|
||||||
|
data-dismissed={undefined /* Updated by client script */}
|
||||||
|
data-supports-push-notifications={undefined /* Updated by client script */}
|
||||||
|
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
|
||||||
|
data-is-enabled={undefined /* Updated by client script */}
|
||||||
|
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',
|
||||||
|
'data-dismissed:hidden',
|
||||||
|
hideIfEnabled && 'data-is-enabled:hidden',
|
||||||
|
'not-data-supports-push-notifications:hidden',
|
||||||
|
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-blue-500/20 to-transparent opacity-50 blur-xl"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-blue-500/20 to-transparent opacity-50 blur-xl"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-x-3">
|
||||||
|
<div class="rounded-md bg-blue-800/30 p-2">
|
||||||
|
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-blue-100">
|
||||||
|
<span data-show-if-enabled>Push notifications enabled</span>
|
||||||
|
<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 class="flex items-center gap-2">
|
||||||
|
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
|
||||||
|
<Button
|
||||||
|
as="span"
|
||||||
|
label="Yes, notify me"
|
||||||
|
color="white"
|
||||||
|
data-push-action="subscribe"
|
||||||
|
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||||
|
data-show-if-disabled
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
as="span"
|
||||||
|
label="Stop notifications"
|
||||||
|
color="white"
|
||||||
|
variant="faded"
|
||||||
|
data-push-action="unsubscribe"
|
||||||
|
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||||
|
data-show-if-enabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/////////////////////////////////////////////////////////////
|
||||||
|
// Script to handle push notification banner dismissal. //
|
||||||
|
/////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
import { typedLocalStorage } from '../lib/localstorage'
|
||||||
|
|
||||||
|
document.addEventListener('astro:page-load', () => {
|
||||||
|
let pushNotificationsBannerDismissedAt = typedLocalStorage.pushNotificationsBannerDismissedAt.get()
|
||||||
|
|
||||||
|
if (
|
||||||
|
pushNotificationsBannerDismissedAt &&
|
||||||
|
pushNotificationsBannerDismissedAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 365) // 1 year
|
||||||
|
) {
|
||||||
|
typedLocalStorage.pushNotificationsBannerDismissedAt.remove()
|
||||||
|
pushNotificationsBannerDismissedAt = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((banner) => {
|
||||||
|
const skipButton = banner.querySelector<HTMLElement>('[data-dismiss-button]')
|
||||||
|
if (!skipButton) return
|
||||||
|
|
||||||
|
if (pushNotificationsBannerDismissedAt) {
|
||||||
|
banner.dataset.dismissed = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
skipButton.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
banner.dataset.dismissed = ''
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
typedLocalStorage.pushNotificationsBannerDismissedAt.set(now)
|
||||||
|
pushNotificationsBannerDismissedAt = now
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/////////////////////////////////////////////////////////
|
||||||
|
// Script to style when notifications enabled. //
|
||||||
|
////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
type ServerSubscription = {
|
||||||
|
endpoint: string
|
||||||
|
userAgent: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse push subscriptions from string */
|
||||||
|
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
||||||
|
try {
|
||||||
|
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
|
||||||
|
|
||||||
|
const subscriptions = JSON.parse(subscriptionsAsString)
|
||||||
|
|
||||||
|
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
|
||||||
|
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 */
|
||||||
|
async function getCurrentPushSubscription() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
if (!registration) return null
|
||||||
|
|
||||||
|
return await registration.pushManager.getSubscription()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting current push subscription:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if current subscription matches any server subscription */
|
||||||
|
function isCurrentDeviceSubscribed(
|
||||||
|
currentSubscription: PushSubscription | null,
|
||||||
|
serverSubscriptions: ServerSubscription[]
|
||||||
|
) {
|
||||||
|
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||||
|
|
||||||
|
const currentEndpoint = currentSubscription.endpoint
|
||||||
|
const currentUserAgent = navigator.userAgent
|
||||||
|
|
||||||
|
return serverSubscriptions.some(
|
||||||
|
(sub) =>
|
||||||
|
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('astro:page-load', async () => {
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
|
||||||
|
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
||||||
|
const currentSubscription = await getCurrentPushSubscription()
|
||||||
|
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
|
||||||
|
|
||||||
|
if (isSubscribed) banner.dataset.isEnabled = ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/////////////////////////////////////////////////////////////
|
||||||
|
// Script to handle push notification subscription. //
|
||||||
|
/////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
import type { actions } from 'astro:actions'
|
||||||
|
import type { ActionInput } from '../lib/astroActions'
|
||||||
|
|
||||||
|
/** Utility function to convert VAPID key */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check for browser support */
|
||||||
|
function checkSupport() {
|
||||||
|
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 registerServiceWorker() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register('/sw.js')
|
||||||
|
console.log('Service Worker registered:', registration)
|
||||||
|
|
||||||
|
const readyRegistration = await navigator.serviceWorker.ready
|
||||||
|
console.log('Service Worker is active and ready:', readyRegistration)
|
||||||
|
|
||||||
|
return readyRegistration
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service Worker registration failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeToPush(vapidPublicKey: string) {
|
||||||
|
try {
|
||||||
|
if (!checkSupport()) return
|
||||||
|
|
||||||
|
// Request notification permission
|
||||||
|
const permission = await Notification.requestPermission()
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
alert('Push notifications permission denied')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await registerServiceWorker()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -87,6 +87,25 @@ function makeLink(url: string, referral: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bitcointalkMatch = /^(?:https?:\/\/)?(?:www\.)?bitcointalk\.org$/.exec(hostname)
|
||||||
|
if (bitcointalkMatch) {
|
||||||
|
return {
|
||||||
|
type: 'clearnet' as const,
|
||||||
|
url: urlWithReferral,
|
||||||
|
textBits: [
|
||||||
|
{
|
||||||
|
style: 'normal',
|
||||||
|
text: 'BitcoinTalk ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
style: 'irrelevant',
|
||||||
|
text: 'thread',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
icon: networksBySlug.clearnet.icon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'clearnet' as const,
|
type: 'clearnet' as const,
|
||||||
url: urlWithReferral,
|
url: urlWithReferral,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const {
|
|||||||
class={cn(
|
class={cn(
|
||||||
// Check the scam filter when there is a text quey and the user has checked verified and approved
|
// Check the scam filter when there is a text quey and the user has checked verified and approved
|
||||||
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
|
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
|
||||||
|
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -80,16 +81,20 @@ const {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<p class="text-day-500 mt-1.5 text-center text-sm">
|
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
|
||||||
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
|
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||||
Ties randomly sorted
|
Ties randomly sorted
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
|
||||||
|
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||||
|
Sorted by match first
|
||||||
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- Text Search -->
|
<!-- Text Search -->
|
||||||
<fieldset class="mb-6">
|
<fieldset class="mb-6">
|
||||||
<legend class="font-title mb-3 leading-none text-green-500">
|
<legend class="font-title mb-3 leading-none text-green-500">
|
||||||
<label for="q">Text</label>
|
<label for="q">Name</label>
|
||||||
</legend>
|
</legend>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
sortSeed?: string
|
sortSeed?: string
|
||||||
filters: ServicesFiltersObject
|
filters: ServicesFiltersObject
|
||||||
includeScams: boolean
|
|
||||||
countCommunityOnly: number | null
|
countCommunityOnly: number | null
|
||||||
inlineIcons?: boolean
|
inlineIcons?: boolean
|
||||||
}
|
}
|
||||||
@@ -35,15 +34,12 @@ const {
|
|||||||
sortSeed,
|
sortSeed,
|
||||||
class: className,
|
class: className,
|
||||||
filters,
|
filters,
|
||||||
includeScams,
|
|
||||||
countCommunityOnly,
|
countCommunityOnly,
|
||||||
inlineIcons,
|
inlineIcons,
|
||||||
...divProps
|
...divProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
const hasScams =
|
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
||||||
filters.verification.includes('VERIFICATION_FAILED') || includeScams
|
|
||||||
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
|
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
|
||||||
|
|
||||||
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
|
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
|
||||||
@@ -75,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
countCommunityOnly && (
|
!!countCommunityOnly && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
@@ -196,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
inlineIcon={inlineIcons}
|
inlineIcon={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{countCommunityOnly && (
|
{!!countCommunityOnly && (
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={urlIfIncludingCommunity}
|
href={urlIfIncludingCommunity}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
|||||||
}
|
}
|
||||||
icon: string
|
icon: string
|
||||||
color: ComponentProps<typeof BadgeSmall>['color']
|
color: ComponentProps<typeof BadgeSmall>['color']
|
||||||
|
isSolved: boolean
|
||||||
|
showBanner: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -36,6 +38,8 @@ export const {
|
|||||||
},
|
},
|
||||||
icon: 'ri:question-fill',
|
icon: 'ri:question-fill',
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: false,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -46,8 +50,10 @@ export const {
|
|||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:error-warning-fill',
|
icon: 'ri:alert-fill',
|
||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'WARNING_SOLVED',
|
id: 'WARNING_SOLVED',
|
||||||
@@ -55,10 +61,12 @@ export const {
|
|||||||
label: 'Warning Solved',
|
label: 'Warning Solved',
|
||||||
description: 'A previously reported warning has been solved',
|
description: 'A previously reported warning has been solved',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-green-900 text-green-300 ring-green-900/50',
|
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:check-fill',
|
icon: 'ri:alert-fill',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
|
isSolved: true,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ALERT',
|
id: 'ALERT',
|
||||||
@@ -68,8 +76,10 @@ export const {
|
|||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:alert-fill',
|
icon: 'ri:spam-fill',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ALERT_SOLVED',
|
id: 'ALERT_SOLVED',
|
||||||
@@ -77,10 +87,12 @@ export const {
|
|||||||
label: 'Alert Solved',
|
label: 'Alert Solved',
|
||||||
description: 'A previously reported alert has been solved',
|
description: 'A previously reported alert has been solved',
|
||||||
classNames: {
|
classNames: {
|
||||||
dot: 'bg-green-900 text-green-300 ring-green-900/50',
|
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||||
},
|
},
|
||||||
icon: 'ri:check-fill',
|
icon: 'ri:spam-fill',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
|
isSolved: true,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'INFO',
|
id: 'INFO',
|
||||||
@@ -92,6 +104,8 @@ export const {
|
|||||||
},
|
},
|
||||||
icon: 'ri:information-fill',
|
icon: 'ri:information-fill',
|
||||||
color: 'sky',
|
color: 'sky',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'NORMAL',
|
id: 'NORMAL',
|
||||||
@@ -103,6 +117,8 @@ export const {
|
|||||||
},
|
},
|
||||||
icon: 'ri:notification-fill',
|
icon: 'ri:notification-fill',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'UPDATE',
|
id: 'UPDATE',
|
||||||
@@ -114,6 +130,8 @@ export const {
|
|||||||
},
|
},
|
||||||
icon: 'ri:pencil-fill',
|
icon: 'ri:pencil-fill',
|
||||||
color: 'sky',
|
color: 'sky',
|
||||||
|
isSolved: false,
|
||||||
|
showBanner: false,
|
||||||
},
|
},
|
||||||
] as const satisfies EventTypeInfo<EventType>[]
|
] as const satisfies EventTypeInfo<EventType>[]
|
||||||
)
|
)
|
||||||
|
|||||||
39
web/src/constants/kycLevelClarifications.ts
Normal file
39
web/src/constants/kycLevelClarifications.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||||
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
|
import type { KycLevelClarification } from '@prisma/client'
|
||||||
|
|
||||||
|
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const {
|
||||||
|
dataArray: kycLevelClarifications,
|
||||||
|
dataObject: kycLevelClarificationsById,
|
||||||
|
getFn: getKycLevelClarificationInfo,
|
||||||
|
} = makeHelpersForOptions(
|
||||||
|
'value',
|
||||||
|
(value): KycLevelClarificationInfo<typeof value> => ({
|
||||||
|
value,
|
||||||
|
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||||
|
description: '',
|
||||||
|
icon: 'ri:question-line',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: 'NONE',
|
||||||
|
label: 'None',
|
||||||
|
description: 'No clarification needed.',
|
||||||
|
icon: 'ri:file-copy-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'DEPENDS_ON_PARTNERS',
|
||||||
|
label: 'Depends on partners',
|
||||||
|
description: 'May vary across partners.',
|
||||||
|
icon: 'ri:share-forward-line',
|
||||||
|
},
|
||||||
|
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
|
||||||
|
)
|
||||||
@@ -1 +1 @@
|
|||||||
export const SUPPORT_EMAIL = 'support@kycnot.me'
|
export const SUPPORT_EMAIL = 'contact@kycnot.me'
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const {
|
|||||||
value: 'ARCHIVED',
|
value: 'ARCHIVED',
|
||||||
slug: 'archived',
|
slug: 'archived',
|
||||||
label: 'Archived',
|
label: 'Archived',
|
||||||
description: 'No longer operational',
|
description: 'No longer operational.',
|
||||||
longDescription:
|
longDescription:
|
||||||
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
||||||
icon: 'ri:archive-line',
|
icon: 'ri:archive-line',
|
||||||
|
|||||||
32
web/src/lib/endpoints.ts
Normal file
32
web/src/lib/endpoints.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { type ActionClient } from 'astro:actions'
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
import type { z } from 'astro/zod'
|
||||||
|
|
||||||
|
export function makeEndpointFromAction<Action extends ActionClient<unknown, 'json', z.ZodType> & string>(
|
||||||
|
action: Action
|
||||||
|
): APIRoute {
|
||||||
|
return async (context) => {
|
||||||
|
try {
|
||||||
|
const input = await context.request.json()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const result = await context.callAction(action, input)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error('Error on endpoint', result.error)
|
||||||
|
return new Response(JSON.stringify({ error: result.error.message }), {
|
||||||
|
status: result.error.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(result.data), {
|
||||||
|
status: 200,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error on endpoint', error)
|
||||||
|
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,13 @@ export class ErrorBanners {
|
|||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handler(uiMessage)(error)
|
this.handler(uiMessage)(error)
|
||||||
return fallback as F
|
return fallback as F extends never[]
|
||||||
|
? T extends [infer _First, ...infer _Rest]
|
||||||
|
? []
|
||||||
|
: T extends unknown[]
|
||||||
|
? T[number][]
|
||||||
|
: F
|
||||||
|
: F
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
web/src/lib/findServicesBySimilarity.ts
Normal file
16
web/src/lib/findServicesBySimilarity.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from 'astro/zod'
|
||||||
|
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
|
export async function findServicesBySimilarity(value: string, similarityThreshold = 0.01) {
|
||||||
|
const data = await prisma.$queryRaw`
|
||||||
|
SELECT id, similarity(name, ${value}) AS similarity_score
|
||||||
|
FROM "Service"
|
||||||
|
WHERE similarity(name, ${value}) >= ${similarityThreshold}
|
||||||
|
ORDER BY similarity(name, ${value}) desc`
|
||||||
|
|
||||||
|
const schema = z.array(z.object({ id: z.number(), similarity_score: z.number() }))
|
||||||
|
const parsedData = schema.parse(data)
|
||||||
|
|
||||||
|
return parsedData.map(({ id, similarity_score }) => ({ id, similarityScore: similarity_score }))
|
||||||
|
}
|
||||||
77
web/src/lib/localstorage.ts
Normal file
77
web/src/lib/localstorage.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { z } from 'astro:schema'
|
||||||
|
|
||||||
|
import { typedObjectEntries } from './objects'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
|
interface JSONObject {
|
||||||
|
[k: string]: JSONValue
|
||||||
|
}
|
||||||
|
type JSONList = JSONValue[]
|
||||||
|
type JSONPrimitive = boolean | number | string | null
|
||||||
|
type JSONValue = Date | JSONList | JSONObject | JSONPrimitive
|
||||||
|
|
||||||
|
function makeTypedLocalStorage<
|
||||||
|
Schemas extends Record<string, z.ZodType<JSONValue>>,
|
||||||
|
T extends {
|
||||||
|
[K in keyof Schemas]: {
|
||||||
|
schema: Schemas[K]
|
||||||
|
default?: z.output<Schemas[K]> | undefined
|
||||||
|
key?: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
>(options: T) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
typedObjectEntries(options).map(([originalKey, option]) => {
|
||||||
|
const key = option.key ?? originalKey
|
||||||
|
|
||||||
|
return [
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
get: () => {
|
||||||
|
const stringValue = localStorage.getItem(key)
|
||||||
|
if (!stringValue) return option.default
|
||||||
|
|
||||||
|
let jsonValue: z.output<typeof option.schema> | undefined = option.default
|
||||||
|
try {
|
||||||
|
jsonValue = JSON.parse(stringValue)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return option.default
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = option.schema.safeParse(jsonValue)
|
||||||
|
if (!parsedValue.success) {
|
||||||
|
console.error(parsedValue.error)
|
||||||
|
return option.default
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue.data
|
||||||
|
},
|
||||||
|
|
||||||
|
set: (value: z.input<typeof option.schema>) => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: () => {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
},
|
||||||
|
|
||||||
|
default: option.default,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
) as {
|
||||||
|
[K in keyof T]: {
|
||||||
|
get: () => z.output<T[K]['schema']> | (T[K] extends { default: infer D } ? D : undefined)
|
||||||
|
set: (value: z.input<T[K]['schema']>) => void
|
||||||
|
remove: () => void
|
||||||
|
default: z.output<T[K]['schema']> | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const typedLocalStorage = makeTypedLocalStorage({
|
||||||
|
pushNotificationsBannerDismissedAt: {
|
||||||
|
schema: z.coerce.date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
50
web/src/lib/makeAdminApiCallInfo.ts
Normal file
50
web/src/lib/makeAdminApiCallInfo.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Misc } from 'ts-toolbelt'
|
||||||
|
|
||||||
|
export async function makeAdminApiCallInfo<T extends Misc.JSON.Object>({
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
input,
|
||||||
|
baseUrl,
|
||||||
|
}: {
|
||||||
|
method: 'POST' | 'QUERY'
|
||||||
|
path: `/${string}`
|
||||||
|
input: T
|
||||||
|
baseUrl: URL | string
|
||||||
|
}) {
|
||||||
|
const fullPath = new URL(`/api/v1${path}`, baseUrl).href
|
||||||
|
|
||||||
|
const fetchProsmise = fetch(fullPath, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}).then((res) => {
|
||||||
|
try {
|
||||||
|
return res.json() as Promise<Misc.JSON.Value>
|
||||||
|
} catch (errJson: unknown) {
|
||||||
|
console.error(errJson)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return res.text()
|
||||||
|
} catch (errText: unknown) {
|
||||||
|
console.error(errText)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let output: Misc.JSON.Value = ''
|
||||||
|
try {
|
||||||
|
output = await fetchProsmise
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
output = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
fullPath,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,3 +162,16 @@ export function areEqualObjectsWithoutOrder<T extends Record<string, unknown>>(
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link Object.entries}, but with proper typing.
|
||||||
|
* @example
|
||||||
|
* typedObjectEntries({ a: 1, b: 2 }) // [['a', 1], ['b', 2]]
|
||||||
|
*/
|
||||||
|
export function typedObjectEntries<T extends Record<string, unknown>>(obj: T) {
|
||||||
|
return Object.entries(obj) as Prettify<
|
||||||
|
{
|
||||||
|
[K in Extract<keyof T, string>]: [K, T[K]]
|
||||||
|
}[Extract<keyof T, string>]
|
||||||
|
>[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,24 +25,23 @@ const findManyAndCount = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
|
// NOTE: This used to be necessary to cast the prismaClientSingleton return type, but it seems not anymore. I left it, just in case we need it again
|
||||||
|
// type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
|
||||||
|
|
||||||
type ModelsWithCustomMethods = {
|
// type ModelsWithCustomMethods = {
|
||||||
[Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
// [Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
||||||
findMany: (...args: any[]) => Promise<any>
|
// findMany: (...args: any[]) => Promise<any>
|
||||||
}
|
// }
|
||||||
? PrismaClient[Model] & {
|
// ? PrismaClient[Model] & {
|
||||||
findManyAndCount: FindManyAndCountType
|
// findManyAndCount: FindManyAndCountType
|
||||||
}
|
// }
|
||||||
: PrismaClient[Model]
|
// : PrismaClient[Model]
|
||||||
}
|
// }
|
||||||
|
|
||||||
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
// type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
||||||
|
|
||||||
function prismaClientSingleton(): ExtendedPrismaClient {
|
function prismaClientSingleton() {
|
||||||
const prisma = new PrismaClient().$extends(findManyAndCount)
|
return new PrismaClient().$extends(findManyAndCount)
|
||||||
|
|
||||||
return prisma as unknown as ExtendedPrismaClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
|
|||||||
}
|
}
|
||||||
return url.origin
|
return url.origin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function separateServiceUrlsByType(allServiceUrls: string[]) {
|
||||||
|
const result: {
|
||||||
|
web: string[]
|
||||||
|
onion: string[]
|
||||||
|
i2p: string[]
|
||||||
|
} = {
|
||||||
|
web: [],
|
||||||
|
onion: [],
|
||||||
|
i2p: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of allServiceUrls) {
|
||||||
|
const parsedUrl = new URL(url)
|
||||||
|
if (parsedUrl.origin.endsWith('.onion')) {
|
||||||
|
result.onion.push(url)
|
||||||
|
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
|
||||||
|
result.i2p.push(url)
|
||||||
|
} else {
|
||||||
|
result.web.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
52
web/src/lib/webPush.ts
Normal file
52
web/src/lib/webPush.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/* eslint-disable import/no-named-as-default-member */
|
||||||
|
import { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } from 'astro:env/server'
|
||||||
|
import webpush, { WebPushError } from 'web-push'
|
||||||
|
|
||||||
|
// Configure VAPID keys
|
||||||
|
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
||||||
|
|
||||||
|
export { webpush }
|
||||||
|
|
||||||
|
export async function sendPushNotification(
|
||||||
|
subscription: {
|
||||||
|
endpoint: string
|
||||||
|
keys: {
|
||||||
|
p256dh: string
|
||||||
|
auth: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
icon?: string
|
||||||
|
badge?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await webpush.sendNotification(
|
||||||
|
subscription,
|
||||||
|
JSON.stringify({
|
||||||
|
title: data.title,
|
||||||
|
options: {
|
||||||
|
body: data.body,
|
||||||
|
icon: data.icon ?? '/favicon.svg',
|
||||||
|
badge: data.badge ?? '/favicon.svg',
|
||||||
|
data: {
|
||||||
|
url: data.url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
TTL: 24 * 60 * 60, // 24 hours
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return { success: true, result } as const
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending push notification:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof WebPushError ? error : undefined,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export const zodUrlOptionalProtocol = z.preprocess(
|
|||||||
const cleanInput = input.trim().replace(/\/$/, '')
|
const cleanInput = input.trim().replace(/\/$/, '')
|
||||||
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
|
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
|
||||||
},
|
},
|
||||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||||
message: 'Invalid URL',
|
message: 'Invalid URL',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -34,6 +34,11 @@ const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
|
|||||||
.filter((item) => item !== '')
|
.filter((item) => item !== '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const stringListOfSlugsSchemaRequired = z.preprocess(
|
||||||
|
stringToArrayFactory(/[\s,\n]+/),
|
||||||
|
z.array(z.string().regex(/^[a-z0-9-_A-Z]+$/)).min(1)
|
||||||
|
)
|
||||||
|
|
||||||
export const stringListOfUrlsSchema = z.preprocess(
|
export const stringListOfUrlsSchema = z.preprocess(
|
||||||
stringToArrayFactory(/[\s,\n]+/),
|
stringToArrayFactory(/[\s,\n]+/),
|
||||||
z.array(zodUrlOptionalProtocol).default([])
|
z.array(zodUrlOptionalProtocol).default([])
|
||||||
|
|||||||
@@ -201,6 +201,12 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
|
|||||||
|
|
||||||
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
|
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
You can access basic service data via our public API.
|
||||||
|
|
||||||
|
See the [API page](/docs/api) for more details.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you like this project, you can **support** it through these methods:
|
If you like this project, you can **support** it through these methods:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
disabled: !user.karmaUnlocks.displayName,
|
disabled: !user.karmaUnlocks.displayName,
|
||||||
}}
|
}}
|
||||||
description={!user.karmaUnlocks.displayName
|
description={!user.karmaUnlocks.displayName
|
||||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/karma)`
|
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/docs/karma)`
|
||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
disabled: !user.karmaUnlocks.websiteLink,
|
disabled: !user.karmaUnlocks.websiteLink,
|
||||||
}}
|
}}
|
||||||
description={!user.karmaUnlocks.websiteLink
|
description={!user.karmaUnlocks.websiteLink
|
||||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/karma)`
|
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/docs/karma)`
|
||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
square
|
square
|
||||||
disabled={!user.karmaUnlocks.profilePicture}
|
disabled={!user.karmaUnlocks.profilePicture}
|
||||||
description={!user.karmaUnlocks.profilePicture
|
description={!user.karmaUnlocks.profilePicture
|
||||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/karma)`
|
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/docs/karma)`
|
||||||
: undefined}
|
: undefined}
|
||||||
removeCheckbox={user.picture
|
removeCheckbox={user.picture
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -529,8 +529,9 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
|
|
||||||
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
||||||
<p class="text-day-300">
|
<p class="text-day-300">
|
||||||
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
|
Earn karma to unlock features and privileges. <a
|
||||||
>Learn about karma</a
|
href="/docs/karma"
|
||||||
|
class="text-day-200 hover:underline">Learn about karma</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ if (toggleResult?.error) {
|
|||||||
label: type.label,
|
label: type.label,
|
||||||
value: type.value,
|
value: type.value,
|
||||||
icon: type.icon,
|
icon: type.icon,
|
||||||
|
noTransitionPersist: false,
|
||||||
}))}
|
}))}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
required
|
required
|
||||||
@@ -305,8 +306,8 @@ if (toggleResult?.error) {
|
|||||||
label="Status"
|
label="Status"
|
||||||
error={createInputErrors.isActive}
|
error={createInputErrors.isActive}
|
||||||
options={[
|
options={[
|
||||||
{ label: 'Active', value: 'true' },
|
{ label: 'Active', value: 'true', noTransitionPersist: true },
|
||||||
{ label: 'Inactive', value: 'false' },
|
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
|
||||||
]}
|
]}
|
||||||
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
|
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
@@ -627,6 +628,7 @@ if (toggleResult?.error) {
|
|||||||
label: type.label,
|
label: type.label,
|
||||||
value: type.value,
|
value: type.value,
|
||||||
icon: type.icon,
|
icon: type.icon,
|
||||||
|
noTransitionPersist: true,
|
||||||
}))}
|
}))}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
required
|
required
|
||||||
@@ -659,8 +661,8 @@ if (toggleResult?.error) {
|
|||||||
name="isActive"
|
name="isActive"
|
||||||
label="Status"
|
label="Status"
|
||||||
options={[
|
options={[
|
||||||
{ label: 'Active', value: 'true' },
|
{ label: 'Active', value: 'true', noTransitionPersist: true },
|
||||||
{ label: 'Inactive', value: 'false' },
|
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
|
||||||
]}
|
]}
|
||||||
selectedValue={announcement.isActive ? 'true' : 'false'}
|
selectedValue={announcement.isActive ? 'true' : 'false'}
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { DATABASE_UI_URL } from 'astro:env/server'
|
import { DATABASE_UI_URL, LOGS_UI_URL } from 'astro:env/server'
|
||||||
|
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../../lib/cn'
|
import { cn } from '../../lib/cn'
|
||||||
@@ -65,6 +65,14 @@ const adminLinks: AdminLink[] = [
|
|||||||
base: 'text-pink-300',
|
base: 'text-pink-300',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: 'ri:notification-3-line',
|
||||||
|
title: 'Notifications',
|
||||||
|
href: '/admin/notifications',
|
||||||
|
classNames: {
|
||||||
|
base: 'text-indigo-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: 'ri:rocket-2-line',
|
icon: 'ri:rocket-2-line',
|
||||||
title: 'Releases',
|
title: 'Releases',
|
||||||
@@ -81,6 +89,18 @@ const adminLinks: AdminLink[] = [
|
|||||||
base: 'text-gray-300',
|
base: 'text-gray-300',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(LOGS_UI_URL
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: 'ri:menu-search-line',
|
||||||
|
title: 'Logs',
|
||||||
|
href: LOGS_UI_URL,
|
||||||
|
classNames: {
|
||||||
|
base: 'text-cyan-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -93,25 +113,27 @@ const adminLinks: AdminLink[] = [
|
|||||||
<nav>
|
<nav>
|
||||||
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
|
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
|
||||||
{
|
{
|
||||||
adminLinks.map((link) => (
|
adminLinks
|
||||||
<li
|
.filter((link) => link.href)
|
||||||
class={cn(
|
.map((link) => (
|
||||||
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
|
<li
|
||||||
link.classNames.base
|
class={cn(
|
||||||
)}
|
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
|
||||||
>
|
link.classNames.base
|
||||||
<a
|
)}
|
||||||
href={link.href}
|
|
||||||
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
|
|
||||||
>
|
>
|
||||||
<Icon
|
<a
|
||||||
name={link.icon}
|
href={link.href}
|
||||||
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
|
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
|
||||||
/>
|
>
|
||||||
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
|
<Icon
|
||||||
</a>
|
name={link.icon}
|
||||||
</li>
|
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
|
||||||
))
|
/>
|
||||||
|
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
155
web/src/pages/admin/notifications.astro
Normal file
155
web/src/pages/admin/notifications.astro
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { actions, isInputError } from 'astro:actions'
|
||||||
|
import { groupBy, round, uniq } from 'lodash-es'
|
||||||
|
|
||||||
|
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||||
|
import InputText from '../../components/InputText.astro'
|
||||||
|
import InputTextArea from '../../components/InputTextArea.astro'
|
||||||
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if (!Astro.locals.user?.admin) {
|
||||||
|
return Astro.redirect('/access-denied')
|
||||||
|
}
|
||||||
|
|
||||||
|
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
|
||||||
|
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
|
||||||
|
|
||||||
|
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
|
||||||
|
|
||||||
|
const subscriptions = await Astro.locals.banners.try(
|
||||||
|
'Error while fetching subscriptions by user',
|
||||||
|
() =>
|
||||||
|
prisma.pushSubscription.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[] as []
|
||||||
|
)
|
||||||
|
const totalSubscriptions = subscriptions.length
|
||||||
|
const subscriptionsByUser = groupBy(subscriptions, 'user.id')
|
||||||
|
|
||||||
|
const totalUsers = Object.keys(subscriptionsByUser).length
|
||||||
|
|
||||||
|
const adminUsers = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
icon: 'ri:notification-4-line',
|
||||||
|
iconClass: 'text-blue-400',
|
||||||
|
title: 'Total Subscriptions',
|
||||||
|
value: totalSubscriptions.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'ri:user-3-line',
|
||||||
|
iconClass: 'text-green-400',
|
||||||
|
title: 'Subscribed Users',
|
||||||
|
value: totalUsers.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'ri:smartphone-line',
|
||||||
|
iconClass: 'text-purple-400',
|
||||||
|
title: 'Avg Devices/User',
|
||||||
|
value: (totalUsers > 0 ? round(totalSubscriptions / totalUsers, 1) : 0).toLocaleString(),
|
||||||
|
},
|
||||||
|
] satisfies {
|
||||||
|
icon: string
|
||||||
|
iconClass: string
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
}[]
|
||||||
|
---
|
||||||
|
|
||||||
|
<MiniLayout
|
||||||
|
pageTitle="Push notifications"
|
||||||
|
description="Send test notifications"
|
||||||
|
layoutHeader={{
|
||||||
|
icon: 'ri:notification-3-line',
|
||||||
|
title: 'Push notifications',
|
||||||
|
subtitle: 'Send test notifications',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="mb-6 grid gap-4 sm:grid-cols-3">
|
||||||
|
{
|
||||||
|
stats.map((stat) => (
|
||||||
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
|
<div class="flex items-end gap-1">
|
||||||
|
<span class="text-5xl leading-[0.8] font-bold text-white">{stat.value}</span>
|
||||||
|
<Icon name={stat.icon} class={cn('size-5 shrink-0', stat.iconClass)} />
|
||||||
|
</div>
|
||||||
|
<span class="text-day-200 flex grow flex-col justify-center text-sm leading-none font-medium text-balance">
|
||||||
|
{stat.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
|
||||||
|
|
||||||
|
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
|
||||||
|
<InputTextArea
|
||||||
|
label="Users"
|
||||||
|
name="userNames"
|
||||||
|
inputProps={{
|
||||||
|
placeholder: 'john-doe, jane-doe',
|
||||||
|
class: 'leading-tight min-h-24',
|
||||||
|
}}
|
||||||
|
value={uniq([Astro.locals.user, ...adminUsers].map((user) => user.name)).join('\n')}
|
||||||
|
description={[
|
||||||
|
'- Comma-separated list of user names.',
|
||||||
|
'- Minimum 1 user name.',
|
||||||
|
'- By default, all admin users are selected.',
|
||||||
|
].join('\n')}
|
||||||
|
error={testInputErrors.userNames}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
label="Title"
|
||||||
|
name="title"
|
||||||
|
inputProps={{
|
||||||
|
value: 'Test Notification',
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
error={testInputErrors.title}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputTextArea
|
||||||
|
label="Body"
|
||||||
|
name="body"
|
||||||
|
inputProps={{
|
||||||
|
value: 'This is a test push notification from KYCNot.me',
|
||||||
|
}}
|
||||||
|
error={testInputErrors.body}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
label="Action URL"
|
||||||
|
name="url"
|
||||||
|
inputProps={{
|
||||||
|
placeholder: 'https://example.com/path',
|
||||||
|
}}
|
||||||
|
description="URL to open when the notification is clicked"
|
||||||
|
error={testInputErrors.url}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
|
||||||
|
</form>
|
||||||
|
</MiniLayout>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
|
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
|
||||||
|
|
||||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
|
||||||
import MiniLayout from '../../layouts/MiniLayout.astro'
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
|
import { timeAgo } from '../../lib/timeAgo'
|
||||||
|
|
||||||
const releaseDate =
|
const releaseDate =
|
||||||
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
|
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
|
||||||
@@ -37,7 +37,7 @@ const releaseDate =
|
|||||||
{
|
{
|
||||||
!!releaseDate && (
|
!!releaseDate && (
|
||||||
<p class="text-day-500 mt-2">
|
<p class="text-day-500 mt-2">
|
||||||
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />)
|
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { Markdown } from 'astro-remote'
|
import { Markdown } from 'astro-remote'
|
||||||
import { actions, isInputError } from 'astro:actions'
|
import { actions, isInputError } from 'astro:actions'
|
||||||
|
import { Code } from 'astro:components'
|
||||||
import { orderBy } from 'lodash-es'
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
import BadgeSmall from '../../../../components/BadgeSmall.astro'
|
import BadgeSmall from '../../../../components/BadgeSmall.astro'
|
||||||
@@ -25,6 +26,7 @@ import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
|
|||||||
import { formatContactMethod } from '../../../../constants/contactMethods'
|
import { formatContactMethod } from '../../../../constants/contactMethods'
|
||||||
import { currencies } from '../../../../constants/currencies'
|
import { currencies } from '../../../../constants/currencies'
|
||||||
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
|
||||||
|
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
|
||||||
import { kycLevels } from '../../../../constants/kycLevels'
|
import { kycLevels } from '../../../../constants/kycLevels'
|
||||||
import { serviceVisibilities } from '../../../../constants/serviceVisibility'
|
import { serviceVisibilities } from '../../../../constants/serviceVisibility'
|
||||||
import { verificationStatuses } from '../../../../constants/verificationStatus'
|
import { verificationStatuses } from '../../../../constants/verificationStatus'
|
||||||
@@ -33,6 +35,8 @@ import {
|
|||||||
verificationStepStatuses,
|
verificationStepStatuses,
|
||||||
} from '../../../../constants/verificationStepStatus'
|
} from '../../../../constants/verificationStepStatus'
|
||||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||||
|
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
|
||||||
|
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
||||||
import { pluralize } from '../../../../lib/pluralize'
|
import { pluralize } from '../../../../lib/pluralize'
|
||||||
import { prisma } from '../../../../lib/prisma'
|
import { prisma } from '../../../../lib/prisma'
|
||||||
|
|
||||||
@@ -180,7 +184,35 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!service) return Astro.rewrite('/404')
|
if (!service) {
|
||||||
|
try {
|
||||||
|
const serviceWithOldSlug = await prisma.service.findFirst({
|
||||||
|
where: { previousSlugs: { has: slug } },
|
||||||
|
select: { slug: true },
|
||||||
|
})
|
||||||
|
if (serviceWithOldSlug) {
|
||||||
|
return Astro.redirect(`/admin/services/${serviceWithOldSlug.slug}/edit`, 301)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Astro.rewrite('/404')
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiCalls = await Astro.locals.banners.try(
|
||||||
|
'Error fetching api calls',
|
||||||
|
() =>
|
||||||
|
Promise.all([
|
||||||
|
makeAdminApiCallInfo({
|
||||||
|
method: 'QUERY',
|
||||||
|
path: '/service/get',
|
||||||
|
input: { slug: service.slug },
|
||||||
|
baseUrl: Astro.url,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
[]
|
||||||
|
)
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout pageTitle={`Edit Service: ${service.name}`}>
|
<BaseLayout pageTitle={`Edit Service: ${service.name}`}>
|
||||||
@@ -233,16 +265,32 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={service.id} />
|
<input type="hidden" name="id" value={service.id} />
|
||||||
<InputText
|
|
||||||
label="Name"
|
|
||||||
name="name"
|
|
||||||
inputProps={{
|
|
||||||
required: true,
|
|
||||||
value: service.name,
|
|
||||||
}}
|
|
||||||
error={serviceInputErrors.name}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
||||||
|
<InputText
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
inputProps={{
|
||||||
|
required: true,
|
||||||
|
value: service.name,
|
||||||
|
}}
|
||||||
|
error={serviceInputErrors.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
label="Slug"
|
||||||
|
description={`Auto-generated if empty. ${
|
||||||
|
service.previousSlugs.length > 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : ''
|
||||||
|
}`}
|
||||||
|
name="slug"
|
||||||
|
inputProps={{
|
||||||
|
value: service.slug,
|
||||||
|
class: 'font-title',
|
||||||
|
}}
|
||||||
|
error={serviceInputErrors.slug}
|
||||||
|
class="font-title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<InputTextArea
|
<InputTextArea
|
||||||
label="Description"
|
label="Description"
|
||||||
name="description"
|
name="description"
|
||||||
@@ -254,76 +302,44 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
error={serviceInputErrors.description}
|
error={serviceInputErrors.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputText
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
label="Slug"
|
|
||||||
description="Auto-generated if empty"
|
|
||||||
name="slug"
|
|
||||||
inputProps={{
|
|
||||||
value: service.slug,
|
|
||||||
class: 'font-title',
|
|
||||||
}}
|
|
||||||
error={serviceInputErrors.slug}
|
|
||||||
class="font-title"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
|
||||||
<InputTextArea
|
<InputTextArea
|
||||||
label="Service URLs"
|
label="Service URLs"
|
||||||
description="One per line"
|
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
|
||||||
name="serviceUrls"
|
name="allServiceUrls"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
rows: 3,
|
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
|
||||||
placeholder: 'https://example1.com\nhttps://example2.com',
|
class: 'grow min-h-24',
|
||||||
|
required: true,
|
||||||
}}
|
}}
|
||||||
value={service.serviceUrls.join('\n')}
|
class="row-span-2 flex flex-col self-stretch"
|
||||||
error={serviceInputErrors.serviceUrls}
|
value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
|
||||||
|
error={serviceInputErrors.allServiceUrls}
|
||||||
/>
|
/>
|
||||||
<InputTextArea
|
<InputTextArea
|
||||||
label="ToS URLs"
|
label="ToS URLs"
|
||||||
description="One per line"
|
description="One per line"
|
||||||
name="tosUrls"
|
name="tosUrls"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
rows: 3,
|
|
||||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||||
required: true,
|
required: true,
|
||||||
}}
|
}}
|
||||||
value={service.tosUrls.join('\n')}
|
value={service.tosUrls.join('\n')}
|
||||||
error={serviceInputErrors.tosUrls}
|
error={serviceInputErrors.tosUrls}
|
||||||
/>
|
/>
|
||||||
<InputTextArea
|
<InputText
|
||||||
label="Onion URLs"
|
label="Referral link path"
|
||||||
description="One per line"
|
name="referral"
|
||||||
name="onionUrls"
|
|
||||||
inputProps={{
|
inputProps={{
|
||||||
rows: 3,
|
value: service.referral,
|
||||||
placeholder: 'http://example1.onion\nhttp://example2.onion',
|
placeholder: 'e.g. ?ref=123 or /ref/123',
|
||||||
}}
|
}}
|
||||||
value={service.onionUrls.join('\n')}
|
error={serviceInputErrors.referral}
|
||||||
error={serviceInputErrors.onionUrls}
|
class="self-end"
|
||||||
/>
|
description="Will be appended to the service URL"
|
||||||
<InputTextArea
|
|
||||||
label="I2P URLs"
|
|
||||||
description="One per line"
|
|
||||||
name="i2pUrls"
|
|
||||||
inputProps={{
|
|
||||||
rows: 3,
|
|
||||||
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
|
|
||||||
}}
|
|
||||||
value={service.i2pUrls.join('\n')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputText
|
|
||||||
label="Referral link path"
|
|
||||||
name="referral"
|
|
||||||
inputProps={{
|
|
||||||
value: service.referral,
|
|
||||||
placeholder: 'e.g. ?ref=123 or /ref/123',
|
|
||||||
}}
|
|
||||||
error={serviceInputErrors.referral}
|
|
||||||
description="Will be appended to the service URL"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<InputImageFile
|
<InputImageFile
|
||||||
label="Image"
|
label="Image"
|
||||||
@@ -391,6 +407,7 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
value: kycLevel.id.toString(),
|
value: kycLevel.id.toString(),
|
||||||
icon: kycLevel.icon,
|
icon: kycLevel.icon,
|
||||||
description: kycLevel.description,
|
description: kycLevel.description,
|
||||||
|
noTransitionPersist: true,
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.kycLevel.toString()}
|
selectedValue={service.kycLevel.toString()}
|
||||||
iconSize="md"
|
iconSize="md"
|
||||||
@@ -399,6 +416,22 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
|
class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InputCardGroup
|
||||||
|
name="kycLevelClarification"
|
||||||
|
label="KYC Level Clarification"
|
||||||
|
options={kycLevelClarifications.map((clarification) => ({
|
||||||
|
label: clarification.label,
|
||||||
|
value: clarification.value,
|
||||||
|
icon: clarification.icon,
|
||||||
|
description: clarification.description,
|
||||||
|
noTransitionPersist: true,
|
||||||
|
}))}
|
||||||
|
selectedValue={service.kycLevelClarification ?? 'NONE'}
|
||||||
|
iconSize="sm"
|
||||||
|
cardSize="sm"
|
||||||
|
error={serviceInputErrors.kycLevelClarification}
|
||||||
|
/>
|
||||||
|
|
||||||
<InputCardGroup
|
<InputCardGroup
|
||||||
name="verificationStatus"
|
name="verificationStatus"
|
||||||
label="Verification Status"
|
label="Verification Status"
|
||||||
@@ -408,6 +441,7 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
icon: status.icon,
|
icon: status.icon,
|
||||||
iconClass: status.classNames.icon,
|
iconClass: status.classNames.icon,
|
||||||
description: status.description,
|
description: status.description,
|
||||||
|
noTransitionPersist: true,
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.verificationStatus}
|
selectedValue={service.verificationStatus}
|
||||||
error={serviceInputErrors.verificationStatus}
|
error={serviceInputErrors.verificationStatus}
|
||||||
@@ -423,6 +457,7 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
label: currency.name,
|
label: currency.name,
|
||||||
value: currency.id,
|
value: currency.id,
|
||||||
icon: currency.icon,
|
icon: currency.icon,
|
||||||
|
noTransitionPersist: true,
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.acceptedCurrencies}
|
selectedValue={service.acceptedCurrencies}
|
||||||
error={serviceInputErrors.acceptedCurrencies}
|
error={serviceInputErrors.acceptedCurrencies}
|
||||||
@@ -463,6 +498,7 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
icon: visibility.icon,
|
icon: visibility.icon,
|
||||||
iconClass: visibility.iconClass,
|
iconClass: visibility.iconClass,
|
||||||
description: visibility.description,
|
description: visibility.description,
|
||||||
|
noTransitionPersist: true,
|
||||||
}))}
|
}))}
|
||||||
selectedValue={service.serviceVisibility}
|
selectedValue={service.serviceVisibility}
|
||||||
error={serviceInputErrors.serviceVisibility}
|
error={serviceInputErrors.serviceVisibility}
|
||||||
@@ -685,9 +721,14 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
label="Started At"
|
label="Started At"
|
||||||
name="startedAt"
|
name="startedAt"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
type: 'date',
|
type: 'datetime-local',
|
||||||
required: true,
|
required: true,
|
||||||
value: new Date(event.startedAt).toISOString().split('T')[0],
|
value: new Date(
|
||||||
|
new Date(event.startedAt).getTime() -
|
||||||
|
new Date(event.startedAt).getTimezoneOffset() * 60000
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16),
|
||||||
}}
|
}}
|
||||||
error={eventUpdateInputErrors.startedAt}
|
error={eventUpdateInputErrors.startedAt}
|
||||||
/>
|
/>
|
||||||
@@ -696,7 +737,15 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
label="Ended At"
|
label="Ended At"
|
||||||
name="endedAt"
|
name="endedAt"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '',
|
type: 'datetime-local',
|
||||||
|
value: event.endedAt
|
||||||
|
? new Date(
|
||||||
|
new Date(event.endedAt).getTime() -
|
||||||
|
new Date(event.endedAt).getTimezoneOffset() * 60000
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16)
|
||||||
|
: '',
|
||||||
}}
|
}}
|
||||||
error={eventUpdateInputErrors.endedAt}
|
error={eventUpdateInputErrors.endedAt}
|
||||||
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
|
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
|
||||||
@@ -756,9 +805,11 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
label="Started At"
|
label="Started At"
|
||||||
name="startedAt"
|
name="startedAt"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
type: 'date',
|
type: 'datetime-local',
|
||||||
required: true,
|
required: true,
|
||||||
value: new Date().toISOString().split('T')[0],
|
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16),
|
||||||
}}
|
}}
|
||||||
error={eventInputErrors.startedAt}
|
error={eventInputErrors.startedAt}
|
||||||
/>
|
/>
|
||||||
@@ -767,7 +818,10 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
label="Ended At"
|
label="Ended At"
|
||||||
name="endedAt"
|
name="endedAt"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
value: new Date().toISOString().split('T')[0],
|
type: 'datetime-local',
|
||||||
|
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16),
|
||||||
}}
|
}}
|
||||||
error={eventInputErrors.endedAt}
|
error={eventInputErrors.endedAt}
|
||||||
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
|
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
|
||||||
@@ -1100,5 +1154,27 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
</form>
|
</form>
|
||||||
</FormSubSection>
|
</FormSubSection>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title="API">
|
||||||
|
{
|
||||||
|
DEPLOYMENT_MODE === 'staging' && (
|
||||||
|
<p class="rounded-lg bg-red-900/30 p-4 text-sm text-red-200">
|
||||||
|
<Icon name="ri:alert-line" class="inline-block size-4 align-[-0.2em] text-red-400" />
|
||||||
|
This endpoints section doesn't work in PRE. Use curl commands instead.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
apiCalls.map((call) => (
|
||||||
|
<FormSubSection title={`${call.method} ${call.path}`}>
|
||||||
|
<p class="text-day-400 text-sm">Input:</p>
|
||||||
|
<Code code={JSON.stringify(call.input, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
|
||||||
|
|
||||||
|
<p class="text-day-400 text-sm">Output:</p>
|
||||||
|
<Code code={JSON.stringify(call.output, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
|
||||||
|
</FormSubSection>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</FormSection>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label>
|
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
|
||||||
<textarea
|
<textarea
|
||||||
transition:persist
|
transition:persist
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
||||||
name="serviceUrls"
|
name="allServiceUrls"
|
||||||
id="serviceUrls"
|
id="allServiceUrls"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="https://example1.com https://example2.com"
|
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
|
||||||
set:text=""
|
set:text=""
|
||||||
/>
|
/>
|
||||||
{
|
{
|
||||||
inputErrors.serviceUrls && (
|
inputErrors.allServiceUrls && (
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
|
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="onionUrls" class="font-title mb-2 block text-sm text-green-500">onionUrls</label>
|
|
||||||
<textarea
|
|
||||||
transition:persist
|
|
||||||
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
|
|
||||||
name="onionUrls"
|
|
||||||
id="onionUrls"
|
|
||||||
rows={3}
|
|
||||||
placeholder="http://example.onion"
|
|
||||||
set:text=""
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
inputErrors.onionUrls && (
|
|
||||||
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
|
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
@@ -226,9 +226,24 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
name="type"
|
name="type"
|
||||||
label="Type"
|
label="Type"
|
||||||
options={[
|
options={[
|
||||||
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' },
|
{
|
||||||
{ label: 'Moderator', value: 'moderator', icon: 'ri:graduation-cap-fill' },
|
label: 'Admin',
|
||||||
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' },
|
value: 'admin',
|
||||||
|
icon: 'ri:shield-star-fill',
|
||||||
|
noTransitionPersist: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Moderator',
|
||||||
|
value: 'moderator',
|
||||||
|
icon: 'ri:graduation-cap-fill',
|
||||||
|
noTransitionPersist: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Spammer',
|
||||||
|
value: 'spammer',
|
||||||
|
icon: 'ri:alert-fill',
|
||||||
|
noTransitionPersist: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Verified',
|
label: 'Verified',
|
||||||
value: 'verified',
|
value: 'verified',
|
||||||
@@ -419,6 +434,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
label: role.label,
|
label: role.label,
|
||||||
value: role.value,
|
value: role.value,
|
||||||
icon: role.icon,
|
icon: role.icon,
|
||||||
|
noTransitionPersist: true,
|
||||||
}))}
|
}))}
|
||||||
required
|
required
|
||||||
cardSize="sm"
|
cardSize="sm"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type { Prisma } from '@prisma/client'
|
|||||||
|
|
||||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||||
{
|
{
|
||||||
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'),
|
'sort-by': z.enum(['name', 'role', 'lastLoginAt', 'karma', 'createdAt']).default('createdAt'),
|
||||||
'sort-order': z.enum(['asc', 'desc']).default('desc'),
|
'sort-order': z.enum(['asc', 'desc']).default('desc'),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
|
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
|
||||||
@@ -29,7 +29,10 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
|
|||||||
|
|
||||||
// Set up Prisma orderBy with correct typing
|
// Set up Prisma orderBy with correct typing
|
||||||
const prismaOrderBy =
|
const prismaOrderBy =
|
||||||
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma'
|
filters['sort-by'] === 'name' ||
|
||||||
|
filters['sort-by'] === 'createdAt' ||
|
||||||
|
filters['sort-by'] === 'lastLoginAt' ||
|
||||||
|
filters['sort-by'] === 'karma'
|
||||||
? {
|
? {
|
||||||
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
|
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
|
||||||
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
|
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
|
||||||
@@ -86,6 +89,7 @@ const dbUsers = await prisma.user.findMany({
|
|||||||
totalKarma: true,
|
totalKarma: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
internalNotes: {
|
internalNotes: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -218,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
<th
|
<th
|
||||||
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||||
>
|
>
|
||||||
<a
|
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||||
href={makeSortUrl('createdAt')}
|
<a
|
||||||
class="flex items-center justify-center hover:text-zinc-200"
|
href={makeSortUrl('lastLoginAt')}
|
||||||
>
|
class="flex items-center justify-center hover:text-zinc-200"
|
||||||
Joined <SortArrowIcon
|
>
|
||||||
active={filters['sort-by'] === 'createdAt'}
|
Login <SortArrowIcon
|
||||||
sortOrder={filters['sort-order']}
|
active={filters['sort-by'] === 'lastLoginAt'}
|
||||||
/>
|
sortOrder={filters['sort-order']}
|
||||||
</a>
|
/>
|
||||||
|
</a>
|
||||||
|
<span class="text-zinc-600">/</span>
|
||||||
|
<a
|
||||||
|
href={makeSortUrl('createdAt')}
|
||||||
|
class="flex items-center justify-center hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
Joined <SortArrowIcon
|
||||||
|
active={filters['sort-by'] === 'createdAt'}
|
||||||
|
sortOrder={filters['sort-order']}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th
|
<th
|
||||||
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
|
||||||
>
|
>
|
||||||
@@ -305,8 +322,24 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
{user.totalKarma}
|
{user.totalKarma}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center text-sm text-zinc-400">
|
<td class="px-4 py-3 text-center text-sm">
|
||||||
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} />
|
<div class="flex flex-wrap items-center justify-center gap-1 text-center">
|
||||||
|
<TimeFormatted
|
||||||
|
class="text-zinc-300"
|
||||||
|
date={user.lastLoginAt}
|
||||||
|
hourPrecision
|
||||||
|
hoursShort
|
||||||
|
prefix={false}
|
||||||
|
/>
|
||||||
|
<span class="text-zinc-600">/</span>
|
||||||
|
<TimeFormatted
|
||||||
|
class="text-zinc-400"
|
||||||
|
date={user.createdAt}
|
||||||
|
hourPrecision
|
||||||
|
hoursShort
|
||||||
|
prefix={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex justify-center gap-3">
|
<div class="flex justify-center gap-3">
|
||||||
|
|||||||
13
web/src/pages/api/[...catchAll].ts
Normal file
13
web/src/pages/api/[...catchAll].ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const ALL: APIRoute = (context) => {
|
||||||
|
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Endpoint not found',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
7
web/src/pages/api/v1/service/get.ts
Normal file
7
web/src/pages/api/v1/service/get.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
import { makeEndpointFromAction } from '../../../../lib/endpoints'
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const QUERY: APIRoute = makeEndpointFromAction(actions.api.service.get)
|
||||||
174
web/src/pages/docs/api.mdx
Normal file
174
web/src/pages/docs/api.mdx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
layout: ../../layouts/MarkdownLayout.astro
|
||||||
|
title: API
|
||||||
|
author: KYCnot.me
|
||||||
|
pubDate: 2025-05-31
|
||||||
|
description: 'Access basic service data via our public API.'
|
||||||
|
icon: 'ri:plug-line'
|
||||||
|
---
|
||||||
|
|
||||||
|
import { SOURCE_CODE_URL } from 'astro:env/server'
|
||||||
|
import { kycLevels } from '../../constants/kycLevels'
|
||||||
|
import { verificationStatuses } from '../../constants/verificationStatus'
|
||||||
|
import { serviceVisibilities } from '../../constants/serviceVisibility'
|
||||||
|
|
||||||
|
Access basic service data via our public API.
|
||||||
|
|
||||||
|
All endpoints should be prefixed with `/api/v1/`.
|
||||||
|
|
||||||
|
The endpoints <a href={SOURCE_CODE_URL}>source code</a> is available on the `/web/src/actions/api/index.ts` file.
|
||||||
|
|
||||||
|
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
|
||||||
|
|
||||||
|
## `QUERY` `/service/get`
|
||||||
|
|
||||||
|
Fetches details for a single service by various lookup criteria.
|
||||||
|
|
||||||
|
### Request Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| ------------ | ------ | -------- | ----------- |
|
||||||
|
| `id` | number | No* | Service ID |
|
||||||
|
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
|
||||||
|
| `serviceUrl` | string | No* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
|
||||||
|
|
||||||
|
\* At least one of the marked parameters is required.
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ServiceResponse = {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
serviceVisibility: 'PUBLIC' | 'ARCHIVED' | 'UNLISTED'
|
||||||
|
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
|
||||||
|
verificationStatusInfo: {
|
||||||
|
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
|
||||||
|
slug: string
|
||||||
|
label: string
|
||||||
|
labelShort: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
verifiedAt: Date | null
|
||||||
|
kycLevel: 0 | 1 | 2 | 3 | 4
|
||||||
|
kycLevelInfo: {
|
||||||
|
value: 0 | 1 | 2 | 3 | 4
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
categories: {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}[]
|
||||||
|
listedAt: Date
|
||||||
|
serviceUrls: string[]
|
||||||
|
tosUrls: string[]
|
||||||
|
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### KYC Levels
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{kycLevels.map((level) => (
|
||||||
|
<li key={level.id}>
|
||||||
|
<strong>{level.id}</strong>: {level.name} - {level.description}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
#### Verification Status
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{verificationStatuses.map((status) => (
|
||||||
|
<li key={status.value}>
|
||||||
|
<strong>{status.value}</strong>: {status.description}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
#### Service Visibility
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
|
||||||
|
<li key={visibility.value}>
|
||||||
|
<strong>{visibility.value}</strong>: {visibility.longDescription}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"slug": "my-example-service"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Example Service",
|
||||||
|
"description": "This is a description of my example service",
|
||||||
|
"serviceVisibility": "PUBLIC",
|
||||||
|
"verificationStatus": "VERIFICATION_SUCCESS",
|
||||||
|
"verificationStatusInfo": {
|
||||||
|
"value": "VERIFICATION_SUCCESS",
|
||||||
|
"slug": "verified",
|
||||||
|
"label": "Verified",
|
||||||
|
"labelShort": "Verified",
|
||||||
|
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
|
||||||
|
},
|
||||||
|
"verifiedAt": "2025-01-20T07:12:29.393Z",
|
||||||
|
"kycLevel": 0,
|
||||||
|
"kycLevelInfo": {
|
||||||
|
"value": 0,
|
||||||
|
"name": "Guaranteed no KYC",
|
||||||
|
"description": "Terms explicitly state KYC will never be requested."
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "Exchange",
|
||||||
|
"slug": "exchange"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"listedAt": "2025-05-31T19:09:18.043Z",
|
||||||
|
"serviceUrls": [
|
||||||
|
"https://example.com",
|
||||||
|
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
|
||||||
|
],
|
||||||
|
"tosUrls": ["https://example.com/terms-of-service"],
|
||||||
|
"kycnotmeUrl": "https://kycnot.me/service/bisq"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error Responses
|
||||||
|
|
||||||
|
**404 Not Found**: Service not found
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Service not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**400 Bad Request**: Invalid input parameters
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Validation error message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**500 Internal Server Error**: Server error
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Internal server error"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
layout: ../layouts/MarkdownLayout.astro
|
layout: ../../layouts/MarkdownLayout.astro
|
||||||
title: How does karma work?
|
title: How does karma work?
|
||||||
description: "KYCnot.me has a user karma system, here's how it works"
|
description: "KYCnot.me has a user karma system, here's how it works"
|
||||||
icon: 'ri:hearts-line'
|
icon: 'ri:hearts-line'
|
||||||
@@ -7,7 +7,7 @@ author: KYCnot.me
|
|||||||
pubDate: 2025-05-15
|
pubDate: 2025-05-15
|
||||||
---
|
---
|
||||||
|
|
||||||
import KarmaUnlocksTable from '../components/KarmaUnlocksTable.astro'
|
import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
|
||||||
|
|
||||||
[KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services.
|
[KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services.
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getEventTypeInfo,
|
getEventTypeInfo,
|
||||||
getEventTypeInfoBySlug,
|
getEventTypeInfoBySlug,
|
||||||
} from '../constants/eventTypes'
|
} from '../constants/eventTypes'
|
||||||
|
import { getServiceVisibilityInfo } from '../constants/serviceVisibility'
|
||||||
import { getVerificationStatusInfo } from '../constants/verificationStatus'
|
import { getVerificationStatusInfo } from '../constants/verificationStatus'
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
@@ -44,6 +45,8 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
|||||||
async () =>
|
async () =>
|
||||||
prisma.service.findMany({
|
prisma.service.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||||
events: {
|
events: {
|
||||||
some: {
|
some: {
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -72,8 +75,15 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
|||||||
createdAt: {
|
createdAt: {
|
||||||
lte: params.now,
|
lte: params.now,
|
||||||
},
|
},
|
||||||
...(params.service ? { service: { slug: params.service } } : {}),
|
service: {
|
||||||
...(params.type ? { type: getEventTypeInfoBySlug(params.type).id } : {}),
|
slug: params.service ?? undefined,
|
||||||
|
listedAt: params.service ? undefined : { lte: new Date() },
|
||||||
|
serviceVisibility: {
|
||||||
|
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: params.type ? getEventTypeInfoBySlug(params.type).id : undefined,
|
||||||
|
|
||||||
...(params.from || params.to
|
...(params.from || params.to
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -105,6 +115,7 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
|||||||
name: true,
|
name: true,
|
||||||
imageUrl: true,
|
imageUrl: true,
|
||||||
verificationStatus: true,
|
verificationStatus: true,
|
||||||
|
serviceVisibility: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -126,6 +137,7 @@ const events = orderBy(
|
|||||||
service: {
|
service: {
|
||||||
...event.service,
|
...event.service,
|
||||||
verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus),
|
verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus),
|
||||||
|
serviceVisibilityInfo: getServiceVisibilityInfo(event.service.serviceVisibility),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
['actualEndedAt', 'startedAt'],
|
['actualEndedAt', 'startedAt'],
|
||||||
@@ -416,6 +428,16 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{event.service.serviceVisibility === 'ARCHIVED' && (
|
||||||
|
<Icon
|
||||||
|
name={event.service.serviceVisibilityInfo.icon}
|
||||||
|
class={cn(
|
||||||
|
'ms-1 inline-block size-3 shrink-0',
|
||||||
|
event.service.serviceVisibilityInfo.iconClass
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{event.source && (
|
{event.source && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { z } from 'astro:schema'
|
import { z } from 'astro:schema'
|
||||||
import { groupBy, omit, orderBy, uniq } from 'lodash-es'
|
import { groupBy, omit, orderBy, sortBy, uniq } from 'lodash-es'
|
||||||
import seedrandom from 'seedrandom'
|
import seedrandom from 'seedrandom'
|
||||||
|
|
||||||
import Button from '../components/Button.astro'
|
import Button from '../components/Button.astro'
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../constants/verificationStatus'
|
} from '../constants/verificationStatus'
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
|
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
|
||||||
|
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||||
import { parseIntWithFallback } from '../lib/numbers'
|
import { parseIntWithFallback } from '../lib/numbers'
|
||||||
import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
||||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||||
@@ -181,6 +182,7 @@ const {
|
|||||||
'min-score': { if: 'default' },
|
'min-score': { if: 'default' },
|
||||||
'user-rating': { if: 'default' },
|
'user-rating': { if: 'default' },
|
||||||
'max-kyc': { if: 'default' },
|
'max-kyc': { if: 'default' },
|
||||||
|
'sort-seed': { if: 'another-is-unset', prop: 'page' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -213,17 +215,16 @@ const groupedAttributes = groupBy(
|
|||||||
'value'
|
'value'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
listedAt: {
|
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
|
||||||
lte: new Date(),
|
listedAt: { lte: new Date() },
|
||||||
},
|
|
||||||
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
|
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
|
||||||
verificationStatus: {
|
verificationStatus: {
|
||||||
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
||||||
},
|
},
|
||||||
serviceVisibility: {
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||||
in: ['PUBLIC', 'ARCHIVED'],
|
|
||||||
},
|
|
||||||
overallScore: { gte: filters['min-score'] },
|
overallScore: { gte: filters['min-score'] },
|
||||||
acceptedCurrencies: filters.currencies.length
|
acceptedCurrencies: filters.currencies.length
|
||||||
? filters['currency-mode'] === 'and'
|
? filters['currency-mode'] === 'and'
|
||||||
@@ -243,16 +244,6 @@ const where = {
|
|||||||
} satisfies Prisma.ServiceWhereInput,
|
} satisfies Prisma.ServiceWhereInput,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(filters.q
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{ name: { contains: filters.q, mode: 'insensitive' as const } },
|
|
||||||
{ description: { contains: filters.q, mode: 'insensitive' as const } },
|
|
||||||
],
|
|
||||||
} satisfies Prisma.ServiceWhereInput,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(filters.networks.length
|
...(filters.networks.length
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -325,9 +316,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
select: {
|
select: {
|
||||||
services: {
|
services: {
|
||||||
where: {
|
where: {
|
||||||
serviceVisibility: {
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||||
in: ['PUBLIC', 'ARCHIVED'],
|
listedAt: { lte: new Date() },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -338,33 +328,46 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
[
|
[
|
||||||
'Unable to load services.',
|
'Unable to load services.',
|
||||||
async () => {
|
async () => {
|
||||||
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
|
const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
|
||||||
where,
|
{
|
||||||
select: {
|
where,
|
||||||
id: true,
|
select: {
|
||||||
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
id: true,
|
||||||
(typeof sortOptions)[number]['orderBy']['key'],
|
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
||||||
true
|
(typeof sortOptions)[number]['orderBy']['key'],
|
||||||
>),
|
true
|
||||||
},
|
>),
|
||||||
})
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const unsortedServices = unsortedServicesMissingSimilarityScore.map((service) => ({
|
||||||
|
...service,
|
||||||
|
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
const rng = seedrandom(filters['sort-seed'])
|
const rng = seedrandom(filters['sort-seed'])
|
||||||
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
|
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
|
||||||
|
|
||||||
const sortedServices = orderBy(
|
const sortedServices = orderBy(
|
||||||
unsortedServices,
|
// NOTE: We do a first sort by id to make the seeded sort deterministic
|
||||||
[selectedSort.orderBy.key, () => rng()],
|
sortBy(unsortedServices, 'id'),
|
||||||
[selectedSort.orderBy.direction, 'asc']
|
[
|
||||||
|
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||||
|
selectedSort.orderBy.key,
|
||||||
|
() => rng(),
|
||||||
|
],
|
||||||
|
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||||
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
|
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
|
||||||
|
|
||||||
const unsortedServicesWithInfo = await prisma.service.findMany({
|
const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
in: sortedServices.map((service) => service.id),
|
in: sortedServices.map((service) => service.id),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
description: true,
|
description: true,
|
||||||
@@ -398,14 +401,20 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const unsortedServicesWithInfo = unsortedServicesWithInfoMissingSimilarityScore.map((service) => ({
|
||||||
|
...service,
|
||||||
|
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
const sortedServicesWithInfo = orderBy(
|
const sortedServicesWithInfo = orderBy(
|
||||||
unsortedServicesWithInfo,
|
unsortedServicesWithInfo,
|
||||||
[
|
[
|
||||||
|
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||||
selectedSort.orderBy.key,
|
selectedSort.orderBy.key,
|
||||||
// Now we can shuffle indeternimistically, because the pagination was already applied
|
// Now we can shuffle indeternimistically, because the pagination was already applied
|
||||||
() => Math.random(),
|
() => Math.random(),
|
||||||
],
|
],
|
||||||
[selectedSort.orderBy.direction, 'asc']
|
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||||
)
|
)
|
||||||
|
|
||||||
return [sortedServicesWithInfo, totalServices] as const
|
return [sortedServicesWithInfo, totalServices] as const
|
||||||
@@ -712,7 +721,6 @@ const showFiltersId = 'show-filters'
|
|||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
sortSeed={filters['sort-seed']}
|
sortSeed={filters['sort-seed']}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
includeScams={includeScams}
|
|
||||||
countCommunityOnly={countCommunityOnly}
|
countCommunityOnly={countCommunityOnly}
|
||||||
inlineIcons
|
inlineIcons
|
||||||
/>
|
/>
|
||||||
|
|||||||
13
web/src/pages/internal-api/[...catchAll].ts
Normal file
13
web/src/pages/internal-api/[...catchAll].ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const ALL: APIRoute = (context) => {
|
||||||
|
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Endpoint not found',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
7
web/src/pages/internal-api/notifications/subscribe.ts
Normal file
7
web/src/pages/internal-api/notifications/subscribe.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
import { makeEndpointFromAction } from '../../../lib/endpoints'
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const POST: APIRoute = makeEndpointFromAction(actions.notification.webPush.subscribe)
|
||||||
7
web/src/pages/internal-api/notifications/unsubscribe.ts
Normal file
7
web/src/pages/internal-api/notifications/unsubscribe.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
import { makeEndpointFromAction } from '../../../lib/endpoints'
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const POST: APIRoute = makeEndpointFromAction(actions.notification.webPush.unsubscribe)
|
||||||
@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
import Button from '../components/Button.astro'
|
import Button from '../components/Button.astro'
|
||||||
|
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
|
||||||
import TimeFormatted from '../components/TimeFormatted.astro'
|
import TimeFormatted from '../components/TimeFormatted.astro'
|
||||||
import Tooltip from '../components/Tooltip.astro'
|
import Tooltip from '../components/Tooltip.astro'
|
||||||
import { getNotificationTypeInfo } from '../constants/notificationTypes'
|
import { getNotificationTypeInfo } from '../constants/notificationTypes'
|
||||||
@@ -28,131 +29,146 @@ const { data: params } = zodParseQueryParamsStoringErrors(
|
|||||||
)
|
)
|
||||||
const skip = (params.page - 1) * PAGE_SIZE
|
const skip = (params.page - 1) * PAGE_SIZE
|
||||||
|
|
||||||
const [dbNotifications, notificationPreferences, totalNotifications] = await Astro.locals.banners.tryMany([
|
const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] =
|
||||||
[
|
await Astro.locals.banners.tryMany([
|
||||||
'Error while fetching notifications',
|
[
|
||||||
() =>
|
'Error while fetching notifications',
|
||||||
prisma.notification.findMany({
|
() =>
|
||||||
where: {
|
prisma.notification.findMany({
|
||||||
userId: user.id,
|
where: {
|
||||||
},
|
userId: user.id,
|
||||||
orderBy: {
|
},
|
||||||
createdAt: 'desc',
|
orderBy: {
|
||||||
},
|
createdAt: 'desc',
|
||||||
skip,
|
},
|
||||||
take: PAGE_SIZE,
|
skip,
|
||||||
select: {
|
take: PAGE_SIZE,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
createdAt: true,
|
||||||
|
read: true,
|
||||||
|
aboutAccountStatusChange: true,
|
||||||
|
aboutCommentStatusChange: true,
|
||||||
|
aboutServiceVerificationStatusChange: true,
|
||||||
|
aboutSuggestionStatusChange: true,
|
||||||
|
aboutComment: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: true,
|
||||||
|
content: true,
|
||||||
|
communityNote: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutServiceSuggestionId: true,
|
||||||
|
aboutServiceSuggestion: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutServiceSuggestionMessage: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
suggestion: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutEvent: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutService: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
verificationStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutKarmaTransaction: {
|
||||||
|
select: {
|
||||||
|
points: true,
|
||||||
|
action: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Error while fetching notification preferences',
|
||||||
|
() =>
|
||||||
|
getOrCreateNotificationPreferences(user.id, {
|
||||||
id: true,
|
id: true,
|
||||||
type: true,
|
enableOnMyCommentStatusChange: true,
|
||||||
createdAt: true,
|
enableAutowatchMyComments: true,
|
||||||
read: true,
|
enableNotifyPendingRepliesOnWatch: true,
|
||||||
aboutAccountStatusChange: true,
|
karmaNotificationThreshold: true,
|
||||||
aboutCommentStatusChange: true,
|
}),
|
||||||
aboutServiceVerificationStatusChange: true,
|
null,
|
||||||
aboutSuggestionStatusChange: true,
|
],
|
||||||
aboutComment: {
|
[
|
||||||
select: {
|
'Error while fetching total notifications',
|
||||||
id: true,
|
() => prisma.notification.count({ where: { userId: user.id } }),
|
||||||
author: {
|
0,
|
||||||
select: {
|
],
|
||||||
id: true,
|
[
|
||||||
},
|
'Error while fetching push subscriptions',
|
||||||
},
|
() =>
|
||||||
status: true,
|
prisma.pushSubscription.findMany({
|
||||||
content: true,
|
where: { userId: user.id },
|
||||||
communityNote: true,
|
select: {
|
||||||
service: {
|
endpoint: true,
|
||||||
select: {
|
userAgent: true,
|
||||||
slug: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parent: {
|
|
||||||
select: {
|
|
||||||
author: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
aboutServiceSuggestionId: true,
|
}),
|
||||||
aboutServiceSuggestion: {
|
[],
|
||||||
select: {
|
],
|
||||||
status: true,
|
])
|
||||||
service: {
|
|
||||||
select: {
|
if (!notificationPreferences) console.error('No notification preferences found')
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
aboutServiceSuggestionMessage: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
content: true,
|
|
||||||
suggestion: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
service: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
aboutEvent: {
|
|
||||||
select: {
|
|
||||||
title: true,
|
|
||||||
type: true,
|
|
||||||
service: {
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
aboutService: {
|
|
||||||
select: {
|
|
||||||
slug: true,
|
|
||||||
name: true,
|
|
||||||
verificationStatus: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
aboutKarmaTransaction: {
|
|
||||||
select: {
|
|
||||||
points: true,
|
|
||||||
action: true,
|
|
||||||
description: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Error while fetching notification preferences',
|
|
||||||
() =>
|
|
||||||
getOrCreateNotificationPreferences(user.id, {
|
|
||||||
id: true,
|
|
||||||
enableOnMyCommentStatusChange: true,
|
|
||||||
enableAutowatchMyComments: true,
|
|
||||||
enableNotifyPendingRepliesOnWatch: true,
|
|
||||||
karmaNotificationThreshold: true,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'Error while fetching total notifications',
|
|
||||||
() => prisma.notification.count({ where: { userId: user.id } }),
|
|
||||||
0,
|
|
||||||
],
|
|
||||||
])
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalNotifications / PAGE_SIZE)
|
const totalPages = Math.ceil(totalNotifications / PAGE_SIZE)
|
||||||
|
|
||||||
@@ -199,6 +215,17 @@ const notifications = dbNotifications.map((notification) => ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section class="mx-auto w-full">
|
<section class="mx-auto w-full">
|
||||||
|
{
|
||||||
|
notifications.length >= 5 && (
|
||||||
|
<PushNotificationBanner
|
||||||
|
class="mb-4"
|
||||||
|
dismissable
|
||||||
|
pushSubscriptions={pushSubscriptions}
|
||||||
|
hideIfEnabled
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
|
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
|
||||||
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
|
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
|
||||||
@@ -306,57 +333,57 @@ const notifications = dbNotifications.map((notification) => ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
<h2 class="font-title mt-8 mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
|
||||||
!!notificationPreferences && (
|
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
|
||||||
<div class="mt-8">
|
Notification Settings
|
||||||
<h2 class="font-title mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
|
</h2>
|
||||||
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
|
|
||||||
Notification Settings
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form
|
<PushNotificationBanner class="mb-3" pushSubscriptions={pushSubscriptions} />
|
||||||
method="POST"
|
|
||||||
action={actions.notification.preferences.update}
|
|
||||||
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
{notificationPreferenceFields.map((field) => (
|
|
||||||
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
|
|
||||||
<span class="flex items-center text-zinc-300">
|
|
||||||
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
|
|
||||||
{field.label}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name={field.id}
|
|
||||||
checked={notificationPreferences[field.id]}
|
|
||||||
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
|
<form
|
||||||
<span class="flex items-center text-zinc-300">
|
method="POST"
|
||||||
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
|
action={actions.notification.preferences.update}
|
||||||
Notify me when my karma changes by at least
|
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
|
||||||
</span>
|
>
|
||||||
<div class="flex items-center gap-2">
|
{
|
||||||
<input
|
notificationPreferenceFields.map((field) => (
|
||||||
type="number"
|
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
|
||||||
name="karmaNotificationThreshold"
|
<span class="flex items-center text-zinc-300">
|
||||||
value={notificationPreferences.karmaNotificationThreshold}
|
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
|
||||||
min="1"
|
{field.label}
|
||||||
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
|
</span>
|
||||||
/>
|
<input
|
||||||
<span class="text-zinc-400">points</span>
|
type="checkbox"
|
||||||
</div>
|
name={field.id}
|
||||||
</div>
|
checked={notificationPreferences?.[field.id]}
|
||||||
|
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end">
|
<div
|
||||||
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
|
class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800"
|
||||||
</div>
|
>
|
||||||
</form>
|
<span class="flex items-center text-zinc-300">
|
||||||
|
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
|
||||||
|
Notify me when my karma changes by at least
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="karmaNotificationThreshold"
|
||||||
|
value={notificationPreferences?.karmaNotificationThreshold}
|
||||||
|
min="1"
|
||||||
|
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span class="text-zinc-400">points</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import InputTextArea from '../../components/InputTextArea.astro'
|
|||||||
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
|
||||||
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||||
import { currencies } from '../../constants/currencies'
|
import { currencies } from '../../constants/currencies'
|
||||||
|
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
|
||||||
import { kycLevels } from '../../constants/kycLevels'
|
import { kycLevels } from '../../constants/kycLevels'
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
@@ -201,34 +202,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
error={inputErrors.description}
|
error={inputErrors.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputTextArea
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
label="Service URLs"
|
<InputTextArea
|
||||||
name="serviceUrls"
|
label="Service URLs"
|
||||||
inputProps={{
|
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
|
||||||
required: true,
|
name="allServiceUrls"
|
||||||
placeholder: 'https://example1.com\nhttps://example2.org',
|
inputProps={{
|
||||||
}}
|
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
|
||||||
error={inputErrors.serviceUrls}
|
class: 'min-h-24',
|
||||||
/>
|
required: true,
|
||||||
|
}}
|
||||||
<InputTextArea
|
class="row-span-2 flex flex-col self-stretch"
|
||||||
label="Terms of Service URLs"
|
error={inputErrors.allServiceUrls}
|
||||||
name="tosUrls"
|
/>
|
||||||
inputProps={{
|
<InputTextArea
|
||||||
required: true,
|
label="ToS URLs"
|
||||||
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms',
|
description="One per line"
|
||||||
}}
|
name="tosUrls"
|
||||||
error={inputErrors.tosUrls}
|
inputProps={{
|
||||||
/>
|
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||||
|
class: 'md:min-h-24',
|
||||||
<InputTextArea
|
required: true,
|
||||||
label="Onion URLs"
|
}}
|
||||||
name="onionUrls"
|
error={inputErrors.tosUrls}
|
||||||
inputProps={{
|
/>
|
||||||
placeholder: 'http://example1.onion\nhttp://example2.onion',
|
</div>
|
||||||
}}
|
|
||||||
error={inputErrors.onionUrls}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputCardGroup
|
<InputCardGroup
|
||||||
name="kycLevel"
|
name="kycLevel"
|
||||||
@@ -245,6 +243,21 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
error={inputErrors.kycLevel}
|
error={inputErrors.kycLevel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InputCardGroup
|
||||||
|
name="kycLevelClarification"
|
||||||
|
label="KYC Level Clarification"
|
||||||
|
options={kycLevelClarifications.map((clarification) => ({
|
||||||
|
label: clarification.label,
|
||||||
|
value: clarification.value,
|
||||||
|
icon: clarification.icon,
|
||||||
|
description: clarification.description,
|
||||||
|
}))}
|
||||||
|
selectedValue="NONE"
|
||||||
|
iconSize="sm"
|
||||||
|
cardSize="sm"
|
||||||
|
error={inputErrors.kycLevelClarification}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
|
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
|
||||||
<InputCheckboxGroup
|
<InputCheckboxGroup
|
||||||
name="categories"
|
name="categories"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { VerificationStepStatus } from '@prisma/client'
|
import { VerificationStepStatus, EventType, KycLevelClarification } from '@prisma/client'
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { Markdown } from 'astro-remote'
|
import { Markdown } from 'astro-remote'
|
||||||
import { Schema } from 'astro-seo-schema'
|
import { Schema } from 'astro-seo-schema'
|
||||||
@@ -32,6 +32,7 @@ import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
|||||||
import { formatContactMethod } from '../../constants/contactMethods'
|
import { formatContactMethod } from '../../constants/contactMethods'
|
||||||
import { currencies, getCurrencyInfo } from '../../constants/currencies'
|
import { currencies, getCurrencyInfo } from '../../constants/currencies'
|
||||||
import { getEventTypeInfo } from '../../constants/eventTypes'
|
import { getEventTypeInfo } from '../../constants/eventTypes'
|
||||||
|
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
|
||||||
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
|
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
|
||||||
import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
|
import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
|
||||||
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
|
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
|
||||||
@@ -67,13 +68,18 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
|||||||
'Error fetching service',
|
'Error fetching service',
|
||||||
async () =>
|
async () =>
|
||||||
prisma.service.findUnique({
|
prisma.service.findUnique({
|
||||||
where: { slug },
|
where: {
|
||||||
|
slug,
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
name: true,
|
name: true,
|
||||||
description: true,
|
description: true,
|
||||||
kycLevel: true,
|
kycLevel: true,
|
||||||
|
kycLevelClarification: true,
|
||||||
overallScore: true,
|
overallScore: true,
|
||||||
privacyScore: true,
|
privacyScore: true,
|
||||||
trustScore: true,
|
trustScore: true,
|
||||||
@@ -219,6 +225,34 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
try {
|
||||||
|
const serviceWithOldSlug = await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
previousSlugs: { has: slug },
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
},
|
||||||
|
select: { slug: true },
|
||||||
|
})
|
||||||
|
if (serviceWithOldSlug) {
|
||||||
|
return Astro.redirect(`/service/${serviceWithOldSlug.slug}`, 301)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Astro.rewrite('/404')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
service.serviceVisibility !== 'PUBLIC' &&
|
||||||
|
service.serviceVisibility !== 'UNLISTED' &&
|
||||||
|
service.serviceVisibility !== 'ARCHIVED'
|
||||||
|
) {
|
||||||
|
return Astro.rewrite('/404')
|
||||||
|
}
|
||||||
|
|
||||||
const makeWatchingDetails = (
|
const makeWatchingDetails = (
|
||||||
dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{
|
dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
@@ -254,17 +288,7 @@ const makeWatchingDetails = (
|
|||||||
} as const
|
} as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.id)
|
const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service.id)
|
||||||
|
|
||||||
if (!service) return Astro.rewrite('/404')
|
|
||||||
|
|
||||||
if (
|
|
||||||
service.serviceVisibility !== 'PUBLIC' &&
|
|
||||||
service.serviceVisibility !== 'UNLISTED' &&
|
|
||||||
service.serviceVisibility !== 'ARCHIVED'
|
|
||||||
) {
|
|
||||||
return Astro.rewrite('/404')
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIcon = {
|
const statusIcon = {
|
||||||
...verificationStatusesByValue,
|
...verificationStatusesByValue,
|
||||||
@@ -286,6 +310,9 @@ const hiddenLinks = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const kycLevelInfo = getKycLevelInfo(`${service.kycLevel}`)
|
const kycLevelInfo = getKycLevelInfo(`${service.kycLevel}`)
|
||||||
|
|
||||||
|
const kycLevelClarificationInfo = getKycLevelClarificationInfo(service.kycLevelClarification)
|
||||||
|
|
||||||
const userSentiment = service.userSentiment
|
const userSentiment = service.userSentiment
|
||||||
? { ...service.userSentiment, info: getUserSentimentInfo(service.userSentiment.sentiment) }
|
? { ...service.userSentiment, info: getUserSentimentInfo(service.userSentiment.sentiment) }
|
||||||
: null
|
: null
|
||||||
@@ -380,6 +407,10 @@ const ogImageTemplateData = {
|
|||||||
} satisfies OgImageAllTemplatesWithProps
|
} satisfies OgImageAllTemplatesWithProps
|
||||||
|
|
||||||
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
|
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
|
||||||
|
|
||||||
|
const activeAlertOrWarningEvents = service.events.filter(
|
||||||
|
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
|
||||||
|
)
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -480,6 +511,32 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
|
|||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
{
|
||||||
|
activeAlertOrWarningEvents.length > 0 && (
|
||||||
|
<a
|
||||||
|
href="#events"
|
||||||
|
class={cn(
|
||||||
|
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
|
||||||
|
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||||
|
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
|
||||||
|
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={
|
||||||
|
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||||
|
? 'ri:alert-fill'
|
||||||
|
: 'ri:alarm-warning-fill'
|
||||||
|
}
|
||||||
|
class="me-1.5 inline-block size-4 align-[-0.15em]"
|
||||||
|
/>
|
||||||
|
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
|
||||||
|
? 'There is an active alert for this service. Click to see details.'
|
||||||
|
: 'There is an active warning for this service. Click to see details.'}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
(serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
|
(serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
|
||||||
<div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}>
|
<div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}>
|
||||||
@@ -844,8 +901,12 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
|
|||||||
'@type': 'Review',
|
'@type': 'Review',
|
||||||
itemReviewed: { '@id': itemReviewedId },
|
itemReviewed: { '@id': itemReviewedId },
|
||||||
reviewAspect: 'KYC Level',
|
reviewAspect: 'KYC Level',
|
||||||
name: kycLevelInfo.name,
|
name:
|
||||||
reviewBody: kycLevelInfo.description,
|
kycLevelInfo.name +
|
||||||
|
(kycLevelClarificationInfo.value !== 'NONE' ? ` (${kycLevelClarificationInfo.label})` : ''),
|
||||||
|
reviewBody:
|
||||||
|
kycLevelInfo.description +
|
||||||
|
(kycLevelClarificationInfo.value !== 'NONE' ? ` ${kycLevelClarificationInfo.description}` : ''),
|
||||||
reviewRating: {
|
reviewRating: {
|
||||||
'@type': 'Rating',
|
'@type': 'Rating',
|
||||||
ratingValue: kycLevelInfo.value,
|
ratingValue: kycLevelInfo.value,
|
||||||
@@ -866,9 +927,22 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="flex-grow-5 basis-0">
|
<dl class="flex-grow-5 basis-0">
|
||||||
<dt class="text-base font-bold text-pretty">{kycLevelInfo.name}</dt>
|
<dt class="text-base font-bold text-pretty">
|
||||||
|
{kycLevelInfo.name}
|
||||||
|
{
|
||||||
|
kycLevelClarificationInfo.value !== 'NONE' && (
|
||||||
|
<>
|
||||||
|
<span class="text-day-400 mx-1">•</span>
|
||||||
|
<span class="text-blue-500">{kycLevelClarificationInfo.label}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</dt>
|
||||||
<dd class="text-day-700 mt-1 font-sans text-sm text-pretty">
|
<dd class="text-day-700 mt-1 font-sans text-sm text-pretty">
|
||||||
{kycLevelInfo.description}
|
{kycLevelInfo.description}
|
||||||
|
<span class="font-medium text-blue-600">
|
||||||
|
{kycLevelClarificationInfo.value !== 'NONE' && ` ${kycLevelClarificationInfo.description}`}
|
||||||
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -1182,6 +1256,7 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
|
|||||||
|
|
||||||
<div class="mt-3 max-w-md pe-8">
|
<div class="mt-3 max-w-md pe-8">
|
||||||
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
|
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
|
||||||
|
{typeInfo.isSolved && <BadgeSmall text="Solved" icon="ri:check-line" color="green" />}
|
||||||
{event.title}
|
{event.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
verifiedLink: true,
|
verifiedLink: true,
|
||||||
totalKarma: true,
|
totalKarma: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
comments: true,
|
comments: true,
|
||||||
@@ -93,7 +94,16 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: { service: { serviceVisibility: 'PUBLIC' } },
|
where: {
|
||||||
|
service: {
|
||||||
|
listedAt: {
|
||||||
|
lte: new Date(),
|
||||||
|
},
|
||||||
|
serviceVisibility: {
|
||||||
|
in: ['PUBLIC', 'ARCHIVED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 5,
|
take: 5,
|
||||||
},
|
},
|
||||||
@@ -469,6 +479,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<AdminOnly>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
|
||||||
|
<div>
|
||||||
|
<p class="text-day-500 text-xs">Last login</p>
|
||||||
|
<p class="text-day-300">
|
||||||
|
{
|
||||||
|
formatDateShort(user.lastLoginAt, {
|
||||||
|
prefix: false,
|
||||||
|
hourPrecision: true,
|
||||||
|
caseType: 'sentence',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</AdminOnly>
|
||||||
|
|
||||||
{
|
{
|
||||||
user.verifiedLink && (
|
user.verifiedLink && (
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
@@ -628,8 +656,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
|
|
||||||
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
||||||
<p class="text-day-300">
|
<p class="text-day-300">
|
||||||
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
|
Earn karma to unlock features and privileges. <a
|
||||||
>Learn about karma</a
|
href="/docs/karma"
|
||||||
|
class="text-day-200 hover:underline">Learn about karma</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user