diff --git a/.changeset/sync-makeswift-1-6-0.md b/.changeset/sync-makeswift-1-6-0.md new file mode 100644 index 0000000000..99f09a7783 --- /dev/null +++ b/.changeset/sync-makeswift-1-6-0.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-b2b-makeswift": minor +--- + +Pulls in changes from the `@bigcommerce/catalyst-makeswift@1.6.0` release. For more information, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/7daf598c469e8f8792504bb013111293ee0765fe/core/CHANGELOG.md#160). diff --git a/.claude/skills/release-catalyst/SKILL.md b/.claude/skills/release-catalyst/SKILL.md new file mode 100644 index 0000000000..e522ebd33f --- /dev/null +++ b/.claude/skills/release-catalyst/SKILL.md @@ -0,0 +1,120 @@ +--- +name: release-catalyst +description: > + Cut a new release of Catalyst (`@bigcommerce/catalyst-core` and `@bigcommerce/catalyst-makeswift`). + Use when the user says "/release-catalyst", "cut a release", "release catalyst", or asks to + publish new versions of the Catalyst packages. This skill orchestrates the full two-stage release + process: merging the Version Packages PR on canary, syncing integrations/makeswift, and pushing + @latest tags. +--- + +# Release Catalyst + +Execute stages in order. Pause for user input where indicated. + +## Stage 1: Cut release from `canary` + +### 1a. Find and merge the Version Packages PR + +```bash +gh pr list --search "Version Packages (canary)" --state open --json number,title,reviews,mergeable +``` + +- If **no open PR** exists, inform the user that there are no pending changesets on `canary` and stop. +- If the PR is **approved and checks are passing**, merge it: `gh pr merge --squash` +- If the PR is **not approved or checks are not passing**, tell the user and wait. + - Bot-opened PRs often don't trigger CI. If checks aren't running, push an empty commit to trigger them: + ```bash + git checkout --track origin/changeset-release/canary + git commit --allow-empty -m "chore: trigger CI" + git push origin changeset-release/canary + git checkout canary && git branch -D changeset-release/canary + ``` + - **Stop here.** Wait for the user to confirm checks pass and the PR is approved before merging. + +### 1b. Verify the release + +After the PR merges: + +```bash +git fetch origin --tags +``` + +Determine the new `@bigcommerce/catalyst-core` version from the PR body (look for `## @bigcommerce/catalyst-core@X.Y.Z`). Then verify: + +```bash +gh release view @bigcommerce/catalyst-core@ --json tagName,name,isDraft,isPrerelease +``` + +If the release and tag don't exist yet, wait briefly and retry — the Changesets action may still be running. + +Record the **version number** and **bump type** (patch/minor/major) for use in Stage 2. + +## Stage 2: Sync and release `integrations/makeswift` + +### 2a. Sync branches + +Invoke the `/sync-makeswift` skill, with one addition: during the sync (after merge, before pushing), also add a changeset for `@bigcommerce/catalyst-makeswift`: + +**Determine bump type**: Match the bump type from Stage 1 (e.g., if core went `1.4.2` → `1.5.0`, that's a `minor`). + +**Create changeset file** (`.changeset/sync-canary-.md`): + +```markdown +--- +"@bigcommerce/catalyst-makeswift": +--- + +Pulls in changes from the `@bigcommerce/catalyst-core@` release. For more information about what was included in the `@bigcommerce/catalyst-core@` release, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob//core/CHANGELOG.md#). +``` + +Where: +- `` is the merge commit SHA on canary (from the Version Packages merge) +- `` is the version with dots removed (e.g., `1.5.0` → `150`) + +Include this changeset in the merge commit (amend if needed) alongside the normal sync work. + +### 2b. Merge the Version Packages (`integrations/makeswift`) PR + +After the sync lands, the Changesets action will open a "Version Packages (`integrations/makeswift`)" PR. + +```bash +gh pr list --search "Version Packages (integrations/makeswift)" --state open --json number,title +``` + +Same flow as Stage 1a: +- If checks aren't running (bot PR), push an empty commit to trigger CI, then **drop it before merging** by resetting to the parent and force-pushing. +- Once approved and green, merge with `gh pr merge --squash`. + - Note: squash merging is normally disallowed on `integrations/makeswift` to preserve merge bases for sync PRs. The user may need to temporarily enable squash merging in the branch protection rules for this step, then re-disable it after. + +### 2c. Verify the makeswift release + +```bash +git fetch origin --tags +gh release view @bigcommerce/catalyst-makeswift@ --json tagName,name,isDraft,isPrerelease +``` + +## Stage 3: Push `@latest` tags + +Update both `@latest` tags to point to the new releases: + +```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 +``` + +Confirm both tags were pushed successfully. + +## Stage 4: Cleanup + +```bash +git checkout canary +git pull +``` + +Delete any leftover local branches (`changeset-release/*`, `sync-integrations-makeswift`, `integrations/makeswift`). + +Report the final state: both package versions released, tags updated, branches cleaned up. diff --git a/.claude/skills/sync-makeswift/SKILL.md b/.claude/skills/sync-makeswift/SKILL.md new file mode 100644 index 0000000000..83a71522c7 --- /dev/null +++ b/.claude/skills/sync-makeswift/SKILL.md @@ -0,0 +1,82 @@ +--- +name: sync-makeswift +description: > + Sync the `integrations/makeswift` branch with `canary` in the Catalyst monorepo. + Use when the user says "/sync-makeswift", "sync makeswift", "sync integrations/makeswift", + or asks to bring `integrations/makeswift` up to date with `canary`. +--- + +# Sync `integrations/makeswift` with `canary` + +Execute the following phases in order. Pause for user input where indicated. + +## Phase 1: Prepare and merge + +```bash +git fetch origin +git checkout -B sync-integrations-makeswift origin/integrations/makeswift +git merge canary +``` + +If the merge completes cleanly, skip to changeset cleanup. Otherwise, resolve conflicts. + +### Conflict resolution rules + +- `core/package.json`: the `name` field MUST stay `@bigcommerce/catalyst-makeswift`. The `version` field MUST stay at the latest published `@bigcommerce/catalyst-makeswift` version (check what's on `origin/integrations/makeswift`, not `canary`). +- `core/CHANGELOG.md`: the latest release entry MUST match the latest published `@bigcommerce/catalyst-makeswift` version. +- `pnpm-lock.yaml`: accept canary's version (`git checkout --theirs pnpm-lock.yaml`), then regenerate with `pnpm install --no-frozen-lockfile`. +- For all other conflicts, prefer canary's structure/patterns while preserving makeswift-specific additions (imports, components, config). + +After resolving all conflicts, stage everything and verify no unresolved conflicts remain: + +```bash +git add +git diff --name-only --diff-filter=U # should return empty +``` + +### Changeset cleanup + +Remove any `.changeset/*.md` files that do NOT target `@bigcommerce/catalyst-makeswift`. Read each changeset file and delete any that reference `@bigcommerce/catalyst-core` or other packages. Amend the removals into the merge commit. + +### Commit the merge + +```bash +git commit --no-edit +``` + +If changesets were removed after the initial commit, amend them in (`git commit --amend --no-edit`) rather than creating a separate commit. + +## Phase 2: Push and open PR + +```bash +git push origin sync-integrations-makeswift +``` + +Open a PR into `integrations/makeswift` (not `canary`): + +- Title: `sync \`integrations/makeswift\` with \`canary\`` +- Body: summarize what came from canary, list conflict resolutions, and include this notice: + +> **Do not squash or rebase-and-merge this PR.** Use a true merge commit or rebase locally to preserve the merge base between `canary` and `integrations/makeswift`. + +**Stop here.** Tell the user the PR is ready for review and wait for them to confirm approval before continuing. + +## Phase 3: Rebase and push (after PR approval) + +```bash +git fetch origin +git checkout -B integrations/makeswift origin/integrations/makeswift +git rebase sync-integrations-makeswift +git push origin integrations/makeswift --force-with-lease +``` + +This closes the PR automatically. Confirm with the user that the push succeeded and the PR closed. + +## Phase 4: Cleanup + +Switch back to `canary` and delete the local branches that are no longer needed: + +```bash +git checkout canary +git branch -D sync-integrations-makeswift integrations/makeswift +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 556297f799..6ad9f42f52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,40 +171,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)/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/da.json b/core/messages/da.json index 49c0f8334f..c90859b0c1 100644 --- a/core/messages/da.json +++ b/core/messages/da.json @@ -98,6 +98,7 @@ "heading": "Ny konto", "cta": "Opret konto", "somethingWentWrong": "Noget gik galt. Prøv igen senere.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "Fornavnet er påkrævet", "lastNameRequired": "Efternavnet er påkrævet", @@ -561,6 +562,7 @@ "emailLabel": "E-mail", "successMessage": "Din anmeldelse er blevet indsendt!", "somethingWentWrong": "Noget gik galt. Prøv igen senere.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Titel er påkrævet", "authorRequired": "Navn er påkrævet", @@ -591,7 +593,8 @@ "email": "E-mail", "comments": "Kommentarer/spørgsmål", "cta": "Indsend formular", - "somethingWentWrong": "Noget gik galt. Prøv igen senere." + "somethingWentWrong": "Noget gik galt. Prøv igen senere.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Valgfrit", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Tjek din indtastning, og prøv igen", "invalidFormat": "Den indtastede værdi stemmer ikke overens med det krævede format" diff --git a/core/messages/de.json b/core/messages/de.json index 8ffa5e7126..702f2cc745 100644 --- a/core/messages/de.json +++ b/core/messages/de.json @@ -98,6 +98,7 @@ "heading": "Neues Konto", "cta": "Konto erstellen", "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "Es muss ein Vorname angegeben werden", "lastNameRequired": "Es muss ein Nachname angegeben werden", @@ -561,6 +562,7 @@ "emailLabel": "E-Mail", "successMessage": "Ihre Bewertung wurde erfolgreich übermittelt!", "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Titel ist erforderlich", "authorRequired": "Es muss ein Name angegeben werden", @@ -591,7 +593,8 @@ "email": "E-Mail", "comments": "Kommentare/Fragen", "cta": "Formular einreichen", - "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut." + "somethingWentWrong": "Es ist etwas schiefgegangen Bitte versuchen Sie es später erneut.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "optional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Bitte überprüfen Sie Ihre Eingabe und versuchen Sie es erneut", "invalidFormat": "Der eingegebene Wert entspricht nicht dem erforderlichen Format" diff --git a/core/messages/en.json b/core/messages/en.json index e6ebd7675b..dee84fc4a1 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", @@ -564,6 +565,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", @@ -594,7 +596,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." } } }, @@ -764,6 +767,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/messages/es-419.json b/core/messages/es-419.json index 7da9edb9a7..8679cf1883 100644 --- a/core/messages/es-419.json +++ b/core/messages/es-419.json @@ -98,6 +98,7 @@ "heading": "Cuenta nueva", "cta": "Crear cuenta", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "El campo Nombre es obligatorio.", "lastNameRequired": "El campo Apellido es obligatorio.", @@ -561,6 +562,7 @@ "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Se requiere título", "authorRequired": "El campo Nombre es obligatorio", @@ -591,7 +593,8 @@ "email": "Correo electrónico", "comments": "Comentarios/Preguntas", "cta": "Enviar formulario", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo", "invalidFormat": "El valor introducido no coincide con el formato requerido" diff --git a/core/messages/es-AR.json b/core/messages/es-AR.json index 7da9edb9a7..8679cf1883 100644 --- a/core/messages/es-AR.json +++ b/core/messages/es-AR.json @@ -98,6 +98,7 @@ "heading": "Cuenta nueva", "cta": "Crear cuenta", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "El campo Nombre es obligatorio.", "lastNameRequired": "El campo Apellido es obligatorio.", @@ -561,6 +562,7 @@ "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Se requiere título", "authorRequired": "El campo Nombre es obligatorio", @@ -591,7 +593,8 @@ "email": "Correo electrónico", "comments": "Comentarios/Preguntas", "cta": "Enviar formulario", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo", "invalidFormat": "El valor introducido no coincide con el formato requerido" diff --git a/core/messages/es-CL.json b/core/messages/es-CL.json index 7da9edb9a7..8679cf1883 100644 --- a/core/messages/es-CL.json +++ b/core/messages/es-CL.json @@ -98,6 +98,7 @@ "heading": "Cuenta nueva", "cta": "Crear cuenta", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "El campo Nombre es obligatorio.", "lastNameRequired": "El campo Apellido es obligatorio.", @@ -561,6 +562,7 @@ "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Se requiere título", "authorRequired": "El campo Nombre es obligatorio", @@ -591,7 +593,8 @@ "email": "Correo electrónico", "comments": "Comentarios/Preguntas", "cta": "Enviar formulario", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo", "invalidFormat": "El valor introducido no coincide con el formato requerido" diff --git a/core/messages/es-CO.json b/core/messages/es-CO.json index 7da9edb9a7..8679cf1883 100644 --- a/core/messages/es-CO.json +++ b/core/messages/es-CO.json @@ -98,6 +98,7 @@ "heading": "Cuenta nueva", "cta": "Crear cuenta", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "El campo Nombre es obligatorio.", "lastNameRequired": "El campo Apellido es obligatorio.", @@ -561,6 +562,7 @@ "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Se requiere título", "authorRequired": "El campo Nombre es obligatorio", @@ -591,7 +593,8 @@ "email": "Correo electrónico", "comments": "Comentarios/Preguntas", "cta": "Enviar formulario", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo", "invalidFormat": "El valor introducido no coincide con el formato requerido" diff --git a/core/messages/es-LA.json b/core/messages/es-LA.json index 7da9edb9a7..8679cf1883 100644 --- a/core/messages/es-LA.json +++ b/core/messages/es-LA.json @@ -98,6 +98,7 @@ "heading": "Cuenta nueva", "cta": "Crear cuenta", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "El campo Nombre es obligatorio.", "lastNameRequired": "El campo Apellido es obligatorio.", @@ -561,6 +562,7 @@ "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Se requiere título", "authorRequired": "El campo Nombre es obligatorio", @@ -591,7 +593,8 @@ "email": "Correo electrónico", "comments": "Comentarios/Preguntas", "cta": "Enviar formulario", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo", "invalidFormat": "El valor introducido no coincide con el formato requerido" diff --git a/core/messages/es-MX.json b/core/messages/es-MX.json index 7da9edb9a7..8679cf1883 100644 --- a/core/messages/es-MX.json +++ b/core/messages/es-MX.json @@ -98,6 +98,7 @@ "heading": "Cuenta nueva", "cta": "Crear cuenta", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "El campo Nombre es obligatorio.", "lastNameRequired": "El campo Apellido es obligatorio.", @@ -561,6 +562,7 @@ "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Se requiere título", "authorRequired": "El campo Nombre es obligatorio", @@ -591,7 +593,8 @@ "email": "Correo electrónico", "comments": "Comentarios/Preguntas", "cta": "Enviar formulario", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo", "invalidFormat": "El valor introducido no coincide con el formato requerido" diff --git a/core/messages/es-PE.json b/core/messages/es-PE.json index 7da9edb9a7..8679cf1883 100644 --- a/core/messages/es-PE.json +++ b/core/messages/es-PE.json @@ -98,6 +98,7 @@ "heading": "Cuenta nueva", "cta": "Crear cuenta", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "El campo Nombre es obligatorio.", "lastNameRequired": "El campo Apellido es obligatorio.", @@ -561,6 +562,7 @@ "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña fue enviada con éxito!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Se requiere título", "authorRequired": "El campo Nombre es obligatorio", @@ -591,7 +593,8 @@ "email": "Correo electrónico", "comments": "Comentarios/Preguntas", "cta": "Enviar formulario", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Por favor, revisa tu opinión y vuelve a intentarlo", "invalidFormat": "El valor introducido no coincide con el formato requerido" diff --git a/core/messages/es.json b/core/messages/es.json index 20978eb72e..7e27a2cd1e 100644 --- a/core/messages/es.json +++ b/core/messages/es.json @@ -98,6 +98,7 @@ "heading": "Cuenta nueva", "cta": "Crear cuenta", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "El campo Nombre es obligatorio.", "lastNameRequired": "El campo Apellido(s) es obligatorio.", @@ -561,6 +562,7 @@ "emailLabel": "Correo electrónico", "successMessage": "¡Tu reseña se ha enviado correctamente!", "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "El título es obligatorio", "authorRequired": "El campo Nombre es obligatorio", @@ -591,7 +593,8 @@ "email": "Correo electrónico", "comments": "Comentarios/Preguntas", "cta": "Enviar formulario", - "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde." + "somethingWentWrong": "Algo salió mal. Vuelva a intentarlo más tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Comprueba lo que has escrito e inténtalo de nuevo", "invalidFormat": "El valor introducido no coincide con el formato requerido" diff --git a/core/messages/fr.json b/core/messages/fr.json index fdb811e478..de9e8e20d0 100644 --- a/core/messages/fr.json +++ b/core/messages/fr.json @@ -98,6 +98,7 @@ "heading": "Nouveau compte", "cta": "Créer un compte", "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "Le prénom est requis", "lastNameRequired": "Le nom de famille est requis", @@ -561,6 +562,7 @@ "emailLabel": "E-mail", "successMessage": "Votre avis a bien été envoyé !", "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Le titre est obligatoire", "authorRequired": "Le nom doit être renseigné", @@ -591,7 +593,8 @@ "email": "E-mail", "comments": "Commentaires/Questions", "cta": "Envoyer le formulaire", - "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard." + "somethingWentWrong": "Une erreur s'est produite. Veuillez réessayer plus tard.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "facultatif", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Veuillez vérifier votre saisie et réessayer", "invalidFormat": "La valeur saisie ne correspond pas au format requis" diff --git a/core/messages/it.json b/core/messages/it.json index df052c3b8d..316e527d07 100644 --- a/core/messages/it.json +++ b/core/messages/it.json @@ -98,6 +98,7 @@ "heading": "Nuovo account", "cta": "Crea account", "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "Il nome è obbligatorio", "lastNameRequired": "Il cognome è obbligatorio", @@ -561,6 +562,7 @@ "emailLabel": "E-mail", "successMessage": "La tua recensione è stata inviata correttamente.", "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Il titolo è obbligatorio", "authorRequired": "Il nome è obbligatorio", @@ -591,7 +593,8 @@ "email": "E-mail", "comments": "Commenti/domande", "cta": "Invia modulo", - "somethingWentWrong": "Si è verificato un errore. Riprova più tardi." + "somethingWentWrong": "Si è verificato un errore. Riprova più tardi.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "facoltativo", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Controlla i dati inseriti e riprova", "invalidFormat": "Il valore inserito non corrisponde al formato richiesto" diff --git a/core/messages/ja.json b/core/messages/ja.json index 3baada44d7..ae3b5d2a4f 100644 --- a/core/messages/ja.json +++ b/core/messages/ja.json @@ -98,6 +98,7 @@ "heading": "新しいアカウント", "cta": "アカウント作成", "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "名は必須です", "lastNameRequired": "姓は必須です", @@ -561,6 +562,7 @@ "emailLabel": "Eメール", "successMessage": "レビューは正常に送信されました。", "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "タイトルは必須です", "authorRequired": "商品名は必須です", @@ -591,7 +593,8 @@ "email": "Eメール", "comments": "コメント/質問", "cta": "フォームを送信", - "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。" + "somethingWentWrong": "何か問題が発生しました。後でもう一度やり直してください。", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "オプション", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "入力内容を確認してもう一度お試しください", "invalidFormat": "入力された値は必要な形式と一致しません" diff --git a/core/messages/nl.json b/core/messages/nl.json index 9f063d3234..24d9f9b9d3 100644 --- a/core/messages/nl.json +++ b/core/messages/nl.json @@ -98,6 +98,7 @@ "heading": "Nieuw account", "cta": "Account aanmaken", "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "Voornaam is vereist", "lastNameRequired": "Achternaam is vereist", @@ -561,6 +562,7 @@ "emailLabel": "E-mailadres", "successMessage": "Je beoordeling is succesvol ingediend!", "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Titel is vereist", "authorRequired": "Naam is verplicht", @@ -591,7 +593,8 @@ "email": "E-mailadres", "comments": "Opmerkingen/vragen", "cta": "Formulier indienen", - "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw." + "somethingWentWrong": "Er is iets fout gegaan. Probeer het later opnieuw.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "optioneel", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Controleer uw invoer en probeer het opnieuw", "invalidFormat": "De ingevoerde waarde komt niet overeen met het vereiste formaat" diff --git a/core/messages/no.json b/core/messages/no.json index 488addcff4..738fa4c336 100644 --- a/core/messages/no.json +++ b/core/messages/no.json @@ -98,6 +98,7 @@ "heading": "Ny konto", "cta": "Opprett konto", "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "Fornavn er påkrevd", "lastNameRequired": "Etternavn er påkrevd", @@ -561,6 +562,7 @@ "emailLabel": "E-post", "successMessage": "Din anmeldelse er sendt inn!", "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Tittel er påkrevd", "authorRequired": "Navn er påkrevd", @@ -591,7 +593,8 @@ "email": "E-post", "comments": "Kommentarer/spørsmål", "cta": "Send inn skjema", - "somethingWentWrong": "Noe gikk galt. Prøv igjen senere." + "somethingWentWrong": "Noe gikk galt. Prøv igjen senere.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "valgfri", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Sjekk inndataene dine og prøv igjen", "invalidFormat": "Den angitte verdien samsvarer ikke med det nødvendige formatet" diff --git a/core/messages/pl.json b/core/messages/pl.json index e5a241ed13..336a89eca0 100644 --- a/core/messages/pl.json +++ b/core/messages/pl.json @@ -98,6 +98,7 @@ "heading": "Nowe konto", "cta": "Utwórz konto", "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "Imię jest wymagane", "lastNameRequired": "Nazwisko jest wymagane", @@ -561,6 +562,7 @@ "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Title is required", "authorRequired": "Nazwa jest wymagana", @@ -591,7 +593,8 @@ "email": "Email", "comments": "Uwagi/pytania", "cta": "Prześlij formularz", - "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później." + "somethingWentWrong": "Coś poszło nie tak. Proszę spróbować później.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "opcjonalne", + "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/messages/pt-BR.json b/core/messages/pt-BR.json index b931389eaf..ad0ff23de8 100644 --- a/core/messages/pt-BR.json +++ b/core/messages/pt-BR.json @@ -98,6 +98,7 @@ "heading": "Nova conta", "cta": "Criar conta", "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "O primeiro nome é obrigatório", "lastNameRequired": "O sobrenome é obrigatório", @@ -561,6 +562,7 @@ "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Title is required", "authorRequired": "O nome é obrigatório", @@ -591,7 +593,8 @@ "email": "Email", "comments": "Comentários/perguntas", "cta": "Enviar formulário", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "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/messages/pt.json b/core/messages/pt.json index b931389eaf..ad0ff23de8 100644 --- a/core/messages/pt.json +++ b/core/messages/pt.json @@ -98,6 +98,7 @@ "heading": "Nova conta", "cta": "Criar conta", "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "O primeiro nome é obrigatório", "lastNameRequired": "O sobrenome é obrigatório", @@ -561,6 +562,7 @@ "emailLabel": "Email", "successMessage": "Your review has been submitted successfully!", "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Title is required", "authorRequired": "O nome é obrigatório", @@ -591,7 +593,8 @@ "email": "Email", "comments": "Comentários/perguntas", "cta": "Enviar formulário", - "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde." + "somethingWentWrong": "Ocorreu um erro. Tente novamente mais tarde.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "Opcional", + "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/messages/sv.json b/core/messages/sv.json index 0f64b9ca46..cc69efca8f 100644 --- a/core/messages/sv.json +++ b/core/messages/sv.json @@ -98,6 +98,7 @@ "heading": "Nytt konto", "cta": "Skapa konto", "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "firstNameRequired": "Förnamn krävs", "lastNameRequired": "Efternamn krävs", @@ -561,6 +562,7 @@ "emailLabel": "E-post", "successMessage": "Din recension har skickats in!", "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "FieldErrors": { "titleRequired": "Rubrik krävs", "authorRequired": "Namn krävs", @@ -591,7 +593,8 @@ "email": "E-post", "comments": "Kommentarer/frågor", "cta": "Skicka formulär", - "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare." + "somethingWentWrong": "Någonting gick fel. Vänligen försök igen senare.", + "recaptchaRequired": "Please complete the reCAPTCHA verification." } } }, @@ -761,6 +764,7 @@ }, "Form": { "optional": "frivillig", + "recaptchaRequired": "Please complete the reCAPTCHA verification.", "Errors": { "invalidInput": "Kontrollera det som angetts och försök igen", "invalidFormat": "Det inmatade värdet stämmer inte överens med det format som krävs" diff --git a/core/package.json b/core/package.json index 1e6a0a13a0..2ba6863763 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 && (