diff --git a/web/package-lock.json b/web/package-lock.json index 38e9fcd..4ce3a65 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,6 +28,8 @@ "astro-seo-schema": "5.0.0", "canvas": "3.1.2", "clsx": "2.1.1", + "countries-list": "3.1.1", + "country-flag-icons": "1.5.19", "he": "1.2.0", "htmx.org": "2.0.6", "javascript-time-ago": "2.5.11", @@ -2680,19 +2682,32 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@faker-js/faker": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz", @@ -7884,6 +7899,18 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/countries-list": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/countries-list/-/countries-list-3.1.1.tgz", + "integrity": "sha512-nPklKJ5qtmY5MdBKw1NiBAoyx5Sa7p2yPpljZyQ7gyCN1m+eMFs9I6CT37Mxt8zvR5L3VzD3DJBE4WQzX3WF4A==", + "license": "MIT" + }, + "node_modules/country-flag-icons": { + "version": "1.5.19", + "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.19.tgz", + "integrity": "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g==", + "license": "MIT" + }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", @@ -10064,14 +10091,16 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { diff --git a/web/package.json b/web/package.json index 122a432..8f75abd 100644 --- a/web/package.json +++ b/web/package.json @@ -44,6 +44,8 @@ "astro-seo-schema": "5.0.0", "canvas": "3.1.2", "clsx": "2.1.1", + "countries-list": "3.1.1", + "country-flag-icons": "1.5.19", "he": "1.2.0", "htmx.org": "2.0.6", "javascript-time-ago": "2.5.11", diff --git a/web/prisma/migrations/20250723070746_add_registered_country/migration.sql b/web/prisma/migrations/20250723070746_add_registered_country/migration.sql new file mode 100644 index 0000000..fcc03a4 --- /dev/null +++ b/web/prisma/migrations/20250723070746_add_registered_country/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Service" ADD COLUMN "registrationCountryCode" VARCHAR(2); diff --git a/web/prisma/migrations/20250723073720_add_company_name_field/migration.sql b/web/prisma/migrations/20250723073720_add_company_name_field/migration.sql new file mode 100644 index 0000000..1609242 --- /dev/null +++ b/web/prisma/migrations/20250723073720_add_company_name_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Service" ADD COLUMN "registeredCompanyName" TEXT; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 6886c46..3be77ac 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -345,60 +345,64 @@ model ServiceSuggestionMessage { } 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) + 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) /// Date only, no time. - operatingSince DateTime? @db.Date - overallScore Int @default(0) - privacyScore Int @default(0) - trustScore Int @default(0) + operatingSince DateTime? @db.Date + overallScore Int @default(0) + privacyScore Int @default(0) + trustScore Int @default(0) /// Computed via trigger. Do not update through prisma. - averageUserRating Float? - serviceVisibility ServiceVisibility @default(PUBLIC) - serviceInfoBanner ServiceInfoBanner @default(NONE) - serviceInfoBannerNotes String? - verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED) - verificationSummary String? - verificationRequests ServiceVerificationRequest[] - verificationProofMd String? + averageUserRating Float? + serviceVisibility ServiceVisibility @default(PUBLIC) + serviceInfoBanner ServiceInfoBanner @default(NONE) + serviceInfoBannerNotes String? + verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED) + verificationSummary String? + verificationRequests ServiceVerificationRequest[] + verificationProofMd String? /// [UserSentiment] - userSentiment Json? - userSentimentAt DateTime? - referral String? - acceptedCurrencies Currency[] @default([]) - serviceUrls String[] - tosUrls String[] @default([]) - onionUrls String[] @default([]) - i2pUrls String[] @default([]) - imageUrl String? + userSentiment Json? + userSentimentAt DateTime? + referral String? + acceptedCurrencies Currency[] @default([]) + serviceUrls String[] + tosUrls String[] @default([]) + onionUrls String[] @default([]) + i2pUrls String[] @default([]) + imageUrl String? + /// ISO 3166-1 alpha-2 country code where the service company is registered + registrationCountryCode String? @db.VarChar(2) + /// Official name of the registered company + registeredCompanyName String? /// [TosReview] - tosReview Json? - tosReviewAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + tosReview Json? + tosReviewAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt /// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma. - listedAt DateTime? + listedAt DateTime? /// Computed via trigger when the verification status is APPROVED. Do not update through prisma. - approvedAt DateTime? + approvedAt DateTime? /// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma. - verifiedAt DateTime? + verifiedAt DateTime? /// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma. - spamAt DateTime? + spamAt DateTime? /// Computed via trigger. Do not update through prisma. - isRecentlyApproved Boolean @default(false) - comments Comment[] - events Event[] - contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod") - attributes ServiceAttribute[] - verificationSteps VerificationStep[] - suggestions ServiceSuggestion[] - internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes") + isRecentlyApproved Boolean @default(false) + comments Comment[] + events Event[] + contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod") + attributes ServiceAttribute[] + verificationSteps VerificationStep[] + suggestions ServiceSuggestion[] + internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes") onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices") onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices") diff --git a/web/prisma/seed.ts b/web/prisma/seed.ts index 6594344..453bf3a 100755 --- a/web/prisma/seed.ts +++ b/web/prisma/seed.ts @@ -28,6 +28,7 @@ import { generateUsername } from 'unique-username-generator' import { kycLevels } from '../src/constants/kycLevels' import { undefinedIfEmpty } from '../src/lib/arrays' import { transformCase } from '../src/lib/strings' +import { countries } from '../src/constants/countries' // Exit if not in development mode if (process.env.NODE_ENV === 'production') { @@ -697,6 +698,12 @@ const generateFakeService = (users: User[]) => { { count: { min: 0, max: 2 } } ), imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`, + registrationCountryCode: faker.helpers.maybe(() => faker.helpers.arrayElement(countries).code, { + probability: 0.7, + }), + registeredCompanyName: faker.helpers.maybe(() => faker.company.name(), { + probability: 0.6, + }), listedAt: serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED' ? faker.date.recent({ days: 30 }) diff --git a/web/prisma/triggers/02_service_score.sql b/web/prisma/triggers/02_service_score.sql index e47e653..bd97f88 100644 --- a/web/prisma/triggers/02_service_score.sql +++ b/web/prisma/triggers/02_service_score.sql @@ -96,6 +96,7 @@ DECLARE recently_approved_factor INT := 0; tos_penalty_factor INT := 0; operating_since_factor INT := 0; + legally_registered_factor INT := 0; BEGIN -- Get verification status factor SELECT @@ -160,9 +161,17 @@ BEGIN INTO operating_since_factor FROM "Service" WHERE id = service_id; + + -- Check for legal registration (country code or company name) + IF EXISTS ( + SELECT 1 FROM "Service" + WHERE id = service_id AND ("registrationCountryCode" IS NOT NULL OR "registeredCompanyName" IS NOT NULL) + ) THEN + legally_registered_factor := 2; + END IF; -- Calculate final trust score (base 100) - trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor; + trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor + legally_registered_factor; -- Ensure the score is in reasonable bounds (0-100) trust_score := GREATEST(0, LEAST(100, trust_score)); diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts index 5d60166..3e0b2af 100644 --- a/web/src/actions/admin/service.ts +++ b/web/src/actions/admin/service.ts @@ -4,6 +4,7 @@ import { ActionError } from 'astro:actions' import { uniq } from 'lodash-es' import slugify from 'slugify' +import { countriesZodEnumById } from '../../constants/countries' import { defineProtectedAction } from '../../lib/defineProtectedAction' import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage' import { prisma } from '../../lib/prisma' @@ -59,6 +60,14 @@ const serviceSchemaBase = z.object({ .nullable() .default(null), operatingSince: z.coerce.date().optional().nullable(), + registrationCountryCode: z + .union([countriesZodEnumById, z.literal('')]) + .optional() + .nullable() + .refine((val) => val === null || val === undefined || val === '' || val.length === 2, { + message: 'Country code must be a valid 2-character code or empty', + }), + registeredCompanyName: z.string().trim().max(100).optional().nullable(), imageFile: imageFileSchema, overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), serviceVisibility: z.nativeEnum(ServiceVisibility), @@ -283,6 +292,8 @@ export const adminServiceActions = { })), }, operatingSince: input.operatingSince, + registrationCountryCode: input.registrationCountryCode ?? null, + registeredCompanyName: input.registeredCompanyName, }, }) diff --git a/web/src/actions/serviceSuggestion.ts b/web/src/actions/serviceSuggestion.ts index 643c030..079854b 100644 --- a/web/src/actions/serviceSuggestion.ts +++ b/web/src/actions/serviceSuggestion.ts @@ -3,6 +3,7 @@ import { z } from 'astro/zod' import { ActionError } from 'astro:actions' import { formatDistanceStrict } from 'date-fns' +import { countriesZodEnumById } from '../constants/countries' import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation' import { defineProtectedAction } from '../lib/defineProtectedAction' import { saveFileLocally } from '../lib/fileStorage' @@ -184,6 +185,14 @@ export const serviceSuggestionActions = { }), }), operatingSince: z.coerce.date().optional(), + registrationCountryCode: z + .union([countriesZodEnumById, z.literal('')]) + .optional() + .nullable() + .refine((val) => val === null || val === undefined || val === '' || val.length === 2, { + message: 'Country code must be a valid 2-character code or empty', + }), + registeredCompanyName: z.string().trim().max(100).optional(), /** @deprecated Honey pot field, do not use */ message: z.unknown().optional(), skipDuplicateCheck: z @@ -282,6 +291,8 @@ export const serviceSuggestionActions = { slug: input.slug, description: input.description, operatingSince: input.operatingSince, + registrationCountryCode: input.registrationCountryCode ?? null, + registeredCompanyName: input.registeredCompanyName, serviceUrls, tosUrls: input.tosUrls, onionUrls, diff --git a/web/src/constants/countries.ts b/web/src/constants/countries.ts new file mode 100644 index 0000000..fc43673 --- /dev/null +++ b/web/src/constants/countries.ts @@ -0,0 +1,76 @@ +import { countries as countriesData, type TCountryCode } from 'countries-list' +import { countries as flagCountries } from 'country-flag-icons' +import getUnicodeFlagIcon from 'country-flag-icons/unicode' + +import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' + +type CountryInfo = { + code: T + name: string + flag: string + slug: string + order: number +} + +// Convert countries-list data to our format, ensuring we only use countries that have flags +const countriesArray = Object.entries(countriesData) + .filter(([code]) => flagCountries.includes(code as TCountryCode)) + .map(([code, data]) => ({ + code: code as TCountryCode, + name: data.name, + flag: getUnicodeFlagIcon(code) || '🏳️', + slug: code.toLowerCase(), + order: data.name.charCodeAt(0), // Sort alphabetically by first letter + })) + // Pre-sort the array alphabetically by name for performance + .sort((a, b) => a.name.localeCompare(b.name)) + +// Create a map for efficient lookups +const countriesMap = new Map(countriesArray.map((country) => [country.code, country])) + +export const { + dataArray: countries, + dataObject: countriesById, + getFn: getCountryInfo, + getFnSlug: getCountryInfoBySlug, + zodEnumBySlug: countriesZodEnumBySlug, + zodEnumById: countriesZodEnumById, + keyToSlug: countryCodeToSlug, + slugToKey: countrySlugToCode, +} = makeHelpersForOptions( + 'code', + (code): CountryInfo => { + // For null, undefined, or empty string, return a default "No Country" object + if (!code) { + return { + code, + name: 'No Country', + flag: '🏳️', + slug: '', + order: 999, + } as CountryInfo + } + + // Try to find the country in our pre-built map + const country = countriesMap.get(code as TCountryCode) + + // If found, return it; otherwise, return a default "Unknown Country" object + if (country) { + return country as CountryInfo + } else { + return { + code, + name: 'Unknown Country', + flag: '🏳️', + slug: code.toLowerCase(), + order: 999, + } as CountryInfo + } + }, + countriesArray +) + +// Helper function to validate country code +export const isValidCountryCode = (code: string): code is TCountryCode => { + return code in countriesData && flagCountries.includes(code as TCountryCode) +} diff --git a/web/src/lib/attributes.ts b/web/src/lib/attributes.ts index 0fa83d7..59d03aa 100644 --- a/web/src/lib/attributes.ts +++ b/web/src/lib/attributes.ts @@ -1,8 +1,10 @@ import { differenceInMonths, differenceInYears } from 'date-fns' +import he from 'he' import { orderBy } from 'lodash-es' import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeTypeInfo } from '../constants/attributeTypes' +import { getCountryInfo } from '../constants/countries' import { kycLevelClarifications } from '../constants/kycLevelClarifications' import { kycLevels } from '../constants/kycLevels' import { serviceVisibilitiesById } from '../constants/serviceVisibility' @@ -47,6 +49,8 @@ type NonDbAttributeFull = NonDbAttribute & { kycLevel: true kycLevelClarification: true operatingSince: true + registrationCountryCode: true + registeredCompanyName: true } }> ) => Partial> & { @@ -306,6 +310,42 @@ export const nonDbAttributes: NonDbAttributeFull[] = [ } }, }, + { + slug: 'legally-registered', + title: 'Legally registered', + type: 'INFO', + category: 'TRUST', + description: 'This service is legally registered as a company.', + privacyPoints: 0, + trustPoints: 2, + links: [], + customize: (service) => { + const countryCode = service.registrationCountryCode + const companyName = service.registeredCompanyName + + if (!countryCode && !companyName) { + return { show: false } + } + + const countryInfo = countryCode ? getCountryInfo(countryCode) : null + const flagTitle = countryInfo ? `${countryInfo.flag} Legally registered` : 'Legally registered' + + let description = 'Legally registered.' + if (companyName && countryCode && countryInfo) { + description = `Legally registered as **${he.escape(companyName)}** in **${countryInfo.name}**.` + } else if (companyName) { + description = `Legally registered as **${he.escape(companyName)}**.` + } else if (countryCode && countryInfo) { + description = `Legally registered in **${countryInfo.name}**.` + } + + return { + show: true, + title: flagTitle, + description, + } + }, + }, ] export function sortAttributes< diff --git a/web/src/pages/admin/services/[slug]/edit.astro b/web/src/pages/admin/services/[slug]/edit.astro index 5039e42..8dacc8a 100644 --- a/web/src/pages/admin/services/[slug]/edit.astro +++ b/web/src/pages/admin/services/[slug]/edit.astro @@ -25,6 +25,7 @@ import UserBadge from '../../../../components/UserBadge.astro' import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories' import { getAttributeTypeInfo } from '../../../../constants/attributeTypes' import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods' +import { countries } from '../../../../constants/countries' import { currencies } from '../../../../constants/currencies' import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes' import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications' @@ -386,6 +387,36 @@ const apiCalls = await Astro.locals.banners.try( /> +
+ + + a.name.localeCompare(b.name)) + .map((country) => ({ + label: `${country.flag} ${country.name}`, + value: country.code, + })) + ]} + selectedValue={service.registrationCountryCode || ''} + error={serviceInputErrors.registrationCountryCode} + /> +
+
+
+ + + a.name.localeCompare(b.name)) + .map((country) => ({ + label: `${country.flag} ${country.name}`, + value: country.code, + })) + ]} + selectedValue="" + /> +
+