diff --git a/web/src/components/Header.astro b/web/src/components/Header.astro index 148538a..f4c70fb 100644 --- a/web/src/components/Header.astro +++ b/web/src/components/Header.astro @@ -119,7 +119,7 @@ const splashText = showSplashText ? sample(splashTexts) : null , 'children' | 'inputId' | 'required'> & { - inputProps?: Omit, 'name'> + inputProps?: Omit, 'name'> & { + 'transition:persist'?: boolean + } inputIcon?: string inputIconClass?: string } @@ -26,7 +28,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0 inputIcon ? (
& { error?: string[] | string icon?: string inputId?: string + hideLabel?: boolean } const { @@ -30,6 +31,7 @@ const { icon, class: className, inputId, + hideLabel, ...htmlProps } = Astro.props @@ -37,17 +39,20 @@ const hasError = !!error && error.length > 0 ---
-
- - {icon && } - {required && '*'} - - { - !!descriptionLabel && ( - {descriptionLabel} - ) - } -
+ { + !hideLabel && ( +
+ + {icon && } + + {required && '*'} + + {!!descriptionLabel && ( + {descriptionLabel} + )} +
+ ) + } diff --git a/web/src/components/PillsRadioGroup.astro b/web/src/components/PillsRadioGroup.astro index 8079aa1..018952c 100644 --- a/web/src/components/PillsRadioGroup.astro +++ b/web/src/components/PillsRadioGroup.astro @@ -9,10 +9,11 @@ type Props = HTMLAttributes<'div'> & { value: HTMLAttributes<'input'>['value'] label: string }[] + inputProps?: Omit, 'checked' | 'class' | 'name' | 'type' | 'value'> selectedValue?: string | null } -const { name, options, selectedValue, class: className, ...rest } = Astro.props +const { name, options, selectedValue, inputProps, class: className, ...rest } = Astro.props ---
{option.label} diff --git a/web/src/components/ServicesFilters.astro b/web/src/components/ServicesFilters.astro index 7830d27..e4d98ea 100644 --- a/web/src/components/ServicesFilters.astro +++ b/web/src/components/ServicesFilters.astro @@ -34,7 +34,8 @@ const {
verification.default) .map((verification) => verification.slug)} {...formProps} - class={cn('', className)} + 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', + className + )} >

FILTERS

