diff --git a/.env.docker.example b/.env.docker.example
index 70841edd0..9223417d4 100644
--- a/.env.docker.example
+++ b/.env.docker.example
@@ -35,12 +35,11 @@ THE_GAMES_DB_API_KEY="your_tgdb_api_key_here"
TUNNEL_TOKEN="your_cloudflare_tunnel_token_here"
# ===========================================
-# RECAPTCHA (Optional)
+# CLOUDFLARE TURNSTILE (Optional)
# ===========================================
-# Google reCAPTCHA for form protection
-NEXT_PUBLIC_RECAPTCHA_SITE_KEY="your_recaptcha_site_key_here"
-RECAPTCHA_SECRET_KEY="your_recaptcha_secret_key_here"
-NEXT_PUBLIC_DISABLE_RECAPTCHA="false"
+# Human verification
+NEXT_PUBLIC_TURNSTILE_SITE_KEY="your_turnstile_site_key_here"
+TURNSTILE_SECRET_KEY="your_turnstile_secret_key_here"
# ===========================================
# EMAIL SERVICES (Optional)
diff --git a/.env.example b/.env.example
index 5efdaeff9..592c1934d 100644
--- a/.env.example
+++ b/.env.example
@@ -16,10 +16,9 @@ NEXT_PUBLIC_THE_GAMES_DB_API_KEY="The-Games-DB-Public-API-KEY"
NEXT_PUBLIC_IGDB_CLIENT_ID="IGDB-Client-ID"
IGDB_CLIENT_KEY="IGDB-Client-Secret"
-# reCAPTCHA v3
-NEXT_PUBLIC_RECAPTCHA_SITE_KEY=site-key-here
-RECAPTCHA_SECRET_KEY=secret-key-here
-NEXT_PUBLIC_DISABLE_RECAPTCHA=false
+# Cloudflare Turnstile
+NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4XXXXXXXXXXXXXXXXXXXXX
+TURNSTILE_SECRET_KEY=0x4XXXXXXXXXXXXXXXXXXXXXXXX-XXXXXXX
# Email Providers (For the future)
EMAIL_ENABLED=false
diff --git a/.env.test.example b/.env.test.example
index ff39873e6..8f23094b8 100644
--- a/.env.test.example
+++ b/.env.test.example
@@ -6,10 +6,9 @@ DATABASE_DIRECT_URL="postgres://postgres:pooler.supabase.com:5432/postgres"
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
-# reCAPTCHA V3
-NEXT_PUBLIC_RECAPTCHA_SITE_KEY=
-RECAPTCHA_SECRET_KEY=
-NEXT_PUBLIC_DISABLE_RECAPTCHA=false
+# Cloudflare Turnstile
+NEXT_PUBLIC_TURNSTILE_SITE_KEY=
+TURNSTILE_SECRET_KEY=
# Email (For the future)
EMAIL_ENABLED=false
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..e0c538a40
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,123 @@
+# Project Guide
+
+This file is the source of working guidance for AI coding agents in this repository.
+
+## Commands
+
+- Use `pnpm`, not `npm` or `npx`.
+- Common checks:
+ - `pnpm lint`
+ - `pnpm types`
+ - `pnpm test`
+ - `pnpm check`
+- Prisma:
+ - `pnpm db:generate` generates Prisma Client and TypedSQL.
+ - `pnpm prisma validate` validates the schema.
+ - Database-backed Prisma commands must use the project scripts that wrap `scripts/db-cmd.sh` when available.
+- Do not run `pnpm dev`, `pnpm build`, `pnpm start`, `pnpm run deploy`, migrations, seeds, or data scripts unless the user explicitly asks.
+- Never deploy, commit, or push unless the user explicitly asks.
+- Use the current git user as commit author; never add Codex/AI authorship or AI-themed branch names.
+
+## Domain Rules
+
+- A Listing is a handheld compatibility report: game plus handheld device plus emulator.
+- A PC Listing is a PC compatibility report: game plus PC hardware plus emulator.
+- User-facing UI should say "Compatibility Report", "Handheld Report", "PC Report", or "PC Compatibility Report", not "listing".
+- Listings and PC Listings must stay behaviorally aligned for voting, comments, moderation, trust effects, notifications, and approval flows.
+- Shared cross-listing behavior belongs in utilities instead of duplicated implementations.
+
+## Architecture
+
+- Feature folders should follow the project-structure guidance from Bulletproof React:
+ https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md
+- Within `src/features/*`, prefer scoped subdirectories such as `components`, `hooks`, `utils`, `server`, and `shared` instead of flat feature folders.
+- Routers in `src/server/api/routers/` are thin orchestration layers. They handle auth context, schema-validated input, repository/service calls, and response formatting.
+- Do not put raw Prisma queries or business logic in routers.
+- Define Zod schemas in `src/schemas/*`; do not define inline schemas in router `.input(...)` calls.
+- All database access belongs in repository classes under `src/server/repositories/` extending `BaseRepository`.
+- Repositories should use project error helpers and consistent database operation handling.
+- Multi-step business logic, external API orchestration, and complex calculations belong in services under `src/server/services/`.
+- Use `AppError` and `ResourceError` helpers instead of raw `Error`, raw strings, or one-off `TRPCError` usage.
+- Use specialized procedures such as `protectedProcedure`, `adminProcedure`, and `permissionProcedure(...)` instead of ad hoc permission checks.
+
+## Database And Prisma
+
+- Treat database changes as high risk.
+- Do not run migrations, seeds, `db:push`, or data scripts without explicit user approval.
+- Prisma migration commands may only target the local database configured by `.env.local`, never a hosted or Supabase database, unless the user explicitly instructs otherwise.
+- Do not create or edit migration SQL manually. Use Prisma migration commands.
+- Do not edit an existing migration after it has been created. Create a new migration when a schema change is required.
+- Prefer `migrate deploy` for applying migrations. Never use `db:push` outside disposable local development.
+- Prisma Client is generated to `prisma/generated/client`; app imports use `@orm`, `@orm/client`, and `@orm/sql`.
+- Use `pnpm prisma ...` or project `pnpm db:*` scripts, not `npx prisma`.
+
+## TypeScript And Code Quality
+
+- Do not use `any` or `z.any()`. Use concrete types, discriminated unions, or `unknown` with narrowing.
+- Do not use `@ts-ignore`, `@ts-expect-error`, or `eslint-disable` comments.
+- Do not use casts to hide type problems. Fix the underlying type issue.
+- Handle null and undefined explicitly.
+- Use generated Prisma types where appropriate.
+- Do not add unused functions, exports, or speculative helpers.
+- Remove dead code when refactoring.
+- Prefer function declarations for top-level functions/components.
+- Component props interfaces should be named `Props`.
+- Do not destructure component props in function parameters; use `props.foo`.
+- Keep `useEffect` dependencies correct.
+- Comments should be rare, factual, and explain only non-obvious external constraints or business invariants.
+
+## Enums
+
+- Import enum values from `@orm`; do not use string literals for enum values in application code.
+- This applies to UI state, filters, schemas, comparisons, routers, services, and repositories.
+- Prefer `z.nativeEnum(SomeEnum)` over hard-coded `z.enum([...])` when a Prisma enum exists.
+- Tests may use string literals only when mocking requires it.
+
+## UI
+
+- Before adding custom UI markup, check existing components in `src/components/ui/`.
+- Prefer extending shared UI components over duplicating badge, button, modal, table, loading, card, or form markup.
+- Use `Button`, `Badge`, `Card`, table utilities, form components, `LoadingSpinner`, and dialog components from the shared UI library where applicable.
+- Never use `window.confirm()`. Use `useConfirmDialog` from `@/components/ui`.
+- Keep admin pages consistent: table controls, search/filtering, pagination, statistics, and bulk actions should follow existing admin patterns.
+
+## Filters
+
+- Controllers own filter behavior: interactions, analytics, collapsed badges, active summaries, and calls into presentational content.
+- Content components should only render fields and call handlers passed by controllers.
+- URL/state hooks own URL sync and local UI state; they must not emit per-filter analytics.
+- Filter analytics should be emitted once from controllers, using `filterAnalytics` and `selectedLabels`.
+- Call `onChange` before emitting analytics.
+- Use shared filter UI pieces: `FilterSidebarShell`, `CollapsedBadges`, `ActiveFiltersSummary`, and `MobileFilterSheet`.
+- Use shared option mappers from `src/utils/options.ts`.
+
+## Async Multi-Selects
+
+- Use `src/components/ui/form/async-multi-select/AsyncMultiSelect.tsx` as the base.
+- Entity wrappers such as CPU, GPU, Device, and SoC selects should stay thin: call TRPC, map to `Option[]`, manage pagination state, and pass data to the base component.
+- Selected chips must persist by deriving them from `options` plus `selectedByIds`.
+
+## Security
+
+- For write operations, never trust a user ID supplied by input when ownership matters. Use `ctx.session.user.id`.
+- Pass `requestingUserRole` when admin override behavior is supported.
+- Reads may accept user IDs for filtering, but writes must validate ownership or permission.
+- Use transactions for multi-step writes that must stay consistent.
+- Validate input at API boundaries with Zod schemas.
+
+## Verification
+
+- Run the smallest relevant checks first, then broader checks when risk warrants it.
+- Before claiming a fix is complete, verify the actual failing behavior when possible.
+- For API changes, exercise the endpoint or generated contract.
+- For UI changes, run the relevant UI/test path.
+- For Prisma changes, run schema validation and generation. Only run migration commands with explicit approval.
+- If a check cannot be run, report the exact blocker.
+
+
+
+# Next.js: ALWAYS read docs before coding
+
+Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth.
+
+
diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md
index a2d2f52ae..34f096c90 100644
--- a/docs/DEVELOPMENT_SETUP.md
+++ b/docs/DEVELOPMENT_SETUP.md
@@ -36,7 +36,7 @@ CLERK_SECRET_KEY=...
NEXT_PUBLIC_APP_URL=http://localhost:3000
```
-External provider keys such as `RAWG_API_KEY`, `THE_GAMES_DB_API_KEY`, and reCAPTCHA keys are only needed for the features that call those services.
+External provider keys such as `RAWG_API_KEY`, `THE_GAMES_DB_API_KEY`, and Cloudflare Turnstile keys are only needed for the features that call those services.
## Troubleshooting
diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md
index 6e8591f48..57e751106 100644
--- a/docs/MOBILE_API.md
+++ b/docs/MOBILE_API.md
@@ -1,6 +1,6 @@
# EmuReady Mobile API (tRPC)
-*Auto-generated on: 2026-02-17T20:21:36.450Z*
+*Auto-generated on: 2026-05-25T13:13:26.417Z*
## Summary
- **Total Endpoints**: 112
@@ -710,7 +710,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 27. **get**
- **Method**: GET
- **Path**: `/preferences.get`
-- **Description**: Get user preferences
+- **Description**: get - preferences
- **Tags**: preferences
- **Authentication**: Bearer token required
@@ -718,7 +718,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 28. **update**
- **Method**: POST
- **Path**: `/preferences.update`
-- **Description**: Update user preferences
+- **Description**: update - preferences
- **Tags**: preferences
- **Request Body**: JSON object required
- **Content-Type**: application/json
@@ -727,7 +727,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 29. **addDevice**
- **Method**: POST
- **Path**: `/preferences.addDevice`
-- **Description**: Add device preference
+- **Description**: addDevice - preferences
- **Tags**: preferences
- **Request Body**: JSON object required
- **Content-Type**: application/json
@@ -736,7 +736,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 30. **removeDevice**
- **Method**: POST
- **Path**: `/preferences.removeDevice`
-- **Description**: Remove device preference
+- **Description**: removeDevice - preferences
- **Tags**: preferences
- **Request Body**: JSON object required
- **Content-Type**: application/json
@@ -745,7 +745,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 31. **bulkUpdateDevices**
- **Method**: POST
- **Path**: `/preferences.bulkUpdateDevices`
-- **Description**: Bulk update device preferences
+- **Description**: bulkUpdateDevices - preferences
- **Tags**: preferences
- **Request Body**: JSON object required
- **Content-Type**: application/json
@@ -754,7 +754,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 32. **bulkUpdateSocs**
- **Method**: POST
- **Path**: `/preferences.bulkUpdateSocs`
-- **Description**: Bulk update SOC preferences
+- **Description**: bulkUpdateSocs - preferences
- **Tags**: preferences
- **Request Body**: JSON object required
- **Content-Type**: application/json
@@ -763,7 +763,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 33. **currentProfile**
- **Method**: GET
- **Path**: `/preferences.currentProfile`
-- **Description**: Get current user's profile
+- **Description**: currentProfile - preferences
- **Tags**: preferences
- **Authentication**: Bearer token required
@@ -771,7 +771,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 34. **profile**
- **Method**: GET
- **Path**: `/preferences.profile`
-- **Description**: Get user profile by ID
+- **Description**: profile - preferences
- **Tags**: preferences
- **Authentication**: Bearer token required
@@ -779,7 +779,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT.
#### 35. **updateProfile**
- **Method**: POST
- **Path**: `/preferences.updateProfile`
-- **Description**: Update profile
+- **Description**: updateProfile - preferences
- **Tags**: preferences
- **Request Body**: JSON object required
- **Content-Type**: application/json
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 235e19c2f..64b1f9ba1 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,9 +1,24 @@
+import { existsSync, readdirSync } from 'node:fs'
+
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import eslintConfigPrettier from 'eslint-config-prettier'
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'
import nextTypeScript from 'eslint-config-next/typescript'
+const featureNames = existsSync('./src/features')
+ ? readdirSync('./src/features', { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ : []
+
+const featureBoundaryZones = featureNames.map((featureName) => ({
+ target: `./src/features/${featureName}`,
+ from: './src/features',
+ except: [`./${featureName}`],
+ message: 'Features must not import from other features. Compose features at the route layer.',
+}))
+
const eslintConfig = [
{
ignores: [
@@ -137,6 +152,7 @@ const eslintConfig = [
tsx: 'never',
},
],
+ 'import/no-restricted-paths': ['error', { zones: featureBoundaryZones }],
},
}, // UI component import cycle override
{
diff --git a/next.config.ts b/next.config.ts
index 9dff5afa0..c043f273e 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -5,18 +5,6 @@ import type { Configuration as WebpackConfiguration } from 'webpack'
const isVercelBuild = process.env.VERCEL === '1'
-const recaptchaScriptSources = [
- 'https://www.google.com/recaptcha/',
- 'https://www.gstatic.com/recaptcha/',
-]
-
-const recaptchaConnectSources = ['https://www.google.com/recaptcha/']
-
-const recaptchaFrameSources = [
- 'https://www.google.com/recaptcha/',
- 'https://recaptcha.google.com/recaptcha/',
-]
-
const contentSecurityPolicyDirectives = [
{
name: 'default-src',
@@ -40,7 +28,6 @@ const contentSecurityPolicyDirectives = [
'https://storage.ko-fi.com',
'https://ko-fi.com',
'https://unpkg.com',
- ...recaptchaScriptSources,
],
},
{
@@ -117,7 +104,6 @@ const contentSecurityPolicyDirectives = [
'https://*.r2.cloudflarestorage.com',
'https://cdn.emuready.com',
'https://retrocatalog.com',
- ...recaptchaConnectSources,
],
},
{
@@ -132,7 +118,6 @@ const contentSecurityPolicyDirectives = [
'https://vercel.live',
'https://*.vercel.live',
'https://ko-fi.com',
- ...recaptchaFrameSources,
],
},
{
diff --git a/package.json b/package.json
index 3005b00cb..c6b2216e6 100644
--- a/package.json
+++ b/package.json
@@ -107,7 +107,6 @@
"react": "19.2.6",
"react-dom": "19.2.6",
"react-error-boundary": "^6.0.0",
- "react-google-recaptcha-v3": "^1.11.0",
"react-hook-form": "^7.56.4",
"react-syntax-highlighter": "^15.6.6",
"remeda": "^2.22.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f77ee367..079b3450a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -173,9 +173,6 @@ importers:
react-error-boundary:
specifier: ^6.0.0
version: 6.0.0(react@19.2.6)
- react-google-recaptcha-v3:
- specifier: ^1.11.0
- version: 1.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-hook-form:
specifier: ^7.56.4
version: 7.58.1(react@19.2.6)
@@ -5518,9 +5515,6 @@ packages:
highlightjs-vue@1.0.0:
resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
- hoist-non-react-statics@3.3.2:
- resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
-
hono@4.12.21:
resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==}
engines: {node: '>=16.9.0'}
@@ -6733,12 +6727,6 @@ packages:
peerDependencies:
react: '>=16.13.1'
- react-google-recaptcha-v3@1.11.0:
- resolution: {integrity: sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==}
- peerDependencies:
- react: ^16.3 || ^17.0 || ^18.0 || ^19.0
- react-dom: ^17.0 || ^18.0 || ^19.0
-
react-hook-form@7.58.1:
resolution: {integrity: sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==}
engines: {node: '>=18.0.0'}
@@ -14195,10 +14183,6 @@ snapshots:
highlightjs-vue@1.0.0: {}
- hoist-non-react-statics@3.3.2:
- dependencies:
- react-is: 16.13.1
-
hono@4.12.21: {}
hookable@5.5.3: {}
@@ -15551,12 +15535,6 @@ snapshots:
'@babel/runtime': 7.27.6
react: 19.2.6
- react-google-recaptcha-v3@1.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
- dependencies:
- hoist-non-react-statics: 3.3.2
- react: 19.2.6
- react-dom: 19.2.6(react@19.2.6)
-
react-hook-form@7.58.1(react@19.2.6):
dependencies:
react: 19.2.6
diff --git a/public/api-docs/mobile-openapi.json b/public/api-docs/mobile-openapi.json
index c1e3d9356..397c14f31 100644
--- a/public/api-docs/mobile-openapi.json
+++ b/public/api-docs/mobile-openapi.json
@@ -481,9 +481,16 @@
"not": {}
},
{
- "type": "object",
- "properties": {},
- "additionalProperties": false
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
}
]
},
@@ -714,162 +721,53 @@
"type": "number"
},
"notes": {
- "type": "string"
+ "anyOf": [
+ {
+ "type": "string",
+ "maxLength": 5000
+ },
+ {
+ "type": "null"
+ }
+ ]
},
"customFieldValues": {
- "type": "array",
- "items": {
- "anyOf": [
- {
- "anyOf": [
- {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "TEXT"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": "string"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "customFieldDefinitionId": {
+ "type": "string",
+ "format": "uuid"
},
- {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "TEXTAREA"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
+ "value": {
+ "anyOf": [
+ {
"type": "string"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
- },
- {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "URL"
},
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": "string",
- "format": "uri"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
- },
- {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "BOOLEAN"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
+ {
+ "type": "number"
},
- "value": {
+ {
"type": "boolean"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
- },
- {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "SELECT"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
},
- "value": {
- "type": "string"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
- },
- {
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "RANGE"
+ {
+ "type": "null"
},
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/CreateListingSchema/properties/customFieldValues/anyOf/0/items/properties/value"
+ }
},
- "value": {
- "type": "number"
+ {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/CreateListingSchema/properties/customFieldValues/anyOf/0/items/properties/value"
+ }
}
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
- }
- ]
- },
- {
- "type": "object",
- "properties": {
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": [
- "string",
- "number",
- "boolean",
- "null"
]
}
},
@@ -879,8 +777,11 @@
],
"additionalProperties": false
}
- ]
- }
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
@@ -1065,17 +966,36 @@
"format": "uuid"
},
"value": {
- "type": [
- "string",
- "number",
- "boolean",
- "null"
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/UpdateListingSchema/properties/customFieldValues/items/anyOf/1/properties/value"
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/UpdateListingSchema/properties/customFieldValues/items/anyOf/1/properties/value"
+ }
+ }
]
}
},
"required": [
- "customFieldDefinitionId",
- "value"
+ "customFieldDefinitionId"
],
"additionalProperties": false
}
@@ -1441,7 +1361,8 @@
"type": "number"
},
"memorySize": {
- "type": "number",
+ "type": "integer",
+ "exclusiveMinimum": 0,
"minimum": 1,
"maximum": 256
},
@@ -1460,179 +1381,57 @@
"minLength": 1
},
"notes": {
- "type": "string"
+ "type": "string",
+ "maxLength": 5000
},
"customFieldValues": {
"type": "array",
"items": {
- "anyOf": [
- {
+ "type": "object",
+ "properties": {
+ "customFieldDefinitionId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "value": {
"anyOf": [
{
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "TEXT"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": "string"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
+ "type": "string"
},
{
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "TEXTAREA"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": "string"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
+ "type": "number"
},
{
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "URL"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": "string",
- "format": "uri"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
+ "type": "boolean"
},
{
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "BOOLEAN"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": "boolean"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
+ "type": "null"
},
{
- "type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "SELECT"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": "string"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/CreatePcListingSchema/properties/customFieldValues/items/properties/value"
+ }
},
{
"type": "object",
- "properties": {
- "type": {
- "type": "string",
- "const": "RANGE"
- },
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": "number"
- }
- },
- "required": [
- "type",
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
+ "additionalProperties": {
+ "$ref": "#/definitions/CreatePcListingSchema/properties/customFieldValues/items/properties/value"
+ }
}
]
- },
- {
- "type": "object",
- "properties": {
- "customFieldDefinitionId": {
- "type": "string",
- "format": "uuid"
- },
- "value": {
- "type": [
- "string",
- "number",
- "boolean",
- "null"
- ]
- }
- },
- "required": [
- "customFieldDefinitionId",
- "value"
- ],
- "additionalProperties": false
}
- ]
+ },
+ "required": [
+ "customFieldDefinitionId"
+ ],
+ "additionalProperties": false
}
}
},
"required": [
"gameId",
"cpuId",
- "gpuId",
"emulatorId",
"performanceId",
"memorySize",
@@ -1838,17 +1637,36 @@
"format": "uuid"
},
"value": {
- "type": [
- "string",
- "number",
- "boolean",
- "null"
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/UpdatePcListingSchema/properties/customFieldValues/items/anyOf/1/properties/value"
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/UpdatePcListingSchema/properties/customFieldValues/items/anyOf/1/properties/value"
+ }
+ }
]
}
},
"required": [
- "customFieldDefinitionId",
- "value"
+ "customFieldDefinitionId"
],
"additionalProperties": false
}
@@ -11349,7 +11167,6 @@
"example": {
"gameId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"cpuId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
- "gpuId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"emulatorId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"performanceId": 1,
"memorySize": 1,
@@ -12496,8 +12313,7 @@
},
"/preferences.get": {
"get": {
- "summary": "Get user preferences",
- "description": "Get user preferences",
+ "summary": "get - preferences",
"tags": [
"preferences"
],
@@ -12628,8 +12444,7 @@
},
"/preferences.update": {
"post": {
- "summary": "Update user preferences",
- "description": "Update user preferences",
+ "summary": "update - preferences",
"tags": [
"preferences"
],
@@ -12772,8 +12587,7 @@
},
"/preferences.addDevice": {
"post": {
- "summary": "Add device preference",
- "description": "Add device preference",
+ "summary": "addDevice - preferences",
"tags": [
"preferences"
],
@@ -12918,8 +12732,7 @@
},
"/preferences.removeDevice": {
"post": {
- "summary": "Remove device preference",
- "description": "Remove device preference",
+ "summary": "removeDevice - preferences",
"tags": [
"preferences"
],
@@ -13064,8 +12877,7 @@
},
"/preferences.bulkUpdateDevices": {
"post": {
- "summary": "Bulk update device preferences",
- "description": "Bulk update device preferences",
+ "summary": "bulkUpdateDevices - preferences",
"tags": [
"preferences"
],
@@ -13213,8 +13025,7 @@
},
"/preferences.bulkUpdateSocs": {
"post": {
- "summary": "Bulk update SOC preferences",
- "description": "Bulk update SOC preferences",
+ "summary": "bulkUpdateSocs - preferences",
"tags": [
"preferences"
],
@@ -13362,8 +13173,7 @@
},
"/preferences.currentProfile": {
"get": {
- "summary": "Get current user's profile",
- "description": "Get current user's profile",
+ "summary": "currentProfile - preferences",
"tags": [
"preferences"
],
@@ -13494,8 +13304,7 @@
},
"/preferences.profile": {
"get": {
- "summary": "Get user profile by ID",
- "description": "Get user profile by ID",
+ "summary": "profile - preferences",
"tags": [
"preferences"
],
@@ -13637,8 +13446,7 @@
},
"/preferences.updateProfile": {
"post": {
- "summary": "Update profile",
- "description": "Update profile",
+ "summary": "updateProfile - preferences",
"tags": [
"preferences"
],
diff --git a/src/app/listings/[id]/components/CommentForm.tsx b/src/app/listings/[id]/components/CommentForm.tsx
index 4bb2653eb..22ee9dadf 100644
--- a/src/app/listings/[id]/components/CommentForm.tsx
+++ b/src/app/listings/[id]/components/CommentForm.tsx
@@ -3,7 +3,6 @@
import { GenericCommentForm, type CommentFormConfig } from '@/components/comments'
import analytics from '@/lib/analytics'
import { api } from '@/lib/api'
-import { useRecaptchaForComment } from '@/lib/captcha/hooks'
import { type RouterInput } from '@/types/trpc'
interface Props {
@@ -20,8 +19,6 @@ interface Props {
}
function CommentForm(props: Props) {
- const { executeForComment, isCaptchaEnabled } = useRecaptchaForComment()
-
const createComment = api.listings.createComment.useMutation({
onSuccess: (data) => {
if (data?.id) {
@@ -63,7 +60,6 @@ function CommentForm(props: Props) {
reply: 'Write your reply...',
},
maxLength: 1000,
- enableRecaptcha: isCaptchaEnabled,
showSignInPrompt: true,
buttonStyle: 'default',
}
@@ -71,13 +67,13 @@ function CommentForm(props: Props) {
const handleSubmit = async (data: {
content: string
parentId?: string
- recaptchaToken?: string | null
+ humanVerificationToken?: string
}) => {
await createComment.mutateAsync({
listingId: props.listingId,
content: data.content,
parentId: data.parentId,
- ...(data.recaptchaToken && { recaptchaToken: data.recaptchaToken }),
+ humanVerificationToken: data.humanVerificationToken,
} satisfies RouterInput['listings']['createComment'])
}
@@ -105,7 +101,6 @@ function CommentForm(props: Props) {
onUpdate={handleUpdate}
onSuccess={props.onCommentSuccess}
onCancel={props.onCancelEdit}
- getRecaptchaToken={isCaptchaEnabled ? executeForComment : undefined}
isCreating={createComment.isPending}
isUpdating={editComment.isPending}
/>
diff --git a/src/app/listings/[id]/components/CommentThread.tsx b/src/app/listings/[id]/components/CommentThread.tsx
index 08cd630a8..1a01675ba 100644
--- a/src/app/listings/[id]/components/CommentThread.tsx
+++ b/src/app/listings/[id]/components/CommentThread.tsx
@@ -11,7 +11,6 @@ import {
import useVerifiedDeveloper from '@/hooks/useVerifiedDeveloper'
import analytics from '@/lib/analytics'
import { api } from '@/lib/api'
-import { useRecaptchaForComment } from '@/lib/captcha/hooks'
import { type RouterInput } from '@/types/trpc'
import { hasRolePermission } from '@/utils/permissions'
import { Role } from '@orm'
@@ -28,7 +27,6 @@ interface Props {
function CommentThread(props: Props) {
const { user } = useUser()
const [sortBy, setSortBy] = useState(props.initialSortBy ?? 'newest')
- const { executeForComment, isCaptchaEnabled } = useRecaptchaForComment()
const listingsQuery = api.listings.getSortedComments.useQuery(
{ listingId: props.listingId, sortBy },
@@ -120,7 +118,6 @@ function CommentThread(props: Props) {
entityIdField: 'listingId',
entityType: 'listing',
enableVoting: true,
- enableRecaptcha: true,
enableAnalytics: true,
sortOptions: [
{ value: 'newest', label: 'Newest' },
@@ -141,7 +138,6 @@ function CommentThread(props: Props) {
reply: 'Write your reply...',
},
maxLength: 1000,
- enableRecaptcha: isCaptchaEnabled,
showSignInPrompt: true,
buttonStyle: 'default',
}
@@ -162,13 +158,13 @@ function CommentThread(props: Props) {
const handleCreateComment = async (data: {
content: string
parentId?: string
- recaptchaToken?: string | null
+ humanVerificationToken?: string
}) => {
const mutation = createComment.mutateAsync({
listingId: props.listingId,
content: data.content,
parentId: data.parentId,
- ...(data.recaptchaToken && { recaptchaToken: data.recaptchaToken }),
+ humanVerificationToken: data.humanVerificationToken,
} satisfies RouterInput['listings']['createComment'])
// Track analytics for replies
@@ -234,7 +230,6 @@ function CommentThread(props: Props) {
onUpdate={handleEditComment}
onSuccess={onSuccess}
onCancel={onCancel}
- getRecaptchaToken={isCaptchaEnabled ? executeForComment : undefined}
isCreating={createComment.isPending}
isUpdating={editComment.isPending}
/>
diff --git a/src/app/listings/[id]/components/VoteButtons.tsx b/src/app/listings/[id]/components/VoteButtons.tsx
index 331281403..14aa19b64 100644
--- a/src/app/listings/[id]/components/VoteButtons.tsx
+++ b/src/app/listings/[id]/components/VoteButtons.tsx
@@ -2,7 +2,6 @@
import { VoteButtons as SharedVoteButtons } from '@/components/ui'
import { api } from '@/lib/api'
-import { useRecaptchaForVote } from '@/lib/captcha/hooks'
import { type RouterInput } from '@/types/trpc'
import VotingHelpModal from './VotingHelpModal'
@@ -19,8 +18,6 @@ interface Props {
}
export function VoteButtons(props: Props) {
- const { executeForVote, isCaptchaEnabled } = useRecaptchaForVote()
-
const voteMutation = api.listings.vote.useMutation({
onSuccess: () => {
props.onVoteSuccess?.()
@@ -28,14 +25,9 @@ export function VoteButtons(props: Props) {
})
const handleVote = async (value: boolean) => {
- // Get CAPTCHA token if enabled
- let recaptchaToken: string | null = null
- if (isCaptchaEnabled) recaptchaToken = await executeForVote()
-
voteMutation.mutate({
listingId: props.listingId,
value,
- ...(recaptchaToken && { recaptchaToken }),
} satisfies RouterInput['listings']['vote'])
}
diff --git a/src/app/listings/components/shared/details/utils/logVoteError.test.ts b/src/app/listings/components/shared/details/utils/logVoteError.test.ts
index 8722b1544..6c1329967 100644
--- a/src/app/listings/components/shared/details/utils/logVoteError.test.ts
+++ b/src/app/listings/components/shared/details/utils/logVoteError.test.ts
@@ -58,7 +58,7 @@ describe('logPcVoteError', () => {
const PC_LISTING_ID = '00000000-0000-4000-a000-000000000011'
it('passes pcListingId as extra context to logger.error', () => {
- const error = new Error('CAPTCHA failed')
+ const error = new Error('Vote failed')
logPcVoteError({ error, pcListingId: PC_LISTING_ID })
diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx
index baded4f21..48f5efb75 100644
--- a/src/app/listings/new/NewListingPage.tsx
+++ b/src/app/listings/new/NewListingPage.tsx
@@ -18,9 +18,9 @@ import '@/shared/emulator-config/eden'
import '@/shared/emulator-config/azahar'
import '@/shared/emulator-config/gamenative'
import { Button, LoadingSpinner } from '@/components/ui'
+import { useSubmitWithHumanVerification } from '@/features/human-verification/client'
import analytics from '@/lib/analytics'
import { api } from '@/lib/api'
-import { useRecaptchaForCreateListing } from '@/lib/captcha/hooks'
import { MarkdownEditor } from '@/lib/dynamic-imports'
import toast from '@/lib/toast'
import { cn } from '@/lib/utils'
@@ -56,7 +56,7 @@ function AddListingPage() {
const router = useRouter()
const searchParams = useSearchParams()
const utils = api.useUtils()
- const { executeForCreateListing, isCaptchaEnabled } = useRecaptchaForCreateListing()
+ const submitWithHumanVerification = useSubmitWithHumanVerification()
const gameIdFromUrl = searchParams.get('gameId')
@@ -409,9 +409,6 @@ function AddListingPage() {
toast.success('Handheld Report successfully submitted for review!')
router.push('/listings')
},
- onError: (error) => {
- toast.error(`Error creating Handheld Report: ${getErrorMessage(error)}`)
- },
})
useEffect(() => {
@@ -424,19 +421,16 @@ function AddListingPage() {
return toast.error('You must be signed in to create a Compatibility Report.')
}
- // Get CAPTCHA token if enabled
- let recaptchaToken: string | null = null
- if (isCaptchaEnabled) {
- recaptchaToken = await executeForCreateListing()
- if (!recaptchaToken) {
- return toast.error('CAPTCHA verification could not start. Please refresh and try again.')
- }
+ try {
+ await submitWithHumanVerification((humanVerificationToken) =>
+ createListingMutation.mutateAsync({
+ ...data,
+ humanVerificationToken,
+ }),
+ )
+ } catch (error) {
+ toast.error(`Error creating Handheld Report: ${getErrorMessage(error)}`)
}
-
- createListingMutation.mutate({
- ...data,
- ...(recaptchaToken && { recaptchaToken }),
- })
}
return (
diff --git a/src/app/pc-listings/[id]/components/PcCommentForm.tsx b/src/app/pc-listings/[id]/components/PcCommentForm.tsx
index 7f1277075..2fc9c717e 100644
--- a/src/app/pc-listings/[id]/components/PcCommentForm.tsx
+++ b/src/app/pc-listings/[id]/components/PcCommentForm.tsx
@@ -25,16 +25,20 @@ function PcCommentForm(props: Props) {
reply: 'Write your reply...',
},
maxLength: 2000,
- enableRecaptcha: false,
showSignInPrompt: false,
buttonStyle: 'compact',
}
- const handleSubmit = async (data: { content: string; parentId?: string }) => {
+ const handleSubmit = async (data: {
+ content: string
+ parentId?: string
+ humanVerificationToken?: string
+ }) => {
await createComment.mutateAsync({
pcListingId: props.pcListingId,
content: data.content,
parentId: data.parentId,
+ humanVerificationToken: data.humanVerificationToken,
})
}
diff --git a/src/app/pc-listings/[id]/components/PcCommentThread.tsx b/src/app/pc-listings/[id]/components/PcCommentThread.tsx
index aac881288..c1f80821f 100644
--- a/src/app/pc-listings/[id]/components/PcCommentThread.tsx
+++ b/src/app/pc-listings/[id]/components/PcCommentThread.tsx
@@ -104,7 +104,6 @@ function PcCommentThread(props: Props) {
reply: 'Write your reply...',
},
maxLength: 2000,
- enableRecaptcha: false,
showSignInPrompt: false,
buttonStyle: 'compact',
}
@@ -113,11 +112,16 @@ function PcCommentThread(props: Props) {
await deleteComment.mutateAsync({ commentId })
}
- const handleCreateComment = async (data: { content: string; parentId?: string }) => {
+ const handleCreateComment = async (data: {
+ content: string
+ parentId?: string
+ humanVerificationToken?: string
+ }) => {
await createComment.mutateAsync({
pcListingId: props.pcListingId,
content: data.content,
parentId: data.parentId,
+ humanVerificationToken: data.humanVerificationToken,
})
}
diff --git a/src/app/pc-listings/[id]/components/PcVoteButtons.tsx b/src/app/pc-listings/[id]/components/PcVoteButtons.tsx
index d193a5cb6..9b89fb900 100644
--- a/src/app/pc-listings/[id]/components/PcVoteButtons.tsx
+++ b/src/app/pc-listings/[id]/components/PcVoteButtons.tsx
@@ -3,7 +3,6 @@
import VotingHelpModal from '@/app/listings/[id]/components/VotingHelpModal'
import { VoteButtons } from '@/components/ui'
import { api } from '@/lib/api'
-import { useRecaptchaForVote } from '@/lib/captcha/hooks'
import { type RouterInput } from '@/types/trpc'
interface Props {
@@ -20,8 +19,6 @@ interface Props {
}
function PcVoteButtons(props: Props) {
- const { executeForVote, isCaptchaEnabled } = useRecaptchaForVote()
-
const voteMutation = api.pcListings.vote.useMutation({
onSuccess: () => {
props.onVoteSuccess?.()
@@ -29,16 +26,9 @@ function PcVoteButtons(props: Props) {
})
const handleVote = async (value: boolean) => {
- // Get CAPTCHA token if enabled
- let recaptchaToken: string | null = null
- if (isCaptchaEnabled) {
- recaptchaToken = await executeForVote()
- }
-
voteMutation.mutate({
pcListingId: props.pcListingId,
value,
- ...(recaptchaToken && { recaptchaToken }),
} satisfies RouterInput['pcListings']['vote'])
}
diff --git a/src/app/pc-listings/new/NewPcListingPage.tsx b/src/app/pc-listings/new/NewPcListingPage.tsx
index e79c13518..55772811d 100644
--- a/src/app/pc-listings/new/NewPcListingPage.tsx
+++ b/src/app/pc-listings/new/NewPcListingPage.tsx
@@ -22,9 +22,9 @@ import {
} from '@/app/listings/hooks'
import { Autocomplete, Button, Input, LoadingSpinner, SelectInput } from '@/components/ui'
import { PC_OS_OPTIONS } from '@/data/pc-os'
+import { useSubmitWithHumanVerification } from '@/features/human-verification/client'
import analytics from '@/lib/analytics'
import { api } from '@/lib/api'
-import { useRecaptchaForCreateListing } from '@/lib/captcha/hooks'
import { MarkdownEditor } from '@/lib/dynamic-imports'
import toast from '@/lib/toast'
import { type RouterInput, type RouterOutput } from '@/types/trpc'
@@ -46,6 +46,7 @@ function AddPcListingPage() {
const router = useRouter()
const searchParams = useSearchParams()
const currentUserQuery = api.users.me.useQuery()
+ const submitWithHumanVerification = useSubmitWithHumanVerification()
const gameIdFromUrl = searchParams.get('gameId')
@@ -64,7 +65,6 @@ function AddPcListingPage() {
const createPcListing = api.pcListings.create.useMutation()
const performanceScalesQuery = api.performanceScales.get.useQuery()
const presetsQuery = api.pcListings.presets.get.useQuery({})
- const { executeForCreateListing, isCaptchaEnabled } = useRecaptchaForCreateListing()
const { handleKeyDown } = useFormKeyDown()
const { gameSearchTerm, setGameSearchTerm, loadGameItems } = useGameLoader()
@@ -194,15 +194,12 @@ function AddPcListingPage() {
return toast.error('You must be signed in to create a Compatibility Report.')
}
try {
- const recaptchaToken = isCaptchaEnabled ? await executeForCreateListing() : null
- if (isCaptchaEnabled && !recaptchaToken) {
- return toast.error('CAPTCHA verification could not start. Please refresh and try again.')
- }
-
- const result = await createPcListing.mutateAsync({
- ...data,
- ...(recaptchaToken && { recaptchaToken }),
- })
+ const result = await submitWithHumanVerification((humanVerificationToken) =>
+ createPcListing.mutateAsync({
+ ...data,
+ humanVerificationToken,
+ }),
+ )
analytics.listing.created({
listingId: result.id,
@@ -230,9 +227,8 @@ function AddPcListingPage() {
},
[
currentUserQuery.data?.id,
- executeForCreateListing,
- isCaptchaEnabled,
createPcListing,
+ submitWithHumanVerification,
selectedGame?.system?.id,
parsedCustomFields.length,
router,
diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx
index 342ec70a1..e0d99140d 100644
--- a/src/components/Providers.tsx
+++ b/src/components/Providers.tsx
@@ -1,35 +1,18 @@
'use client'
-import { type PropsWithChildren, type ReactNode } from 'react'
-import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'
+import { type PropsWithChildren } from 'react'
+import { HumanVerificationProvider } from '@/features/human-verification/client'
import { TRPCProvider } from '@/lib/api'
-import { RECAPTCHA_CONFIG, isCaptchaClientEnabled } from '@/lib/captcha/config'
import ThemeProvider from './ThemeProvider'
import { ConfirmDialogProvider } from './ui'
function Providers(props: PropsWithChildren) {
- const recaptchaWrapper = (children: ReactNode) => {
- return !isCaptchaClientEnabled() ? (
- <>{children}>
- ) : (
-
- {children}
-
- )
- }
-
return (
- {recaptchaWrapper(props.children)}
+
+ {props.children}
+
)
diff --git a/src/components/comments/GenericCommentForm.tsx b/src/components/comments/GenericCommentForm.tsx
index cad70589a..337519ff8 100644
--- a/src/components/comments/GenericCommentForm.tsx
+++ b/src/components/comments/GenericCommentForm.tsx
@@ -4,6 +4,7 @@ import { useUser, SignInButton } from '@clerk/nextjs'
import { Send, X } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { Button } from '@/components/ui'
+import { useSubmitWithHumanVerification } from '@/features/human-verification/client'
import { MarkdownEditor } from '@/lib/dynamic-imports'
import toast from '@/lib/toast'
import { cn } from '@/lib/utils'
@@ -16,7 +17,6 @@ export interface CommentFormConfig {
reply?: string
}
maxLength?: number
- enableRecaptcha?: boolean
showSignInPrompt?: boolean
buttonStyle?: 'default' | 'compact'
}
@@ -30,15 +30,12 @@ interface GenericCommentFormProps {
onSubmit: (data: {
content: string
parentId?: string
- recaptchaToken?: string | null
+ humanVerificationToken?: string
}) => Promise
onUpdate?: (data: { commentId: string; content: string }) => Promise
onSuccess?: () => void
onCancel?: () => void
- getRecaptchaToken?: () => Promise
-
- // Loading states
isCreating?: boolean
isUpdating?: boolean
}
@@ -46,6 +43,7 @@ interface GenericCommentFormProps {
export function GenericCommentForm(props: GenericCommentFormProps) {
const { user } = useUser()
const [content, setContent] = useState(props.editingComment?.content ?? '')
+ const submitWithHumanVerification = useSubmitWithHumanVerification()
const isLoading = props.isCreating || props.isUpdating
const maxLength = props.config.maxLength ?? 2000
@@ -77,24 +75,18 @@ export function GenericCommentForm(props: GenericCommentFormProps) {
try {
if (props.editingComment && props.onUpdate) {
- // Update existing comment
await props.onUpdate({
commentId: props.editingComment.id,
content: trimmedContent,
})
toast.success('Comment updated successfully')
} else {
- // Get CAPTCHA token if enabled
- let recaptchaToken: string | null = null
- if (props.config.enableRecaptcha && props.getRecaptchaToken) {
- recaptchaToken = await props.getRecaptchaToken()
- }
-
- // Create new comment
- await props.onSubmit({
- content: trimmedContent,
- parentId: props.parentId,
- recaptchaToken,
+ await submitWithHumanVerification((humanVerificationToken) => {
+ return props.onSubmit({
+ content: trimmedContent,
+ parentId: props.parentId,
+ humanVerificationToken,
+ })
})
toast.success('Comment posted successfully')
}
@@ -187,7 +179,6 @@ export function GenericCommentForm(props: GenericCommentFormProps) {
)
}
- // Default style
return (