diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29c9e04c6f..f39d598047 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,40 +103,33 @@ This ensures `integrations/makeswift` remains a faithful mirror of `canary` whil #### Stage 2: Sync and Release `integrations/makeswift` -2. Follow steps 1-6 under "[Keeping `integrations/makeswift` in sync with `canary`](#keeping-integrationsmakeswift-in-sync-with-canary)" +2. Follow steps 1-6 under "[Keeping `integrations/makeswift` in sync with `canary`](#keeping-integrationsmakeswift-in-sync-with-canary)", with one addition: **include a changeset for `@bigcommerce/catalyst-makeswift` in the sync merge commit** rather than opening a separate PR for it afterwards. -3. **IMPORTANT**: After step 6, you'll need to open another PR into `integrations/makeswift` - - Ensure a local `integrations/makeswift` branch exists and is up to date (`git checkout -B integrations/makeswift origin/integrations/makeswift`) - - Run `git fetch origin` and create a new branch from `integrations/makeswift` (`git checkout -B bump-version origin/integrations/makeswift`) - - From this new `bump-version` branch, run `pnpm changeset` - - Select `@bigcommerce/catalyst-makeswift` - - For choosing between a `patch/minor/major` bump, you should copy the bump from Stage 1. (e.g., if `@bigcommerce/catalyst-core` went from `1.1.0` to `1.2.0`, choose `minor`) - - Example changeset: + - Match the bump type from Stage 1 (e.g., if `@bigcommerce/catalyst-core` went from `1.4.2` to `1.5.0`, use `minor`) + - Create a changeset file in `.changeset/` (e.g., `.changeset/sync-canary-1-5-0.md`): ``` --- - "@bigcommerce/catalyst-makeswift": patch + "@bigcommerce/catalyst-makeswift": minor --- - Pulls in changes from the `@bigcommerce/catalyst-core@1.4.1` patch. + Pulls in changes from the `@bigcommerce/catalyst-core@1.5.0` release. For more information, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob//core/CHANGELOG.md#150). ``` - - Commit the generated changeset file and open a PR to merge this branch into `integrations/makeswift` - - Once merged, you can proceed to the next step + - Replace `` with the merge commit SHA of the Version Packages PR on `canary` so the link remains stable + - Amend this changeset into the merge commit alongside any other sync changes (changeset cleanup, `core/package.json` and `core/CHANGELOG.md` fixes, etc.) -4. Merge the **Version Packages (`integrations/makeswift`)** PR: Changesets will open another PR (similar to Stage 1) bumping `@bigcommerce/catalyst-makeswift`. Merge it following the same process. This cuts a new release of the Makeswift variant. +3. Merge the **Version Packages (`integrations/makeswift`)** PR: After the sync lands, Changesets will open a PR (similar to Stage 1) bumping `@bigcommerce/catalyst-makeswift`. Merge it following the same process. This cuts a new release of the Makeswift variant. -5. **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. If needed, update `latest` tags in GitHub manually. +4. **Tags and Releases:** Confirm tags exist for both `@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`. Update `latest` tags to point to the new releases: -- Push manually: - ``` - git checkout canary - # Make sure you have the latest code - git fetch origin - git pull - git tag @bigcommerce/catalyst-core@latest -f - git push origin @bigcommerce/catalyst-core@latest -f - ``` + ```bash + git fetch origin --tags + git tag @bigcommerce/catalyst-core@latest @bigcommerce/catalyst-core@ -f + git tag @bigcommerce/catalyst-makeswift@latest @bigcommerce/catalyst-makeswift@ -f + git push origin @bigcommerce/catalyst-core@latest -f + git push origin @bigcommerce/catalyst-makeswift@latest -f + ``` ### Additional Notes 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 cd7259ab16..29c5fee3ed 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 { assertRecaptchaTokenPresent, getRecaptchaFromForm } 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,23 @@ export async function registerCustomer( }; } + const { siteKey, token } = await getRecaptchaFromForm(formData); + const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired')); + + if (!recaptchaValidation.success) { + return { + lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }), + }; + } + try { const input = parseRegisterCustomerInput(submission.value, fields); const response = await client.fetch({ document: RegisterCustomerMutation, variables: { input, + reCaptchaV2: + recaptchaValidation.token != null ? { token: recaptchaValidation.token } : 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 bf8ebf1357..23004a7b8f 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 e37c9aa5d9..385bd3813f 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 { assertRecaptchaTokenPresent, getRecaptchaFromForm } 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,6 +42,16 @@ export async function submitReview( return { ...prevState, lastResult: submission.reply() }; } + const { siteKey, token } = await getRecaptchaFromForm(payload); + const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired')); + + if (!recaptchaValidation.success) { + return { + ...prevState, + lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }), + }; + } + const { productEntityId, ...input } = submission.value; try { @@ -52,6 +66,8 @@ export async function submitReview( }, productEntityId, }, + reCaptchaV2: + recaptchaValidation.token != null ? { token: recaptchaValidation.token } : undefined, }, }); diff --git a/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx b/core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx index 6429de74be..47a533d5c9 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 dd0268116d..59675e48c8 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -13,6 +13,7 @@ import { productOptionsTransformer } from '~/data-transformers/product-options-t import { getPreferredCurrencyCode } from '~/lib/currency'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; import { ProductDetail } from '~/lib/makeswift/components/product-detail'; +import { getRecaptchaSiteKey } from '~/lib/recaptcha'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { addToCart } from './_actions/add-to-cart'; @@ -80,7 +81,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); @@ -586,6 +590,7 @@ export default async function Product({ params, searchParams }: Props) { }} productId={baseProduct.entityId} quantityLabel={t('ProductDetails.quantity')} + recaptchaSiteKey={recaptchaSiteKey} reviewFormAction={submitReview} thumbnailLabel={t('ProductDetails.thumbnail')} user={streamableUser} @@ -607,6 +612,7 @@ export default async function Product({ params, searchParams }: Props) {
( }; } + const { siteKey, token } = await getRecaptchaFromForm(formData); + const recaptchaValidation = assertRecaptchaTokenPresent(siteKey, token, t('recaptchaRequired')); + + if (!recaptchaValidation.success) { + return { + lastResult: submission.reply({ formErrors: recaptchaValidation.formErrors }), + }; + } + try { const input = parseContactFormInput(submission.value); const response = await client.fetch({ document: SubmitContactUsMutation, variables: { input, + reCaptchaV2: + recaptchaValidation.token != null ? { token: recaptchaValidation.token } : 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 04468930b0..65c48e07f6 100644 --- a/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -13,6 +13,7 @@ import { truncateBreadcrumbs, } from '~/data-transformers/breadcrumbs-transformer'; import { getMakeswiftPageMetadata } from '~/lib/makeswift'; +import { getRecaptchaSiteKey } from '~/lib/recaptcha'; import { getMetadataAlternates } from '~/lib/seo/canonical'; import { WebPage, WebPageContent } from '../_components/web-page'; @@ -198,6 +199,8 @@ export default async function ContactPage({ params, searchParams }: Props) { ); } + const recaptchaSiteKey = await getRecaptchaSiteKey(); + return ( getWebPageBreadcrumbs(id))} @@ -207,6 +210,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 0000000000..77d5aa1518 --- /dev/null +++ b/core/lib/recaptcha.ts @@ -0,0 +1,79 @@ +import 'server-only'; + +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +import { RECAPTCHA_TOKEN_FORM_KEY, 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 { + 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 { + 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; +}); + +export async function getRecaptchaFromForm( + formData: FormData, +): Promise<{ siteKey: string | undefined; token: string }> { + const siteKey = await getRecaptchaSiteKey(); + const raw = formData.get(RECAPTCHA_TOKEN_FORM_KEY); + const token = typeof raw === 'string' ? raw : ''; + + return { siteKey, token }; +} + +export function assertRecaptchaTokenPresent( + siteKey: string | undefined, + token: string, + recaptchaRequiredMessage: string, +): { success: true; token: string | undefined } | { success: false; formErrors: [string] } { + if (!siteKey) { + return { success: true, token: undefined }; + } + + const tokenValue = token.trim(); + + if (!tokenValue) { + return { success: false, formErrors: [recaptchaRequiredMessage] }; + } + + return { success: true, token: tokenValue }; +} diff --git a/core/lib/recaptcha/constants.ts b/core/lib/recaptcha/constants.ts new file mode 100644 index 0000000000..b9c5045376 --- /dev/null +++ b/core/lib/recaptcha/constants.ts @@ -0,0 +1,6 @@ +export interface ReCaptchaSettings { + isEnabledOnStorefront: boolean; + siteKey: string; +} + +export const RECAPTCHA_TOKEN_FORM_KEY = 'g-recaptcha-response'; diff --git a/core/messages/en.json b/core/messages/en.json index 021be9f102..abdbd6f26c 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", @@ -561,6 +562,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", @@ -591,7 +593,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." } } }, @@ -761,6 +764,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 ba89355646..c8bb107586 100644 --- a/core/package.json +++ b/core/package.json @@ -69,6 +69,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", @@ -95,6 +96,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/set-cookie-parser": "^2.4.10", "@types/uuid": "^10.0.0", diff --git a/core/tests/ui/e2e/auth/register.spec.ts b/core/tests/ui/e2e/auth/register.spec.ts index 2d20864188..1a736d672f 100644 --- a/core/tests/ui/e2e/auth/register.spec.ts +++ b/core/tests/ui/e2e/auth/register.spec.ts @@ -37,6 +37,16 @@ test('Registration works as expected', { tag: [TAGS.writesData] }, async ({ page await page.getByRole('combobox', { name: 'Country' }).click(); await page.keyboard.type('United States'); await page.keyboard.press('Enter'); + + // Click reCAPTCHA if enabled (uses test key — no challenge, always passes) + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + if (await recaptchaCheckbox.isVisible()) { + await recaptchaCheckbox.click(); + await recaptchaFrame.locator('.recaptcha-checkbox-checked').waitFor(); + } + await page.getByRole('button', { name: t('Auth.Register.cta') }).click(); await expect(page).toHaveURL('/account/orders/'); @@ -83,6 +93,16 @@ test('Registration fails if email is already in use', async ({ page, customer }) await page.getByRole('combobox', { name: 'Country' }).click(); await page.keyboard.type('United States'); await page.keyboard.press('Enter'); + + // Click reCAPTCHA if enabled (uses test key — no challenge, always passes) + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + if (await recaptchaCheckbox.isVisible()) { + await recaptchaCheckbox.click(); + await recaptchaFrame.locator('.recaptcha-checkbox-checked').waitFor(); + } + await page.getByRole('button', { name: t('cta') }).click(); await expect(page).not.toHaveURL('/account/orders/'); @@ -90,3 +110,42 @@ test('Registration fails if email is already in use', async ({ page, customer }) // TODO: Error message needs to be translated await expect(page.getByText('The email address is already in use.')).toBeVisible(); }); + +test('Registration fails if reCAPTCHA is not completed', async ({ page }) => { + const t = await getTranslations('Auth.Register'); + + await page.goto('/register'); + await page.getByRole('heading', { name: t('heading') }).waitFor(); + + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + try { + await recaptchaCheckbox.waitFor({ state: 'visible', timeout: 5000 }); + } catch { + test.skip(); + } + + // Fill form but intentionally skip clicking reCAPTCHA + await page.getByLabel('First Name').fill(faker.person.firstName()); + await page.getByLabel('Last Name').fill(faker.person.lastName()); + await page.getByLabel('Email Address').fill(faker.internet.email({ provider: 'example.com' })); + + const password = faker.internet.password({ pattern: /[a-zA-Z0-9]/, prefix: '1At!', length: 10 }); + + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm Password').fill(password); + await page.getByLabel('Phone').fill(faker.phone.number({ style: 'national' })); + await page.getByLabel('Address Line 1').fill(faker.location.streetAddress()); + await page.getByLabel('Suburb/City').fill(faker.location.city()); + await page.getByLabel('State/Province').fill(faker.location.state()); + await page.getByLabel('Zip/Postcode').fill(faker.location.zipCode()); + await page.getByRole('combobox', { name: 'Country' }).click(); + await page.keyboard.type('United States'); + await page.keyboard.press('Enter'); + + await page.getByRole('button', { name: t('cta') }).click(); + + await expect(page).not.toHaveURL('/account/orders/'); + await expect(page.getByText(t('recaptchaRequired'))).toBeVisible(); +}); diff --git a/core/tests/ui/e2e/reviews.spec.ts b/core/tests/ui/e2e/reviews.spec.ts index 1ac5ffd09d..1199e20496 100644 --- a/core/tests/ui/e2e/reviews.spec.ts +++ b/core/tests/ui/e2e/reviews.spec.ts @@ -44,6 +44,15 @@ test( await expect(modal.getByLabel(t('nameLabel'))).toBeEnabled(); await expect(modal.getByLabel(t('emailLabel'))).toBeEnabled(); + // Click reCAPTCHA if enabled (uses test key — no challenge, always passes) + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + if (await recaptchaCheckbox.isVisible()) { + await recaptchaCheckbox.click(); + await recaptchaFrame.locator('.recaptcha-checkbox-checked').waitFor(); + } + await modal.getByRole('button', { name: t('submit') }).click(); await page.waitForLoadState('networkidle'); @@ -94,3 +103,42 @@ test('Shows validation errors when submitting review form with empty inputs', as await expect(errorMessages.nth(3)).toBeVisible(); await expect(errorMessages.nth(4)).toBeVisible(); }); + +test('Review submission fails if reCAPTCHA is not completed', async ({ page, catalog }) => { + const t = await getTranslations('Product.Reviews.Form'); + const product = await catalog.getDefaultOrCreateSimpleProduct(); + + await page.goto(product.path); + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: t('button') }).click(); + + const modal = page.getByRole('dialog'); + + await expect(modal.getByRole('heading', { name: t('title') })).toBeVisible(); + + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + try { + await recaptchaCheckbox.waitFor({ state: 'visible', timeout: 5000 }); + } catch { + test.skip(); + } + + // Fill all required fields but intentionally skip clicking reCAPTCHA + const rating = faker.number.int({ min: 1, max: 5 }); + const ratingLabel = rating === 1 ? '1 star' : `${rating} stars`; + + await modal.getByLabel(ratingLabel).click(); + await modal.getByLabel(t('titleLabel')).fill(faker.lorem.sentence()); + await modal.getByLabel(t('reviewLabel')).fill(faker.lorem.paragraph()); + await modal.getByLabel(t('nameLabel')).fill(faker.person.fullName()); + await modal.getByLabel(t('emailLabel')).fill(faker.internet.email()); + + await modal.getByRole('button', { name: t('submit') }).click(); + await page.waitForLoadState('networkidle'); + + await expect(modal).toBeVisible(); + await expect(page.getByText(t('recaptchaRequired'))).toBeVisible(); +}); diff --git a/core/tests/ui/e2e/webpages.spec.ts b/core/tests/ui/e2e/webpages.spec.ts index 1727cc85d3..d2c5cd307f 100644 --- a/core/tests/ui/e2e/webpages.spec.ts +++ b/core/tests/ui/e2e/webpages.spec.ts @@ -97,8 +97,48 @@ test('Contact page works with all fields and submits successfully', async ({ pag await page.getByLabel(t('Form.rma')).fill(faker.string.numeric(10)); await page.getByLabel(t('Form.comments')).fill(faker.lorem.paragraph()); + // Click reCAPTCHA if enabled (uses test key — no challenge, always passes) + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + if (await recaptchaCheckbox.isVisible()) { + await recaptchaCheckbox.click(); + await recaptchaFrame.locator('.recaptcha-checkbox-checked').waitFor(); + } + await page.getByRole('button', { name: t('Form.cta') }).click(); await page.waitForLoadState('networkidle'); await expect(page.getByText(t('Form.success'))).toBeVisible(); await expect(page.getByRole('link', { name: t('Form.successCta') })).toBeVisible(); }); + +test('Contact page fails if reCAPTCHA is not completed', async ({ page, webPage }) => { + const t = await getTranslations('WebPages.ContactUs'); + const contactPage = await webPage.create({ + type: 'contact_form', + body: '

Reach out to us with any questions!

', + email: faker.internet.email({ provider: 'catalyst-example.catalyst' }), + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await page.goto(contactPage.path!); + await page.waitForLoadState('networkidle'); + + const recaptchaFrame = page.frameLocator('iframe[title="reCAPTCHA"]'); + const recaptchaCheckbox = recaptchaFrame.locator('.recaptcha-checkbox-border'); + + try { + await recaptchaCheckbox.waitFor({ state: 'visible', timeout: 5000 }); + } catch { + test.skip(); + } + + // Fill required fields but intentionally skip clicking reCAPTCHA + await page.getByLabel(t('Form.email')).fill(faker.internet.email()); + await page.getByLabel(t('Form.comments')).fill(faker.lorem.paragraph()); + + await page.getByRole('button', { name: t('Form.cta') }).click(); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText(t('Form.recaptchaRequired'))).toBeVisible(); +}); diff --git a/core/vibes/soul/form/dynamic-form/index.tsx b/core/vibes/soul/form/dynamic-form/index.tsx index 53c21b1d57..706d67a507 100644 --- a/core/vibes/soul/form/dynamic-form/index.tsx +++ b/core/vibes/soul/form/dynamic-form/index.tsx @@ -21,6 +21,7 @@ import { useEffect, } 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'; @@ -77,6 +78,7 @@ export interface DynamicFormProps { onSuccess?: (lastResult: SubmissionResult, successMessage: ReactNode) => void; passwordComplexity?: PasswordComplexitySettings | null; errorTranslations?: FormErrorTranslationMap; + recaptchaSiteKey?: string; } export function DynamicForm({ @@ -92,6 +94,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 @@ -191,6 +194,7 @@ export function DynamicForm({ return ; })} + {recaptchaSiteKey ? : null}
{onCancel && (