diff --git a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts index cd7259ab1..4fe4e7d55 100644 --- a/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts +++ b/core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts @@ -14,13 +14,17 @@ import { graphql, VariablesOf } from '~/client/graphql'; import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils'; import { redirect } from '~/i18n/routing'; import { getCartId } from '~/lib/cart'; +import { getRecaptchaSiteKey, RECAPTCHA_TOKEN_FORM_KEY } from '~/lib/recaptcha'; import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './prefixes'; const RegisterCustomerMutation = graphql(` - mutation RegisterCustomerMutation($input: RegisterCustomerInput!) { + mutation RegisterCustomerMutation( + $input: RegisterCustomerInput! + $reCaptchaV2: ReCaptchaV2Input + ) { customer { - registerCustomer(input: $input) { + registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) { customer { firstName lastName @@ -356,12 +360,29 @@ export async function registerCustomer( }; } + const recaptchaSiteKey = await getRecaptchaSiteKey(); + + if (recaptchaSiteKey) { + const recaptchaToken = formData.get(RECAPTCHA_TOKEN_FORM_KEY); + + if (typeof recaptchaToken !== 'string' || !recaptchaToken.trim()) { + return { + lastResult: submission.reply({ formErrors: [t('recaptchaRequired')] }), + }; + } + } + try { const input = parseRegisterCustomerInput(submission.value, fields); + const recaptchaToken = formData.get(RECAPTCHA_TOKEN_FORM_KEY); const response = await client.fetch({ document: RegisterCustomerMutation, variables: { input, + reCaptchaV2: + typeof recaptchaToken === 'string' && recaptchaToken + ? { token: recaptchaToken } + : undefined, }, fetchOptions: { cache: 'no-store' }, }); diff --git a/core/app/[locale]/(default)/(auth)/register/page.tsx b/core/app/[locale]/(default)/(auth)/register/page.tsx index bf8ebf135..23004a7b8 100644 --- a/core/app/[locale]/(default)/(auth)/register/page.tsx +++ b/core/app/[locale]/(default)/(auth)/register/page.tsx @@ -13,6 +13,7 @@ import { REGISTER_CUSTOMER_FORM_LAYOUT, transformFieldsToLayout, } from '~/data-transformers/form-field-transformer/utils'; +import { getRecaptchaSiteKey } from '~/lib/recaptcha'; import { exists } from '~/lib/utils'; import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './_actions/prefixes'; @@ -63,6 +64,8 @@ export default async function Register({ params }: Props) { const { addressFields, customerFields, countries, passwordComplexitySettings } = registerCustomerData; + const recaptchaSiteKey = await getRecaptchaSiteKey(); + const fields = transformFieldsToLayout( [ ...addressFields.map((field) => { @@ -154,6 +157,7 @@ export default async function Register({ params }: Props) { }} fields={fields} passwordComplexity={passwordComplexitySettings} + recaptchaSiteKey={recaptchaSiteKey} submitLabel={t('cta')} title={t('heading')} /> diff --git a/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts b/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts index e37c9aa5d..27a707ac8 100644 --- a/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts +++ b/core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts @@ -9,11 +9,15 @@ import { schema } from '@/vibes/soul/sections/reviews/schema'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; +import { getRecaptchaSiteKey, RECAPTCHA_TOKEN_FORM_KEY } from '~/lib/recaptcha'; const AddProductReviewMutation = graphql(` - mutation AddProductReviewMutation($input: AddProductReviewInput!) { + mutation AddProductReviewMutation( + $input: AddProductReviewInput! + $reCaptchaV2: ReCaptchaV2Input + ) { catalog { - addProductReview(input: $input) { + addProductReview(input: $input, reCaptchaV2: $reCaptchaV2) { __typename errors { __typename @@ -38,7 +42,22 @@ export async function submitReview( return { ...prevState, lastResult: submission.reply() }; } + const recaptchaSiteKey = await getRecaptchaSiteKey(); + + if (recaptchaSiteKey) { + const token = payload.get(RECAPTCHA_TOKEN_FORM_KEY); + const tokenValue = typeof token === 'string' ? token.trim() : ''; + + if (!tokenValue) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: [t('recaptchaRequired')] }), + }; + } + } + const { productEntityId, ...input } = submission.value; + const recaptchaToken = payload.get(RECAPTCHA_TOKEN_FORM_KEY); try { const response = await client.fetch({ @@ -52,6 +71,10 @@ export async function submitReview( }, productEntityId, }, + reCaptchaV2: + typeof recaptchaToken === 'string' && recaptchaToken + ? { token: recaptchaToken } + : undefined, }, }); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 6429de74b..47a533d5c 100644 --- a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx +++ b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx @@ -82,6 +82,7 @@ interface Props { pageInfo?: { hasNextPage: boolean; endCursor: string | null }; }>; streamableProduct: Streamable>>; + recaptchaSiteKey?: string; } export const Reviews = async ({ @@ -89,6 +90,7 @@ export const Reviews = async ({ searchParams, streamableProduct, streamableImages, + recaptchaSiteKey, }: Props) => { const t = await getTranslations('Product.Reviews'); @@ -189,6 +191,7 @@ export const Reviews = async ({ paginationInfo={streamablePaginationInfo} previousLabel={t('previous')} productId={productId} + recaptchaSiteKey={recaptchaSiteKey} reviews={streamableReviews} reviewsLabel={t('title')} streamableImages={streamableImages} diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index c1e8a96bf..8d2001ee8 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -12,6 +12,7 @@ import { pricesTransformer } from '~/data-transformers/prices-transformer'; import { productCardTransformer } from '~/data-transformers/product-card-transformer'; import { productOptionsTransformer } from '~/data-transformers/product-options-transformer'; import { getPreferredCurrencyCode } from '~/lib/currency'; +import { getRecaptchaSiteKey } from '~/lib/recaptcha'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addToCart } from './_actions/add-to-cart'; @@ -76,7 +77,10 @@ export default async function Product({ params, searchParams }: Props) { const productId = Number(slug); - const { product: baseProduct, settings } = await getProduct(productId, customerAccessToken); + const [{ product: baseProduct, settings }, recaptchaSiteKey] = await Promise.all([ + getProduct(productId, customerAccessToken), + getRecaptchaSiteKey(), + ]); const reviewsEnabled = Boolean(settings?.reviews.enabled && !settings.display.showProductRating); const showRating = Boolean(settings?.reviews.enabled && settings.display.showProductRating); @@ -581,6 +585,7 @@ export default async function Product({ params, searchParams }: Props) { backorderDisplayData: streamableBackorderDisplayData, }} quantityLabel={t('ProductDetails.quantity')} + recaptchaSiteKey={recaptchaSiteKey} reviewFormAction={submitReview} thumbnailLabel={t('ProductDetails.thumbnail')} user={streamableUser} @@ -602,6 +607,7 @@ export default async function Product({ params, searchParams }: Props) {
( }; } + const recaptchaSiteKey = await getRecaptchaSiteKey(); + + if (recaptchaSiteKey) { + const recaptchaToken = formData.get(RECAPTCHA_TOKEN_FORM_KEY); + + if (typeof recaptchaToken !== 'string' || !recaptchaToken.trim()) { + return { + lastResult: submission.reply({ formErrors: [t('recaptchaRequired')] }), + }; + } + } + try { const input = parseContactFormInput(submission.value); + const recaptchaToken = formData.get(RECAPTCHA_TOKEN_FORM_KEY); const response = await client.fetch({ document: SubmitContactUsMutation, variables: { input, + reCaptchaV2: + typeof recaptchaToken === 'string' && recaptchaToken + ? { token: recaptchaToken } + : undefined, }, fetchOptions: { cache: 'no-store' }, }); diff --git a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index b8d1f2e75..2c8896c40 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -12,6 +12,7 @@ import { breadcrumbsTransformer, truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; +import { getRecaptchaSiteKey } from '~/lib/recaptcha'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPage, WebPageContent } from '../_components/web-page'; @@ -195,6 +196,8 @@ export default async function ContactPage({ params, searchParams }: Props) { ); } + const recaptchaSiteKey = await getRecaptchaSiteKey(); + return ( getWebPageBreadcrumbs(id))} @@ -204,6 +207,7 @@ export default async function ContactPage({ params, searchParams }: Props) {
diff --git a/core/lib/recaptcha.ts b/core/lib/recaptcha.ts new file mode 100644 index 000000000..73cca8263 --- /dev/null +++ b/core/lib/recaptcha.ts @@ -0,0 +1,55 @@ +import 'server-only'; + +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +import type { ReCaptchaSettings } from './recaptcha/constants'; + +export { RECAPTCHA_TOKEN_FORM_KEY } from './recaptcha/constants'; +export type { ReCaptchaSettings } from './recaptcha/constants'; + +export const ReCaptchaSettingsQuery = graphql(` + query ReCaptchaSettingsQuery { + site { + settings { + reCaptcha { + failedLoginLockoutDurationSeconds + isEnabledOnCheckout + isEnabledOnStorefront + siteKey + } + } + } + } +`); + +export const getReCaptchaSettings = cache(async (): Promise => { + const { data } = await client.fetch({ + document: ReCaptchaSettingsQuery, + fetchOptions: { next: { revalidate } }, + }); + + const reCaptcha = data.site.settings?.reCaptcha; + + if (!reCaptcha?.siteKey) { + return null; + } + + return { + failedLoginLockoutDurationSeconds: reCaptcha.failedLoginLockoutDurationSeconds ?? null, + isEnabledOnCheckout: reCaptcha.isEnabledOnCheckout, + isEnabledOnStorefront: reCaptcha.isEnabledOnStorefront, + siteKey: reCaptcha.siteKey, + }; +}); + +export const getRecaptchaSiteKey = cache(async (): Promise => { + const settings = await getReCaptchaSettings(); + + return settings?.isEnabledOnStorefront === true && settings.siteKey + ? settings.siteKey + : undefined; +}); diff --git a/core/lib/recaptcha/constants.ts b/core/lib/recaptcha/constants.ts new file mode 100644 index 000000000..7af1f4e99 --- /dev/null +++ b/core/lib/recaptcha/constants.ts @@ -0,0 +1,8 @@ +export interface ReCaptchaSettings { + failedLoginLockoutDurationSeconds: number | null; + isEnabledOnCheckout: boolean; + isEnabledOnStorefront: boolean; + siteKey: string; +} + +export const RECAPTCHA_TOKEN_FORM_KEY = 'recaptchaToken'; diff --git a/core/messages/en.json b/core/messages/en.json index 1f09a5058..7fa359039 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -98,6 +98,7 @@ "heading": "New account", "cta": "Create account", "somethingWentWrong": "Something went wrong. Please try again later.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "First name is required", "lastNameRequired": "Last name is required", @@ -505,6 +506,7 @@ "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", "somethingWentWrong": "Something went wrong. Please try again later.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Title is required", "authorRequired": "Name is required", @@ -535,7 +537,8 @@ "email": "Email", "comments": "Comments/questions", "cta": "Submit form", - "somethingWentWrong": "Something went wrong. Please try again later." + "somethingWentWrong": "Something went wrong. Please try again later.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -705,6 +708,7 @@ }, "Form": { "optional": "optional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Please check your input and try again", "invalidFormat": "The value entered does not match the required format" diff --git a/core/package.json b/core/package.json index 30f5af80e..3862a6eb8 100644 --- a/core/package.json +++ b/core/package.json @@ -65,6 +65,7 @@ "react": "19.1.5", "react-day-picker": "^9.7.0", "react-dom": "19.1.5", + "react-google-recaptcha": "^3.1.0", "react-headroom": "^3.2.1", "schema-dts": "^1.1.5", "server-only": "^0.0.1", @@ -88,6 +89,7 @@ "@types/node": "^22.15.30", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", + "@types/react-google-recaptcha": "^2.1.9", "@types/react-headroom": "^3.2.3", "@types/uuid": "^10.0.0", "autoprefixer": "^10.4.21", diff --git a/core/vibes/soul/form/dynamic-form/index.tsx b/core/vibes/soul/form/dynamic-form/index.tsx index 53c21b1d5..638e22014 100644 --- a/core/vibes/soul/form/dynamic-form/index.tsx +++ b/core/vibes/soul/form/dynamic-form/index.tsx @@ -13,14 +13,18 @@ import { import { getZodConstraint, parseWithZod } from '@conform-to/zod'; import { useTranslations } from 'next-intl'; import { + type ComponentRef, FormEvent, MouseEvent, ReactNode, startTransition, useActionState, useEffect, + useRef, + useState, } from 'react'; import { useFormStatus } from 'react-dom'; +import RecaptchaWidget from 'react-google-recaptcha'; import { z } from 'zod'; import { ButtonRadioGroup } from '@/vibes/soul/form/button-radio-group'; @@ -36,6 +40,7 @@ import { Select } from '@/vibes/soul/form/select'; import { SwatchRadioGroup } from '@/vibes/soul/form/swatch-radio-group'; import { Textarea } from '@/vibes/soul/form/textarea'; import { Button, ButtonProps } from '@/vibes/soul/primitives/button'; +import { RECAPTCHA_TOKEN_FORM_KEY } from '~/lib/recaptcha/constants'; import { Field, @@ -77,9 +82,14 @@ export interface DynamicFormProps { onSuccess?: (lastResult: SubmissionResult, successMessage: ReactNode) => void; passwordComplexity?: PasswordComplexitySettings | null; errorTranslations?: FormErrorTranslationMap; + recaptchaSiteKey?: string; } -export function DynamicForm({ +export function DynamicForm(props: DynamicFormProps) { + return ; +} + +function DynamicFormInner({ action, fields, buttonSize = 'medium', @@ -92,6 +102,7 @@ export function DynamicForm({ onSuccess, passwordComplexity, errorTranslations, + recaptchaSiteKey, }: DynamicFormProps) { const t = useTranslations('Form'); // Remove options from fields before passing to action to reduce payload size @@ -104,6 +115,9 @@ export function DynamicForm({ lastResult: null, }); + const recaptchaRef = useRef | null>(null); + const [recaptchaError, setRecaptchaError] = useState(null); + const dynamicSchema = schema(fields, passwordComplexity, errorTranslations); const defaultValue = fields .flatMap((f) => (Array.isArray(f) ? f : [f])) @@ -148,8 +162,26 @@ export function DynamicForm({ onSubmit(event, { formData }) { event.preventDefault(); + setRecaptchaError(null); + + let payload: FormData = formData; + + if (recaptchaSiteKey && recaptchaRef.current) { + const token = recaptchaRef.current.getValue(); + + if (!token || typeof token !== 'string') { + setRecaptchaError(t('recaptchaRequired')); + + return; + } + + payload = new FormData(event.currentTarget); + payload.set(RECAPTCHA_TOKEN_FORM_KEY, token); + recaptchaRef.current.reset(); + } + startTransition(() => { - formAction(formData); + formAction(payload); }); }, }); @@ -191,6 +223,9 @@ export function DynamicForm({ return ; })} + {recaptchaSiteKey ? ( + + ) : null}
{onCancel && (
+ {recaptchaError ? {recaptchaError} : null} {form.errors?.map((error, index) => ( {error} diff --git a/core/vibes/soul/sections/dynamic-form-section/index.tsx b/core/vibes/soul/sections/dynamic-form-section/index.tsx index 3de3df6b3..739af77ed 100644 --- a/core/vibes/soul/sections/dynamic-form-section/index.tsx +++ b/core/vibes/soul/sections/dynamic-form-section/index.tsx @@ -18,6 +18,7 @@ interface Props { className?: string; passwordComplexity?: PasswordComplexitySettings | null; errorTranslations?: FormErrorTranslationMap; + recaptchaSiteKey?: string; } export function DynamicFormSection({ @@ -29,6 +30,7 @@ export function DynamicFormSection({ action, passwordComplexity, errorTranslations, + recaptchaSiteKey, }: Props) { return ( @@ -47,6 +49,7 @@ export function DynamicFormSection({ errorTranslations={errorTranslations} fields={fields} passwordComplexity={passwordComplexity} + recaptchaSiteKey={recaptchaSiteKey} submitLabel={submitLabel} /> diff --git a/core/vibes/soul/sections/product-detail/index.tsx b/core/vibes/soul/sections/product-detail/index.tsx index fe08e3e78..de0afc1c6 100644 --- a/core/vibes/soul/sections/product-detail/index.tsx +++ b/core/vibes/soul/sections/product-detail/index.tsx @@ -75,6 +75,7 @@ export interface ProductDetailProps { reviewFormAction: SubmitReviewAction; user: Streamable<{ email: string; name: string }>; loadMoreImagesAction?: ProductGalleryLoadMoreAction; + recaptchaSiteKey?: string; } // eslint-disable-next-line valid-jsdoc @@ -117,6 +118,7 @@ export function ProductDetail({ reviewFormAction, user, loadMoreImagesAction, + recaptchaSiteKey, }: ProductDetailProps) { return (
@@ -164,6 +166,7 @@ export function ProductDetail({ formSubmitLabel={reviewFormSubmitLabel} formTitleLabel={reviewFormTitleLabel} productId={Number(product.id)} + recaptchaSiteKey={recaptchaSiteKey} streamableImages={product.images} streamableProduct={{ name: product.title }} streamableUser={user} diff --git a/core/vibes/soul/sections/reviews/index.tsx b/core/vibes/soul/sections/reviews/index.tsx index b27c75f97..831fe8021 100644 --- a/core/vibes/soul/sections/reviews/index.tsx +++ b/core/vibes/soul/sections/reviews/index.tsx @@ -40,6 +40,7 @@ interface Props { }>; streamableProduct: Streamable<{ name: string }>; streamableUser: Streamable<{ email: string; name: string }>; + recaptchaSiteKey?: string; } export function Reviews({ @@ -65,6 +66,7 @@ export function Reviews({ streamableProduct, streamableImages, streamableUser, + recaptchaSiteKey, }: Readonly) { return ( } value={streamableReviews}> @@ -84,6 +86,7 @@ export function Reviews({ formTitleLabel={formTitleLabel} message={emptyStateMessage} productId={productId} + recaptchaSiteKey={recaptchaSiteKey} reviewsLabel={reviewsLabel} streamableImages={streamableImages} streamableProduct={streamableProduct} @@ -139,6 +142,7 @@ export function Reviews({ formSubmitLabel={formSubmitLabel} formTitleLabel={formTitleLabel} productId={productId} + recaptchaSiteKey={recaptchaSiteKey} streamableImages={streamableImages} streamableProduct={streamableProduct} streamableUser={streamableUser} @@ -201,6 +205,7 @@ export function ReviewsEmptyState({ streamableProduct, streamableImages, streamableUser, + recaptchaSiteKey, }: { message?: string; reviewsLabel?: string; @@ -221,6 +226,7 @@ export function ReviewsEmptyState({ }>; streamableProduct: Streamable<{ name: string }>; streamableUser: Streamable<{ email: string; name: string }>; + recaptchaSiteKey?: string; }) { return ( ; streamableProduct: Streamable<{ name: string }>; streamableUser: Streamable<{ email: string; name: string }>; + recaptchaSiteKey?: string; } export const ReviewForm = ({ @@ -61,6 +71,7 @@ export const ReviewForm = ({ streamableProduct, streamableImages, streamableUser, + recaptchaSiteKey, }: Props) => { const t = useTranslations('Product.Reviews.Form'); const errorTranslations = reviewFormErrorTranslations(t); @@ -69,6 +80,8 @@ export const ReviewForm = ({ lastResult: null, }); const formRef = useRef(null); + const recaptchaRef = useRef | null>(null); + const [recaptchaError, setRecaptchaError] = useState(null); const user = useStreamable(streamableUser); @@ -87,8 +100,26 @@ export const ReviewForm = ({ onSubmit(event, { formData }) { event.preventDefault(); + setRecaptchaError(null); + + let payload: FormData = formData; + + if (recaptchaSiteKey && recaptchaRef.current) { + const token = recaptchaRef.current.getValue(); + + if (!token || typeof token !== 'string') { + setRecaptchaError(t('recaptchaRequired')); + + return; + } + + payload = new FormData(event.currentTarget); + payload.set(RECAPTCHA_TOKEN_FORM_KEY, token); + recaptchaRef.current.reset(); + } + startTransition(() => { - formAction(formData); + formAction(payload); }); }, }); @@ -215,11 +246,17 @@ export const ReviewForm = ({ type="email" value={typeof emailControl.value === 'string' ? emailControl.value : ''} /> + {recaptchaError ? {recaptchaError} : null} {form.errors?.map((error, index) => ( {error} ))} + {recaptchaSiteKey ? ( +
+ +
+ ) : null}