Compare commits
36 Commits
release-20
...
release-59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b90b3eef6 | ||
|
|
2489e94b0e | ||
|
|
144af17a70 | ||
|
|
02e52d7351 | ||
|
|
dacf73a804 | ||
|
|
5812399e29 | ||
|
|
6a6908518d | ||
|
|
d065910ff3 | ||
|
|
490433b002 | ||
|
|
e17bc8a521 | ||
|
|
ec1215f2ae | ||
|
|
3afa824c18 | ||
|
|
9a68112e24 | ||
|
|
0c40d8eec5 | ||
|
|
e16c9b64ed | ||
|
|
22944fcdb3 | ||
|
|
f7f380c591 | ||
|
|
577c524ca2 | ||
|
|
da12e8de79 | ||
|
|
ea40f17d3c | ||
|
|
7e0d41cc7a | ||
|
|
70a097054b | ||
|
|
e536ca6519 | ||
|
|
b361ed3aa8 | ||
|
|
50ede46d50 | ||
|
|
ba809840c6 | ||
|
|
f2021a3027 | ||
|
|
6b86a72d1e | ||
|
|
8f2b2c34ff | ||
|
|
ac9a2f428a | ||
|
|
970622d061 | ||
|
|
4806a7fd4e | ||
|
|
85605de8aa | ||
|
|
7a22629c55 | ||
|
|
8deb9acb93 | ||
|
|
61a5448ff5 |
53
.clinerules
53
.clinerules
@@ -1,53 +0,0 @@
|
||||
# Cursor Rules
|
||||
|
||||
- When merging tailwind classes, use the `cn` function.
|
||||
- When using Tailwind and you need to merge classes use the `cn` function if avilable.
|
||||
- We use Tailwind 4 (the latest version), make sure to not use outdated classes.
|
||||
- Instead of using the syntax`Array<T>`, use `T[]`.
|
||||
- Use TypeScript `type` over `interface`.
|
||||
- You are forbiddent o add comments unless explicitly stated by the user.
|
||||
- Avoid sending JavaScript to the client. The JS send should be optional.
|
||||
- In prisma preffer `select` over `include` when making queries.
|
||||
- Import the types from prisma instead of hardcoding duplicates.
|
||||
- Avoid duplicating similar html code, and parametrize it when possible or create separate components.
|
||||
- Remember to check the prisma schema when doing things related to the database.
|
||||
- Avoid hardcoding enums from the database, import them from prisma.
|
||||
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
|
||||
- The admin pages can use client-side JavaScript.
|
||||
- Keep README.md in sync with new capabilities.
|
||||
- The package manager is npm.
|
||||
- For icons use the `Icon` component from `astro-icon/components`.
|
||||
- For icons use the Remix Icon library preferably.
|
||||
- Use the `Image` component from `astro:assets` for images.
|
||||
- Use the `zod` library for schema validation.
|
||||
- In the astro actions return, don't return success: true, or similar, just return an object with the newly created/edited objects or nothing.
|
||||
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
|
||||
- Don't forget that the astro files have thre dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
|
||||
- The admin actions go into a separate folder.
|
||||
- In Actro actions when throwing errors use ActionError.
|
||||
- @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`. Example:
|
||||
|
||||
```ts
|
||||
import { actions } from "astro:actions"; /* CORRECT */
|
||||
import { server } from "~/actions"; /* WRONG!!!! DON'T DO THIS */
|
||||
import { adminAttributeActions } from "~/actions/admin/attribute.ts"; /* WRONG!!!! DON'T DO THIS */
|
||||
|
||||
const result = Astro.getActionResult(actions.admin.attribute.create);
|
||||
```
|
||||
|
||||
- Always use Astro actions instead of with API routes or `if (Astro.request.method === "POST")`.
|
||||
- When adding clientside js do it with HTMX.
|
||||
- When adding HTMX, the layout component BaseLayout accepts a prop htmx to load it in that page. No need to use a cdn.
|
||||
- When redirecting to login use the `makeLoginUrl` function from web/src/lib/redirectUrls.ts
|
||||
|
||||
```ts
|
||||
function makeLoginUrl(
|
||||
currentUrl: URL,
|
||||
options: {
|
||||
redirect?: URL | string | null;
|
||||
error?: string | null;
|
||||
logout?: boolean;
|
||||
message?: string | null;
|
||||
} = {}
|
||||
);
|
||||
```
|
||||
20
.cursor/rules/astro-actions-api.mdc
Normal file
20
.cursor/rules/astro-actions-api.mdc
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description:
|
||||
globs: web/src/actions,web/src/pages
|
||||
alwaysApply: false
|
||||
---
|
||||
- In the astro actions return, generaly don't return anythig unless the caller doesn't needs it. Specially don't `return { success: true }`, or similar. If needed, just return an object with the newly created/edited objects (Like: `return { newService: service }` or don't return anything if not needed).
|
||||
- When importing actions, use `import { actions } from 'astro:actions'`. Example:
|
||||
|
||||
```ts
|
||||
import { actions } from 'astro:actions'; /* CORRECT */
|
||||
import { server } from '~/actions'; /* WRONG!!!! DON'T DO THIS */
|
||||
import { adminAttributeActions } from '~/actions/admin/attribute.ts'; /* WRONG!!!! DON'T DO THIS */
|
||||
|
||||
const result = Astro.getActionResult(actions.admin.attribute.create);
|
||||
```
|
||||
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
|
||||
- When throwing errors in Astro actions use ActionError.
|
||||
- Always use Astro actions instead of with API routes and instead of `if (Astro.request.method === "POST")`.
|
||||
- Generally call the actions using html forms. But if you need to, you can call them from the server-side code with Astro.callAction(), or [callActionWithUrlParams.ts](mdc:web/src/lib/callActionWithUrlParams.ts).
|
||||
- The admin actions go into a separate folder.
|
||||
26
.cursor/rules/client-side-javascript.mdc
Normal file
26
.cursor/rules/client-side-javascript.mdc
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description:
|
||||
globs: web/src/pages,web/src/components
|
||||
alwaysApply: false
|
||||
---
|
||||
- Avoid sending JavaScript to the client. The JS send should always be optional.
|
||||
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
|
||||
- To avoid using JavaScript, you can use HTML and CSS features such as hidden checkboxes, deltails tag, etc.
|
||||
- The admin pages can use client-side JavaScript.
|
||||
- When adding clientside JS do it with HTMX.
|
||||
- When adding HTMX, the layout component BaseLayout [BaseLayout.astro](mdc:web/src/layouts/BaseLayout.astro) [BaseHead.astro](mdc:web/src/components/BaseHead.astro) accepts a prop htmx to load it in that page. No need to use a cdn.
|
||||
- When adding client scripts remember to use the event `astro:page-load`, `querySelectorAll<Type>` and add an explanation comment, like so:
|
||||
|
||||
```tsx
|
||||
<script>
|
||||
////////////////////////////////////////////////////////////
|
||||
// Optional script for __________. //
|
||||
// Desctiption goes here... //
|
||||
////////////////////////////////////////////////////////////
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLDivElement>('[data-my-div]').forEach((myDiv) => {
|
||||
// Do something
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
8
.cursor/rules/code-style.mdc
Normal file
8
.cursor/rules/code-style.mdc
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
- Instead of using the syntax`Array<T>`, use `T[]`.
|
||||
- Use TypeScript `type` over `interface`.
|
||||
- You should never add unnecessary or unuseful comments, if you add a comment it must provide some value to the code.
|
||||
55
.cursor/rules/database.mdc
Normal file
55
.cursor/rules/database.mdc
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: Querying the database, editing the database, needing to import types from the database, or anything related to the database or Prisma.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
- We use Prisma as ORM.
|
||||
- Remember to check the prisma schema [schema.prisma](mdc:web/prisma/schema.prisma) when doing things related to the database.
|
||||
- After making changes to the [schema.prisma](mdc:web/prisma/schema.prisma) database or [seed.ts](mdc:web/prisma/seed.ts), you run `npm run db-reset` (from `/web/` folder) [package.json](mdc:web/package.json). And never do the migrations manually.
|
||||
- Import the types from prisma instead of hardcoding duplicates. Specially use the Prisma.___GetPayload type and the enums. Like this:
|
||||
```ts
|
||||
type Props = {
|
||||
user: Prisma.UserGetPayload<{
|
||||
select: {
|
||||
name: true
|
||||
displayName: true
|
||||
picture: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
- Only `select` the necessary fields, no more.
|
||||
- In prisma preffer `select` over `include` when making queries.
|
||||
- Avoid hardcoding enums from the database, import them from prisma.
|
||||
- To query the database from Astro pages, use Astro.locals.try() or Astro.locals.tryMany([]) [errorBanners.ts](mdc:web/src/lib/errorBanners.ts) [middleware.ts](mdc:web/src/middleware.ts) , like so:
|
||||
```ts
|
||||
const [user, services] = await Astro.locals.banners.tryMany([
|
||||
[
|
||||
'Error fetching user',
|
||||
() =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
name: true,
|
||||
displayName: true,
|
||||
picture: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'Error fetching services',
|
||||
() =>
|
||||
prisma.service.findMany({
|
||||
where: { categories: { some: { id: categoryId } } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
}),
|
||||
[] as [],
|
||||
],
|
||||
])
|
||||
```
|
||||
- When editing the database, remember to edit the db seeding file [seed.ts](mdc:web/prisma/seed.ts) to generate data for the new schema.
|
||||
98
.cursor/rules/design-patterns.mdc
Normal file
98
.cursor/rules/design-patterns.mdc
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
- The main libraries used are: Astro, TypeScript, Tailwind 4, HTMX, Prisma, npm, zod, lodash-es, date-fns, ts-toolbelt. Full list in: [package.json](mdc:web/package.json)
|
||||
- When creating constants or enums, use the `makeHelpersForOptions` function [makeHelpersForOptions.ts](mdc:web/src/lib/makeHelpersForOptions.ts) like in this example. Save the file in the `web/src/constants` folder (like [attributeTypes.ts](mdc:web/src/constants/attributeTypes.ts)). Note that it's not necessary to use all the options or export all the variables that the example has, just the ones you need.
|
||||
|
||||
```ts
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions';
|
||||
import { transformCase } from '../lib/strings';
|
||||
|
||||
import type { AttributeType } from '@prisma/client';
|
||||
|
||||
type AttributeTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T;
|
||||
slug: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
order: number;
|
||||
classNames: {
|
||||
text: string;
|
||||
icon: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const {
|
||||
dataArray: attributeTypes,
|
||||
dataObject: attributeTypesById,
|
||||
getFn: getAttributeTypeInfo,
|
||||
getFnSlug: getAttributeTypeInfoBySlug,
|
||||
zodEnumBySlug: attributeTypesZodEnumBySlug,
|
||||
zodEnumById: attributeTypesZodEnumById,
|
||||
keyToSlug: attributeTypeIdToSlug,
|
||||
slugToKey: attributeTypeSlugToId,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): AttributeTypeInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value
|
||||
? transformCase(value.replace('_', ' '), 'title')
|
||||
: String(value),
|
||||
icon: 'ri:question-line',
|
||||
order: Infinity,
|
||||
classNames: {
|
||||
text: 'text-current/60',
|
||||
icon: 'text-current/60',
|
||||
},
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'BAD',
|
||||
slug: 'bad',
|
||||
label: 'Bad',
|
||||
icon: 'ri:close-line',
|
||||
order: 1,
|
||||
classNames: {
|
||||
text: 'text-red-200',
|
||||
icon: 'text-red-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'WARNING',
|
||||
slug: 'warning',
|
||||
label: 'Warning',
|
||||
icon: 'ri:alert-line',
|
||||
order: 2,
|
||||
classNames: {
|
||||
text: 'text-yellow-200',
|
||||
icon: 'text-yellow-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'GOOD',
|
||||
slug: 'good',
|
||||
label: 'Good',
|
||||
icon: 'ri:check-line',
|
||||
order: 3,
|
||||
classNames: {
|
||||
text: 'text-green-200',
|
||||
icon: 'text-green-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'INFO',
|
||||
slug: 'info',
|
||||
label: 'Info',
|
||||
icon: 'ri:information-line',
|
||||
order: 4,
|
||||
classNames: {
|
||||
text: 'text-blue-200',
|
||||
icon: 'text-blue-400',
|
||||
},
|
||||
},
|
||||
] as const satisfies AttributeTypeInfo<AttributeType>[]
|
||||
);
|
||||
```
|
||||
161
.cursor/rules/pages-and-components.mdc
Normal file
161
.cursor/rules/pages-and-components.mdc
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
description:
|
||||
globs: web/src/pages,web/src/components
|
||||
alwaysApply: false
|
||||
---
|
||||
- On .astro files, don't forget to include the three dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
|
||||
- For icons use the `Icon` component from `astro-icon/components`.
|
||||
- For icons use the Remix Icon library preferably.
|
||||
- Use the `MyPicture` component from `src/components/MyPicture.astro` for images.
|
||||
- When redirecting to login use the `makeLoginUrl` function from [redirectUrls.ts](mdc:web/src/lib/redirectUrls.ts) and if the link is for an `<a>` tag, use the `data-astro-reload` attribute. Similar for the logout and impersonate.
|
||||
- Don't use the `web/src/pages/admin` pages as example unless explicitly stated or you're creating/editing an admin page.
|
||||
- Checkout the @errorBanners.ts @middleware.ts @env.d.ts to see the avilable Astro.locals values.
|
||||
- Avoid duplicating similar html code. You can use jsx for loops, create variables in the constants folder, or create separate components.
|
||||
- When redirecting to the 404 not found page, use `Astro.rewrite` (Like this example: `if (!user) return Astro.rewrite('/404')`)
|
||||
- Include schema markup in the pages when it makes sense. Examples: [[slug].astro](mdc:web/src/pages/service/[slug].astro)
|
||||
- When creating forms, we already have utilities, components and established design patterns. Follow this example. (Note that this example may come slightly outdaded, but the overall philosophy doesn't change)
|
||||
```astro
|
||||
---
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import Captcha from '../../components/Captcha.astro'
|
||||
import InputCardGroup from '../../components/InputCardGroup.astro'
|
||||
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
||||
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
||||
import InputImageFile from '../../components/InputImageFile.astro'
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputText from '../../components/InputText.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
|
||||
const user = Astro.locals.user
|
||||
if (!user) {
|
||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
|
||||
}
|
||||
|
||||
const result = Astro.getActionResult(actions.serviceSuggestion.editService)
|
||||
if (result && !result.error) {
|
||||
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
|
||||
}
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
|
||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
notes: z.string().default(''),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
|
||||
if (!params.serviceId) return Astro.rewrite('/404')
|
||||
|
||||
const service = await Astro.locals.banners.try(
|
||||
'Failed to fetch service',
|
||||
async () =>
|
||||
prisma.service.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
overallScore: true,
|
||||
kycLevel: true,
|
||||
imageUrl: true,
|
||||
verificationStatus: true,
|
||||
acceptedCurrencies: true,
|
||||
categories: {
|
||||
select: {
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { id: params.serviceId },
|
||||
}),
|
||||
null
|
||||
)
|
||||
|
||||
if (!service) return Astro.rewrite('/404')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
pageTitle="Edit service"
|
||||
description="Suggest an edit to service"
|
||||
ogImage={{
|
||||
template: 'generic',
|
||||
title: 'Edit service',
|
||||
description: 'Suggest an edit to service',
|
||||
icon: 'ri:edit-line',
|
||||
}}
|
||||
widthClassName="max-w-screen-md"
|
||||
>
|
||||
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
|
||||
|
||||
<form method="POST" action={actions.serviceSuggestion.editService} class="space-y-6">
|
||||
<input type="hidden" name="serviceId" value={params.serviceId} />
|
||||
|
||||
<InputText
|
||||
label="Service name"
|
||||
name="name"
|
||||
value={service.name}
|
||||
error={inputErrors.name}
|
||||
inputProps={{ 'data-custom-value': true, required: true }}
|
||||
/>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevel"
|
||||
label="KYC Level"
|
||||
options={kycLevels.map((kycLevel) => ({
|
||||
label: kycLevel.name,
|
||||
value: kycLevel.id.toString(),
|
||||
icon: kycLevel.icon,
|
||||
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/5_`,
|
||||
}))}
|
||||
iconSize="md"
|
||||
cardSize="md"
|
||||
required
|
||||
error={inputErrors.kycLevel}
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
required
|
||||
options={categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id.toString(),
|
||||
icon: category.icon,
|
||||
}))}
|
||||
error={inputErrors.categories}
|
||||
/>
|
||||
|
||||
<InputImageFile
|
||||
label="Service Image"
|
||||
name="imageFile"
|
||||
description="Square image. At least 192x192px. Transparency supported."
|
||||
error={inputErrors.imageFile}
|
||||
square
|
||||
required
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Note for Moderators"
|
||||
name="notes"
|
||||
value={params.notes}
|
||||
inputProps={{ rows: 10 }}
|
||||
error={inputErrors.notes}
|
||||
/>
|
||||
|
||||
<Captcha action={actions.serviceSuggestion.createService} />
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
<InputSubmitButton hideCancel />
|
||||
</form>
|
||||
</BaseLayout>
|
||||
```
|
||||
10
.cursor/rules/styles.mdc
Normal file
10
.cursor/rules/styles.mdc
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description:
|
||||
globs: /web/src/pages,/web/src/components,/web/src/constants
|
||||
alwaysApply: false
|
||||
---
|
||||
- We use Tailwind 4 (the latest version), make sure to not use outdated classes from Tailwind 3.
|
||||
- Checkout the custom tailwind theme [global.css](mdc:web/src/styles/global.css).
|
||||
- When adding conditional styles or merging tailwind classes, use the `cn` function. Never use template strings. [cn.ts](mdc:web/src/lib/cn.ts)
|
||||
- For the grayscale colors, try to use the custom color `day` for the light/foreground colors (50-500) and `night` for the dark/bakground (500-950).
|
||||
- Generally avoid using opacity modifiers (In `text-red-500/50` the `/50`), but it's fine to also use it.
|
||||
312
.cursorrules
312
.cursorrules
@@ -1,312 +0,0 @@
|
||||
# Cursor Rules
|
||||
|
||||
- When merging tailwind classes, use the `cn` function.
|
||||
- When using Tailwind and you need to merge classes use the `cn` function if avilable.
|
||||
- We use Tailwind 4 (the latest version), make sure to not use outdated classes.
|
||||
- Instead of using the syntax`Array<T>`, use `T[]`.
|
||||
- Use TypeScript `type` over `interface`.
|
||||
- You are forbiddent o add comments unless explicitly stated by the user.
|
||||
- Avoid sending JavaScript to the client. The JS send should be optional.
|
||||
- In prisma preffer `select` over `include` when making queries.
|
||||
- Import the types from prisma instead of hardcoding duplicates.
|
||||
- Avoid duplicating similar html code, and parametrize it when possible or create separate components.
|
||||
- Remember to check the prisma schema when doing things related to the database.
|
||||
- Avoid hardcoding enums from the database, import them from prisma.
|
||||
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
|
||||
- The admin pages can use client-side JavaScript.
|
||||
- Keep README.md in sync with new capabilities.
|
||||
- The package manager is npm.
|
||||
- For icons use the `Icon` component from `astro-icon/components`.
|
||||
- For icons use the Remix Icon library preferably.
|
||||
- Use the `Image` component from `astro:assets` for images.
|
||||
- Use the `zod` library for schema validation.
|
||||
- In the astro actions return, don't return success: true, or similar, just return an object with the newly created/edited objects or nothing.
|
||||
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
|
||||
- Don't forget that the astro files have three dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
|
||||
- The admin actions go into a separate folder.
|
||||
- In Actro actions when throwing errors use ActionError.
|
||||
- @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`. Example:
|
||||
|
||||
```ts
|
||||
import { actions } from 'astro:actions'; /* CORRECT */
|
||||
import { server } from '~/actions'; /* WRONG!!!! DON'T DO THIS */
|
||||
import { adminAttributeActions } from '~/actions/admin/attribute.ts'; /* WRONG!!!! DON'T DO THIS */
|
||||
|
||||
const result = Astro.getActionResult(actions.admin.attribute.create);
|
||||
```
|
||||
|
||||
- Always use Astro actions instead of with API routes or `if (Astro.request.method === "POST")`.
|
||||
- When adding clientside js do it with HTMX.
|
||||
- When adding HTMX, the layout component BaseLayout accepts a prop htmx to load it in that page. No need to use a cdn.
|
||||
- When redirecting to login use the `makeLoginUrl` function from web/src/lib/redirectUrls.ts and if the link is for an `<a>` tag, use the `data-astro-reload` attribute.
|
||||
|
||||
```ts
|
||||
function makeLoginUrl(
|
||||
currentUrl: URL,
|
||||
options: {
|
||||
redirect?: URL | string | null;
|
||||
error?: string | null;
|
||||
logout?: boolean;
|
||||
message?: string | null;
|
||||
} = {}
|
||||
);
|
||||
```
|
||||
|
||||
- When adding client scripts remember to use the event `astro:page-load`, `querySelectorAll<Type>` and add an explanation comment, like so:
|
||||
|
||||
```tsx
|
||||
<script>
|
||||
////////////////////////////////////////////////////////////
|
||||
// Optional script for __________. //
|
||||
// Desctiption goes here... //
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLDivElement>('[data-my-div]').forEach((myDiv) => {
|
||||
// Do something
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
- When creating forms, we already have utilities, components and established design patterns. Follow this example:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import Captcha from '../../components/Captcha.astro'
|
||||
import InputCardGroup from '../../components/InputCardGroup.astro'
|
||||
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
||||
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
||||
import InputImageFile from '../../components/InputImageFile.astro'
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputText from '../../components/InputText.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
|
||||
const user = Astro.locals.user
|
||||
if (!user) {
|
||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
|
||||
}
|
||||
|
||||
const result = Astro.getActionResult(actions.serviceSuggestion.editService)
|
||||
if (result && !result.error) {
|
||||
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
|
||||
}
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
|
||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
notes: z.string().default(''),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
|
||||
if (!params.serviceId) return Astro.rewrite('/404')
|
||||
|
||||
const service = await Astro.locals.banners.try(
|
||||
'Failed to fetch service',
|
||||
async () =>
|
||||
prisma.service.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
overallScore: true,
|
||||
kycLevel: true,
|
||||
imageUrl: true,
|
||||
verificationStatus: true,
|
||||
acceptedCurrencies: true,
|
||||
categories: {
|
||||
select: {
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { id: params.serviceId },
|
||||
}),
|
||||
null
|
||||
)
|
||||
|
||||
if (!service) return Astro.rewrite('/404')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
pageTitle="Edit service"
|
||||
description="Suggest an edit to service"
|
||||
ogImage={{
|
||||
template: 'generic',
|
||||
title: 'Edit service',
|
||||
description: 'Suggest an edit to service',
|
||||
icon: 'ri:edit-line',
|
||||
}}
|
||||
widthClassName="max-w-screen-md"
|
||||
>
|
||||
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
|
||||
|
||||
<form method="POST" action={actions.serviceSuggestion.editService} class="space-y-6">
|
||||
<input type="hidden" name="serviceId" value={params.serviceId} />
|
||||
|
||||
<InputText
|
||||
label="Service name"
|
||||
name="name"
|
||||
value={service.name}
|
||||
error={inputErrors.name}
|
||||
inputProps={{ 'data-custom-value': true, required: true }}
|
||||
/>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevel"
|
||||
label="KYC Level"
|
||||
options={kycLevels.map((kycLevel) => ({
|
||||
label: kycLevel.name,
|
||||
value: kycLevel.id.toString(),
|
||||
icon: kycLevel.icon,
|
||||
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/5_`,
|
||||
}))}
|
||||
iconSize="md"
|
||||
cardSize="md"
|
||||
required
|
||||
error={inputErrors.kycLevel}
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
required
|
||||
options={categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id.toString(),
|
||||
icon: category.icon,
|
||||
}))}
|
||||
error={inputErrors.categories}
|
||||
/>
|
||||
|
||||
<InputImageFile
|
||||
label="Service Image"
|
||||
name="imageFile"
|
||||
description="Square image. At least 192x192px. Transparency supported."
|
||||
error={inputErrors.imageFile}
|
||||
square
|
||||
required
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Note for Moderators"
|
||||
name="notes"
|
||||
value={params.notes}
|
||||
inputProps={{ rows: 10 }}
|
||||
error={inputErrors.notes}
|
||||
/>
|
||||
|
||||
<Captcha action={actions.serviceSuggestion.createService} />
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
<InputSubmitButton hideCancel />
|
||||
</form>
|
||||
</BaseLayout>
|
||||
```
|
||||
|
||||
- Don't use the `web/src/pages/admin` pages as example unless explicitly stated or you're creating/editing an admin page.
|
||||
- When creating constants or enums, use the `makeHelpersForOptions` function like in this example. Save the file in the `web/src/constants` folder. Note that it's not necessary to use all the options the example has, just the ones you need.
|
||||
|
||||
```ts
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions';
|
||||
import { transformCase } from '../lib/strings';
|
||||
|
||||
import type { AttributeType } from '@prisma/client';
|
||||
|
||||
type AttributeTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T;
|
||||
slug: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
order: number;
|
||||
classNames: {
|
||||
text: string;
|
||||
icon: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const {
|
||||
dataArray: attributeTypes,
|
||||
dataObject: attributeTypesById,
|
||||
getFn: getAttributeTypeInfo,
|
||||
getFnSlug: getAttributeTypeInfoBySlug,
|
||||
zodEnumBySlug: attributeTypesZodEnumBySlug,
|
||||
zodEnumById: attributeTypesZodEnumById,
|
||||
keyToSlug: attributeTypeIdToSlug,
|
||||
slugToKey: attributeTypeSlugToId,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): AttributeTypeInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value
|
||||
? transformCase(value.replace('_', ' '), 'title')
|
||||
: String(value),
|
||||
icon: 'ri:question-line',
|
||||
order: Infinity,
|
||||
classNames: {
|
||||
text: 'text-current/60',
|
||||
icon: 'text-current/60',
|
||||
},
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'BAD',
|
||||
slug: 'bad',
|
||||
label: 'Bad',
|
||||
icon: 'ri:close-line',
|
||||
order: 1,
|
||||
classNames: {
|
||||
text: 'text-red-200',
|
||||
icon: 'text-red-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'WARNING',
|
||||
slug: 'warning',
|
||||
label: 'Warning',
|
||||
icon: 'ri:alert-line',
|
||||
order: 2,
|
||||
classNames: {
|
||||
text: 'text-yellow-200',
|
||||
icon: 'text-yellow-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'GOOD',
|
||||
slug: 'good',
|
||||
label: 'Good',
|
||||
icon: 'ri:check-line',
|
||||
order: 3,
|
||||
classNames: {
|
||||
text: 'text-green-200',
|
||||
icon: 'text-green-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'INFO',
|
||||
slug: 'info',
|
||||
label: 'Info',
|
||||
icon: 'ri:information-line',
|
||||
order: 4,
|
||||
classNames: {
|
||||
text: 'text-blue-200',
|
||||
icon: 'text-blue-400',
|
||||
},
|
||||
},
|
||||
] as const satisfies AttributeTypeInfo<AttributeType>[]
|
||||
);
|
||||
```
|
||||
46
.env.example
Normal file
46
.env.example
Normal file
@@ -0,0 +1,46 @@
|
||||
# Database
|
||||
POSTGRES_USER=your_db_user
|
||||
POSTGRES_PASSWORD=your_db_password
|
||||
POSTGRES_DATABASE=your_db_name
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
|
||||
DATABASE_UI_URL="https://db.example.com"
|
||||
|
||||
# Generic Config
|
||||
UPLOAD_DIR=/app/uploads
|
||||
SITE_URL="https://your-site.example.com"
|
||||
SOURCE_CODE_URL="https://your-source-code.example.com"
|
||||
TIME_TRAP_SECRET=your_time_trap_secret
|
||||
LOGS_UI_URL="https://logs.example.com"
|
||||
|
||||
# Release Info
|
||||
RELEASE_NUMBER=
|
||||
RELEASE_DATE=
|
||||
|
||||
# Redis
|
||||
REDIS_URL="redis://redis:6379"
|
||||
|
||||
# Crawl4AI
|
||||
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
|
||||
CRAWL4AI_API_TOKEN=your_crawl4ai_token
|
||||
|
||||
# Tor and I2P
|
||||
ONION_ADDRESS="http://youronionaddress.onion"
|
||||
I2P_ADDRESS="http://youri2paddress.b32.i2p"
|
||||
I2P_PASS=your_i2p_password
|
||||
|
||||
# Push Notifications
|
||||
VAPID_PUBLIC_KEY=your_vapid_public_key
|
||||
VAPID_PRIVATE_KEY=your_vapid_private_key
|
||||
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
OPENAI_BASE_URL="https://your-openai-base-url.example.com"
|
||||
OPENAI_MODEL=your_openai_model
|
||||
OPENAI_RETRY=3
|
||||
|
||||
# Pyworker Crons
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,4 +13,5 @@ dump*.sql
|
||||
*.log
|
||||
*.bak
|
||||
migrate.py
|
||||
sync-all.sh
|
||||
sync-all.sh
|
||||
.DS_Store
|
||||
|
||||
@@ -23,13 +23,12 @@ cd web
|
||||
nvm install
|
||||
npm i
|
||||
cp -n .env.example .env
|
||||
npm run db-push
|
||||
npm run db-fill-clean
|
||||
npm run db-reset
|
||||
```
|
||||
|
||||
Now open the [.env](web/.env) file and fill in the missing values.
|
||||
|
||||
> Default users are created with tokens: `admin`, `verifier`, `verified`, `normal` (configurable via env vars)
|
||||
> Default users are created with tokens: `admin`, `moderator`, `verified`, `normal` (configurable via env vars)
|
||||
|
||||
### Running the project
|
||||
|
||||
|
||||
@@ -7,10 +7,8 @@ services:
|
||||
volumes:
|
||||
- database:/var/lib/postgresql/data:z
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
|
||||
interval: 10s
|
||||
@@ -20,19 +18,17 @@ services:
|
||||
pyworker:
|
||||
build:
|
||||
context: ./pyworker
|
||||
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
||||
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
|
||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
crawl4ai:
|
||||
image: unclecode/crawl4ai:basic-amd64
|
||||
expose:
|
||||
- "11235"
|
||||
environment:
|
||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
|
||||
MAX_CONCURRENT_TASKS: 10
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /dev/shm:/dev/shm
|
||||
deploy:
|
||||
@@ -53,15 +49,11 @@ services:
|
||||
|
||||
astro:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: web/Dockerfile
|
||||
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
36
justfile
36
justfile
@@ -51,42 +51,6 @@ import-db file="":
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Restoring database from $BACKUP_FILE..."
|
||||
# First drop all connections to the database
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
|
||||
|
||||
# Drop and recreate database
|
||||
echo "Dropping and recreating the database..."
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
|
||||
|
||||
# Restore the database
|
||||
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
|
||||
echo "Database restored successfully!"
|
||||
|
||||
# Import triggers
|
||||
echo "Importing triggers..."
|
||||
just import-triggers
|
||||
|
||||
echo "Database import completed!"
|
||||
# Check if migrations need to be run
|
||||
cd web && npx prisma migrate status
|
||||
|
||||
#!/bin/bash
|
||||
if [ -z "{{file}}" ]; then
|
||||
BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1)
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "Error: No backup files found in the backups directory"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
BACKUP_FILE="{{file}}"
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "Error: Backup file '$BACKUP_FILE' not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== STEP 1: PREPARING DATABASE ==="
|
||||
# Drop all connections to the database
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
|
||||
|
||||
@@ -184,16 +184,16 @@ Be concise but thorough, and make sure your output is properly formatted JSON.
|
||||
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
||||
You will be given a list of user comments to a service.
|
||||
Your task is to summarize the comments in a way that is easy to understand and to the point.
|
||||
The summary should be concise and to the point, no more than 150 words.
|
||||
The summary should be concise and to the point, no more than 100 words. Keep it short and concise.
|
||||
Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
|
||||
|
||||
You must format your response as a valid JSON object with the following structure:
|
||||
|
||||
interface CommentSummary {
|
||||
summary: string;
|
||||
summary: string; // Concise, 100 words max
|
||||
sentiment: 'positive'|'negative'|'neutral';
|
||||
whatUsersLike: string[]; // Concise, 2-3 words, max 4
|
||||
whatUsersDislike: string[]; // Concise, 2-3 words, max 4
|
||||
whatUsersLike: string[]; // Concise, 2-3 words max
|
||||
whatUsersDislike: string[]; // Concise, 2-3 words max
|
||||
}
|
||||
|
||||
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
SOURCE_CODE_URL="https://github.com"
|
||||
SITE_URL="https://localhost:4321"
|
||||
DATABASE_UI_URL="http://localhost:5555"
|
||||
SITE_URL="http://localhost:4321"
|
||||
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
|
||||
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
||||
RELEASE_NUMBER=123
|
||||
RELEASE_DATE="2025-05-23T19:00:00.000Z"
|
||||
# Generated with `npx web-push generate-vapid-keys`
|
||||
VAPID_PUBLIC_KEY="BPmJbRXzG9zT181vyg1GlpyV8qu7rjVjfg6vkkOgtqeTZECyt6lR4MuzmlarEHSBF6gPpc77ZA0_tTVtmYh65iM"
|
||||
VAPID_PRIVATE_KEY="eN_S2SMXDB2hpwVXbgDkDrPIPMqirllZaJcUgYTt9w0"
|
||||
VAPID_SUBJECT="mailto:no-reply@kycnot.me"
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
FROM node:lts AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY .env .env
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
|
||||
COPY .npmrc .npmrc
|
||||
COPY web/.npmrc .npmrc
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
COPY web/ .
|
||||
|
||||
ARG ASTRO_BUILD_MODE=production
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
|
||||
|
||||
@@ -20,7 +23,7 @@ ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
|
||||
# Add knm-migrate command script and make it executable
|
||||
COPY migrate.sh /usr/local/bin/knm-migrate
|
||||
COPY web/migrate.sh /usr/local/bin/knm-migrate
|
||||
RUN chmod +x /usr/local/bin/knm-migrate
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
|
||||
@@ -6,24 +6,23 @@
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :------------------------------------------------------------------- |
|
||||
| `nvm install` | Installs and uses the correct version of node |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
| `npm run db-admin` | Runs Prisma Studio (database admin) |
|
||||
| `npm run db-gen` | Generates the Prisma client without running migrations |
|
||||
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
|
||||
| `npm run db-fill` | Fills the database with fake data (development mode) |
|
||||
| `npm run db-fill-clean` | Cleans existing data and fills with new fake data (development mode) |
|
||||
| `npm run format` | Formats the code with Prettier |
|
||||
| `npm run lint` | Lints the code with ESLint |
|
||||
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
|
||||
| Command | Action |
|
||||
| :------------------------ | :------------------------------------------------------------------ |
|
||||
| `nvm install` | Installs and uses the correct version of node |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
| `npm run db-admin` | Runs Prisma Studio (database admin) |
|
||||
| `npm run db-gen` | Generates the Prisma client without running migrations |
|
||||
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
|
||||
| `npm run db-seed` | Seeds the database with fake data (development mode) |
|
||||
| `npm run format` | Formats the code with Prettier |
|
||||
| `npm run lint` | Lints the code with ESLint |
|
||||
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
|
||||
|
||||
> **Note**: `db-fill` and `db-fill-clean` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-fill -- --services=5` will add 5 fake services.
|
||||
> **Note**: `db-seed` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-seed -- --services=5` will add 5 fake services.
|
||||
|
||||
> **Note**: `db-fill` and `db-fill-clean` create default users with tokens: `admin`, `verifier`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
|
||||
> **Note**: `db-seed` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
|
||||
|
||||
@@ -6,11 +6,11 @@ import sitemap from '@astrojs/sitemap'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig, envField } from 'astro/config'
|
||||
import icon from 'astro-icon'
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
// @ts-expect-error process.env actually exists
|
||||
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
|
||||
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
|
||||
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
integrations: [
|
||||
postgresListener(),
|
||||
icon(),
|
||||
mdx(),
|
||||
sitemap({
|
||||
@@ -131,11 +132,11 @@ export default defineConfig({
|
||||
min: 1,
|
||||
default: 'admin',
|
||||
}),
|
||||
DEV_VERIFIER_USER_SECRET_TOKEN: envField.string({
|
||||
DEV_MODERATOR_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
default: 'verifier',
|
||||
default: 'moderator',
|
||||
}),
|
||||
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
@@ -170,6 +171,52 @@ export default defineConfig({
|
||||
url: true,
|
||||
optional: false,
|
||||
}),
|
||||
|
||||
DATABASE_UI_URL: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
url: true,
|
||||
optional: false,
|
||||
}),
|
||||
LOGS_UI_URL: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
url: true,
|
||||
optional: true,
|
||||
}),
|
||||
|
||||
RELEASE_NUMBER: envField.number({
|
||||
context: 'server',
|
||||
access: 'public',
|
||||
int: true,
|
||||
optional: true,
|
||||
}),
|
||||
RELEASE_DATE: envField.string({
|
||||
context: 'server',
|
||||
access: 'public',
|
||||
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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
1489
web/package-lock.json
generated
1489
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,27 +12,30 @@
|
||||
"db-push": "prisma migrate dev",
|
||||
"db-triggers": "just import-triggers",
|
||||
"db-update": "prisma migrate dev && just import-triggers",
|
||||
"db-reset": "prisma migrate reset && prisma migrate dev && just import-triggers && tsx scripts/faker.ts",
|
||||
"db-fill": "tsx scripts/faker.ts",
|
||||
"db-fill-clean": "tsx scripts/faker.ts --cleanup",
|
||||
"db-reset": "prisma migrate reset -f && prisma migrate dev",
|
||||
"db-seed": "prisma db seed",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint . --fix && prettier --write ."
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@astrojs/db": "0.14.14",
|
||||
"@astrojs/mdx": "4.2.6",
|
||||
"@astrojs/node": "9.2.1",
|
||||
"@astrojs/sitemap": "3.4.0",
|
||||
"@fontsource-variable/space-grotesk": "5.2.7",
|
||||
"@astrojs/db": "0.15.0",
|
||||
"@astrojs/mdx": "4.3.0",
|
||||
"@astrojs/node": "9.2.2",
|
||||
"@astrojs/sitemap": "3.4.1",
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
"@fontsource/inter": "5.2.5",
|
||||
"@fontsource/space-grotesk": "5.2.7",
|
||||
"@prisma/client": "6.8.2",
|
||||
"@tailwindcss/vite": "4.1.7",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@fontsource/space-grotesk": "5.2.8",
|
||||
"@prisma/client": "6.9.0",
|
||||
"@tailwindcss/vite": "4.1.8",
|
||||
"@types/mime-types": "3.0.0",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.7.13",
|
||||
"astro": "5.9.0",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"astro-seo-schema": "5.0.0",
|
||||
@@ -40,56 +43,59 @@
|
||||
"clsx": "2.1.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.8",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
"lodash-es": "4.17.21",
|
||||
"mime-types": "3.0.1",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"pg": "8.16.0",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.0.1",
|
||||
"redis": "5.5.6",
|
||||
"schema-dts": "1.1.5",
|
||||
"seedrandom": "3.0.5",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.34.2",
|
||||
"slugify": "1.6.6",
|
||||
"tailwind-merge": "3.3.0",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.7",
|
||||
"tailwindcss": "4.1.8",
|
||||
"typescript": "5.8.3",
|
||||
"unique-username-generator": "1.4.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod-form-data": "2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.27.0",
|
||||
"@eslint/js": "9.28.0",
|
||||
"@faker-js/faker": "9.8.0",
|
||||
"@iconify-json/material-symbols": "1.2.21",
|
||||
"@iconify-json/material-symbols": "1.2.24",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
"@stylistic/eslint-plugin": "4.2.0",
|
||||
"@stylistic/eslint-plugin": "4.4.1",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.33.1",
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-import-resolver-typescript": "4.3.5",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-import-resolver-typescript": "4.4.3",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"globals": "16.1.0",
|
||||
"globals": "16.2.0",
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.11",
|
||||
"prisma": "6.8.2",
|
||||
"prisma-json-types-generator": "3.4.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.12",
|
||||
"prisma": "6.9.0",
|
||||
"prisma-json-types-generator": "3.4.2",
|
||||
"tailwind-htmx": "0.1.2",
|
||||
"ts-essentials": "10.0.4",
|
||||
"ts-toolbelt": "9.6.0",
|
||||
"tsx": "4.19.4",
|
||||
"typescript-eslint": "8.32.1"
|
||||
"typescript-eslint": "8.33.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Manully edited to be a rename migration.
|
||||
*/
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
ALTER TYPE "AccountStatusChange" RENAME VALUE 'VERIFIER_TRUE' TO 'MODERATOR_TRUE';
|
||||
ALTER TYPE "AccountStatusChange" RENAME VALUE 'VERIFIER_FALSE' TO 'MODERATOR_FALSE';
|
||||
COMMIT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User"
|
||||
RENAME COLUMN "verifier" TO "moderator"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ServiceVisibility" ADD VALUE 'ARCHIVED';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InternalServiceNote" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
"addedByUserId" INTEGER,
|
||||
|
||||
CONSTRAINT "InternalServiceNote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_serviceId_idx" ON "InternalServiceNote"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_addedByUserId_idx" ON "InternalServiceNote"("addedByUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_createdAt_idx" ON "InternalServiceNote"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_addedByUserId_fkey" FOREIGN KEY ("addedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `kycLevelDetailsId` on the `Service` table. All the data in the column will be lost.
|
||||
- Made the column `kycLevelClarification` on table `Service` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" DROP COLUMN "kycLevelDetailsId",
|
||||
ALTER COLUMN "kycLevelClarification" SET NOT NULL,
|
||||
ALTER COLUMN "kycLevelClarification" SET DEFAULT 'NONE';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'TEST';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';
|
||||
@@ -25,6 +25,7 @@ enum OrderIdStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
model Comment {
|
||||
@@ -87,6 +88,7 @@ enum ServiceVisibility {
|
||||
PUBLIC
|
||||
UNLISTED
|
||||
HIDDEN
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
@@ -120,13 +122,14 @@ enum AccountStatusChange {
|
||||
ADMIN_FALSE
|
||||
VERIFIED_TRUE
|
||||
VERIFIED_FALSE
|
||||
VERIFIER_TRUE
|
||||
VERIFIER_FALSE
|
||||
MODERATOR_TRUE
|
||||
MODERATOR_FALSE
|
||||
SPAMMER_TRUE
|
||||
SPAMMER_FALSE
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
TEST
|
||||
COMMENT_STATUS_CHANGE
|
||||
REPLY_COMMENT_CREATED
|
||||
COMMUNITY_NOTE_ADDED
|
||||
@@ -296,6 +299,11 @@ enum ServiceSuggestionType {
|
||||
EDIT_SERVICE
|
||||
}
|
||||
|
||||
enum KycLevelClarification {
|
||||
NONE
|
||||
DEPENDS_ON_PARTNERS
|
||||
}
|
||||
|
||||
model ServiceSuggestion {
|
||||
id Int @id @default(autoincrement())
|
||||
type ServiceSuggestionType
|
||||
@@ -335,9 +343,11 @@ model Service {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
slug String @unique
|
||||
previousSlugs String[] @default([])
|
||||
description String
|
||||
categories Category[] @relation("ServiceToCategory")
|
||||
kycLevel Int @default(4)
|
||||
kycLevelClarification KycLevelClarification @default(NONE)
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
@@ -376,6 +386,7 @@ model Service {
|
||||
attributes ServiceAttribute[]
|
||||
verificationSteps VerificationStep[]
|
||||
suggestions ServiceSuggestion[]
|
||||
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
||||
|
||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||
@@ -394,6 +405,7 @@ model Service {
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@index([slug])
|
||||
@@index([previousSlugs])
|
||||
}
|
||||
|
||||
model ServiceContactMethod {
|
||||
@@ -441,6 +453,7 @@ model Attribute {
|
||||
|
||||
model InternalUserNote {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Markdown
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -455,6 +468,23 @@ model InternalUserNote {
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model InternalServiceNote {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Markdown
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
service Service @relation("ServiceRecievedNotes", fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
addedByUser User? @relation("UserAddedServiceNotes", fields: [addedByUserId], references: [id], onDelete: SetNull)
|
||||
addedByUserId Int?
|
||||
|
||||
@@index([serviceId])
|
||||
@@index([addedByUserId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
@@ -464,7 +494,7 @@ model User {
|
||||
spammer Boolean @default(false)
|
||||
verified Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
verifier Boolean @default(false)
|
||||
moderator Boolean @default(false)
|
||||
verifiedLink String?
|
||||
secretTokenHash String @unique
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
@@ -481,10 +511,12 @@ model User {
|
||||
suggestionMessages ServiceSuggestionMessage[]
|
||||
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
|
||||
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
|
||||
addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
notifications Notification[] @relation("NotificationOwner")
|
||||
notificationPreferences NotificationPreferences?
|
||||
serviceAffiliations ServiceUser[] @relation("UserServices")
|
||||
pushSubscriptions PushSubscription[]
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([totalKarma])
|
||||
@@ -633,3 +665,21 @@ model Announcement {
|
||||
|
||||
@@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])
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import crypto from 'crypto'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { faker } from '@faker-js/faker'
|
||||
import {
|
||||
AnnouncementType,
|
||||
AttributeCategory,
|
||||
AttributeType,
|
||||
CommentStatus,
|
||||
Currency,
|
||||
EventType,
|
||||
PrismaClient,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceSuggestionType,
|
||||
ServiceUserRole,
|
||||
VerificationStatus,
|
||||
type Prisma,
|
||||
EventType,
|
||||
type User,
|
||||
ServiceUserRole,
|
||||
AnnouncementType,
|
||||
type ServiceVisibility,
|
||||
ServiceSuggestionType,
|
||||
KycLevelClarification,
|
||||
} from '@prisma/client'
|
||||
import { uniqBy } from 'lodash-es'
|
||||
import { omit, uniqBy } from 'lodash-es'
|
||||
import { generateUsername } from 'unique-username-generator'
|
||||
|
||||
import { kycLevels } from '../src/constants/kycLevels'
|
||||
@@ -85,7 +89,7 @@ async function createAccount(preGeneratedToken?: string) {
|
||||
verifiedLink,
|
||||
verified: !!verifiedLink,
|
||||
admin: faker.datatype.boolean({ probability: 0.1 }),
|
||||
verifier: faker.datatype.boolean({ probability: 0.1 }),
|
||||
moderator: faker.datatype.boolean({ probability: 0.1 }),
|
||||
},
|
||||
include: {
|
||||
serviceAffiliations: true,
|
||||
@@ -95,20 +99,6 @@ async function createAccount(preGeneratedToken?: string) {
|
||||
return { token, user }
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2)
|
||||
const shouldCleanup = args.includes('--cleanup') || args.includes('-c')
|
||||
const onlyCleanup = args.includes('--only-cleanup') || args.includes('-oc')
|
||||
|
||||
// Parse number of services from --services or -s flag
|
||||
const servicesArg = args.find((arg) => arg.startsWith('--services=') || arg.startsWith('-s='))
|
||||
const numServices = parseIntWithFallback(servicesArg?.split('=')[1], 100) // Default to 100 if not specified
|
||||
|
||||
if (isNaN(numServices) || numServices < 1) {
|
||||
console.error('❌ Invalid number of services specified. Must be a positive number.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const generateFakeAttribute = () => {
|
||||
@@ -611,18 +601,35 @@ const generateFakeEvent = (serviceId: number) => {
|
||||
}
|
||||
|
||||
const generateFakeService = (users: User[]) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(VerificationStatus))
|
||||
const status = faker.helpers.weightedArrayElement<VerificationStatus>([
|
||||
{ weight: 20, value: 'VERIFICATION_SUCCESS' },
|
||||
{ weight: 30, value: 'APPROVED' },
|
||||
{ weight: 40, value: 'COMMUNITY_CONTRIBUTED' },
|
||||
{ weight: 10, value: 'VERIFICATION_FAILED' },
|
||||
])
|
||||
const name = faker.helpers.arrayElement(serviceNames)
|
||||
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
||||
|
||||
return {
|
||||
name,
|
||||
slug,
|
||||
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
|
||||
description: faker.helpers.arrayElement(serviceDescriptions),
|
||||
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,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||
{ weight: 80, value: 'PUBLIC' },
|
||||
{ weight: 10, value: 'UNLISTED' },
|
||||
{ weight: 5, value: 'HIDDEN' },
|
||||
{ weight: 5, value: 'ARCHIVED' },
|
||||
]),
|
||||
verificationStatus: status,
|
||||
verificationSummary:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||
@@ -636,25 +643,37 @@ const generateFakeService = (users: User[]) => {
|
||||
},
|
||||
verificationProofMd:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
||||
referral: `?ref=${faker.string.alphanumeric(6)}`,
|
||||
referral: faker.helpers.arrayElement([
|
||||
`?ref=${faker.string.alphanumeric(6)}`,
|
||||
`/ref/${faker.string.alphanumeric(6)}`,
|
||||
]),
|
||||
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
|
||||
serviceUrls: Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => faker.internet.url()),
|
||||
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()),
|
||||
onionUrls: Array.from(
|
||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`
|
||||
serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
|
||||
tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
|
||||
onionUrls: faker.helpers.multiple(
|
||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
i2pUrls: Array.from(
|
||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
||||
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`
|
||||
i2pUrls: faker.helpers.multiple(
|
||||
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`,
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||
listedAt: faker.date.past(),
|
||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
||||
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
||||
tosReviewAt: faker.date.past(),
|
||||
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined,
|
||||
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
||||
userSentimentAt: faker.date.recent(),
|
||||
internalNotes: faker.helpers.maybe(
|
||||
() => ({
|
||||
create: {
|
||||
content: faker.lorem.paragraph(),
|
||||
addedByUserId: faker.helpers.arrayElement(users.filter((user) => user.admin)).id,
|
||||
},
|
||||
}),
|
||||
{ probability: 0.33 }
|
||||
),
|
||||
} as const satisfies Prisma.ServiceCreateInput
|
||||
}
|
||||
|
||||
@@ -859,6 +878,22 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
||||
{
|
||||
value: `https://x.com/${faker.internet.username()}`,
|
||||
},
|
||||
{
|
||||
value: `https://matrix.to/#/@${faker.internet.username()}:${faker.internet.domainName()}`,
|
||||
},
|
||||
{
|
||||
value: `https://instagram.com/${faker.internet.username()}`,
|
||||
},
|
||||
{
|
||||
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
||||
},
|
||||
{
|
||||
label: faker.lorem.word({ length: 2 }),
|
||||
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||
},
|
||||
{
|
||||
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||
},
|
||||
{
|
||||
value: faker.internet.url(),
|
||||
},
|
||||
@@ -867,7 +902,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
||||
value: faker.internet.url(),
|
||||
},
|
||||
{
|
||||
value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
|
||||
value: `https://linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
|
||||
},
|
||||
] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[]
|
||||
|
||||
@@ -883,31 +918,31 @@ const specialUsersData = {
|
||||
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
|
||||
defaultToken: 'admin',
|
||||
admin: true,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
verified: true,
|
||||
verifiedLink: 'https://kycnot.me',
|
||||
totalKarma: 1001,
|
||||
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',
|
||||
},
|
||||
verifier: {
|
||||
name: 'verifier_dev',
|
||||
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
|
||||
defaultToken: 'verifier',
|
||||
moderator: {
|
||||
name: 'moderator_dev',
|
||||
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
|
||||
defaultToken: 'moderator',
|
||||
admin: false,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
verified: true,
|
||||
verifiedLink: 'https://kycnot.me',
|
||||
totalKarma: 1001,
|
||||
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: {
|
||||
name: 'verified_dev',
|
||||
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
|
||||
defaultToken: 'verified',
|
||||
admin: false,
|
||||
verifier: false,
|
||||
moderator: false,
|
||||
verified: true,
|
||||
verifiedLink: 'https://kycnot.me',
|
||||
totalKarma: 1001,
|
||||
@@ -917,7 +952,7 @@ const specialUsersData = {
|
||||
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
|
||||
defaultToken: 'normal',
|
||||
admin: false,
|
||||
verifier: false,
|
||||
moderator: false,
|
||||
verified: false,
|
||||
},
|
||||
spam: {
|
||||
@@ -925,7 +960,7 @@ const specialUsersData = {
|
||||
envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
|
||||
defaultToken: 'spam',
|
||||
admin: false,
|
||||
verifier: false,
|
||||
moderator: false,
|
||||
verified: false,
|
||||
totalKarma: -100,
|
||||
spammer: true,
|
||||
@@ -987,359 +1022,378 @@ const generateFakeAnnouncement = () => {
|
||||
} as const satisfies Prisma.AnnouncementCreateInput
|
||||
}
|
||||
|
||||
async function runFaker() {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// ---- Clean up existing data if requested ----
|
||||
if (shouldCleanup || onlyCleanup) {
|
||||
console.info('🧹 Cleaning up existing data...')
|
||||
async function cleanup() {
|
||||
console.info('🧹 Cleaning up existing data...')
|
||||
|
||||
try {
|
||||
await tx.commentVote.deleteMany()
|
||||
await tx.karmaTransaction.deleteMany()
|
||||
await tx.comment.deleteMany()
|
||||
await tx.serviceAttribute.deleteMany()
|
||||
await tx.serviceContactMethod.deleteMany()
|
||||
await tx.event.deleteMany()
|
||||
await tx.verificationStep.deleteMany()
|
||||
await tx.serviceSuggestionMessage.deleteMany()
|
||||
await tx.serviceSuggestion.deleteMany()
|
||||
await tx.serviceVerificationRequest.deleteMany()
|
||||
await tx.service.deleteMany()
|
||||
await tx.attribute.deleteMany()
|
||||
await tx.category.deleteMany()
|
||||
await tx.internalUserNote.deleteMany()
|
||||
await tx.user.deleteMany()
|
||||
await tx.announcement.deleteMany()
|
||||
console.info('✅ Existing data cleaned up')
|
||||
} catch (error) {
|
||||
console.error('❌ Error cleaning up data:', error)
|
||||
throw error
|
||||
}
|
||||
if (onlyCleanup) return
|
||||
}
|
||||
|
||||
// ---- Get or create categories ----
|
||||
const categories = await Promise.all(
|
||||
categoriesToCreate.map(async (cat) => {
|
||||
const existing = await tx.category.findUnique({
|
||||
where: { name: cat.name },
|
||||
})
|
||||
if (existing) return existing
|
||||
|
||||
return await tx.category.create({
|
||||
data: cat,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create users ----
|
||||
const specialUsersUntyped = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(specialUsersData).map(async ([key, userData]) => {
|
||||
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
|
||||
const secretTokenHash = hashUserSecretToken(secretToken)
|
||||
|
||||
const { envToken, defaultToken, ...userCreateData } = userData
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
notificationPreferences: { create: {} },
|
||||
...userCreateData,
|
||||
secretTokenHash,
|
||||
},
|
||||
})
|
||||
|
||||
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
|
||||
|
||||
return [key, user] as const
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const specialUsers = specialUsersUntyped as {
|
||||
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
|
||||
}
|
||||
|
||||
let users = await Promise.all(
|
||||
Array.from({ length: 10 }, async () => {
|
||||
const { user } = await createAccount()
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create attributes ----
|
||||
const attributes = await Promise.all(
|
||||
Array.from({ length: 16 }, async () => {
|
||||
return await tx.attribute.create({
|
||||
data: generateFakeAttribute(),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create services ----
|
||||
const services = await Promise.all(
|
||||
Array.from({ length: numServices }, async () => {
|
||||
const serviceData = generateFakeService(users)
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create contact methods for each service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
tx.serviceContactMethod.create({
|
||||
data: generateFakeServiceContactMethod(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Link random attributes to the service
|
||||
await Promise.all(
|
||||
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
|
||||
tx.serviceAttribute.create({
|
||||
data: {
|
||||
serviceId: service.id,
|
||||
attributeId: attr.id,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Create events for the service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
|
||||
tx.event.create({
|
||||
data: generateFakeEvent(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return service
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service user affiliations for the service ----
|
||||
await Promise.all(
|
||||
users
|
||||
.filter((user) => user.verified)
|
||||
.map(async (user) => {
|
||||
const servicesToAddAffiliations = uniqBy(
|
||||
faker.helpers.arrayElements(services, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
}),
|
||||
'id'
|
||||
)
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
serviceAffiliations: {
|
||||
createMany: {
|
||||
data: servicesToAddAffiliations.map((service) => ({
|
||||
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
|
||||
serviceId: service.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
users = await tx.user.findMany({
|
||||
include: {
|
||||
serviceAffiliations: true,
|
||||
},
|
||||
})
|
||||
|
||||
// ---- Create comments and replies ----
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
// Create parent comments
|
||||
const commentCount = faker.number.int({ min: 1, max: 10 })
|
||||
const commentData = Array.from({ length: commentCount }, () =>
|
||||
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
|
||||
)
|
||||
const indexesToUpdate = users.map((user) => {
|
||||
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
|
||||
})
|
||||
commentData.forEach((comment, index) => {
|
||||
if (indexesToUpdate.includes(index)) comment.ratingActive = true
|
||||
})
|
||||
|
||||
await tx.comment.createMany({
|
||||
data: commentData,
|
||||
})
|
||||
|
||||
const comments = await tx.comment.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
parentId: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: commentCount,
|
||||
})
|
||||
|
||||
const affiliatedUsers = undefinedIfEmpty(
|
||||
users.filter((user) =>
|
||||
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
|
||||
)
|
||||
)
|
||||
|
||||
// Create replies to comments
|
||||
await Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
const replyCount = faker.number.int({ min: 0, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: replyCount }, () => {
|
||||
const user = faker.helpers.arrayElement(
|
||||
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
|
||||
)
|
||||
|
||||
return tx.comment.create({
|
||||
data: generateFakeComment(user.id, service.id, comment.id),
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service suggestions for normal_dev user ----
|
||||
// First create 3 CREATE_SERVICE suggestions with their services
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const serviceData = generateFakeService(users)
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
tx.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Then create 5 EDIT_SERVICE suggestions
|
||||
await Promise.all(
|
||||
services.slice(0, 5).map(async (service) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
|
||||
const suggestion = await tx.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
status,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
|
||||
tx.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(suggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create internal notes for users ----
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
// Create 1-3 notes for each user
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
tx.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.verifier.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Add some notes to special users as well
|
||||
await Promise.all(
|
||||
Object.values(specialUsers).map(async (user) => {
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
tx.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.verifier.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create announcement ----
|
||||
await tx.announcement.create({
|
||||
data: generateFakeAnnouncement(),
|
||||
})
|
||||
},
|
||||
{
|
||||
timeout: 1000 * 60 * 10, // 10 minutes
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await runFaker()
|
||||
|
||||
console.info('✅ Fake data generated successfully')
|
||||
await prisma.commentVote.deleteMany()
|
||||
await prisma.karmaTransaction.deleteMany()
|
||||
await prisma.comment.deleteMany()
|
||||
await prisma.serviceAttribute.deleteMany()
|
||||
await prisma.serviceContactMethod.deleteMany()
|
||||
await prisma.event.deleteMany()
|
||||
await prisma.verificationStep.deleteMany()
|
||||
await prisma.serviceSuggestionMessage.deleteMany()
|
||||
await prisma.serviceSuggestion.deleteMany()
|
||||
await prisma.serviceVerificationRequest.deleteMany()
|
||||
await prisma.service.deleteMany()
|
||||
await prisma.attribute.deleteMany()
|
||||
await prisma.category.deleteMany()
|
||||
await prisma.internalUserNote.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
await prisma.announcement.deleteMany()
|
||||
console.info('✅ Existing data cleaned up')
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating fake data:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
console.error('❌ Error cleaning up data:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('❌ Fatal error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
function importTriggers() {
|
||||
console.info('🔄 Importing SQL triggers...')
|
||||
try {
|
||||
execSync('just import-triggers', { stdio: 'inherit' })
|
||||
console.info('✅ Triggers imported')
|
||||
} catch (error) {
|
||||
console.error('❌ Error importing triggers:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values: options } = parseArgs({
|
||||
options: {
|
||||
services: { type: 'string', short: 's', default: '100' },
|
||||
cleanup: { type: 'boolean', short: 'c', default: true },
|
||||
'only-cleanup': { type: 'boolean', short: 'o', default: false },
|
||||
},
|
||||
})
|
||||
const numServices = parseIntWithFallback(options.services, 100)
|
||||
if (isNaN(numServices) || numServices < 1) {
|
||||
console.error('❌ Invalid number of services specified. Must be a positive number.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
importTriggers()
|
||||
|
||||
if (options.cleanup || options['only-cleanup']) {
|
||||
await cleanup()
|
||||
if (options['only-cleanup']) return
|
||||
}
|
||||
|
||||
// ---- Get or create categories ----
|
||||
const categories = await Promise.all(
|
||||
categoriesToCreate.map(async (cat) => {
|
||||
const existing = await prisma.category.findUnique({
|
||||
where: { name: cat.name },
|
||||
})
|
||||
if (existing) return existing
|
||||
|
||||
return await prisma.category.create({
|
||||
data: cat,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create users ----
|
||||
const specialUsersUntyped = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(specialUsersData).map(async ([key, userData]) => {
|
||||
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
|
||||
const secretTokenHash = hashUserSecretToken(secretToken)
|
||||
|
||||
const { envToken, defaultToken, ...userCreateData } = userData
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
notificationPreferences: { create: {} },
|
||||
...userCreateData,
|
||||
secretTokenHash,
|
||||
},
|
||||
})
|
||||
|
||||
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
|
||||
|
||||
return [key, user] as const
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const specialUsers = specialUsersUntyped as {
|
||||
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
|
||||
}
|
||||
|
||||
let users = await Promise.all(
|
||||
Array.from({ length: 10 }, async () => {
|
||||
const { user } = await createAccount()
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create attributes ----
|
||||
const attributes = await Promise.all(
|
||||
Array.from({ length: 16 }, async () => {
|
||||
return await prisma.attribute.create({
|
||||
data: generateFakeAttribute(),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create services ----
|
||||
const services = await Promise.all(
|
||||
Array.from({ length: numServices }, async () => {
|
||||
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create contact methods for each service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
prisma.serviceContactMethod.create({
|
||||
data: generateFakeServiceContactMethod(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Link random attributes to the service
|
||||
await Promise.all(
|
||||
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
|
||||
prisma.serviceAttribute.create({
|
||||
data: {
|
||||
serviceId: service.id,
|
||||
attributeId: attr.id,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Create events for the service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
|
||||
prisma.event.create({
|
||||
data: generateFakeEvent(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return service
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service user affiliations for the service ----
|
||||
await Promise.all(
|
||||
users
|
||||
.filter((user) => user.verified)
|
||||
.map(async (user) => {
|
||||
const servicesToAddAffiliations = uniqBy(
|
||||
faker.helpers.arrayElements(services, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
}),
|
||||
'id'
|
||||
)
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
serviceAffiliations: {
|
||||
createMany: {
|
||||
data: servicesToAddAffiliations.map((service) => ({
|
||||
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
|
||||
serviceId: service.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
users = await prisma.user.findMany({
|
||||
include: {
|
||||
serviceAffiliations: true,
|
||||
},
|
||||
})
|
||||
|
||||
// ---- Create comments and replies ----
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
// Create parent comments
|
||||
const commentCount = faker.number.int({ min: 1, max: 10 })
|
||||
const commentData = Array.from({ length: commentCount }, () =>
|
||||
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
|
||||
)
|
||||
const indexesToUpdate = users.map((user) => {
|
||||
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
|
||||
})
|
||||
commentData.forEach((comment, index) => {
|
||||
if (indexesToUpdate.includes(index)) comment.ratingActive = true
|
||||
})
|
||||
|
||||
await prisma.comment.createMany({
|
||||
data: commentData,
|
||||
})
|
||||
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
parentId: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: commentCount,
|
||||
})
|
||||
|
||||
const affiliatedUsers = undefinedIfEmpty(
|
||||
users.filter((user) =>
|
||||
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
|
||||
)
|
||||
)
|
||||
|
||||
// Create replies to comments
|
||||
await Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
const replyCount = faker.number.int({ min: 0, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: replyCount }, () => {
|
||||
const user = faker.helpers.arrayElement(
|
||||
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
|
||||
)
|
||||
|
||||
return prisma.comment.create({
|
||||
data: generateFakeComment(user.id, service.id, comment.id),
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service suggestions for normal_dev user ----
|
||||
// First create 3 CREATE_SERVICE suggestions with their services
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
prisma.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Then create 5 EDIT_SERVICE suggestions
|
||||
await Promise.all(
|
||||
services.slice(0, 5).map(async (service) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
|
||||
const suggestion = await prisma.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
status,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
|
||||
prisma.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(suggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create internal notes for users ----
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
// Create 1-3 notes for each user
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
prisma.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Add some notes to special users as well
|
||||
await Promise.all(
|
||||
Object.values(specialUsers).map(async (user) => {
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
prisma.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create announcement ----
|
||||
await prisma.announcement.create({
|
||||
data: generateFakeAnnouncement(),
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
console.info('✅ Fake data generated successfully')
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error(
|
||||
'❌ Fatal error:',
|
||||
typeof error === 'object' && error !== null && 'message' in error ? error.message : 'Unknown error'
|
||||
)
|
||||
console.error(error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -25,12 +25,12 @@ BEGIN
|
||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||
END IF;
|
||||
|
||||
-- Check for verifier status change
|
||||
IF OLD.verifier IS DISTINCT FROM NEW.verifier THEN
|
||||
IF NEW.verifier = true THEN
|
||||
status_change := 'VERIFIER_TRUE';
|
||||
-- Check for moderator status change
|
||||
IF OLD.moderator IS DISTINCT FROM NEW.moderator THEN
|
||||
IF NEW.moderator = true THEN
|
||||
status_change := 'MODERATOR_TRUE';
|
||||
ELSE
|
||||
status_change := 'VERIFIER_FALSE';
|
||||
status_change := 'MODERATOR_FALSE';
|
||||
END IF;
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
|
||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||
@@ -57,6 +57,6 @@ DROP TRIGGER IF EXISTS user_status_change_notifications_trigger ON "User";
|
||||
|
||||
-- Create the trigger to fire after updates on specific status columns
|
||||
CREATE TRIGGER user_status_change_notifications_trigger
|
||||
AFTER UPDATE OF admin, verified, verifier, spammer ON "User"
|
||||
AFTER UPDATE OF admin, verified, moderator, spammer ON "User"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_user_status_change_notifications();
|
||||
|
||||
16
web/prisma/triggers/12_notification_push_trigger.sql
Normal file
16
web/prisma/triggers/12_notification_push_trigger.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE OR REPLACE FUNCTION trigger_notification_push()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notification_created', json_build_object('id', NEW.id)::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the trigger if it exists to ensure a clean setup
|
||||
DROP TRIGGER IF EXISTS notification_push_trigger ON "Notification";
|
||||
|
||||
-- Create the trigger to fire after inserts
|
||||
CREATE TRIGGER notification_push_trigger
|
||||
AFTER INSERT ON "Notification"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_notification_push();
|
||||
114
web/public/sw.js
Normal file
114
web/public/sw.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// @ts-check
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/** @type {ServiceWorkerGlobalScope} */
|
||||
// @ts-expect-error
|
||||
const typedSelf = self
|
||||
|
||||
const CACHE_NAME = 'kycnot-sw-push-notifications-v2'
|
||||
|
||||
/** @typedef {import('../src/lib/webPush').NotificationPayload} NotificationPayload */
|
||||
/** @typedef {{defaultActionUrl: string, payload: NotificationPayload | null}} NotificationData */
|
||||
/** @typedef {NotificationOptions & { actions: { action: string; title: string; icon?: string }[], timestamp: number, data: NotificationData } } CustomNotificationOptions */
|
||||
|
||||
typedSelf.addEventListener('install', (event) => {
|
||||
console.log('Service Worker installing')
|
||||
typedSelf.skipWaiting()
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker activating')
|
||||
event.waitUntil(typedSelf.clients.claim())
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('push', (event) => {
|
||||
console.log('Push event received:', event)
|
||||
|
||||
if (!event.data) {
|
||||
console.error('Push event but no data')
|
||||
return
|
||||
}
|
||||
|
||||
let title = 'New Notification'
|
||||
/** @type {CustomNotificationOptions} */
|
||||
let options = {
|
||||
body: 'You have a new notification',
|
||||
lang: 'en-US',
|
||||
icon: '/favicon.svg',
|
||||
badge: '/favicon.svg',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
defaultActionUrl: '/notifications',
|
||||
payload: null,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
/** @type {NotificationPayload} */
|
||||
const rawData = event.data.json()
|
||||
if (typeof rawData !== 'object' || rawData === null) throw new Error('Invalid push data, not an object')
|
||||
if (!('title' in rawData) || typeof rawData.title !== 'string')
|
||||
throw new Error('Invalid push data, no title')
|
||||
title = rawData.title
|
||||
|
||||
options = {
|
||||
...options,
|
||||
body: rawData.body || undefined,
|
||||
actions: rawData.actions.map((action) => ({
|
||||
action: action.action,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
})),
|
||||
data: {
|
||||
...options.data,
|
||||
payload: rawData,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing push data:', error)
|
||||
}
|
||||
|
||||
event.waitUntil(typedSelf.registration.showNotification(title, options))
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('notificationclick', (event) => {
|
||||
console.log('Notification clicked:', event)
|
||||
|
||||
event.notification.close()
|
||||
|
||||
/** @type {NotificationData} */
|
||||
const data = event.notification.data
|
||||
|
||||
// @ts-expect-error I already use optional chaining
|
||||
const url = data.payload?.[event.action]?.url || data.defaultActionUrl
|
||||
|
||||
event.waitUntil(
|
||||
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
// If a window is already open, focus it
|
||||
for (const client of clientList) {
|
||||
if (client.url === url && 'focus' in client) {
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, open a new window
|
||||
if (typedSelf.clients.openWindow) {
|
||||
return typedSelf.clients.openWindow(url)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('notificationclose', (event) => {
|
||||
console.log('Notification closed:', event)
|
||||
})
|
||||
@@ -1,11 +1,9 @@
|
||||
import { type Prisma, type PrismaClient } from '@prisma/client'
|
||||
import { type Prisma } from '@prisma/client'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
||||
|
||||
const prisma = prismaInstance as PrismaClient
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const selectAnnouncementReturnFields = {
|
||||
id: true,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { adminAnnouncementActions } from './announcement'
|
||||
import { adminAttributeActions } from './attribute'
|
||||
import { adminEventActions } from './event'
|
||||
import { adminNotificationActions } from './notification'
|
||||
import { adminServiceActions } from './service'
|
||||
import { adminServiceSuggestionActions } from './serviceSuggestion'
|
||||
import { adminUserActions } from './user'
|
||||
import { verificationStep } from './verificationStep'
|
||||
|
||||
export const adminActions = {
|
||||
attribute: adminAttributeActions,
|
||||
announcement: adminAnnouncementActions,
|
||||
attribute: adminAttributeActions,
|
||||
event: adminEventActions,
|
||||
notification: adminNotificationActions,
|
||||
service: adminServiceActions,
|
||||
serviceSuggestions: adminServiceSuggestionActions,
|
||||
user: adminUserActions,
|
||||
|
||||
33
web/src/actions/admin/notification.ts
Normal file
33
web/src/actions/admin/notification.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||
|
||||
export const adminNotificationActions = {
|
||||
test: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userNames: stringListOfSlugsSchemaRequired,
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { name: { in: input.userNames } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const notifications = await prisma.notification.createManyAndReturn({
|
||||
data: users.map((user) => ({
|
||||
type: 'TEST',
|
||||
userId: user.id,
|
||||
})),
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return {
|
||||
message: `Created ${notifications.length.toString()} notifications.`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,42 +1,20 @@
|
||||
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
|
||||
import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { uniq } from 'lodash-es'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||
import {
|
||||
imageFileSchema,
|
||||
stringListOfUrlsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
zodContactMethod,
|
||||
} 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().optional().nullable().default(null),
|
||||
imageFile: imageFileSchema,
|
||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
})
|
||||
|
||||
const addSlugIfMissing = <
|
||||
T extends {
|
||||
slug?: string | null | undefined
|
||||
@@ -56,12 +34,61 @@ 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)
|
||||
|
||||
const evidenceImageAddSchema = z.object({
|
||||
serviceId: z.number().int().positive(),
|
||||
imageFile: imageFileSchema,
|
||||
})
|
||||
|
||||
const evidenceImageDeleteSchema = z.object({
|
||||
fileUrl: z.string().startsWith('/files/evidence/', 'Must be a valid evidence file URL'),
|
||||
})
|
||||
|
||||
export const adminServiceActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
input: createServiceInputSchema,
|
||||
handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
|
||||
const existing = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
@@ -75,12 +102,34 @@ export const adminServiceActions = {
|
||||
})
|
||||
}
|
||||
|
||||
const { imageFile, ...serviceData } = input
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
const imageUrl = 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.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
||||
verificationStatus: input.verificationStatus,
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
@@ -92,6 +141,14 @@ export const adminServiceActions = {
|
||||
})),
|
||||
},
|
||||
imageUrl,
|
||||
internalNotes: input.internalNote
|
||||
? {
|
||||
create: {
|
||||
content: input.internalNote,
|
||||
addedByUserId: context.locals.user.id,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -106,34 +163,40 @@ export const adminServiceActions = {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
const { id, categories, attributes, imageFile, ...data } = input
|
||||
|
||||
const existing = await prisma.service.findUnique({
|
||||
input: updateServiceInputSchema,
|
||||
handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
|
||||
const anotherServiceWithNewSlug = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
NOT: { id },
|
||||
NOT: { id: input.id },
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
if (anotherServiceWithNewSlug) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A service with this slug already exists',
|
||||
})
|
||||
}
|
||||
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
|
||||
// Get existing attributes and categories to compute differences
|
||||
const existingService = await prisma.service.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
categories: true,
|
||||
where: { id: input.id },
|
||||
select: {
|
||||
slug: true,
|
||||
previousSlugs: true,
|
||||
categories: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
include: {
|
||||
attribute: true,
|
||||
select: {
|
||||
attributeId: true,
|
||||
attribute: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -146,89 +209,259 @@ export const adminServiceActions = {
|
||||
})
|
||||
}
|
||||
|
||||
// Find categories to connect and disconnect
|
||||
const existingCategoryIds = existingService.categories.map((c) => c.id)
|
||||
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
|
||||
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
|
||||
const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId))
|
||||
const categoriesToRemove = existingCategoryIds.filter((cId) => !input.categories.includes(cId))
|
||||
|
||||
// Find attributes to connect and disconnect
|
||||
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
|
||||
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
|
||||
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.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({
|
||||
where: { id },
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...data,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
||||
verificationStatus: input.verificationStatus,
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
previousSlugs:
|
||||
existingService.slug !== input.slug
|
||||
? {
|
||||
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
|
||||
(slug) => slug !== input.slug
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
imageUrl,
|
||||
categories: {
|
||||
connect: categoriesToAdd.map((id) => ({ id })),
|
||||
disconnect: categoriesToRemove.map((id) => ({ id })),
|
||||
},
|
||||
attributes: {
|
||||
// Connect new attributes
|
||||
create: attributesToAdd.map((attributeId) => ({
|
||||
attribute: {
|
||||
connect: { id: attributeId },
|
||||
},
|
||||
})),
|
||||
// Delete specific attributes that are no longer needed
|
||||
|
||||
deleteMany: attributesToRemove.map((attributeId) => ({
|
||||
attributeId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { service }
|
||||
},
|
||||
}),
|
||||
|
||||
createContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
label: z.string().min(1).max(50).optional(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
contactMethod: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: zodContactMethod,
|
||||
serviceId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: {
|
||||
label: input.label,
|
||||
value: input.value,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: input,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
updateContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive().optional(),
|
||||
label: z.string().min(1).max(50).optional(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
label: input.label,
|
||||
value: input.value,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const { id, ...data } = input
|
||||
const contactMethod = await prisma.serviceContactMethod.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
deleteContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
internalNote: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
serviceId: z.number().int().positive(),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
handler: async (input, { locals }) => {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: input.serviceId },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
serviceId: input.serviceId,
|
||||
addedByUserId: locals.user.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.number().int().positive(),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const note = await prisma.internalServiceNote.findUnique({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Note not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.update({
|
||||
where: { id: input.noteId },
|
||||
data: { content: input.content },
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const note = await prisma.internalServiceNote.findUnique({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Note not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.delete({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
evidenceImage: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: evidenceImageAddSchema,
|
||||
handler: async (input) => {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: input.serviceId },
|
||||
select: { slug: true },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found to associate image with.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!input.imageFile) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Image file is required.',
|
||||
})
|
||||
}
|
||||
|
||||
const imageUrl = await saveFileLocally(
|
||||
input.imageFile,
|
||||
input.imageFile.name,
|
||||
`evidence/${service.slug}`
|
||||
)
|
||||
|
||||
return { imageUrl }
|
||||
},
|
||||
}),
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: evidenceImageDeleteSchema,
|
||||
handler: async (input) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await deleteFileLocally(input.fileUrl)
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
|
||||
import { type Prisma, type ServiceUserRole } from '@prisma/client'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
||||
|
||||
const prisma = prismaInstance as PrismaClient
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const selectUserReturnFields = {
|
||||
id: true,
|
||||
@@ -16,7 +14,7 @@ const selectUserReturnFields = {
|
||||
picture: true,
|
||||
admin: true,
|
||||
verified: true,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
verifiedLink: true,
|
||||
secretTokenHash: true,
|
||||
totalKarma: true,
|
||||
@@ -55,7 +53,7 @@ export const adminUserActions = {
|
||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
.transform((val) => val || null),
|
||||
pictureFile: z.instanceof(File).optional(),
|
||||
type: z.array(z.enum(['admin', 'verifier', 'spammer'])),
|
||||
type: z.array(z.enum(['admin', 'moderator', 'spammer'])),
|
||||
verifiedLink: z
|
||||
.string()
|
||||
.url('Invalid URL')
|
||||
@@ -101,7 +99,7 @@ export const adminUserActions = {
|
||||
verified: !!valuesToUpdate.verifiedLink,
|
||||
picture: pictureUrl,
|
||||
admin: type.includes('admin'),
|
||||
verifier: type.includes('verifier'),
|
||||
moderator: type.includes('moderator'),
|
||||
spammer: type.includes('spammer'),
|
||||
},
|
||||
select: selectUserReturnFields,
|
||||
|
||||
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,
|
||||
}
|
||||
151
web/src/actions/api/service.ts
Normal file
151
web/src/actions/api/service.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
|
||||
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,
|
||||
kycLevelClarification: 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']),
|
||||
kycLevelClarification: service.kycLevelClarification,
|
||||
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||
'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,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -331,7 +331,7 @@ export const commentActions = {
|
||||
}),
|
||||
|
||||
moderate: defineProtectedAction({
|
||||
permissions: ['admin', 'verifier'],
|
||||
permissions: ['admin', 'moderator'],
|
||||
input: z.object({
|
||||
commentId: z.number(),
|
||||
userId: z.number(),
|
||||
@@ -345,10 +345,11 @@ export const commentActions = {
|
||||
'order-id-status',
|
||||
'kyc-requested',
|
||||
'funds-blocked',
|
||||
'toggle-rating-active',
|
||||
]),
|
||||
value: z.union([
|
||||
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
||||
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
|
||||
z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
]),
|
||||
@@ -411,7 +412,7 @@ export const commentActions = {
|
||||
updateData.privateContext = input.value as string
|
||||
break
|
||||
case 'order-id-status':
|
||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
|
||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
|
||||
break
|
||||
case 'kyc-requested':
|
||||
updateData.kycRequested = !!input.value
|
||||
@@ -419,6 +420,9 @@ export const commentActions = {
|
||||
case 'funds-blocked':
|
||||
updateData.fundsBlocked = !!input.value
|
||||
break
|
||||
case 'toggle-rating-active':
|
||||
updateData.ratingActive = !!input.value
|
||||
break
|
||||
}
|
||||
|
||||
// Update the comment
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { accountActions } from './account'
|
||||
import { adminActions } from './admin'
|
||||
import { apiActions } from './api'
|
||||
import { commentActions } from './comment'
|
||||
import { notificationActions } from './notifications'
|
||||
import { serviceActions } from './service'
|
||||
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
|
||||
export const server = {
|
||||
account: accountActions,
|
||||
admin: adminActions,
|
||||
api: apiActions,
|
||||
comment: commentActions,
|
||||
notification: notificationActions,
|
||||
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: {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
@@ -31,7 +88,7 @@ export const notificationActions = {
|
||||
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
|
||||
enableAutowatchMyComments: 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) => {
|
||||
await prisma.notificationPreferences.upsert({
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Currency,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceSuggestionType,
|
||||
ServiceVisibility,
|
||||
VerificationStatus,
|
||||
} from '@prisma/client'
|
||||
import { Currency, KycLevelClarification } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { formatDistanceStrict } from 'date-fns'
|
||||
@@ -12,11 +6,13 @@ import { formatDistanceStrict } from 'date-fns'
|
||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { separateServiceUrlsByType } from '../lib/urls'
|
||||
import {
|
||||
imageFileSchemaRequired,
|
||||
stringListOfUrlsSchema,
|
||||
stringListOfContactMethodsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
} from '../lib/zodUtils'
|
||||
@@ -33,11 +29,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
|
||||
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
||||
|
||||
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: {
|
||||
name: {
|
||||
contains: input.name,
|
||||
mode: 'insensitive',
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -47,8 +44,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
return possibleDuplicates
|
||||
}
|
||||
|
||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
@@ -122,9 +117,9 @@ export const serviceSuggestionActions = {
|
||||
|
||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||
data: {
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
type: 'EDIT_SERVICE',
|
||||
notes: combinedNotes,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
status: 'PENDING',
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
@@ -161,10 +156,11 @@ export const serviceSuggestionActions = {
|
||||
{ message: 'Slug must be unique, try a different one' }
|
||||
),
|
||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
onionUrls: stringListOfUrlsSchema,
|
||||
contactMethods: stringListOfContactMethodsSchema,
|
||||
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
||||
kycLevelClarification: z.nativeEnum(KycLevelClarification),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
||||
@@ -210,6 +206,12 @@ export const serviceSuggestionActions = {
|
||||
|
||||
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 serviceSelect = {
|
||||
id: true,
|
||||
@@ -221,18 +223,20 @@ export const serviceSuggestionActions = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
serviceUrls: input.serviceUrls,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls: input.onionUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
kycLevelClarification: input.kycLevelClarification,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
imageUrl,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
listedAt: new Date(),
|
||||
serviceVisibility: ServiceVisibility.UNLISTED,
|
||||
serviceVisibility: 'UNLISTED',
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
@@ -241,6 +245,11 @@ export const serviceSuggestionActions = {
|
||||
attributeId: id,
|
||||
})),
|
||||
},
|
||||
contactMethods: {
|
||||
create: input.contactMethods.map((value) => ({
|
||||
value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: serviceSelect,
|
||||
})
|
||||
@@ -248,8 +257,8 @@ export const serviceSuggestionActions = {
|
||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||
data: {
|
||||
notes: input.notes,
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
type: 'CREATE_SERVICE',
|
||||
status: 'PENDING',
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
const badge = tv({
|
||||
slots: {
|
||||
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
|
||||
icon: 'size-3 shrink-0',
|
||||
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap [&>a]:hover:underline [&>a]:focus-visible:underline',
|
||||
},
|
||||
variants: {
|
||||
color: {
|
||||
@@ -122,22 +123,29 @@ const badge = tv({
|
||||
},
|
||||
})
|
||||
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
||||
type Props<
|
||||
Tag extends 'a' | 'div' | 'li' = 'div',
|
||||
Text extends string | undefined = string | undefined,
|
||||
> = Polymorphic<
|
||||
VariantProps<typeof badge> & {
|
||||
as: Tag
|
||||
icon?: string
|
||||
text: string
|
||||
endIcon?: string
|
||||
text?: Text
|
||||
inlineIcon?: boolean
|
||||
classNames?: {
|
||||
icon?: string
|
||||
text?: string
|
||||
endIcon?: string
|
||||
}
|
||||
children?: Text extends string ? never : AstroChildren
|
||||
}
|
||||
>
|
||||
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
icon: iconName,
|
||||
endIcon: endIconName,
|
||||
text: textContent,
|
||||
inlineIcon,
|
||||
classNames,
|
||||
@@ -158,5 +166,10 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
|
||||
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
|
||||
)
|
||||
}
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent ?? <slot />}</span>
|
||||
{
|
||||
!!endIconName && (
|
||||
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
|
||||
@@ -8,11 +8,12 @@ import type { Polymorphic } from 'astro/types'
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
|
||||
as: Tag
|
||||
icon: string
|
||||
endIcon?: string
|
||||
text: string
|
||||
inlineIcon?: boolean
|
||||
}>
|
||||
|
||||
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||
const { icon, text, class: className, inlineIcon, endIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||
---
|
||||
|
||||
<Tag
|
||||
@@ -24,4 +25,5 @@ const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps }
|
||||
>
|
||||
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
|
||||
<span>{text}</span>
|
||||
{!!endIcon && <Icon name={endIcon} class="size-4" is:inline={inlineIcon} />}
|
||||
</Tag>
|
||||
|
||||
41
web/src/components/BadgeStandardFilter.astro
Normal file
41
web/src/components/BadgeStandardFilter.astro
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import { uniq } from 'lodash-es'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import BadgeStandard from './BadgeStandard.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<
|
||||
ComponentProps<typeof BadgeStandard>,
|
||||
'as' | 'endIcon' | 'href' | 'icon' | 'text' | 'variant'
|
||||
> & {
|
||||
name: string
|
||||
value: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const { name, value, label, icon, ...props } = Astro.props
|
||||
|
||||
const selectedValues = Astro.url.searchParams.getAll(name)
|
||||
const isSelected = selectedValues.includes(value)
|
||||
|
||||
const url = new URL(Astro.url)
|
||||
url.searchParams.delete(name)
|
||||
const valuesToSet = uniq(isSelected ? selectedValues.filter((v) => v !== value) : [...selectedValues, value])
|
||||
for (const value of valuesToSet) {
|
||||
url.searchParams.set(name, value)
|
||||
}
|
||||
---
|
||||
|
||||
<BadgeStandard
|
||||
as="a"
|
||||
href={url.href}
|
||||
class={cn(isSelected && 'bg-green-950 text-green-500')}
|
||||
text={label}
|
||||
icon={icon}
|
||||
endIcon={isSelected ? 'ri:close-fill' : undefined}
|
||||
{...props}
|
||||
/>
|
||||
@@ -2,13 +2,15 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
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>> &
|
||||
VariantProps<typeof button> & {
|
||||
as: Tag
|
||||
label?: string
|
||||
label: string
|
||||
icon?: string
|
||||
endIcon?: string
|
||||
classNames?: {
|
||||
@@ -55,30 +57,21 @@ const button = tv({
|
||||
iconOnly: {
|
||||
true: {
|
||||
base: 'p-0',
|
||||
label: 'sr-only',
|
||||
},
|
||||
},
|
||||
color: {
|
||||
black: {
|
||||
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
|
||||
},
|
||||
white: {
|
||||
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
|
||||
},
|
||||
gray: {
|
||||
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
|
||||
},
|
||||
success: {
|
||||
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
|
||||
},
|
||||
error: {
|
||||
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
|
||||
},
|
||||
warning: {
|
||||
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
info: {
|
||||
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
|
||||
},
|
||||
black: '',
|
||||
white: '',
|
||||
gray: '',
|
||||
success: '',
|
||||
danger: '',
|
||||
warning: '',
|
||||
info: '',
|
||||
},
|
||||
variant: {
|
||||
solid: '',
|
||||
faded: '',
|
||||
},
|
||||
shadow: {
|
||||
true: {
|
||||
@@ -92,6 +85,107 @@ const button = tv({
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Color variants - solid
|
||||
{
|
||||
color: 'black',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'white',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'success',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'warning',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'info',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
|
||||
},
|
||||
},
|
||||
// Color variants - faded
|
||||
{
|
||||
color: 'black',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'bg2night-800/15 hover:bg-night-700/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'white',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'b2-day-100/15 hover:bg-day-200/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'b2-day-400/15 hover:bg-day-500/30 text-day-300 hover:text-day-100 border-current/30 focus-visible:ring-white/50',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'success',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'border-current/20 bg-green-500/15 text-green-300 hover:bg-green-500/30 hover:text-green-100',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'border-current/20 bg-red-500/15 text-red-300 hover:bg-red-500/30 hover:text-red-100',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'warning',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'border-current/20 bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/30 hover:text-yellow-100',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'info',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'border-current/20 bg-blue-500/15 text-blue-300 hover:bg-blue-500/30 hover:text-blue-100',
|
||||
},
|
||||
},
|
||||
// Shadow variants
|
||||
{
|
||||
color: 'black',
|
||||
shadow: true,
|
||||
@@ -113,7 +207,7 @@ const button = tv({
|
||||
class: 'shadow-green-500/30',
|
||||
},
|
||||
{
|
||||
color: 'error',
|
||||
color: 'danger',
|
||||
shadow: true,
|
||||
class: 'shadow-red-500/30',
|
||||
},
|
||||
@@ -127,6 +221,7 @@ const button = tv({
|
||||
shadow: true,
|
||||
class: 'shadow-blue-500/30',
|
||||
},
|
||||
// Icon only variants
|
||||
{
|
||||
iconOnly: true,
|
||||
size: 'sm',
|
||||
@@ -146,6 +241,7 @@ const button = tv({
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'black',
|
||||
variant: 'solid',
|
||||
shadow: false,
|
||||
disabled: false,
|
||||
iconOnly: false,
|
||||
@@ -153,12 +249,13 @@ const button = tv({
|
||||
})
|
||||
|
||||
const {
|
||||
as: Tag = 'button' as 'a' | 'button' | 'label',
|
||||
as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
|
||||
label,
|
||||
icon,
|
||||
endIcon,
|
||||
size,
|
||||
color,
|
||||
variant,
|
||||
shadow,
|
||||
class: className,
|
||||
classNames,
|
||||
@@ -166,6 +263,7 @@ const {
|
||||
dataAstroReload,
|
||||
disabled,
|
||||
inlineIcon,
|
||||
iconOnly,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
@@ -174,21 +272,27 @@ const {
|
||||
icon: iconSlot,
|
||||
label: labelSlot,
|
||||
endIcon: endIconSlot,
|
||||
} = button({ size, color, shadow, disabled, iconOnly: !label && !(!!icon && !!endIcon) })
|
||||
} = button({
|
||||
size,
|
||||
color,
|
||||
variant,
|
||||
shadow,
|
||||
disabled,
|
||||
iconOnly: iconOnly ?? (!label && !(!!icon && !!endIcon)),
|
||||
})
|
||||
|
||||
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
||||
---
|
||||
|
||||
<ActualTag
|
||||
class={base({ class: className })}
|
||||
role={role ??
|
||||
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||
aria-disabled={disabled}
|
||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||
{...htmlProps}
|
||||
>
|
||||
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />}
|
||||
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
|
||||
<span class={labelSlot({ class: classNames?.label })}>{label}</span>
|
||||
{
|
||||
!!endIcon && (
|
||||
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>
|
||||
|
||||
@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
/>
|
||||
|
||||
<Tooltip text="Send">
|
||||
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" />
|
||||
<Button
|
||||
type="submit"
|
||||
icon="ri:send-plane-fill"
|
||||
size="lg"
|
||||
color="success"
|
||||
class="h-16"
|
||||
label="Send"
|
||||
iconOnly
|
||||
/>
|
||||
</Tooltip>
|
||||
</form>
|
||||
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}
|
||||
|
||||
@@ -61,8 +61,8 @@ const isHighlighted = comment.id === highlightedCommentId
|
||||
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
|
||||
|
||||
const isAuthor = user?.id === comment.author.id
|
||||
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
|
||||
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
|
||||
const isAdminOrModerator = !!user && (user.admin || user.moderator)
|
||||
const isAuthorOrPrivileged = isAuthor || isAdminOrModerator
|
||||
|
||||
// Check if user is new (less than 1 week old)
|
||||
const isNewUser =
|
||||
@@ -75,7 +75,7 @@ const isRatingActive =
|
||||
!comment.suspicious &&
|
||||
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
|
||||
|
||||
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier
|
||||
// Skip rendering if comment is not approved/verified and user is not the author or admin/moderator
|
||||
const shouldShow =
|
||||
comment.status === 'APPROVED' ||
|
||||
comment.status === 'VERIFIED' ||
|
||||
@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
{...htmlProps}
|
||||
id={`comment-${comment.id.toString()}`}
|
||||
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',
|
||||
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
|
||||
'bg-[#182a1f]',
|
||||
@@ -150,7 +150,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
checked={comment.suspicious}
|
||||
/>
|
||||
|
||||
<div class="comment-header flex items-center gap-2 text-sm">
|
||||
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
|
||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||
<span class="collapse-symbol text-xs"></span>
|
||||
<span class="sr-only">Toggle comment visibility</span>
|
||||
@@ -164,10 +164,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
/>
|
||||
|
||||
{
|
||||
(comment.author.verified || comment.author.admin || comment.author.verifier) && (
|
||||
(comment.author.verified || comment.author.admin || comment.author.moderator) && (
|
||||
<Tooltip
|
||||
text={`${
|
||||
comment.author.admin || comment.author.verifier
|
||||
comment.author.admin || comment.author.moderator
|
||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||
: ''
|
||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
||||
@@ -186,7 +186,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.author.verifier && !comment.author.admin && (
|
||||
comment.author.moderator && !comment.author.admin && (
|
||||
<BadgeSmall
|
||||
icon="ri:graduation-cap-fill"
|
||||
color="teal"
|
||||
@@ -198,14 +198,14 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
}
|
||||
|
||||
{
|
||||
isNewUser && !comment.author.admin && !comment.author.verifier && (
|
||||
isNewUser && !comment.author.admin && !comment.author.moderator && (
|
||||
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
|
||||
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
|
||||
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.moderator && (
|
||||
<BadgeSmall
|
||||
icon={karmaUnlocksById.highKarmaBadge.icon}
|
||||
color="lime"
|
||||
@@ -243,13 +243,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
comment.author.serviceAffiliations.map((affiliation) => {
|
||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||
return (
|
||||
<BadgeSmall
|
||||
icon={roleInfo.icon}
|
||||
color={roleInfo.color}
|
||||
text={`${roleInfo.label} at ${affiliation.service.name}`}
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
|
||||
{roleInfo.label} at
|
||||
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
||||
</BadgeSmall>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -270,12 +267,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
|
||||
{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 && (
|
||||
<Tooltip
|
||||
@@ -320,6 +311,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
color={commentStatusById.REJECTED.color}
|
||||
text={commentStatusById.REJECTED.label}
|
||||
inlineIcon
|
||||
endIcon="ri:lock-line"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
||||
<BadgeSmall
|
||||
icon="ri:alert-fill"
|
||||
color="yellow"
|
||||
text="Needs admin review"
|
||||
inlineIcon
|
||||
endIcon="ri:lock-line"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -380,7 +384,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
}
|
||||
|
||||
{
|
||||
user && (user.admin || user.verifier) && comment.internalNote && (
|
||||
user && (user.admin || user.moderator) && comment.internalNote && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-red-400">Internal note:</span>
|
||||
@@ -391,7 +395,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
}
|
||||
|
||||
{
|
||||
user && (user.admin || user.verifier) && comment.privateContext && (
|
||||
user && (user.admin || user.moderator) && comment.privateContext && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-blue-400">Private context:</span>
|
||||
|
||||
@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
|
||||
privateContext: true
|
||||
orderId: true
|
||||
orderIdStatus: true
|
||||
rating: true
|
||||
ratingActive: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
@@ -28,8 +30,8 @@ const { comment, class: className, ...divProps } = Astro.props
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
// Only render for admin/verifier users
|
||||
if (!user || !user.admin || !user.verifier) return null
|
||||
// Only render for admin/moderator users
|
||||
if (!user || !user.admin || !user.moderator) return null
|
||||
---
|
||||
|
||||
<div {...divProps} class={cn('text-xs', className)}>
|
||||
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
<div
|
||||
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
|
||||
>
|
||||
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
|
||||
<div class="border-night-500 flex flex-wrap items-center gap-1 border-b pb-2">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -59,42 +61,13 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
|
||||
<Icon name="ri:close-circle-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.suspicious
|
||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||
)}
|
||||
data-action="suspicious"
|
||||
data-value={!comment.suspicious}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.suspicious ? 'Not Spam' : 'Spam'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.requiresAdminReview
|
||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||
)}
|
||||
data-action="requires-admin-review"
|
||||
data-value={!comment.requiresAdminReview}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'VERIFIED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
@@ -104,12 +77,13 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
|
||||
<Icon name="ri:verified-badge-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
@@ -121,12 +95,49 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
|
||||
<Icon name="ri:checkbox-circle-line" class="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="bg-night-500 h-5 w-px"></div>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.suspicious
|
||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||
)}
|
||||
data-action="suspicious"
|
||||
data-value={!comment.suspicious}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:spam-2-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.suspicious ? 'Not Spam' : 'Spam'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.requiresAdminReview
|
||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||
)}
|
||||
data-action="requires-admin-review"
|
||||
data-value={!comment.requiresAdminReview}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:shield-user-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.kycRequested
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -136,12 +147,13 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
|
||||
<Icon name="ri:bank-card-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.fundsBlocked
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -151,8 +163,31 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
|
||||
<Icon name="ri:lock-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}</span>
|
||||
</button>
|
||||
|
||||
<div class="bg-night-500 h-5 w-px"></div>
|
||||
|
||||
{
|
||||
comment.rating && (
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.ratingActive
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
)}
|
||||
data-action="toggle-rating-active"
|
||||
data-value={!comment.ratingActive}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:star-line" class="h-3.5 w-3.5" />
|
||||
<span>{comment.ratingActive ? 'Disable Rating' : 'Enable Rating'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1.5">
|
||||
@@ -208,7 +243,7 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'APPROVED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
@@ -218,11 +253,12 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Approve
|
||||
<Icon name="ri:check-line" class="h-3.5 w-3.5" />
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
@@ -232,11 +268,12 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Reject
|
||||
<Icon name="ri:close-line" class="h-3.5 w-3.5" />
|
||||
<span>Reject</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
@@ -246,7 +283,23 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Pending
|
||||
<Icon name="ri:time-line" class="h-3.5 w-3.5" />
|
||||
<span>Pending</span>
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'WITHDRAWN'
|
||||
? 'border-night-400 bg-night-500/50 text-night-300 border'
|
||||
: 'bg-night-700 hover:bg-night-500/50 hover:text-night-300'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="WITHDRAWN"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
<Icon name="ri:arrow-go-back-line" class="h-3.5 w-3.5" />
|
||||
<span>Withdrawn</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
action === 'suspicious' ||
|
||||
action === 'requires-admin-review' ||
|
||||
action === 'kyc-requested' ||
|
||||
action === 'funds-blocked'
|
||||
action === 'funds-blocked' ||
|
||||
action === 'toggle-rating-active'
|
||||
? value === 'true'
|
||||
: value,
|
||||
})
|
||||
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
if (action === 'status') {
|
||||
window.location.reload()
|
||||
} else if (action === 'suspicious') {
|
||||
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'Not Spam' : 'Spam'
|
||||
btn.classList.toggle('bg-yellow-500/20')
|
||||
btn.classList.toggle('text-yellow-400')
|
||||
btn.classList.toggle('border-yellow-500/30')
|
||||
@@ -298,7 +353,8 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'requires-admin-review') {
|
||||
btn.textContent = value === 'true' ? 'No Review' : 'Review'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No Admin Review' : 'Needs Admin Review'
|
||||
btn.classList.toggle('bg-purple-500/20')
|
||||
btn.classList.toggle('text-purple-400')
|
||||
btn.classList.toggle('border-purple-500/30')
|
||||
@@ -309,7 +365,8 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
// Refresh to show updated order ID status
|
||||
window.location.reload()
|
||||
} else if (action === 'kyc-requested') {
|
||||
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
@@ -317,13 +374,23 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'funds-blocked') {
|
||||
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'toggle-rating-active') {
|
||||
const span = btn.querySelector('span')
|
||||
if (span) span.textContent = value === 'true' ? 'Disable Rating' : 'Enable Rating'
|
||||
btn.classList.toggle('bg-blue-500/20')
|
||||
btn.classList.toggle('text-blue-400')
|
||||
btn.classList.toggle('border-blue-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
}
|
||||
} else {
|
||||
console.error('Error moderating comment:', error)
|
||||
|
||||
@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<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">
|
||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
|
||||
@@ -202,7 +202,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
||||
Most Upvotes
|
||||
</a>
|
||||
{
|
||||
user && (user.admin || user.verifier) && (
|
||||
user && (user.admin || user.moderator) && (
|
||||
<a
|
||||
href={getSortUrl('status')}
|
||||
class={cn([
|
||||
|
||||
@@ -47,12 +47,9 @@ const averageUserRatingFromQuery =
|
||||
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
|
||||
|
||||
if (averageUserRatingFromProps !== undefined) {
|
||||
if (
|
||||
averageUserRatingFromQuery !== averageUserRatingFromProps ||
|
||||
(averageUserRatingFromQuery !== null &&
|
||||
averageUserRatingFromProps !== null &&
|
||||
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
|
||||
) {
|
||||
const a = averageUserRatingFromQuery === null ? null : round(averageUserRatingFromQuery, 2)
|
||||
const b = averageUserRatingFromProps === null ? null : round(averageUserRatingFromProps, 2)
|
||||
if (a !== b) {
|
||||
console.error(
|
||||
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
|
||||
)
|
||||
|
||||
@@ -27,6 +27,12 @@ const links = [
|
||||
icon: 'i2p',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: '/docs/api',
|
||||
label: 'API',
|
||||
icon: 'ri:plug-line',
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: '/about',
|
||||
label: 'About',
|
||||
@@ -52,7 +58,7 @@ const { class: className, ...htmlProps } = Astro.props
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700"
|
||||
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
|
||||
>
|
||||
<Icon name={icon} class="h-4 w-4" />
|
||||
{label}
|
||||
|
||||
24
web/src/components/FormSection.astro
Normal file
24
web/src/components/FormSection.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'section'> & {
|
||||
title: string
|
||||
subtitle?: string
|
||||
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
children: AstroChildren
|
||||
}
|
||||
|
||||
const { title, subtitle, class: className, heading = 'h2', ...props } = Astro.props
|
||||
|
||||
const HeadingTag = heading
|
||||
---
|
||||
|
||||
<section class={cn('mt-24 space-y-2 first:mt-0', className)} {...props}>
|
||||
<HeadingTag class="font-title text-center text-3xl leading-none font-bold">{title}</HeadingTag>
|
||||
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
24
web/src/components/FormSubSection.astro
Normal file
24
web/src/components/FormSubSection.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'section'> & {
|
||||
title: string
|
||||
subtitle?: string
|
||||
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
children: AstroChildren
|
||||
}
|
||||
|
||||
const { title, subtitle, class: className, heading = 'h3', ...props } = Astro.props
|
||||
|
||||
const HeadingTag = heading
|
||||
---
|
||||
|
||||
<section class={cn('mt-6 space-y-2 first:mt-0', className)} {...props}>
|
||||
<HeadingTag class="font-title text-day-400 text-center text-lg font-medium">{title}</HeadingTag>
|
||||
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
@@ -160,6 +160,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
||||
<a
|
||||
href={makeUnimpersonateUrl(Astro.url)}
|
||||
data-astro-reload
|
||||
data-astro-prefetch="tap"
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
||||
transition:name="header-unimpersonate-link"
|
||||
aria-label="Unimpersonate"
|
||||
|
||||
@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist={option.noTransitionPersist ? undefined : true}
|
||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
|
||||
@@ -12,13 +12,15 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
icon?: string[] | string
|
||||
iconClassName?: string[] | string
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValues?: string[]
|
||||
size?: 'lg' | 'md'
|
||||
}
|
||||
|
||||
const { options, disabled, selectedValues = [], ...wrapperProps } = Astro.props
|
||||
const { options, disabled, selectedValues = [], size = 'md', ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
@@ -26,23 +28,38 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}>
|
||||
<div class="h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5">
|
||||
<div
|
||||
class={cn('h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5', {
|
||||
'h-96': size === 'lg',
|
||||
'h-48': size === 'md',
|
||||
})}
|
||||
>
|
||||
{
|
||||
options.map((option) => (
|
||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{option.icon && <Icon name={option.icon} class="size-4" />}
|
||||
<span class="text-sm leading-none">{option.label}</span>
|
||||
</label>
|
||||
))
|
||||
options.map((option) => {
|
||||
const icons = option.icon ? (Array.isArray(option.icon) ? option.icon : [option.icon]) : []
|
||||
const iconClassName = option.iconClassName
|
||||
? Array.isArray(option.iconClassName)
|
||||
? option.iconClassName
|
||||
: Array.from({ length: icons.length }, () => option.iconClassName)
|
||||
: []
|
||||
return (
|
||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1 has-checked:bg-green-800/20 has-checked:hover:bg-green-800/30">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{icons.map((icon, index) => (
|
||||
<Icon name={icon} class={cn('size-4 shrink-0', iconClassName[index])} />
|
||||
))}
|
||||
<span class="truncate text-sm leading-none">{option.label}</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,20 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
|
||||
}
|
||||
}
|
||||
|
||||
const { accept, disabled, multiple, removeCheckbox, ...wrapperProps } = Astro.props
|
||||
const { accept, disabled, multiple, removeCheckbox, classNames, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<InputWrapper
|
||||
inputId={inputId}
|
||||
classNames={{
|
||||
...classNames,
|
||||
description: cn(classNames?.description, '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'),
|
||||
}}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{
|
||||
!!removeCheckbox && (
|
||||
<label
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
import { cn } from '../lib/cn'
|
||||
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import InputFile from './InputFile.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
|
||||
square?: boolean
|
||||
value?: string | null
|
||||
downloadButton?: boolean
|
||||
}
|
||||
|
||||
const { class: className, square, value, ...inputFileProps } = Astro.props
|
||||
const { class: className, square, value, downloadButton, ...inputFileProps } = Astro.props
|
||||
|
||||
function makeDownloadFilename(value: string) {
|
||||
const url = new URL(value, Astro.url.origin)
|
||||
return url.pathname.split('/').pop() ?? 'service-image'
|
||||
}
|
||||
---
|
||||
|
||||
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
|
||||
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
|
||||
'[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'
|
||||
)}
|
||||
/>
|
||||
{
|
||||
downloadButton && value && (
|
||||
<Tooltip
|
||||
text="Download"
|
||||
classNames={{
|
||||
tooltip: 'min-2xs:[&:is(:has([data-remove-checkbox]:checked)_~_*_*)]:hidden',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
href={value}
|
||||
download={makeDownloadFilename(value)}
|
||||
icon="ri:download-line"
|
||||
size="sm"
|
||||
label="Download"
|
||||
class={cn(
|
||||
'bg-night-600 border-night-400 text-day-200 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:h-24 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:px-0 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:w-8 shrink-0 rounded-md border'
|
||||
)}
|
||||
classNames={{
|
||||
label: '2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:hidden block ',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
hideCancel?: boolean
|
||||
icon?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
color?: ComponentProps<typeof Button>['color']
|
||||
}
|
||||
|
||||
const {
|
||||
hideCancel = false,
|
||||
icon = 'ri:send-plane-2-line',
|
||||
label = 'Submit',
|
||||
disabled = false,
|
||||
class: className,
|
||||
color = 'success',
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
|
||||
{!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>
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
|
||||
inputProps?: Omit<HTMLAttributes<'textarea'>, 'name'>
|
||||
value?: string
|
||||
value?: string | null | undefined
|
||||
}
|
||||
|
||||
const { inputProps, value, ...wrapperProps } = Astro.props
|
||||
|
||||
@@ -19,6 +19,9 @@ type Props = HTMLAttributes<'div'> & {
|
||||
icon?: string
|
||||
inputId?: string
|
||||
hideLabel?: boolean
|
||||
classNames?: {
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -32,19 +35,22 @@ const {
|
||||
class: className,
|
||||
inputId,
|
||||
hideLabel,
|
||||
classNames,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const hasError = !!error && error.length > 0
|
||||
---
|
||||
|
||||
<fieldset class={cn('space-y-1', className)} {...htmlProps}>
|
||||
<fieldset class={cn('min-w-0 space-y-1', className)} {...htmlProps}>
|
||||
{
|
||||
!hideLabel && (
|
||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
|
||||
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
|
||||
<label for={inputId}>{label}</label>
|
||||
<label for={inputId} transition:persist>
|
||||
{label}
|
||||
</label>
|
||||
{required && '*'}
|
||||
</legend>
|
||||
{!!descriptionLabel && (
|
||||
@@ -71,7 +77,12 @@ const hasError = !!error && error.length > 0
|
||||
|
||||
{
|
||||
!!description && (
|
||||
<div class="prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty">
|
||||
<div
|
||||
class={cn(
|
||||
'prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty',
|
||||
classNames?.description
|
||||
)}
|
||||
>
|
||||
<Markdown content={description} />
|
||||
</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>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
@@ -25,6 +26,7 @@ type Props = HTMLAttributes<'a'> & {
|
||||
kycLevel: true
|
||||
imageUrl: true
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
acceptedCurrencies: true
|
||||
categories: {
|
||||
select: {
|
||||
@@ -43,11 +45,11 @@ const {
|
||||
slug,
|
||||
description,
|
||||
overallScore,
|
||||
|
||||
kycLevel,
|
||||
imageUrl,
|
||||
categories,
|
||||
verificationStatus,
|
||||
serviceVisibility,
|
||||
acceptedCurrencies,
|
||||
},
|
||||
class: className,
|
||||
@@ -69,7 +71,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||
{...aProps}
|
||||
class={cn(
|
||||
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -79,7 +83,11 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
src={imageUrl}
|
||||
fallback="service"
|
||||
alt={name || 'Service logo'}
|
||||
class="size-12 shrink-0 rounded-sm object-contain text-white"
|
||||
class={cn(
|
||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||
)}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
@@ -110,6 +118,23 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
]}
|
||||
</Tooltip>
|
||||
)
|
||||
}{
|
||||
serviceVisibility === 'ARCHIVED' && (
|
||||
<Tooltip
|
||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h3>
|
||||
<div class="max-h-2 flex-1"></div>
|
||||
|
||||
@@ -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 {
|
||||
type: 'clearnet' as const,
|
||||
url: urlWithReferral,
|
||||
|
||||
@@ -40,6 +40,7 @@ const {
|
||||
hx-select={`#${searchResultsId}`}
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-indicator"
|
||||
hx-swap="outerHTML"
|
||||
data-services-filters-form
|
||||
data-default-verification-filter={options.verification
|
||||
.filter((verification) => verification.default)
|
||||
@@ -48,6 +49,7 @@ const {
|
||||
class={cn(
|
||||
// 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]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -79,16 +81,20 @@ const {
|
||||
))
|
||||
}
|
||||
</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]" />
|
||||
Ties randomly sorted
|
||||
</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>
|
||||
|
||||
<!-- Text Search -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="q">Text</label>
|
||||
<label for="q">Name</label>
|
||||
</legend>
|
||||
<input
|
||||
type="text"
|
||||
@@ -107,7 +113,7 @@ const {
|
||||
{
|
||||
options.categories?.map((category) => (
|
||||
<li data-show-always={category.showAlways ? '' : undefined}>
|
||||
<label class="flex cursor-pointer items-center space-x-2 text-sm text-white">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
@@ -116,6 +122,7 @@ const {
|
||||
checked={category.checked}
|
||||
data-trigger-on-change
|
||||
/>
|
||||
<Icon name={category.icon} class="size-4" />
|
||||
<span class="peer-checked:font-bold">
|
||||
{category.name}
|
||||
<span class="text-day-500 font-normal">{category._count.services}</span>
|
||||
|
||||
@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
|
||||
pageSize: number
|
||||
sortSeed?: string
|
||||
filters: ServicesFiltersObject
|
||||
includeScams: boolean
|
||||
countCommunityOnly: number | null
|
||||
inlineIcons?: boolean
|
||||
}
|
||||
@@ -35,15 +34,12 @@ const {
|
||||
sortSeed,
|
||||
class: className,
|
||||
filters,
|
||||
includeScams,
|
||||
countCommunityOnly,
|
||||
inlineIcons,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
|
||||
const hasScams =
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
filters.verification.includes('VERIFICATION_FAILED') || includeScams
|
||||
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
|
||||
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
|
||||
|
||||
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
|
||||
@@ -75,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
/>
|
||||
|
||||
{
|
||||
countCommunityOnly && (
|
||||
!!countCommunityOnly && (
|
||||
<>
|
||||
<Button
|
||||
as="a"
|
||||
@@ -196,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
inlineIcon={inlineIcons}
|
||||
/>
|
||||
)}
|
||||
{countCommunityOnly && (
|
||||
{!!countCommunityOnly && (
|
||||
<Button
|
||||
as="a"
|
||||
href={urlIfIncludingCommunity}
|
||||
@@ -205,6 +201,13 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
inlineIcon={inlineIcons}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
as="a"
|
||||
href="/service-suggestion/new"
|
||||
label="Add service"
|
||||
icon="ri:add-line"
|
||||
inlineIcon={inlineIcons}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -241,14 +244,18 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
</>
|
||||
)
|
||||
}
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
as="a"
|
||||
href="/service-suggestion/new"
|
||||
label="Add service"
|
||||
icon="ri:add-line"
|
||||
inlineIcon={inlineIcons}
|
||||
class="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
services && services.length > 0 && (
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
as="a"
|
||||
href="/service-suggestion/new"
|
||||
label="Add service"
|
||||
icon="ri:add-line"
|
||||
inlineIcon={inlineIcons}
|
||||
class="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,15 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
|
||||
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
|
||||
<span>Community-contributed. Information not reviewed.</span>
|
||||
<span>
|
||||
Community-contributed. Information not reviewed.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : wasRecentlyAdded ? (
|
||||
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
@@ -64,11 +72,25 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||
caution.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
|
||||
<Icon name="ri:information-line" class="size-5 text-blue-400" />
|
||||
<span>Basic checks passed, but not fully verified.</span>
|
||||
<span>
|
||||
Basic checks passed, but not fully verified.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -43,14 +43,14 @@ export const {
|
||||
notificationTitle: 'Your account is no longer verified',
|
||||
},
|
||||
{
|
||||
value: 'VERIFIER_TRUE',
|
||||
label: 'Verifier role granted',
|
||||
notificationTitle: 'Verifier role granted',
|
||||
value: 'MODERATOR_TRUE',
|
||||
label: 'Moderator role granted',
|
||||
notificationTitle: 'Moderator role granted',
|
||||
},
|
||||
{
|
||||
value: 'VERIFIER_FALSE',
|
||||
label: 'Verifier role revoked',
|
||||
notificationTitle: 'Verifier role revoked',
|
||||
value: 'MODERATOR_FALSE',
|
||||
label: 'Moderator role revoked',
|
||||
notificationTitle: 'Moderator role revoked',
|
||||
},
|
||||
{
|
||||
value: 'SPAMMER_TRUE',
|
||||
|
||||
@@ -3,13 +3,17 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { Assert } from '../lib/assert'
|
||||
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
|
||||
|
||||
type ContactMethodInfo<T extends string | null | undefined = string> = {
|
||||
type: T
|
||||
label: string
|
||||
/** Notice that the first capture group is then used to format the value */
|
||||
matcher: RegExp
|
||||
formatter: (value: string) => string | null
|
||||
formatter: (match: RegExpMatchArray) => string | null
|
||||
icon: string
|
||||
urlType: string
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -22,94 +26,132 @@ export const {
|
||||
(type): ContactMethodInfo<typeof type> => ({
|
||||
type,
|
||||
label: type ? transformCase(type, 'title') : String(type),
|
||||
icon: 'ri:shield-fill',
|
||||
icon: 'ri:link',
|
||||
matcher: /(.*)/,
|
||||
formatter: (value) => value,
|
||||
formatter: ([, value]) => value ?? String(value),
|
||||
urlType: type ?? 'unknown',
|
||||
}),
|
||||
[
|
||||
{
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
matcher: /mailto:(.*)/,
|
||||
formatter: (value) => value,
|
||||
matcher: /mailto:(.+)/,
|
||||
formatter: ([, value]) => value ?? 'Email',
|
||||
icon: 'ri:mail-line',
|
||||
urlType: 'email',
|
||||
},
|
||||
{
|
||||
type: 'telephone',
|
||||
label: 'Telephone',
|
||||
matcher: /tel:(.*)/,
|
||||
formatter: (value) => {
|
||||
return parsePhoneNumberWithError(value).formatInternational()
|
||||
matcher: /tel:(.+)/,
|
||||
formatter: ([, value]) => {
|
||||
try {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
|
||||
} catch (_error) {
|
||||
console.error(`Invalid telephone number: ${value ?? 'undefined'}`, _error)
|
||||
return value ?? 'Telephone'
|
||||
}
|
||||
},
|
||||
icon: 'ri:phone-line',
|
||||
urlType: 'telephone',
|
||||
},
|
||||
{
|
||||
type: 'whatsapp',
|
||||
label: 'WhatsApp',
|
||||
matcher: /https?:\/\/(?:www\.)?wa\.me\/(.*)\/?/,
|
||||
formatter: (value) => {
|
||||
return parsePhoneNumberWithError(value).formatInternational()
|
||||
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
|
||||
formatter: ([, value]) => {
|
||||
try {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
|
||||
} catch (_error) {
|
||||
console.error(`Invalid WhatsApp number: ${value ?? 'undefined'}`, _error)
|
||||
return value ?? 'WhatsApp'
|
||||
}
|
||||
},
|
||||
icon: 'ri:whatsapp-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'telegram',
|
||||
label: 'Telegram',
|
||||
matcher: /https?:\/\/(?:www\.)?t\.me\/(.*)\/?/,
|
||||
formatter: (value) => `t.me/${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
|
||||
icon: 'ri:telegram-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'linkedin',
|
||||
label: 'LinkedIn',
|
||||
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/,
|
||||
formatter: (value) => `in/${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
|
||||
icon: 'ri:linkedin-box-line',
|
||||
},
|
||||
{
|
||||
type: 'website',
|
||||
label: 'Website',
|
||||
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||
formatter: (value) => value,
|
||||
icon: 'ri:global-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'x',
|
||||
label: 'X',
|
||||
matcher: /https?:\/\/(?:www\.)?x\.com\/(.*)\/?/,
|
||||
formatter: (value) => `@${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `@${value}` : 'X'),
|
||||
icon: 'ri:twitter-x-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
label: 'Instagram',
|
||||
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.*)\/?/,
|
||||
formatter: (value) => `@${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
|
||||
icon: 'ri:instagram-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'matrix',
|
||||
label: 'Matrix',
|
||||
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.*)\/?/,
|
||||
formatter: (value) => value,
|
||||
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
||||
icon: 'ri:hashtag',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'bitcointalk',
|
||||
label: 'BitcoinTalk',
|
||||
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||
formatter: () => 'BitcoinTalk',
|
||||
icon: 'ri:btc-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'simplex',
|
||||
label: 'SimpleX Chat',
|
||||
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
|
||||
formatter: () => 'SimpleX Chat',
|
||||
icon: 'simplex',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'nostr',
|
||||
label: 'Nostr',
|
||||
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
|
||||
formatter: () => 'Nostr',
|
||||
icon: 'nostr',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
// Website must go last because it's a catch-all
|
||||
type: 'website',
|
||||
label: 'Website',
|
||||
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||
formatter: ([, value]) => value ?? 'Website',
|
||||
icon: 'ri:global-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
] as const satisfies ContactMethodInfo[]
|
||||
)
|
||||
|
||||
export function formatContactMethod(url: string) {
|
||||
for (const contactMethod of contactMethods) {
|
||||
const captureGroup = url.match(contactMethod.matcher)?.[1]
|
||||
if (!captureGroup) continue
|
||||
const match = url.match(contactMethod.matcher)
|
||||
if (!match) continue
|
||||
|
||||
const formattedValue = contactMethod.formatter(captureGroup)
|
||||
const formattedValue = contactMethod.formatter(match)
|
||||
if (!formattedValue) continue
|
||||
|
||||
return {
|
||||
@@ -120,3 +162,38 @@ export function formatContactMethod(url: string) {
|
||||
|
||||
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
|
||||
}
|
||||
|
||||
type ContactMethodUrlTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
labelPlural: string
|
||||
}
|
||||
|
||||
export const {
|
||||
dataArray: contactMethodUrlTypes,
|
||||
dataObject: contactMethodUrlTypesById,
|
||||
getFn: getContactMethodUrlTypeInfo,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): ContactMethodUrlTypeInfo<typeof value> => ({
|
||||
value,
|
||||
labelPlural: value ? transformCase(value, 'title') : String(value),
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'email',
|
||||
labelPlural: 'emails',
|
||||
},
|
||||
{
|
||||
value: 'telephone',
|
||||
labelPlural: 'phone numbers',
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
labelPlural: 'URLs',
|
||||
},
|
||||
] as const satisfies ContactMethodUrlTypeInfo<(typeof contactMethods)[number]['urlType']>[]
|
||||
)
|
||||
|
||||
type _ExpectUrlTypesToHaveAllValues = Assert<
|
||||
Equals<(typeof contactMethods)[number]['urlType'], keyof typeof contactMethodUrlTypesById>
|
||||
>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||
import type { EventType } from '@prisma/client'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||
id: T
|
||||
@@ -12,6 +14,9 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||
dot: string
|
||||
}
|
||||
icon: string
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
isSolved: boolean
|
||||
showBanner: boolean
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -32,6 +37,9 @@ export const {
|
||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||
},
|
||||
icon: 'ri:question-fill',
|
||||
color: 'gray',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
}),
|
||||
[
|
||||
{
|
||||
@@ -42,7 +50,10 @@ export const {
|
||||
classNames: {
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
},
|
||||
icon: 'ri:error-warning-fill',
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'yellow',
|
||||
isSolved: false,
|
||||
showBanner: true,
|
||||
},
|
||||
{
|
||||
id: 'WARNING_SOLVED',
|
||||
@@ -50,9 +61,12 @@ export const {
|
||||
label: 'Warning Solved',
|
||||
description: 'A previously reported warning has been solved',
|
||||
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',
|
||||
isSolved: true,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'ALERT',
|
||||
@@ -62,7 +76,10 @@ export const {
|
||||
classNames: {
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
},
|
||||
icon: 'ri:alert-fill',
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'red',
|
||||
isSolved: false,
|
||||
showBanner: true,
|
||||
},
|
||||
{
|
||||
id: 'ALERT_SOLVED',
|
||||
@@ -70,9 +87,12 @@ export const {
|
||||
label: 'Alert Solved',
|
||||
description: 'A previously reported alert has been solved',
|
||||
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',
|
||||
isSolved: true,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'INFO',
|
||||
@@ -83,6 +103,9 @@ export const {
|
||||
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
|
||||
},
|
||||
icon: 'ri:information-fill',
|
||||
color: 'sky',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'NORMAL',
|
||||
@@ -93,6 +116,9 @@ export const {
|
||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||
},
|
||||
icon: 'ri:notification-fill',
|
||||
color: 'green',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'UPDATE',
|
||||
@@ -103,6 +129,9 @@ export const {
|
||||
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
|
||||
},
|
||||
icon: 'ri:pencil-fill',
|
||||
color: 'sky',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
] 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>[]
|
||||
)
|
||||
@@ -20,6 +20,11 @@ export const {
|
||||
icon: 'ri:notification-line',
|
||||
}),
|
||||
[
|
||||
{
|
||||
id: 'TEST',
|
||||
label: 'Test notification',
|
||||
icon: 'ri:flask-line',
|
||||
},
|
||||
{
|
||||
id: 'COMMENT_STATUS_CHANGE',
|
||||
label: 'Comment status changed',
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const SUPPORT_EMAIL = 'support@kycnot.me'
|
||||
export const SUPPORT_EMAIL = 'contact@kycnot.me'
|
||||
|
||||
@@ -8,7 +8,7 @@ type ServiceSuggestionStatusInfo<T extends string | null | undefined = string> =
|
||||
slug: string
|
||||
label: string
|
||||
icon: string
|
||||
iconClass: string
|
||||
color: string
|
||||
default: boolean
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const {
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
icon: 'ri:question-line',
|
||||
iconClass: 'text-current/60',
|
||||
color: 'gray',
|
||||
default: false,
|
||||
}),
|
||||
[
|
||||
@@ -37,7 +37,7 @@ export const {
|
||||
slug: 'pending',
|
||||
label: 'Pending',
|
||||
icon: 'ri:time-line',
|
||||
iconClass: 'text-yellow-400',
|
||||
color: 'yellow',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
@@ -45,7 +45,7 @@ export const {
|
||||
slug: 'approved',
|
||||
label: 'Approved',
|
||||
icon: 'ri:check-line',
|
||||
iconClass: 'text-green-400',
|
||||
color: 'green',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
@@ -53,7 +53,7 @@ export const {
|
||||
slug: 'rejected',
|
||||
label: 'Rejected',
|
||||
icon: 'ri:close-line',
|
||||
iconClass: 'text-red-400',
|
||||
color: 'red',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
@@ -61,7 +61,7 @@ export const {
|
||||
slug: 'withdrawn',
|
||||
label: 'Withdrawn',
|
||||
icon: 'ri:arrow-left-line',
|
||||
iconClass: 'text-gray-400',
|
||||
color: 'gray',
|
||||
default: false,
|
||||
},
|
||||
] as const satisfies ServiceSuggestionStatusInfo<ServiceSuggestionStatus>[]
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||
import type { ServiceSuggestionType } from '@prisma/client'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
slug: string
|
||||
label: string
|
||||
icon: string
|
||||
order: number
|
||||
default: boolean
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -25,9 +29,11 @@ export const {
|
||||
(value): ServiceSuggestionTypeInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||
icon: 'ri:question-line',
|
||||
order: Infinity,
|
||||
default: false,
|
||||
color: 'zinc',
|
||||
}),
|
||||
[
|
||||
{
|
||||
@@ -35,14 +41,18 @@ export const {
|
||||
slug: 'create',
|
||||
label: 'Create',
|
||||
icon: 'ri:add-line',
|
||||
order: 1,
|
||||
default: true,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'EDIT_SERVICE',
|
||||
slug: 'edit',
|
||||
label: 'Edit',
|
||||
icon: 'ri:pencil-line',
|
||||
order: 2,
|
||||
default: false,
|
||||
color: 'blue',
|
||||
},
|
||||
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ type ServiceVisibilityInfo<T extends string | null | undefined = string> = {
|
||||
slug: string
|
||||
label: string
|
||||
description: string
|
||||
longDescription: string
|
||||
icon: string
|
||||
iconClass: string
|
||||
}
|
||||
@@ -28,6 +29,7 @@ export const {
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
description: '',
|
||||
longDescription: '',
|
||||
icon: 'ri:eye-line',
|
||||
iconClass: 'text-current/60',
|
||||
}),
|
||||
@@ -37,6 +39,7 @@ export const {
|
||||
slug: 'public',
|
||||
label: 'Public',
|
||||
description: 'Listed in search and browse.',
|
||||
longDescription: 'Listed in search and browse.',
|
||||
icon: 'ri:global-line',
|
||||
iconClass: 'text-green-500',
|
||||
},
|
||||
@@ -45,6 +48,7 @@ export const {
|
||||
slug: 'unlisted',
|
||||
label: 'Unlisted',
|
||||
description: 'Only accessible via direct link.',
|
||||
longDescription: "Unlisted service, only accessible via direct link and won't appear in searches.",
|
||||
icon: 'ri:link',
|
||||
iconClass: 'text-yellow-500',
|
||||
},
|
||||
@@ -53,8 +57,19 @@ export const {
|
||||
slug: 'hidden',
|
||||
label: 'Hidden',
|
||||
description: 'Only visible to moderators.',
|
||||
longDescription: 'Hidden service, only visible to moderators.',
|
||||
icon: 'ri:lock-line',
|
||||
iconClass: 'text-red-500',
|
||||
},
|
||||
{
|
||||
value: 'ARCHIVED',
|
||||
slug: 'archived',
|
||||
label: 'Archived',
|
||||
description: 'No longer operational.',
|
||||
longDescription:
|
||||
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
||||
icon: 'ri:archive-line',
|
||||
iconClass: 'text-day-100',
|
||||
},
|
||||
] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
|
||||
)
|
||||
|
||||
53
web/src/constants/verificationStepStatus.ts
Normal file
53
web/src/constants/verificationStepStatus.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||
import type { VerificationStepStatus } from '@prisma/client'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
label: string
|
||||
icon: string
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
}
|
||||
|
||||
export const {
|
||||
dataArray: verificationStepStatuses,
|
||||
dataObject: verificationStepStatusesByValue,
|
||||
getFn: getVerificationStepStatusInfo,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): VerificationStepStatusInfo<typeof value> => ({
|
||||
value,
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
icon: 'ri:question-line',
|
||||
color: 'gray',
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'PASSED',
|
||||
label: 'Passed',
|
||||
icon: 'ri:verified-badge-fill',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'IN_PROGRESS',
|
||||
label: 'In Progress',
|
||||
icon: 'ri:loader-line',
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
value: 'FAILED',
|
||||
label: 'Failed',
|
||||
icon: 'ri:alert-line',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
value: 'PENDING',
|
||||
label: 'Pending',
|
||||
icon: 'ri:time-line',
|
||||
color: 'sky',
|
||||
},
|
||||
] as const satisfies VerificationStepStatusInfo<VerificationStepStatus>[]
|
||||
)
|
||||
4
web/src/icons/nostr.svg
Normal file
4
web/src/icons/nostr.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 620 620">
|
||||
<path
|
||||
d="M620 270v328c0 12-10 22-22 22H332c-12 0-22-10-22-22v-61c1-75 9-147 26-179 9-20 26-30 44-36 36-11 98-4 124-5 0 0 80 3 80-42 0-37-36-34-36-34-39 1-69-1-88-9-33-13-34-36-34-44-1-91-134-102-252-79-128 24 2 209 2 456v33c0 12-10 22-22 22H22c-12 0-22-10-22-22V31C0 19 10 9 22 9h124c12 0 22 10 22 22 0 19 20 29 35 18C248 17 305 0 369 0c143 0 251 84 251 270Zm-238-66c0-27-21-48-47-48s-47 21-47 48c0 26 21 48 47 48s47-22 47-48Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
5
web/src/icons/simplex.svg
Normal file
5
web/src/icons/simplex.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd"
|
||||
d="m2.14 6.31 3.95 3.79 3.97-3.81L6.11 2.5 8.13.57l3.94 3.79L16.1.5l1.98 1.9-4.03 3.85L18 10.03l4.03-3.85L24 8.07l-4.03 3.86 3.95 3.78-2.01 1.93-3.95-3.78-4.03 3.85 3.95 3.79-2 1.93-3.96-3.79L7.9 23.5l-1.98-1.9 4.06-3.89-3.95-3.78-4.06 3.89L0 15.92l4.06-3.88L.1 8.26 2.14 6.3Zm13.85 5.65L12 15.77 8.06 12l3.98-3.81 3.95 3.78Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -8,7 +8,7 @@ import BaseLayout from './BaseLayout.astro'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
||||
layoutHeader: { icon: string; title: string; subtitle: string }
|
||||
layoutHeader: { icon: string; title: string; subtitle?: string }
|
||||
}
|
||||
|
||||
const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||
@@ -28,10 +28,19 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
||||
</div>
|
||||
|
||||
<h1 class="font-title text-day-200 mt-1 text-center text-3xl font-semibold">
|
||||
<h1
|
||||
class={cn(
|
||||
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
|
||||
!layoutHeader.subtitle && 'xs:mb-8 mb-6'
|
||||
)}
|
||||
>
|
||||
{layoutHeader.title}
|
||||
</h1>
|
||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
||||
{
|
||||
!!layoutHeader.subtitle && (
|
||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
||||
)
|
||||
}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
10
web/src/lib/assert.ts
Normal file
10
web/src/lib/assert.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Gives an error if the type is not equal to 1.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'a'>> // Gives an error
|
||||
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'b' | 'a'>> // No error
|
||||
* ```
|
||||
*/
|
||||
export type Assert<T extends 1> = T
|
||||
@@ -2,6 +2,7 @@ import { orderBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
|
||||
import { formatDateShort } from './timeAgo'
|
||||
@@ -36,6 +37,7 @@ export function makeNonDbAttributes(
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
createdAt: true
|
||||
@@ -134,6 +136,16 @@ export function makeNonDbAttributes(
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
show: service.serviceVisibility === 'ARCHIVED',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||
privacyPoints: 0,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: 'Recently listed',
|
||||
show: service.isRecentlyListed,
|
||||
|
||||
@@ -31,7 +31,7 @@ const commentReplyQuery = {
|
||||
name: true,
|
||||
verified: true,
|
||||
admin: true,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
createdAt: true,
|
||||
displayName: true,
|
||||
picture: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { MaybePromise } from 'astro/actions/runtime/utils.js'
|
||||
import type { z } from 'astro/zod'
|
||||
|
||||
type SpecialUserPermission = 'admin' | 'verified' | 'verifier'
|
||||
type SpecialUserPermission = 'admin' | 'moderator' | 'verified'
|
||||
type Permission = SpecialUserPermission | 'guest' | 'not-spammer' | 'user'
|
||||
|
||||
type ActionAPIContextWithUser = ActionAPIContext & {
|
||||
@@ -87,8 +87,8 @@ export function defineProtectedAction<
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions === 'verifier' || (Array.isArray(permissions) && permissions.includes('verifier'))) &&
|
||||
!context.locals.user.verifier
|
||||
(permissions === 'moderator' || (Array.isArray(permissions) && permissions.includes('moderator'))) &&
|
||||
!context.locals.user.moderator
|
||||
) {
|
||||
if (context.locals.user.spammer) {
|
||||
throw new ActionError({
|
||||
@@ -98,7 +98,7 @@ export function defineProtectedAction<
|
||||
}
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Verifier privileges required.',
|
||||
message: 'Moderator privileges required.',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
} catch (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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,53 @@ export async function saveFileLocally(
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files in a specific subdirectory of the upload directory.
|
||||
* Returns an array of web-accessible URLs.
|
||||
*/
|
||||
export async function listFiles(subDir: string): Promise<string[]> {
|
||||
const { fsPath: uploadDir, webPath: webUploadPath } = getUploadDir(subDir)
|
||||
try {
|
||||
const files = await fs.readdir(uploadDir)
|
||||
return files.map((file) => sanitizePath(`${webUploadPath}/${file}`))
|
||||
} catch (error: unknown) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
console.error(`Error listing files in ${uploadDir}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file locally given its web-accessible URL path
|
||||
*/
|
||||
export async function deleteFileLocally(fileUrl: string): Promise<void> {
|
||||
// Extract the subpath and filename from the webPath
|
||||
// Example: /files/evidence/service-slug/image.jpg -> evidence/service-slug/image.jpg
|
||||
const basePath = '/files'
|
||||
if (!fileUrl.startsWith(basePath)) {
|
||||
throw new Error('Invalid file URL for deletion. Must start with /files')
|
||||
}
|
||||
|
||||
const subPathAndFile = fileUrl.substring(basePath.length).replace(/^\/+/, '') // Remove leading /files/ and any extra leading slashes
|
||||
const { fsPath: uploadDirWithoutSubDir } = getUploadDir() // Get base upload directory
|
||||
const filePath = path.join(uploadDirWithoutSubDir, subPathAndFile)
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch (error: unknown) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code === 'ENOENT') {
|
||||
console.warn(`File not found for deletion, but treating as success: ${filePath}`)
|
||||
return
|
||||
}
|
||||
console.error(`Error deleting file ${filePath}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePath(inputPath: string): string {
|
||||
let sanitized = inputPath.replace(/\\+/g, '/')
|
||||
// Collapse multiple slashes, but preserve protocol (e.g., http://)
|
||||
|
||||
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 }))
|
||||
}
|
||||
35
web/src/lib/json.ts
Normal file
35
web/src/lib/json.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { z } from 'astro:content'
|
||||
|
||||
// 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
|
||||
|
||||
export type ZodJSON = z.ZodType<JSONValue>
|
||||
|
||||
export function zodParseJSON<T extends z.ZodType<JSONValue>, D extends z.output<T> | undefined = undefined>(
|
||||
schema: T,
|
||||
stringValue: string | null | undefined,
|
||||
defaultValue?: D
|
||||
): D | z.output<T> {
|
||||
if (!stringValue) return defaultValue as D
|
||||
|
||||
let jsonValue: D | z.output<typeof schema> = defaultValue as D
|
||||
try {
|
||||
jsonValue = JSON.parse(stringValue)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return defaultValue as D
|
||||
}
|
||||
|
||||
const parsedValue = schema.safeParse(jsonValue)
|
||||
if (!parsedValue.success) {
|
||||
console.error(parsedValue.error)
|
||||
return defaultValue as D
|
||||
}
|
||||
|
||||
return parsedValue.data
|
||||
}
|
||||
53
web/src/lib/localstorage.ts
Normal file
53
web/src/lib/localstorage.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from 'astro:schema'
|
||||
|
||||
import { zodParseJSON, type ZodJSON } from './json'
|
||||
import { typedObjectEntries } from './objects'
|
||||
|
||||
function makeTypedLocalStorage<
|
||||
Schemas extends Record<string, ZodJSON>,
|
||||
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: () => {
|
||||
return zodParseJSON(option.schema, localStorage.getItem(key), option.default)
|
||||
},
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { prisma } from './prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>(
|
||||
userId: number,
|
||||
select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never },
|
||||
tx: Prisma.TransactionClient = prisma
|
||||
tx:
|
||||
| Parameters<Parameters<(typeof prisma)['$transaction']>[0]>[0]
|
||||
| Prisma.TransactionClient
|
||||
| PrismaClient = prisma
|
||||
) {
|
||||
return (
|
||||
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??
|
||||
|
||||
@@ -7,11 +7,13 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
|
||||
|
||||
import { makeCommentUrl } from './commentsWithReplies'
|
||||
|
||||
import type { NotificationAction } from './webPush'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export function makeNotificationTitle(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
type: true
|
||||
aboutAccountStatusChange: true
|
||||
aboutCommentStatusChange: true
|
||||
@@ -87,6 +89,9 @@ export function makeNotificationTitle(
|
||||
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
|
||||
): string {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return `Test notification #${notification.id.toString()}`
|
||||
}
|
||||
case 'COMMENT_STATUS_CHANGE': {
|
||||
if (!notification.aboutComment) return 'A comment you are watching had a status change'
|
||||
|
||||
@@ -178,6 +183,7 @@ export function makeNotificationTitle(
|
||||
export function makeNotificationContent(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
createdAt: true
|
||||
type: true
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
@@ -204,6 +210,9 @@ export function makeNotificationContent(
|
||||
}>
|
||||
): string | null {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return `Created on ${notification.createdAt.toLocaleString()}`
|
||||
}
|
||||
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
// case 'KARMA_UNLOCK':
|
||||
case 'KARMA_CHANGE': {
|
||||
@@ -236,7 +245,7 @@ export function makeNotificationContent(
|
||||
}
|
||||
}
|
||||
|
||||
export function makeNotificationLink(
|
||||
export function makeNotificationActions(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
type: true
|
||||
@@ -278,44 +287,120 @@ export function makeNotificationLink(
|
||||
}
|
||||
}>,
|
||||
origin: string
|
||||
): string | null {
|
||||
): NotificationAction[] {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/notifications`,
|
||||
},
|
||||
{
|
||||
action: 'profile',
|
||||
title: 'Profile',
|
||||
...iconNameAndUrl('ri:user-line'),
|
||||
url: `${origin}/account`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'COMMENT_STATUS_CHANGE':
|
||||
case 'REPLY_COMMENT_CREATED':
|
||||
case 'COMMUNITY_NOTE_ADDED':
|
||||
case 'ROOT_COMMENT_CREATED': {
|
||||
if (!notification.aboutComment) return null
|
||||
return makeCommentUrl({
|
||||
serviceSlug: notification.aboutComment.service.slug,
|
||||
commentId: notification.aboutComment.id,
|
||||
origin,
|
||||
})
|
||||
if (!notification.aboutComment) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: makeCommentUrl({
|
||||
serviceSlug: notification.aboutComment.service.slug,
|
||||
commentId: notification.aboutComment.id,
|
||||
origin,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_MESSAGE': {
|
||||
if (!notification.aboutServiceSuggestionMessage) return null
|
||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`
|
||||
if (!notification.aboutServiceSuggestionMessage) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_STATUS_CHANGE': {
|
||||
if (!notification.aboutServiceSuggestionId) return null
|
||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`
|
||||
if (!notification.aboutServiceSuggestionId) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
// case 'KARMA_UNLOCK': {
|
||||
// return `${origin}/account#karma-unlocks`
|
||||
// return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
|
||||
// }
|
||||
case 'KARMA_CHANGE': {
|
||||
return `${origin}/account#karma-transactions`
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/account#karma-transactions`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'ACCOUNT_STATUS_CHANGE': {
|
||||
return `${origin}/account#account-status`
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/account#account-status`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'EVENT_CREATED': {
|
||||
if (!notification.aboutEvent) return null
|
||||
return `${origin}/service/${notification.aboutEvent.service.slug}#events`
|
||||
if (!notification.aboutEvent) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
||||
if (!notification.aboutService) return null
|
||||
return `${origin}/service/${notification.aboutService.slug}#verification`
|
||||
if (!notification.aboutService) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service/${notification.aboutService.slug}#verification`,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function iconUrl<T extends `${string}:${string}`>(iconName: T) {
|
||||
return `https://api.iconify.design/${iconName.replace(':', '/') as T extends `${infer Prefix}:${infer Suffix}` ? `${Prefix}/${Suffix}` : never}.svg` as const
|
||||
}
|
||||
|
||||
function iconNameAndUrl<T extends `${string}:${string}`>(iconName: T) {
|
||||
return {
|
||||
iconName,
|
||||
icon: iconUrl(iconName),
|
||||
} as const
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user