@@ -64,6 +69,7 @@ const { name="sort" id="sort" class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden" + data-trigger-on-change > { options.sort.map((option) => ( @@ -108,6 +114,7 @@ const { name="categories" value={category.slug} checked={category.checked} + data-trigger-on-change /> {category.name} @@ -121,13 +128,7 @@ const { { options.categories.filter((category) => category.showAlways).length < options.categories.length && ( <> - +
@@ -188,6 +193,7 @@ const { name="currencies" value={currency.slug} checked={filters.currencies?.some((id) => id === currency.id)} + data-trigger-on-change /> {currency.name} @@ -210,6 +216,7 @@ const { name="networks" value={network.slug} checked={filters.networks?.some((slug) => slug === network.slug)} + data-trigger-on-change /> {network.name} @@ -233,6 +240,7 @@ const { id="max-kyc" value={filters['max-kyc'] ?? 4} class="w-full accent-green-500" + data-trigger-on-change />
@@ -261,6 +269,7 @@ const { id="user-rating" value={filters['user-rating']} class="w-full accent-green-500" + data-trigger-on-change />
@@ -289,6 +298,9 @@ const { options={options.modeOptions} selectedValue={filters['attribute-mode']} class="-my-2" + inputProps={{ + 'data-trigger-on-change': true, + }} />
{ @@ -318,6 +330,7 @@ const { value="" checked={!attribute.value} aria-label="Ignore" + data-trigger-on-change />
diff --git a/web/src/components/ServicesSearchResults.astro b/web/src/components/ServicesSearchResults.astro index e6a8e74..a1ef4e0 100644 --- a/web/src/components/ServicesSearchResults.astro +++ b/web/src/components/ServicesSearchResults.astro @@ -19,7 +19,7 @@ type Props = HTMLAttributes<'div'> & { pageSize: number sortSeed?: string filters: ServicesFiltersObject - hadToIncludeCommunityContributed: boolean + includeScams: boolean } const { @@ -31,14 +31,15 @@ const { sortSeed, class: className, filters, - hadToIncludeCommunityContributed, + includeScams, ...divProps } = Astro.props -const hasScams = filters.verification.includes('VERIFICATION_FAILED') -const hasCommunityContributed = +const hasScams = // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - filters.verification.includes('COMMUNITY_CONTRIBUTED') || hadToIncludeCommunityContributed + filters.verification.includes('VERIFICATION_FAILED') || includeScams + +const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED') const totalPages = Math.ceil(total / pageSize) || 1 --- @@ -66,7 +67,7 @@ const totalPages = Math.ceil(total / pageSize) || 1 Showing SCAM and unverified community-contributed services. - {hadToIncludeCommunityContributed && 'Because there were no other results.'} + {includeScams && 'Because there is a text query.'}
) } @@ -75,7 +76,7 @@ const totalPages = Math.ceil(total / pageSize) || 1 hasScams && !hasCommunityContributed && (
- Showing SCAM services! + {includeScams ? 'Showing SCAM services because there is a text query.' : 'Showing SCAM services!'}
) } @@ -84,10 +85,7 @@ const totalPages = Math.ceil(total / pageSize) || 1 !hasScams && hasCommunityContributed && (
- - {hadToIncludeCommunityContributed - ? 'Showing unverified community-contributed services, because there were no other results. Some might be scams.' - : 'Showing unverified community-contributed services, some might be scams.'} + Showing unverified community-contributed services, some might be scams.
) } diff --git a/web/src/pages/events.astro b/web/src/pages/events.astro index 7d08f07..536be68 100644 --- a/web/src/pages/events.astro +++ b/web/src/pages/events.astro @@ -169,7 +169,7 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => { '[&:has(~[data-has-default-filters="true"])_[data-clear-filters-button]]:hidden' )} hx-get={Astro.url.pathname} - hx-trigger="input from:input, keyup[key=='Enter'], change from:select" + hx-trigger="input from:find input, keyup[key=='Enter'], change from:find select" hx-target="#events-list-container" hx-select="#events-list-container" hx-swap="outerHTML" diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 161a743..78827a4 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -1,10 +1,11 @@ --- import { ServiceVisibility } from '@prisma/client' import { z } from 'astro:schema' -import { groupBy, orderBy } from 'lodash-es' +import { groupBy, omit, orderBy, uniq } from 'lodash-es' import seedrandom from 'seedrandom' import Button from '../components/Button.astro' +import InputText from '../components/InputText.astro' import Pagination from '../components/Pagination.astro' import ServiceFiltersPill from '../components/ServiceFiltersPill.astro' import ServicesFilters from '../components/ServicesFilters.astro' @@ -26,6 +27,7 @@ import { import BaseLayout from '../layouts/BaseLayout.astro' import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays' import { parseIntWithFallback } from '../lib/numbers' +import { areEqualObjectsWithoutOrder } from '../lib/objects' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { prisma } from '../lib/prisma' import { makeSortSeed } from '../lib/sortSeed' @@ -130,9 +132,12 @@ const attributeOptions = [ prefix: string }[] +const ignoredKeysForDefaultData = ['sort-seed'] + const { data: filters, hasDefaultData: hasDefaultFilters, + defaultData: defaultFilters, redirectUrl, } = zodParseQueryParamsStoringErrors( { @@ -164,7 +169,7 @@ const { }, Astro, { - ignoredKeysForDefaultData: ['sort-seed'], + ignoredKeysForDefaultData, cleanUrl: { removeUneededObjectParams: true, removeParams: { @@ -181,46 +186,63 @@ const { } ) +const hasDefaultFiltersIgnoringQ = areEqualObjectsWithoutOrder( + omit(filters, [...ignoredKeysForDefaultData, 'q']), + omit(defaultFilters, [...ignoredKeysForDefaultData, 'q']) +) + if (redirectUrl) return Astro.redirect(redirectUrl.toString()) +const includeScams = + !!filters.q && + (areEqualArraysWithoutOrder(filters.verification, ['VERIFICATION_SUCCESS', 'APPROVED']) || + areEqualArraysWithoutOrder(filters.verification, [ + 'VERIFICATION_SUCCESS', + 'APPROVED', + 'COMMUNITY_CONTRIBUTED', + ])) + export type ServicesFiltersObject = typeof filters -const [categories, [services, totalServices, hadToIncludeCommunityContributed]] = - await Astro.locals.banners.tryMany([ - [ - 'Unable to load category filters.', - () => - prisma.category.findMany({ - select: { - name: true, - slug: true, - icon: true, - _count: { - select: { - services: true, - }, +const [categories, [services, totalServices]] = await Astro.locals.banners.tryMany([ + [ + 'Unable to load category filters.', + () => + prisma.category.findMany({ + select: { + name: true, + slug: true, + icon: true, + _count: { + select: { + services: true, }, }, + }, + }), + ], + [ + 'Unable to load services.', + async () => { + const groupedAttributes = groupBy( + Object.entries(filters.attr ?? {}).flatMap(([key, value]) => { + const id = parseIntWithFallback(key) + if (id === null) return [] + return [{ id, value }] }), - ], - [ - 'Unable to load services.', - async () => { - const groupedAttributes = groupBy( - Object.entries(filters.attr ?? {}).flatMap(([key, value]) => { - const id = parseIntWithFallback(key) - if (id === null) return [] - return [{ id, value }] - }), - 'value' - ) - const where = { + 'value' + ) + + const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({ + where: { listedAt: { lte: new Date(), }, categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined, verificationStatus: { - in: filters.verification, + in: includeScams + ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) + : filters.verification, }, serviceVisibility: ServiceVisibility.PUBLIC, overallScore: { gte: filters['min-score'] }, @@ -308,108 +330,79 @@ const [categories, [services, totalServices, hadToIncludeCommunityContributed]] ] : []), ], - } as const satisfies Prisma.ServiceWhereInput - - const select = { + }, + select: { id: true, ...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record< (typeof sortOptions)[number]['orderBy']['key'], true >), - } as const satisfies Prisma.ServiceSelect + }, + }) - let [unsortedServices, totalServices] = await prisma.service.findManyAndCount({ - where, - select, - }) - let hadToIncludeCommunityContributed = false + const rng = seedrandom(filters['sort-seed']) + const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption - if ( - totalServices === 0 && - areEqualArraysWithoutOrder(where.verificationStatus.in, ['VERIFICATION_FAILED', 'APPROVED']) - ) { - const [unsortedServiceCommunityServices, totalCommunityServices] = - await prisma.service.findManyAndCount({ - where: { - ...where, - verificationStatus: { - ...where.verificationStatus, - in: [...where.verificationStatus.in, 'COMMUNITY_CONTRIBUTED'], - }, - }, - select, - }) + const sortedServices = orderBy( + unsortedServices, + [selectedSort.orderBy.key, () => rng()], + [selectedSort.orderBy.direction, 'asc'] + ).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE) - if (totalCommunityServices !== 0) { - hadToIncludeCommunityContributed = true - unsortedServices = unsortedServiceCommunityServices - totalServices = totalCommunityServices - } - } - - const rng = seedrandom(filters['sort-seed']) - const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption - - const sortedServices = orderBy( - unsortedServices, - [selectedSort.orderBy.key, () => rng()], - [selectedSort.orderBy.direction, 'asc'] - ).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE) - - const unsortedServicesWithInfo = await prisma.service.findMany({ - where: { - id: { - in: sortedServices.map((service) => service.id), - }, + const unsortedServicesWithInfo = await prisma.service.findMany({ + where: { + id: { + in: sortedServices.map((service) => service.id), }, - select: { - name: true, - slug: true, - description: true, - overallScore: true, - privacyScore: true, - trustScore: true, - kycLevel: true, - imageUrl: true, - verificationStatus: true, - acceptedCurrencies: true, - attributes: { - select: { - attribute: { - select: { - id: true, - slug: true, - title: true, - category: true, - type: true, - }, + }, + select: { + name: true, + slug: true, + description: true, + overallScore: true, + privacyScore: true, + trustScore: true, + kycLevel: true, + imageUrl: true, + verificationStatus: true, + acceptedCurrencies: true, + attributes: { + select: { + attribute: { + select: { + id: true, + slug: true, + title: true, + category: true, + type: true, }, }, }, - categories: { - select: { - name: true, - icon: true, - }, + }, + categories: { + select: { + name: true, + icon: true, }, }, - }) + }, + }) - const sortedServicesWithInfo = orderBy( - unsortedServicesWithInfo, - [ - selectedSort.orderBy.key, - // Now we can shuffle indeternimistically, because the pagination was already applied - () => Math.random(), - ], - [selectedSort.orderBy.direction, 'asc'] - ) + const sortedServicesWithInfo = orderBy( + unsortedServicesWithInfo, + [ + selectedSort.orderBy.key, + // Now we can shuffle indeternimistically, because the pagination was already applied + () => Math.random(), + ], + [selectedSort.orderBy.direction, 'asc'] + ) - return [sortedServicesWithInfo, totalServices, hadToIncludeCommunityContributed] as const - }, - [[] as [], 0, false] as const, - ], - ]) + return [sortedServicesWithInfo, totalServices] as const + }, + [[] as [], 0, false] as const, + ], +]) const attributes = await Astro.locals.banners.try( 'Unable to load attribute filters.', @@ -488,7 +481,8 @@ const filtersOptions = { export type ServicesFiltersOptions = typeof filtersOptions -// +const searchResultsId = 'search-results' +const showFiltersId = 'show-filters' --- { - !hasDefaultFilters ? ( + hasDefaultFilters || hasDefaultFiltersIgnoringQ ? ( + + + + ) : (
{filters.q && ( @@ -618,12 +638,19 @@ export type ServicesFiltersOptions = typeof filtersOptions ) })}
- ) : ( -
No filters
) } -
{ diff --git a/web/src/styles/global.css b/web/src/styles/global.css index d1575a2..e5422e5 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -93,15 +93,20 @@ --color-night-950: oklch(11.97% 0.004 145.32); } -@layer utilities { - .text-shadow-glow { - text-shadow: - 0 0 16px color-mix(in oklab, currentColor 30%, transparent), - 0 0 4px color-mix(in oklab, currentColor 60%, transparent); - } - .drop-shadow-glow { - filter: drop-shadow(0 0 16px color-mix(in oklab, currentColor 30%, transparent)) - drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent)); +@utility text-shadow-glow { + text-shadow: + 0 0 16px color-mix(in oklab, currentColor 30%, transparent), + 0 0 4px color-mix(in oklab, currentColor 60%, transparent); +} +@utility drop-shadow-glow { + filter: drop-shadow(0 0 16px color-mix(in oklab, currentColor 30%, transparent)) + drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent)); +} + +@utility checkbox-force-checked { + &:not(:checked) { + @apply border-transparent! bg-current/50!; + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e") !important; } }